From 9ecaabd87c0e51b8a7408dabb0133a9344586859 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 22 May 2026 15:24:13 +0900 Subject: feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling - Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts --- bin/up | 9 +- docker-compose.yml | 2 + packages/api/src/agent-manager.ts | 61 +- packages/api/src/app.ts | 23 +- packages/api/src/index.ts | 8 +- packages/api/src/permission-manager.ts | 2 +- packages/api/src/routes/agents.ts | 109 +++ packages/api/src/routes/config.ts | 2 +- packages/api/src/routes/models.ts | 29 +- packages/api/src/routes/skills.ts | 11 +- packages/api/src/routes/tabs.ts | 28 +- packages/core/src/agent/agent.ts | 147 +++- packages/core/src/agents/index.ts | 1 + packages/core/src/agents/loader.ts | 212 ++++++ packages/core/src/config/loader.ts | 4 +- packages/core/src/config/schema.ts | 25 +- packages/core/src/config/watcher.ts | 14 +- packages/core/src/credentials/api-keys.ts | 19 +- packages/core/src/credentials/claude.ts | 95 ++- packages/core/src/credentials/index.ts | 26 +- packages/core/src/credentials/opencode.ts | 9 +- packages/core/src/credentials/store.ts | 26 +- packages/core/src/db/messages.ts | 26 +- packages/core/src/db/settings.ts | 4 +- packages/core/src/index.ts | 1 + packages/core/src/llm/provider.ts | 11 +- packages/core/src/models/registry.ts | 8 +- packages/core/src/skills/index.ts | 6 +- packages/core/src/skills/loader.ts | 140 ++-- packages/core/src/skills/parser.ts | 4 +- packages/core/src/tools/bash-arity.ts | 60 +- packages/core/src/tools/run-shell.ts | 14 +- packages/core/src/tools/shell-analyze.ts | 29 +- packages/core/src/types/index.ts | 28 +- packages/core/tests/config/loader.test.ts | 2 +- packages/core/tests/llm/provider.test.ts | 40 +- packages/frontend/src/App.svelte | 24 +- packages/frontend/src/app.css | 61 +- .../src/lib/components/AgentBuilder.svelte | 746 +++++++++++++++++++++ .../frontend/src/lib/components/ChatInput.svelte | 4 +- .../frontend/src/lib/components/ChatMessage.svelte | 2 +- .../frontend/src/lib/components/ChatPanel.svelte | 74 +- .../frontend/src/lib/components/ClaudeReset.svelte | 247 +++---- .../frontend/src/lib/components/ConfigPanel.svelte | 177 ++--- packages/frontend/src/lib/components/Header.svelte | 3 +- .../src/lib/components/HotReloadIndicator.svelte | 38 +- .../frontend/src/lib/components/KeyUsage.svelte | 320 +++++---- .../src/lib/components/MarkdownRenderer.svelte | 308 ++++----- .../src/lib/components/ModelSelector.svelte | 202 +++++- .../frontend/src/lib/components/ModelStatus.svelte | 254 +++---- .../src/lib/components/PermissionPrompt.svelte | 5 +- .../src/lib/components/SettingsPanel.svelte | 201 +++--- .../src/lib/components/SidebarPanel.svelte | 44 +- .../src/lib/components/SkillsBrowser.svelte | 247 +++++-- .../src/lib/components/SystemPromptPanel.svelte | 66 +- packages/frontend/src/lib/components/TabBar.svelte | 5 +- .../src/lib/components/TaskListPanel.svelte | 11 - .../src/lib/components/ThemeSwitcher.svelte | 16 +- .../src/lib/components/ToolPermissions.svelte | 99 ++- packages/frontend/src/lib/router.svelte.ts | 12 + packages/frontend/src/lib/tabs.svelte.ts | 159 ++++- packages/frontend/tests/chat-store.test.ts | 32 +- 62 files changed, 3218 insertions(+), 1374 deletions(-) create mode 100644 packages/api/src/routes/agents.ts create mode 100644 packages/core/src/agents/index.ts create mode 100644 packages/core/src/agents/loader.ts create mode 100644 packages/frontend/src/lib/components/AgentBuilder.svelte create mode 100644 packages/frontend/src/lib/router.svelte.ts diff --git a/bin/up b/bin/up index 2cccfa8..2e67857 100755 --- a/bin/up +++ b/bin/up @@ -1,20 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -# Force GPG to use terminal-based pinentry (required for SSH sessions) -export GPG_TTY=$(tty) - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -# Load secrets from gopass -OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)" - # Pass host user identity so the container runs as the same UID/GID export HOST_UID="$(id -u)" export HOST_GID="$(id -g)" export HOST_USER="$(whoami)" # Start all services -OPENCODE_API_KEY="$OPENCODE_API_KEY" \ - docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@" +docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 2ce08a8..d8514da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - .:/app - ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude - ${HOME}/.local/share/dispatch:/home/${HOST_USER:-dispatch}/.local/share/dispatch + - ${HOME}/.skills:/home/${HOST_USER:-dispatch}/.skills + - ${HOME}/.config/dispatch:/home/${HOST_USER:-dispatch}/.config/dispatch env_file: - .env.dispatch environment: diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 7e814af..82b8456 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -299,7 +299,24 @@ export class AgentManager { if (!tabAgent.agent) { const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); - const workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir; + let workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir; + + // Expand ~ to home directory + if (workingDirectory === "~" || workingDirectory.startsWith("~/")) { + const { homedir } = await import("node:os"); + const { join } = await import("node:path"); + workingDirectory = join(homedir(), workingDirectory.slice(1)); + } + + // Auto-create the working directory if it doesn't exist + try { + const { mkdirSync, existsSync } = await import("node:fs"); + if (!existsSync(workingDirectory)) { + mkdirSync(workingDirectory, { recursive: true }); + } + } catch { + // Ignore — tool execution will surface the error naturally + } // Build tools list — child agents use their toolsOverride whitelist, // parent agents use permission settings from DB @@ -573,15 +590,34 @@ export class AgentManager { const tabId = crypto.randomUUID(); const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task; - // Validate working directory is within the parent's workspace + // Validate working directory is within the parent agent's effective CWD const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + let parentEffectiveDir = options.parentTabId + ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir) + : defaultWorkDir; + + // Expand ~ in parent dir + if (parentEffectiveDir === "~" || parentEffectiveDir.startsWith("~/")) { + const { homedir } = await import("node:os"); + const { join } = await import("node:path"); + parentEffectiveDir = join(homedir(), parentEffectiveDir.slice(1)); + } + if (options.workingDirectory) { - const { resolve } = await import("node:path"); - const resolved = resolve(options.workingDirectory); - const parentDir = resolve(defaultWorkDir); - if (!resolved.startsWith(`${parentDir}/`) && resolved !== parentDir) { + const { isAbsolute, relative, resolve, join } = await import("node:path"); + // Expand ~ in child working directory + let childDir = options.workingDirectory; + if (childDir === "~" || childDir.startsWith("~/")) { + const { homedir } = await import("node:os"); + childDir = join(homedir(), childDir.slice(1)); + } + const parentDir = resolve(parentEffectiveDir); + const resolved = resolve(parentDir, childDir); + const rel = relative(parentDir, resolved); + const isOutside = rel.startsWith("..") || isAbsolute(rel); + if (isOutside) { throw new Error( - `Working directory "${options.workingDirectory}" is outside the workspace "${parentDir}".`, + `Working directory "${options.workingDirectory}" is outside the parent's working directory "${parentDir}".`, ); } } @@ -676,8 +712,19 @@ export class AgentManager { keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max", + workingDirectory?: string, ): Promise { const tabAgent = this._getOrCreateTabAgent(tabId); + + // Apply working directory override from frontend if provided + if (workingDirectory !== undefined) { + const prevDir = tabAgent.workingDirectoryOverride; + tabAgent.workingDirectoryOverride = workingDirectory || undefined; + // Invalidate cached agent if working directory changed + if (prevDir !== tabAgent.workingDirectoryOverride) { + tabAgent.agent = null; + } + } tabAgent.abortController = new AbortController(); tabAgent.status = "running"; this.messageCount += 1; diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index f5588a6..37514c3 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -2,9 +2,10 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { AgentManager } from "./agent-manager.js"; import { PermissionManager } from "./permission-manager.js"; +import { agentsRoutes } from "./routes/agents.js"; import { configRoutes } from "./routes/config.js"; -import { skillsRoutes } from "./routes/skills.js"; import { modelsRoutes, startWakeScheduler } from "./routes/models.js"; +import { skillsRoutes } from "./routes/skills.js"; import { tabsRoutes } from "./routes/tabs.js"; export const permissionManager = new PermissionManager(); @@ -35,7 +36,14 @@ app.get("/status", (c) => { }); app.post("/chat", async (c) => { - const body = await c.req.json<{ tabId?: unknown; message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>(); + const body = await c.req.json<{ + tabId?: unknown; + message?: unknown; + keyId?: unknown; + modelId?: unknown; + reasoningEffort?: unknown; + workingDirectory?: unknown; + }>(); const { tabId, message } = body; if (typeof tabId !== "string" || tabId.trim() === "") { @@ -52,13 +60,15 @@ 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 validEfforts = ["none", "low", "medium", "high", "max"]; - const reasoningEffort = typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort) - ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max") - : undefined; + const reasoningEffort = + typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort) + ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max") + : undefined; // Non-blocking — let the agent run in the background - agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort).catch(console.error); + agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory).catch(console.error); return c.json({ status: "ok" }); }); @@ -67,6 +77,7 @@ app.route("/config", configRoutes); app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); app.route("/tabs", tabsRoutes); +app.route("/agents", agentsRoutes); // Start the wake scheduler on boot (restores persisted schedule) startWakeScheduler(); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a05f800..045196b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,6 @@ +import type { PermissionReply } from "@dispatch/core"; import { createBunWebSocket } from "hono/bun"; import { agentManager, app, permissionManager } from "./app.js"; -import type { PermissionReply } from "@dispatch/core"; const { upgradeWebSocket, websocket } = createBunWebSocket(); @@ -41,7 +41,11 @@ app.get( id?: string; reply?: string; }; - if (message.type === "permission-reply" && typeof message.id === "string" && typeof message.reply === "string") { + if ( + message.type === "permission-reply" && + typeof message.id === "string" && + typeof message.reply === "string" + ) { const validReplies: PermissionReply[] = ["once", "always", "reject"]; if (validReplies.includes(message.reply as PermissionReply)) { permissionManager.reply(message.id, message.reply as PermissionReply); diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts index 6a04d3d..d98dc52 100644 --- a/packages/api/src/permission-manager.ts +++ b/packages/api/src/permission-manager.ts @@ -1,7 +1,7 @@ import { - PermissionService, type PermissionReply, type PermissionRequest, + PermissionService, type Ruleset, } from "@dispatch/core"; diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts new file mode 100644 index 0000000..42339bf --- /dev/null +++ b/packages/api/src/routes/agents.ts @@ -0,0 +1,109 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentDefinition } from "@dispatch/core"; +import { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "@dispatch/core"; +import { Hono } from "hono"; + +const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/; + +function isValidSlug(slug: string): boolean { + return SAFE_SLUG_RE.test(slug) && slug.length > 0 && slug.length <= 100; +} + +const agentsRoutes = new Hono(); + +// GET /agents — list all agents (global + project-scoped) +// Query param: ?projectDir=... (optional, the working directory) +agentsRoutes.get("/", (c) => { + const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined; + const agents = loadAgents(projectDir); + const dirs = getAgentDirs(projectDir); + return c.json({ agents, dirs }); +}); + +// GET /agents/dirs — list available agent directories +agentsRoutes.get("/dirs", (c) => { + const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined; + const dirs = getAgentDirs(projectDir); + return c.json({ dirs }); +}); + +// POST /agents — create or update an agent +agentsRoutes.post("/", async (c) => { + try { + const body = await c.req.json(); + // Validate required fields + if (!body.name || !body.slug || !body.scope) { + return c.json({ error: "name, slug, and scope are required" }, 400); + } + if (!isValidSlug(body.slug)) { + return c.json( + { error: "Invalid slug: must be alphanumeric with hyphens/underscores only" }, + 400, + ); + } + if (body.scope !== "global" && body.scope.includes("..")) { + return c.json({ error: "Invalid scope" }, 400); + } + // Ensure arrays exist + const agent: AgentDefinition = { + name: body.name, + description: body.description || "", + skills: body.skills || [], + tools: body.tools || [], + models: body.models || [], + scope: body.scope, + slug: body.slug, + ...(body.cwd ? { cwd: body.cwd } : {}), + }; + saveAgent(agent); + return c.json({ ok: true, agent }); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : "Failed to save agent" }, 500); + } +}); + +// DELETE /agents/:slug — delete an agent +// Query param: ?scope=... (required: "global" or directory path) +agentsRoutes.delete("/:slug", (c) => { + const slug = c.req.param("slug"); + const scope = c.req.query("scope"); + if (!scope) { + return c.json({ error: "scope query param is required" }, 400); + } + if (!isValidSlug(slug)) { + return c.json({ error: "Invalid slug" }, 400); + } + if (slug === "default" && scope === "global") { + return c.json({ error: "Cannot delete the default agent" }, 403); + } + if (scope !== "global" && scope.includes("..")) { + return c.json({ error: "Invalid scope" }, 400); + } + const deleted = deleteAgent(slug, scope); + if (!deleted) { + return c.json({ error: "Agent not found" }, 404); + } + return c.json({ ok: true }); +}); + +// GET /agents/check-dir?path=... — check if a directory exists +agentsRoutes.get("/check-dir", (c) => { + let dirPath = c.req.query("path"); + if (!dirPath) { + return c.json({ exists: false }); + } + // Expand ~ to home directory + if (dirPath === "~" || dirPath.startsWith("~/")) { + dirPath = path.join(os.homedir(), dirPath.slice(1)); + } + try { + const stat = fs.statSync(dirPath); + return c.json({ exists: stat.isDirectory() }); + } catch { + return c.json({ exists: false }); + } +}); + +export { agentsRoutes }; diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts index 2d08167..65a1e2a 100644 --- a/packages/api/src/routes/config.ts +++ b/packages/api/src/routes/config.ts @@ -1,5 +1,5 @@ -import { Hono } from "hono"; import type { DispatchConfig } from "@dispatch/core"; +import { Hono } from "hono"; let getConfig: () => DispatchConfig = () => ({ permissions: {} }); diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 86411f1..1daf37e 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -3,11 +3,11 @@ import { ANTHROPIC_MODELS_FALLBACK, type ClaudeAccount, fetchAnthropicModels, - getClaudeAccountsFromDB, fetchCopilotUsage, fetchOpencodeUsage, getAccountUsage, getAnthropicHeaders, + getClaudeAccountsFromDB, getDatabase, importCredentialsFromFile, listApiKeys, @@ -22,9 +22,7 @@ import { Hono } from "hono"; let getRegistry: () => ModelRegistry | null = () => null; let getAccounts: () => ClaudeAccount[] = () => []; -export function setModelsGetter( - registryGetter: () => ModelRegistry | null, -): void { +export function setModelsGetter(registryGetter: () => ModelRegistry | null): void { getRegistry = registryGetter; } @@ -80,8 +78,9 @@ modelsRoutes.get("/available", async (c) => { if (key.definition.provider === "anthropic") { const credFile = key.definition.credentials_file; const accounts = resolveClaudeAccounts(); - const account = accounts.find((a) => a.id === keyId) - ?? (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]); + const account = + accounts.find((a) => a.id === keyId) ?? + (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]); if (!account) { return c.json({ error: "no Claude credentials found" }, 500); @@ -413,9 +412,8 @@ async function wakeAllClaudeAccounts(): Promise< } } } - const accounts = configuredKeyIds.size > 0 - ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) - : allAccounts; + const accounts = + configuredKeyIds.size > 0 ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) : allAccounts; if (accounts.length === 0) { return [{ label: "(none)", ok: false, error: "no Claude accounts available" }]; } @@ -483,7 +481,10 @@ function nextOccurrenceAt15(hour: number): number { function loadScheduleFromDB(): WakeSchedule { try { const db = getDatabase(); - const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{ hour: number; next_wake_at: number }>; + const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{ + hour: number; + next_wake_at: number; + }>; const schedule: WakeSchedule = {}; let needsUpdate = false; for (const row of rows) { @@ -508,7 +509,9 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void { const db = getDatabase(); const data = scheduleToSave ?? wakeSchedule; db.run("DELETE FROM wake_schedule"); - const insert = db.query("INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)"); + const insert = db.query( + "INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)", + ); for (const [hour, nextWakeAt] of Object.entries(data)) { insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt }); } @@ -517,8 +520,8 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void { } } -let wakeSchedule: WakeSchedule = loadScheduleFromDB(); -let pendingRetries: PendingRetry[] = []; +const wakeSchedule: WakeSchedule = loadScheduleFromDB(); +const pendingRetries: PendingRetry[] = []; // HMR-safe: clear previous tick before starting a new one (globalThis as Record)._dispatchWakeTimer ??= undefined; diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts index 245fb4c..7696b47 100644 --- a/packages/api/src/routes/skills.ts +++ b/packages/api/src/routes/skills.ts @@ -1,9 +1,14 @@ -import { Hono } from "hono"; import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core"; +import { Hono } from "hono"; -let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ skills: [], mappings: [] }); +let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ + skills: [], + mappings: [], +}); -export function setSkillsGetter(getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }): void { +export function setSkillsGetter( + getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }, +): void { getSkills = getter; } diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index 288cd51..6e6734d 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -1,23 +1,26 @@ -import { Hono } from "hono"; import { + archiveTab, createTab, + deleteSetting, + getMessagesForTab, + getSetting, getTab, listOpenTabs, - updateTabTitle, + setSetting, updateTabModel, updateTabStatus, - archiveTab, - getMessagesForTab, - getSetting, - setSetting, - deleteSetting, + updateTabTitle, } from "@dispatch/core"; +import { Hono } from "hono"; export const tabsRoutes = new Hono(); -let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () => null; +let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () => + null; -export function setTabsAgentManager(getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null): void { +export function setTabsAgentManager( + getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null, +): void { getAgentManager = getter; } @@ -69,7 +72,12 @@ tabsRoutes.get("/:id/messages", (c) => { tabsRoutes.patch("/:id", async (c) => { const id = c.req.param("id"); - const body = await c.req.json<{ title?: string; keyId?: string; modelId?: string; status?: string }>(); + const body = await c.req.json<{ + title?: string; + keyId?: string; + modelId?: string; + status?: string; + }>(); if (body.title !== undefined) updateTabTitle(id, body.title); if (body.keyId !== undefined || body.modelId !== undefined) { updateTabModel(id, body.keyId ?? null, body.modelId ?? null); diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 006ee1b..c2c5880 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -1,11 +1,11 @@ +import { realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import type { CoreMessage } from "ai"; import { streamText } from "ai"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { realpathSync } from "node:fs"; +import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; import { createToolRegistry } from "../tools/registry.js"; import { analyzeCommand } from "../tools/shell-analyze.js"; -import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import type { AgentConfig, AgentEvent, @@ -21,7 +21,10 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes if (msg.role === "user") { result.push({ role: "user", content: msg.content }); } else if (msg.role === "assistant") { - const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record }> = [{ type: "text", text: msg.content }]; + const parts: Array< + | { type: "text"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; args: Record } + > = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); @@ -29,7 +32,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; - result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] }); + result.push({ + role: "tool", + content: [ + { type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }, + ], + }); } } } @@ -76,7 +84,12 @@ export class Agent { const registry = createToolRegistry(this.config.tools); const tool = registry.getTool(tc.name); if (!tool) { - return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true }; + return { + toolCallId: tc.id, + toolName: tc.name, + result: `Unknown tool: ${tc.name}`, + isError: true, + }; } // Permission check for shell commands — only prompt for external directory access. @@ -133,7 +146,8 @@ export class Agent { // Check if outside workspace const rel = relative(this.config.workingDirectory, resolvedPath); - const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); + const isOutside = + rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); if (isOutside) { const permissionType = @@ -194,7 +208,10 @@ export class Agent { } } - async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator { + async *run( + userMessage: string, + options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }, + ): AsyncGenerator { this.status = "running"; yield { type: "status", status: "running" }; @@ -213,8 +230,8 @@ export class Agent { const aiTools = registry.getAISDKTools(); const tools = isAnthropic ? Object.fromEntries( - Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), - ) + Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), + ) : aiTools; // Build system prompt @@ -246,8 +263,19 @@ export class Agent { }; if (isAnthropic && effort !== "none") { - const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0; - streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } }; + const budgetTokens = + effort === "max" + ? 16000 + : effort === "high" + ? 10000 + : effort === "medium" + ? 5000 + : effort === "low" + ? 2000 + : 0; + streamOptions.providerOptions = { + anthropic: { thinking: { type: "enabled" as const, budgetTokens } }, + }; streamOptions.maxTokens = budgetTokens + 8000; } else if (!isAnthropic && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; @@ -258,31 +286,64 @@ export class Agent { let stepText = ""; const stepToolCalls: ToolCall[] = []; - for await (const event of result.fullStream) { - if (event.type === "text-delta") { - stepText += event.textDelta; - finalText += event.textDelta; - yield { type: "text-delta", delta: event.textDelta }; - } else if (event.type === "reasoning") { - yield { type: "reasoning-delta", delta: event.textDelta }; - } else if (event.type === "tool-call") { - const rawName = event.toolName; - const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; - const toolCall: ToolCall = { - id: event.toolCallId, - name: toolName, - arguments: event.args as Record, - }; - stepToolCalls.push(toolCall); - allToolCalls.push(toolCall); - yield { type: "tool-call", toolCall }; - } else if (event.type === "error") { - const errorMsg = formatError(event.error, this.config); - yield { type: "error", error: errorMsg }; - this.status = "error"; - yield { type: "status", status: "error" }; - return; + try { + for await (const event of result.fullStream) { + if (event.type === "text-delta") { + stepText += event.textDelta; + finalText += event.textDelta; + yield { type: "text-delta", delta: event.textDelta }; + } else if (event.type === "reasoning") { + yield { type: "reasoning-delta", delta: event.textDelta }; + } else if (event.type === "tool-call") { + const rawName = event.toolName; + const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; + const toolCall: ToolCall = { + id: event.toolCallId, + name: toolName, + arguments: event.args as Record, + }; + stepToolCalls.push(toolCall); + allToolCalls.push(toolCall); + yield { type: "tool-call", toolCall }; + } else if (event.type === "error") { + const errorMsg = formatError(event.error, this.config); + yield { type: "error", error: errorMsg }; + this.status = "error"; + yield { type: "status", status: "error" }; + return; + } } + } catch (streamErr) { + const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr); + const unavailMatch = errMsg.match( + /tried to call unavailable tool '([^']+)'/i, + ); + if (!unavailMatch) throw streamErr; + + // Model tried to call an unavailable tool. + // Add a synthetic tool call + error result for the bad tool. + const badToolName = unavailMatch[1]; + const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`; + const availableTools = Object.keys(aiTools).join(", "); + const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`; + + const badToolCall: ToolCall = { + id: fakeId, + name: badToolName, + arguments: {}, + }; + stepToolCalls.push(badToolCall); + allToolCalls.push(badToolCall); + yield { type: "tool-call", toolCall: badToolCall }; + + const badToolResult: ToolResult = { + toolCallId: fakeId, + toolName: badToolName, + result: errorResult, + isError: true, + }; + allToolResults.push(badToolResult); + yield { type: "tool-result", toolResult: badToolResult }; } // No tool calls means the agent is done @@ -304,7 +365,17 @@ export class Agent { // Execute tool calls manually const stepToolResults: ToolResult[] = []; + // Track tool calls that already have results (e.g. synthetic unavailable-tool errors) + const alreadyResolved = new Set(allToolResults.map((r) => r.toolCallId)); + for (const tc of stepToolCalls) { + // Skip execution for tool calls that already have synthetic results + if (alreadyResolved.has(tc.id)) { + const existing = allToolResults.find((r) => r.toolCallId === tc.id); + if (existing) stepToolResults.push(existing); + continue; + } + const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue); @@ -321,7 +392,9 @@ export class Agent { } const raceResult = await Promise.race([ execPromise.then((r) => ({ done: true as const, value: r })), - new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))), + new Promise<{ done: false }>((resolve) => + setImmediate(() => resolve({ done: false })), + ), ]); if (raceResult.done) { toolResult = raceResult.value; diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts new file mode 100644 index 0000000..13f6244 --- /dev/null +++ b/packages/core/src/agents/index.ts @@ -0,0 +1 @@ +export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./loader.js"; diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts new file mode 100644 index 0000000..ec29983 --- /dev/null +++ b/packages/core/src/agents/loader.ts @@ -0,0 +1,212 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml"; +import type { AgentDefinition, AgentModelEntry } from "../types/index.js"; + +// ─── Helpers ───────────────────────────────────────────────────── + +/** Sanitize a slug to prevent path traversal */ +function sanitizeSlug(slug: string): string { + // Strip directory components and ensure only safe characters + const base = path.basename(slug); + const clean = base + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + if (!clean) throw new Error("Invalid agent slug"); + return clean; +} + +// ─── Constants ─────────────────────────────────────────────────── + +const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents"); + +function getProjectAgentsDir(projectDir: string): string { + return path.join(projectDir, ".dispatch", "agents"); +} + +// ─── Public API ────────────────────────────────────────────────── + +/** + * Returns the agent directories that exist or could exist. + * Always includes global. Includes project dir if projectDir is provided. + */ +export function getAgentDirs( + projectDir?: string, +): Array<{ label: string; path: string; scope: string }> { + const dirs: Array<{ label: string; path: string; scope: string }> = [ + { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" }, + ]; + if (projectDir) { + dirs.push({ + label: `.dispatch/agents (${path.basename(projectDir)})`, + path: getProjectAgentsDir(projectDir), + scope: projectDir, + }); + } + return dirs; +} + +/** + * Ensure the default global agent exists. Creates it if missing. + */ +function ensureDefaultAgent(): void { + const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml"); + if (fs.existsSync(filePath)) return; + + const defaultAgent: AgentDefinition = { + name: "Default", + description: "Default agent with all tools enabled", + skills: [], + tools: ["read", "edit", "bash", "summon"], + models: [], + scope: "global", + slug: "default", + }; + saveAgent(defaultAgent); +} + +/** + * Load all agent definitions from global + project directories. + * Auto-generates the default global agent if it doesn't exist. + */ +export function loadAgents(projectDir?: string): AgentDefinition[] { + ensureDefaultAgent(); + + const agents: AgentDefinition[] = []; + + // Global agents + agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global")); + + // Project-scoped agents + if (projectDir) { + agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir)); + } + + return agents; +} + +/** + * Save (create or update) an agent definition to a TOML file. + * The scope determines which directory: + * - "global" -> ~/.config/dispatch/agents/ + * - any other string -> that directory path + /.dispatch/agents/ + */ +export function saveAgent(agent: AgentDefinition): void { + if (agent.scope !== "global" && agent.scope.includes("..")) { + throw new Error("Invalid agent scope"); + } + const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope); + + fs.mkdirSync(dir, { recursive: true }); + + const tomlContent: Record = { + name: agent.name, + description: agent.description, + skills: agent.skills, + tools: agent.tools, + }; + + if (agent.cwd) { + tomlContent.cwd = agent.cwd; + } + + // smol-toml handles [[models]] array-of-tables + if (agent.models.length > 0) { + tomlContent.models = agent.models.map((m) => ({ + key_id: m.key_id, + model_id: m.model_id, + })); + } + + const content = stringifyTOML(tomlContent); + const safeSlug = sanitizeSlug(agent.slug); + const filePath = path.join(dir, `${safeSlug}.toml`); + fs.writeFileSync(filePath, content, "utf-8"); +} + +/** + * Delete an agent TOML file. + */ +export function deleteAgent(slug: string, scope: string): boolean { + if (scope !== "global" && scope.includes("..")) { + throw new Error("Invalid agent scope"); + } + const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope); + + const safeSlug = sanitizeSlug(slug); + const filePath = path.join(dir, `${safeSlug}.toml`); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + +// ─── Internal ──────────────────────────────────────────────────── + +function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] { + if (!fs.existsSync(dir)) return []; + + const results: AgentDefinition[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".toml")) continue; + + const filePath = path.join(dir, entry.name); + const slug = entry.name.slice(0, -5); // remove .toml + + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = parseTOML(raw); + + const models: AgentModelEntry[] = []; + if (Array.isArray(parsed.models)) { + for (const m of parsed.models) { + if (m && typeof m === "object" && "key_id" in m && "model_id" in m) { + models.push({ + key_id: String((m as Record).key_id), + model_id: String((m as Record).model_id), + }); + } + } + } + + const skills: string[] = []; + if (Array.isArray(parsed.skills)) { + for (const s of parsed.skills) { + if (typeof s === "string") skills.push(s); + } + } + + const tools: string[] = []; + if (Array.isArray(parsed.tools)) { + for (const t of parsed.tools) { + if (typeof t === "string") tools.push(t); + } + } + + results.push({ + name: typeof parsed.name === "string" ? parsed.name : slug, + description: typeof parsed.description === "string" ? parsed.description : "", + skills, + tools, + models, + scope, + slug, + ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}), + }); + } catch { + // Skip unparseable files + } + } + + return results; +} diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 3b4d733..bccbb8f 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -28,7 +28,9 @@ export function loadConfig(dir: string): DispatchConfig { // File doesn't exist — return empty default return DEFAULT_CONFIG; } - console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`, + ); throw err; } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 2cd8d55..ef10b65 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,8 +1,4 @@ -import type { - ConfigError, - DispatchConfig, - KeyDefinition, -} from "../types/index.js"; +import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -33,19 +29,28 @@ function validatePermissions( const result: Record> = {}; for (const [key, value] of Object.entries(raw)) { if (!isPermissionsValue(value)) { - errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" }); + errors.push({ + path: `${path}.${key}`, + message: "must be a string or a flat string-keyed object", + }); continue; } if (typeof value === "string") { if (!isValidAction(value)) { - errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` }); + errors.push({ + path: `${path}.${key}`, + message: `invalid action "${value}"; must be "allow", "deny", or "ask"`, + }); continue; } } else { let hasError = false; for (const [pattern, action] of Object.entries(value)) { if (!isValidAction(action)) { - errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` }); + errors.push({ + path: `${path}.${key}.${pattern}`, + message: `invalid action "${action}"; must be "allow", "deny", or "ask"`, + }); hasError = true; } } @@ -80,7 +85,9 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi 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 : {}), + ...(typeof raw["credentials_file"] === "string" + ? ({ credentials_file: raw["credentials_file"] } as Pick) + : {}), }; } diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index 42b2f87..70821ed 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -1,5 +1,5 @@ -import { watch } from "chokidar"; import { join } from "node:path"; +import { watch } from "chokidar"; import type { DispatchConfig } from "../types/index.js"; import { loadConfig } from "./loader.js"; @@ -26,7 +26,9 @@ export function createConfigWatcher( const config = loadConfig(dir); onChange(config); } catch (err) { - console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`, + ); } }, 300); }; @@ -36,7 +38,9 @@ export function createConfigWatcher( watcher.on("unlink", handleChange); watcher.on("error", (err) => { - console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`, + ); }); return { @@ -46,7 +50,9 @@ export function createConfigWatcher( debounceTimer = null; } watcher.close().catch((err) => { - console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`, + ); }); }, }; diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts index 5f92ffa..af5aa0e 100644 --- a/packages/core/src/credentials/api-keys.ts +++ b/packages/core/src/credentials/api-keys.ts @@ -33,9 +33,9 @@ export function setApiKey(keyId: string, provider: string, apiKey: string): void */ export function getApiKey(keyId: string): string | null { const db = getDatabase(); - const row = db.query( - "SELECT api_key FROM api_keys WHERE key_id = $keyId", - ).get({ $keyId: keyId }) as { api_key: string } | null; + const row = db + .query("SELECT api_key FROM api_keys WHERE key_id = $keyId") + .get({ $keyId: keyId }) as { api_key: string } | null; return row?.api_key ?? null; } @@ -57,11 +57,16 @@ export function deleteApiKey(keyId: string): void { /** * List all stored API keys with metadata (key value excluded for security). */ -export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> { +export function listApiKeys(): Array<{ + keyId: string; + provider: string; + importedAt: number; + updatedAt: number; +}> { const db = getDatabase(); - const rows = db.query( - "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id", - ).all() as Array>; + const rows = db + .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id") + .all() as Array>; return rows.map((row) => ({ keyId: row.key_id as string, provider: row.provider as string, diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 1b9d148..6018207 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -1,9 +1,16 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs"; -import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js"; -import { getDatabase } from "../db/index.js"; -import { dirname, join, basename } from "node:path"; -import { homedir } from "node:os"; import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { getDatabase } from "../db/index.js"; +import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js"; export interface ClaudeCredentials { accessToken: string; @@ -41,7 +48,10 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { const data = (parsed as Record).claudeAiOauth ?? parsed; const creds = data as Record; - if ((creds as Record).mcpOAuth && !(creds as Record).accessToken) { + if ( + (creds as Record).mcpOAuth && + !(creds as Record).accessToken + ) { return null; } @@ -57,7 +67,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { accessToken: creds.accessToken as string, refreshToken: creds.refreshToken as string, expiresAt: creds.expiresAt as number, - subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, + subscriptionType: + typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, }; } @@ -122,7 +133,7 @@ async function refreshViaOAuth(refreshToken: string): Promise; + const data = (await response.json()) as Record; if (!data.access_token || typeof data.access_token !== "string") { return null; } @@ -131,7 +142,8 @@ async function refreshViaOAuth(refreshToken: string): Promise now + 60_000) { + if ( + cached && + now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && + cached.creds.expiresAt > now + 60_000 + ) { return cached.creds; } @@ -257,10 +273,16 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent return null; } -export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise { +export async function refreshAccountCredentialsAsync( + account: ClaudeAccount, +): Promise { const cached = accountCacheMap.get(account.id); const now = Date.now(); - if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + if ( + cached && + now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && + cached.creds.expiresAt > now + 60_000 + ) { return cached.creds; } @@ -294,7 +316,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr account.credentials = refreshed; // Update DB if this is a DB-backed account, otherwise write to file if (account.source.startsWith("db:")) { - updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt); + updateStoredTokens( + account.id, + refreshed.accessToken, + refreshed.refreshToken, + refreshed.expiresAt, + ); } else { writeCredentialsFile(account.source, refreshed); } @@ -323,14 +350,14 @@ function computeCch(messageText: string): string { } function computeVersionSuffix(messageText: string, version: string): string { - const sampled = [4, 7, 20] - .map((i) => (i < messageText.length ? messageText[i] : "0")) - .join(""); + const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join(""); const input = `${BILLING_SALT}${sampled}${version}`; return createHash("sha256").update(input).digest("hex").slice(0, 3); } -export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string { +export function buildBillingHeaderValue( + messages: Array<{ role: string; content: string }>, +): string { const text = extractFirstUserMessageText(messages); const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION; const suffix = computeVersionSuffix(text, version); @@ -404,11 +431,16 @@ export async function fetchAnthropicModels(accessToken: string): Promise; models?: Array<{ id: string }> }; + const data = (await response.json()) as { + data?: Array<{ id: string }>; + models?: Array<{ id: string }>; + }; const entries = data.data ?? data.models ?? []; return entries.map((m) => m.id).filter(Boolean); } catch (err) { - console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`, + ); return []; } } @@ -434,7 +466,9 @@ export interface ClaudeProfile { * Validate that Claude credentials are usable by hitting the OAuth profile endpoint. * Returns the profile info if valid, or null if the token is dead. */ -export async function validateAccountCredentials(account: ClaudeAccount): Promise { +export async function validateAccountCredentials( + account: ClaudeAccount, +): Promise { const creds = await refreshAccountCredentialsAsync(account); if (!creds) return null; @@ -487,7 +521,8 @@ async function fetchClaudeUsage(accessToken: string): Promise { +export async function fetchOpencodeUsage(keyId: string): Promise { const cookie = resolveApiKey("opencode-cookie"); const wsId = getWorkspaceId(keyId); @@ -96,10 +94,7 @@ export async function fetchOpencodeUsage( const html = await response.text(); // Auth redirect check - if ( - html.includes("/auth/authorize") || - html.includes('window.location="/auth/authorize"') - ) { + if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) { return null; } diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts index 6c814f9..662b322 100644 --- a/packages/core/src/credentials/store.ts +++ b/packages/core/src/credentials/store.ts @@ -1,6 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; import { getDatabase } from "../db/index.js"; import type { ClaudeCredentials } from "./claude.js"; -import { existsSync, readFileSync } from "node:fs"; export interface StoredCredential { keyId: string; @@ -41,7 +41,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { accessToken: creds.accessToken as string, refreshToken: creds.refreshToken as string, expiresAt: creds.expiresAt as number, - subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, + subscriptionType: + typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, }; } @@ -62,7 +63,10 @@ export function importCredentialsFromFile( try { raw = readFileSync(filePath, "utf-8").trim(); } catch (e) { - return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` }; + return { + success: false, + error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, + }; } if (!raw) { @@ -106,9 +110,11 @@ export function importCredentialsFromFile( */ export function getStoredCredentials(keyId: string): StoredCredential | null { const db = getDatabase(); - const row = db.query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", - ).get({ $keyId: keyId }) as Record | null; + const row = db + .query( + "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", + ) + .get({ $keyId: keyId }) as Record | null; if (!row) return null; @@ -159,9 +165,11 @@ export function deleteStoredCredentials(keyId: string): void { */ export function listStoredCredentials(): StoredCredential[] { const db = getDatabase(); - const rows = db.query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", - ).all() as Array>; + const rows = db + .query( + "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", + ) + .all() as Array>; return rows.map((row) => ({ keyId: row.key_id as string, diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts index 5a8758f..80a1f22 100644 --- a/packages/core/src/db/messages.ts +++ b/packages/core/src/db/messages.ts @@ -10,14 +10,30 @@ export interface MessageRow { createdAt: number; } -export function appendMessage(tabId: string, id: string, role: string, contentJson: string, thinking?: string): void { +export function appendMessage( + tabId: string, + id: string, + role: string, + contentJson: string, + thinking?: string, +): void { const db = getDatabase(); - const maxSeq = db.query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId").get({ $tabId: tabId }) as { max_seq: number }; + const maxSeq = db + .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId") + .get({ $tabId: tabId }) as { max_seq: number }; const seq = (maxSeq?.max_seq ?? -1) + 1; db.query( `INSERT INTO messages (id, tab_id, seq, role, content_json, thinking, created_at) VALUES ($id, $tabId, $seq, $role, $contentJson, $thinking, $now)`, - ).run({ $id: id, $tabId: tabId, $seq: seq, $role: role, $contentJson: contentJson, $thinking: thinking ?? null, $now: Date.now() }); + ).run({ + $id: id, + $tabId: tabId, + $seq: seq, + $role: role, + $contentJson: contentJson, + $thinking: thinking ?? null, + $now: Date.now(), + }); } export function updateMessage(id: string, contentJson: string, thinking?: string): void { @@ -29,7 +45,9 @@ export function updateMessage(id: string, contentJson: string, thinking?: string export function getMessagesForTab(tabId: string): MessageRow[] { const db = getDatabase(); - const rows = db.query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC").all({ $tabId: tabId }) as Array>; + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC") + .all({ $tabId: tabId }) as Array>; return rows.map((row) => ({ id: row.id as string, tabId: row.tab_id as string, diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts index 51d6ea2..f9d152e 100644 --- a/packages/core/src/db/settings.ts +++ b/packages/core/src/db/settings.ts @@ -2,7 +2,9 @@ import { getDatabase } from "./index.js"; export function getSetting(key: string): string | null { const db = getDatabase(); - const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { value: string } | null; + const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { + value: string; + } | null; return row?.value ?? null; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5aa81b..283916a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ // Agent & LLM export { Agent } from "./agent/agent.js"; +export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./agents/index.js"; // Config export { configToRuleset, diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 3131b1a..7cbb829 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -1,7 +1,7 @@ -import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createAnthropic } from "@ai-sdk/anthropic"; -import { wrapLanguageModel } from "ai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; +import { wrapLanguageModel } from "ai"; function normalizeMessages(msgs: unknown[]): unknown[] { return msgs.map((msg: unknown) => { @@ -19,7 +19,10 @@ function normalizeMessages(msgs: unknown[]): unknown[] { ); const existingMetadata = (message.providerMetadata ?? {}) as Record; - const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record; + const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record< + string, + unknown + >; return { ...message, @@ -120,4 +123,4 @@ function createAnthropicProvider(config: ProviderConfig) { }; } -export { prefixToolName, unprefixToolName }; \ No newline at end of file +export { prefixToolName, unprefixToolName }; diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts index 9ea0b32..4a24a51 100644 --- a/packages/core/src/models/registry.ts +++ b/packages/core/src/models/registry.ts @@ -10,10 +10,7 @@ export class ModelRegistry { this._initConfig(keys, new Map()); } - private _initConfig( - keys: KeyDefinition[], - existingStates: Map, - ): void { + private _initConfig(keys: KeyDefinition[], existingStates: Map): void { this.keyOrder = keys.map((k) => k.id); const newStates = new Map(); @@ -83,8 +80,7 @@ export class ModelRegistry { return this.keyOrder .map((id) => this.keyStates.get(id)) .filter( - (state): state is KeyState => - state !== undefined && state.definition.provider === provider, + (state): state is KeyState => state !== undefined && state.definition.provider === provider, ); } } diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 5f958b3..a3fac70 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -1,7 +1,7 @@ -export { parseSkillFile } from "./parser.js"; export { + createSkillsWatcher, + getSkillByName, loadSkills, resolveSkillsForAgent, - getSkillByName, - createSkillsWatcher, } from "./loader.js"; +export { parseSkillFile } from "./parser.js"; diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index 1dcd39e..5d043dd 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -1,89 +1,58 @@ import * as fs from "node:fs"; -import * as path from "node:path"; import * as os from "node:os"; +import * as path from "node:path"; import chokidar from "chokidar"; -import type { SkillDefinition, AgentSkillMapping, SkillScope } from "../types/index.js"; +import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js"; import { parseSkillFile } from "./parser.js"; // ─── Internal Helpers ──────────────────────────────────────────── -function loadSkillsFromDir( - dir: string, - scope: SkillScope, -): SkillDefinition[] { - if (!fs.existsSync(dir)) { - return []; - } +/** + * Recursively scan a directory for .md skill files. + * The `directory` field on each skill is the relative path from `baseDir` to the file's parent. + * Skips the `agents/` subdirectory (handled separately). + */ +function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] { + if (!fs.existsSync(baseDir)) return []; const results: SkillDefinition[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) { - continue; - } - const filePath = path.join(dir, entry.name); + function walk(dir: string) { + let entries: fs.Dirent[]; try { - const content = fs.readFileSync(filePath, "utf-8"); - const skill = parseSkillFile( - filePath, - content, - scope, - // We'll set directory based on the parent dir segment — caller sets it - "default", - ); - results.push(skill); + entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { - // Skip unreadable files + return; } - } - - return results; -} - -function loadSkillsFromDirWithDirectory( - dir: string, - scope: SkillScope, - directory: SkillDefinition["directory"], -): SkillDefinition[] { - if (!fs.existsSync(dir)) { - return []; - } - const results: SkillDefinition[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) { - continue; - } - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, "utf-8"); - const skill = parseSkillFile(filePath, content, scope, directory); - results.push(skill); - } catch { - // Skip unreadable files + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip agents/ at the top level (handled by loadAgentMappings) + const relFromBase = path.relative(baseDir, fullPath); + if (relFromBase === "agents") continue; + walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const relDir = path.relative(baseDir, dir); + // relDir is "" for root, "general" for general/, "general/webapps" for nested + const directory = relDir === "." ? "" : relDir; + try { + const content = fs.readFileSync(fullPath, "utf-8"); + const skill = parseSkillFile(fullPath, content, scope, directory); + results.push(skill); + } catch { + // Skip unreadable files + } + } } } + walk(baseDir); return results; } -function loadAgentMappings( - agentsDir: string, - scope: SkillScope, -): AgentSkillMapping[] { +function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] { if (!fs.existsSync(agentsDir)) { return []; } @@ -141,44 +110,16 @@ export function loadSkills(projectDir: string): { const skills: SkillDefinition[] = []; const mappings: AgentSkillMapping[] = []; - // 1. Global default/ - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(globalBase, "default"), - "global", - "default", - ), - ); + // 1. Scan all global skills recursively (skipping agents/) + skills.push(...scanSkillsRecursive(globalBase, "global")); - // 2. Project default/ - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(projectBase, "default"), - "project", - "default", - ), - ); + // 2. Scan all project skills recursively (skipping agents/) + skills.push(...scanSkillsRecursive(projectBase, "project")); // 3. Agent mappings — global then project mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global")); mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project")); - // 4. Project/ skills (manually activated) - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(globalBase, "project"), - "global", - "project", - ), - ); - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(projectBase, "project"), - "project", - "project", - ), - ); - return { skills, mappings }; } @@ -305,6 +246,3 @@ export function createSkillsWatcher( }, }; } - -// Keep loadSkillsFromDir exported for potential testing use -export { loadSkillsFromDir }; diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts index 4df5450..289073f 100644 --- a/packages/core/src/skills/parser.ts +++ b/packages/core/src/skills/parser.ts @@ -1,6 +1,6 @@ -import { parse } from "smol-toml"; import * as path from "node:path"; -import type { SkillDefinition, SkillScope, SkillDirectory } from "../types/index.js"; +import { parse } from "smol-toml"; +import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js"; const FRONTMATTER_DELIMITER = "+++"; diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts index 5dde955..1aaba8e 100644 --- a/packages/core/src/tools/bash-arity.ts +++ b/packages/core/src/tools/bash-arity.ts @@ -1,37 +1,37 @@ // Hardcoded dictionary of well-known commands and their arity // (number of tokens that form the "human-understandable" prefix) const ARITY: Record = { - "git": 2, // "git checkout", "git commit", etc. - "npm": 3, // "npm run dev", "npm install -g" - "docker": 2, // "docker compose", "docker build" - "kubectl": 2, // "kubectl get", "kubectl apply" - "bun": 2, // "bun install", "bun test" - "cargo": 2, // "cargo build", "cargo test" - "go": 2, // "go build", "go test" - "python": 2, // "python -m", "python script.py" - "python3": 2, - "pip": 2, - "pip3": 2, - "brew": 2, - "apt": 2, + git: 2, // "git checkout", "git commit", etc. + npm: 3, // "npm run dev", "npm install -g" + docker: 2, // "docker compose", "docker build" + kubectl: 2, // "kubectl get", "kubectl apply" + bun: 2, // "bun install", "bun test" + cargo: 2, // "cargo build", "cargo test" + go: 2, // "go build", "go test" + python: 2, // "python -m", "python script.py" + python3: 2, + pip: 2, + pip3: 2, + brew: 2, + apt: 2, "apt-get": 2, - "dnf": 2, - "yum": 2, - "pacman": 2, - "systemctl": 2, - "journalctl": 2, - "ssh": 2, - "scp": 2, - "rsync": 2, - "curl": 2, // "curl -X", "curl https://" - "wget": 2, - "tar": 2, // "tar -xzf", "tar -czf" - "zip": 2, - "unzip": 2, - "chown": 2, - "chmod": 2, - "mount": 2, - "umount": 2, + dnf: 2, + yum: 2, + pacman: 2, + systemctl: 2, + journalctl: 2, + ssh: 2, + scp: 2, + rsync: 2, + curl: 2, // "curl -X", "curl https://" + wget: 2, + tar: 2, // "tar -xzf", "tar -czf" + zip: 2, + unzip: 2, + chown: 2, + chmod: 2, + mount: 2, + umount: 2, // Default: all other commands are arity 1 }; diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts index d549316..608c91d 100644 --- a/packages/core/src/tools/run-shell.ts +++ b/packages/core/src/tools/run-shell.ts @@ -11,12 +11,12 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.", parameters: z.object({ command: z.string().describe("The shell command to execute"), - timeout: z - .number() - .optional() - .describe("Timeout in milliseconds (default 2 minutes)"), + timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), }), - execute: async (args: Record, context?: ToolExecuteContext): Promise => { + execute: async ( + args: Record, + context?: ToolExecuteContext, + ): Promise => { const command = args.command as string; const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; @@ -68,7 +68,5 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { } function getShell(): [string, string[]] { - return process.platform === "win32" - ? ["powershell", ["-Command"]] - : ["bash", ["-c"]]; + return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]]; } diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts index 23bbfc9..b70108b 100644 --- a/packages/core/src/tools/shell-analyze.ts +++ b/packages/core/src/tools/shell-analyze.ts @@ -1,6 +1,6 @@ -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import * as BashArity from "./bash-arity.js"; // Commands that touch files — triggers external_directory check. @@ -12,9 +12,27 @@ import * as BashArity from "./bash-arity.js"; // - `cd` state changes: we don't track cwd mutations across pipeline stages // - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely const FILE_COMMANDS = new Set([ - "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", - "cat", "ls", "find", "grep", - "head", "tail", "less", "more", "wc", "diff", "file", "stat", "du", "df", + "rm", + "cp", + "mv", + "mkdir", + "touch", + "chmod", + "chown", + "cat", + "ls", + "find", + "grep", + "head", + "tail", + "less", + "more", + "wc", + "diff", + "file", + "stat", + "du", + "df", ]); // Lazy-initialized parser @@ -142,6 +160,7 @@ function isInsideWorkspace(filePath: string, wd: string): boolean { // rel === "" means filePath IS the workspace root — that is inside. // If relative path starts with "../" or is ".." exactly, or is an absolute path // (on Windows when drives differ), the file is outside the workspace. - const isOutside = rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); + const isOutside = + rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); return !isOutside; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index eaf8669..8f80b08 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -110,7 +110,7 @@ export interface KeyState { // ─── Skills Types ──────────────────────────────────────────────── export type SkillScope = "global" | "project"; -export type SkillDirectory = "default" | "agents" | "project"; +export type SkillDirectory = string; export interface SkillDefinition { name: string; @@ -146,3 +146,29 @@ export interface ConfigError { path: string; message: string; } + +// ─── Agent Definition Types ────────────────────────────────────── + +export interface AgentModelEntry { + key_id: string; + model_id: string; +} + +export interface AgentDefinition { + /** Human-readable name */ + name: string; + /** Short description of what this agent does */ + description: string; + /** Skills to auto-include, as "scope:name" strings */ + skills: string[]; + /** Allowed tools (allowlist) */ + tools: string[]; + /** Key+model fallback hierarchy, tried in order */ + models: AgentModelEntry[]; + /** Where the TOML was loaded from: "global" or a directory path */ + scope: string; + /** The slug (filename without .toml) */ + slug: string; + /** Default working directory for this agent (optional, absolute path) */ + cwd?: string; +} diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts index 5c02326..a572425 100644 --- a/packages/core/tests/config/loader.test.ts +++ b/packages/core/tests/config/loader.test.ts @@ -1,7 +1,7 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, sep } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { writeFileSync, mkdirSync, rmSync } from "node:fs"; import { configToRuleset, loadConfig } from "../../src/config/loader.js"; const TMP = join("/tmp/opencode", "dispatch-config-test"); diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts index 9b5439e..2a98556 100644 --- a/packages/core/tests/llm/provider.test.ts +++ b/packages/core/tests/llm/provider.test.ts @@ -31,16 +31,21 @@ const { createProvider } = await import("../../src/llm/provider.js"); // A helper that runs the middleware's transformParams on a prompt // and returns the resulting normalized prompt. -async function runTransform( - prompt: unknown[], -): Promise { +async function runTransform(prompt: unknown[]): Promise { const wrappedModel = createProvider({ apiKey: "test-key", baseURL: "https://example.com/v1", })("test-model"); const middleware = ( - (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record }) => Promise }> } + wrappedModel as unknown as { + _middleware: Array<{ + transformParams: (args: { + type: string; + params: Record; + }) => Promise; + }>; + } )._middleware; const result = await middleware[0]!.transformParams({ @@ -59,7 +64,14 @@ describe("createProvider middleware", () => { })("test-model"); const middleware = ( - (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record }) => Promise }> } + wrappedModel as unknown as { + _middleware: Array<{ + transformParams: (args: { + type: string; + params: Record; + }) => Promise; + }>; + } )._middleware; const params = { prompt: [], temperature: 0.5 }; @@ -108,9 +120,7 @@ describe("createProvider middleware", () => { const prompt = [ { role: "assistant", - content: [ - { type: "text", text: "Hello!" }, - ], + content: [{ type: "text", text: "Hello!" }], }, ]; @@ -141,9 +151,7 @@ describe("createProvider middleware", () => { }); it("does not modify system messages", async () => { - const prompt = [ - { role: "system", content: "You are a helpful assistant." }, - ]; + const prompt = [{ role: "system", content: "You are a helpful assistant." }]; const normalized = await runTransform(prompt); expect(normalized).toEqual(prompt); @@ -274,11 +282,17 @@ describe("createProvider middleware", () => { const normalized = await runTransform(prompt); const msg1 = normalized[0] as Record; - const compat1 = (msg1.providerMetadata as Record).openaiCompatible as Record; + const compat1 = (msg1.providerMetadata as Record).openaiCompatible as Record< + string, + unknown + >; expect(compat1.reasoning_content).toBe("First thought."); const msg2 = normalized[1] as Record; - const compat2 = (msg2.providerMetadata as Record).openaiCompatible as Record; + const compat2 = (msg2.providerMetadata as Record).openaiCompatible as Record< + string, + unknown + >; expect(compat2.reasoning_content).toBe("Second thought."); }); }); diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 8288dfd..33415bd 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -1,16 +1,18 @@ + + + +
+ +
+

Agent Settings

+ +
+ +
+ {#if error} +
{error}
+ {/if} + + {#if editing} + +
+
+

{editingSlug ? "Edit Agent" : "New Agent"}

+ + {#if formError} +
{formError}
+ {/if} + + +
+ + + {#if formName} + slug: {slugify(formName)} + {/if} +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + {#if formCwd.trim()} + {#if cwdExists === true} + + {:else if cwdExists === false} + + {:else} + + {/if} + {/if} +
+ {#if cwdExists === false && formCwd.trim()} + Directory will be created automatically on first message. + {:else} + Absolute path. Leave empty to use the project root. + {/if} +
+ + +
+
+ Models * +
+
+ {#each formModels as entry, i (i)} +
{ + dragIndex = i; + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }} + ondragover={(e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragOverIndex = i; + }} + ondragleave={() => { + if (dragOverIndex === i) dragOverIndex = null; + }} + ondrop={(e) => { + e.preventDefault(); + if (dragIndex !== null && dragIndex !== i) { + const reordered = [...formModels]; + const moved = reordered.splice(dragIndex, 1)[0]; + if (moved) { + reordered.splice(i, 0, moved); + formModels = reordered; + } + } + dragIndex = null; + dragOverIndex = null; + }} + ondragend={() => { dragIndex = null; dragOverIndex = null; }} + > + {i + 1} + + + +
+ {/each} +
+ +
+ + +
+
+ Tools +
+ +
+ + +
+
+ Skills +
+ +
+ + +
+ {#if editingSlug && !(editingSlug === "default" && formScope === "global")} + + {:else} +
+ {/if} +
+ {#if saving} + Saving... + {/if} +
+
+
+
+
+ {:else} + + {#if loading} +
+ +
+ {:else} + +
+

Global Agents

+
+ {#each globalAgents as agent} + + {/each} + +
+
+ + + {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")} +
+

Project Agents

+ {#if projectAgents.length === 0} +

No project agents yet.

+ {:else} +
+ {#each projectAgents as agent} + + {/each} +
+ {/if} +
+ {/if} + {/if} + {/if} +
+
+ + +{#if modelModalIndex !== null && modelModalType === "key"} + +{/if} + + +{#if modelModalIndex !== null && modelModalType === "model"} + +{/if} + + +{#if deletingAgent !== null} + {@const agentToDelete = deletingAgent} + +{/if} diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 8910bb4..21c85fe 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -24,13 +24,13 @@ function submit() { } -
+
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte index bcefbcf..baddb73 100644 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ b/packages/frontend/src/lib/components/ClaudeReset.svelte @@ -1,151 +1,152 @@
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte index 25cbe98..f517d28 100644 --- a/packages/frontend/src/lib/components/ConfigPanel.svelte +++ b/packages/frontend/src/lib/components/ConfigPanel.svelte @@ -1,94 +1,101 @@
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index ada5500..06b8ac1 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -1,4 +1,5 @@ {#if visible} diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index f5f7d6d..fc85739 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -1,201 +1,197 @@
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte index f73140f..0fbf314 100644 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte @@ -1,172 +1,172 @@
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte index 0af5ae6..21364f9 100644 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -1,10 +1,22 @@ -
-
- Key - + +
+ +
+ { + const val = e.currentTarget.value.trim(); + onWorkingDirectoryChange(val || null); + }} + /> + {#if workingDirectory} + {#if cwdExists === true} + + {:else if cwdExists === false} + + {:else} + + {/if} + {/if} +
-
- Model - +
- {#if activeModelId} + {#if mode === "manual"} +
+ Key + +
+
- Thinking - + Model +
+ + {#if activeModelId} +
+ Thinking + +
+ {/if} + {:else} + + {#if loadingAgents} +
+ + Loading agents... +
+ {:else if agents.length === 0} +

No agents configured.

+ {:else} +
+ {#each agents as agent (agent.slug + ":" + agent.scope)} + + {/each} +
+ {/if} + {/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index a627de8..1270fcc 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -1,148 +1,154 @@
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte index ce7afd7..3a67b30 100644 --- a/packages/frontend/src/lib/components/PermissionPrompt.svelte +++ b/packages/frontend/src/lib/components/PermissionPrompt.svelte @@ -1,7 +1,10 @@
@@ -103,26 +106,30 @@

Title Generation Model

Used to generate short titles for new tabs after the first message.

- - + - - +
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index a0aeb1d..041679a 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -7,7 +7,6 @@ import ModelSelector from "./ModelSelector.svelte"; import ModelStatus from "./ModelStatus.svelte"; import SettingsPanel from "./SettingsPanel.svelte"; import SkillsBrowser from "./SkillsBrowser.svelte"; -import SystemPromptPanel from "./SystemPromptPanel.svelte"; import TaskListPanel from "./TaskListPanel.svelte"; import ToolPermissions from "./ToolPermissions.svelte"; @@ -19,9 +18,13 @@ const { activeKeyId = null, activeModelId = null, reasoningEffort = "max", + activeAgentSlug = null as string | null, + workingDirectory = null as string | null, onKeyChange, onModelChange, onReasoningChange, + onAgentChange = (_agent: any) => {}, + onWorkingDirectoryChange = (_dir: string | null) => {}, }: { keys?: KeyInfo[]; tasks?: TaskItem[]; @@ -30,9 +33,13 @@ const { activeKeyId?: string | null; activeModelId?: string | null; reasoningEffort?: string; + activeAgentSlug?: string | null; + workingDirectory?: string | null; onKeyChange: (keyId: string) => void; onModelChange: (keyId: string, modelId: string) => void; onReasoningChange: (effort: string) => void; + onAgentChange?: (agent: any) => void; + onWorkingDirectoryChange?: (dir: string | null) => void; } = $props(); interface Panel { @@ -41,11 +48,11 @@ interface Panel { } let nextId = 0; -let panels = $state([{ id: nextId++, selected: "Model Choice" }]); +let panels = $state([{ id: nextId++, selected: "Chat Settings" }]); const viewOptions = [ "Select a view", - "Model Choice", + "Chat Settings", "Key Usage", "Claude Reset", "Model Status", @@ -53,7 +60,6 @@ const viewOptions = [ "Config", "Skills", "Tools", - "System Prompt", "Settings", ]; @@ -104,16 +110,20 @@ function contentClass(selected: string): string {
- {#if panel.selected === "Model Choice"} - + {#if panel.selected === "Chat Settings"} + {:else if panel.selected === "Key Usage"} {:else if panel.selected === "Claude Reset"} @@ -128,16 +138,14 @@ function contentClass(selected: string): string { {:else if panel.selected === "Tools"} - {:else if panel.selected === "System Prompt"} - - {:else if panel.selected === "Settings"} + {:else if panel.selected === "Settings"} {/if}
{/each} -
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte index 685f890..add43e4 100644 --- a/packages/frontend/src/lib/components/SkillsBrowser.svelte +++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte @@ -7,7 +7,7 @@ interface Skill { description: string; tags: string[]; scope: "global" | "project"; - directory: "default" | "agents" | "project"; + directory: string; } interface SkillsResponse { @@ -20,7 +20,27 @@ interface SkillDetail extends Skill { source: string; } -const { apiBase }: { apiBase: string } = $props(); +interface DirGroup { + path: string; + label: string; + scope: "global" | "project"; + skills: Skill[]; +} + +const { + apiBase, + checkedSkills = null, + onSkillToggle = null, +}: { + apiBase: string; + /** External checked set (agent builder mode). When null, uses appSettings. */ + checkedSkills?: Set | null; + /** Callback when a skill is toggled in external mode. */ + onSkillToggle?: ((key: string, checked: boolean) => void) | null; +} = $props(); + +/** Whether we're in external (agent builder) mode */ +const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null); let skills = $state([]); let loading = $state(false); @@ -28,6 +48,7 @@ let error = $state(null); let expandedSkill = $state(null); let expandedDetail = $state(null); let loadingDetail = $state(false); +let collapsedDirs = $state>(new Set()); async function fetchSkills() { loading = true; @@ -48,23 +69,64 @@ function skillKey(skill: Skill): string { return `${skill.scope}:${skill.name}`; } +/** Build a unique key for a directory group (scope + path) */ +function dirKey(group: DirGroup): string { + return `${group.scope}:${group.path}`; +} + function isChecked(skill: Skill): boolean { - return appSettings.skillChecks[skillKey(skill)] === true; + const key = skillKey(skill); + if (externalMode) { + return checkedSkills!.has(key); + } + return appSettings.skillChecks[key] === true; } function isInjected(skill: Skill): boolean { + if (externalMode) return false; return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false; } function toggleCheck(skill: Skill): void { const key = skillKey(skill); + if (externalMode) { + onSkillToggle!(key, !checkedSkills!.has(key)); + return; + } appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) }; } function resetChecks(): void { + if (externalMode) return; appSettings.skillChecks = {}; } +function toggleDir(key: string): void { + const next = new Set(collapsedDirs); + if (next.has(key)) next.delete(key); + else next.add(key); + collapsedDirs = next; +} + +/** Check if a group is hidden because an ancestor directory is collapsed */ +function isHiddenByParent(group: DirGroup): boolean { + if (!group.path.includes("/")) return false; + // Check each ancestor path segment + const parts = group.path.split("/"); + for (let i = 1; i < parts.length; i++) { + const ancestorPath = parts.slice(0, i).join("/"); + const ancestorKey = `${group.scope}:${ancestorPath}`; + if (collapsedDirs.has(ancestorKey)) return true; + } + return false; +} + +/** Get the top-level directory for grouping spacing */ +function topLevelDir(group: DirGroup): string { + const slash = group.path.indexOf("/"); + return slash === -1 ? group.path : group.path.slice(0, slash); +} + async function toggleExpand(skill: Skill) { const key = skillKey(skill); if (expandedSkill === key) { @@ -92,7 +154,37 @@ $effect(() => { fetchSkills(); }); -const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) => v).length); +const checkedCount = $derived( + externalMode + ? checkedSkills!.size + : Object.values(appSettings.skillChecks).filter((v) => v).length, +); + +/** Group skills by scope + directory, sorted */ +const dirGroups = $derived.by((): DirGroup[] => { + const map = new Map(); + for (const skill of skills) { + const key = `${skill.scope}:${skill.directory}`; + let group = map.get(key); + if (!group) { + const label = skill.directory || "(root)"; + group = { path: skill.directory, label, scope: skill.scope, skills: [] }; + map.set(key, group); + } + group.skills.push(skill); + } + // Sort: global before project, then alphabetically by path + const groups = Array.from(map.values()); + groups.sort((a, b) => { + if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1; + return a.path.localeCompare(b.path); + }); + // Sort skills within each group alphabetically + for (const g of groups) { + g.skills.sort((a, b) => a.name.localeCompare(b.name)); + } + return groups; +});
@@ -102,7 +194,7 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) {skills.length} {/if} {#if checkedCount > 0} - {checkedCount} queued + {checkedCount} {externalMode ? 'selected' : 'queued'} {/if}
-

Check skills to inject with your next message.

+ {#if !externalMode} +

Check skills to inject with your next message.

+ {/if} {#if loading}
@@ -127,64 +221,97 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) No skills found. Create .skills/ directories to get started.

{:else} -
- {#each skills as skill (skillKey(skill))} - {@const key = skillKey(skill)} - {@const checked = isChecked(skill)} - {@const injected = isInjected(skill)} -
- - - {#if expandedSkill === key} -
- {#if loadingDetail} - - {:else if expandedDetail} -
{expandedDetail.content}
- {:else} -

Failed to load skill content.

+
+ {#each dirGroups as group, idx (dirKey(group))} + {@const collapsed = collapsedDirs.has(dirKey(group))} + {@const hidden = isHiddenByParent(group)} + {@const prevGroup = dirGroups[idx - 1]} + {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope} + {#if !hidden} + {#if isNewTopLevel && idx > 0} +
+ {/if} +
+
+ + + + + {#if !collapsed} +
+ {#each group.skills as skill (skillKey(skill))} + {@const key = skillKey(skill)} + {@const checked = isChecked(skill)} + {@const injected = isInjected(skill)} +
+ + + {#if expandedSkill === key} +
+ {#if loadingDetail} + + {:else if expandedDetail} +
{expandedDetail.content}
+ {:else} +

Failed to load skill content.

+ {/if} +
+ {/if} +
+ {/each} +
{/if}
- {/if} -
+
+ {/if} {/each}
{/if} - + {#if !externalMode} + + {/if}
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte index 6b91ebe..d9039f4 100644 --- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte +++ b/packages/frontend/src/lib/components/SystemPromptPanel.svelte @@ -1,42 +1,42 @@
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index cfa5887..feb1e5b 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -25,12 +25,13 @@ const activeUserTabId = $derived(
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} >
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} > @@ -72,7 +73,7 @@ const activeUserTabId = $derived( {#if hasSubagentTabs} -
+
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte index fe12cc5..418fcea 100644 --- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte +++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte @@ -1,8 +1,18 @@
Tool Permissions
-

Changes are applied when you send your next message.

+ {#if !externalMode} +

Changes are applied when you send your next message.

+ {/if}
{#each toolPermissions as perm (perm.id)} @@ -73,7 +96,7 @@ onMount(() => { togglePermission(perm.id)} />
@@ -84,35 +107,37 @@ onMount(() => { {/each}
- + {#if !externalMode} + -

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

+

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

- - {#if entries.length > 0} -
- -
- Log ({entries.length}) -
-
- {#each entries as entry (entry.id)} -
- - {entry.action} - - {entry.permission} - {entry.timestamp} -
-

{entry.description}

- {/each} + + {#if entries.length > 0} +
+ +
+ Log ({entries.length}) +
+
+ {#each entries as entry (entry.id)} +
+ + {entry.action} + + {entry.permission} + {entry.timestamp} +
+

{entry.description}

+ {/each} +
-
+ {/if} {/if}
diff --git a/packages/frontend/src/lib/router.svelte.ts b/packages/frontend/src/lib/router.svelte.ts new file mode 100644 index 0000000..8bed880 --- /dev/null +++ b/packages/frontend/src/lib/router.svelte.ts @@ -0,0 +1,12 @@ +type Page = "dashboard" | "agent-builder"; + +let currentPage = $state("dashboard"); + +export const router = { + get page() { + return currentPage; + }, + navigate(page: Page) { + currentPage = page; + }, +}; diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 15a0736..7722a7e 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -38,6 +38,12 @@ export interface Tab { parentTabId: string | null; /** Persistent tabs stay until manually closed. Temp tabs disappear when agent finishes. */ persistent: boolean; + /** Slug of the selected agent, or null for manual mode */ + agentSlug: string | null; + /** Scope of the selected agent */ + agentScope: string | null; + /** Custom working directory override for this tab */ + workingDirectory: string | null; } function createTabStore() { @@ -96,12 +102,18 @@ function createTabStore() { injectedSkills: [], parentTabId: null, persistent: true, + agentSlug: null, + agentScope: null, + workingDirectory: null, }; tabs = [...tabs, tab]; activeTabId = id; - // Auto-check default skills for injection with the first message - void autoCheckDefaultSkills(); + // Auto-check default skills then apply default agent (sequential to avoid race) + void (async () => { + await autoCheckDefaultSkills(); + await autoSelectDefaultAgent(id); + })(); return tab; } @@ -181,6 +193,9 @@ function createTabStore() { injectedSkills: [], parentTabId: tabData.parentTabId ?? null, persistent: true, + agentSlug: null, + agentScope: null, + workingDirectory: null, }; tabs = [...tabs, newTab]; activeTabId = agentId; @@ -209,7 +224,7 @@ function createTabStore() { if (fallback && !fallback.persistent) { updateTab(fallback.id, { persistent: true }); } - activeTabId = fallback?.id; + activeTabId = fallback?.id ?? null; } else { await createNewTab(); } @@ -447,6 +462,9 @@ function createTabStore() { injectedSkills: [], parentTabId: newTabEvent.parentTabId ?? null, persistent: newTabEvent.parentTabId == null, + agentSlug: null, + agentScope: null, + workingDirectory: null, }; tabs = [...tabs, tab]; } @@ -478,6 +496,73 @@ function createTabStore() { } } + async function autoSelectDefaultAgent(tabId: string): Promise { + try { + const res = await fetch(`${config.apiBase}/agents`); + if (!res.ok) return; + const data = (await res.json()) as { + agents?: Array<{ + slug: string; + scope: string; + name: string; + skills: string[]; + tools: string[]; + models: Array<{ key_id: string; model_id: string }>; + cwd?: string; + }>; + }; + const agents = data.agents ?? []; + const defaultAgent = agents.find((a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global"); + if (!defaultAgent) return; + + const tab = getTabById(tabId); + if (!tab) return; + + // Apply the default agent + const firstModel = defaultAgent.models[0]; + const patch: Partial = { + agentSlug: defaultAgent.slug, + agentScope: defaultAgent.scope, + workingDirectory: defaultAgent.cwd || null, + }; + if (firstModel) { + patch.keyId = firstModel.key_id; + patch.modelId = firstModel.model_id; + } + updateTab(tabId, patch); + + // Merge the agent's skills into existing checked skills + if (defaultAgent.skills.length > 0) { + const checks: Record = { ...appSettings.skillChecks }; + for (const skillKey of defaultAgent.skills) { + checks[skillKey] = true; + } + appSettings.skillChecks = checks; + } + + // Apply tool permissions + const perms: Record = {}; + for (const key of Object.keys(appSettings.toolPerms)) { + perms[key] = false; + } + for (const tool of defaultAgent.tools) { + perms[tool] = true; + } + appSettings.toolPerms = perms; + + // Persist to backend + if (firstModel) { + fetch(`${config.apiBase}/tabs/${tabId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: firstModel.key_id, modelId: firstModel.model_id }), + }).catch(() => {}); + } + } catch { + // Silently ignore + } + } + async function fetchSkillContent(scope: string, name: string): Promise { try { const res = await fetch( @@ -584,6 +669,7 @@ function createTabStore() { ...(tab.keyId ? { keyId: tab.keyId } : {}), ...(tab.modelId ? { modelId: tab.modelId } : {}), reasoningEffort: tab.reasoningEffort, + ...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}), }), }); if (!res.ok) { @@ -639,6 +725,71 @@ function createTabStore() { }).catch(() => {}); } + function setWorkingDirectory(dir: string | null): void { + const tab = getActiveTab(); + if (!tab) return; + updateTab(tab.id, { workingDirectory: dir || null }); + } + + function setAgent( + agent: { + slug: string; + scope: string; + skills: string[]; + tools: string[]; + models: Array<{ key_id: string; model_id: string }>; + cwd?: string; + } | null, + ): void { + const tab = getActiveTab(); + if (!tab) return; + + if (!agent) { + // Switch back to manual mode — clear agent + updateTab(tab.id, { agentSlug: null, agentScope: null }); + return; + } + + // Apply agent's first model as the active key+model + const firstModel = agent.models[0]; + const patch: Partial = { + agentSlug: agent.slug, + agentScope: agent.scope, + workingDirectory: agent.cwd || null, + }; + if (firstModel) { + patch.keyId = firstModel.key_id; + patch.modelId = firstModel.model_id; + } + updateTab(tab.id, patch); + + // Reset and apply the agent's skills (don't accumulate from previous agents) + const checks: Record = {}; + for (const skillKey of agent.skills) { + checks[skillKey] = true; + } + appSettings.skillChecks = checks; + + // Always reset tool permissions to agent's allowlist (even if empty) + const perms: Record = {}; + for (const key of Object.keys(appSettings.toolPerms)) { + perms[key] = false; + } + for (const tool of agent.tools) { + perms[tool] = true; + } + appSettings.toolPerms = perms; + + // Persist to backend + fetch(`${config.apiBase}/tabs/${tab.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...(firstModel ? { keyId: firstModel.key_id, modelId: firstModel.model_id } : {}), + }), + }).catch(() => {}); + } + function replyPermission(id: string, reply: "once" | "always" | "reject"): void { if (wsClient.connectionStatus !== "connected") return; const prompt = pendingPermissions.find((p) => p.id === id); @@ -722,10 +873,12 @@ function createTabStore() { sendMessage, changeModel, setKey, + setAgent, replyPermission, copyConversation, promoteTab, openAgentTab, + setWorkingDirectory, }; } diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index a56ac13..978bce1 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -87,15 +87,15 @@ function createTestStore(wsSend?: (data: unknown) => void) { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - }, - ]; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }, + ]; return { ...m, content: segments }; } return m; @@ -109,7 +109,11 @@ function createTestStore(wsSend?: (data: unknown) => void) { ...m, content: m.content.map((seg) => { if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { - return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; + return { + ...seg, + result: event.toolResult.result, + isError: event.toolResult.isError, + }; } return seg; }), @@ -338,7 +342,9 @@ describe("chat store logic", () => { it("error event adds an error message and sets status to error", () => { store.handleEvent({ type: "error", error: "something went wrong" }); expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.content).toEqual([{ type: "text", text: "Error: something went wrong" }]); + expect(store.messages[0]?.content).toEqual([ + { type: "text", text: "Error: something went wrong" }, + ]); expect(store.agentStatus).toBe("error"); }); @@ -529,7 +535,9 @@ describe("permission log", () => { }); // Shell output parsing logic (mirrors ToolCallDisplay logic) -function parseShellResult(result: string): { stdout: string; stderr: string; exitCode: number } | null { +function parseShellResult( + result: string, +): { stdout: string; stderr: string; exitCode: number } | null { try { const parsed = JSON.parse(result) as unknown; if ( -- cgit v1.2.3