From 9287cccb29d135ea19f2612c26f3090c94820d8c Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 23 May 2026 05:06:12 +0900 Subject: feat: add is_subagent flag to agents, fix all lint/type/test issues - Add is_subagent checkbox to agent editor; subagents are hidden from Chat Settings - Add is_subagent field to AgentDefinition type, TOML serialization, and API route - Filter subagents from ModelSelector agent list - Fix all biome lint/format errors across codebase (useLiteralKeys, noNonNullAssertion, noExplicitAny, formatting, import sorting) - Fix svelte-check errors (type narrowing in SkillsBrowser, ToolPermissions, SidebarPanel) - Fix a11y warnings in App.svelte (label-control associations) - Fix test mocks missing BackgroundShellStore, BackgroundTranscriptStore, createWebSearchTool, createYoutubeTranscribeTool - Update stale 409 test to match current message-queuing behavior - Exclude packaging/ and release/ dirs from biome to avoid linting stale build artifacts --- bin/import-credentials.ts | 14 ++- bin/seed-opencode-keys.ts | 2 +- biome.json | 10 +- packages/api/src/agent-manager.ts | 83 ++++++++++------- packages/api/src/app.ts | 7 +- packages/api/src/routes/agents.ts | 1 + packages/api/src/routes/models.ts | 12 +-- packages/api/tests/agent-manager.test.ts | 32 +++++++ packages/api/tests/routes.test.ts | 41 ++++++++- packages/core/src/agent/agent.ts | 102 ++++++++++----------- packages/core/src/agents/loader.ts | 5 + packages/core/src/config/schema.ts | 36 ++++---- packages/core/src/index.ts | 5 +- packages/core/src/skills/parser.ts | 14 ++- packages/core/src/tools/retrieve.ts | 32 ++++--- packages/core/src/tools/web-search.ts | 6 +- packages/core/src/tools/youtube-transcribe.ts | 6 +- packages/core/src/types/index.ts | 2 + packages/core/tests/config/loader.test.ts | 26 +++--- packages/core/tests/llm/provider.test.ts | 6 +- packages/frontend/electron/main.cjs | 16 ++-- packages/frontend/electron/preload.cjs | 4 +- packages/frontend/src/App.svelte | 7 +- .../src/lib/components/AgentBuilder.svelte | 23 ++++- .../src/lib/components/MarkdownRenderer.svelte | 2 +- .../src/lib/components/ModelSelector.svelte | 10 +- .../frontend/src/lib/components/ModelStatus.svelte | 4 +- .../src/lib/components/SettingsPanel.svelte | 8 +- .../src/lib/components/SidebarPanel.svelte | 15 ++- .../src/lib/components/SkillsBrowser.svelte | 6 +- .../src/lib/components/ToolPermissions.svelte | 14 ++- packages/frontend/src/lib/settings.svelte.ts | 4 + packages/frontend/src/lib/tabs.svelte.ts | 68 +++++++------- packages/frontend/vite.config.ts | 2 +- 34 files changed, 389 insertions(+), 236 deletions(-) diff --git a/bin/import-credentials.ts b/bin/import-credentials.ts index f99f20a..a4ea499 100755 --- a/bin/import-credentials.ts +++ b/bin/import-credentials.ts @@ -4,11 +4,19 @@ * Reads from ~/.claude/.credentials-{1,2}.json and stores them * for the configured keys (claude-pro, claude-max). */ -import { importCredentialsFromFile, getDatabasePath } from "../packages/core/src/index.js"; +import { getDatabasePath, importCredentialsFromFile } from "../packages/core/src/index.js"; const imports = [ - { keyId: "claude-pro", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-1.json` }, - { keyId: "claude-max", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-2.json` }, + { + keyId: "claude-pro", + provider: "anthropic", + file: `${process.env.HOME}/.claude/.credentials-1.json`, + }, + { + keyId: "claude-max", + provider: "anthropic", + file: `${process.env.HOME}/.claude/.credentials-2.json`, + }, ]; console.log(`Database: ${getDatabasePath()}\n`); diff --git a/bin/seed-opencode-keys.ts b/bin/seed-opencode-keys.ts index c461592..c481b8d 100644 --- a/bin/seed-opencode-keys.ts +++ b/bin/seed-opencode-keys.ts @@ -2,7 +2,7 @@ /** * Seed API keys from environment variables into the SQLite database. */ -import { setApiKey, getDatabasePath } from "../packages/core/src/index.js"; +import { getDatabasePath, setApiKey } from "../packages/core/src/index.js"; console.log(`Database: ${getDatabasePath()}\n`); diff --git a/biome.json b/biome.json index 4fbaeda..408d2a0 100644 --- a/biome.json +++ b/biome.json @@ -47,6 +47,14 @@ } ], "files": { - "includes": ["**", "!**/node_modules", "!**/dist", "!**/build", "!references/**"] + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/build", + "!references", + "!packaging", + "!**/release" + ] } } diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 3789b68..74995b2 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -4,6 +4,8 @@ import { type AgentSkillMapping, type AgentStatus, appendMessage, + BackgroundShellStore, + BackgroundTranscriptStore, type ClaudeAccount, configToRuleset, createConfigWatcher, @@ -11,8 +13,6 @@ import { createReadFileTool, createRetrieveTool, createRunShellTool, - BackgroundShellStore, - BackgroundTranscriptStore, createSkillsWatcher, createSummonTool, createTaskListTool, @@ -298,13 +298,15 @@ export class AgentManager { const effectiveKeyId = keyId ?? tabAgent.keyId; const effectiveModelId = modelId ?? tabAgent.modelId; - // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask) + // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask, web=ask, youtube=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 permWebSearch = getSetting("perm_web_search") === "allow"; + const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -358,13 +360,19 @@ export class AgentManager { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (allowed.has("run_shell")) { - toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore) }); + toolEntries.push({ + name: "run_shell", + tool: createRunShellTool(workingDirectory, tabAgent.shellStore), + }); } if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } if (allowed.has("youtube_transcribe")) { - toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool(tabAgent.transcriptStore) }); + toolEntries.push({ + name: "youtube_transcribe", + tool: createYoutubeTranscribeTool(tabAgent.transcriptStore), + }); } if (allowed.has("todo")) { toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); @@ -408,10 +416,20 @@ export class AgentManager { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (permBash) { - toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore) }); + toolEntries.push({ + name: "run_shell", + tool: createRunShellTool(workingDirectory, tabAgent.shellStore), + }); + } + if (permWebSearch) { + toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); + } + if (permYoutubeTranscribe) { + toolEntries.push({ + name: "youtube_transcribe", + tool: createYoutubeTranscribeTool(tabAgent.transcriptStore), + }); } - toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); - toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool(tabAgent.transcriptStore) }); toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); if (permSummon) { // Capture parent's allowed tool names for child permission enforcement @@ -544,25 +562,25 @@ export class AgentManager { tabAgent.modelId = null; } - const customSystemPrompt = getSetting("system_prompt") || undefined; - tabAgent.agent = new Agent( - { - model, - apiKey, - baseURL, - systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt), - tools, - workingDirectory, - permissionChecker: this.permissionManager ?? undefined, - ruleset, - provider, - ...(claudeCredentials ? { claudeCredentials } : {}), - }, - { - dequeueMessages: () => this.dequeueMessages(tabId), - waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), - }, - ); + const customSystemPrompt = getSetting("system_prompt") || undefined; + tabAgent.agent = new Agent( + { + model, + apiKey, + baseURL, + systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt), + tools, + workingDirectory, + permissionChecker: this.permissionManager ?? undefined, + ruleset, + provider, + ...(claudeCredentials ? { claudeCredentials } : {}), + }, + { + dequeueMessages: () => this.dequeueMessages(tabId), + waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), + }, + ); } return tabAgent.agent; } @@ -672,7 +690,7 @@ export class AgentManager { // 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)); + childTools = options.tools.filter((t) => options.parentAllowedTools?.has(t)); } // Create the tab agent entry with overrides @@ -931,10 +949,7 @@ export class AgentManager { const messages = [...tabAgent.messageQueue]; tabAgent.messageQueue = []; if (messages.length > 0) { - this.emit( - { type: "message-consumed", tabId, messageIds: messages.map((m) => m.id) }, - tabId, - ); + this.emit({ type: "message-consumed", tabId, messageIds: messages.map((m) => m.id) }, tabId); } return messages; } @@ -951,7 +966,7 @@ export class AgentManager { }); const cancel = () => { if (listener) { - tabAgent.queueListeners = tabAgent.queueListeners.filter(l => l !== listener); + tabAgent.queueListeners = tabAgent.queueListeners.filter((l) => l !== listener); listener = null; } }; diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 53cfb6c..9ccc911 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -63,7 +63,8 @@ app.post("/chat", async (c) => { const keyId = typeof body.keyId === "string" ? body.keyId : undefined; const modelId = typeof body.modelId === "string" ? body.modelId : undefined; - const workingDirectory = typeof body.workingDirectory === "string" ? body.workingDirectory : undefined; + const workingDirectory = + typeof body.workingDirectory === "string" ? body.workingDirectory : undefined; const validEfforts = ["none", "low", "medium", "high", "max"]; const reasoningEffort = typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort) @@ -71,7 +72,9 @@ app.post("/chat", async (c) => { : undefined; // Non-blocking — let the agent run in the background - agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory).catch(console.error); + agentManager + .processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory) + .catch(console.error); return c.json({ status: "ok" }); }); diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts index 42339bf..d3ca23f 100644 --- a/packages/api/src/routes/agents.ts +++ b/packages/api/src/routes/agents.ts @@ -56,6 +56,7 @@ agentsRoutes.post("/", async (c) => { scope: body.scope, slug: body.slug, ...(body.cwd ? { cwd: body.cwd } : {}), + ...(body.is_subagent ? { is_subagent: true } : {}), }; saveAgent(agent); return c.json({ ok: true, agent }); diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index d6b82c2..b250263 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -112,7 +112,7 @@ modelsRoutes.get("/available", async (c) => { }); } - const apiKeyValue = resolveApiKey(keyId!); + const apiKeyValue = resolveApiKey(keyId); if (!apiKeyValue) { return c.json({ error: `no API key found for ${keyId}` }, 500); } @@ -420,10 +420,7 @@ modelsRoutes.post("/add-key", async (c) => { // Validate provider if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) { - return c.json( - { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, - 400, - ); + return c.json({ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, 400); } const provider = body.provider as SupportedProvider; const base_url = PROVIDER_BASE_URLS[provider]; @@ -452,7 +449,7 @@ modelsRoutes.post("/add-key", async (c) => { newBlock += "\n"; // Insert before the # ─── Permissions section if it exists, otherwise at end - const permissionsMarker = /\n# [─\-]+ Permissions/; + const permissionsMarker = /\n# [─-]+ Permissions/; let newContent: string; const permMatch = permissionsMarker.exec(tomlContent); if (permMatch) { @@ -509,7 +506,8 @@ modelsRoutes.post("/remove-key", async (c) => { return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404); } - const newContent = tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); + const newContent = + tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); try { writeFileSync(tomlPath, newContent, "utf-8"); diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index f8e5e12..b426c4e 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -169,6 +169,38 @@ vi.mock("@dispatch/core", () => ({ return null; }, appendMessage() {}, + BackgroundShellStore: class MockBackgroundShellStore { + has() { + return false; + } + getResult() { + return Promise.resolve({ status: "error", error: "not found" }); + } + }, + BackgroundTranscriptStore: class MockBackgroundTranscriptStore { + has() { + return false; + } + getResult() { + return Promise.resolve({ status: "error", error: "not found" }); + } + }, + createWebSearchTool() { + return { + name: "web_search", + description: "web search", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createYoutubeTranscribeTool() { + return { + name: "youtube_transcribe", + description: "youtube transcribe", + 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 05f8358..c3c7eaa 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -170,6 +170,38 @@ vi.mock("@dispatch/core", () => ({ return null; }, appendMessage() {}, + BackgroundShellStore: class MockBackgroundShellStore { + has() { + return false; + } + getResult() { + return Promise.resolve({ status: "error", error: "not found" }); + } + }, + BackgroundTranscriptStore: class MockBackgroundTranscriptStore { + has() { + return false; + } + getResult() { + return Promise.resolve({ status: "error", error: "not found" }); + } + }, + createWebSearchTool() { + return { + name: "web_search", + description: "web search", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createYoutubeTranscribeTool() { + return { + name: "youtube_transcribe", + description: "youtube transcribe", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, })); const { app } = await import("../src/app.js"); @@ -241,7 +273,7 @@ describe("POST /chat", () => { expect(res.status).toBe(400); }); - it("returns 409 when agent is already running", async () => { + it("queues message when agent is already running", async () => { // Start a message (non-blocking) await app.request("/chat", { method: "POST", @@ -252,12 +284,15 @@ describe("POST /chat", () => { // Small delay to let the async generator start and emit "running" status await new Promise((r) => setTimeout(r, 20)); - // Immediately send a second — agent should be running + // Send a second — agent should queue it const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tabId: "tab-2", message: "second message" }), }); - expect(res.status).toBe(409); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("queued"); + expect(typeof body.messageId).toBe("string"); }); }); diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 64a602b..6f1a5a4 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -188,12 +188,12 @@ export class Agent { } try { - const execPromise = tool.execute(tc.arguments, { - onOutput: (data: string, stream: "stdout" | "stderr") => { - shellOutputQueue.push({ data, stream }); - }, - queueCallbacks: this.queueCallbacks, - }); + const execPromise = tool.execute(tc.arguments, { + onOutput: (data: string, stream: "stdout" | "stderr") => { + shellOutputQueue.push({ data, stream }); + }, + queueCallbacks: this.queueCallbacks, + }); const rawResult = await execPromise; const resultStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult); @@ -320,9 +320,7 @@ export class Agent { } } catch (streamErr) { const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr); - const unavailMatch = errMsg.match( - /tried to call unavailable tool '([^']+)'/i, - ); + const unavailMatch = errMsg.match(/tried to call unavailable tool '([^']+)'/i); if (!unavailMatch) throw streamErr; // Model tried to call an unavailable tool. @@ -391,8 +389,8 @@ export class Agent { let toolResult: ToolResult | undefined; while (toolResult === undefined) { if (shellOutputQueue.length > 0) { - const item = shellOutputQueue.shift()!; - yield { type: "shell-output", data: item.data, stream: item.stream }; + const item = shellOutputQueue.shift(); + if (item) yield { type: "shell-output", data: item.data, stream: item.stream }; continue; } const raceResult = await Promise.race([ @@ -406,30 +404,28 @@ export class Agent { } } - // Drain any remaining shell output emitted before we read the result - while (shellOutputQueue.length > 0) { - const item = shellOutputQueue.shift()!; - yield { type: "shell-output", data: item.data, stream: item.stream }; - } + // Drain any remaining shell output emitted before we read the result + while (shellOutputQueue.length > 0) { + const item = shellOutputQueue.shift(); + if (item) yield { type: "shell-output", data: item.data, stream: item.stream }; + } - // Check for queued user messages and append them to the tool result - let finalToolResult = toolResult; - if (this.queueCallbacks) { - const queuedMsgs = this.queueCallbacks.dequeueMessages(); - if (queuedMsgs.length > 0) { - const userMessages = queuedMsgs - .map((m) => m.message) - .join("\n---\n"); - finalToolResult = { - ...toolResult, - result: `${toolResult.result}\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`, - }; + // Check for queued user messages and append them to the tool result + let finalToolResult = toolResult; + if (this.queueCallbacks) { + const queuedMsgs = this.queueCallbacks.dequeueMessages(); + if (queuedMsgs.length > 0) { + const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); + finalToolResult = { + ...toolResult, + result: `${toolResult.result}\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`, + }; + } } - } - stepToolResults.push(finalToolResult); - allToolResults.push(finalToolResult); - yield { type: "tool-result", toolResult: finalToolResult }; + stepToolResults.push(finalToolResult); + allToolResults.push(finalToolResult); + yield { type: "tool-result", toolResult: finalToolResult }; } // Add tool results back to step messages so LLM can see them @@ -451,29 +447,29 @@ export class Agent { } } - const assistantMessage: ChatMessage = { - role: "assistant", - content: finalText, - toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, - toolResults: allToolResults.length > 0 ? allToolResults : undefined, - }; - this.messages.push(assistantMessage); - - // Drain any remaining queued messages that arrived after the last tool call - if (this.queueCallbacks) { - const remaining = this.queueCallbacks.dequeueMessages(); - if (remaining.length > 0) { - // These messages arrived too late to be injected into a tool result. - // Append them as a user message to the conversation so they're not lost. - const userMessages = remaining.map(m => m.message).join("\n---\n"); - this.messages.push({ - role: "user", - content: userMessages, - }); + const assistantMessage: ChatMessage = { + role: "assistant", + content: finalText, + toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, + toolResults: allToolResults.length > 0 ? allToolResults : undefined, + }; + this.messages.push(assistantMessage); + + // Drain any remaining queued messages that arrived after the last tool call + if (this.queueCallbacks) { + const remaining = this.queueCallbacks.dequeueMessages(); + if (remaining.length > 0) { + // These messages arrived too late to be injected into a tool result. + // Append them as a user message to the conversation so they're not lost. + const userMessages = remaining.map((m) => m.message).join("\n---\n"); + this.messages.push({ + role: "user", + content: userMessages, + }); + } } - } - yield { type: "done", message: assistantMessage }; + yield { type: "done", message: assistantMessage }; } catch (err) { const errorMsg = formatError(err, this.config); yield { type: "error", error: errorMsg }; diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts index ec29983..cf84381 100644 --- a/packages/core/src/agents/loader.ts +++ b/packages/core/src/agents/loader.ts @@ -112,6 +112,10 @@ export function saveAgent(agent: AgentDefinition): void { tomlContent.cwd = agent.cwd; } + if (agent.is_subagent) { + tomlContent.is_subagent = true; + } + // smol-toml handles [[models]] array-of-tables if (agent.models.length > 0) { tomlContent.models = agent.models.map((m) => ({ @@ -202,6 +206,7 @@ function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] { scope, slug, ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}), + ...(parsed.is_subagent === true ? { is_subagent: true } : {}), }); } catch { // Skip unparseable files diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index ef10b65..a459a4d 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -66,37 +66,37 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi errors.push({ path, message: "must be an object" }); return null; } - if (typeof raw["id"] !== "string") { + if (typeof raw.id !== "string") { errors.push({ path: `${path}.id`, message: "must be a string" }); return null; } - if (typeof raw["provider"] !== "string") { + if (typeof raw.provider !== "string") { errors.push({ path: `${path}.provider`, message: "must be a string" }); return null; } - if (typeof raw["base_url"] !== "string") { + if (typeof raw.base_url !== "string") { errors.push({ path: `${path}.base_url`, message: "must be a string" }); return null; } // "anthropic" provider uses credentials_file instead of env - if (raw["provider"] === "anthropic") { + if (raw.provider === "anthropic") { return { - id: raw["id"] as string, - provider: raw["provider"] as string, - base_url: raw["base_url"] as string, - ...(typeof raw["credentials_file"] === "string" - ? ({ credentials_file: raw["credentials_file"] } as Pick) + id: raw.id as string, + provider: raw.provider as string, + base_url: raw.base_url as string, + ...(typeof raw.credentials_file === "string" + ? ({ credentials_file: raw.credentials_file } as Pick) : {}), }; } // Other providers: env is optional (keys can be stored in DB) return { - id: raw["id"] as string, - provider: raw["provider"] as string, - base_url: raw["base_url"] as string, - ...(typeof raw["env"] === "string" ? { env: raw["env"] } : {}), + id: raw.id as string, + provider: raw.provider as string, + base_url: raw.base_url as string, + ...(typeof raw.env === "string" ? { env: raw.env } : {}), }; } @@ -109,17 +109,17 @@ export function validateConfig(raw: unknown): { config: DispatchConfig; errors: } // permissions (required, but can be empty) - const permissions = validatePermissions(raw["permissions"] ?? {}, "permissions", errors); + const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors); // keys (optional) let keys: KeyDefinition[] | undefined; - if (raw["keys"] !== undefined) { - if (!Array.isArray(raw["keys"])) { + 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); + for (let i = 0; i < raw.keys.length; i++) { + const key = validateKey(raw.keys[i], `keys[${i}]`, errors); if (key) keys.push(key); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d5db16..1e55425 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,9 @@ export { createSummonTool, type SummonCallbacks } from "./tools/summon.js"; export { createTaskListTool, TaskList } from "./tools/task-list.js"; export { createWebSearchTool } from "./tools/web-search.js"; export { createWriteFileTool } from "./tools/write-file.js"; -export { BackgroundTranscriptStore, createYoutubeTranscribeTool } from "./tools/youtube-transcribe.js"; +export { + BackgroundTranscriptStore, + createYoutubeTranscribeTool, +} from "./tools/youtube-transcribe.js"; // Types & Permissions export * from "./types/index.js"; diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts index 289073f..bd1205e 100644 --- a/packages/core/src/skills/parser.ts +++ b/packages/core/src/skills/parser.ts @@ -30,16 +30,14 @@ export function parseSkillFile( try { const frontmatter = parse(tomlSource); - if (typeof frontmatter["name"] === "string") { - name = frontmatter["name"]; + if (typeof frontmatter.name === "string") { + name = frontmatter.name; } - if (typeof frontmatter["description"] === "string") { - description = frontmatter["description"]; + 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", - ); + 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 diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts index 021d1b7..80c3715 100644 --- a/packages/core/src/tools/retrieve.ts +++ b/packages/core/src/tools/retrieve.ts @@ -28,29 +28,33 @@ export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition parameters: z.object({ agent_id: z.string().describe("The agent_id returned by a previous summon call."), }), - execute: async (args: Record, context?: ToolExecuteContext): Promise => { + execute: async ( + args: Record, + context?: ToolExecuteContext, + ): Promise => { const agentId = args.agent_id as string; const queueCallbacks = context?.queueCallbacks; try { let outcome: { status: "done"; result: string } | { status: "error"; error: string }; - if (queueCallbacks) { - const childPromise = callbacks.getResult(agentId); - const { promise: queuePromise, cancel: cancelQueueWait } = queueCallbacks.waitForQueuedMessage(); - const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); + if (queueCallbacks) { + const childPromise = callbacks.getResult(agentId); + const { promise: queuePromise, cancel: cancelQueueWait } = + queueCallbacks.waitForQueuedMessage(); + const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); - const raceResult = await Promise.race([childPromise, queueSignal]); + const raceResult = await Promise.race([childPromise, queueSignal]); - if (raceResult === "QUEUE_INTERRUPT") { - const queuedMsgs = queueCallbacks.dequeueMessages(); - const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); - return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`; - } + if (raceResult === "QUEUE_INTERRUPT") { + const queuedMsgs = queueCallbacks.dequeueMessages(); + const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); + return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`; + } - // Child finished first — clean up the queue listener - cancelQueueWait(); - outcome = raceResult; + // Child finished first — clean up the queue listener + cancelQueueWait(); + outcome = raceResult; } else { outcome = await callbacks.getResult(agentId); } diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 4265aa7..7f061a5 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -70,7 +70,9 @@ export function createWebSearchTool(): ToolDefinition { return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`; } - let json: { data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }> }; + let json: { + data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>; + }; try { json = await response.json(); } catch { @@ -96,7 +98,7 @@ export function createWebSearchTool(): ToolDefinition { let output = parts.join("\n\n---\n\n"); if (output.length > MAX_OUTPUT_CHARS) { - output = output.slice(0, MAX_OUTPUT_CHARS) + "\n\n[Output truncated]"; + output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated]`; } return output; }, diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts index cfa006d..ea8ed43 100644 --- a/packages/core/src/tools/youtube-transcribe.ts +++ b/packages/core/src/tools/youtube-transcribe.ts @@ -41,9 +41,7 @@ function formatTime(seconds: number): string { function formatTranscript(data: TranscriptResponse): string { const segments = data.segments ?? []; - const segmentsText = segments - .map((seg) => `[${formatTime(seg.start)}] ${seg.text}`) - .join("\n"); + const segmentsText = segments.map((seg) => `[${formatTime(seg.start)}] ${seg.text}`).join("\n"); const output = [ `Video ID: ${data.video_id}`, @@ -58,7 +56,7 @@ function formatTranscript(data: TranscriptResponse): string { ].join("\n"); return output.length > MAX_OUTPUT_CHARS - ? output.slice(0, MAX_OUTPUT_CHARS) + "\n\n[Transcript truncated]" + ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Transcript truncated]` : output; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index be37674..ff47f49 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -188,4 +188,6 @@ export interface AgentDefinition { slug: string; /** Default working directory for this agent (optional, absolute path) */ cwd?: string; + /** Whether this agent is a subagent (hidden from Chat Settings) */ + is_subagent?: boolean; } diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts index a572425..de025de 100644 --- a/packages/core/tests/config/loader.test.ts +++ b/packages/core/tests/config/loader.test.ts @@ -27,14 +27,14 @@ describe("loadConfig", () => { it("parses simple string permissions", () => { writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`); const config = loadConfig(TMP); - expect(config.permissions["read"]).toBe("allow"); - expect(config.permissions["edit"]).toBe("deny"); + expect(config.permissions.read).toBe("allow"); + expect(config.permissions.edit).toBe("deny"); }); it("parses nested pattern permissions", () => { writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`); const config = loadConfig(TMP); - const bash = config.permissions["bash"] as Record; + const bash = config.permissions.bash as Record; expect(bash["npm test"]).toBe("allow"); expect(bash["*"]).toBe("ask"); }); @@ -42,27 +42,27 @@ describe("loadConfig", () => { it("ignores comment lines", () => { writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`); const config = loadConfig(TMP); - expect(config.permissions["read"]).toBe("allow"); + expect(config.permissions.read).toBe("allow"); }); it("handles ~ expansion in nested keys", () => { writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`); const config = loadConfig(TMP); - const read = config.permissions["read"] as Record; + const read = config.permissions.read as Record; expect(read["~/projects/*"]).toBe("allow"); }); it("handles $HOME expansion in nested keys", () => { writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`); const config = loadConfig(TMP); - const read = config.permissions["read"] as Record; + const read = config.permissions.read as Record; expect(read["$HOME/docs/*"]).toBe("allow"); }); it("parses quoted keys", () => { writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`); const config = loadConfig(TMP); - const bash = config.permissions["bash"] as Record; + const bash = config.permissions.bash as Record; expect(bash["git commit *"]).toBe("allow"); expect(bash["rm *"]).toBe("deny"); }); @@ -72,11 +72,11 @@ describe("loadConfig", () => { `[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"); - const edit = config.permissions["edit"] as Record; + expect(config.permissions.read).toBe("allow"); + const edit = config.permissions.edit as Record; expect(edit["*"]).toBe("ask"); expect(edit["src/**"]).toBe("allow"); - const bash = config.permissions["bash"] as Record; + const bash = config.permissions.bash as Record; expect(bash["npm test"]).toBe("allow"); expect(bash["*"]).toBe("ask"); }); @@ -84,14 +84,14 @@ describe("loadConfig", () => { 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; + const bash = config.permissions.bash as Record; expect(bash["file#1"]).toBe("allow"); }); 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; + const bash = config.permissions.bash as Record; expect(bash["*"]).toBe("allow"); }); @@ -100,7 +100,7 @@ describe("loadConfig", () => { const pattern = `~${sep}projects${sep}*`; writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`); const config = loadConfig(TMP); - const read = config.permissions["read"] as Record; + const read = config.permissions.read as Record; expect(read[pattern]).toBe("allow"); }); diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts index 2a98556..fb5dca5 100644 --- a/packages/core/tests/llm/provider.test.ts +++ b/packages/core/tests/llm/provider.test.ts @@ -48,7 +48,7 @@ async function runTransform(prompt: unknown[]): Promise { } )._middleware; - const result = await middleware[0]!.transformParams({ + const result = await middleware[0]?.transformParams({ type: "stream", params: { prompt }, }); @@ -75,7 +75,7 @@ describe("createProvider middleware", () => { )._middleware; const params = { prompt: [], temperature: 0.5 }; - const result = (await middleware[0]!.transformParams({ + const result = (await middleware[0]?.transformParams({ type: "generate", params, })) as Record; @@ -130,7 +130,7 @@ describe("createProvider middleware", () => { // Content unchanged const content = msg.content as Array>; expect(content).toHaveLength(1); - expect(content[0]!.type).toBe("text"); + expect(content[0]?.type).toBe("text"); // reasoning_content always set, even empty const pm = msg.providerMetadata as Record; diff --git a/packages/frontend/electron/main.cjs b/packages/frontend/electron/main.cjs index 2d947da..251e96c 100644 --- a/packages/frontend/electron/main.cjs +++ b/packages/frontend/electron/main.cjs @@ -1,33 +1,33 @@ -const { app, BrowserWindow } = require('electron'); -const path = require('path'); +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, - title: 'Dispatch', + title: "Dispatch", webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: path.join(__dirname, 'preload.cjs'), + preload: path.join(__dirname, "preload.cjs"), }, }); - win.loadFile(path.join(__dirname, '../dist/index.html')); + win.loadFile(path.join(__dirname, "../dist/index.html")); } app.whenReady().then(() => { createWindow(); - app.on('activate', () => { + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { app.quit(); } }); diff --git a/packages/frontend/electron/preload.cjs b/packages/frontend/electron/preload.cjs index c254892..14ebb26 100644 --- a/packages/frontend/electron/preload.cjs +++ b/packages/frontend/electron/preload.cjs @@ -1,6 +1,6 @@ -const { contextBridge } = require('electron'); +const { contextBridge } = require("electron"); -contextBridge.exposeInMainWorld('versions', { +contextBridge.exposeInMainWorld("versions", { electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome, diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index d96cb63..a2f7327 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -186,16 +186,17 @@ onMount(() => {

Add New Key

- -
- + >(new Set()); let formTools = $state>(new Set()); let formModels = $state([]); + let formIsSubagent = $state(false); // Model selection modal state let modelModalIndex = $state(null); @@ -121,6 +123,7 @@ const modelCache = new Map(); formSkills = new Set(); formTools = new Set(); formModels = []; + formIsSubagent = false; editing = true; // Allow the effect to skip the initial population setTimeout(() => { formReady = true; }, 0); @@ -136,6 +139,7 @@ const modelCache = new Map(); formSkills = new Set(agent.skills); formTools = new Set(agent.tools); formModels = agent.models.map((m) => ({ ...m })); + formIsSubagent = agent.is_subagent ?? false; editing = true; // Allow the effect to skip the initial population setTimeout(() => { formReady = true; }, 0); @@ -260,6 +264,7 @@ const modelCache = new Map(); scope: formScope, slug: editingSlug ?? slugify(formName.trim()), ...(formCwd.trim() ? { cwd: formCwd.trim() } : {}), + ...(formIsSubagent ? { is_subagent: true } : {}), }; saving = true; @@ -317,7 +322,8 @@ const modelCache = new Map(); // Auto-save with debounce whenever form fields change $effect(() => { // Read all reactive form fields to subscribe - const _ = [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels]; + // noinspection: intentionally unused — reading these values subscribes the effect to them + void [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels, formIsSubagent]; if (!formReady || !editing) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { @@ -420,6 +426,21 @@ const modelCache = new Map(); />
+ +
+ +
+
- {:else if agents.length === 0} + {:else if visibleAgents.length === 0}

No agents configured.

{:else}
- {#each agents as agent (agent.slug + ":" + agent.scope)} + {#each visibleAgents as agent (agent.slug + ":" + agent.scope)}