import { Agent, type AgentEvent, type AgentSkillMapping, type AgentStatus, appendMessage, BackgroundShellStore, BackgroundTranscriptStore, type ClaudeAccount, configToRuleset, createConfigWatcher, createListFilesTool, createReadFileTool, createRetrieveTool, createRunShellTool, createSkillsWatcher, createSummonTool, createTaskListTool, createWebSearchTool, createWriteFileTool, createYoutubeTranscribeTool, type DispatchConfig, getClaudeAccountsFromDB, getSetting, loadConfig, loadSkills, ModelRegistry, type QueuedMessage, refreshAccountCredentials, refreshAccountCredentialsAsync, resolveApiKey, type SkillDefinition, TaskList, validateConfig, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; import { setAccountsGetter, setModelsGetter } from "./routes/models.js"; import { setSkillsGetter } from "./routes/skills.js"; import { setTabsAgentManager } from "./routes/tabs.js"; const TOOL_DESCRIPTIONS: Record = { read_file: "Read the contents of a file", list_files: "List files and directories", write_file: "Write content to a file (creates parent directories if needed)", run_shell: "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.", todo: "Manage a todo list for planning and tracking work. Actions: add, update, list, get, remove. Statuses: pending, in_progress, done.", summon: "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.", retrieve: "Wait for a background task to finish and get its result (blocking). Pass the job_id or agent_id.", web_search: "Search the web and optionally scrape full page content from results.", youtube_transcribe: "Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.", }; const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; const TODO_GUIDANCE = ` ## Todo List The user can see your todo list in real-time. Use it to communicate your plan and progress. ### When to use - Tasks that require 3 or more steps - When the user provides multiple things to do - Complex work that benefits from planning before starting - After receiving new instructions, capture them as todos immediately ### When NOT to use - Single, straightforward tasks that need no tracking - Purely conversational or informational responses - Anything completable in under 3 trivial steps ### State management - Only ONE item should be "in_progress" at a time. Finish current work before starting the next item. - Mark items "done" IMMEDIATELY after completing them. Do not batch completions. - When starting work on an item, mark it "in_progress" first. - Add new items as you discover sub-tasks during execution. ### Examples User: "Run the build and fix any type errors" Good approach: 1. Add todo: "Run the build" -> mark in_progress -> run build -> mark done 2. If 5 errors found, add 5 todos for each error 3. Work through each one sequentially, marking in_progress then done User: "What does the git status command do?" No todo needed — this is a simple informational question. User: "Rename the function getUser to fetchUser across the project" Good approach: 1. Add todo: "Search for all occurrences of getUser" 2. After searching, add a todo per file that needs changes 3. Work through each file sequentially `.trim(); function buildSystemPrompt(toolNames: string[], basePrompt?: string): string { const base = basePrompt || DEFAULT_SYSTEM_PROMPT; const toolList = toolNames .filter((name) => TOOL_DESCRIPTIONS[name]) .map((name) => `- ${name}: ${TOOL_DESCRIPTIONS[name]}`) .join("\n"); if (!toolList) return base; const hasTodo = toolNames.includes("todo"); let prompt = `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`; if (hasTodo) { prompt += `\n\n${TODO_GUIDANCE}`; } return prompt; } interface TabAgent { agent: Agent | null; status: AgentStatus; keyId: string | null; modelId: string | null; taskList: TaskList; _lastPermKey?: string; /** Ordered key+model fallback hierarchy from the agent definition. */ agentModels?: Array<{ key_id: string; model_id: string }>; /** Abort controller for cancelling a running agent. */ abortController?: AbortController; /** For child agents: resolves when the agent finishes its task. */ completionResolve?: ( result: { status: "done"; result: string } | { status: "error"; error: string }, ) => void; completionPromise?: Promise< { status: "done"; result: string } | { status: "error"; error: string } >; /** Accumulated final text output from the child agent. */ finalOutput?: string; /** Tools whitelist for child agents (set by summon). */ toolsOverride?: string[]; /** Working directory override for child agents. */ workingDirectoryOverride?: string; /** Queue of messages sent while the agent is running. */ messageQueue: QueuedMessage[]; /** Callbacks to wake up blocking tools waiting for queued messages. */ queueListeners: Array<() => void>; /** Store for shell commands backgrounded due to user interrupt. */ shellStore: BackgroundShellStore; /** Store for transcript requests backgrounded due to user interrupt. */ transcriptStore: BackgroundTranscriptStore; } export class AgentManager { private tabAgents: Map = new Map(); private messageCount = 0; private eventListeners: Set<(event: AgentEvent & { tabId: string }) => void> = new Set(); private permissionManager: PermissionManager | undefined; private config: DispatchConfig; private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }; private modelRegistry: ModelRegistry | null = null; private configWatcher: { close(): void } | null = null; private skillsWatcher: { close(): void } | null = null; private claudeAccounts: ClaudeAccount[] = []; constructor(permissionManager?: PermissionManager) { this.permissionManager = permissionManager; const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); // Load initial config this.config = loadConfig(workingDirectory); const { errors } = validateConfig(this.config); if (errors.length > 0) { for (const err of errors) { console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`); } } // Initialize model registry + resolver if config has models and keys this._initModelRegistry(this.config); // Load initial skills this.skillsData = loadSkills(workingDirectory); // Discover Claude accounts this._refreshClaudeAccounts(); // Wire route getters setConfigGetter(() => this.config); setSkillsGetter(() => this.skillsData); setModelsGetter(() => this.modelRegistry); setAccountsGetter(() => this.claudeAccounts); setTabsAgentManager(() => this); // Set up hot-reload watchers this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => { this.config = newConfig; const { errors: newErrors } = validateConfig(newConfig); if (newErrors.length > 0) { for (const err of newErrors) { console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`); } } // Update model registry with new config this._initModelRegistry(newConfig); // 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 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); } }); } private _refreshClaudeAccounts(): void { try { this.claudeAccounts = getClaudeAccountsFromDB(); if (this.claudeAccounts.length > 0) { console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`); } } catch (err) { console.warn( `dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`, ); } } private _initModelRegistry(config: DispatchConfig): void { if (config.keys) { if (this.modelRegistry) { this.modelRegistry.updateConfig(config.keys); } else { this.modelRegistry = new ModelRegistry(config.keys); } } else { this.modelRegistry = null; } } getPermissionManager(): PermissionManager | undefined { return this.permissionManager; } /** 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; } /** 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, messageQueue: [], queueListeners: [], shellStore: new BackgroundShellStore(), transcriptStore: new BackgroundTranscriptStore(), }; this.tabAgents.set(tabId, tabAgent); } return tabAgent; } private async getOrCreateAgentForTab( tabId: string, keyId?: string, modelId?: string, ): Promise { 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; // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask, web=ask, youtube=ask) const permRead = getSetting("perm_read") !== "ask"; const permEdit = getSetting("perm_edit") === "allow"; const permBash = getSetting("perm_bash") === "allow"; const permSummon = getSetting("perm_summon") === "allow"; const permWebSearch = getSetting("perm_web_search") === "allow"; const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( tabAgent.agent && (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId || permKey !== tabAgent._lastPermKey) ) { tabAgent.agent = null; } if (!tabAgent.agent) { const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); 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)); } // Resolve relative paths against the default working directory // (e.g. subagent cwd "./subtask" resolves relative to the parent's effective dir) { const { isAbsolute, resolve } = await import("node:path"); if (!isAbsolute(workingDirectory)) { workingDirectory = resolve(defaultWorkDir, workingDirectory); } } // 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 const toolEntries: Array<{ name: string; tool: ReturnType }> = []; if (tabAgent.toolsOverride) { // Child agent: use explicit tool whitelist const allowed = new Set(tabAgent.toolsOverride); if (allowed.has("read_file")) { toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); // list_files is bundled with read access if (allowed.has("list_files")) { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } } if (allowed.has("list_files") && !allowed.has("read_file")) { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (allowed.has("write_file")) { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (allowed.has("run_shell")) { toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore), }); } if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } if (allowed.has("youtube_transcribe")) { toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool(tabAgent.transcriptStore), }); } if (allowed.has("todo")) { toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); } if (allowed.has("summon")) { const childParentAllowedTools = new Set(toolEntries.map((e) => e.name)); toolEntries.push({ name: "summon", tool: createSummonTool(workingDirectory, { spawn: (opts) => this.spawnChildAgent({ ...opts, parentKeyId: tabAgent.keyId, parentModelId: tabAgent.modelId, parentAllowedTools: childParentAllowedTools, parentTabId: tabId, }), getResult: (id) => this.getChildResult(id), }), }); } if (allowed.has("retrieve")) { toolEntries.push({ name: "retrieve", tool: createRetrieveTool({ getResult: (id) => tabAgent.shellStore.has(id) ? tabAgent.shellStore.getResult(id) : tabAgent.transcriptStore.has(id) ? tabAgent.transcriptStore.getResult(id) : this.getChildResult(id), }), }); } } else { // Parent agent: use permission settings from DB if (permRead) { toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (permEdit) { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (permBash) { toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore), }); } if (permWebSearch) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } if (permYoutubeTranscribe) { toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool(tabAgent.transcriptStore), }); } toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); if (permSummon) { // Capture parent's allowed tool names for child permission enforcement const parentAllowedTools = new Set(toolEntries.map((e) => e.name)); toolEntries.push({ name: "summon", tool: createSummonTool(workingDirectory, { spawn: (opts) => this.spawnChildAgent({ ...opts, parentKeyId: tabAgent.keyId, parentModelId: tabAgent.modelId, parentAllowedTools, parentTabId: tabId, }), getResult: (id) => this.getChildResult(id), }), }); toolEntries.push({ name: "retrieve", tool: createRetrieveTool({ getResult: (id) => tabAgent.shellStore.has(id) ? tabAgent.shellStore.getResult(id) : tabAgent.transcriptStore.has(id) ? tabAgent.transcriptStore.getResult(id) : this.getChildResult(id), }), }); } } const tools = toolEntries.map((e) => e.tool); const toolNames = toolEntries.map((e) => e.name); tabAgent._lastPermKey = permKey; const ruleset = configToRuleset(this.config); // Try to resolve model from registry, fall back to env vars let apiKey = ""; let model = "deepseek-v4-flash"; let baseURL = "https://opencode.ai/zen/go/v1"; let provider: string | undefined; let claudeCredentials: { accessToken: string } | undefined; let useOverride = false; if (effectiveKeyId && effectiveModelId && this.modelRegistry) { // Direct override: look up the key by id in the registry const keyState = this.modelRegistry .getKeys() .find((k) => k.definition.id === effectiveKeyId); if (keyState) { const key = keyState.definition; if (key.provider === "anthropic") { // Anthropic provider: resolve credentials from Claude accounts const credFile = key.credentials_file; const account = this.claudeAccounts.find((a) => a.id === effectiveKeyId) ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]); if (account) { const creds = refreshAccountCredentials(account); if (creds && creds.expiresAt > Date.now() + 60_000) { claudeCredentials = { accessToken: creds.accessToken }; apiKey = creds.accessToken; baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; tabAgent.keyId = effectiveKeyId; tabAgent.modelId = effectiveModelId; useOverride = true; } else { // Token expired — await the async refresh const fresh = await refreshAccountCredentialsAsync(account); if (fresh && fresh.expiresAt > Date.now() + 60_000) { account.credentials = fresh; claudeCredentials = { accessToken: fresh.accessToken }; apiKey = fresh.accessToken; baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; tabAgent.keyId = effectiveKeyId; tabAgent.modelId = effectiveModelId; useOverride = true; } else { console.warn( `dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`, ); claudeCredentials = { accessToken: account.credentials.accessToken }; apiKey = account.credentials.accessToken; baseURL = key.base_url; model = effectiveModelId; provider = "anthropic"; tabAgent.keyId = effectiveKeyId; tabAgent.modelId = effectiveModelId; useOverride = true; } } } else { console.warn(`dispatch: no Claude credentials found for key "${key.id}"`); } } else { // Standard key: resolve from env var const envKey = resolveApiKey(key.id); if (envKey) { apiKey = envKey; baseURL = key.base_url; model = 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`, ); tabAgent.keyId = effectiveKeyId; tabAgent.modelId = effectiveModelId; useOverride = true; } } } else { console.warn(`dispatch: key "${effectiveKeyId}" not found in model registry`); } } if (!useOverride) { // Clear any previous override when falling back to default resolution tabAgent.keyId = null; tabAgent.modelId = null; } const customSystemPrompt = getSetting("system_prompt") || undefined; tabAgent.agent = new Agent( { model, apiKey, baseURL, systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt), tools, workingDirectory, permissionChecker: this.permissionManager ?? undefined, ruleset, provider, ...(claudeCredentials ? { claudeCredentials } : {}), }, { dequeueMessages: () => this.dequeueMessages(tabId), waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), }, ); } return tabAgent.agent; } getTabStatus(tabId: string): AgentStatus { return this.tabAgents.get(tabId)?.status ?? "idle"; } getAllStatuses(): Record { const result: Record = {}; for (const [tabId, tabAgent] of this.tabAgents.entries()) { result[tabId] = tabAgent.status; } return result; } /** @deprecated Use getTabStatus(tabId) instead */ getStatus(): AgentStatus { // 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 & { tabId: string }) => void): () => void { this.eventListeners.add(listener); return () => { this.eventListeners.delete(listener); }; } private emit(event: AgentEvent, tabId: string): void { for (const listener of this.eventListeners) { listener({ ...event, tabId } as AgentEvent & { tabId: string }); } } stopTab(tabId: string): void { const tabAgent = this.tabAgents.get(tabId); if (tabAgent) { tabAgent.abortController?.abort(); tabAgent.status = "idle"; tabAgent.agent = null; // Resolve any pending completion promise so retrieve doesn't hang tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." }); } } deleteTab(tabId: string): void { this.stopTab(tabId); this.tabAgents.delete(tabId); } /** * Spawn a child agent in a new tab. Returns the tab ID (agent_id). * The child runs asynchronously — use getChildResult to await completion. */ async spawnChildAgent(options: { task: string; tools: string[]; workingDirectory?: string; parentKeyId?: string | null; parentModelId?: string | null; parentAllowedTools?: Set; parentTabId?: string; }): Promise { const tabId = crypto.randomUUID(); const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task; // 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)); } // Resolve and validate child working directory against parent's effective dir let resolvedWorkingDirectory = options.workingDirectory; if (options.workingDirectory) { 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 parent's working directory "${parentDir}".`, ); } // Store the resolved absolute path so downstream code doesn't // re-resolve against the wrong base directory resolvedWorkingDirectory = resolved; } // Intersect requested tools with parent's allowed tools to prevent privilege escalation let childTools = options.tools; if (options.parentAllowedTools) { childTools = options.tools.filter((t) => options.parentAllowedTools?.has(t)); } // Create the tab agent entry with overrides const tabAgent = this._getOrCreateTabAgent(tabId); tabAgent.toolsOverride = childTools; tabAgent.workingDirectoryOverride = resolvedWorkingDirectory; tabAgent.keyId = options.parentKeyId ?? null; tabAgent.modelId = options.parentModelId ?? null; tabAgent.finalOutput = ""; // Inherit parent's agent fallback models if (options.parentTabId) { const parentAgent = this.tabAgents.get(options.parentTabId); if (parentAgent?.agentModels) { tabAgent.agentModels = parentAgent.agentModels; } } // Set up completion tracking tabAgent.completionPromise = new Promise((resolve) => { tabAgent.completionResolve = resolve; }); // Create tab in DB try { const { createTab } = await import("@dispatch/core"); createTab(tabId, title, { keyId: tabAgent.keyId, modelId: tabAgent.modelId, parentTabId: options.parentTabId, }); } catch { // Continue even if DB fails } // Notify the frontend about the new tab this.emit( { type: "tab-created", id: tabId, title, keyId: tabAgent.keyId, modelId: tabAgent.modelId, parentTabId: options.parentTabId ?? null, workingDirectory: resolvedWorkingDirectory ?? null, }, tabId, ); // Start the child agent in the background this.processMessage( tabId, options.task, options.parentKeyId ?? undefined, options.parentModelId ?? undefined, ).catch((err) => { const errorMsg = err instanceof Error ? err.message : String(err); tabAgent.completionResolve?.({ status: "error", error: errorMsg }); }); return tabId; } /** * Wait for a child agent to finish and return its result. * Blocks until the child completes or errors. */ async getChildResult( agentId: string, ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { const tabAgent = this.tabAgents.get(agentId); if (!tabAgent) { return { status: "error", error: `No agent found with id '${agentId}'` }; } if (!tabAgent.completionPromise) { // Not a child agent or already completed if (tabAgent.status === "idle") { return { status: "done", result: tabAgent.finalOutput ?? "(no output)" }; } return { status: "error", error: "Agent has no completion tracking. It may not have been spawned via summon.", }; } return tabAgent.completionPromise; } async processMessage( tabId: string, message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max", workingDirectory?: string, agentModels?: Array<{ key_id: string; model_id: string }>, ): Promise { 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; // Persist user message to DB (once, before any fallback retry) appendMessage( tabId, crypto.randomUUID(), "user", JSON.stringify([{ type: "text", text: message }]), ); // Store agent models on the tab if provided (defines fallback order) if (agentModels) { tabAgent.agentModels = agentModels; } // Build the fallback sequence: the agent's models list in order, or a single manual entry const fallbackSequence = this.buildFallbackSequence(tabAgent, keyId, modelId); const maxFallbackAttempts = fallbackSequence.length; let processError: string | null = null; let allOutput = ""; let currentKeyId: string | undefined; let currentModelId: string | undefined; for (let fallbackIdx = 0; fallbackIdx < maxFallbackAttempts; fallbackIdx++) { const entry = fallbackSequence[fallbackIdx]; currentKeyId = entry.key_id; currentModelId = entry.model_id; allOutput = ""; let assistantText = ""; let assistantThinking = ""; const assistantToolCalls: Array<{ id: string; name: string; arguments: Record; result?: string; isError?: boolean; }> = []; let attemptError: string | null = null; try { const agent = await this.getOrCreateAgentForTab(tabId, currentKeyId, currentModelId); // Ensure tab exists in DB (frontend may have failed to create it) try { const { getDatabase } = await import("@dispatch/core"); const db = getDatabase(); const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId }); if (!exists) { const { createTab } = await import("@dispatch/core"); createTab(tabId, "New Tab", { keyId: currentKeyId ?? null, modelId: currentModelId ?? null, }); } } catch { // Best-effort — if this fails, appendMessage will throw and we'll catch it below } for await (const event of agent.run( message, reasoningEffort ? { reasoningEffort } : undefined, )) { // Stop processing if the tab was aborted (closed/stopped) if (tabAgent.abortController?.signal.aborted) break; if (event.type === "error") { attemptError = event.error; break; } 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; allOutput += 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> = []; 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) { console.error(`[dispatch] processMessage error for tab ${tabId}:`, err); attemptError = err instanceof Error ? err.message : String(err); } // Flush any accumulated assistant content from this attempt if (assistantText || assistantToolCalls.length > 0) { const contentSegments: Array> = []; 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, ); } } // No error — success if (!attemptError) { processError = null; break; } // Check if error is retryable (rate limit / exhausted key) const isRetryable = attemptError.includes("status=429") || attemptError.toLowerCase().includes("rate limit") || attemptError.toLowerCase().includes("rate_limit"); if (isRetryable && this.modelRegistry && tabAgent.keyId) { this.modelRegistry.markKeyExhausted(tabAgent.keyId, attemptError); // Try the next entry in the agent's fallback sequence const nextIdx = fallbackIdx + 1; if (nextIdx < maxFallbackAttempts) { const nextEntry = fallbackSequence[nextIdx]; const fallbackMsg = `Key "${tabAgent.keyId}" rate limited. ` + `Falling back to "${nextEntry.key_id}" (model: ${nextEntry.model_id})...`; console.warn(`[dispatch] ${fallbackMsg}`); this.emit({ type: "notice", message: fallbackMsg }, tabId); this.emit( { type: "model-changed", keyId: nextEntry.key_id, modelId: nextEntry.model_id }, tabId, ); tabAgent.agent = null; continue; } } // All fallbacks exhausted or non-retryable error processError = attemptError; tabAgent.status = "error"; this.emit({ type: "error", error: attemptError }, tabId); this.emit({ type: "status", status: "error" }, tabId); break; } // Resolve completion promise for child agents if (processError === null) { tabAgent.finalOutput = allOutput; tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" }); } else { tabAgent.completionResolve?.({ status: "error", error: processError }); } } private buildFallbackSequence( tabAgent: TabAgent, keyId?: string, modelId?: string, ): Array<{ key_id: string; model_id: string }> { // Agent mode: use the agent's configured fallback hierarchy in strict order const models = tabAgent.agentModels; if (models && models.length > 0) { const startIdx = models.findIndex((m) => m.key_id === keyId && m.model_id === modelId); return startIdx >= 0 ? models.slice(startIdx) : models; } // Manual mode: no fallback — just the selected key/model pair if (keyId && modelId) return [{ key_id: keyId, model_id: modelId }]; return []; } queueMessage(tabId: string, message: string, clientId?: string): { messageId: string } { const tabAgent = this.tabAgents.get(tabId); if (!tabAgent) throw new Error("Tab not found"); const id = clientId || crypto.randomUUID(); const queued: QueuedMessage = { id, message, timestamp: Date.now() }; tabAgent.messageQueue.push(queued); // Wake up any blocking tools waiting for queue for (const listener of tabAgent.queueListeners) { listener(); } tabAgent.queueListeners = []; this.emit({ type: "message-queued", tabId, messageId: id, message }, tabId); return { messageId: id }; } cancelQueuedMessage(tabId: string, messageId: string): boolean { const tabAgent = this.tabAgents.get(tabId); if (!tabAgent) return false; const idx = tabAgent.messageQueue.findIndex((m) => m.id === messageId); if (idx === -1) return false; tabAgent.messageQueue.splice(idx, 1); this.emit({ type: "message-cancelled", tabId, messageId }, tabId); return true; } dequeueMessages(tabId: string): QueuedMessage[] { const tabAgent = this.tabAgents.get(tabId); if (!tabAgent) return []; const messages = [...tabAgent.messageQueue]; tabAgent.messageQueue = []; if (messages.length > 0) { this.emit({ type: "message-consumed", tabId, messageIds: messages.map((m) => m.id) }, tabId); } return messages; } waitForQueuedMessage(tabId: string): { promise: Promise; cancel: () => void } { const tabAgent = this.tabAgents.get(tabId); if (!tabAgent) return { promise: Promise.resolve(), cancel: () => {} }; if (tabAgent.messageQueue.length > 0) return { promise: Promise.resolve(), cancel: () => {} }; let listener: (() => void) | null = null; const promise = new Promise((resolve) => { listener = resolve; tabAgent.queueListeners.push(resolve); }); const cancel = () => { if (listener) { tabAgent.queueListeners = tabAgent.queueListeners.filter((l) => l !== listener); listener = null; } }; return { promise, cancel }; } destroy(): void { this.configWatcher?.close(); this.skillsWatcher?.close(); } }