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/routes | |
| 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/routes')
| -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 |
5 files changed, 152 insertions, 27 deletions
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); |
