diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
| commit | adc8bd185b54935e7a31aae04da3175b7989927a (patch) | |
| tree | 031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/api | |
| parent | 52af4ca33b1f83b8f863a6267d447944f55e729d (diff) | |
| download | dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip | |
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar
- Model/key resolution: tag-based model selection, key fallback chains
- Skills system: directory loader with TOML frontmatter, agent mappings
- Task list tool: add/update/list/get operations with WebSocket events
- API routes: GET /config, /skills, /skills/:name, /models, /models/resolve
- Frontend: sidebar with model status, task list, config viewer, skills browser, permission log
- Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 138 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 7 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/api/src/routes/config.ts | 27 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 69 | ||||
| -rw-r--r-- | packages/api/src/routes/skills.ts | 43 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 43 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 43 |
8 files changed, 369 insertions, 5 deletions
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"); |
