summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
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
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')
-rw-r--r--packages/api/src/agent-manager.ts61
-rw-r--r--packages/api/src/app.ts23
-rw-r--r--packages/api/src/index.ts8
-rw-r--r--packages/api/src/permission-manager.ts2
-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
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);