diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
| commit | 9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch) | |
| tree | 4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/api/src | |
| parent | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff) | |
| download | dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip | |
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
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 61 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 23 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 8 | ||||
| -rw-r--r-- | packages/api/src/permission-manager.ts | 2 | ||||
| -rw-r--r-- | packages/api/src/routes/agents.ts | 109 | ||||
| -rw-r--r-- | packages/api/src/routes/config.ts | 2 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 29 | ||||
| -rw-r--r-- | packages/api/src/routes/skills.ts | 11 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 28 |
9 files changed, 230 insertions, 43 deletions
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<void> { 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<AgentDefinition>(); + // 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<string, unknown>)._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); |
