From adc8bd185b54935e7a31aae04da3175b7989927a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 20 May 2026 15:04:26 +0900 Subject: feat: phase 3 — config, skills, model groups, task list, and sidebar UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 1 + bun.lock | 12 +- packages/api/src/agent-manager.ts | 138 ++++++++- packages/api/src/app.ts | 7 + packages/api/src/index.ts | 4 + packages/api/src/routes/config.ts | 27 ++ packages/api/src/routes/models.ts | 69 +++++ packages/api/src/routes/skills.ts | 43 +++ packages/api/tests/agent-manager.test.ts | 43 +++ packages/api/tests/routes.test.ts | 43 +++ packages/core/package.json | 2 + packages/core/src/config/index.ts | 3 + packages/core/src/config/loader.ts | 119 ++------ packages/core/src/config/schema.ts | 235 ++++++++++++++++ packages/core/src/config/watcher.ts | 53 ++++ packages/core/src/index.ts | 17 +- packages/core/src/models/index.ts | 2 + packages/core/src/models/registry.ts | 134 +++++++++ packages/core/src/models/resolver.ts | 88 ++++++ packages/core/src/skills/index.ts | 7 + packages/core/src/skills/loader.ts | 310 +++++++++++++++++++++ packages/core/src/skills/parser.ts | 62 +++++ packages/core/src/tools/task-list.ts | 148 ++++++++++ packages/core/src/types/index.ts | 108 ++++++- packages/core/tests/config/loader.test.ts | 48 ++-- packages/frontend/src/App.svelte | 110 +++++++- packages/frontend/src/lib/chat.svelte.ts | 21 +- .../frontend/src/lib/components/ConfigPanel.svelte | 244 ++++++++++++++++ packages/frontend/src/lib/components/Header.svelte | 10 + .../src/lib/components/HotReloadIndicator.svelte | 35 +++ .../frontend/src/lib/components/ModelStatus.svelte | 135 +++++++++ .../src/lib/components/SkillsBrowser.svelte | 234 ++++++++++++++++ .../src/lib/components/TaskListPanel.svelte | 72 +++++ packages/frontend/src/lib/types.ts | 9 + 34 files changed, 2449 insertions(+), 144 deletions(-) create mode 100644 packages/api/src/routes/config.ts create mode 100644 packages/api/src/routes/models.ts create mode 100644 packages/api/src/routes/skills.ts create mode 100644 packages/core/src/config/index.ts create mode 100644 packages/core/src/config/schema.ts create mode 100644 packages/core/src/config/watcher.ts create mode 100644 packages/core/src/models/index.ts create mode 100644 packages/core/src/models/registry.ts create mode 100644 packages/core/src/models/resolver.ts create mode 100644 packages/core/src/skills/index.ts create mode 100644 packages/core/src/skills/loader.ts create mode 100644 packages/core/src/skills/parser.ts create mode 100644 packages/core/src/tools/task-list.ts create mode 100644 packages/frontend/src/lib/components/ConfigPanel.svelte create mode 100644 packages/frontend/src/lib/components/HotReloadIndicator.svelte create mode 100644 packages/frontend/src/lib/components/ModelStatus.svelte create mode 100644 packages/frontend/src/lib/components/SkillsBrowser.svelte create mode 100644 packages/frontend/src/lib/components/TaskListPanel.svelte 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": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -405,7 +407,7 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "rollup": ["rollup@4.60.4", "", { "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": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -484,5 +488,9 @@ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "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>; -} - -// 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> = {}; - 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)[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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + 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 { + return typeof value === "string" || isStringRecord(value); +} + +function validatePermissions( + raw: unknown, + path: string, + errors: ConfigError[], +): Record> { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return {}; + } + const result: Record> = {}; + 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 | 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 | 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; + 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, + ): void { + this.models = [...models]; + this.fallbackOrder = this._buildFallbackOrder(keys, fallbackOrder); + + const newStates = new Map(); + 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(); + 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 { + 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((resolve) => { + let timer: ReturnType | 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(); + 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 | 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): Promise => { + 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, context?: ToolExecuteContext) => Promise; } -// 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; + models?: ModelDefinition[]; + keys?: KeyDefinition[]; + fallback?: string[]; + permissions: Record>; +} + +export interface AgentTemplate { + name: string; + description: string; + system_prompt: string; + tools: string[]; + permissions: Record>; + 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; 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; - 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; - 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; 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; - 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; 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; - 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(() => {
-
-
- +
sidebarOpen = !sidebarOpen} /> + +
+ +
+
+ +
+ +
+ + +
+
+
+ +
Model Status
+
+ +
+
+ +
+ +
Tasks
+
+ +
+
+ + + + + + +
+
-
+ chatStore.replyPermission(id, reply)} /> -
- + +
+
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 @@ + + +
+ + Configuration + {#if modelCount > 0 || keyCount > 0} + {modelCount} models + {keyCount} keys + {/if} + {#if loading} + + {/if} + + +
+ {#if error} +
+ Failed to load config: {error} +
+ {/if} + +
+ +
+ + + {#if configData?.agents && Object.keys(configData.agents).length > 0} +
+
Agent Templates
+ {#each Object.entries(configData.agents) as [name, template]} +
+
+ {name} + {#if template.model_tag} + {template.model_tag} + {/if} +
+ {#if template.description} +

{template.description}

+ {/if} + {#if template.tools && template.tools.length > 0} +
+ {#each template.tools as tool} + {tool} + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + + + {#if modelsData?.models && modelsData.models.length > 0} +
+
Models
+ {#each modelsData.models as model} +
+ {model.id} + {#if model.provider} + {model.provider} + {/if} + {#if model.tags && model.tags.length > 0} + {#each model.tags as tag} + {tag} + {/each} + {/if} +
+ {/each} +
+ {/if} + + + {#if modelsData?.keys && modelsData.keys.length > 0} +
+
API Keys
+ {#each modelsData.keys as key} +
+
+ {key.id} + {#if key.provider} + {key.provider} + {/if} + {#if key.status === "exhausted"} + exhausted + {:else} + active + {/if} +
+ {#if key.status === "exhausted" && key.lastError} +

{key.lastError}

+ {/if} + {#if key.exhaustedAt} +

Since {formatDate(key.exhaustedAt)}

+ {/if} +
+ {/each} +
+ {/if} + + + {#if configData?.fallback && configData.fallback.length > 0} +
+
Fallback Order
+
    + {#each configData.fallback as keyId, i} +
  1. + {i + 1} + {keyId} +
  2. + {/each} +
+
+ {/if} + + + {#if configData?.permissions && Object.keys(configData.permissions).length > 0} +
+
Permissions
+ {#each permissionEntries(configData.permissions) as entry} +
+ {entry.name} + {#if isSimpleRule(entry.value)} + {entry.value.action} + {:else if isPatternRule(entry.value)} + {#each Object.entries(entry.value) as [pattern, rule]} +
+ {pattern} + {#if typeof rule === "object" && rule !== null && "action" in rule} + {(rule as { action: string }).action} + {/if} +
+ {/each} + {:else} + {JSON.stringify(entry.value)} + {/if} +
+ {/each} +
+ {/if} + + {#if !loading && !error && !configData && !modelsData} +

No configuration loaded.

+ {/if} +
+
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 +
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 @@ + + +{#if visible} +
+ + Config reloaded +
+{/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 @@ + + +
+ {#if models.length === 0 && keys.length === 0} +

+ No models configured. Using environment defaults. +

+ {:else} + + {#if allActive} +
+ + All keys available +
+ {:else if allExhausted} +
+ + All keys exhausted — waiting for refresh +
+ {:else if someExhausted} +
+ + + Fallback active ({activeKeys}/{totalKeys} keys available) + +
+ {/if} + + + {#if currentModel} +
+

Current Model

+

{currentModel}

+
+ {/if} + + + {#if uniqueTags.length > 0} +
+

Tags

+
+ {#each uniqueTags as tag (tag)} + {tag} + {/each} +
+
+ {/if} + + + {#if keys.length > 0} +
+

API Keys

+
    + {#each keys as key (key.id)} +
  • +
    + + {key.status} + + {key.id} + {key.provider} +
    + {#if key.status === "exhausted"} +
    + {#if key.lastError} +

    + {truncate(key.lastError, 80)} +

    + {/if} + {#if key.exhaustedAt !== null} +

    {timeAgo(key.exhaustedAt)}

    + {/if} +
    + {/if} +
  • + {/each} +
+
+ {/if} + {/if} +
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 @@ + + +
+ + Skills + {#if !loading} + {skills.length} + {/if} + + +
+ {#if loading} +
+ + Loading skills... +
+ {:else if error} +
{error}
+ {:else if skills.length === 0} +

+ No skills found. Create a .skills/default/ directory to get started. +

+ {:else} + {#snippet skillItem(skill: Skill)} + {@const key = `${skill.scope}:${skill.name}`} + {@const isExpanded = key in expandedSkills} + {@const detail = expandedSkills[key]} + {@const isLoading = loadingSkill[key]} +
+
+ + {#if isLoading} + + {/if} + {#each skill.tags as tag} + {tag} + {/each} +
+ {#if skill.description} +

{skill.description}

+ {/if} + {#if isExpanded} +
+ {#if detail} +
{detail.content}
+ {:else} +

Failed to load skill content.

+ {/if} + +
+ {/if} +
+ {/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)} +
+
+ {label} + {scope} +
+ + {#if defaultSkills.length > 0} +
+
default/
+
+ {#each defaultSkills as skill} + {@render skillItem(skill)} + {/each} +
+
+ {/if} + + {#if agentSkills.length > 0 || scopeMappings.length > 0} +
+
agents/
+
+ {#if scopeMappings.length > 0} + {#each scopeMappings as mapping} +
+
+ {mapping.agentType} + {#if mapping.isOrchestrator} + (orchestrator) + {/if} + + {#each mapping.skills as skillName} + {@const mappedSkill = agentSkills.find((s) => s.name === skillName)} + {#if mappedSkill} + {@render skillItem(mappedSkill)} + {:else} + {skillName} + {/if} + {/each} +
+
+ {/each} + {:else} + {#each agentSkills as skill} + {@render skillItem(skill)} + {/each} + {/if} +
+
+ {/if} + + {#if projectDirSkills.length > 0} +
+
project/
+
+ {#each projectDirSkills as skill} + {@render skillItem(skill)} + {/each} +
+
+ {/if} +
+ {/if} + {/snippet} + + {@render scopeSection("Global", globalSkills, "global")} + {@render scopeSection("Project", projectSkills, "project")} + {/if} +
+
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 @@ + + +
+ {#if tasks.length === 0} +

No tasks yet.

+ {:else} +

+ {tasks.length} task{tasks.length !== 1 ? "s" : ""} + ({doneCount} done, {inProgressCount} in progress) +

+
    + {#each tasks as task (task.id)} +
  • +
    + + {statusIcon(task.status)} + + + {task.title} + +
    + {#if task.description} +

    {task.description}

    + {/if} +

    {task.id}

    +
  • + {/each} +
+ {/if} +
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; -- cgit v1.2.3