summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
committerAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
commitadc8bd185b54935e7a31aae04da3175b7989927a (patch)
tree031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/core/src
parent52af4ca33b1f83b8f863a6267d447944f55e729d (diff)
downloaddispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz
dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar - Model/key resolution: tag-based model selection, key fallback chains - Skills system: directory loader with TOML frontmatter, agent mappings - Task list tool: add/update/list/get operations with WebSocket events - API routes: GET /config, /skills, /skills/:name, /models, /models/resolve - Frontend: sidebar with model status, task list, config viewer, skills browser, permission log - Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/config/index.ts3
-rw-r--r--packages/core/src/config/loader.ts119
-rw-r--r--packages/core/src/config/schema.ts235
-rw-r--r--packages/core/src/config/watcher.ts53
-rw-r--r--packages/core/src/index.ts17
-rw-r--r--packages/core/src/models/index.ts2
-rw-r--r--packages/core/src/models/registry.ts134
-rw-r--r--packages/core/src/models/resolver.ts88
-rw-r--r--packages/core/src/skills/index.ts7
-rw-r--r--packages/core/src/skills/loader.ts310
-rw-r--r--packages/core/src/skills/parser.ts62
-rw-r--r--packages/core/src/tools/task-list.ts148
-rw-r--r--packages/core/src/types/index.ts108
13 files changed, 1179 insertions, 107 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
new file mode 100644
index 0000000..5b128c4
--- /dev/null
+++ b/packages/core/src/config/index.ts
@@ -0,0 +1,3 @@
+export { configToRuleset, loadConfig } from "./loader.js";
+export { validateConfig } from "./schema.js";
+export { createConfigWatcher } from "./watcher.js";
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
index 0e58ad2..3b4d733 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -1,25 +1,12 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
+import { parse } from "smol-toml";
import type { PermissionRule, Ruleset } from "../permission/index.js";
+import type { DispatchConfig } from "../types/index.js";
+import { validateConfig } from "./schema.js";
-// Strip inline comments that appear outside of quoted strings.
-// Handles both single- and double-quoted values.
-function stripInlineComment(line: string): string {
- // Walk character by character; track whether we're inside quotes.
- let inQuote: string | null = null;
- for (let i = 0; i < line.length; i++) {
- const ch = line[i];
- if (inQuote) {
- if (ch === inQuote) inQuote = null;
- } else if (ch === '"' || ch === "'") {
- inQuote = ch;
- } else if (ch === "#") {
- return line.slice(0, i).trimEnd();
- }
- }
- return line;
-}
+const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
@@ -29,89 +16,27 @@ function validateAction(raw: string): "allow" | "deny" | "ask" {
return "ask";
}
-export interface DispatchConfig {
- permissions: Record<string, string | Record<string, string>>;
-}
-
-// Load dispatch.yaml from the given directory
+// Load dispatch.toml from the given directory
export function loadConfig(dir: string): DispatchConfig {
- const yamlPath = join(dir, "dispatch.yaml");
+ const tomlPath = join(dir, "dispatch.toml");
+ let raw: unknown;
try {
- const content = readFileSync(yamlPath, "utf-8");
- return parseYaml(content);
- } catch {
- return { permissions: {} };
- }
-}
-
-function expandHome(value: string): string {
- const home = homedir();
- return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home);
-}
-
-function stripQuotes(s: string): string {
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
- return s.slice(1, -1);
- }
- return s;
-}
-
-// Parse simple YAML for the permissions structure
-function parseYaml(content: string): DispatchConfig {
- const permissions: Record<string, string | Record<string, string>> = {};
- const lines = content.split("\n");
-
- let inPermissions = false;
- let currentKey: string | null = null;
-
- for (const raw of lines) {
- // Skip comments and blank lines
- const trimmed = raw.trimEnd();
- const stripped = trimmed.trimStart();
- if (stripped === "" || stripped.startsWith("#")) continue;
-
- const indent = trimmed.length - stripped.length;
-
- if (indent === 0) {
- // Top-level key
- inPermissions = stripped.startsWith("permissions:");
- currentKey = null;
- continue;
- }
-
- if (!inPermissions) continue;
-
- if (indent === 2) {
- // permission key line: " key: value" or " key:"
- const colonIdx = stripped.indexOf(":");
- if (colonIdx === -1) continue;
- const key = stripQuotes(stripped.slice(0, colonIdx).trim());
- const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim();
- if (valueRaw === "" || valueRaw === null) {
- // nested map follows
- currentKey = key;
- permissions[currentKey] = {};
- } else {
- // inline value
- const value = stripQuotes(valueRaw);
- permissions[key] = value;
- currentKey = null;
- }
- continue;
- }
-
- if (indent >= 4 && currentKey !== null) {
- // sub-key line: " "pattern": action"
- const colonIdx = stripped.indexOf(":");
- if (colonIdx === -1) continue;
- const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim()));
- const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim());
- const action = validateAction(actionRaw);
- (permissions[currentKey] as Record<string, string>)[pattern] = action;
+ const content = readFileSync(tomlPath, "utf-8");
+ raw = parse(content);
+ } catch (err: unknown) {
+ if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
+ // File doesn't exist — return empty default
+ return DEFAULT_CONFIG;
}
+ console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`);
+ throw err;
}
- return { permissions };
+ const { config, errors } = validateConfig(raw);
+ for (const e of errors) {
+ console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
+ }
+ return config;
}
// Convert the config's permission block to a Ruleset
@@ -126,8 +51,8 @@ export function configToRuleset(config: DispatchConfig): Ruleset {
} else {
for (const [rawPattern, rawAction] of Object.entries(value)) {
const pattern = rawPattern
- .replace(/^\$HOME(?=[\/\\]|$)/, home)
- .replace(/^~(?=[\/\\]|$)/, home);
+ .replace(/^\$HOME(?=[/\\]|$)/, home)
+ .replace(/^~(?=[/\\]|$)/, home);
const action = validateAction(rawAction);
rules.push({ permission, pattern, action });
}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
new file mode 100644
index 0000000..d3eea98
--- /dev/null
+++ b/packages/core/src/config/schema.ts
@@ -0,0 +1,235 @@
+import type {
+ AgentTemplate,
+ ConfigError,
+ DispatchConfig,
+ KeyDefinition,
+ ModelDefinition,
+} from "../types/index.js";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isStringRecord(value: unknown): value is Record<string, string> {
+ if (!isRecord(value)) return false;
+ return Object.values(value).every((v) => typeof v === "string");
+}
+
+function isValidAction(value: string): boolean {
+ return value === "allow" || value === "deny" || value === "ask";
+}
+
+function isPermissionsValue(value: unknown): value is string | Record<string, string> {
+ return typeof value === "string" || isStringRecord(value);
+}
+
+function validatePermissions(
+ raw: unknown,
+ path: string,
+ errors: ConfigError[],
+): Record<string, string | Record<string, string>> {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return {};
+ }
+ const result: Record<string, string | Record<string, string>> = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (!isPermissionsValue(value)) {
+ errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" });
+ continue;
+ }
+ if (typeof value === "string") {
+ if (!isValidAction(value)) {
+ errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` });
+ continue;
+ }
+ } else {
+ let hasError = false;
+ for (const [pattern, action] of Object.entries(value)) {
+ if (!isValidAction(action)) {
+ errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` });
+ hasError = true;
+ }
+ }
+ if (hasError) continue;
+ }
+ result[key] = value;
+ }
+ return result;
+}
+
+function validateAgentTemplate(
+ raw: unknown,
+ path: string,
+ errors: ConfigError[],
+): AgentTemplate | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+
+ const requiredStrings = ["name", "description", "system_prompt", "model_tag"] as const;
+ for (const field of requiredStrings) {
+ if (typeof raw[field] !== "string") {
+ errors.push({ path: `${path}.${field}`, message: "must be a string" });
+ return null;
+ }
+ }
+
+ if (!Array.isArray(raw["tools"]) || !raw["tools"].every((t) => typeof t === "string")) {
+ errors.push({ path: `${path}.tools`, message: "must be an array of strings" });
+ return null;
+ }
+
+ const perms = validatePermissions(raw["permissions"] ?? {}, `${path}.permissions`, errors);
+
+ return {
+ name: raw["name"] as string,
+ description: raw["description"] as string,
+ system_prompt: raw["system_prompt"] as string,
+ tools: raw["tools"] as string[],
+ model_tag: raw["model_tag"] as string,
+ permissions: perms,
+ };
+}
+
+function validateModel(raw: unknown, path: string, errors: ConfigError[]): ModelDefinition | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+ if (typeof raw["id"] !== "string") {
+ errors.push({ path: `${path}.id`, message: "must be a string" });
+ return null;
+ }
+ if (typeof raw["provider"] !== "string") {
+ errors.push({ path: `${path}.provider`, message: "must be a string" });
+ return null;
+ }
+ if (
+ !Array.isArray(raw["tags"]) ||
+ raw["tags"].length === 0 ||
+ !raw["tags"].every((t) => typeof t === "string")
+ ) {
+ errors.push({ path: `${path}.tags`, message: "must be a non-empty array of strings" });
+ return null;
+ }
+ return {
+ id: raw["id"] as string,
+ provider: raw["provider"] as string,
+ tags: raw["tags"] as string[],
+ };
+}
+
+function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+ for (const field of ["id", "provider", "env", "base_url"] as const) {
+ if (typeof raw[field] !== "string") {
+ errors.push({ path: `${path}.${field}`, message: "must be a string" });
+ return null;
+ }
+ }
+ return {
+ id: raw["id"] as string,
+ provider: raw["provider"] as string,
+ env: raw["env"] as string,
+ base_url: raw["base_url"] as string,
+ };
+}
+
+export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } {
+ const errors: ConfigError[] = [];
+
+ if (!isRecord(raw)) {
+ errors.push({ path: "", message: "config must be an object" });
+ return { config: { permissions: {} }, errors };
+ }
+
+ // permissions (required, but can be empty)
+ const permissions = validatePermissions(raw["permissions"] ?? {}, "permissions", errors);
+
+ // agents (optional)
+ let agents: Record<string, AgentTemplate> | undefined;
+ if (raw["agents"] !== undefined) {
+ if (!isRecord(raw["agents"])) {
+ errors.push({ path: "agents", message: "must be an object" });
+ } else {
+ agents = {};
+ for (const [key, value] of Object.entries(raw["agents"])) {
+ const agent = validateAgentTemplate(value, `agents.${key}`, errors);
+ if (agent) agents[key] = agent;
+ }
+ }
+ }
+
+ // models (optional)
+ let models: ModelDefinition[] | undefined;
+ if (raw["models"] !== undefined) {
+ if (!Array.isArray(raw["models"])) {
+ errors.push({ path: "models", message: "must be an array" });
+ } else {
+ models = [];
+ for (let i = 0; i < raw["models"].length; i++) {
+ const model = validateModel(raw["models"][i], `models[${i}]`, errors);
+ if (model) models.push(model);
+ }
+ }
+ }
+
+ // keys (optional)
+ let keys: KeyDefinition[] | undefined;
+ if (raw["keys"] !== undefined) {
+ if (!Array.isArray(raw["keys"])) {
+ errors.push({ path: "keys", message: "must be an array" });
+ } else {
+ keys = [];
+ for (let i = 0; i < raw["keys"].length; i++) {
+ const key = validateKey(raw["keys"][i], `keys[${i}]`, errors);
+ if (key) keys.push(key);
+ }
+ }
+ }
+
+ // fallback (optional)
+ let fallback: string[] | undefined;
+ if (raw["fallback"] !== undefined) {
+ if (!Array.isArray(raw["fallback"]) || !raw["fallback"].every((f) => typeof f === "string")) {
+ errors.push({ path: "fallback", message: "must be an array of strings" });
+ } else {
+ fallback = raw["fallback"] as string[];
+ // Validate that referenced key IDs exist
+ const keyIds = new Set((keys ?? []).map((k) => k.id));
+ for (const id of fallback) {
+ if (!keyIds.has(id)) {
+ errors.push({ path: "fallback", message: `key id "${id}" not found in keys` });
+ }
+ }
+ }
+ }
+
+ // Warn if agent model_tags don't match any model
+ if (agents && models) {
+ const allTags = new Set(models.flatMap((m) => m.tags));
+ for (const [name, agent] of Object.entries(agents)) {
+ if (!allTags.has(agent.model_tag)) {
+ errors.push({
+ path: `agents.${name}.model_tag`,
+ message: `no model found with tag "${agent.model_tag}"`,
+ });
+ }
+ }
+ }
+
+ const config: DispatchConfig = {
+ permissions,
+ ...(agents !== undefined && { agents }),
+ ...(models !== undefined && { models }),
+ ...(keys !== undefined && { keys }),
+ ...(fallback !== undefined && { fallback }),
+ };
+
+ return { config, errors };
+}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
new file mode 100644
index 0000000..42b2f87
--- /dev/null
+++ b/packages/core/src/config/watcher.ts
@@ -0,0 +1,53 @@
+import { watch } from "chokidar";
+import { join } from "node:path";
+import type { DispatchConfig } from "../types/index.js";
+import { loadConfig } from "./loader.js";
+
+export function createConfigWatcher(
+ dir: string,
+ onChange: (config: DispatchConfig) => void,
+): { close(): void } {
+ const tomlPath = join(dir, "dispatch.toml");
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+
+ const watcher = watch(tomlPath, {
+ ignoreInitial: true,
+ persistent: false,
+ });
+
+ const handleChange = () => {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ console.log(`dispatch: reloading config from ${tomlPath}`);
+ try {
+ const config = loadConfig(dir);
+ onChange(config);
+ } catch (err) {
+ console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }, 300);
+ };
+
+ watcher.on("change", handleChange);
+ watcher.on("add", handleChange);
+ watcher.on("unlink", handleChange);
+
+ watcher.on("error", (err) => {
+ console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`);
+ });
+
+ return {
+ close() {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ watcher.close().catch((err) => {
+ console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ },
+ };
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 233cb10..3009a0b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,7 +1,10 @@
// @dispatch/core — Agent runtime, LLM integration, tools
+// Agent & LLM
export { Agent } from "./agent/agent.js";
export { createProvider } from "./llm/provider.js";
+
+// Tools
export { createListFilesTool } from "./tools/list-files.js";
export { createRunShellTool } from "./tools/run-shell.js";
export { analyzeCommand } from "./tools/shell-analyze.js";
@@ -9,7 +12,17 @@ export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
export { createReadFileTool } from "./tools/read-file.js";
export { createToolRegistry } from "./tools/registry.js";
export { createWriteFileTool } from "./tools/write-file.js";
+export { TaskList, createTaskListTool } from "./tools/task-list.js";
+
+// Types & Permissions
export * from "./types/index.js";
export * from "./permission/index.js";
-export { loadConfig, configToRuleset } from "./config/loader.js";
-export type { DispatchConfig } from "./config/loader.js";
+
+// Config
+export { loadConfig, configToRuleset, validateConfig, createConfigWatcher } from "./config/index.js";
+
+// Skills
+export { parseSkillFile, loadSkills, resolveSkillsForAgent, getSkillByName, createSkillsWatcher } from "./skills/index.js";
+
+// Models
+export { ModelRegistry, ModelResolver } from "./models/index.js";
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
new file mode 100644
index 0000000..e5efcc8
--- /dev/null
+++ b/packages/core/src/models/index.ts
@@ -0,0 +1,2 @@
+export { ModelRegistry } from "./registry.js";
+export { ModelResolver } from "./resolver.js";
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
new file mode 100644
index 0000000..f78fe0e
--- /dev/null
+++ b/packages/core/src/models/registry.ts
@@ -0,0 +1,134 @@
+import type { KeyDefinition, KeyState, ModelDefinition } from "../types/index.js";
+
+export class ModelRegistry {
+ private models: ModelDefinition[];
+ private keyStates: Map<string, KeyState>;
+ private fallbackOrder: string[];
+
+ constructor(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]) {
+ this.models = [];
+ this.keyStates = new Map();
+ this.fallbackOrder = [];
+ this._initConfig(models, keys, fallbackOrder, new Map());
+ }
+
+ private _initConfig(
+ models: ModelDefinition[],
+ keys: KeyDefinition[],
+ fallbackOrder: string[],
+ existingStates: Map<string, KeyState>,
+ ): void {
+ this.models = [...models];
+ this.fallbackOrder = this._buildFallbackOrder(keys, fallbackOrder);
+
+ const newStates = new Map<string, KeyState>();
+ for (const key of keys) {
+ const existing = existingStates.get(key.id);
+ if (existing) {
+ // Preserve existing state but update definition
+ newStates.set(key.id, { ...existing, definition: key });
+ } else {
+ newStates.set(key.id, { definition: key, status: "active" });
+ }
+ }
+ this.keyStates = newStates;
+ }
+
+ private _buildFallbackOrder(keys: KeyDefinition[], fallbackOrder: string[]): string[] {
+ const ordered: string[] = [];
+ const keyIds = new Set(keys.map((k) => k.id));
+
+ // Add keys from fallbackOrder first (if they exist)
+ for (const id of fallbackOrder) {
+ if (keyIds.has(id) && !ordered.includes(id)) {
+ ordered.push(id);
+ }
+ }
+
+ // Append remaining keys not in fallbackOrder
+ for (const key of keys) {
+ if (!ordered.includes(key.id)) {
+ ordered.push(key.id);
+ }
+ }
+
+ return ordered;
+ }
+
+ getModels(): ModelDefinition[] {
+ return [...this.models];
+ }
+
+ getKeys(): KeyState[] {
+ return this.fallbackOrder
+ .map((id) => this.keyStates.get(id))
+ .filter((state): state is KeyState => state !== undefined);
+ }
+
+ getModelsByTag(tag: string): ModelDefinition[] {
+ return this.models.filter((m) => m.tags.includes(tag));
+ }
+
+ getAllTags(): string[] {
+ const tags = new Set<string>();
+ for (const model of this.models) {
+ for (const tag of model.tags) {
+ tags.add(tag);
+ }
+ }
+ return [...tags];
+ }
+
+ markKeyExhausted(keyId: string, error?: string): void {
+ const state = this.keyStates.get(keyId);
+ if (!state) return;
+ this.keyStates.set(keyId, {
+ ...state,
+ status: "exhausted",
+ lastError: error,
+ exhaustedAt: Date.now(),
+ });
+ }
+
+ markKeyActive(keyId: string): void {
+ const state = this.keyStates.get(keyId);
+ if (!state) return;
+ const updated: KeyState = {
+ definition: state.definition,
+ status: "active",
+ };
+ this.keyStates.set(keyId, updated);
+ }
+
+ hasAvailableKey(provider: string): boolean {
+ for (const state of this.keyStates.values()) {
+ if (state.definition.provider === provider && state.status === "active") {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ allKeysExhausted(): boolean {
+ for (const state of this.keyStates.values()) {
+ if (state.status === "active") {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ updateConfig(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]): void {
+ this._initConfig(models, keys, fallbackOrder, this.keyStates);
+ }
+
+ // Internal: get ordered key states for a specific provider
+ getOrderedKeysForProvider(provider: string): KeyState[] {
+ return this.fallbackOrder
+ .map((id) => this.keyStates.get(id))
+ .filter(
+ (state): state is KeyState =>
+ state !== undefined && state.definition.provider === provider,
+ );
+ }
+}
diff --git a/packages/core/src/models/resolver.ts b/packages/core/src/models/resolver.ts
new file mode 100644
index 0000000..3ada713
--- /dev/null
+++ b/packages/core/src/models/resolver.ts
@@ -0,0 +1,88 @@
+import type { ResolvedModel } from "../types/index.js";
+import type { ModelRegistry } from "./registry.js";
+
+export class ModelResolver {
+ private registry: ModelRegistry;
+
+ constructor(registry: ModelRegistry) {
+ this.registry = registry;
+ }
+
+ resolve(tag: string): ResolvedModel | null {
+ const models = this.registry.getModelsByTag(tag);
+ const keys = this.registry.getKeys();
+
+ for (const keyState of keys) {
+ if (keyState.status !== "active") continue;
+ const model = models.find((m) => m.provider === keyState.definition.provider);
+ if (model) {
+ return { model, key: keyState.definition };
+ }
+ }
+
+ return null;
+ }
+
+ async waitForKey(
+ tag: string,
+ options?: {
+ pollIntervalMs?: number;
+ signal?: AbortSignal;
+ onWaiting?: () => void;
+ onResume?: () => void;
+ },
+ ): Promise<ResolvedModel | null> {
+ const pollIntervalMs = options?.pollIntervalMs ?? 60000;
+ const signal = options?.signal;
+
+ // Try immediately first
+ const immediate = this.resolve(tag);
+ if (immediate) return immediate;
+
+ // Check if aborted before entering wait state
+ if (signal?.aborted) return null;
+
+ options?.onWaiting?.();
+
+ return new Promise<ResolvedModel | null>((resolve) => {
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ const cleanup = () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ const onAbort = () => {
+ cleanup();
+ resolve(null);
+ };
+
+ if (signal) {
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ const poll = () => {
+ if (signal?.aborted) {
+ resolve(null);
+ return;
+ }
+
+ const result = this.resolve(tag);
+ if (result) {
+ if (signal) {
+ signal.removeEventListener("abort", onAbort);
+ }
+ options?.onResume?.();
+ resolve(result);
+ return;
+ }
+
+ timer = setTimeout(poll, pollIntervalMs);
+ };
+
+ timer = setTimeout(poll, pollIntervalMs);
+ });
+ }
+}
diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts
new file mode 100644
index 0000000..5f958b3
--- /dev/null
+++ b/packages/core/src/skills/index.ts
@@ -0,0 +1,7 @@
+export { parseSkillFile } from "./parser.js";
+export {
+ loadSkills,
+ resolveSkillsForAgent,
+ getSkillByName,
+ createSkillsWatcher,
+} from "./loader.js";
diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts
new file mode 100644
index 0000000..1dcd39e
--- /dev/null
+++ b/packages/core/src/skills/loader.ts
@@ -0,0 +1,310 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as os from "node:os";
+import chokidar from "chokidar";
+import type { SkillDefinition, AgentSkillMapping, SkillScope } from "../types/index.js";
+import { parseSkillFile } from "./parser.js";
+
+// ─── Internal Helpers ────────────────────────────────────────────
+
+function loadSkillsFromDir(
+ dir: string,
+ scope: SkillScope,
+): SkillDefinition[] {
+ if (!fs.existsSync(dir)) {
+ return [];
+ }
+
+ const results: SkillDefinition[] = [];
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
+ continue;
+ }
+ const filePath = path.join(dir, entry.name);
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const skill = parseSkillFile(
+ filePath,
+ content,
+ scope,
+ // We'll set directory based on the parent dir segment — caller sets it
+ "default",
+ );
+ results.push(skill);
+ } catch {
+ // Skip unreadable files
+ }
+ }
+
+ return results;
+}
+
+function loadSkillsFromDirWithDirectory(
+ dir: string,
+ scope: SkillScope,
+ directory: SkillDefinition["directory"],
+): SkillDefinition[] {
+ if (!fs.existsSync(dir)) {
+ return [];
+ }
+
+ const results: SkillDefinition[] = [];
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
+ continue;
+ }
+ const filePath = path.join(dir, entry.name);
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const skill = parseSkillFile(filePath, content, scope, directory);
+ results.push(skill);
+ } catch {
+ // Skip unreadable files
+ }
+ }
+
+ return results;
+}
+
+function loadAgentMappings(
+ agentsDir: string,
+ scope: SkillScope,
+): AgentSkillMapping[] {
+ if (!fs.existsSync(agentsDir)) {
+ return [];
+ }
+
+ const results: AgentSkillMapping[] = [];
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(agentsDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".txt")) {
+ continue;
+ }
+
+ const fileName = entry.name;
+ let isOrchestrator = false;
+ let agentType: string;
+
+ if (fileName.endsWith(".o.txt")) {
+ isOrchestrator = true;
+ agentType = fileName.slice(0, -6); // remove ".o.txt"
+ } else {
+ agentType = fileName.slice(0, -4); // remove ".txt"
+ }
+
+ const filePath = path.join(agentsDir, fileName);
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const skills = content
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
+
+ results.push({ agentType, isOrchestrator, skills, scope });
+ } catch {
+ // Skip unreadable mapping files
+ }
+ }
+
+ return results;
+}
+
+// ─── Public API ──────────────────────────────────────────────────
+
+export function loadSkills(projectDir: string): {
+ skills: SkillDefinition[];
+ mappings: AgentSkillMapping[];
+} {
+ const globalBase = path.join(os.homedir(), ".skills");
+ const projectBase = path.join(projectDir, ".skills");
+
+ const skills: SkillDefinition[] = [];
+ const mappings: AgentSkillMapping[] = [];
+
+ // 1. Global default/
+ skills.push(
+ ...loadSkillsFromDirWithDirectory(
+ path.join(globalBase, "default"),
+ "global",
+ "default",
+ ),
+ );
+
+ // 2. Project default/
+ skills.push(
+ ...loadSkillsFromDirWithDirectory(
+ path.join(projectBase, "default"),
+ "project",
+ "default",
+ ),
+ );
+
+ // 3. Agent mappings — global then project
+ mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global"));
+ mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project"));
+
+ // 4. Project/ skills (manually activated)
+ skills.push(
+ ...loadSkillsFromDirWithDirectory(
+ path.join(globalBase, "project"),
+ "global",
+ "project",
+ ),
+ );
+ skills.push(
+ ...loadSkillsFromDirWithDirectory(
+ path.join(projectBase, "project"),
+ "project",
+ "project",
+ ),
+ );
+
+ return { skills, mappings };
+}
+
+export function resolveSkillsForAgent(
+ agentType: string,
+ isOrchestrator: boolean,
+ skills: SkillDefinition[],
+ mappings: AgentSkillMapping[],
+): SkillDefinition[] {
+ // Helper: project overrides global for same-named skills
+ const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => {
+ const seen = new Map<string, SkillDefinition>();
+ for (const skill of list) {
+ const existing = seen.get(skill.name);
+ if (!existing || skill.scope === "project") {
+ seen.set(skill.name, skill);
+ }
+ }
+ return Array.from(seen.values());
+ };
+
+ // All default-directory skills (global first, then project — dedupe preserves project)
+ const defaultSkills = skills.filter((s) => s.directory === "default");
+
+ // Skills mapped to this agent type
+ const relevantMappings = mappings.filter(
+ (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator,
+ );
+
+ // Gather agent-specific skills in order: global mappings first, then project
+ const globalMappings = relevantMappings.filter((m) => m.scope === "global");
+ const projectMappings = relevantMappings.filter((m) => m.scope === "project");
+
+ const agentSkillNames: string[] = [];
+ for (const mapping of [...globalMappings, ...projectMappings]) {
+ for (const skillFile of mapping.skills) {
+ const skillName = path.basename(skillFile, path.extname(skillFile));
+ agentSkillNames.push(skillName);
+ }
+ }
+
+ const agentSpecificSkills = agentSkillNames
+ .map((name) => getSkillByName(name, skills, "project"))
+ .filter((s): s is SkillDefinition => s !== undefined);
+
+ const combined = [...defaultSkills, ...agentSpecificSkills];
+ return dedupeByName(combined);
+}
+
+export function getSkillByName(
+ name: string,
+ skills: SkillDefinition[],
+ preferScope?: SkillScope,
+): SkillDefinition | undefined {
+ const matches = skills.filter((s) => s.name === name);
+ if (matches.length === 0) {
+ return undefined;
+ }
+
+ if (preferScope) {
+ const preferred = matches.find((s) => s.scope === preferScope);
+ if (preferred) {
+ return preferred;
+ }
+ }
+
+ // Default: project takes precedence
+ const projectMatch = matches.find((s) => s.scope === "project");
+ return projectMatch ?? matches[0];
+}
+
+export function createSkillsWatcher(
+ projectDir: string,
+ onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void,
+): { close(): void } {
+ const globalBase = path.join(os.homedir(), ".skills");
+ const projectBase = path.join(projectDir, ".skills");
+
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+
+ const reload = () => {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ const result = loadSkills(projectDir);
+ onChange(result);
+ }, 300);
+ };
+
+ const watchPaths = [globalBase, projectBase];
+
+ const watcher = chokidar.watch(watchPaths, {
+ ignoreInitial: true,
+ persistent: true,
+ });
+
+ watcher.on("add", (filePath: string) => {
+ if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
+ reload();
+ }
+ });
+
+ watcher.on("change", (filePath: string) => {
+ if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
+ reload();
+ }
+ });
+
+ watcher.on("unlink", (filePath: string) => {
+ if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
+ reload();
+ }
+ });
+
+ return {
+ close() {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ watcher.close();
+ },
+ };
+}
+
+// Keep loadSkillsFromDir exported for potential testing use
+export { loadSkillsFromDir };
diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts
new file mode 100644
index 0000000..4df5450
--- /dev/null
+++ b/packages/core/src/skills/parser.ts
@@ -0,0 +1,62 @@
+import { parse } from "smol-toml";
+import * as path from "node:path";
+import type { SkillDefinition, SkillScope, SkillDirectory } from "../types/index.js";
+
+const FRONTMATTER_DELIMITER = "+++";
+
+export function parseSkillFile(
+ filePath: string,
+ content: string,
+ scope: SkillScope,
+ directory: SkillDirectory,
+): SkillDefinition {
+ const defaultName = path.basename(filePath, path.extname(filePath));
+
+ let name = defaultName;
+ let description = "";
+ let tags: string[] = [];
+ let body = content;
+
+ // Check for TOML frontmatter
+ const trimmed = content.trimStart();
+ if (trimmed.startsWith(FRONTMATTER_DELIMITER)) {
+ const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length);
+ // Find the closing +++
+ const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER);
+ if (closeIndex !== -1) {
+ const tomlSource = afterOpen.slice(0, closeIndex);
+ body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length);
+
+ try {
+ const frontmatter = parse(tomlSource);
+
+ if (typeof frontmatter["name"] === "string") {
+ name = frontmatter["name"];
+ }
+ if (typeof frontmatter["description"] === "string") {
+ description = frontmatter["description"];
+ }
+ if (Array.isArray(frontmatter["tags"])) {
+ tags = (frontmatter["tags"] as unknown[]).filter(
+ (t): t is string => typeof t === "string",
+ );
+ }
+ } catch {
+ // Malformed TOML — fall through with defaults
+ }
+ }
+ }
+
+ // Trim leading newline from body (handles both LF and CRLF)
+ body = body.replace(/^\r?\n/, "");
+
+ return {
+ name,
+ description,
+ tags,
+ content: body,
+ scope,
+ source: filePath,
+ directory,
+ };
+}
diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts
new file mode 100644
index 0000000..0bacdb4
--- /dev/null
+++ b/packages/core/src/tools/task-list.ts
@@ -0,0 +1,148 @@
+import { z } from "zod";
+import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js";
+
+export class TaskList {
+ private tasks: TaskItem[] = [];
+ private counter = 0;
+ private listeners: Array<(tasks: TaskItem[]) => void> = [];
+
+ private notify(): void {
+ const snapshot = this.getTasks();
+ for (const listener of this.listeners) {
+ listener(snapshot);
+ }
+ }
+
+ getTasks(): TaskItem[] {
+ return [...this.tasks];
+ }
+
+ getTask(id: string): TaskItem | undefined {
+ return this.tasks.find((t) => t.id === id);
+ }
+
+ addTask(title: string, description: string): TaskItem {
+ this.counter++;
+ const task: TaskItem = {
+ id: `task-${this.counter}`,
+ title,
+ description,
+ status: "pending",
+ };
+ this.tasks.push(task);
+ this.notify();
+ return task;
+ }
+
+ updateTask(id: string, status: TaskStatus): TaskItem | undefined {
+ const task = this.tasks.find((t) => t.id === id);
+ if (!task) return undefined;
+ task.status = status;
+ this.notify();
+ return { ...task };
+ }
+
+ removeTask(id: string): boolean {
+ const index = this.tasks.findIndex((t) => t.id === id);
+ if (index === -1) return false;
+ this.tasks.splice(index, 1);
+ this.notify();
+ return true;
+ }
+
+ onChange(callback: (tasks: TaskItem[]) => void): () => void {
+ this.listeners.push(callback);
+ return () => {
+ this.listeners = this.listeners.filter((l) => l !== callback);
+ };
+ }
+}
+
+export function createTaskListTool(taskList: TaskList): ToolDefinition {
+ return {
+ name: "task_list",
+ description:
+ "Manages a task list for tracking work items. The agent can add tasks, update their status, list all tasks, or get details on a specific task.",
+ parameters: z.object({
+ action: z
+ .enum(["add", "update", "list", "get", "remove"])
+ .describe("The action to perform"),
+ title: z.string().optional().describe("Task title (required for 'add')"),
+ description: z
+ .string()
+ .optional()
+ .describe("Task description (for 'add', defaults to empty)"),
+ task_id: z
+ .string()
+ .optional()
+ .describe("Task ID (required for 'update', 'get', 'remove')"),
+ status: z
+ .enum(["pending", "in_progress", "done", "blocked"])
+ .optional()
+ .describe("New status (required for 'update')"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const action = args.action as string;
+
+ if (action === "add") {
+ const title = args.title as string | undefined;
+ if (!title) {
+ return "Error: 'title' is required for the 'add' action.";
+ }
+ const description = (args.description as string | undefined) ?? "";
+ const task = taskList.addTask(title, description);
+ return JSON.stringify(task);
+ }
+
+ if (action === "update") {
+ const task_id = args.task_id as string | undefined;
+ const status = args.status as TaskStatus | undefined;
+ if (!task_id) {
+ return "Error: 'task_id' is required for the 'update' action.";
+ }
+ if (!status) {
+ return "Error: 'status' is required for the 'update' action.";
+ }
+ const updated = taskList.updateTask(task_id, status);
+ if (!updated) {
+ return `Error: Task with ID '${task_id}' not found.`;
+ }
+ return JSON.stringify(updated);
+ }
+
+ if (action === "get") {
+ const task_id = args.task_id as string | undefined;
+ if (!task_id) {
+ return "Error: 'task_id' is required for the 'get' action.";
+ }
+ const task = taskList.getTask(task_id);
+ if (!task) {
+ return `Error: Task with ID '${task_id}' not found.`;
+ }
+ return JSON.stringify(task);
+ }
+
+ if (action === "list") {
+ const tasks = taskList.getTasks();
+ if (tasks.length === 0) {
+ return "No tasks.";
+ }
+ return JSON.stringify(tasks);
+ }
+
+ if (action === "remove") {
+ const task_id = args.task_id as string | undefined;
+ if (!task_id) {
+ return "Error: 'task_id' is required for the 'remove' action.";
+ }
+ const removed = taskList.removeTask(task_id);
+ if (!removed) {
+ return `Error: Task with ID '${task_id}' not found.`;
+ }
+ return `Task '${task_id}' removed successfully.`;
+ }
+
+ return `Error: Unknown action '${action}'.`;
+ },
+ };
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 6262d2b..5a761b5 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -1,7 +1,8 @@
import type { ZodType } from "zod";
import type { PermissionChecker, Ruleset } from "../permission/index.js";
-// Message types for the agent conversation
+// ─── Message Types ───────────────────────────────────────────────
+
export type MessageRole = "user" | "assistant" | "tool";
export interface ChatMessage {
@@ -24,10 +25,10 @@ export interface ToolResult {
isError: boolean;
}
-// Agent status
-export type AgentStatus = "idle" | "running" | "error";
+// ─── Agent Status & Events ───────────────────────────────────────
+
+export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
-// Agent events emitted during execution (for WebSocket streaming)
export type AgentEvent =
| { type: "status"; status: AgentStatus }
| { type: "text-delta"; delta: string }
@@ -36,14 +37,16 @@ export type AgentEvent =
| { type: "tool-result"; toolResult: ToolResult }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
| { type: "error"; error: string }
- | { type: "done"; message: ChatMessage };
+ | { type: "done"; message: ChatMessage }
+ | { type: "task-list-update"; tasks: TaskItem[] }
+ | { type: "config-reload" };
+
+// ─── Tool Types ──────────────────────────────────────────────────
-// Context passed to tool execute functions
export interface ToolExecuteContext {
onOutput?: (data: string, stream: "stdout" | "stderr") => void;
}
-// Tool definition interface
export interface ToolDefinition {
name: string;
description: string;
@@ -51,7 +54,8 @@ export interface ToolDefinition {
execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>;
}
-// Agent configuration
+// ─── Agent Configuration ─────────────────────────────────────────
+
export interface AgentConfig {
model: string;
apiKey: string;
@@ -62,3 +66,91 @@ export interface AgentConfig {
permissionChecker?: PermissionChecker;
ruleset?: Ruleset;
}
+
+// ─── Config Types (dispatch.toml) ────────────────────────────────
+
+export interface DispatchConfig {
+ agents?: Record<string, AgentTemplate>;
+ models?: ModelDefinition[];
+ keys?: KeyDefinition[];
+ fallback?: string[];
+ permissions: Record<string, string | Record<string, string>>;
+}
+
+export interface AgentTemplate {
+ name: string;
+ description: string;
+ system_prompt: string;
+ tools: string[];
+ permissions: Record<string, string | Record<string, string>>;
+ model_tag: string;
+}
+
+export interface ModelDefinition {
+ id: string;
+ provider: string;
+ tags: string[];
+}
+
+export interface KeyDefinition {
+ id: string;
+ provider: string;
+ env: string;
+ base_url: string;
+}
+
+// ─── Model Resolution ────────────────────────────────────────────
+
+export interface ResolvedModel {
+ model: ModelDefinition;
+ key: KeyDefinition;
+}
+
+export type KeyStatus = "active" | "exhausted";
+
+export interface KeyState {
+ definition: KeyDefinition;
+ status: KeyStatus;
+ lastError?: string;
+ exhaustedAt?: number;
+}
+
+// ─── Skills Types ────────────────────────────────────────────────
+
+export type SkillScope = "global" | "project";
+export type SkillDirectory = "default" | "agents" | "project";
+
+export interface SkillDefinition {
+ name: string;
+ description: string;
+ tags: string[];
+ content: string;
+ scope: SkillScope;
+ source: string;
+ directory: SkillDirectory;
+}
+
+export interface AgentSkillMapping {
+ agentType: string;
+ isOrchestrator: boolean;
+ skills: string[];
+ scope: SkillScope;
+}
+
+// ─── Task List Types ─────────────────────────────────────────────
+
+export type TaskStatus = "pending" | "in_progress" | "done" | "blocked";
+
+export interface TaskItem {
+ id: string;
+ title: string;
+ description: string;
+ status: TaskStatus;
+}
+
+// ─── Config Validation ───────────────────────────────────────────
+
+export interface ConfigError {
+ path: string;
+ message: string;
+}