diff options
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 212 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 19 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 8 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 81 |
4 files changed, 252 insertions, 68 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 6e78ad7..07af249 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -19,6 +19,7 @@ import { TaskList, createTaskListTool, type ClaudeAccount, + appendMessage, getClaudeAccountsFromDB, refreshAccountCredentials, refreshAccountCredentialsAsync, @@ -28,6 +29,7 @@ import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; import { setSkillsGetter } from "./routes/skills.js"; import { setModelsGetter, setAccountsGetter } from "./routes/models.js"; +import { setTabsAgentManager } from "./routes/tabs.js"; const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory: @@ -39,20 +41,23 @@ const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`; +interface TabAgent { + agent: Agent | null; + status: AgentStatus; + keyId: string | null; + modelId: string | null; + taskList: TaskList; +} + export class AgentManager { - private agent: Agent | null = null; - private status: AgentStatus = "idle"; + private tabAgents: Map<string, TabAgent> = new Map(); private messageCount = 0; - private eventListeners: Set<(event: AgentEvent) => void> = new Set(); + private eventListeners: Set<(event: AgentEvent & { tabId: string }) => void> = new Set(); private permissionManager: PermissionManager | undefined; - activeModelId: string | null = null; - activeKeyId: string | null = null; - private config: DispatchConfig; private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }; private modelRegistry: ModelRegistry | null = null; - private taskList: TaskList; private configWatcher: { close(): void } | null = null; private skillsWatcher: { close(): void } | null = null; @@ -89,12 +94,7 @@ export class AgentManager { () => this.modelRegistry, ); setAccountsGetter(() => this.claudeAccounts); - - // Set up task list - this.taskList = new TaskList(); - this.taskList.onChange((tasks) => { - this.emit({ type: "task-list-update", tasks }); - }); + setTabsAgentManager(() => this); // Set up hot-reload watchers this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => { @@ -107,16 +107,26 @@ export class AgentManager { } // Update model registry with new config this._initModelRegistry(newConfig); - // Invalidate cached agent so next message uses updated config - this.agent = null; - this.emit({ type: "config-reload" }); + // Invalidate cached agents so next message uses updated config + for (const tabAgent of this.tabAgents.values()) { + tabAgent.agent = null; + } + // Emit config-reload to all tabs + for (const tabId of this.tabAgents.keys()) { + this.emit({ type: "config-reload" }, tabId); + } }); this.skillsWatcher = createSkillsWatcher(workingDirectory, (result) => { this.skillsData = result; - // Invalidate cached agent so next message uses updated skills - this.agent = null; - this.emit({ type: "config-reload" }); + // Invalidate cached agents so next message uses updated skills + for (const tabAgent of this.tabAgents.values()) { + tabAgent.agent = null; + } + // Emit config-reload to all tabs + for (const tabId of this.tabAgents.keys()) { + this.emit({ type: "config-reload" }, tabId); + } }); } @@ -147,28 +157,51 @@ export class AgentManager { return this.permissionManager; } - getTaskList(): TaskList { - return this.taskList; + /** Get the TaskList for a specific tab (creates the tab entry if missing). */ + getTaskList(tabId: string): TaskList { + return this._getOrCreateTabAgent(tabId).taskList; } getClaudeAccounts(): ClaudeAccount[] { return this.claudeAccounts; } - private async getOrCreateAgent(keyId?: string, modelId?: string): Promise<Agent> { - // Determine effective override: use provided values, or fall back to stored active values - const effectiveKeyId = keyId ?? this.activeKeyId ?? undefined; - const effectiveModelId = modelId ?? this.activeModelId ?? undefined; + /** Get or create the TabAgent entry for a tab (without creating an Agent). */ + private _getOrCreateTabAgent(tabId: string): TabAgent { + let tabAgent = this.tabAgents.get(tabId); + if (!tabAgent) { + const taskList = new TaskList(); + taskList.onChange((tasks) => { + this.emit({ type: "task-list-update", tasks }, tabId); + }); + tabAgent = { + agent: null, + status: "idle", + keyId: null, + modelId: null, + taskList, + }; + this.tabAgents.set(tabId, tabAgent); + } + return tabAgent; + } + + private async getOrCreateAgentForTab(tabId: string, keyId?: string, modelId?: string): Promise<Agent> { + const tabAgent = this._getOrCreateTabAgent(tabId); + + // Determine effective override: use provided values, or fall back to stored per-tab values + const effectiveKeyId = keyId ?? tabAgent.keyId; + const effectiveModelId = modelId ?? tabAgent.modelId; // If the override differs from what the current agent was built with, invalidate the cache if ( - this.agent && - (effectiveKeyId !== this.activeKeyId || effectiveModelId !== this.activeModelId) + tabAgent.agent && + (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId) ) { - this.agent = null; + tabAgent.agent = null; } - if (!this.agent) { + if (!tabAgent.agent) { const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); const tools = [ @@ -176,7 +209,7 @@ export class AgentManager { createWriteFileTool(workingDirectory), createListFilesTool(workingDirectory), createRunShellTool(workingDirectory), - createTaskListTool(this.taskList), + createTaskListTool(tabAgent.taskList), ]; const ruleset = configToRuleset(this.config); @@ -208,8 +241,8 @@ export class AgentManager { baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; - this.activeKeyId = effectiveKeyId; - this.activeModelId = effectiveModelId; + tabAgent.keyId = effectiveKeyId; + tabAgent.modelId = effectiveModelId; useOverride = true; } else { // Token expired — await the async refresh @@ -221,8 +254,8 @@ export class AgentManager { baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; - this.activeKeyId = effectiveKeyId; - this.activeModelId = effectiveModelId; + tabAgent.keyId = effectiveKeyId; + tabAgent.modelId = effectiveModelId; useOverride = true; } else { console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`); @@ -231,8 +264,8 @@ export class AgentManager { baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; - this.activeKeyId = effectiveKeyId; - this.activeModelId = effectiveModelId; + tabAgent.keyId = effectiveKeyId; + tabAgent.modelId = effectiveModelId; useOverride = true; } } @@ -246,13 +279,13 @@ export class AgentManager { apiKey = envKey; baseURL = key.base_url; model = effectiveModelId; - this.activeKeyId = effectiveKeyId; - this.activeModelId = effectiveModelId; + tabAgent.keyId = effectiveKeyId; + tabAgent.modelId = effectiveModelId; useOverride = true; } else { console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`); - this.activeKeyId = effectiveKeyId; - this.activeModelId = effectiveModelId; + tabAgent.keyId = effectiveKeyId; + tabAgent.modelId = effectiveModelId; useOverride = true; } } @@ -263,11 +296,11 @@ export class AgentManager { if (!useOverride) { // Clear any previous override when falling back to default resolution - this.activeKeyId = null; - this.activeModelId = null; + tabAgent.keyId = null; + tabAgent.modelId = null; } - this.agent = new Agent({ + tabAgent.agent = new Agent({ model, apiKey, baseURL, @@ -280,45 +313,112 @@ export class AgentManager { ...(claudeCredentials ? { claudeCredentials } : {}), }); } - return this.agent; + return tabAgent.agent; + } + + getTabStatus(tabId: string): AgentStatus { + return this.tabAgents.get(tabId)?.status ?? "idle"; + } + + getAllStatuses(): Record<string, AgentStatus> { + const result: Record<string, AgentStatus> = {}; + for (const [tabId, tabAgent] of this.tabAgents.entries()) { + result[tabId] = tabAgent.status; + } + return result; } + /** @deprecated Use getTabStatus(tabId) instead */ getStatus(): AgentStatus { - return this.status; + // Return running if any tab is running, otherwise idle + for (const tabAgent of this.tabAgents.values()) { + if (tabAgent.status === "running") return "running"; + } + return "idle"; } getMessageCount(): number { return this.messageCount; } - onEvent(listener: (event: AgentEvent) => void): () => void { + onEvent(listener: (event: AgentEvent & { tabId: string }) => void): () => void { this.eventListeners.add(listener); return () => { this.eventListeners.delete(listener); }; } - private emit(event: AgentEvent): void { + private emit(event: AgentEvent, tabId: string): void { for (const listener of this.eventListeners) { - listener(event); + listener({ ...event, tabId } as AgentEvent & { tabId: string }); } } - async processMessage(message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> { - this.status = "running"; + stopTab(tabId: string): void { + const tabAgent = this.tabAgents.get(tabId); + if (tabAgent) { + tabAgent.status = "idle"; + tabAgent.agent = null; + } + } + + deleteTab(tabId: string): void { + this.stopTab(tabId); + this.tabAgents.delete(tabId); + } + + async processMessage(tabId: string, message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> { + const tabAgent = this._getOrCreateTabAgent(tabId); + tabAgent.status = "running"; this.messageCount += 1; try { - const agent = await this.getOrCreateAgent(keyId, modelId); + const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId); + + // Persist user message to DB + appendMessage(tabId, crypto.randomUUID(), "user", JSON.stringify([{ type: "text", text: message }])); + + let assistantText = ""; + let assistantThinking = ""; + const assistantToolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown>; result?: string; isError?: boolean }> = []; + for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) { - this.status = event.type === "status" ? event.status : this.status; - this.emit(event); + if (event.type === "status") { + tabAgent.status = event.status; + } + this.emit(event, tabId); + + // Accumulate content for DB persistence + if (event.type === "text-delta") { + assistantText += event.delta; + } else if (event.type === "reasoning-delta") { + assistantThinking += event.delta; + } else if (event.type === "tool-call") { + assistantToolCalls.push({ id: event.toolCall.id, name: event.toolCall.name, arguments: event.toolCall.arguments }); + } else if (event.type === "tool-result") { + const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId); + if (tc) { tc.result = event.toolResult.result; tc.isError = event.toolResult.isError; } + } else if (event.type === "done") { + // Persist assistant message to DB + const contentSegments: Array<Record<string, unknown>> = []; + if (assistantText) contentSegments.push({ type: "text", text: assistantText }); + for (const tc of assistantToolCalls) { + contentSegments.push({ type: "tool-call", ...tc }); + } + if (contentSegments.length > 0) { + appendMessage(tabId, crypto.randomUUID(), "assistant", JSON.stringify(contentSegments), assistantThinking || undefined); + } + // Reset for next turn + assistantText = ""; + assistantThinking = ""; + assistantToolCalls.length = 0; + } } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - this.status = "error"; - this.emit({ type: "error", error: errorMsg }); - this.emit({ type: "status", status: "error" }); + tabAgent.status = "error"; + this.emit({ type: "error", error: errorMsg }, tabId); + this.emit({ type: "status", status: "error" }, tabId); } } diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index ce26aaa..f5588a6 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -5,6 +5,7 @@ import { PermissionManager } from "./permission-manager.js"; import { configRoutes } from "./routes/config.js"; import { skillsRoutes } from "./routes/skills.js"; import { modelsRoutes, startWakeScheduler } from "./routes/models.js"; +import { tabsRoutes } from "./routes/tabs.js"; export const permissionManager = new PermissionManager(); export const agentManager = new AgentManager(permissionManager); @@ -17,7 +18,7 @@ app.use( origin: "http://localhost:5173", credentials: true, allowHeaders: ["Content-Type", "Authorization"], - allowMethods: ["GET", "POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], }), ); @@ -29,19 +30,24 @@ app.get("/status", (c) => { return c.json({ status: agentManager.getStatus(), messageCount: agentManager.getMessageCount(), + statuses: agentManager.getAllStatuses(), }); }); app.post("/chat", async (c) => { - const body = await c.req.json<{ message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>(); - const message = body.message; + const body = await c.req.json<{ tabId?: unknown; message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>(); + const { tabId, message } = body; + + if (typeof tabId !== "string" || tabId.trim() === "") { + return c.json({ error: "tabId must be a non-empty string" }, 400); + } if (typeof message !== "string" || message.trim() === "") { return c.json({ error: "message must be a non-empty string" }, 400); } - if (agentManager.getStatus() === "running") { - return c.json({ error: "agent is already running" }, 409); + if (agentManager.getTabStatus(tabId) === "running") { + return c.json({ error: "agent is already running for this tab" }, 409); } const keyId = typeof body.keyId === "string" ? body.keyId : undefined; @@ -52,7 +58,7 @@ app.post("/chat", async (c) => { : undefined; // Non-blocking — let the agent run in the background - agentManager.processMessage(message, keyId, modelId, reasoningEffort).catch(console.error); + agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort).catch(console.error); return c.json({ status: "ok" }); }); @@ -60,6 +66,7 @@ app.post("/chat", async (c) => { app.route("/config", configRoutes); app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); +app.route("/tabs", tabsRoutes); // 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 6ac6bca..a05f800 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -13,12 +13,8 @@ app.get( return { onOpen(_event, ws) { - // Send current status immediately - ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() })); - - // Send current task list state - const tasks = agentManager.getTaskList().getTasks(); - ws.send(JSON.stringify({ type: "task-list-update", tasks })); + // Send current statuses immediately + ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() })); // Send any pending permission prompts const pending = permissionManager.getPending(); diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts new file mode 100644 index 0000000..7fe05d9 --- /dev/null +++ b/packages/api/src/routes/tabs.ts @@ -0,0 +1,81 @@ +import { Hono } from "hono"; +import { + createTab, + getTab, + listOpenTabs, + updateTabTitle, + updateTabModel, + updateTabStatus, + archiveTab, + getMessagesForTab, + getSetting, + setSetting, +} from "@dispatch/core"; + +export const tabsRoutes = new Hono(); + +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 { + getAgentManager = getter; +} + +tabsRoutes.get("/", (c) => { + const tabs = listOpenTabs(); + return c.json({ tabs }); +}); + +tabsRoutes.post("/", async (c) => { + const body = await c.req.json<{ id?: string; title?: string }>(); + const id = body.id ?? crypto.randomUUID(); + const title = body.title ?? "New Tab"; + const tab = createTab(id, title); + return c.json(tab); +}); + +// Settings routes (must be before /:id to avoid conflict) +tabsRoutes.get("/settings/title-model", (c) => { + const keyId = getSetting("title_model_key_id"); + const modelId = getSetting("title_model_id"); + return c.json({ keyId, modelId }); +}); + +tabsRoutes.put("/settings/title-model", async (c) => { + const body = await c.req.json<{ keyId?: string; modelId?: string }>(); + if (body.keyId) setSetting("title_model_key_id", body.keyId); + if (body.modelId) setSetting("title_model_id", body.modelId); + return c.json({ success: true }); +}); + +tabsRoutes.get("/:id", (c) => { + const id = c.req.param("id"); + const tab = getTab(id); + if (!tab) return c.json({ error: "tab not found" }, 404); + return c.json(tab); +}); + +tabsRoutes.get("/:id/messages", (c) => { + const id = c.req.param("id"); + const messages = getMessagesForTab(id); + return c.json({ messages }); +}); + +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 }>(); + if (body.title !== undefined) updateTabTitle(id, body.title); + if (body.keyId !== undefined || body.modelId !== undefined) { + updateTabModel(id, body.keyId ?? null, body.modelId ?? null); + } + if (body.status !== undefined) updateTabStatus(id, body.status); + const tab = getTab(id); + return c.json(tab); +}); + +tabsRoutes.delete("/:id", (c) => { + const id = c.req.param("id"); + const mgr = getAgentManager(); + if (mgr) mgr.deleteTab(id); + archiveTab(id); + return c.json({ success: true }); +}); |
