summaryrefslogtreecommitdiffhomepage
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
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)
-rw-r--r--.gitignore1
-rw-r--r--bun.lock12
-rw-r--r--packages/api/src/agent-manager.ts138
-rw-r--r--packages/api/src/app.ts7
-rw-r--r--packages/api/src/index.ts4
-rw-r--r--packages/api/src/routes/config.ts27
-rw-r--r--packages/api/src/routes/models.ts69
-rw-r--r--packages/api/src/routes/skills.ts43
-rw-r--r--packages/api/tests/agent-manager.test.ts43
-rw-r--r--packages/api/tests/routes.test.ts43
-rw-r--r--packages/core/package.json2
-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
-rw-r--r--packages/core/tests/config/loader.test.ts48
-rw-r--r--packages/frontend/src/App.svelte110
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts21
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte244
-rw-r--r--packages/frontend/src/lib/components/Header.svelte10
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte35
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte135
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte234
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte72
-rw-r--r--packages/frontend/src/lib/types.ts9
34 files changed, 2449 insertions, 144 deletions
diff --git a/.gitignore b/.gitignore
index b924793..405d693 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ build/
*.db
*.sqlite
.DS_Store
+.skills/
diff --git a/bun.lock b/bun.lock
index a728a8b..3e93a00 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,6 +27,8 @@
"dependencies": {
"@ai-sdk/openai-compatible": "^0.2.0",
"ai": "^4.0.0",
+ "chokidar": "^5.0.0",
+ "smol-toml": "^1.6.1",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.26.8",
"zod": "^3.23.0",
@@ -291,7 +293,7 @@
"check-error": ["[email protected]", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
- "chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -405,7 +407,7 @@
"react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
- "readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+ "readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
@@ -415,6 +417,8 @@
"siginfo": ["[email protected]", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+ "smol-toml": ["[email protected]", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
+
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["[email protected]", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -484,5 +488,9 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "svelte-check/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "svelte-check/chokidar/readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
}
}
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 817ddbf..28b54f5 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -2,14 +2,28 @@ import {
Agent,
type AgentEvent,
type AgentStatus,
+ type DispatchConfig,
+ type AgentSkillMapping,
+ type SkillDefinition,
createListFilesTool,
createReadFileTool,
createRunShellTool,
createWriteFileTool,
loadConfig,
configToRuleset,
+ validateConfig,
+ createConfigWatcher,
+ loadSkills,
+ createSkillsWatcher,
+ ModelRegistry,
+ ModelResolver,
+ TaskList,
+ createTaskListTool,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
+import { setConfigGetter } from "./routes/config.js";
+import { setSkillsGetter } from "./routes/skills.js";
+import { setModelsGetter } from "./routes/models.js";
const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
@@ -17,6 +31,7 @@ const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have
- write_file: Write content to a file (creates parent directories if needed)
- list_files: List files and directories
- run_shell: Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.
+- task_list: Manage a task list for tracking work items.
When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`;
@@ -27,18 +42,98 @@ export class AgentManager {
private eventListeners: Set<(event: AgentEvent) => void> = new Set();
private permissionManager: PermissionManager | undefined;
+ private config: DispatchConfig;
+ private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] };
+ private modelRegistry: ModelRegistry | null = null;
+ private modelResolver: ModelResolver | null = null;
+ private taskList: TaskList;
+
+ private configWatcher: { close(): void } | null = null;
+ private skillsWatcher: { close(): void } | null = null;
+
constructor(permissionManager?: PermissionManager) {
this.permissionManager = permissionManager;
+
+ const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+
+ // Load initial config
+ this.config = loadConfig(workingDirectory);
+ const { errors } = validateConfig(this.config);
+ if (errors.length > 0) {
+ for (const err of errors) {
+ console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
+ }
+ }
+
+ // Initialize model registry + resolver if config has models and keys
+ this._initModelRegistry(this.config);
+
+ // Load initial skills
+ this.skillsData = loadSkills(workingDirectory);
+
+ // Wire route getters
+ setConfigGetter(() => this.config);
+ setSkillsGetter(() => this.skillsData);
+ setModelsGetter(
+ () => this.modelRegistry,
+ () => this.modelResolver,
+ );
+
+ // Set up task list
+ this.taskList = new TaskList();
+ this.taskList.onChange((tasks) => {
+ this.emit({ type: "task-list-update", tasks });
+ });
+
+ // Set up hot-reload watchers
+ this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => {
+ this.config = newConfig;
+ const { errors: newErrors } = validateConfig(newConfig);
+ if (newErrors.length > 0) {
+ for (const err of newErrors) {
+ console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
+ }
+ }
+ // Update model registry with new config
+ this._initModelRegistry(newConfig);
+ // Invalidate cached agent so next message uses updated config
+ this.agent = null;
+ this.emit({ type: "config-reload" });
+ });
+
+ this.skillsWatcher = createSkillsWatcher(workingDirectory, (result) => {
+ this.skillsData = result;
+ // Invalidate cached agent so next message uses updated skills
+ this.agent = null;
+ this.emit({ type: "config-reload" });
+ });
+ }
+
+ private _initModelRegistry(config: DispatchConfig): void {
+ if (config.models && config.keys) {
+ if (this.modelRegistry) {
+ this.modelRegistry.updateConfig(config.models, config.keys, config.fallback ?? []);
+ } else {
+ this.modelRegistry = new ModelRegistry(config.models, config.keys, config.fallback ?? []);
+ this.modelResolver = new ModelResolver(this.modelRegistry);
+ }
+ } else {
+ // Models/keys removed from config — clear the registry
+ this.modelRegistry = null;
+ this.modelResolver = null;
+ }
}
getPermissionManager(): PermissionManager | undefined {
return this.permissionManager;
}
+ getTaskList(): TaskList {
+ return this.taskList;
+ }
+
private getOrCreateAgent(): Agent {
if (!this.agent) {
- const apiKey = process.env.OPENCODE_API_KEY ?? "";
- const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
const tools = [
@@ -46,15 +141,43 @@ export class AgentManager {
createWriteFileTool(workingDirectory),
createListFilesTool(workingDirectory),
createRunShellTool(workingDirectory),
+ createTaskListTool(this.taskList),
];
- const config = loadConfig(workingDirectory);
- const ruleset = configToRuleset(config);
+ const ruleset = configToRuleset(this.config);
+
+ // Try to resolve model from registry, fall back to env vars
+ let apiKey = process.env.OPENCODE_API_KEY ?? "";
+ let model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
+ let baseURL = "https://opencode.ai/zen/go/v1";
+
+ if (this.modelRegistry && this.modelResolver) {
+ // Try to get model_tag from default agent template, fall back to "heavy"
+ const defaultAgent = this.config.agents?.["default"];
+ const tag = defaultAgent?.model_tag ?? "heavy";
+ const resolved = this.modelResolver.resolve(tag);
+ if (resolved) {
+ model = resolved.model.id;
+ baseURL = resolved.key.base_url;
+ const envKey = process.env[resolved.key.env];
+ if (envKey) {
+ apiKey = envKey;
+ } else {
+ console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`);
+ // Don't use the resolved key — fall back to default env vars entirely
+ model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
+ baseURL = "https://opencode.ai/zen/go/v1";
+ apiKey = process.env.OPENCODE_API_KEY ?? "";
+ }
+ } else {
+ console.warn(`dispatch: could not resolve model for tag "${tag}", falling back to env vars`);
+ }
+ }
this.agent = new Agent({
model,
apiKey,
- baseURL: "https://opencode.ai/zen/go/v1",
+ baseURL,
systemPrompt: SYSTEM_PROMPT,
tools,
workingDirectory,
@@ -104,4 +227,9 @@ export class AgentManager {
this.emit({ type: "status", status: "error" });
}
}
+
+ destroy(): void {
+ this.configWatcher?.close();
+ this.skillsWatcher?.close();
+ }
}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index b612c6a..c2fbae5 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -2,6 +2,9 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { AgentManager } from "./agent-manager.js";
import { PermissionManager } from "./permission-manager.js";
+import { configRoutes } from "./routes/config.js";
+import { skillsRoutes } from "./routes/skills.js";
+import { modelsRoutes } from "./routes/models.js";
export const permissionManager = new PermissionManager();
export const agentManager = new AgentManager(permissionManager);
@@ -46,3 +49,7 @@ app.post("/chat", async (c) => {
return c.json({ status: "ok" });
});
+
+app.route("/config", configRoutes);
+app.route("/skills", skillsRoutes);
+app.route("/models", modelsRoutes);
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index bf0f7f9..6ac6bca 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -16,6 +16,10 @@ app.get(
// Send current status immediately
ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() }));
+ // Send current task list state
+ const tasks = agentManager.getTaskList().getTasks();
+ ws.send(JSON.stringify({ type: "task-list-update", tasks }));
+
// Send any pending permission prompts
const pending = permissionManager.getPending();
if (pending.length > 0) {
diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts
new file mode 100644
index 0000000..2d08167
--- /dev/null
+++ b/packages/api/src/routes/config.ts
@@ -0,0 +1,27 @@
+import { Hono } from "hono";
+import type { DispatchConfig } from "@dispatch/core";
+
+let getConfig: () => DispatchConfig = () => ({ permissions: {} });
+
+export function setConfigGetter(getter: () => DispatchConfig): void {
+ getConfig = getter;
+}
+
+const configRoutes = new Hono();
+
+configRoutes.get("/", (c) => {
+ const config = getConfig();
+
+ // Strip env field values from keys for security
+ const safeConfig: DispatchConfig = {
+ ...config,
+ keys: config.keys?.map((key) => ({
+ ...key,
+ env: "***",
+ })),
+ };
+
+ return c.json({ config: safeConfig });
+});
+
+export { configRoutes };
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
new file mode 100644
index 0000000..62f7340
--- /dev/null
+++ b/packages/api/src/routes/models.ts
@@ -0,0 +1,69 @@
+import { Hono } from "hono";
+import type { ModelRegistry, ModelResolver } from "@dispatch/core";
+
+let getRegistry: () => ModelRegistry | null = () => null;
+let getResolver: () => ModelResolver | null = () => null;
+
+export function setModelsGetter(
+ registryGetter: () => ModelRegistry | null,
+ resolverGetter: () => ModelResolver | null,
+): void {
+ getRegistry = registryGetter;
+ getResolver = resolverGetter;
+}
+
+export const modelsRoutes = new Hono();
+
+modelsRoutes.get("/", (c) => {
+ const registry = getRegistry();
+ if (!registry) {
+ return c.json({ models: [], tags: [], keys: [] });
+ }
+
+ const models = registry.getModels();
+ const tags = registry.getAllTags();
+ const keyStates = registry.getKeys();
+
+ const keys = keyStates.map((ks) => ({
+ id: ks.definition.id,
+ provider: ks.definition.provider,
+ status: ks.status,
+ lastError: ks.lastError ?? null,
+ exhaustedAt: ks.exhaustedAt ?? null,
+ }));
+
+ return c.json({ models, tags, keys });
+});
+
+modelsRoutes.get("/resolve", (c) => {
+ const registry = getRegistry();
+ const resolver = getResolver();
+ if (!registry || !resolver) {
+ return c.json({ resolved: null, reason: "no models configured" });
+ }
+
+ const tag = c.req.query("tag");
+ if (!tag) {
+ return c.json({ error: "tag query parameter is required" }, 400);
+ }
+
+ const matchingModels = registry.getModelsByTag(tag);
+ if (matchingModels.length === 0) {
+ return c.json({ resolved: null, reason: "no models match tag" });
+ }
+
+ const resolved = resolver.resolve(tag);
+ if (!resolved) {
+ return c.json({ resolved: null, reason: "all keys exhausted for matching providers" });
+ }
+
+ return c.json({
+ resolved: {
+ model: resolved.model,
+ key: {
+ id: resolved.key.id,
+ provider: resolved.key.provider,
+ },
+ },
+ });
+});
diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts
new file mode 100644
index 0000000..245fb4c
--- /dev/null
+++ b/packages/api/src/routes/skills.ts
@@ -0,0 +1,43 @@
+import { Hono } from "hono";
+import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core";
+
+let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ skills: [], mappings: [] });
+
+export function setSkillsGetter(getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }): void {
+ getSkills = getter;
+}
+
+export const skillsRoutes = new Hono();
+
+skillsRoutes.get("/", (c) => {
+ const { skills, mappings } = getSkills();
+ const skillSummaries = skills.map(({ name, description, tags, scope, directory }) => ({
+ name,
+ description,
+ tags,
+ scope,
+ directory,
+ }));
+ return c.json({ skills: skillSummaries, mappings });
+});
+
+skillsRoutes.get("/:name", (c) => {
+ const { name } = c.req.param();
+ const scopeParam = c.req.query("scope") as SkillScope | undefined;
+ const { skills } = getSkills();
+
+ const matches = skills.filter((s) => s.name === name);
+ if (matches.length === 0) {
+ return c.json({ error: "Skill not found" }, 404);
+ }
+
+ if (scopeParam) {
+ const scoped = matches.find((s) => s.scope === scopeParam);
+ if (!scoped) {
+ return c.json({ error: "Skill not found" }, 404);
+ }
+ return c.json(scoped);
+ }
+
+ return c.json(matches[0]);
+});
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 2111b0e..1a9f1e2 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -65,6 +65,49 @@ vi.mock("@dispatch/core", () => ({
configToRuleset(_config: unknown) {
return [];
},
+ validateConfig(_config: unknown) {
+ return { config: _config, errors: [] };
+ },
+ createConfigWatcher(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
+ loadSkills(_dir: string) {
+ return { skills: [], mappings: [] };
+ },
+ createSkillsWatcher(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
+ ModelRegistry: class MockModelRegistry {
+ getModels() { return []; }
+ getKeys() { return []; }
+ getModelsByTag(_tag: string) { return []; }
+ getAllTags() { return []; }
+ hasAvailableKey(_provider: string) { return false; }
+ allKeysExhausted() { return true; }
+ markKeyExhausted() {}
+ markKeyActive() {}
+ updateConfig() {}
+ },
+ ModelResolver: class MockModelResolver {
+ resolve(_tag: string) { return null; }
+ waitForKey() { return Promise.resolve(null); }
+ },
+ TaskList: class MockTaskList {
+ getTasks() { return []; }
+ getTask() { return undefined; }
+ addTask() { return { id: "task-1", title: "", description: "", status: "pending" }; }
+ updateTask() { return undefined; }
+ removeTask() { return false; }
+ onChange(_cb: unknown) { return () => {}; }
+ },
+ createTaskListTool(_taskList: unknown) {
+ return {
+ name: "task_list",
+ description: "task list",
+ parameters: { _type: "z.ZodObject", shape: {} },
+ execute: async () => "mock",
+ };
+ },
}));
// Import after mock is defined (Vitest hoists vi.mock automatically)
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index 87ff436..5ecfcb0 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -66,6 +66,49 @@ vi.mock("@dispatch/core", () => ({
configToRuleset(_config: unknown) {
return [];
},
+ validateConfig(_config: unknown) {
+ return { config: _config, errors: [] };
+ },
+ createConfigWatcher(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
+ loadSkills(_dir: string) {
+ return { skills: [], mappings: [] };
+ },
+ createSkillsWatcher(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
+ ModelRegistry: class MockModelRegistry {
+ getModels() { return []; }
+ getKeys() { return []; }
+ getModelsByTag(_tag: string) { return []; }
+ getAllTags() { return []; }
+ hasAvailableKey(_provider: string) { return false; }
+ allKeysExhausted() { return true; }
+ markKeyExhausted() {}
+ markKeyActive() {}
+ updateConfig() {}
+ },
+ ModelResolver: class MockModelResolver {
+ resolve(_tag: string) { return null; }
+ waitForKey() { return Promise.resolve(null); }
+ },
+ TaskList: class MockTaskList {
+ getTasks() { return []; }
+ getTask() { return undefined; }
+ addTask() { return { id: "task-1", title: "", description: "", status: "pending" }; }
+ updateTask() { return undefined; }
+ removeTask() { return false; }
+ onChange(_cb: unknown) { return () => {}; }
+ },
+ createTaskListTool(_taskList: unknown) {
+ return {
+ name: "task_list",
+ description: "task list",
+ parameters: { _type: "z.ZodObject", shape: {} },
+ execute: async () => "mock",
+ };
+ },
}));
const { app } = await import("../src/app.js");
diff --git a/packages/core/package.json b/packages/core/package.json
index 66c8726..4cb69f9 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -13,6 +13,8 @@
"dependencies": {
"@ai-sdk/openai-compatible": "^0.2.0",
"ai": "^4.0.0",
+ "chokidar": "^5.0.0",
+ "smol-toml": "^1.6.1",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.26.8",
"zod": "^3.23.0"
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;
+}
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
index 9a358fe..5c02326 100644
--- a/packages/core/tests/config/loader.test.ts
+++ b/packages/core/tests/config/loader.test.ts
@@ -14,25 +14,25 @@ afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
});
-function writeYaml(content: string): void {
- writeFileSync(join(TMP, "dispatch.yaml"), content, "utf-8");
+function writeToml(content: string): void {
+ writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8");
}
describe("loadConfig", () => {
- it("returns empty permissions when dispatch.yaml is missing", () => {
+ it("returns empty permissions when dispatch.toml is missing", () => {
const config = loadConfig(TMP);
expect(config.permissions).toEqual({});
});
it("parses simple string permissions", () => {
- writeYaml(`permissions:\n read: allow\n edit: deny\n`);
+ writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`);
const config = loadConfig(TMP);
expect(config.permissions["read"]).toBe("allow");
expect(config.permissions["edit"]).toBe("deny");
});
it("parses nested pattern permissions", () => {
- writeYaml(`permissions:\n bash:\n "npm test": allow\n "*": ask\n`);
+ writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`);
const config = loadConfig(TMP);
const bash = config.permissions["bash"] as Record<string, string>;
expect(bash["npm test"]).toBe("allow");
@@ -40,29 +40,27 @@ describe("loadConfig", () => {
});
it("ignores comment lines", () => {
- writeYaml(`# this is a comment\npermissions:\n # another comment\n read: allow\n`);
+ writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`);
const config = loadConfig(TMP);
expect(config.permissions["read"]).toBe("allow");
});
it("handles ~ expansion in nested keys", () => {
- const home = homedir();
- writeYaml(`permissions:\n read:\n "~/projects/*": allow\n`);
+ writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`);
const config = loadConfig(TMP);
const read = config.permissions["read"] as Record<string, string>;
- expect(read[`${home}/projects/*`]).toBe("allow");
+ expect(read["~/projects/*"]).toBe("allow");
});
it("handles $HOME expansion in nested keys", () => {
- const home = homedir();
- writeYaml(`permissions:\n read:\n "$HOME/docs/*": allow\n`);
+ writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`);
const config = loadConfig(TMP);
const read = config.permissions["read"] as Record<string, string>;
- expect(read[`${home}/docs/*`]).toBe("allow");
+ expect(read["$HOME/docs/*"]).toBe("allow");
});
it("parses quoted keys", () => {
- writeYaml(`permissions:\n bash:\n "git commit *": allow\n "rm *": deny\n`);
+ writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`);
const config = loadConfig(TMP);
const bash = config.permissions["bash"] as Record<string, string>;
expect(bash["git commit *"]).toBe("allow");
@@ -70,8 +68,8 @@ describe("loadConfig", () => {
});
it("handles multiple permission groups", () => {
- writeYaml(
- `permissions:\n read: allow\n edit:\n "*": ask\n "src/**": allow\n bash:\n "npm test": allow\n "*": ask\n`,
+ writeToml(
+ `[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`,
);
const config = loadConfig(TMP);
expect(config.permissions["read"]).toBe("allow");
@@ -83,28 +81,32 @@ describe("loadConfig", () => {
expect(bash["*"]).toBe("ask");
});
- it("preserves # inside quoted string values", () => {
- writeYaml(`permissions:\n bash:\n '"file#1"': allow\n`);
+ it("preserves # inside quoted string keys", () => {
+ writeToml(`[permissions.bash]\n"file#1" = "allow"\n`);
const config = loadConfig(TMP);
const bash = config.permissions["bash"] as Record<string, string>;
- expect(bash['"file#1"']).toBe("allow");
+ expect(bash["file#1"]).toBe("allow");
});
- it("strips inline comments on nested map keys (empty value after comment)", () => {
- writeYaml(`permissions:\n bash: # scripts\n "*": allow\n`);
+ it("strips inline comments on table headers", () => {
+ writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`);
const config = loadConfig(TMP);
const bash = config.permissions["bash"] as Record<string, string>;
expect(bash["*"]).toBe("allow");
});
it("expands ~ with platform path separator", () => {
- const home = homedir();
// Simulate a path using the OS separator
const pattern = `~${sep}projects${sep}*`;
- writeYaml(`permissions:\n read:\n "${pattern}": allow\n`);
+ writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`);
const config = loadConfig(TMP);
const read = config.permissions["read"] as Record<string, string>;
- expect(read[`${home}${sep}projects${sep}*`]).toBe("allow");
+ expect(read[pattern]).toBe("allow");
+ });
+
+ it("throws on TOML parse errors", () => {
+ writeToml("this is not valid TOML [[[");
+ expect(() => loadConfig(TMP)).toThrow();
});
});
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index b980abf..02a80ba 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -5,11 +5,60 @@ import ChatPanel from "./lib/components/ChatPanel.svelte";
import Header from "./lib/components/Header.svelte";
import PermissionPrompt from "./lib/components/PermissionPrompt.svelte";
import PermissionLog from "./lib/components/PermissionLog.svelte";
+import ConfigPanel from "./lib/components/ConfigPanel.svelte";
+import SkillsBrowser from "./lib/components/SkillsBrowser.svelte";
+import TaskListPanel from "./lib/components/TaskListPanel.svelte";
+import ModelStatus from "./lib/components/ModelStatus.svelte";
+import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
import { chatStore } from "./lib/chat.svelte.js";
import { wsClient } from "./lib/ws.svelte.js";
+import { config } from "./lib/config.js";
const STORAGE_KEY = "dispatch-theme";
+interface KeyInfo {
+ id: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError: string | null;
+ exhaustedAt: number | null;
+}
+
+interface ModelInfo {
+ id: string;
+ provider: string;
+ tags: string[];
+}
+
+let modelsData = $state<{ models: ModelInfo[]; keys: KeyInfo[]; tags: string[] }>({
+ models: [],
+ keys: [],
+ tags: [],
+});
+
+let sidebarOpen = $state(true);
+
+async function fetchModels() {
+ try {
+ const res = await fetch(`${config.apiBase}/models`);
+ if (!res.ok) return;
+ const data = await res.json();
+ modelsData = {
+ models: data.models ?? [],
+ keys: data.keys ?? [],
+ tags: data.tags ? (Array.isArray(data.tags) ? data.tags : Object.keys(data.tags)) : [],
+ };
+ } catch {
+ // ignore fetch errors
+ }
+}
+
+$effect(() => {
+ if (chatStore.configReloaded) {
+ fetchModels();
+ }
+});
+
onMount(() => {
// Apply saved theme
const saved = localStorage.getItem(STORAGE_KEY);
@@ -20,6 +69,9 @@ onMount(() => {
// Connect WebSocket
wsClient.connect();
+ // Initial models fetch
+ fetchModels();
+
return () => {
wsClient.disconnect();
};
@@ -27,18 +79,64 @@ onMount(() => {
</script>
<div class="flex flex-col h-screen overflow-hidden">
- <Header />
- <div class="flex-1 overflow-hidden">
- <ChatPanel />
+ <Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
+
+ <div class="flex flex-1 overflow-hidden">
+ <!-- Main chat area -->
+ <div class="flex flex-col flex-1 min-w-0 overflow-hidden">
+ <div class="flex-1 overflow-hidden">
+ <ChatPanel />
+ </div>
+ <ChatInput />
+ </div>
+
+ <!-- Right sidebar — slides in/out while chat smoothly resizes -->
+ <div
+ class="shrink-0 overflow-hidden border-l border-base-300 transition-[width] duration-300 ease-out"
+ class:w-80={sidebarOpen}
+ class:w-0={!sidebarOpen}
+ >
+ <div
+ class="w-80 overflow-y-auto bg-base-100 px-2 py-2 flex flex-col gap-2 h-full transition-transform duration-300 ease-out"
+ style="transform: translateX({sidebarOpen ? '0' : '100%'})"
+ >
+ <div class="collapse collapse-arrow bg-base-200">
+ <input type="checkbox" checked />
+ <div class="collapse-title text-sm font-medium">Model Status</div>
+ <div class="collapse-content">
+ <ModelStatus
+ models={modelsData.models}
+ keys={modelsData.keys}
+ tags={modelsData.tags}
+ />
+ </div>
+ </div>
+
+ <div class="collapse collapse-arrow bg-base-200">
+ <input type="checkbox" checked />
+ <div class="collapse-title text-sm font-medium">Tasks</div>
+ <div class="collapse-content">
+ <TaskListPanel tasks={chatStore.tasks} />
+ </div>
+ </div>
+
+ <ConfigPanel apiBase={config.apiBase} />
+
+ <SkillsBrowser apiBase={config.apiBase} />
+
+ <PermissionLog entries={chatStore.permissionLog} />
+ </div>
+ </div>
</div>
- <ChatInput />
</div>
+<!-- Fixed overlay elements -->
<PermissionPrompt
pending={chatStore.pendingPermissions}
onReply={(id, reply) => chatStore.replyPermission(id, reply)}
/>
-<div class="fixed bottom-24 right-4 w-80 z-10">
- <PermissionLog entries={chatStore.permissionLog} />
+<!-- Hot reload indicator fixed top-right -->
+<div class="fixed top-4 right-4 z-50">
+ <HotReloadIndicator active={chatStore.configReloaded} />
</div>
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts
index c0f0a98..9559f20 100644
--- a/packages/frontend/src/lib/chat.svelte.ts
+++ b/packages/frontend/src/lib/chat.svelte.ts
@@ -1,5 +1,5 @@
import { config } from "./config.js";
-import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt } from "./types.js";
+import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt, TaskItem } from "./types.js";
import { wsClient } from "./ws.svelte.js";
function generateId() {
@@ -71,6 +71,8 @@ function createChatStore() {
let currentAssistantId: string | null = null;
let pendingPermissions: PermissionPrompt[] = $state([]);
let permissionLog: LogEntry[] = $state([]);
+ let tasks: TaskItem[] = $state([]);
+ let configReloaded = $state(false);
wsClient.onEvent((event) => {
handleEvent(event);
@@ -209,6 +211,17 @@ function createChatStore() {
pendingPermissions = event.pending;
break;
}
+ case "task-list-update": {
+ tasks = event.tasks;
+ break;
+ }
+ case "config-reload": {
+ configReloaded = true;
+ setTimeout(() => {
+ configReloaded = false;
+ }, 2500);
+ break;
+ }
case "shell-output": {
messages = messages.map((m) => {
if (m.id === currentAssistantId) {
@@ -334,6 +347,12 @@ function createChatStore() {
get permissionLog() {
return permissionLog;
},
+ get tasks() {
+ return tasks;
+ },
+ get configReloaded() {
+ return configReloaded;
+ },
sendMessage,
handleEvent,
replyPermission,
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte
new file mode 100644
index 0000000..25cbe98
--- /dev/null
+++ b/packages/frontend/src/lib/components/ConfigPanel.svelte
@@ -0,0 +1,244 @@
+<script lang="ts">
+ interface AgentTemplate {
+ name: string;
+ description?: string;
+ model_tag?: string;
+ tools?: string[];
+ }
+
+ interface ModelEntry {
+ id: string;
+ provider?: string;
+ tags?: string[];
+ }
+
+ interface KeyEntry {
+ id: string;
+ provider?: string;
+ status?: string;
+ lastError?: string;
+ exhaustedAt?: string;
+ }
+
+ interface ConfigData {
+ agents?: Record<string, AgentTemplate>;
+ models?: Record<string, { provider?: string; tags?: string[] }>;
+ keys?: Record<string, { env?: string }>;
+ fallback?: string[];
+ permissions?: Record<string, unknown>;
+ }
+
+ interface ModelsData {
+ models?: ModelEntry[];
+ tags?: Record<string, string[]>;
+ keys?: KeyEntry[];
+ }
+
+ const { apiBase }: { apiBase: string } = $props();
+
+ let configData = $state<ConfigData | null>(null);
+ let modelsData = $state<ModelsData | null>(null);
+ let error = $state<string | null>(null);
+ let loading = $state(false);
+
+ async function fetchData() {
+ loading = true;
+ error = null;
+ try {
+ const [configRes, modelsRes] = await Promise.all([
+ fetch(`${apiBase}/config`),
+ fetch(`${apiBase}/models`),
+ ]);
+ if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
+ if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
+ const configJson = await configRes.json();
+ const modelsJson = await modelsRes.json();
+ configData = configJson.config ?? configJson;
+ modelsData = modelsJson;
+ } catch (e) {
+ error = e instanceof Error ? e.message : String(e);
+ } finally {
+ loading = false;
+ }
+ }
+
+ $effect(() => {
+ fetchData();
+ });
+
+ const modelCount = $derived(modelsData?.models?.length ?? 0);
+ const keyCount = $derived(modelsData?.keys?.length ?? 0);
+
+ function formatDate(iso: string | undefined): string {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+ }
+
+ function permissionEntries(permissions: Record<string, unknown>): Array<{ name: string; value: unknown }> {
+ return Object.entries(permissions).map(([name, value]) => ({ name, value }));
+ }
+
+ function isSimpleRule(value: unknown): value is { action: string } {
+ return typeof value === "object" && value !== null && "action" in value && Object.keys(value).length === 1;
+ }
+
+ function isPatternRule(value: unknown): value is Record<string, { action: string }> {
+ return typeof value === "object" && value !== null && !("action" in value);
+ }
+</script>
+
+<details class="collapse collapse-arrow bg-base-200 mt-2">
+ <summary class="collapse-title text-sm font-medium flex items-center gap-2">
+ <span>Configuration</span>
+ {#if modelCount > 0 || keyCount > 0}
+ <span class="badge badge-sm badge-neutral">{modelCount} models</span>
+ <span class="badge badge-sm badge-neutral">{keyCount} keys</span>
+ {/if}
+ {#if loading}
+ <span class="loading loading-spinner loading-xs ml-auto"></span>
+ {/if}
+ </summary>
+
+ <div class="collapse-content text-xs">
+ {#if error}
+ <div class="alert alert-error alert-sm py-1 mb-2 text-xs">
+ <span>Failed to load config: {error}</span>
+ </div>
+ {/if}
+
+ <div class="flex justify-end mb-2">
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost"
+ onclick={fetchData}
+ disabled={loading}
+ >
+ {loading ? "Loading…" : "Refresh"}
+ </button>
+ </div>
+
+ <!-- Agent Templates -->
+ {#if configData?.agents && Object.keys(configData.agents).length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div>
+ {#each Object.entries(configData.agents) as [name, template]}
+ <div class="bg-base-100 rounded p-2 mb-1">
+ <div class="flex items-center gap-2 flex-wrap">
+ <span class="font-medium">{name}</span>
+ {#if template.model_tag}
+ <span class="badge badge-xs badge-info">{template.model_tag}</span>
+ {/if}
+ </div>
+ {#if template.description}
+ <p class="text-base-content/60 mt-0.5">{template.description}</p>
+ {/if}
+ {#if template.tools && template.tools.length > 0}
+ <div class="flex flex-wrap gap-1 mt-1">
+ {#each template.tools as tool}
+ <span class="badge badge-xs badge-ghost">{tool}</span>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Models -->
+ {#if modelsData?.models && modelsData.models.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div>
+ {#each modelsData.models as model}
+ <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300">
+ <span class="font-mono">{model.id}</span>
+ {#if model.provider}
+ <span class="badge badge-xs badge-primary">{model.provider}</span>
+ {/if}
+ {#if model.tags && model.tags.length > 0}
+ {#each model.tags as tag}
+ <span class="badge badge-xs badge-ghost">{tag}</span>
+ {/each}
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Keys -->
+ {#if modelsData?.keys && modelsData.keys.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div>
+ {#each modelsData.keys as key}
+ <div class="bg-base-100 rounded p-1.5 mb-1">
+ <div class="flex items-center gap-2 flex-wrap">
+ <span class="font-mono">{key.id}</span>
+ {#if key.provider}
+ <span class="badge badge-xs badge-secondary">{key.provider}</span>
+ {/if}
+ {#if key.status === "exhausted"}
+ <span class="badge badge-xs badge-error">exhausted</span>
+ {:else}
+ <span class="badge badge-xs badge-success">active</span>
+ {/if}
+ </div>
+ {#if key.status === "exhausted" && key.lastError}
+ <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p>
+ {/if}
+ {#if key.exhaustedAt}
+ <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Fallback Order -->
+ {#if configData?.fallback && configData.fallback.length > 0}
+ <div class="mb-3">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div>
+ <ol class="list-none space-y-0.5">
+ {#each configData.fallback as keyId, i}
+ <li class="flex items-center gap-2">
+ <span class="badge badge-xs badge-outline">{i + 1}</span>
+ <span class="font-mono">{keyId}</span>
+ </li>
+ {/each}
+ </ol>
+ </div>
+ {/if}
+
+ <!-- Permissions -->
+ {#if configData?.permissions && Object.keys(configData.permissions).length > 0}
+ <div class="mb-1">
+ <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div>
+ {#each permissionEntries(configData.permissions) as entry}
+ <div class="py-0.5 border-b border-base-300">
+ <span class="font-medium text-base-content/80">{entry.name}</span>
+ {#if isSimpleRule(entry.value)}
+ <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span>
+ {:else if isPatternRule(entry.value)}
+ {#each Object.entries(entry.value) as [pattern, rule]}
+ <div class="pl-2 flex items-center gap-2">
+ <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span>
+ {#if typeof rule === "object" && rule !== null && "action" in rule}
+ <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span>
+ {/if}
+ </div>
+ {/each}
+ {:else}
+ <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ {#if !loading && !error && !configData && !modelsData}
+ <p class="text-base-content/40 italic">No configuration loaded.</p>
+ {/if}
+ </div>
+</details>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index cf466fe..5d14593 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -2,6 +2,8 @@
import { chatStore } from "../chat.svelte.js";
import ThemeSwitcher from "./ThemeSwitcher.svelte";
+const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
+
let showThemeSwitcher = $state(false);
let copyLabel = $state("Copy");
@@ -46,6 +48,14 @@ async function handleCopy() {
>
Theme
</button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={onToggleSidebar}
+ aria-label="Toggle sidebar"
+ >
+ ☰ Sidebar
+ </button>
</div>
</header>
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
new file mode 100644
index 0000000..3e34d3b
--- /dev/null
+++ b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ const { active }: { active: boolean } = $props();
+
+ let visible = $state(false);
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ $effect(() => {
+ if (active) {
+ visible = true;
+ if (timer !== null) clearTimeout(timer);
+ timer = setTimeout(() => {
+ visible = false;
+ timer = null;
+ }, 2000);
+ }
+ return () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ visible = false;
+ };
+ });
+</script>
+
+{#if visible}
+ <div
+ class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-info/20 text-info text-xs font-medium animate-pulse"
+ role="status"
+ aria-live="polite"
+ >
+ <span class="status status-xs status-info"></span>
+ <span>Config reloaded</span>
+ </div>
+{/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
new file mode 100644
index 0000000..34a0563
--- /dev/null
+++ b/packages/frontend/src/lib/components/ModelStatus.svelte
@@ -0,0 +1,135 @@
+<script lang="ts">
+ interface KeyInfo {
+ id: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError: string | null;
+ exhaustedAt: number | null;
+ }
+
+ interface ModelInfo {
+ id: string;
+ provider: string;
+ tags: string[];
+ }
+
+ const {
+ models = [],
+ keys = [],
+ tags = [],
+ currentModel,
+ }: {
+ models?: ModelInfo[];
+ keys?: KeyInfo[];
+ tags?: string[];
+ currentModel?: string;
+ } = $props();
+
+ const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
+ const totalKeys = $derived(keys.length);
+ const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
+ const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
+ const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
+
+ const uniqueTags = $derived([...new Set(tags)]);
+
+ function timeAgo(ts: number | null): string {
+ if (ts === null) return "";
+ const diffMs = Date.now() - ts;
+ const diffSec = Math.floor(diffMs / 1000);
+ if (diffSec < 60) return `${diffSec}s ago`;
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return `${diffMin}m ago`;
+ const diffHr = Math.floor(diffMin / 60);
+ return `${diffHr}h ago`;
+ }
+
+ function truncate(str: string | null, max: number): string {
+ if (!str) return "";
+ return str.length > max ? str.slice(0, max) + "…" : str;
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ {#if models.length === 0 && keys.length === 0}
+ <p class="text-xs text-base-content/50">
+ No models configured. Using environment defaults.
+ </p>
+ {:else}
+ <!-- Overall status -->
+ {#if allActive}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-success badge-xs">●</span>
+ <span class="text-xs text-success">All keys available</span>
+ </div>
+ {:else if allExhausted}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-error badge-xs">●</span>
+ <span class="text-xs text-error">All keys exhausted — waiting for refresh</span>
+ </div>
+ {:else if someExhausted}
+ <div class="flex items-center gap-1.5">
+ <span class="badge badge-warning badge-xs">●</span>
+ <span class="text-xs text-warning">
+ Fallback active ({activeKeys}/{totalKeys} keys available)
+ </span>
+ </div>
+ {/if}
+
+ <!-- Current model -->
+ {#if currentModel}
+ <div class="flex flex-col gap-0.5">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">Current Model</p>
+ <p class="text-sm font-mono font-semibold text-primary">{currentModel}</p>
+ </div>
+ {/if}
+
+ <!-- Tags -->
+ {#if uniqueTags.length > 0}
+ <div class="flex flex-col gap-1">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">Tags</p>
+ <div class="flex flex-wrap gap-1">
+ {#each uniqueTags as tag (tag)}
+ <span class="badge badge-outline badge-xs">{tag}</span>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Keys -->
+ {#if keys.length > 0}
+ <div class="flex flex-col gap-1">
+ <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
+ <ul class="flex flex-col gap-1">
+ {#each keys as key (key.id)}
+ <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
+ <div class="flex items-center gap-1.5">
+ <span
+ class="badge badge-xs {key.status === 'active'
+ ? 'badge-success'
+ : 'badge-error'}"
+ >
+ {key.status}
+ </span>
+ <span class="text-xs font-mono">{key.id}</span>
+ <span class="text-xs text-base-content/40">{key.provider}</span>
+ </div>
+ {#if key.status === "exhausted"}
+ <div class="pl-2 flex flex-col gap-0.5">
+ {#if key.lastError}
+ <p class="text-xs text-error/70 line-clamp-1">
+ {truncate(key.lastError, 80)}
+ </p>
+ {/if}
+ {#if key.exhaustedAt !== null}
+ <p class="text-xs text-base-content/40">{timeAgo(key.exhaustedAt)}</p>
+ {/if}
+ </div>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte
new file mode 100644
index 0000000..be1ad29
--- /dev/null
+++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte
@@ -0,0 +1,234 @@
+<script lang="ts">
+interface Skill {
+ name: string;
+ description: string;
+ tags: string[];
+ scope: "global" | "project";
+ directory: "default" | "agents" | "project";
+}
+
+interface SkillMapping {
+ agentType: string;
+ isOrchestrator: boolean;
+ skills: string[];
+ scope: string;
+}
+
+interface SkillsResponse {
+ skills: Skill[];
+ mappings: SkillMapping[];
+}
+
+interface SkillDetail extends Skill {
+ content: string;
+ source: string;
+}
+
+const { apiBase }: { apiBase: string } = $props();
+
+let skills = $state<Skill[]>([]);
+let mappings = $state<SkillMapping[]>([]);
+let loading = $state(false);
+let error = $state<string | null>(null);
+let expandedSkills = $state<Record<string, SkillDetail | null>>({});
+let loadingSkill = $state<Record<string, boolean>>({});
+
+async function fetchSkills() {
+ loading = true;
+ error = null;
+ try {
+ const res = await fetch(`${apiBase}/skills`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: SkillsResponse = await res.json();
+ skills = data.skills ?? [];
+ mappings = data.mappings ?? [];
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to fetch skills";
+ } finally {
+ loading = false;
+ }
+}
+
+async function toggleSkill(skill: Skill) {
+ const key = `${skill.scope}:${skill.name}`;
+ if (expandedSkills[key] !== undefined) {
+ const updated = { ...expandedSkills };
+ delete updated[key];
+ expandedSkills = updated;
+ return;
+ }
+ if (loadingSkill[key]) return;
+ loadingSkill = { ...loadingSkill, [key]: true };
+ try {
+ const res = await fetch(`${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: SkillDetail = await res.json();
+ expandedSkills = { ...expandedSkills, [key]: data };
+ } catch (_e) {
+ expandedSkills = { ...expandedSkills, [key]: null };
+ } finally {
+ const updated = { ...loadingSkill };
+ delete updated[key];
+ loadingSkill = updated;
+ }
+}
+
+$effect(() => {
+ fetchSkills();
+});
+
+const globalSkills = $derived(skills.filter((s) => s.scope === "global"));
+const projectSkills = $derived(skills.filter((s) => s.scope === "project"));
+
+function skillsByDirectory(list: Skill[], dir: "default" | "agents" | "project") {
+ return list.filter((s) => s.directory === dir);
+}
+
+function getMappingsForScope(scope: string) {
+ return mappings.filter((m) => m.scope === scope);
+}
+</script>
+
+<details class="collapse collapse-arrow bg-base-200 mt-4">
+ <summary class="collapse-title text-sm font-medium flex items-center gap-2">
+ <span>Skills</span>
+ {#if !loading}
+ <span class="badge badge-sm badge-neutral">{skills.length}</span>
+ {/if}
+ <button
+ class="btn btn-xs btn-ghost ml-auto"
+ onclick={(e) => { e.stopPropagation(); fetchSkills(); }}
+ title="Refresh skills"
+ >
+ ↺ Refresh
+ </button>
+ </summary>
+ <div class="collapse-content text-xs">
+ {#if loading}
+ <div class="flex items-center gap-2 py-2 text-base-content/60">
+ <span class="loading loading-spinner loading-xs"></span>
+ Loading skills...
+ </div>
+ {:else if error}
+ <div class="alert alert-error text-xs py-2">{error}</div>
+ {:else if skills.length === 0}
+ <p class="text-base-content/50 italic py-2">
+ No skills found. Create a <code class="font-mono">.skills/default/</code> directory to get started.
+ </p>
+ {:else}
+ {#snippet skillItem(skill: Skill)}
+ {@const key = `${skill.scope}:${skill.name}`}
+ {@const isExpanded = key in expandedSkills}
+ {@const detail = expandedSkills[key]}
+ {@const isLoading = loadingSkill[key]}
+ <div class="border-b border-base-300 last:border-0 py-1">
+ <div class="flex items-start gap-1 flex-wrap">
+ <button
+ class="font-mono text-primary hover:underline text-left"
+ onclick={() => toggleSkill(skill)}
+ >
+ {skill.name}
+ </button>
+ {#if isLoading}
+ <span class="loading loading-spinner loading-xs text-base-content/40"></span>
+ {/if}
+ {#each skill.tags as tag}
+ <span class="badge badge-xs badge-outline">{tag}</span>
+ {/each}
+ </div>
+ {#if skill.description}
+ <p class="text-base-content/60 truncate max-w-xs">{skill.description}</p>
+ {/if}
+ {#if isExpanded}
+ <div class="mt-2 bg-base-300 rounded p-2">
+ {#if detail}
+ <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{detail.content}</pre>
+ {:else}
+ <p class="text-error text-xs">Failed to load skill content.</p>
+ {/if}
+ <button
+ class="btn btn-xs btn-ghost mt-1"
+ onclick={() => toggleSkill(skill)}
+ >
+ Close
+ </button>
+ </div>
+ {/if}
+ </div>
+ {/snippet}
+
+ {#snippet scopeSection(label: string, scopeSkills: Skill[], scope: string)}
+ {#if scopeSkills.length > 0}
+ {@const defaultSkills = skillsByDirectory(scopeSkills, "default")}
+ {@const agentSkills = skillsByDirectory(scopeSkills, "agents")}
+ {@const projectDirSkills = skillsByDirectory(scopeSkills, "project")}
+ {@const scopeMappings = getMappingsForScope(scope)}
+ <div class="mb-3">
+ <div class="flex items-center gap-1 mb-1">
+ <span class="font-semibold text-base-content/80">{label}</span>
+ <span class="badge badge-xs {scope === 'global' ? 'badge-info' : 'badge-warning'}">{scope}</span>
+ </div>
+
+ {#if defaultSkills.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">default/</div>
+ <div class="ml-2">
+ {#each defaultSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if agentSkills.length > 0 || scopeMappings.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">agents/</div>
+ <div class="ml-2">
+ {#if scopeMappings.length > 0}
+ {#each scopeMappings as mapping}
+ <div class="py-1 border-b border-base-300 last:border-0">
+ <div class="flex items-center gap-1 flex-wrap">
+ <span class="font-mono text-secondary">{mapping.agentType}</span>
+ {#if mapping.isOrchestrator}
+ <span class="badge badge-xs badge-accent">(orchestrator)</span>
+ {/if}
+ <span class="text-base-content/40">→</span>
+ {#each mapping.skills as skillName}
+ {@const mappedSkill = agentSkills.find((s) => s.name === skillName)}
+ {#if mappedSkill}
+ {@render skillItem(mappedSkill)}
+ {:else}
+ <span class="font-mono text-base-content/60">{skillName}</span>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ {:else}
+ {#each agentSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ {#if projectDirSkills.length > 0}
+ <div class="ml-2 mb-2">
+ <div class="text-base-content/50 mb-1">project/</div>
+ <div class="ml-2">
+ {#each projectDirSkills as skill}
+ {@render skillItem(skill)}
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ {/if}
+ {/snippet}
+
+ {@render scopeSection("Global", globalSkills, "global")}
+ {@render scopeSection("Project", projectSkills, "project")}
+ {/if}
+ </div>
+</details>
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
new file mode 100644
index 0000000..5f2ffe2
--- /dev/null
+++ b/packages/frontend/src/lib/components/TaskListPanel.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+ interface TaskItem {
+ id: string;
+ title: string;
+ description: string;
+ status: "pending" | "in_progress" | "done" | "blocked";
+ }
+
+ const { tasks }: { tasks: TaskItem[] } = $props();
+
+ const doneCount = $derived(tasks.filter((t) => t.status === "done").length);
+ const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
+
+ function badgeClass(status: TaskItem["status"]): string {
+ switch (status) {
+ case "pending":
+ return "badge badge-ghost badge-xs";
+ case "in_progress":
+ return "badge badge-info badge-xs";
+ case "done":
+ return "badge badge-success badge-xs";
+ case "blocked":
+ return "badge badge-warning badge-xs";
+ }
+ }
+
+ function statusIcon(status: TaskItem["status"]): string {
+ switch (status) {
+ case "pending":
+ return "⏳";
+ case "in_progress":
+ return "▶";
+ case "done":
+ return "✓";
+ case "blocked":
+ return "⚠";
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ {#if tasks.length === 0}
+ <p class="text-xs text-base-content/50">No tasks yet.</p>
+ {:else}
+ <p class="text-xs text-base-content/60">
+ {tasks.length} task{tasks.length !== 1 ? "s" : ""}
+ ({doneCount} done, {inProgressCount} in progress)
+ </p>
+ <ul class="flex flex-col gap-1">
+ {#each tasks as task (task.id)}
+ <li class="flex flex-col gap-0.5 rounded p-1.5 hover:bg-base-200 transition-colors">
+ <div class="flex items-center gap-1.5">
+ <span class={badgeClass(task.status)}>
+ {statusIcon(task.status)}
+ </span>
+ <span
+ class="text-sm leading-tight {task.status === 'in_progress'
+ ? 'font-bold'
+ : 'font-medium'}"
+ >
+ {task.title}
+ </span>
+ </div>
+ {#if task.description}
+ <p class="text-xs text-base-content/60 line-clamp-2 pl-5">{task.description}</p>
+ {/if}
+ <p class="text-xs text-base-content/30 pl-5 font-mono">{task.id}</p>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+</div>
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 3626bbe..fcf6f92 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -52,6 +52,8 @@ export type AgentEvent =
toolResult: { toolCallId: string; result: string; isError: boolean };
}
| { type: "error"; error: string }
+ | { type: "task-list-update"; tasks: TaskItem[] }
+ | { type: "config-reload" }
| {
type: "done";
message: {
@@ -64,6 +66,13 @@ export type AgentEvent =
| { type: "permission-prompt"; pending: PermissionPrompt[] }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" };
+export interface TaskItem {
+ id: string;
+ title: string;
+ description: string;
+ status: "pending" | "in_progress" | "done" | "blocked";
+}
+
export interface PermissionPrompt {
id: string;
permission: string;