diff options
| author | Adam Malczewski <[email protected]> | 2026-05-23 05:06:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-23 05:06:12 +0900 |
| commit | 9287cccb29d135ea19f2612c26f3090c94820d8c (patch) | |
| tree | 2d68e8cacf6d71786f305d5f4a512a68f19137c5 /packages | |
| parent | ef427d3eae77fca716c203dd8bd84939710c518a (diff) | |
| download | dispatch-9287cccb29d135ea19f2612c26f3090c94820d8c.tar.gz dispatch-9287cccb29d135ea19f2612c26f3090c94820d8c.zip | |
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
Diffstat (limited to 'packages')
31 files changed, 368 insertions, 231 deletions
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<void>((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<KeyDefinition, "credentials_file">) + 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<KeyDefinition, "credentials_file">) : {}), }; } // 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<string, unknown>, context?: ToolExecuteContext): Promise<string> => { + execute: async ( + args: Record<string, unknown>, + context?: ToolExecuteContext, + ): Promise<string> => { 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<string, string>; + const bash = config.permissions.bash as Record<string, string>; 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<string, string>; + const read = config.permissions.read as Record<string, string>; 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<string, string>; + const read = config.permissions.read as Record<string, string>; 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<string, string>; + const bash = config.permissions.bash as Record<string, string>; 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<string, string>; + expect(config.permissions.read).toBe("allow"); + const edit = config.permissions.edit as Record<string, string>; expect(edit["*"]).toBe("ask"); expect(edit["src/**"]).toBe("allow"); - const bash = config.permissions["bash"] as Record<string, string>; + const bash = config.permissions.bash as Record<string, string>; 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<string, string>; + const bash = config.permissions.bash as Record<string, string>; 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<string, string>; + const bash = config.permissions.bash as Record<string, string>; 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<string, string>; + const read = config.permissions.read as Record<string, string>; 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<unknown[]> { } )._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<string, unknown>; @@ -130,7 +130,7 @@ describe("createProvider middleware", () => { // Content unchanged const content = msg.content as Array<Record<string, unknown>>; 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<string, unknown>; 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(() => { <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl"> <h3 class="text-sm font-semibold">Add New Key</h3> <div class="flex flex-col gap-1"> - <label class="text-xs text-base-content/60">Provider</label> - <select class="select select-bordered select-sm w-full" bind:value={addKeyProvider}> + <label class="text-xs text-base-content/60" for="add-key-provider">Provider</label> + <select id="add-key-provider" class="select select-bordered select-sm w-full" bind:value={addKeyProvider}> <option value="anthropic">Anthropic</option> <option value="opencode-go">OpenCode</option> <option value="github-copilot">GitHub Copilot</option> </select> </div> <div class="flex flex-col gap-1"> - <label class="text-xs text-base-content/60">Key ID</label> + <label class="text-xs text-base-content/60" for="add-key-id">Key ID</label> <input + id="add-key-id" type="text" class="input input-bordered input-sm w-full" placeholder="e.g. claude-max, copilot-2" diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte index 4a2d5d8..65fd764 100644 --- a/packages/frontend/src/lib/components/AgentBuilder.svelte +++ b/packages/frontend/src/lib/components/AgentBuilder.svelte @@ -24,6 +24,7 @@ const modelCache = new Map(); scope: string; slug: string; cwd?: string; + is_subagent?: boolean; } interface DirEntry { @@ -65,6 +66,7 @@ const modelCache = new Map(); let formSkills = $state<Set<string>>(new Set()); let formTools = $state<Set<string>>(new Set()); let formModels = $state<AgentModelEntry[]>([]); + let formIsSubagent = $state(false); // Model selection modal state let modelModalIndex = $state<number | null>(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(); /> </div> + <!-- Is Subagent --> + <div class="form-control"> + <label class="label cursor-pointer justify-start gap-3 py-1"> + <input + type="checkbox" + class="checkbox checkbox-sm rounded-sm" + bind:checked={formIsSubagent} + /> + <div> + <span class="label-text font-semibold">Is Subagent</span> + <p class="text-xs text-base-content/50">Subagents are hidden from Chat Settings and can only be used by other agents.</p> + </div> + </label> + </div> + <!-- Scope --> <div class="form-control gap-1"> <label class="label py-0" for="agent-scope"> diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte index 0fbf314..de202b6 100644 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte @@ -88,7 +88,7 @@ const loadCache = new Map<string, Promise<boolean>>(); async function ensureLanguage(lang: string): Promise<boolean> { const name = normalizeLang(lang); if (hljs.getLanguage(name)) return true; - if (loadCache.has(name)) return loadCache.get(name)!; + if (loadCache.has(name)) return loadCache.get(name) ?? false; const promise = (async () => { try { diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte index 5949e71..19ce818 100644 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -16,6 +16,7 @@ const modelCache = new Map<string, string[]>(); tools: string[]; models: Array<{ key_id: string; model_id: string }>; cwd?: string; + is_subagent?: boolean; } // Moves an element to document.body so modals escape the sidebar's @@ -90,6 +91,7 @@ const modelCache = new Map<string, string[]>(); let modeOverride = $state<"manual" | "agent" | null>(null); let mode = $derived(modeOverride ?? (activeAgentSlug ? "agent" : "manual")); let agents = $state<AgentInfo[]>([]); + let visibleAgents = $derived(agents.filter((a) => !a.is_subagent)); let loadingAgents = $state(false); $effect(() => { @@ -206,8 +208,8 @@ const modelCache = new Map<string, string[]>(); modeOverride = "agent"; await fetchAgents(); // Re-apply the active agent's settings (including cwd) - const current = agents.find(a => a.slug === activeAgentSlug); - const agentToApply = current ?? agents[0] ?? null; + const current = visibleAgents.find(a => a.slug === activeAgentSlug); + const agentToApply = current ?? visibleAgents[0] ?? null; if (agentToApply) { onAgentChange(agentToApply); // Force-update the input since the prop may not change (already set) @@ -258,11 +260,11 @@ const modelCache = new Map<string, string[]>(); <span class="loading loading-spinner loading-xs"></span> Loading agents... </div> - {:else if agents.length === 0} + {:else if visibleAgents.length === 0} <p class="text-base-content/50 text-sm py-2">No agents configured.</p> {:else} <div class="flex flex-col gap-1.5"> - {#each agents as agent (agent.slug + ":" + agent.scope)} + {#each visibleAgents as agent (agent.slug + ":" + agent.scope)} <button class="w-full text-left rounded-lg px-3 py-2 transition-colors {activeAgentSlug === agent.slug ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}" onclick={() => { diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index d6ff0f5..57c5efd 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -48,8 +48,6 @@ let keyModalError = $state<string | null>(null); let keyModalSaving = $state(false); let removingKey = $state<string | null>(null); - - async function loadCredentialStatus(): Promise<void> { try { const res = await fetch(`${apiBase}/models/credentials-status`); @@ -173,7 +171,7 @@ function timeAgo(ts: number | null): string { function truncate(str: string | null, max: number): string { if (!str) return ""; - return str.length > max ? str.slice(0, max) + "..." : str; + return str.length > max ? `${str.slice(0, max)}...` : str; } </script> diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index c19fe45..eadfef8 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -25,14 +25,18 @@ function saveBackendUrl(): void { config.setApiBase(trimmed); backendUrl = trimmed; backendUrlSaved = true; - setTimeout(() => { backendUrlSaved = false; }, 2000); + setTimeout(() => { + backendUrlSaved = false; + }, 2000); } function resetBackendUrl(): void { config.setApiBase(config.defaultApiBase); backendUrl = config.defaultApiBase; backendUrlSaved = true; - setTimeout(() => { backendUrlSaved = false; }, 2000); + setTimeout(() => { + backendUrlSaved = false; + }, 2000); } async function loadSettings(): Promise<void> { diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index e89e351..371561a 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -10,6 +10,15 @@ import SkillsBrowser from "./SkillsBrowser.svelte"; import TaskListPanel from "./TaskListPanel.svelte"; import ToolPermissions from "./ToolPermissions.svelte"; +interface AgentInfo { + slug: string; + scope: string; + skills: string[]; + tools: string[]; + models: Array<{ key_id: string; model_id: string }>; + cwd?: string; +} + const { keys = [], tasks = [], @@ -23,7 +32,7 @@ const { onKeyChange, onModelChange, onReasoningChange, - onAgentChange = (_agent: any) => {}, + onAgentChange = (_agent: AgentInfo | null) => {}, onWorkingDirectoryChange = (_dir: string | null) => {}, onAddKey = () => {}, }: { @@ -39,7 +48,7 @@ const { onKeyChange: (keyId: string) => void; onModelChange: (keyId: string, modelId: string) => void; onReasoningChange: (effort: string) => void; - onAgentChange?: (agent: any) => void; + onAgentChange?: (agent: AgentInfo | null) => void; onWorkingDirectoryChange?: (dir: string | null) => void; onAddKey?: () => void; } = $props(); @@ -72,7 +81,7 @@ function addPanel() { 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; + return fill ? `${base} flex-1` : base; } function contentClass(selected: string): string { diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte index add43e4..e697732 100644 --- a/packages/frontend/src/lib/components/SkillsBrowser.svelte +++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte @@ -77,7 +77,7 @@ function dirKey(group: DirGroup): string { function isChecked(skill: Skill): boolean { const key = skillKey(skill); if (externalMode) { - return checkedSkills!.has(key); + return checkedSkills?.has(key) ?? false; } return appSettings.skillChecks[key] === true; } @@ -90,7 +90,7 @@ function isInjected(skill: Skill): boolean { function toggleCheck(skill: Skill): void { const key = skillKey(skill); if (externalMode) { - onSkillToggle!(key, !checkedSkills!.has(key)); + onSkillToggle?.(key, !checkedSkills?.has(key)); return; } appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) }; @@ -156,7 +156,7 @@ $effect(() => { const checkedCount = $derived( externalMode - ? checkedSkills!.size + ? (checkedSkills?.size ?? 0) : Object.values(appSettings.skillChecks).filter((v) => v).length, ); diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte index 6533c7b..7fdaad3 100644 --- a/packages/frontend/src/lib/components/ToolPermissions.svelte +++ b/packages/frontend/src/lib/components/ToolPermissions.svelte @@ -22,6 +22,16 @@ const toolPermissions: ToolPermission[] = [ label: "Summon agents", description: "Allow the AI to spawn child agents to work on tasks", }, + { + id: "web_search", + label: "Web search", + description: "Allow the AI to search the web via Firecrawl", + }, + { + id: "youtube_transcribe", + label: "YouTube transcripts", + description: "Allow the AI to fetch YouTube video transcripts", + }, ]; const { @@ -42,7 +52,7 @@ const { const externalMode = $derived(checkedTools !== null && onToolToggle !== null); function isChecked(id: string): boolean { - if (externalMode) return checkedTools!.has(id); + if (externalMode) return checkedTools?.has(id) ?? false; return appSettings.toolPerms[id] === true; } @@ -67,7 +77,7 @@ async function loadPermissions(): Promise<void> { function togglePermission(id: string): void { if (externalMode) { - onToolToggle!(id, !checkedTools!.has(id)); + onToolToggle?.(id, !checkedTools?.has(id)); return; } appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] }; diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 1352a0c..2828e7c 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -9,6 +9,8 @@ let toolPerms = $state<Record<string, boolean>>({ bash: false, summon: false, external_directory: false, + web_search: false, + youtube_transcribe: false, }); let savedToolPerms = $state<Record<string, boolean>>({ read: true, @@ -16,6 +18,8 @@ let savedToolPerms = $state<Record<string, boolean>>({ bash: false, summon: false, external_directory: false, + web_search: false, + youtube_transcribe: 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 d186ccb..7f4d768 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -465,24 +465,24 @@ function createTabStore() { }; // 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: newTabEvent.keyId ?? null, - modelId: newTabEvent.modelId ?? null, - reasoningEffort: "max", - currentAssistantId: null, - tasks: [], - injectedSkills: [], - parentTabId: newTabEvent.parentTabId ?? null, - persistent: newTabEvent.parentTabId == null, - agentSlug: null, - agentScope: null, - workingDirectory: null, - queuedMessages: [], - }; + const tab: Tab = { + id: newTabEvent.id, + title: newTabEvent.title, + messages: [], + agentStatus: "running", + keyId: newTabEvent.keyId ?? null, + modelId: newTabEvent.modelId ?? null, + reasoningEffort: "max", + currentAssistantId: null, + tasks: [], + injectedSkills: [], + parentTabId: newTabEvent.parentTabId ?? null, + persistent: newTabEvent.parentTabId == null, + agentSlug: null, + agentScope: null, + workingDirectory: null, + queuedMessages: [], + }; tabs = [...tabs, tab]; } break; @@ -506,8 +506,7 @@ function createTabStore() { // Also add as a user chat message if not already present const tabAfterQm = getTabById(tabId); const existingMsg = tabAfterQm?.messages.find( - (m) => - m.id === `queued-${mqEvent.messageId}` || m.id === mqEvent.messageId, + (m) => m.id === `queued-${mqEvent.messageId}` || m.id === mqEvent.messageId, ); if (!existingMsg) { const userMsg: ChatMessage = { @@ -557,15 +556,13 @@ function createTabStore() { // Mark the current assistant message as done streaming const result = rest.map((m) => - m.id === currentAssistantId - ? { ...m, isStreaming: false } - : m, + m.id === currentAssistantId ? { ...m, isStreaming: false } : m, ); // Insert consumed messages right after the current assistant message let insertIdx = result.length; for (let i = result.length - 1; i >= 0; i--) { - if (result[i].id === currentAssistantId) { + if (result[i]?.id === currentAssistantId) { insertIdx = i + 1; break; } @@ -633,7 +630,9 @@ function createTabStore() { }>; }; const agents = data.agents ?? []; - const defaultAgent = agents.find((a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global"); + const defaultAgent = agents.find( + (a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global", + ); if (!defaultAgent) return; const tab = getTabById(tabId); @@ -747,10 +746,13 @@ function createTabStore() { queueId = generateId(); userMsg.id = `queued-${queueId}`; // Pre-populate queuedMessages so WS event finds it immediately - tab.queuedMessages = [...tab.queuedMessages, { id: queueId, message: text, timestamp: Date.now() }]; + tab.queuedMessages = [ + ...tab.queuedMessages, + { id: queueId, message: text, timestamp: Date.now() }, + ]; } - updateTab(tab.id, { messages: [...tab.messages, userMsg] }); // Generate title from first user message + updateTab(tab.id, { messages: [...tab.messages, userMsg] }); // Generate title from first user message if (tab.messages.length === 0 || (tab.messages.length === 1 && tab.title === "New Tab")) { const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text; updateTab(tab.id, { title: titleText }); @@ -818,9 +820,7 @@ function createTabStore() { }); } updateMessages(tab.id, (msgs) => - msgs.map((m) => - m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m, - ), + msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), ); } const errMsg: ChatMessage = { @@ -858,9 +858,7 @@ function createTabStore() { } // Restore the message to a normal (non-queued) ID updateMessages(tab.id, (msgs) => - msgs.map((m) => - m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m, - ), + msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), ); } } @@ -875,9 +873,7 @@ function createTabStore() { }); } updateMessages(tab.id, (msgs) => - msgs.map((m) => - m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m, - ), + msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)), ); } const errMsg: ChatMessage = { diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 1b9921b..ee55efa 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -3,7 +3,7 @@ import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; export default defineConfig({ - base: './', + base: "./", plugins: [tailwindcss(), svelte()], server: { port: 5173, |
