diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
| commit | fb97d4cb72d0a90dde102b7001603716ee6e4c3b (patch) | |
| tree | 55c6e8b56b3395008523ab94c16c4f526083d846 | |
| parent | 7884709e3b2adb1b65c1c086257e0300eed51cee (diff) | |
| download | dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.tar.gz dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.zip | |
feat: agent summoning system, todo improvements, security fixes, double-execution bug fix
- Add summon/retrieve tools for spawning child agents in new tabs
- summon: non-blocking, returns agent_id immediately
- retrieve: blocking, waits for child to finish, returns result
- Child tools are intersected with parent permissions (no privilege escalation)
- Working directory validated to stay within workspace
- Abort controller stops orphaned agents on tab close
- Rename task_list tool to todo with comprehensive usage guidance in system prompt
- Rename PermissionLog.svelte to ToolPermissions.svelte
- Add 'Summon agents' toggle to tool permissions UI
- Redesign TaskListPanel with DaisyUI checkboxes (indeterminate for in-progress)
- Remove 'blocked' status from task system
- Add tab-created WebSocket event for child agent tab visibility
- Add HMR cleanup for WebSocket connections (close stale connections on hot reload)
- Fix ensureAssistantMessage to not throw on closed tabs
- Fix double tool execution: remove execute from AI SDK tool() in registry.ts
(agent.ts already executes tools manually via executeToolWithStreaming)
- Fix all pre-existing test failures (missing mocks, stale API signatures)
- Add debug info to copy button (tab ID, injected skills, all tab IDs)
- Add tab ID and tools to conversation copy output
| -rw-r--r-- | packages/api/src/agent-manager.ts | 401 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 111 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 114 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 74 | ||||
| -rw-r--r-- | packages/core/src/tools/registry.ts | 9 | ||||
| -rw-r--r-- | packages/core/src/tools/retrieve.ts | 41 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 88 | ||||
| -rw-r--r-- | packages/core/src/tools/task-list.ts | 15 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 14 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 120 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TaskListPanel.svelte | 103 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolPermissions.svelte (renamed from packages/frontend/src/lib/components/PermissionLog.svelte) | 21 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 36 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/ws.svelte.ts | 15 |
17 files changed, 921 insertions, 251 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 889142d..e1d9bad 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -1,46 +1,94 @@ import { Agent, type AgentEvent, - type AgentStatus, - type DispatchConfig, type AgentSkillMapping, - type SkillDefinition, + type AgentStatus, + appendMessage, + type ClaudeAccount, + configToRuleset, + createConfigWatcher, createListFilesTool, createReadFileTool, + createRetrieveTool, createRunShellTool, + createSkillsWatcher, + createSummonTool, + createTaskListTool, createWriteFileTool, + type DispatchConfig, + getClaudeAccountsFromDB, + getSetting, loadConfig, - configToRuleset, - validateConfig, - createConfigWatcher, loadSkills, - createSkillsWatcher, ModelRegistry, - TaskList, - createTaskListTool, - type ClaudeAccount, - appendMessage, - getClaudeAccountsFromDB, refreshAccountCredentials, refreshAccountCredentialsAsync, resolveApiKey, - getSetting, + type SkillDefinition, + TaskList, + validateConfig, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; +import { setAccountsGetter, setModelsGetter } from "./routes/models.js"; import { setSkillsGetter } from "./routes/skills.js"; -import { setModelsGetter, setAccountsGetter } from "./routes/models.js"; import { setTabsAgentManager } from "./routes/tabs.js"; const TOOL_DESCRIPTIONS: Record<string, string> = { read_file: "Read the contents of a file", list_files: "List files and directories", write_file: "Write content to a file (creates parent directories if needed)", - 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.", + 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.", + todo: "Manage a todo list for planning and tracking work. Actions: add, update, list, get, remove. Statuses: pending, in_progress, done.", + summon: + "Spawn a child agent to work on a task independently. Returns an agent_id immediately (non-blocking). Use retrieve to collect the result later.", + retrieve: + "Wait for a child agent to finish and get its result (blocking). Pass the agent_id from summon.", }; -const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; +const DEFAULT_SYSTEM_PROMPT = + "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; + +const TODO_GUIDANCE = ` +## Todo List + +The user can see your todo list in real-time. Use it to communicate your plan and progress. + +### When to use +- Tasks that require 3 or more steps +- When the user provides multiple things to do +- Complex work that benefits from planning before starting +- After receiving new instructions, capture them as todos immediately + +### When NOT to use +- Single, straightforward tasks that need no tracking +- Purely conversational or informational responses +- Anything completable in under 3 trivial steps + +### State management +- Only ONE item should be "in_progress" at a time. Finish current work before starting the next item. +- Mark items "done" IMMEDIATELY after completing them. Do not batch completions. +- When starting work on an item, mark it "in_progress" first. +- Add new items as you discover sub-tasks during execution. + +### Examples + +User: "Run the build and fix any type errors" +Good approach: +1. Add todo: "Run the build" -> mark in_progress -> run build -> mark done +2. If 5 errors found, add 5 todos for each error +3. Work through each one sequentially, marking in_progress then done + +User: "What does the git status command do?" +No todo needed — this is a simple informational question. + +User: "Rename the function getUser to fetchUser across the project" +Good approach: +1. Add todo: "Search for all occurrences of getUser" +2. After searching, add a todo per file that needs changes +3. Work through each file sequentially +`.trim(); function buildSystemPrompt(toolNames: string[], basePrompt?: string): string { const base = basePrompt || DEFAULT_SYSTEM_PROMPT; @@ -50,7 +98,13 @@ function buildSystemPrompt(toolNames: string[], basePrompt?: string): string { .join("\n"); if (!toolList) return base; - return `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`; + + const hasTodo = toolNames.includes("todo"); + let prompt = `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`; + if (hasTodo) { + prompt += `\n\n${TODO_GUIDANCE}`; + } + return prompt; } interface TabAgent { @@ -60,6 +114,21 @@ interface TabAgent { modelId: string | null; taskList: TaskList; _lastPermKey?: string; + /** Abort controller for cancelling a running agent. */ + abortController?: AbortController; + /** For child agents: resolves when the agent finishes its task. */ + completionResolve?: ( + result: { status: "done"; result: string } | { status: "error"; error: string }, + ) => void; + completionPromise?: Promise< + { status: "done"; result: string } | { status: "error"; error: string } + >; + /** Accumulated final text output from the child agent. */ + finalOutput?: string; + /** Tools whitelist for child agents (set by summon). */ + toolsOverride?: string[]; + /** Working directory override for child agents. */ + workingDirectoryOverride?: string; } export class AgentManager { @@ -103,9 +172,7 @@ export class AgentManager { // Wire route getters setConfigGetter(() => this.config); setSkillsGetter(() => this.skillsData); - setModelsGetter( - () => this.modelRegistry, - ); + setModelsGetter(() => this.modelRegistry); setAccountsGetter(() => this.claudeAccounts); setTabsAgentManager(() => this); @@ -150,7 +217,9 @@ export class AgentManager { console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`); } } catch (err) { - console.warn(`dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -199,44 +268,121 @@ export class AgentManager { return tabAgent; } - private async getOrCreateAgentForTab(tabId: string, keyId?: string, modelId?: string): Promise<Agent> { + private async getOrCreateAgentForTab( + tabId: string, + keyId?: string, + modelId?: string, + ): Promise<Agent> { const tabAgent = this._getOrCreateTabAgent(tabId); // Determine effective override: use provided values, or fall back to stored per-tab values const effectiveKeyId = keyId ?? tabAgent.keyId; const effectiveModelId = modelId ?? tabAgent.modelId; - // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask) + // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask) const permRead = getSetting("perm_read") !== "ask"; const permEdit = getSetting("perm_edit") === "allow"; const permBash = getSetting("perm_bash") === "allow"; + const permSummon = getSetting("perm_summon") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( tabAgent.agent && - (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId || permKey !== tabAgent._lastPermKey) + (effectiveKeyId !== tabAgent.keyId || + effectiveModelId !== tabAgent.modelId || + permKey !== tabAgent._lastPermKey) ) { tabAgent.agent = null; } if (!tabAgent.agent) { - const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + const workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir; - // Build tools list based on permission settings + // Build tools list — child agents use their toolsOverride whitelist, + // parent agents use permission settings from DB const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = []; - if (permRead) { - toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); - toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); - } - if (permEdit) { - toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); - } - if (permBash) { - toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + + if (tabAgent.toolsOverride) { + // Child agent: use explicit tool whitelist + const allowed = new Set(tabAgent.toolsOverride); + if (allowed.has("read_file")) { + toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); + // list_files is bundled with read access + if (allowed.has("list_files")) { + toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); + } + } + if (allowed.has("list_files") && !allowed.has("read_file")) { + toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); + } + if (allowed.has("write_file")) { + toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + } + if (allowed.has("run_shell")) { + toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + } + if (allowed.has("todo")) { + toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); + } + if (allowed.has("summon")) { + const childParentAllowedTools = new Set(toolEntries.map((e) => e.name)); + toolEntries.push({ + name: "summon", + tool: createSummonTool(workingDirectory, { + spawn: (opts) => + this.spawnChildAgent({ + ...opts, + parentKeyId: tabAgent.keyId, + parentModelId: tabAgent.modelId, + parentAllowedTools: childParentAllowedTools, + }), + }), + }); + } + if (allowed.has("retrieve")) { + toolEntries.push({ + name: "retrieve", + tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }), + }); + } + } else { + // Parent agent: use permission settings from DB + if (permRead) { + toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); + toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); + } + if (permEdit) { + toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + } + if (permBash) { + toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + } + toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); + if (permSummon) { + // Capture parent's allowed tool names for child permission enforcement + const parentAllowedTools = new Set(toolEntries.map((e) => e.name)); + toolEntries.push({ + name: "summon", + tool: createSummonTool(workingDirectory, { + spawn: (opts) => + this.spawnChildAgent({ + ...opts, + parentKeyId: tabAgent.keyId, + parentModelId: tabAgent.modelId, + parentAllowedTools, + }), + }), + }); + toolEntries.push({ + name: "retrieve", + tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }), + }); + } } - toolEntries.push({ name: "task_list", tool: createTaskListTool(tabAgent.taskList) }); + const tools = toolEntries.map((e) => e.tool); const toolNames = toolEntries.map((e) => e.name); tabAgent._lastPermKey = permKey; @@ -254,14 +400,19 @@ export class AgentManager { if (effectiveKeyId && effectiveModelId && this.modelRegistry) { // Direct override: look up the key by id in the registry - const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === effectiveKeyId); + const keyState = this.modelRegistry + .getKeys() + .find((k) => k.definition.id === effectiveKeyId); if (keyState) { const key = keyState.definition; if (key.provider === "anthropic") { // Anthropic provider: resolve credentials from Claude accounts const credFile = key.credentials_file; - const account = this.claudeAccounts.find((a) => a.id === effectiveKeyId) - ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]); + const account = + this.claudeAccounts.find((a) => a.id === effectiveKeyId) ?? + (credFile + ? this.claudeAccounts.find((a) => a.source === credFile) + : this.claudeAccounts[0]); if (account) { const creds = refreshAccountCredentials(account); if (creds && creds.expiresAt > Date.now() + 60_000) { @@ -287,7 +438,9 @@ export class AgentManager { tabAgent.modelId = effectiveModelId; useOverride = true; } else { - console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`); + console.warn( + `dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`, + ); claudeCredentials = { accessToken: account.credentials.accessToken }; apiKey = account.credentials.accessToken; baseURL = key.base_url; @@ -312,7 +465,9 @@ export class AgentManager { tabAgent.modelId = effectiveModelId; useOverride = true; } else { - console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`); + console.warn( + `dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`, + ); tabAgent.keyId = effectiveKeyId; tabAgent.modelId = effectiveModelId; useOverride = true; @@ -387,8 +542,11 @@ export class AgentManager { stopTab(tabId: string): void { const tabAgent = this.tabAgents.get(tabId); if (tabAgent) { + tabAgent.abortController?.abort(); tabAgent.status = "idle"; tabAgent.agent = null; + // Resolve any pending completion promise so retrieve doesn't hang + tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." }); } } @@ -397,8 +555,113 @@ export class AgentManager { this.tabAgents.delete(tabId); } - async processMessage(tabId: string, message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> { + /** + * Spawn a child agent in a new tab. Returns the tab ID (agent_id). + * The child runs asynchronously — use getChildResult to await completion. + */ + async spawnChildAgent(options: { + task: string; + tools: string[]; + workingDirectory?: string; + parentKeyId?: string | null; + parentModelId?: string | null; + parentAllowedTools?: Set<string>; + }): Promise<string> { + const tabId = crypto.randomUUID(); + const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task; + + // Validate working directory is within the parent's workspace + const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + if (options.workingDirectory) { + const { resolve } = await import("node:path"); + const resolved = resolve(options.workingDirectory); + const parentDir = resolve(defaultWorkDir); + if (!resolved.startsWith(`${parentDir}/`) && resolved !== parentDir) { + throw new Error( + `Working directory "${options.workingDirectory}" is outside the workspace "${parentDir}".`, + ); + } + } + + // Intersect requested tools with parent's allowed tools to prevent privilege escalation + let childTools = options.tools; + if (options.parentAllowedTools) { + childTools = options.tools.filter((t) => options.parentAllowedTools!.has(t)); + } + + // Create the tab agent entry with overrides const tabAgent = this._getOrCreateTabAgent(tabId); + tabAgent.toolsOverride = childTools; + tabAgent.workingDirectoryOverride = options.workingDirectory; + tabAgent.keyId = options.parentKeyId ?? null; + tabAgent.modelId = options.parentModelId ?? null; + tabAgent.finalOutput = ""; + + // Set up completion tracking + tabAgent.completionPromise = new Promise((resolve) => { + tabAgent.completionResolve = resolve; + }); + + // Create tab in DB + try { + const { createTab } = await import("@dispatch/core"); + createTab(tabId, title); + } catch { + // Continue even if DB fails + } + + // Notify the frontend about the new tab + this.emit({ type: "tab-created", id: tabId, title }, tabId); + + // Start the child agent in the background + this.processMessage( + tabId, + options.task, + options.parentKeyId ?? undefined, + options.parentModelId ?? undefined, + ).catch((err) => { + const errorMsg = err instanceof Error ? err.message : String(err); + tabAgent.completionResolve?.({ status: "error", error: errorMsg }); + }); + + return tabId; + } + + /** + * Wait for a child agent to finish and return its result. + * Blocks until the child completes or errors. + */ + async getChildResult( + agentId: string, + ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { + const tabAgent = this.tabAgents.get(agentId); + if (!tabAgent) { + return { status: "error", error: `No agent found with id '${agentId}'` }; + } + + if (!tabAgent.completionPromise) { + // Not a child agent or already completed + if (tabAgent.status === "idle") { + return { status: "done", result: tabAgent.finalOutput ?? "(no output)" }; + } + return { + status: "error", + error: "Agent has no completion tracking. It may not have been spawned via summon.", + }; + } + + return tabAgent.completionPromise; + } + + async processMessage( + tabId: string, + message: string, + keyId?: string, + modelId?: string, + reasoningEffort?: "none" | "low" | "medium" | "high" | "max", + ): Promise<void> { + const tabAgent = this._getOrCreateTabAgent(tabId); + tabAgent.abortController = new AbortController(); tabAgent.status = "running"; this.messageCount += 1; @@ -406,13 +669,31 @@ export class AgentManager { const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId); // Persist user message to DB - appendMessage(tabId, crypto.randomUUID(), "user", JSON.stringify([{ type: "text", text: message }])); - + appendMessage( + tabId, + crypto.randomUUID(), + "user", + JSON.stringify([{ type: "text", text: message }]), + ); + + let allOutput = ""; let assistantText = ""; let assistantThinking = ""; - const assistantToolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown>; result?: string; isError?: boolean }> = []; + const assistantToolCalls: Array<{ + id: string; + name: string; + arguments: Record<string, unknown>; + result?: string; + isError?: boolean; + }> = []; + + for await (const event of agent.run( + message, + reasoningEffort ? { reasoningEffort } : undefined, + )) { + // Stop processing if the tab was aborted (closed/stopped) + if (tabAgent.abortController?.signal.aborted) break; - for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) { if (event.type === "status") { tabAgent.status = event.status; } @@ -421,13 +702,21 @@ export class AgentManager { // Accumulate content for DB persistence if (event.type === "text-delta") { assistantText += event.delta; + allOutput += event.delta; } else if (event.type === "reasoning-delta") { assistantThinking += event.delta; } else if (event.type === "tool-call") { - assistantToolCalls.push({ id: event.toolCall.id, name: event.toolCall.name, arguments: event.toolCall.arguments }); + assistantToolCalls.push({ + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }); } else if (event.type === "tool-result") { const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId); - if (tc) { tc.result = event.toolResult.result; tc.isError = event.toolResult.isError; } + if (tc) { + tc.result = event.toolResult.result; + tc.isError = event.toolResult.isError; + } } else if (event.type === "done") { // Persist assistant message to DB const contentSegments: Array<Record<string, unknown>> = []; @@ -436,7 +725,13 @@ export class AgentManager { contentSegments.push({ type: "tool-call", ...tc }); } if (contentSegments.length > 0) { - appendMessage(tabId, crypto.randomUUID(), "assistant", JSON.stringify(contentSegments), assistantThinking || undefined); + appendMessage( + tabId, + crypto.randomUUID(), + "assistant", + JSON.stringify(contentSegments), + assistantThinking || undefined, + ); } // Reset for next turn assistantText = ""; @@ -444,11 +739,15 @@ export class AgentManager { assistantToolCalls.length = 0; } } + // Resolve completion promise for child agents + tabAgent.finalOutput = allOutput; + tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); tabAgent.status = "error"; this.emit({ type: "error", error: errorMsg }, tabId); this.emit({ type: "status", status: "error" }, tabId); + tabAgent.completionResolve?.({ status: "error", error: errorMsg }); } } diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 1a9f1e2..f8e5e12 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -78,36 +78,97 @@ vi.mock("@dispatch/core", () => ({ return { close() {} }; }, ModelRegistry: class MockModelRegistry { - getModels() { return []; } - getKeys() { return []; } - getModelsByTag(_tag: string) { return []; } - getAllTags() { return []; } - hasAvailableKey(_provider: string) { return false; } - allKeysExhausted() { return true; } + 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); } + 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 () => {}; } + 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", + name: "todo", + description: "todo", parameters: { _type: "z.ZodObject", shape: {} }, execute: async () => "mock", }; }, + createSummonTool(_wd: string, _callbacks: unknown) { + return { + name: "summon", + description: "summon", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createRetrieveTool(_callbacks: unknown) { + return { + name: "retrieve", + description: "retrieve", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createTab() {}, + getClaudeAccountsFromDB() { + return []; + }, + refreshAccountCredentials() { + return null; + }, + refreshAccountCredentialsAsync() { + return Promise.resolve(null); + }, + resolveApiKey() { + return null; + }, + getSetting(_key: string) { + return null; + }, + appendMessage() {}, })); // Import after mock is defined (Vitest hoists vi.mock automatically) @@ -131,13 +192,13 @@ describe("AgentManager", () => { events.push(event); }); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(events.length).toBeGreaterThan(0); - expect(events[0]).toEqual({ type: "status", status: "running" }); + expect(events[0]).toMatchObject({ type: "status", status: "running" }); const lastEvent = events[events.length - 1]; - expect(lastEvent).toEqual({ type: "status", status: "idle" }); + expect(lastEvent).toMatchObject({ type: "status", status: "idle" }); const doneEvent = events.find((e) => e.type === "done"); expect(doneEvent).toBeDefined(); @@ -150,7 +211,7 @@ describe("AgentManager", () => { events.push(event); }); - await manager.processMessage("hello"); + await manager.processMessage("tab-1", "hello"); const textDeltas = events.filter((e) => e.type === "text-delta"); expect(textDeltas.length).toBeGreaterThan(0); @@ -158,15 +219,15 @@ describe("AgentManager", () => { it("messageCount increments after processMessage", async () => { const manager = new AgentManager(); - await manager.processMessage("hello"); + await manager.processMessage("tab-1", "hello"); expect(manager.getMessageCount()).toBe(1); - await manager.processMessage("world"); + await manager.processMessage("tab-1", "world"); expect(manager.getMessageCount()).toBe(2); }); it("status returns to idle after processMessage completes", async () => { const manager = new AgentManager(); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(manager.getStatus()).toBe("idle"); }); @@ -178,7 +239,7 @@ describe("AgentManager", () => { }); unsubscribe(); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(events.length).toBe(0); }); @@ -191,7 +252,7 @@ describe("AgentManager", () => { manager.onEvent(listener1); manager.onEvent(listener2); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(listener1).toHaveBeenCalled(); expect(listener2).toHaveBeenCalled(); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 5ecfcb0..05f8358 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -79,36 +79,97 @@ vi.mock("@dispatch/core", () => ({ return { close() {} }; }, ModelRegistry: class MockModelRegistry { - getModels() { return []; } - getKeys() { return []; } - getModelsByTag(_tag: string) { return []; } - getAllTags() { return []; } - hasAvailableKey(_provider: string) { return false; } - allKeysExhausted() { return true; } + 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); } + 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 () => {}; } + 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", + name: "todo", + description: "todo", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createSummonTool(_wd: string, _callbacks: unknown) { + return { + name: "summon", + description: "summon", parameters: { _type: "z.ZodObject", shape: {} }, execute: async () => "mock", }; }, + createRetrieveTool(_callbacks: unknown) { + return { + name: "retrieve", + description: "retrieve", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createTab() {}, + getClaudeAccountsFromDB() { + return []; + }, + refreshAccountCredentials() { + return null; + }, + refreshAccountCredentialsAsync() { + return Promise.resolve(null); + }, + resolveApiKey() { + return null; + }, + getSetting(_key: string) { + return null; + }, + appendMessage() {}, })); const { app } = await import("../src/app.js"); @@ -137,7 +198,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hello world" }), + body: JSON.stringify({ tabId: "tab-1", message: "hello world" }), }); expect(res.status).toBe(200); const body = await res.json(); @@ -148,7 +209,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "" }), + body: JSON.stringify({ tabId: "tab-1", message: "" }), }); expect(res.status).toBe(400); }); @@ -157,7 +218,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: " " }), + body: JSON.stringify({ tabId: "tab-1", message: " " }), }); expect(res.status).toBe(400); }); @@ -166,7 +227,16 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ tabId: "tab-1" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 with missing tabId", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hello" }), }); expect(res.status).toBe(400); }); @@ -176,7 +246,7 @@ describe("POST /chat", () => { await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "first message" }), + body: JSON.stringify({ tabId: "tab-2", message: "first message" }), }); // Small delay to let the async generator start and emit "running" status @@ -186,7 +256,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "second message" }), + body: JSON.stringify({ tabId: "tab-2", message: "second message" }), }); expect(res.status).toBe(409); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3b49de9..c5aa81b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,38 +2,58 @@ // Agent & LLM export { Agent } from "./agent/agent.js"; +// Config +export { + configToRuleset, + createConfigWatcher, + loadConfig, + validateConfig, +} from "./config/index.js"; +// Credentials +export * from "./credentials/index.js"; +// Database +export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js"; +export { + appendMessage, + clearMessagesForTab, + getMessagesForTab, + type MessageRow, + updateMessage, +} from "./db/messages.js"; +export { deleteSetting, getSetting, setSetting } from "./db/settings.js"; +// Tabs & Messages +export { + archiveTab, + createTab, + getTab, + listOpenTabs, + type TabRow, + updateTabModel, + updateTabStatus, + updateTabTitle, +} from "./db/tabs.js"; export { createProvider } from "./llm/provider.js"; - +// Models +export { ModelRegistry } from "./models/index.js"; +export * from "./permission/index.js"; +// Skills +export { + createSkillsWatcher, + getSkillByName, + loadSkills, + parseSkillFile, + resolveSkillsForAgent, +} from "./skills/index.js"; +export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; // Tools export { createListFilesTool } from "./tools/list-files.js"; -export { createRunShellTool } from "./tools/run-shell.js"; -export { analyzeCommand } from "./tools/shell-analyze.js"; -export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createToolRegistry } from "./tools/registry.js"; +export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js"; +export { createRunShellTool } from "./tools/run-shell.js"; +export { analyzeCommand } from "./tools/shell-analyze.js"; +export { createSummonTool, type SummonCallbacks } from "./tools/summon.js"; +export { createTaskListTool, TaskList } from "./tools/task-list.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"; - -// Config -export { loadConfig, configToRuleset, validateConfig, createConfigWatcher } from "./config/index.js"; - -// Skills -export { parseSkillFile, loadSkills, resolveSkillsForAgent, getSkillByName, createSkillsWatcher } from "./skills/index.js"; - -// Models -export { ModelRegistry } from "./models/index.js"; - -// Credentials -export * from "./credentials/index.js"; - -// Database -export { getDatabase, closeDatabase, getDatabasePath } from "./db/index.js"; - -// Tabs & Messages -export { type TabRow, createTab, getTab, listOpenTabs, updateTabTitle, updateTabModel, updateTabStatus, archiveTab } from "./db/tabs.js"; -export { type MessageRow, appendMessage, updateMessage, getMessagesForTab, clearMessagesForTab } from "./db/messages.js"; -export { getSetting, setSetting, deleteSetting } from "./db/settings.js"; diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 4699c93..0c7b110 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -18,15 +18,14 @@ export function createToolRegistry(tools: ToolDefinition[]) { const result: Record<string, ReturnType<typeof tool>> = {}; for (const [name, def] of toolMap) { const schema = def.parameters; + // Do NOT pass execute here — agent.ts handles tool execution + // manually via executeToolWithStreaming. Passing execute would + // cause the AI SDK to auto-execute tools AND agent.ts to execute + // them again, resulting in double execution. const t = tool({ description: def.description, parameters: schema instanceof z.ZodObject ? schema : z.object({}), - execute: async (args) => { - return def.execute(args as Record<string, unknown>); - }, }); - // The AI SDK tool() overloads cause type narrowing issues when - // execute is provided. The runtime value is correct. result[name] = t as unknown as ReturnType<typeof tool>; } return result; diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts new file mode 100644 index 0000000..93f4c89 --- /dev/null +++ b/packages/core/src/tools/retrieve.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +export interface RetrieveCallbacks { + getResult( + agentId: string, + ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>; +} + +export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition { + return { + name: "retrieve", + description: [ + "Wait for a child agent to finish and retrieve its result. This tool BLOCKS until the child completes.", + "", + "Pass the agent_id returned by the summon tool. Once the child finishes, its final output is returned.", + "If the child encountered an error, the error message is returned instead.", + "", + "Typical usage:", + ' 1. summon({ task: "...", tools: [...] }) -> get agent_id', + " 2. ... do other work or summon more agents ...", + ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result', + ].join("\n"), + parameters: z.object({ + agent_id: z.string().describe("The agent_id returned by a previous summon call."), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const agentId = args.agent_id as string; + + try { + const outcome = await callbacks.getResult(agentId); + if (outcome.status === "done") { + return ["<agent_result>", outcome.result, "</agent_result>"].join("\n"); + } + return `Agent error: ${outcome.error}`; + } catch (err) { + return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }; +} diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts new file mode 100644 index 0000000..582b871 --- /dev/null +++ b/packages/core/src/tools/summon.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +export interface SummonCallbacks { + spawn(options: { task: string; tools: string[]; workingDirectory?: string }): Promise<string>; +} + +export function createSummonTool( + defaultWorkingDirectory: string, + callbacks: SummonCallbacks, +): ToolDefinition { + return { + name: "summon", + description: [ + "Spawn a new child agent to work on a task independently. Returns immediately with an agent_id — does NOT wait for the child to finish.", + "", + "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.", + "", + "Pattern for parallel work:", + " 1. Call summon multiple times to start several agents", + " 2. Do your own work or wait", + " 3. Call retrieve for each agent_id to collect results", + "", + "The 'tools' parameter controls what the child can do. Available tool names:", + " - read_file: Read file contents", + " - list_files: List files and directories", + " - write_file: Write/edit files", + " - run_shell: Execute shell commands", + " - todo: Track work items", + " - summon: Spawn its own child agents (enables nesting)", + " - retrieve: Collect results from its children (required if summon is given)", + "", + "If tools is omitted, the child gets read_file, list_files, and todo only (read-only by default).", + ].join("\n"), + parameters: z.object({ + task: z + .string() + .describe( + "Detailed instructions for the child agent. Be specific about what it should do and what it should return.", + ), + tools: z + .array( + z.enum([ + "read_file", + "list_files", + "write_file", + "run_shell", + "todo", + "summon", + "retrieve", + ]), + ) + .optional() + .describe( + 'Tool names to give the child. Defaults to ["read_file", "list_files", "todo"]. Include "summon" and "retrieve" to allow nesting.', + ), + working_directory: z + .string() + .optional() + .describe( + "Absolute path for the child to work in. Defaults to the current working directory.", + ), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const task = args.task as string; + const tools = (args.tools as string[] | undefined) ?? ["read_file", "list_files", "todo"]; + const workingDirectory = + (args.working_directory as string | undefined) ?? defaultWorkingDirectory; + + try { + const agentId = await callbacks.spawn({ + task, + tools, + workingDirectory, + }); + return [ + `Agent spawned successfully.`, + `agent_id: ${agentId}`, + ``, + `The child agent is now working on the task in its own tab.`, + `Use the retrieve tool with this agent_id to get the result when ready.`, + ].join("\n"); + } catch (err) { + return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }; +} diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts index 0bacdb4..29f1543 100644 --- a/packages/core/src/tools/task-list.ts +++ b/packages/core/src/tools/task-list.ts @@ -60,24 +60,19 @@ export class TaskList { export function createTaskListTool(taskList: TaskList): ToolDefinition { return { - name: "task_list", + name: "todo", 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.", + "Manage a todo list for planning and tracking work. Add items, update their status, list all items, or get details on a specific item.", parameters: z.object({ - action: z - .enum(["add", "update", "list", "get", "remove"]) - .describe("The action to perform"), + 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')"), + task_id: z.string().optional().describe("Task ID (required for 'update', 'get', 'remove')"), status: z - .enum(["pending", "in_progress", "done", "blocked"]) + .enum(["pending", "in_progress", "done"]) .optional() .describe("New status (required for 'update')"), }), diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index f5dafbb..bf312ae 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -39,7 +39,8 @@ export type AgentEvent = | { type: "error"; error: string } | { type: "done"; message: ChatMessage } | { type: "task-list-update"; tasks: TaskItem[] } - | { type: "config-reload" }; + | { type: "config-reload" } + | { type: "tab-created"; id: string; title: string }; // ─── Tool Types ────────────────────────────────────────────────── diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index 5be210a..be5272f 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { Agent } from "../../src/agent/agent.js"; import type { AgentConfig } from "../../src/types/index.js"; +// Mock bun:sqlite to avoid Bun-only import in vitest/Node +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => ({})), +})); + +// Mock the credentials module that depends on the DB +vi.mock("../../src/credentials/claude.js", () => ({ + buildBillingHeaderValue: vi.fn(() => ""), + SYSTEM_IDENTITY: "You are a test agent.", +})); + // Mock the ai module's streamText vi.mock("ai", async () => { const actual = await import("ai"); @@ -20,6 +30,8 @@ vi.mock("@ai-sdk/openai-compatible", () => ({ })), })); +const { Agent } = await import("../../src/agent/agent.js"); + function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig { return { model: "test-model", diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 93d528e..a0aeb1d 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -1,64 +1,76 @@ <script lang="ts"> - import ModelSelector from "./ModelSelector.svelte"; - import ModelStatus from "./ModelStatus.svelte"; - import TaskListPanel from "./TaskListPanel.svelte"; - import ConfigPanel from "./ConfigPanel.svelte"; - import SkillsBrowser from "./SkillsBrowser.svelte"; - import PermissionLog from "./PermissionLog.svelte"; - import KeyUsage from "./KeyUsage.svelte"; - import ClaudeReset from "./ClaudeReset.svelte"; - import SettingsPanel from "./SettingsPanel.svelte"; - import SystemPromptPanel from "./SystemPromptPanel.svelte"; - import type { TaskItem, LogEntry, KeyInfo } from "../types.js"; +import type { KeyInfo, LogEntry, TaskItem } from "../types.js"; +import ClaudeReset from "./ClaudeReset.svelte"; +import ConfigPanel from "./ConfigPanel.svelte"; +import KeyUsage from "./KeyUsage.svelte"; +import ModelSelector from "./ModelSelector.svelte"; +import ModelStatus from "./ModelStatus.svelte"; +import SettingsPanel from "./SettingsPanel.svelte"; +import SkillsBrowser from "./SkillsBrowser.svelte"; +import SystemPromptPanel from "./SystemPromptPanel.svelte"; +import TaskListPanel from "./TaskListPanel.svelte"; +import ToolPermissions from "./ToolPermissions.svelte"; - const { - keys = [], - tasks = [], - permissionLog = [], - apiBase = "", - activeKeyId = null, - activeModelId = null, - reasoningEffort = "max", - onKeyChange, - onModelChange, - onReasoningChange, - }: { - keys?: KeyInfo[]; - tasks?: TaskItem[]; - permissionLog?: LogEntry[]; - apiBase?: string; - activeKeyId?: string | null; - activeModelId?: string | null; - reasoningEffort?: string; - onKeyChange: (keyId: string) => void; - onModelChange: (keyId: string, modelId: string) => void; - onReasoningChange: (effort: string) => void; - } = $props(); +const { + keys = [], + tasks = [], + permissionLog = [], + apiBase = "", + activeKeyId = null, + activeModelId = null, + reasoningEffort = "max", + onKeyChange, + onModelChange, + onReasoningChange, +}: { + keys?: KeyInfo[]; + tasks?: TaskItem[]; + permissionLog?: LogEntry[]; + apiBase?: string; + activeKeyId?: string | null; + activeModelId?: string | null; + reasoningEffort?: string; + onKeyChange: (keyId: string) => void; + onModelChange: (keyId: string, modelId: string) => void; + onReasoningChange: (effort: string) => void; +} = $props(); - interface Panel { - id: number; - selected: string; - } +interface Panel { + id: number; + selected: string; +} - let nextId = 0; - let panels = $state<Panel[]>([{ id: nextId++, selected: "Model Choice" }]); +let nextId = 0; +let panels = $state<Panel[]>([{ id: nextId++, selected: "Model Choice" }]); - const viewOptions = ["Select a view", "Model Choice", "Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Tools", "System Prompt", "Settings"]; +const viewOptions = [ + "Select a view", + "Model Choice", + "Key Usage", + "Claude Reset", + "Model Status", + "Tasks", + "Config", + "Skills", + "Tools", + "System Prompt", + "Settings", +]; - function addPanel() { - panels = [...panels, { id: nextId++, selected: "Select a view" }]; - } +function addPanel() { + panels = [...panels, { id: nextId++, selected: "Select a view" }]; +} - function panelClass(selected: string): string { - const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0"; - const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; - return fill ? base + " flex-1" : base; - } +function panelClass(selected: string): string { + const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0"; + const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; + return fill ? base + " flex-1" : base; +} - function contentClass(selected: string): string { - const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; - return fill ? "mt-2 flex-1 min-h-0" : "mt-2"; - } +function contentClass(selected: string): string { + const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; + return fill ? "mt-2 flex-1 min-h-0" : "mt-2"; +} </script> <div class="flex flex-col gap-2"> @@ -115,7 +127,7 @@ {:else if panel.selected === "Skills"} <SkillsBrowser {apiBase} /> {:else if panel.selected === "Tools"} - <PermissionLog entries={permissionLog} {apiBase} /> + <ToolPermissions entries={permissionLog} {apiBase} /> {:else if panel.selected === "System Prompt"} <SystemPromptPanel {apiBase} /> {:else if panel.selected === "Settings"} diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte index 5f2ffe2..d70373f 100644 --- a/packages/frontend/src/lib/components/TaskListPanel.svelte +++ b/packages/frontend/src/lib/components/TaskListPanel.svelte @@ -1,41 +1,45 @@ <script lang="ts"> - interface TaskItem { - id: string; - title: string; - description: string; - status: "pending" | "in_progress" | "done" | "blocked"; - } +interface TaskItem { + id: string; + title: string; + description: string; + status: "pending" | "in_progress" | "done"; +} - const { tasks }: { tasks: TaskItem[] } = $props(); +const { tasks }: { tasks: TaskItem[] } = $props(); - const doneCount = $derived(tasks.filter((t) => t.status === "done").length); - const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length); +const doneCount = $derived(tasks.filter((t) => t.status === "done").length); +const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length); - function badgeClass(status: TaskItem["status"]): string { - switch (status) { - case "pending": - return "badge badge-ghost badge-xs"; - case "in_progress": - return "badge badge-info badge-xs"; - case "done": - return "badge badge-success badge-xs"; - case "blocked": - return "badge badge-warning badge-xs"; - } +function checkboxClass(status: TaskItem["status"]): string { + switch (status) { + case "pending": + return "checkbox checkbox-sm rounded-sm checkbox-secondary"; + case "in_progress": + return "checkbox checkbox-sm rounded-sm checkbox-info"; + case "done": + return "checkbox checkbox-sm rounded-sm checkbox-success"; } +} + +function isChecked(status: TaskItem["status"]): boolean { + return status === "done"; +} - function statusIcon(status: TaskItem["status"]): string { - switch (status) { - case "pending": - return "⏳"; - case "in_progress": - return "▶"; - case "done": - return "✓"; - case "blocked": - return "⚠"; - } +function isIndeterminate(status: TaskItem["status"]): boolean { + return status === "in_progress"; +} + +function statusLabel(status: TaskItem["status"]): string { + switch (status) { + case "pending": + return "Pending"; + case "in_progress": + return "In progress"; + case "done": + return "Done"; } +} </script> <div class="flex flex-col gap-2"> @@ -43,28 +47,35 @@ <p class="text-xs text-base-content/50">No tasks yet.</p> {:else} <p class="text-xs text-base-content/60"> - {tasks.length} task{tasks.length !== 1 ? "s" : ""} - ({doneCount} done, {inProgressCount} in progress) + {doneCount}/{tasks.length} done{#if inProgressCount > 0}, {inProgressCount} in progress{/if} </p> - <ul class="flex flex-col gap-1"> + <ul class="flex flex-col gap-0.5"> {#each tasks as task (task.id)} - <li class="flex flex-col gap-0.5 rounded p-1.5 hover:bg-base-200 transition-colors"> - <div class="flex items-center gap-1.5"> - <span class={badgeClass(task.status)}> - {statusIcon(task.status)} - </span> + <li + class="flex items-start gap-2 rounded p-1.5 transition-colors {task.status === 'done' ? 'opacity-60' : ''}" + > + <input + type="checkbox" + class={checkboxClass(task.status)} + checked={isChecked(task.status)} + indeterminate={isIndeterminate(task.status)} + disabled + tabindex="-1" + /> + <div class="flex flex-col gap-0.5 min-w-0"> <span - class="text-sm leading-tight {task.status === 'in_progress' - ? 'font-bold' - : 'font-medium'}" + class="text-xs leading-tight {task.status === 'done' + ? 'line-through text-base-content/50' + : task.status === 'in_progress' + ? 'font-semibold' + : ''}" > {task.title} </span> + {#if task.description} + <p class="text-xs text-base-content/50 line-clamp-2">{task.description}</p> + {/if} </div> - {#if task.description} - <p class="text-xs text-base-content/60 line-clamp-2 pl-5">{task.description}</p> - {/if} - <p class="text-xs text-base-content/30 pl-5 font-mono">{task.id}</p> </li> {/each} </ul> diff --git a/packages/frontend/src/lib/components/PermissionLog.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte index 7733dcf..c6af47f 100644 --- a/packages/frontend/src/lib/components/PermissionLog.svelte +++ b/packages/frontend/src/lib/components/ToolPermissions.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onMount } from "svelte"; -import type { LogEntry } from "../types.js"; import { appSettings } from "../settings.svelte.js"; +import type { LogEntry } from "../types.js"; const { entries, apiBase = "" }: { entries: LogEntry[]; apiBase?: string } = $props(); @@ -13,9 +13,22 @@ interface ToolPermission { const toolPermissions: ToolPermission[] = [ { id: "read", label: "Read files", description: "Allow the AI to read files in the workspace" }, - { id: "edit", label: "Edit files", description: "Allow the AI to write/edit files in the workspace" }, + { + id: "edit", + label: "Edit files", + description: "Allow the AI to write/edit files in the workspace", + }, { id: "bash", label: "Run commands", description: "Allow the AI to execute shell commands" }, - { id: "external_directory", label: "External directories", description: "Allow access to files outside the workspace" }, + { + id: "summon", + label: "Summon agents", + description: "Allow the AI to spawn child agents to work on tasks", + }, + { + id: "external_directory", + label: "External directories", + description: "Allow access to files outside the workspace", + }, ]; async function loadPermissions(): Promise<void> { @@ -24,7 +37,7 @@ async function loadPermissions(): Promise<void> { try { const res = await fetch(`${apiBase}/tabs/settings/perm_${perm.id}`); if (res.ok) { - const data = await res.json() as { value: string | null }; + const data = (await res.json()) as { value: string | null }; if (data.value !== null) { loaded[perm.id] = data.value === "allow"; } diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 2c8e2f5..1352a0c 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -7,12 +7,14 @@ let toolPerms = $state<Record<string, boolean>>({ read: true, edit: false, bash: false, + summon: false, external_directory: false, }); let savedToolPerms = $state<Record<string, boolean>>({ read: true, edit: false, bash: false, + summon: false, external_directory: false, }); let skillChecks = $state<Record<string, boolean>>({}); diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 55aa161..119df2d 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -95,7 +95,7 @@ function createTabStore() { activeTabId = id; // Auto-check default skills for injection with the first message - autoCheckDefaultSkills(); + void autoCheckDefaultSkills(); return tab; } @@ -133,9 +133,9 @@ function createTabStore() { tabs = tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)); } - function ensureAssistantMessage(tabId: string): ChatMessage { + function ensureAssistantMessage(tabId: string): ChatMessage | null { const tab = getTabById(tabId); - if (!tab) throw new Error(`Tab not found: ${tabId}`); + if (!tab) return null; if (tab.currentAssistantId) { const existing = tab.messages.find((m) => m.id === tab.currentAssistantId); @@ -333,6 +333,26 @@ function createTabStore() { ); break; } + case "tab-created": { + const newTabEvent = event as AgentEvent & { id: string; title: string }; + // Only add if we don't already have this tab + if (!getTabById(newTabEvent.id)) { + const tab: Tab = { + id: newTabEvent.id, + title: newTabEvent.title, + messages: [], + agentStatus: "running", + keyId: null, + modelId: null, + reasoningEffort: "max", + currentAssistantId: null, + tasks: [], + injectedSkills: [], + }; + tabs = [...tabs, tab]; + } + break; + } } } @@ -543,10 +563,20 @@ function createTabStore() { function copyConversation(): string { const tab = getActiveTab(); if (!tab) return ""; + + const enabledTools = Object.entries(appSettings.savedToolPerms) + .filter(([, v]) => v) + .map(([k]) => k); + const lines: string[] = [ "=== Dispatch Conversation ===", + `Tab ID: ${tab.id}`, `Tab: ${tab.title}`, `Model: ${tab.modelId ?? "default"}`, + `Tools: ${enabledTools.length > 0 ? enabledTools.join(", ") : "none"}`, + `Injected Skills: ${tab.injectedSkills.length > 0 ? tab.injectedSkills.join(", ") : "none"}`, + `Total tabs: ${tabs.length}`, + `All tab IDs: ${tabs.map((t) => t.id).join(", ")}`, "", ]; for (const msg of tab.messages) { diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 28752a0..a1ec24b 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -63,13 +63,14 @@ export type AgentEvent = }; } | { type: "permission-prompt"; pending: PermissionPrompt[] } - | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }; + | { type: "shell-output"; data: string; stream: "stdout" | "stderr" } + | { type: "tab-created"; id: string; title: string }; export interface TaskItem { id: string; title: string; description: string; - status: "pending" | "in_progress" | "done" | "blocked"; + status: "pending" | "in_progress" | "done"; } export interface PermissionPrompt { diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts index 95243d5..0970311 100644 --- a/packages/frontend/src/lib/ws.svelte.ts +++ b/packages/frontend/src/lib/ws.svelte.ts @@ -3,6 +3,16 @@ import type { AgentEvent, ConnectionStatus } from "./types.js"; type EventCallback = (event: AgentEvent) => void; +// Close any stale WebSocket from HMR reloads +if (import.meta.hot) { + import.meta.hot.dispose((data: Record<string, unknown>) => { + const old = data._ws as WebSocket | undefined; + if (old && old.readyState === WebSocket.OPEN) { + old.close(); + } + }); +} + function createWebSocketClient(url: string) { let connectionStatus: ConnectionStatus = $state("disconnected"); let ws: WebSocket | null = null; @@ -19,6 +29,11 @@ function createWebSocketClient(url: string) { connectionStatus = "connecting"; ws = new WebSocket(url); + // Store ref for HMR cleanup + if (import.meta.hot) { + import.meta.hot.data._ws = ws; + } + ws.onopen = () => { connectionStatus = "connected"; reconnectDelay = 1000; |
