summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-23 05:06:12 +0900
committerAdam Malczewski <[email protected]>2026-05-23 05:06:12 +0900
commit9287cccb29d135ea19f2612c26f3090c94820d8c (patch)
tree2d68e8cacf6d71786f305d5f4a512a68f19137c5 /packages
parentef427d3eae77fca716c203dd8bd84939710c518a (diff)
downloaddispatch-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')
-rw-r--r--packages/api/src/agent-manager.ts83
-rw-r--r--packages/api/src/app.ts7
-rw-r--r--packages/api/src/routes/agents.ts1
-rw-r--r--packages/api/src/routes/models.ts12
-rw-r--r--packages/api/tests/agent-manager.test.ts32
-rw-r--r--packages/api/tests/routes.test.ts41
-rw-r--r--packages/core/src/agent/agent.ts102
-rw-r--r--packages/core/src/agents/loader.ts5
-rw-r--r--packages/core/src/config/schema.ts36
-rw-r--r--packages/core/src/index.ts5
-rw-r--r--packages/core/src/skills/parser.ts14
-rw-r--r--packages/core/src/tools/retrieve.ts32
-rw-r--r--packages/core/src/tools/web-search.ts6
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts6
-rw-r--r--packages/core/src/types/index.ts2
-rw-r--r--packages/core/tests/config/loader.test.ts26
-rw-r--r--packages/core/tests/llm/provider.test.ts6
-rw-r--r--packages/frontend/electron/main.cjs16
-rw-r--r--packages/frontend/electron/preload.cjs4
-rw-r--r--packages/frontend/src/App.svelte7
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte23
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte2
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte10
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte4
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte8
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte6
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte14
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts4
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts68
-rw-r--r--packages/frontend/vite.config.ts2
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,