summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/routes
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/api/src/routes
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-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.ts109
-rw-r--r--packages/api/src/routes/config.ts2
-rw-r--r--packages/api/src/routes/models.ts29
-rw-r--r--packages/api/src/routes/skills.ts11
-rw-r--r--packages/api/src/routes/tabs.ts28
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);