diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/api/src | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 2453 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 278 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 127 | ||||
| -rw-r--r-- | packages/api/src/permission-manager.ts | 103 | ||||
| -rw-r--r-- | packages/api/src/routes/agents.ts | 126 | ||||
| -rw-r--r-- | packages/api/src/routes/config.ts | 27 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 1073 | ||||
| -rw-r--r-- | packages/api/src/routes/notifications.ts | 88 | ||||
| -rw-r--r-- | packages/api/src/routes/skills.ts | 48 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 229 | ||||
| -rw-r--r-- | packages/api/src/types.ts | 2 | ||||
| -rw-r--r-- | packages/api/src/wake-scheduler.ts | 97 |
12 files changed, 0 insertions, 4651 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts deleted file mode 100644 index 539663c..0000000 --- a/packages/api/src/agent-manager.ts +++ /dev/null @@ -1,2453 +0,0 @@ -import { - Agent, - type AgentEvent, - type AgentModelEntry, - type AgentSkillMapping, - type AgentStatus, - appendChunks, - appendEventToChunks, - BackgroundShellStore, - BackgroundTranscriptStore, - buildCompactionRequest, - buildSummaryTurnText, - type ChatMessage, - type Chunk, - type ClaudeAccount, - clearSpillForTab, - configToRuleset, - createConfigWatcher, - createKeyUsageTool, - createListFilesTool, - createLspTool, - createReadFileSliceTool, - createReadFileTool, - createReadTabTool, - createRetrieveTool, - createRunShellTool, - createSearchCodeTool, - createSendToTabTool, - createSkillsWatcher, - createSummonTool, - createTab, - createTaskListTool, - createWebSearchTool, - createWriteFileTool, - createYoutubeTranscribeTool, - type DispatchConfig, - expandAgentToolNames, - explodeTurn, - explodeUserText, - GLOBAL_AGENTS_DIR, - getAgentDirPaths, - getChunksForTab, - getClaudeAccountsFromDB, - getMessagesForTab, - getSetting, - getTab, - getUsageStatsForTab, - groupRowsToMessages, - LspManager, - listOpenTabs, - loadAgent, - loadAgents, - loadConfig, - loadSkills, - ModelRegistry, - type QueuedMessage, - type ReasoningEffort, - type ResolvedLspServer, - refreshAccountCredentials, - refreshAccountCredentialsAsync, - rekeyChunks, - reportDiagnostics, - resolveApiKey, - resolveServersFromConfig, - resolveTabPrefix, - type SkillDefinition, - type SystemChunkKind, - shortestUniquePrefix, - type TabResolution, - type TabStatusSnapshot, - TaskList, - toAvailableSubagents, - toAvailableUserAgents, - type UsageData, - type UsageStats, - type UserContentPart, - validateConfig, - watchDirConfig, -} 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<string, string> = { - read_file: "Read the contents of a file", - read_file_slice: - "Read a character-range slice of a single line in a file (for inspecting long lines that read_file truncated)", - 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.", - search_code: - "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.", - todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.", - key_usage: - "Report current usage levels for configured API keys: provider, active/exhausted status, remaining rate-limit headroom and reset times per window (5-hour, weekly, monthly where available), and whether the figures are live or cached. Pass key_id for one key; omit to report all. Supported for anthropic and opencode-go keys.", - 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.", - send_to_tab: - "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.", - read_tab: - "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.", - lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.", -}; - -/** - * Maximum number of CONSECUTIVE agent-to-agent auto-wakes a tab will accept - * before it stops auto-responding and waits for a human. Each `send_to_tab` - * that would wake an idle tab consumes one unit; any human-originated message - * (e.g. via `POST /chat`) refills the budget to full. This bounds runaway - * agent ping-pong loops (A wakes B wakes A ...) that would otherwise spend - * tokens unbounded with no human in the loop. See notes/plan-tab-comm.md. - */ -const MAX_AGENT_AUTO_WAKES = 6; - -/** - * Cap on how many OTHER files' LSP error blocks are appended to a write_file - * result, after the written file's own errors. Bounds context spend when a - * single edit surfaces project-wide diagnostics. Mirrors opencode's - * MAX_PROJECT_DIAGNOSTICS_FILES. - */ -const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5; - -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 TASK_MANAGEMENT_GUIDANCE = ` -## Task Management - -You have access to the \`todo\` tool to plan and track tasks. Use it VERY frequently so the user can see your plan and progress in real time. It is also a powerful planning aid: breaking larger work into smaller steps keeps you from forgetting important tasks — that is unacceptable. - -The \`todo\` tool is DECLARATIVE: every call sends the ENTIRE list in the \`todos\` parameter and replaces the previous list. There are no ids and no per-item actions — to change one item, resend the whole list with that item updated. To clear the list, send an empty array. - -### When to use -- A task needs 3+ distinct steps, or benefits from planning -- The user gives multiple tasks (numbered or comma-separated) or asks for a todo list -- New instructions arrive — capture them as todos -- You start a task — mark it in_progress (only one at a time) before working -- You finish a task — mark it completed and add any follow-ups discovered - -### When NOT to use -- A single, straightforward task (or fewer than 3 trivial steps) -- Purely informational or conversational requests -- When tracking adds no organizational value - -### States -- pending — not started -- in_progress — actively working (exactly ONE at a time) -- completed — finished successfully -- cancelled — no longer needed - -### Rules -- Send the full desired list every time; the tool replaces the stored list -- Update status in real time; do NOT batch completions -- Mark completed only after the work is actually done (including any required verification), never on intent -- Keep exactly one in_progress while work remains; if blocked, keep it in_progress and add a follow-up todo describing the blocker - -### Examples - -User: "Run the build and fix any type errors" -Write the list, then work it: send [{content:"Run the build", status:"in_progress"}, {content:"Fix any type errors", status:"pending"}]. Run the build. If it surfaces 10 errors, resend the whole list — the build item completed, plus one item per error — then drive each to completed one at a time. - -User: "How do I print Hello World in Python?" -No todo needed — this is a single informational question. - -User: "Rename getUser to fetchUser across the project" -Send [{content:"Search for all occurrences of getUser", status:"in_progress"}, ...]. After the grep reveals the files, resend the whole list with one item per file, then work through them, resending the list as each flips to completed. -`.trim(); - -/** - * Returns true for OpenCode Go models served via the Anthropic-format - * `/messages` endpoint (MiniMax M2.x, Qwen3.x Plus). See - * https://opencode.ai/docs/go/#endpoints for the per-model endpoint table. - */ -function isOpencodeGoAnthropicModel(modelId: string): boolean { - return modelId.startsWith("minimax-") || modelId.startsWith("qwen"); -} - -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"); - const hasSummon = toolNames.includes("summon"); - 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${TASK_MANAGEMENT_GUIDANCE}`; - } - if (hasSummon) { - prompt += - '\n\nYou have pre-configured subagent types. Use summon(agent="slug", task="...") to delegate specialized work to a subagent. Use list_files and read_file to inspect available agent definitions.'; - } - 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?: AgentModelEntry[]; - /** 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; - /** - * In-flight assistant chunks for the active turn. `null` when no turn is - * running. Out-of-band system events (config-reload, cancel, etc.) push - * onto this list when present; it is exploded into chunk rows when the - * turn flushes. - */ - currentChunks: Chunk[] | null; - /** - * Opaque id of the in-flight assistant turn, used as the `currentAssistantId` - * in the WS status snapshot so a reconnecting frontend can align its local - * streaming message. (No longer a DB row id — the turn is many chunk rows.) - */ - currentAssistantId: string | null; - /** - * `turn_id` shared by the current turn's user message and assistant chunk - * rows. Set at the start of `processMessage`, cleared when the turn ends. - */ - currentTurnId: string | null; - /** - * Remaining consecutive agent-to-agent auto-wakes this tab will accept - * before requiring human intervention (see `MAX_AGENT_AUTO_WAKES`). - * Refilled to the max by any human-originated `deliverMessage`; decremented - * each time an agent-originated `send_to_tab` wakes this tab from idle. When - * it hits 0, further agent messages are queued but do NOT start a turn. - */ - autoWakeBudget: number; - /** - * True while this tab is the SOURCE of an in-flight compaction. New - * messages are queued (not started) until compaction settles so the - * conversation can't mutate mid-summary. - */ - compacting?: boolean; -} - -export class AgentManager { - private tabAgents: Map<string, TabAgent> = 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[] = []; - - /** - * Process-wide owner of LSP client lifecycles. Servers are declared in the - * `dispatch.toml` of a tab's effective working directory; clients are - * spawned lazily per (root + server) and reused across tabs/turns. Shut - * down in `destroy()`. - */ - private lspManager: LspManager = new LspManager(); - /** - * Cache of resolved LSP servers per working directory, so we parse each - * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any - * config hot-reload (the watcher fires for the root config; directory-level - * configs are re-read on demand after a clear). - */ - private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map(); - /** - * One file watcher per distinct SUBDIRECTORY config we've cached in - * `lspServersByDir`. The main `configWatcher` only watches the root + - * global `dispatch.toml`; a tab whose effective working directory is a - * subdirectory with its own `dispatch.toml` needs its cache entry cleared - * when THAT file changes. Keyed by directory; closed on full reload (the - * cache is dropped wholesale then) and in `destroy()`. - */ - private lspDirWatchers: Map<string, { close(): void }> = new Map(); - /** Root working directory watched by `configWatcher` (constructor). */ - private rootWorkingDirectory = ""; - - constructor(permissionManager?: PermissionManager) { - this.permissionManager = permissionManager; - - const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); - this.rootWorkingDirectory = workingDirectory; - - // 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); - // LSP server config may have changed — drop the per-directory cache - // so the next tool build re-reads each working directory's - // `dispatch.toml` `[lsp]` block. - this.lspServersByDir.clear(); - // Tear down the per-subdirectory LSP watchers too; they are lazily - // re-registered by `getLspServersForDir` as directories are re-cached. - for (const watcher of this.lspDirWatchers.values()) watcher.close(); - this.lspDirWatchers.clear(); - // Re-discover Claude accounts: a config reload may accompany freshly - // imported credentials, and (critically) lets a process that failed - // account discovery at boot recover without a full restart. - this._refreshClaudeAccounts(); - // 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 (and persist as a system chunk) - for (const tabId of this.tabAgents.keys()) { - this.emit({ type: "config-reload" }, tabId); - this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded"); - } - }); - - 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 (and persist as a system chunk) - for (const tabId of this.tabAgents.keys()) { - this.emit({ type: "config-reload" }, tabId); - this.routeSystemEventToTab(tabId, "config-reload", "Skills reloaded"); - } - }); - } - - 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)}`, - ); - } - } - - /** - * Resolve (and cache) the LSP servers configured for a working directory. - * - * LSP config is resolved by `loadConfig`, which merges the HOME-directory - * global `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the - * tab's effective working-directory `dispatch.toml` — local `[lsp.<id>]` - * entries override global ones sharing the same id, while global-only - * servers stay active in every repository. We read+merge that config once - * per directory and cache the resolved servers; the cache is cleared on - * config hot-reload. Returns `[]` when neither config declares an `[lsp]` - * block (the common case). - */ - private getLspServersForDir(dir: string): ResolvedLspServer[] { - const cached = this.lspServersByDir.get(dir); - if (cached) return cached; - let servers: ResolvedLspServer[] = []; - try { - const dirConfig = loadConfig(dir); - servers = resolveServersFromConfig(dirConfig.lsp); - } catch (err) { - console.warn( - `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`, - ); - servers = []; - } - this.lspServersByDir.set(dir, servers); - // Hot-reload for SUBDIRECTORY configs: the root/global watcher in the - // constructor does not cover a nested `dispatch.toml`. Register a - // one-per-dir watcher the first time we cache a directory so editing - // its config invalidates just this entry (and cached agents) without a - // restart. The root working directory is already covered by - // `configWatcher`, so skip it to avoid a redundant watch. - this.ensureLspDirWatcher(dir); - return servers; - } - - /** - * Register (once) a file watcher on `<dir>/dispatch.toml` so a change to a - * subdirectory config invalidates that directory's LSP cache entry and - * any cached agents. No-op for the root working directory (already watched - * by `configWatcher`) and for directories already being watched. - */ - private ensureLspDirWatcher(dir: string): void { - if (dir === this.rootWorkingDirectory) return; - if (this.lspDirWatchers.has(dir)) return; - const watcher = watchDirConfig(dir, () => { - // Drop just this directory's resolved servers; the next tool build - // re-reads (and re-merges global) for it. - this.lspServersByDir.delete(dir); - // Invalidate cached agents so the next message rebuilds tools with - // the updated server set. - for (const tabAgent of this.tabAgents.values()) { - tabAgent.agent = null; - } - for (const tabId of this.tabAgents.keys()) { - this.emit({ type: "config-reload" }, tabId); - this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded"); - } - }); - this.lspDirWatchers.set(dir, watcher); - } - - /** - * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's - * working directory has LSP servers configured. The hook touches the - * just-written file through the LSP and returns a formatted diagnostics - * block (the written file's errors first, then a small cap of other-file - * errors) — opencode's diagnostics-on-write pattern. Returns `undefined` - * when no server matches, so writes stay zero-overhead for non-LSP files. - */ - private buildAfterWriteHook( - workingDirectory: string, - servers: ResolvedLspServer[], - ): ((absolutePath: string) => Promise<string>) | undefined { - if (servers.length === 0) return undefined; - const manager = this.lspManager; - return async (absolutePath: string): Promise<string> => { - if (!manager.hasServerForFile(absolutePath, servers)) return ""; - await manager.touchFile({ - file: absolutePath, - root: workingDirectory, - servers, - mode: "document", - }); - const diagnostics = manager.getDiagnostics({ - root: workingDirectory, - servers, - file: absolutePath, - }); - let output = ""; - let otherFileCount = 0; - for (const [file, issues] of Object.entries(diagnostics)) { - const current = file === absolutePath; - if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue; - const block = reportDiagnostics(file, issues); - if (!block) continue; - if (current) { - output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`; - } else { - otherFileCount++; - output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`; - } - } - return output; - }; - } - - 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(), - currentChunks: null, - currentAssistantId: null, - currentTurnId: null, - autoWakeBudget: MAX_AGENT_AUTO_WAKES, - }; - 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; - - // 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 permUserAgent = getSetting("perm_user_agent") === "allow"; - const permSendToTab = getSetting("perm_send_to_tab") === "allow"; - const permReadTab = getSetting("perm_read_tab") === "allow"; - const permWebSearch = getSetting("perm_web_search") === "allow"; - const permSearchCode = getSetting("perm_search_code") === "allow"; - const permKeyUsage = getSetting("perm_key_usage") === "allow"; - const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; - const permLsp = getSetting("perm_lsp") === "allow"; - const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permKeyUsage}:${permLsp}:${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 - } - - // Resolve LSP servers for this working directory once (cached). - // Drives both diagnostics-on-write (the write_file hook) and the - // optional `lsp` tool. Empty for directories with no `[lsp]` block. - const lspServers = this.getLspServersForDir(workingDirectory); - const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers); - - // Build tools list — child agents use their toolsOverride whitelist, - // parent agents use permission settings from DB - const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = []; - - 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) }); - // read_file_slice is a companion to read_file — only useful for - // inspecting long lines that read_file truncated. Ship them together. - toolEntries.push({ - name: "read_file_slice", - tool: createReadFileSliceTool(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, afterWriteHook), - }); - } - if (allowed.has("run_shell")) { - toolEntries.push({ - name: "run_shell", - tool: createRunShellTool(workingDirectory, tabAgent.shellStore), - }); - } - if (allowed.has("search_code")) { - toolEntries.push({ - name: "search_code", - tool: createSearchCodeTool(workingDirectory), - }); - } - if (allowed.has("web_search")) { - toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); - } - if (allowed.has("key_usage")) { - toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() }); - } - if (allowed.has("lsp") && lspServers.length > 0) { - toolEntries.push({ - name: "lsp", - tool: createLspTool(() => ({ - manager: this.lspManager, - workingDirectory, - servers: lspServers, - })), - }); - } - 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)); - const allAgentDefs = loadAgents(workingDirectory); - const availableSubagents = toAvailableSubagents( - allAgentDefs, - GLOBAL_AGENTS_DIR, - workingDirectory, - ); - const availableUserAgents = toAvailableUserAgents( - allAgentDefs, - GLOBAL_AGENTS_DIR, - workingDirectory, - ); - const agentDirPaths = getAgentDirPaths(workingDirectory); - 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), - }, - availableSubagents, - availableUserAgents, - agentDirPaths, - permUserAgent, - ), - }); - } - 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), - }), - }); - } - // Tab-to-tab communication — gated on the child whitelist. - if (allowed.has("send_to_tab") || allowed.has("read_tab")) { - for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) { - if (allowed.has(entry.name)) toolEntries.push(entry); - } - } - } else { - // Parent agent: use permission settings from DB - if (permRead) { - toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) }); - toolEntries.push({ - name: "read_file_slice", - tool: createReadFileSliceTool(workingDirectory), - }); - toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); - } - if (permEdit) { - toolEntries.push({ - name: "write_file", - tool: createWriteFileTool(workingDirectory, afterWriteHook), - }); - } - if (permBash) { - toolEntries.push({ - name: "run_shell", - tool: createRunShellTool(workingDirectory, tabAgent.shellStore), - }); - } - if (permSearchCode) { - toolEntries.push({ - name: "search_code", - tool: createSearchCodeTool(workingDirectory), - }); - } - if (permWebSearch) { - toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); - } - if (permKeyUsage) { - toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() }); - } - // The `lsp` tool exposes diagnostics + navigation on demand. It is - // gated by `perm_lsp` AND requires at least one server configured - // in the working directory's `dispatch.toml`. - if (permLsp && lspServers.length > 0) { - toolEntries.push({ - name: "lsp", - tool: createLspTool(() => ({ - manager: this.lspManager, - workingDirectory, - servers: lspServers, - })), - }); - } - if (permYoutubeTranscribe) { - toolEntries.push({ - name: "youtube_transcribe", - tool: createYoutubeTranscribeTool(tabAgent.transcriptStore), - }); - } - toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); - // The `summon` tool is registered when EITHER the subagent - // permission (`perm_summon`) OR the user-agent permission - // (`perm_user_agent`) is granted — the two are independent. - // `perm_summon` enables ordinary subagent spawning; granting - // only `perm_user_agent` exposes summon in user-agent-only mode - // (spawns top-level user agents exclusively). - if (permSummon || permUserAgent) { - // Capture parent's allowed tool names for child permission enforcement - const parentAllowedTools = new Set(toolEntries.map((e) => e.name)); - const allAgentDefs = loadAgents(workingDirectory); - const availableSubagents = toAvailableSubagents( - allAgentDefs, - GLOBAL_AGENTS_DIR, - workingDirectory, - ); - const availableUserAgents = toAvailableUserAgents( - allAgentDefs, - GLOBAL_AGENTS_DIR, - workingDirectory, - ); - const agentDirPaths = getAgentDirPaths(workingDirectory); - 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), - }, - availableSubagents, - availableUserAgents, - agentDirPaths, - permUserAgent, - permSummon, - ), - }); - // `retrieve` collects subagent results. User agents are - // fire-and-forget, so it is bundled with the subagent - // permission only — a user-agent-only grant doesn't get it. - if (permSummon) { - 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), - }), - }); - } - } - if (permSendToTab || permReadTab) { - const tabCommAllowed = new Set<string>(); - if (permSendToTab) tabCommAllowed.add("send_to_tab"); - if (permReadTab) tabCommAllowed.add("read_tab"); - for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) { - if (tabCommAllowed.has(entry.name)) toolEntries.push(entry); - } - } - } - - 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 findAccount = () => - this.claudeAccounts.find((a) => a.id === effectiveKeyId) ?? - (credFile - ? this.claudeAccounts.find((a) => a.source === credFile) - : this.claudeAccounts[0]); - let account = findAccount(); - // Self-heal: account discovery runs once at construction and can - // fail at boot (e.g. the data dir isn't mounted yet and - // getDatabase() throws EACCES), leaving claudeAccounts empty for - // the process lifetime. If the lookup fails, re-run discovery now - // that the DB is reachable and retry before giving up. - if (!account) { - this._refreshClaudeAccounts(); - account = findAccount(); - } - 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, key.env); - if (envKey) { - apiKey = envKey; - baseURL = key.base_url; - model = effectiveModelId; - // OpenCode Go splits its catalog across two endpoints: - // `/chat/completions` — GLM, Kimi, DeepSeek, MiMo (OpenAI-compatible) - // `/messages` — MiniMax, Qwen (Anthropic-format) - // The configured key has provider="opencode-go" which defaults to - // the OpenAI-compatible path. When the selected model lives on the - // `/messages` route, route through the API-key Anthropic provider - // instead so the SDK targets the correct endpoint and protocol. - if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(model)) { - provider = "opencode-anthropic"; - } - 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`, - ); - // Apply the correct model + baseURL even when the key - // is unavailable so the request at least targets the - // right endpoint and produces a diagnosable auth error - // instead of silently routing to the default OpenCode Go - // endpoint (which may serve a different model). - baseURL = key.base_url; - model = effectiveModelId; - 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, - tabId, - ...(claudeCredentials ? { claudeCredentials } : {}), - }, - { - dequeueMessages: () => this.dequeueMessages(tabId), - waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), - }, - ); - - // Pre-populate the Agent's in-memory message history from the DB - // so prior turns survive Agent recreation. The Agent is - // constructed fresh here in three scenarios that ALL discard - // the previous in-memory `messages` array: - // 1. First call for this tab (no prior Agent existed) - // 2. Model/key/permission/working-directory change — the - // invalidation gate above set `tabAgent.agent = null`. - // This is the model-switcher-slider case: without this - // pre-population, DeepSeek would see zero context after - // switching from Opus mid-conversation. - // 3. Config or skills reload (configWatcher / skillsWatcher - // also null out `tabAgent.agent`). - // - // Boundary semantics: `processMessage` appends the current turn's - // user message (as a chunk row) BEFORE calling this function, so the - // grouped history ends in `[..., u_current]`. In the fallback retry - // path the previous attempt may also have flushed a partial assistant - // turn, so it can end `[..., u_current, partial_a]`. Either way, we - // walk backwards to the most recent user-role message and load only - // strictly-prior messages: `agent.run()` pushes the current user - // message itself, so including it here would duplicate it. - // - // `toModelMessages` already filters out `role === "system"` - // rows and strips `error` / `system` chunks, so it's safe to - // load system messages verbatim. - try { - const rows = getMessagesForTab(tabId); - let cutIdx = rows.length; - for (let i = rows.length - 1; i >= 0; i--) { - const row = rows[i]; - if (row && row.role === "user") { - cutIdx = i; - break; - } - } - if (cutIdx > 0) { - tabAgent.agent.messages = rows - .slice(0, cutIdx) - .map((r) => ({ role: r.role, chunks: r.chunks })); - } - } catch { - // DB read failed — leave `messages: []`. The agent still - // works, just without prior history (matches pre-fix - // behaviour, so this is no worse than what we had before). - } - } - return tabAgent.agent; - } - - /** - * Resolve connection parameters (apiKey / baseURL / model / provider / - * Claude OAuth credentials) for a key+model pair WITHOUT mutating any tab - * state. Mirrors the resolution in `getOrCreateAgentForTab` (Anthropic - * account refresh, env-var keys, OpenCode-Go anthropic-route detection) but - * is side-effect-free so it can be reused by compaction. Returns `null` when - * the key/model can't be resolved to a usable connection. - */ - private async resolveConnection( - keyId: string, - modelId: string, - ): Promise<{ - apiKey: string; - baseURL: string; - model: string; - provider?: string; - claudeCredentials?: { accessToken: string }; - } | null> { - if (!keyId || !modelId || !this.modelRegistry) return null; - const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === keyId); - if (!keyState) return null; - const key = keyState.definition; - - if (key.provider === "anthropic") { - const credFile = key.credentials_file; - const findAccount = () => - this.claudeAccounts.find((a) => a.id === keyId) ?? - (credFile - ? this.claudeAccounts.find((a) => a.source === credFile) - : this.claudeAccounts[0]); - let account = findAccount(); - if (!account) { - this._refreshClaudeAccounts(); - account = findAccount(); - } - if (!account) return null; - let creds = refreshAccountCredentials(account); - if (!creds || creds.expiresAt <= Date.now() + 60_000) { - const fresh = await refreshAccountCredentialsAsync(account); - if (fresh) { - account.credentials = fresh; - creds = fresh; - } - } - const accessToken = creds?.accessToken ?? account.credentials.accessToken; - return { - apiKey: accessToken, - baseURL: key.base_url, - model: modelId, - provider: "anthropic", - claudeCredentials: { accessToken }, - }; - } - - // Standard key resolved from env var. - const envKey = resolveApiKey(key.id, key.env); - if (!envKey) return null; - let provider: string | undefined; - if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(modelId)) { - provider = "opencode-anthropic"; - } - return { apiKey: envKey, baseURL: key.base_url, model: modelId, provider }; - } - - /** - * Resolve the compactor model: the configured `compaction_model_*` setting - * when present, otherwise fall back to the source tab's own key+model. Used - * to run the summary generation request. - */ - private resolveCompactorKeyModel(sourceTabId: string): { keyId: string; modelId: string } | null { - const cfgKey = getSetting("compaction_model_key_id"); - const cfgModel = getSetting("compaction_model_id"); - if (cfgKey && cfgModel) return { keyId: cfgKey, modelId: cfgModel }; - const tabAgent = this.tabAgents.get(sourceTabId); - const row = getTab(sourceTabId); - const keyId = tabAgent?.keyId ?? row?.keyId ?? null; - const modelId = tabAgent?.modelId ?? row?.modelId ?? null; - if (keyId && modelId) return { keyId, modelId }; - return null; - } - - /** - * Run a one-shot, tool-less summary generation using a transient Agent. The - * Agent loop handles Claude-OAuth billing/identity/caching correctly. The - * prompt is the entire summary request (transcript + template); no tools are - * registered so the model can only produce text. Returns the concatenated - * assistant text, or throws on error/abort. - */ - private async generateSummary( - conn: { - apiKey: string; - baseURL: string; - model: string; - provider?: string; - claudeCredentials?: { accessToken: string }; - }, - prompt: string, - abortSignal: AbortSignal, - ): Promise<string> { - const agent = new Agent({ - model: conn.model, - apiKey: conn.apiKey, - baseURL: conn.baseURL, - systemPrompt: - "You are a conversation-summarization assistant. Follow the user's instructions and output ONLY the requested Markdown summary.", - tools: [], - workingDirectory: process.env.DISPATCH_WORKING_DIR ?? process.cwd(), - provider: conn.provider, - ...(conn.claudeCredentials ? { claudeCredentials: conn.claudeCredentials } : {}), - }); - let out = ""; - let errored: string | null = null; - for await (const event of agent.run(prompt, { abortSignal })) { - if (abortSignal.aborted) break; - if (event.type === "text-delta") out += event.delta; - else if (event.type === "error") errored = event.error; - } - if (abortSignal.aborted) throw new Error("Compaction cancelled"); - if (errored) throw new Error(errored); - const trimmed = out.trim(); - if (!trimmed) throw new Error("Compaction produced an empty summary"); - return trimmed; - } - - /** - * Compact a conversation (UI-driven). Summarizes the older "head" of - * `sourceTabId` into an anchored Markdown summary while preserving the last - * N turns verbatim, then performs the id-relocation the product requires: - * - * - The FULL pre-compaction history is moved to a fresh `backupTabId` - * (so nothing is destroyed — fully reversible). - * - `sourceTabId` (the canonical id, with its key/model/working-dir/agent - * and the global tool permissions intact) is re-seeded with the summary - * turn + the preserved tail. - * - * `tempTabId` is the frontend placeholder tab hosting the "compacting…" - * message; it is discarded on completion. Cancellation = the caller aborts - * via `tempTabId`'s abort controller (e.g. closing the placeholder tab). - * - * Returns when the compaction settles; emits `compaction-started`, - * `compaction-complete`, or `compaction-error`. - */ - async compactTab(tempTabId: string, sourceTabId: string): Promise<void> { - const tempAgent = this._getOrCreateTabAgent(tempTabId); - const abortController = new AbortController(); - tempAgent.abortController = abortController; - - const fail = (error: string): void => { - const src = this.tabAgents.get(sourceTabId); - if (src) src.compacting = false; - this.emit({ type: "compaction-error", tempTabId, sourceTabId, error }, tempTabId); - // Drain anything queued on the source while it was locked. - this.continueFromQueue(sourceTabId); - }; - - try { - // Refuse to compact a running tab (turn must have ended). - if (this.getTabStatus(sourceTabId) === "running") { - fail("Cannot compact while a turn is in progress."); - return; - } - - // Lock the source so new messages queue instead of starting turns. - const sourceAgent = this._getOrCreateTabAgent(sourceTabId); - sourceAgent.compacting = true; - this.emit({ type: "compaction-started", tempTabId, sourceTabId }, tempTabId); - - // Read the full history as grouped messages (preserves turnId/seq). - const rows = groupRowsToMessages(getChunksForTab(sourceTabId)); - const { tail, prompt } = buildCompactionRequest({ messages: rows }); - if (!prompt) { - fail("Not enough conversation history to compact."); - return; - } - - // Resolve the compactor model (configured, else source tab's own). - const compactor = this.resolveCompactorKeyModel(sourceTabId); - if (!compactor) { - fail("No model available to run compaction. Configure a compaction model in Settings."); - return; - } - const conn = await this.resolveConnection(compactor.keyId, compactor.modelId); - if (!conn) { - fail("Could not resolve credentials for the compaction model."); - return; - } - - // Generate the summary (abortable). - const summary = await this.generateSummary(conn, prompt, abortController.signal); - if (abortController.signal.aborted) { - fail("Compaction cancelled"); - return; - } - - // Relocate the FULL history to a backup tab, then re-seed the source. - const sourceRow = getTab(sourceTabId); - const backupTabId = crypto.randomUUID(); - const baseTitle = sourceRow?.title ?? "Conversation"; - const backupTitle = `${baseTitle} (pre-compaction)`; - createTab(backupTabId, backupTitle, { - keyId: sourceRow?.keyId ?? null, - modelId: sourceRow?.modelId ?? null, - }); - rekeyChunks(sourceTabId, backupTabId); - - // Re-seed the canonical (source) id: a summary user turn followed by - // the preserved tail rows (turnId/step/role/type/data preserved). - const summaryTurnId = crypto.randomUUID(); - appendChunks(sourceTabId, explodeUserText(summaryTurnId, buildSummaryTurnText(summary))); - for (const msg of tail) { - const drafts = explodeTurn(msg.turnId, msg.chunks); - if (msg.role === "user") { - // groupRowsToMessages collapses a user message to a single text - // chunk; explodeTurn only handles assistant/system shapes, so - // rebuild the user row explicitly. - const text = msg.chunks.find((c) => c.type === "text"); - appendChunks( - sourceTabId, - explodeUserText(msg.turnId, text && text.type === "text" ? text.text : ""), - ); - continue; - } - if (drafts.length > 0) appendChunks(sourceTabId, drafts); - } - - // Reset the source Agent so its in-memory history reloads from the - // freshly re-seeded chunk log on the next turn. - sourceAgent.agent = null; - sourceAgent.compacting = false; - - this.emit( - { type: "compaction-complete", tempTabId, sourceTabId, backupTabId, backupTitle }, - sourceTabId, - ); - // Drain any messages queued while the source was locked. - this.continueFromQueue(sourceTabId); - } catch (err) { - if (abortController.signal.aborted) { - fail("Compaction cancelled"); - return; - } - fail(err instanceof Error ? err.message : String(err)); - } finally { - // The placeholder tab is transient; drop its in-memory agent state. - this.tabAgents.delete(tempTabId); - } - } - - getTabStatus(tabId: string): AgentStatus { - return this.tabAgents.get(tabId)?.status ?? "idle"; - } - - /** - * Prompt-cache WARMING for an idle tab (see `Agent.warmCache`). - * - * Reconstructs the tab's genuine conversation from the persisted chunk log, - * resolves the SAME agent (model/key/tools/system prompt) the next real turn - * would use, and replays the exact cached prefix plus one trivial throwaway - * turn so the provider's ~5-min prompt-cache TTL is refreshed. The warming - * request and its response are NOT persisted, NOT emitted, and NOT folded - * into the real usage aggregate — its `usage` is returned to the caller so a - * warming-only "last request" cache rate can be shown without polluting the - * real Cache Rate metric. - * - * Refuses to fire while the tab is generating (`running`): the prefix would - * be mid-mutation and the request would contend with the live turn. Callers - * gate on idle anyway; this is defence in depth. - * - * Returns `{ ok: true, usage }` on success or `{ ok: false, error }` so the - * route can surface a debug-strip error string. Never throws. - */ - async warmCacheForTab( - tabId: string, - opts: { - keyId?: string; - modelId?: string; - agentModels?: AgentModelEntry[]; - reasoningEffort?: ReasoningEffort; - } = {}, - ): Promise<{ ok: true; usage: UsageData } | { ok: false; error: string }> { - if (this.getTabStatus(tabId) === "running") { - return { ok: false, error: "tab is generating" }; - } - try { - const tabAgent = this._getOrCreateTabAgent(tabId); - if (opts.agentModels) tabAgent.agentModels = opts.agentModels; - - // Resolve the agent the next REAL turn would use. The fallback chain's - // first entry mirrors `processMessage`'s primary attempt; we only warm - // the primary (warming a fallback model would write a DIFFERENT prefix). - const fallbackSequence = this.buildFallbackSequence(tabAgent, opts.keyId, opts.modelId); - const primary = fallbackSequence[0]; - const agent = await this.getOrCreateAgentForTab( - tabId, - primary?.key_id || opts.keyId, - primary?.model_id || opts.modelId, - ); - - // Resolve the SAME reasoning effort the next real turn would use: - // per-model (agent definition) → per-tab selector → Agent default. - // This drives the thinking providerOptions, which is an Anthropic - // message-cache key — warming MUST match it or it warms a different - // cache bucket than the real turn reads (the 0%-on-switch bug). - const effort = primary?.effort ?? opts.reasoningEffort; - - // Rebuild the genuine history exactly as `getOrCreateAgentForTab`'s - // pre-population does, but keep the FULL history (no trailing-user - // trim): warming replays the complete cached prefix as-is. - let history: ChatMessage[] = []; - try { - history = getMessagesForTab(tabId).map((r) => ({ role: r.role, chunks: r.chunks })); - } catch { - // DB read failed — warm with whatever in-memory history the agent has. - history = [...agent.messages]; - } - - const usage = await agent.warmCache(history, { - ...(effort ? { reasoningEffort: effort } : {}), - }); - return { ok: true, usage }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - /** - * Snapshot of every tab the manager is currently tracking. Sent on WS - * connect and via GET /status so a freshly-loaded frontend can - * reconstruct any in-flight assistant turn without missing the chunks - * that arrived before its WS handshake completed. - * - * For each running tab, the snapshot includes: - * - status: "running" - * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks` - * (the live chunk array the streaming loop appends to). The - * consumer owns this copy and may mutate it freely. - * - currentAssistantId: the DB id of the in-flight assistant message - * row. The frontend aligns its local assistant message id with - * this so the next `done` event lands on the right message. - * - * Every tab additionally carries its `tasks` (the current todo list) when - * non-empty, so a reloaded frontend rehydrates the Tasks panel from the - * backend rather than blanking it. - * - * For idle/error tabs, only `status` (plus any `tasks`) is present. Tabs not in - * `this.tabAgents` (e.g. tabs in the DB that have never been touched - * since server start) are absent from the returned record — the - * caller infers their status from the DB row (always "idle" at rest). - */ - getAllStatuses(): Record<string, TabStatusSnapshot> { - const result: Record<string, TabStatusSnapshot> = {}; - for (const [tabId, tabAgent] of this.tabAgents.entries()) { - const snap: TabStatusSnapshot = { status: tabAgent.status }; - // Include the tab's todo list (for ALL tabs, not just running ones) - // so a reloaded frontend rehydrates the Tasks panel from the backend - // instead of blanking it. Omit when empty to keep the payload lean. - const tasks = tabAgent.taskList.getTasks(); - if (tasks.length > 0) { - snap.tasks = tasks; - } - if (tabAgent.status === "running") { - if (tabAgent.currentChunks) { - // Defensive shallow copy: callers may serialize/mutate. - snap.currentChunks = [...tabAgent.currentChunks]; - } - if (tabAgent.currentAssistantId) { - snap.currentAssistantId = tabAgent.currentAssistantId; - } - if (tabAgent.currentTurnId) { - snap.currentTurnId = tabAgent.currentTurnId; - } - } - result[tabId] = snap; - } - 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 }); - } - } - - /** - * Persist a system chunk (notice / model-changed / config-reload / - * cancelled) to a tab's history. - * - * If an assistant turn is in flight (`currentChunks` is non-null), the - * chunk is folded into the in-flight chunk list; it is exploded into a - * `system` chunk row when the turn flushes. - * - * Otherwise we append a standalone `system` chunk row immediately. Adjacent - * system rows are coalesced back into one system message at group time - * (`groupRowsToMessages`). - */ - private routeSystemEventToTab(tabId: string, kind: SystemChunkKind, text: string): void { - const tabAgent = this.tabAgents.get(tabId); - - // Turn in flight → fold into the in-flight chunk list; it is exploded - // into chunk rows (including this system chunk) when the turn flushes. - if (tabAgent?.currentChunks) { - tabAgent.currentChunks.push({ type: "system", kind, text }); - return; - } - - // No turn in flight → persist a standalone system chunk row immediately. - try { - const turnId = tabAgent?.currentTurnId ?? crypto.randomUUID(); - appendChunks(tabId, explodeTurn(turnId, [{ type: "system", kind, text }])); - } catch { - // DB not available (e.g. tab not yet created) — drop silently. - } - } - - stopTab(tabId: string): void { - const tabAgent = this.tabAgents.get(tabId); - if (tabAgent) { - // If a turn is in flight, drop a `cancelled` system chunk into the - // in-flight chunk list so the user sees an explicit "Generation - // cancelled by user" marker at the cancellation point. It is - // persisted (as a chunk row) when `processMessage` flushes the - // aborted turn. - if (tabAgent.currentChunks) { - tabAgent.currentChunks.push({ - type: "system", - kind: "cancelled", - text: "Generation cancelled by user", - }); - } - tabAgent.abortController?.abort(); - tabAgent.status = "idle"; - this.emit({ type: "status", status: "idle" }, tabId); - 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); - // Drop any spilled tool-output files this tab accumulated. Best-effort — - // errors are swallowed inside the helper. See packages/core/src/tools/truncate.ts. - clearSpillForTab(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; - /** - * Optional slug of an `AgentDefinition` to apply. When set, the - * definition's `tools`, `models`, and `cwd` take precedence over - * the `tools`/`workingDirectory` passed in `options`. Tools are - * still intersected with `parentAllowedTools` to prevent a - * subagent from gaining capabilities its parent doesn't have. - */ - agentSlug?: string; - parentKeyId?: string | null; - parentModelId?: string | null; - parentAllowedTools?: Set<string>; - parentTabId?: string; - /** - * When true, spawn as an independent top-level "user agent" tab - * instead of a subagent child tab. User agents have no parent, - * are persistent, and cannot be retrieved (fire-and-forget). - */ - topLevel?: boolean; - }): Promise<string> { - 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.topLevel - ? defaultWorkDir - : 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 the agent definition (if a slug was supplied) BEFORE - // computing the effective working directory and tool whitelist. - // The definition's cwd/tools take precedence over the caller's - // `workingDirectory`/`tools` parameters, mirroring how a top-level - // tab picking the same definition would behave. - let agentDef: ReturnType<typeof loadAgent> = null; - if (options.agentSlug) { - agentDef = loadAgent(options.agentSlug, parentEffectiveDir); - if (!agentDef) { - const allDefs = loadAgents(parentEffectiveDir); - if (options.topLevel) { - const userAgents = allDefs - .filter((d) => !d.is_subagent) - .map((d) => `${d.slug} (${d.name})`); - const hint = - userAgents.length > 0 - ? ` Available user agents: ${userAgents.join(", ")}.` - : " No user agent definitions exist yet."; - throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`); - } else { - const subagents = allDefs - .filter((d) => d.is_subagent) - .map((d) => `${d.slug} (${d.name})`); - const hint = - subagents.length > 0 - ? ` Available subagents: ${subagents.join(", ")}.` - : " No subagent definitions exist yet."; - throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`); - } - } - - // Validate that the definition type matches the spawn mode: - // subagent slugs can't be used with top_level=true, and - // user-agent slugs can't be used without top_level=true. - if (options.topLevel && agentDef.is_subagent) { - throw new Error( - `Cannot spawn user agent: "${options.agentSlug}" is a subagent definition. Use a non-subagent definition for top_level=true.`, - ); - } - if (!options.topLevel && !agentDef.is_subagent) { - throw new Error( - `Cannot spawn subagent: "${options.agentSlug}" is a user agent definition. Set top_level=true to spawn it as an independent tab, or use a subagent definition.`, - ); - } - } - - // Resolve child working directory. - // Subagents are validated to stay within the parent's effective dir. - // User agents (topLevel) are free to use any directory. - const requestedDir = agentDef?.cwd ?? options.workingDirectory; - let resolvedWorkingDirectory = requestedDir; - if (requestedDir) { - const { isAbsolute, relative, resolve, join } = await import("node:path"); - // Expand ~ in child working directory - let childDir = requestedDir; - if (childDir === "~" || childDir.startsWith("~/")) { - const { homedir } = await import("node:os"); - childDir = join(homedir(), childDir.slice(1)); - } - if (options.topLevel) { - // User agents: resolve freely, no containment check - resolvedWorkingDirectory = resolve(defaultWorkDir, childDir); - } else { - // Subagents: validate within parent's directory - 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 "${requestedDir}" 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; - } - } - - // Determine the child's tool whitelist. When an agent definition - // was supplied, expand its short permission-group names - // (read/edit/bash) into concrete tool names. Otherwise use the - // `tools` parameter verbatim. Either way, intersect with - // parentAllowedTools so a subagent can't gain capabilities the - // parent doesn't have — even an agent definition can't escalate. - const baseTools = agentDef ? expandAgentToolNames(agentDef.tools) : options.tools; - let childTools = baseTools; - if (options.parentAllowedTools) { - childTools = baseTools.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.finalOutput = ""; - - const primary = agentDef?.models[0]; - if (agentDef && primary) { - // The agent definition specifies its own model fallback chain. - // Set keyId/modelId to the primary (first) model in the chain so - // the frontend can display the concrete key/model this subagent - // was configured with, while `agentModels` drives the fallback - // sequence (matches how a top-level tab using this definition - // would be configured). - tabAgent.keyId = primary.key_id; - tabAgent.modelId = primary.model_id; - tabAgent.agentModels = agentDef.models; - } else { - // No definition (or definition has no models) → inherit from - // the parent like before. - tabAgent.keyId = options.parentKeyId ?? null; - tabAgent.modelId = options.parentModelId ?? null; - if (options.parentTabId) { - const parentAgent = this.tabAgents.get(options.parentTabId); - if (parentAgent?.agentModels) { - tabAgent.agentModels = parentAgent.agentModels; - } - } - } - - // Set up completion tracking — user agents are fire-and-forget, - // so only subagents get completion promises. - if (!options.topLevel) { - 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.topLevel ? undefined : 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.topLevel ? null : (options.parentTabId ?? null), - agentSlug: options.agentSlug ?? null, - workingDirectory: resolvedWorkingDirectory ?? null, - agentModels: tabAgent.agentModels ?? 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)" }; - } - if (tabAgent.status === "running") { - return { - status: "error", - error: - "This is a user agent (top-level tab) and cannot be retrieved. User agents are fire-and-forget.", - }; - } - return { - status: "error", - error: "Agent has no completion tracking. It may not have been spawned via summon.", - }; - } - - return tabAgent.completionPromise; - } - - // ─── Tab-to-tab communication ─────────────────────────────────── - // - // `send_to_tab` / `read_tab` let an agent message a peer tab by its short - // handle (a git-style prefix of the tab UUID). Delivery reuses the exact - // running→queue / idle→new-turn routing that `POST /chat` uses (see - // `deliverMessage`), so an agent message behaves identically to a user one. - - /** - * Build the `key_usage` tool, wired to the live model registry (key states) - * and the discovered Claude accounts. The tool fetches usage live with a - * cache fallback (anthropic) or a live scrape (opencode-go), reporting - * remaining headroom, reset times, and data freshness per key. - */ - private buildKeyUsageTool(): ReturnType<typeof createKeyUsageTool> { - return createKeyUsageTool({ - listKeys: () => this.modelRegistry?.getKeys() ?? [], - listClaudeAccounts: () => this.claudeAccounts, - }); - } - - /** - * Build the `send_to_tab` + `read_tab` tool entries for `tabId`. Shared by - * both tool-construction paths (child whitelist + permission-gated parent). - * `selfHandle` is computed once so the calling tab can stamp provenance and - * reject self-sends. - * - * `canReadTab` reflects whether THIS tab will also be granted `read_tab` - * (the permissions are split). It is forwarded into `send_to_tab` so the - * tool only points the agent at `read_tab` when it actually has it — never - * advertising a tool the agent wasn't granted. - */ - private buildTabCommToolEntries( - tabId: string, - canReadTab: boolean, - ): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> { - const selfHandle = shortestUniquePrefix(tabId); - return [ - { - name: "send_to_tab", - tool: createSendToTabTool({ - resolveShortId: (prefix) => this.resolveTabHandle(prefix), - // origin: "agent" subjects this to the receiver's auto-wake - // budget so agent↔agent loops are bounded (see deliverMessage). - deliver: (targetId, message) => - this.deliverMessage(targetId, message, { origin: "agent" }), - listOpenHandles: () => this.listOpenHandles(tabId), - self: { id: tabId, handle: selfHandle }, - canReadTab, - }), - }, - { - name: "read_tab", - tool: createReadTabTool({ - resolveShortId: (prefix) => this.resolveTabHandle(prefix), - getLastResponse: (targetId) => this.getLastTabResponse(targetId), - listOpenHandles: () => this.listOpenHandles(tabId), - }), - }, - ]; - } - - /** - * Project a core `ResolveTabPrefixResult` down to the tool-facing - * `TabResolution` (minimal `{ id, title, handle }` refs). Each match's - * `handle` is recomputed via `shortestUniquePrefix` so the value the tool - * echoes back always matches what the UI currently shows. - */ - private resolveTabHandle(prefix: string): TabResolution { - const res = resolveTabPrefix(prefix); - if (res.status === "none") return { status: "none" }; - if (res.status === "ok") { - return { - status: "ok", - tab: { - id: res.tab.id, - title: res.tab.title, - handle: shortestUniquePrefix(res.tab.id), - }, - }; - } - return { - status: "ambiguous", - matches: res.matches.map((t) => ({ - id: t.id, - title: t.title, - handle: shortestUniquePrefix(t.id), - })), - }; - } - - /** Snapshot of open tabs as `{ handle, title }`, excluding `exceptId` - * (typically the caller's own tab). Drives the "available tabs" hints. */ - private listOpenHandles(exceptId?: string): Array<{ handle: string; title: string }> { - return listOpenTabs() - .filter((t) => t.id !== exceptId) - .map((t) => ({ handle: shortestUniquePrefix(t.id), title: t.title })); - } - - /** - * Return a tab's most recent COMPLETED assistant turn as flat text, plus - * its current status. Reads the persisted chunk log (source of truth) and - * grabs the last `role === "assistant"` group's text chunks. `text` is null - * when no completed assistant turn exists yet. - */ - getLastTabResponse(tabId: string): { text: string | null; status: AgentStatus } { - const status = this.getTabStatus(tabId); - try { - const messages = getMessagesForTab(tabId); - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg || msg.role !== "assistant") continue; - const text = msg.chunks - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("") - .trim(); - if (text.length > 0) return { text, status }; - } - } catch { - // DB unavailable / tab unknown — fall through to null. - } - return { text: null, status }; - } - - /** - * Deliver `message` to `tabId`, choosing the SAME routing as `POST /chat`: - * - target running → queue it (consumed like a user interrupt). - * - target idle/errored → wake it and start a new turn. - * - * Returns quickly; does NOT block on the turn. Both the HTTP `/chat` path - * and the `send_to_tab` tool call through here so the running/idle decision - * lives in exactly one place. - * - * `opts` carries the per-request knobs `/chat` forwards (key/model, agent - * fallback chain, reasoning effort, working dir, an explicit queue id). The - * `send_to_tab` tool passes none of these — for a cold wake (a tab not in - * `tabAgents`, e.g. after a server restart) the key/model are hydrated from - * the live `TabAgent` if present, else from the persisted tab row. (A cold - * tab keeps its stored key/model but not its full agent-definition fallback - * chain — see plan notes.) - */ - deliverMessage( - tabId: string, - message: string, - opts: { - keyId?: string; - modelId?: string; - agentModels?: AgentModelEntry[]; - reasoningEffort?: ReasoningEffort; - workingDirectory?: string; - queueId?: string; - /** - * Ephemeral ordered multimodal content (image/pdf attachments) for a - * FRESH human turn. Forwarded to `processMessage` → `agent.run` only - * when the tab is idle (a started turn); never carried into the queue - * path (attachments require a fresh turn — the caller guards that). - */ - content?: UserContentPart[]; - /** - * Who is sending this message. `"human"` (default) is unrestricted - * and REFILLS the target's agent-to-agent auto-wake budget. `"agent"` - * (from the `send_to_tab` tool) is governed by that budget: an - * agent-originated wake of an idle tab consumes one unit, and once the - * budget is exhausted the message is queued WITHOUT starting a turn - * (returned as `suppressed`) so a runaway A↔B loop can't spend tokens - * forever with no human in the loop. - */ - origin?: "human" | "agent"; - } = {}, - ): { status: "queued"; messageId: string } | { status: "started" } | { status: "suppressed" } { - const origin = opts.origin ?? "human"; - - // A human touching the tab clears any accumulated agent-wake throttle: - // the conversation is back under human supervision, so peers get a fresh - // budget of auto-wakes again. - if (origin === "human") { - this._getOrCreateTabAgent(tabId).autoWakeBudget = MAX_AGENT_AUTO_WAKES; - } - - if (this.getTabStatus(tabId) === "running") { - // Busy target → always queue (consumed like a user interrupt), - // regardless of origin. Queuing does not itself start a turn, so it - // can't drive a runaway loop; we don't spend budget here. - const { messageId } = this.queueMessage(tabId, message, opts.queueId); - return { status: "queued", messageId }; - } - - // Tab is mid-compaction → hold the message (queue, never start a turn) - // until compaction settles. continueFromQueue (called after compaction) - // drains it onto the compacted continuation. - if (this.tabAgents.get(tabId)?.compacting) { - const { messageId } = this.queueMessage(tabId, message, opts.queueId); - return { status: "queued", messageId }; - } - - // Idle/errored target → this delivery would WAKE the tab (start a turn). - // For agent-originated wakes, enforce the auto-wake budget first. - if (origin === "agent") { - const target = this._getOrCreateTabAgent(tabId); - if (target.autoWakeBudget <= 0) { - // Budget exhausted: preserve the message (queue it, never drop) - // but do NOT wake the tab. A human message will refill the budget - // and the queued message will be seen on the next human turn. - this.queueMessage(tabId, message, opts.queueId); - const notice = - `Automatic agent-to-agent message limit reached for this tab ` + - `(${MAX_AGENT_AUTO_WAKES} consecutive). Further messages from other tabs ` + - `are held until you send a message here.`; - this.emit({ type: "notice", message: notice }, tabId); - this.routeSystemEventToTab(tabId, "notice", notice); - return { status: "suppressed" }; - } - target.autoWakeBudget -= 1; - } - - // Resolve key/model: explicit opts win, then the live tab agent's, then - // the persisted row's. - const tabAgent = this.tabAgents.get(tabId); - let keyId = opts.keyId ?? tabAgent?.keyId ?? undefined; - let modelId = opts.modelId ?? tabAgent?.modelId ?? undefined; - const agentModels = opts.agentModels ?? tabAgent?.agentModels; - if (!keyId || !modelId) { - const row = getTab(tabId); - if (row) { - keyId = keyId ?? row.keyId ?? undefined; - modelId = modelId ?? row.modelId ?? undefined; - } - } - - this.processMessage( - tabId, - message, - keyId, - modelId, - opts.reasoningEffort, - opts.workingDirectory, - agentModels, - opts.content, - ).catch((err) => { - console.error(`[dispatch] deliverMessage processMessage error for tab ${tabId}:`, err); - }); - return { status: "started" }; - } - - async processMessage( - tabId: string, - message: string, - keyId?: string, - modelId?: string, - reasoningEffort?: ReasoningEffort, - workingDirectory?: string, - agentModels?: AgentModelEntry[], - content?: UserContentPart[], - ): 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; - - // Persist the user message as a chunk row (once, before any fallback - // retry). The whole turn — this user message plus the assistant's - // chunk rows — shares one `turn_id`. - const turnId = crypto.randomUUID(); - tabAgent.currentTurnId = turnId; - // Announce the turn so the frontend can tag its live chunks with this - // turn_id (stable render keys → flicker-free reconcile when the turn - // seals). Emitted before any content delta. - this.emit({ type: "turn-start", turnId }, tabId); - appendChunks(tabId, explodeUserText(turnId, 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]; - if (!entry) break; // unreachable: loop bound guarantees defined, satisfies TS - // Convert empty strings (used when caller omitted keyId/modelId in - // manual mode) to undefined so `getOrCreateAgentForTab` falls back - // to the tabAgent's stored defaults via the `?? tabAgent.keyId` chain. - currentKeyId = entry.key_id || undefined; - currentModelId = entry.model_id || undefined; - // Effort precedence: per-model (agent definition) → per-tab selector - // (the `reasoningEffort` arg) → the Agent's own DEFAULT_REASONING_EFFORT - // floor (applied inside `agent.run`). - const effortForEntry = entry.effort ?? reasoningEffort; - allOutput = ""; - - // Single ordered chunk list accumulating this attempt's assistant - // turn (text / thinking / tool-batch / error / system), folded from - // the stream via the shared `appendEventToChunks` helper. - const chunks: Chunk[] = []; - // Per-attempt usage accumulator. Reset each fallback attempt so a - // superseded (rate-limited) attempt's usage is discarded alongside its - // `chunks`. One `usage` event → one UsageData row. - const usageRows: UsageData[] = []; - const assistantId = crypto.randomUUID(); - let assistantPersisted = false; - tabAgent.currentChunks = chunks; - tabAgent.currentAssistantId = assistantId; - - // Write-on-seal: explode the accumulated turn into flat chunk rows - // ONCE, when the turn settles. `explodeTurn` splits each step's - // `tool-batch` into separate `tool_call` + `tool_result` rows and - // tags every row with `turn_id` + derived `step`. - const flushAssistant = (): void => { - if (assistantPersisted) return; - // Append usage as extra drafts in the SAME appendChunks call as the - // turn's content rows: one atomic write, one fsync, contiguous seqs. - // Usage rows are an invisible side channel (excluded from - // getChunksForTab); `step` is cosmetic for usage (never grouped). - const drafts = explodeTurn(turnId, chunks); - for (const u of usageRows) { - drafts.push({ turnId, step: 0, role: "assistant", type: "usage", data: u }); - } - if (drafts.length === 0) return; - appendChunks(tabId, drafts); - assistantPersisted = true; - }; - - 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, chunk persistence will throw and we'll catch it below - } - - for await (const event of agent.run(message, { - ...(effortForEntry ? { reasoningEffort: effortForEntry } : {}), - abortSignal: tabAgent.abortController?.signal, - ...(content ? { content } : {}), - })) { - // Stop processing if the tab was aborted (closed/stopped). - // stopTab() already injected a `cancelled` system chunk into - // `chunks` before flipping the abort flag, so we just need - // to flush and exit. - if (tabAgent.abortController?.signal.aborted) break; - - if (event.type === "error") { - attemptError = event.error; - // Record the error as a chunk so it's part of the - // persisted turn history. - appendEventToChunks(chunks, event); - break; - } - - if (event.type === "status") { - tabAgent.status = event.status; - } - this.emit(event, tabId); - - // For diagnostics / child agent result harvesting, keep a - // flat string copy of plain text output. - if (event.type === "text-delta") { - allOutput += event.delta; - } - - // Capture per-step usage as a side-channel row to persist with the - // turn (one row per `usage` event). The live `this.emit(event)` - // above still drives in-session accumulation; this is the reload- - // persistence path. `appendEventToChunks` intentionally ignores - // `usage`, so it never becomes message content. - if (event.type === "usage") { - usageRows.push({ ...event.usage }); - } - - // Route every content-bearing event through the shared helper. - // `appendEventToChunks` ignores lifecycle events (status / done - // / task-list-update / tab-created / message-* / etc), so it's - // safe to call unconditionally. Persistence happens once, after - // the loop, so we never write a partial turn that a fallback - // retry would then duplicate. - appendEventToChunks(chunks, event); - } - } catch (err) { - console.error(`[dispatch] processMessage error for tab ${tabId}:`, err); - attemptError = err instanceof Error ? err.message : String(err); - } - - // Decide whether a fallback retry will supersede this attempt. - const isRetryable = - attemptError !== null && - (attemptError.includes("status=429") || - attemptError.toLowerCase().includes("rate limit") || - attemptError.toLowerCase().includes("rate_limit") || - attemptError.toLowerCase().includes("usage limit") || - attemptError.toLowerCase().includes("exhausted")); - const nextEntry = fallbackSequence[fallbackIdx + 1]; - const willRetry = Boolean(isRetryable && this.modelRegistry && tabAgent.keyId && nextEntry); - - // Persist this attempt's turn — unless a retry will replace it, in - // which case the partial (and its error chunk) is discarded so the - // next attempt's chunks don't merge with a failed one. On success, - // abort, or a final error, the turn is flushed exactly once. - if (!willRetry) { - flushAssistant(); - } - tabAgent.currentChunks = null; - tabAgent.currentAssistantId = null; - - // No error — success - if (!attemptError) { - processError = null; - break; - } - - if (willRetry && nextEntry && tabAgent.keyId) { - this.modelRegistry?.markKeyExhausted(tabAgent.keyId, attemptError); - const fallbackMsg = - `Key "${tabAgent.keyId}" rate limited. ` + - `Falling back to "${nextEntry.key_id}" (model: ${nextEntry.model_id})...`; - console.warn(`[dispatch] ${fallbackMsg}`); - // Persist the notice + model-change as standalone system chunk - // rows (no turn in flight now — currentChunks was just cleared). - this.emit({ type: "notice", message: fallbackMsg }, tabId); - this.routeSystemEventToTab(tabId, "notice", fallbackMsg); - this.emit( - { type: "model-changed", keyId: nextEntry.key_id, modelId: nextEntry.model_id }, - tabId, - ); - this.routeSystemEventToTab( - tabId, - "model-changed", - `Switched to ${nextEntry.model_id} (${nextEntry.key_id})`, - ); - 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; - } - // Turn fully settled and its chunks are now persisted (flushAssistant ran - // above). Signal the frontend that the turn's rows — with real seqs — are - // durable so it can fold its live representation into the sealed log. - // Emitted AFTER status:idle/error (which fire before the DB write). - // Carry the authoritative usage aggregate (read AFTER the usage rows were - // persisted) so the frontend reconciles its live cacheStats to the DB truth - // — self-healing the live overshoot from a discarded rate-limited attempt. - let usageStats: UsageStats | null = null; - try { - usageStats = getUsageStatsForTab(tabId); - } catch { - // DB read failed — omit reconciliation rather than crash the turn. - } - this.emit({ type: "turn-sealed", turnId, usageStats }, tabId); - - // Turn fully settled — clear the shared turn id. - tabAgent.currentTurnId = null; - - // 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 }); - } - - // The turn has fully settled. If messages piled up on the queue during it - // and were NOT injected as a mid-turn interrupt (they arrived after the - // last tool call, or this turn had no tool calls), kick off a fresh turn - // to answer them instead of letting them sit unanswered — the queue is - // consumed, not just appended. Only on a clean finish: a turn the user - // explicitly stopped, or one that errored out, leaves its queue intact - // for the next deliberate send (see continueFromQueue). - if (processError === null) { - this.continueFromQueue(tabId); - } - } - - /** - * Start a new turn for any messages that accumulated on `tabId`'s queue - * during the turn that just finished. This is what makes a queued message - * (from a user OR another agent via send_to_tab) actually get a response - * after the agent's current turn ends, rather than waiting forever. - * - * Loop safety: a queued-then-continued turn draws from the SAME - * `autoWakeBudget` that bounds agent-to-agent wakes. Every human-originated - * message refills that budget when it is delivered (see deliverMessage), so - * human conversations are never throttled; only a runaway agent<->agent - * chain (A queues B, B queues A, ...) is capped. When the budget is spent - * the messages stay queued and a notice is emitted; the next human message - * refills the budget and starts their turn. - */ - private continueFromQueue(tabId: string): void { - const tabAgent = this.tabAgents.get(tabId); - if (!tabAgent) return; - if (tabAgent.messageQueue.length === 0) return; - // Never auto-continue a turn the user stopped or one that errored. - if (tabAgent.status === "error") return; - if (tabAgent.abortController?.signal.aborted) return; - - if (tabAgent.autoWakeBudget <= 0) { - // Budget spent — hold the queued messages (don't drop them) until a - // human message refills the budget. Prevents unbounded agent loops. - const notice = - `Automatic continuation limit reached for this tab ` + - `(${MAX_AGENT_AUTO_WAKES} consecutive turns). Queued messages are held ` + - `until you send a message here.`; - this.emit({ type: "notice", message: notice }, tabId); - this.routeSystemEventToTab(tabId, "notice", notice); - return; - } - tabAgent.autoWakeBudget -= 1; - - // Drain the queue as a "continuation" so the frontend folds the pending - // queued bubbles into this NEW turn's initiating user row (rather than - // into a running turn's tool result, which is the "interrupt" case). - const drained = this.dequeueMessages(tabId, "continuation"); - if (drained.length === 0) return; - const message = drained.map((m) => m.message).join("\n---\n"); - - // Reuse the tab's resolved key/model/fallback chain — the continuation is - // the same conversation, just a new turn. Fire-and-forget: if more - // messages arrive during it, its own tail will continue the chain. - this.processMessage( - tabId, - message, - tabAgent.keyId ?? undefined, - tabAgent.modelId ?? undefined, - undefined, - undefined, - tabAgent.agentModels, - ).catch((err) => { - console.error(`[dispatch] continueFromQueue processMessage error for tab ${tabId}:`, err); - }); - } - - private buildFallbackSequence( - tabAgent: TabAgent, - keyId?: string, - modelId?: string, - ): AgentModelEntry[] { - // 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. - // Always return at least one entry so `processMessage` runs the agent - // once (empty strings let `getOrCreateAgentForTab` fall back to the - // tabAgent's stored defaults or environment-driven config). - return [{ key_id: keyId ?? "", model_id: modelId ?? "" }]; - } - - 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, - reason: "interrupt" | "continuation" = "interrupt", - ): 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), reason }, - tabId, - ); - } - return messages; - } - - waitForQueuedMessage(tabId: string): { promise: Promise<void>; 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<void>((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(); - for (const watcher of this.lspDirWatchers.values()) watcher.close(); - this.lspDirWatchers.clear(); - // Shut down all long-lived LSP server processes. Fire-and-forget: the - // promise is detached so `destroy()` stays synchronous (matching its - // existing contract), but every client gets `shutdown()` called. - void this.lspManager.shutdownAll(); - } -} diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts deleted file mode 100644 index 72188ff..0000000 --- a/packages/api/src/app.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - type AgentModelEntry, - getTab, - isReasoningEffort, - NotificationDispatcher, - type UserContentPart, - validateUserContent, -} from "@dispatch/core"; -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 { modelsRoutes, startWakeScheduler } from "./routes/models.js"; -import { notificationsRoutes } from "./routes/notifications.js"; -import { skillsRoutes } from "./routes/skills.js"; -import { tabsRoutes } from "./routes/tabs.js"; - -/** - * Validate and normalise the `agentModels` fallback chain coming from the - * frontend. Each entry must carry string `key_id`/`model_id`; an `effort` is - * kept only when it's a recognised level (otherwise dropped so the per-tab / - * default effort applies). Returns `undefined` when the input isn't an array. - */ -function sanitizeAgentModels(raw: unknown): AgentModelEntry[] | undefined { - if (!Array.isArray(raw)) return undefined; - const out: AgentModelEntry[] = []; - for (const m of raw) { - if (!m || typeof m !== "object") continue; - const entry = m as Record<string, unknown>; - if (typeof entry.key_id !== "string" || typeof entry.model_id !== "string") continue; - out.push({ - key_id: entry.key_id, - model_id: entry.model_id, - ...(isReasoningEffort(entry.effort) ? { effort: entry.effort } : {}), - }); - } - return out; -} - -/** - * Validate and normalise the optional multimodal `content` array from the - * `/chat` body. Each entry is either a `{ type: "text", text }` part or a - * `{ type: "attachment", mediaType, data, name? }` part (base64 payload). - * Returns `undefined` when the input isn't a non-empty array or contains no - * attachment (so the plain-string path is taken — byte-identical to before). - * Shape only: SIZE/TYPE limits are enforced separately by `validateUserContent`. - */ -function sanitizeUserContent(raw: unknown): UserContentPart[] | undefined { - if (!Array.isArray(raw) || raw.length === 0) return undefined; - const out: UserContentPart[] = []; - let hasAttachment = false; - for (const p of raw) { - if (!p || typeof p !== "object") continue; - const part = p as Record<string, unknown>; - if (part.type === "text") { - if (typeof part.text === "string") out.push({ type: "text", text: part.text }); - continue; - } - if (part.type === "attachment") { - if (typeof part.mediaType !== "string" || typeof part.data !== "string") continue; - hasAttachment = true; - out.push({ - type: "attachment", - mediaType: part.mediaType, - data: part.data, - ...(typeof part.name === "string" ? { name: part.name } : {}), - }); - } - } - // No attachment → let the plain-text path handle it (avoids needlessly - // switching the model message to array content for a text-only turn). - return hasAttachment ? out : undefined; -} - -export const permissionManager = new PermissionManager(); -export const agentManager = new AgentManager(permissionManager); - -// ntfy.sh push notifications. The dispatcher reads its config from the -// `settings` table on every send, so config changes apply immediately — -// no restart, no re-attach needed. -export const notificationDispatcher = new NotificationDispatcher({ - getTabTitle: (tabId) => { - try { - return getTab(tabId)?.title ?? null; - } catch { - return null; - } - }, - getTabParentId: (tabId) => { - try { - // `undefined` when the lookup fails (tab not found / DB unavailable) - // so the dispatcher falls back to "treat as top-level" rather than - // silently dropping notifications. - const row = getTab(tabId); - return row ? row.parentTabId : undefined; - } catch { - return undefined; - } - }, -}); -notificationDispatcher.attachToAgentManager(agentManager); -notificationDispatcher.attachToPermissionManager(permissionManager); - -export const app = new Hono(); - -app.use( - "*", - cors({ - origin: (origin) => origin || "*", - credentials: true, - allowHeaders: ["Content-Type", "Authorization"], - allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], - }), -); - -app.get("/health", (c) => { - return c.json({ ok: true }); -}); - -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<{ - tabId?: unknown; - message?: unknown; - content?: unknown; - keyId?: unknown; - modelId?: unknown; - agentModels?: unknown; - reasoningEffort?: unknown; - workingDirectory?: unknown; - queueId?: 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); - } - - const keyId = typeof body.keyId === "string" ? body.keyId : undefined; - const modelId = typeof body.modelId === "string" ? body.modelId : undefined; - const agentModels = sanitizeAgentModels(body.agentModels); - const workingDirectory = - typeof body.workingDirectory === "string" ? body.workingDirectory : undefined; - const queueId = typeof body.queueId === "string" ? body.queueId : undefined; - const reasoningEffort = isReasoningEffort(body.reasoningEffort) - ? body.reasoningEffort - : undefined; - - // Optional multimodal content (image/pdf attachments). When present, the - // attachments are EPHEMERAL — forwarded to the model for this turn only and - // never persisted (the chunk log keeps just `message`, which the frontend - // has already projected to text with `[image]`/`[pdf]` markers). - const content = sanitizeUserContent(body.content); - if (content) { - // Enforce size/type/count ceilings server-side (defence in depth; the - // frontend also enforces them at paste time). Reject the whole request - // so no tokens are spent on an over-limit payload. - const validation = validateUserContent(content); - if (!validation.ok) { - return c.json({ error: "invalid attachments", details: validation.errors }, 400); - } - // Attachments only attach to a FRESH turn. If the tab is mid-turn the - // message would queue (text-only machinery), silently dropping the - // images. Reject clearly instead so the user can retry once idle. - if (agentManager.getTabStatus(tabId) === "running") { - return c.json( - { error: "cannot attach images while the agent is generating; wait for it to finish" }, - 409, - ); - } - } - - // Single routing decision (queue if busy, new turn if idle) shared with the - // `send_to_tab` tool via `AgentManager.deliverMessage`. Non-blocking — a - // started turn runs in the background. - const outcome = agentManager.deliverMessage(tabId, message, { - ...(keyId ? { keyId } : {}), - ...(modelId ? { modelId } : {}), - ...(agentModels ? { agentModels } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - ...(workingDirectory !== undefined ? { workingDirectory } : {}), - ...(queueId ? { queueId } : {}), - ...(content ? { content } : {}), - }); - - if (outcome.status === "queued") { - return c.json({ status: "queued", messageId: outcome.messageId }); - } - return c.json({ status: "ok" }); -}); - -app.route("/config", configRoutes); - -app.post("/chat/cancel", async (c) => { - const body = await c.req.json(); - if (typeof body.tabId !== "string" || typeof body.messageId !== "string") { - return c.json({ error: "tabId and messageId are required strings" }, 400); - } - const tabId = body.tabId; - const messageId = body.messageId; - const cancelled = agentManager.cancelQueuedMessage(tabId, messageId); - return c.json({ success: cancelled }); -}); - -app.post("/chat/stop", async (c) => { - const body = await c.req.json(); - if (typeof body.tabId !== "string") { - return c.json({ error: "tabId is required" }, 400); - } - agentManager.stopTab(body.tabId); - return c.json({ success: true }); -}); - -// Prompt-cache WARMING (see AgentManager.warmCacheForTab / Agent.warmCache). -// -// Replays the tab's exact cached prefix + one trivial throwaway turn so the -// provider's ~5-min prompt-cache TTL is refreshed while the tab sits idle. -// The frontend's cache-warming timer drives this every ~4 minutes. The -// warming request is NEVER persisted, NEVER emitted, and NEVER folded into the -// real usage aggregate — we return ONLY its `usage` so the UI can show a -// warming-specific "last request" cache rate without polluting the real -// Cache Rate metric. Returns 409 when the tab is mid-turn (caller also gates). -app.post("/chat/warm", async (c) => { - const body = await c.req.json<{ - tabId?: unknown; - keyId?: unknown; - modelId?: unknown; - agentModels?: unknown; - reasoningEffort?: unknown; - }>(); - const { tabId } = body; - if (typeof tabId !== "string" || tabId.trim() === "") { - return c.json({ error: "tabId must be a non-empty string" }, 400); - } - const keyId = typeof body.keyId === "string" ? body.keyId : undefined; - const modelId = typeof body.modelId === "string" ? body.modelId : undefined; - const agentModels = sanitizeAgentModels(body.agentModels); - // Same effort the real turn would use — a message-cache key, so warming must - // match it to refresh the SAME bucket the next real message reads. - const reasoningEffort = isReasoningEffort(body.reasoningEffort) - ? body.reasoningEffort - : undefined; - - const result = await agentManager.warmCacheForTab(tabId, { - ...(keyId ? { keyId } : {}), - ...(modelId ? { modelId } : {}), - ...(agentModels ? { agentModels } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - }); - if (!result.ok) { - // "tab is generating" is an expected race (not a server fault) → 409. - const status = result.error === "tab is generating" ? 409 : 500; - return c.json({ error: result.error }, status); - } - return c.json({ usage: result.usage }); -}); - -app.route("/skills", skillsRoutes); -app.route("/models", modelsRoutes); -app.route("/tabs", tabsRoutes); -app.route("/agents", agentsRoutes); -app.route("/notifications", notificationsRoutes); - -// Start the wake scheduler on boot (restores persisted schedule) -startWakeScheduler(); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts deleted file mode 100644 index 5615e08..0000000 --- a/packages/api/src/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { PermissionReply } from "@dispatch/core"; -import { createBunWebSocket } from "hono/bun"; -import { agentManager, app, permissionManager } from "./app.js"; - -const { upgradeWebSocket, websocket } = createBunWebSocket(); - -let clientIdCounter = 0; - -app.get( - "/ws", - upgradeWebSocket((_c) => { - const clientId = String(++clientIdCounter); - - return { - onOpen(_event, ws) { - // Send current statuses immediately - ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() })); - - // Send any pending permission prompts - const pending = permissionManager.getPending(); - if (pending.length > 0) { - ws.send(JSON.stringify({ type: "permission-prompt", pending })); - } - - const unsubscribe = agentManager.onEvent((event) => { - ws.send(JSON.stringify(event)); - }); - - permissionManager.registerClient(clientId, (data) => { - ws.send(JSON.stringify(data)); - }); - - // Store cleanup on the raw socket - (ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe; - (ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId; - }, - onMessage(event, _ws) { - try { - const message = JSON.parse(String(event.data)) as { - type?: string; - id?: string; - 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); - } - } - } catch { - // ignore malformed messages - } - }, - onClose(_event, ws) { - const raw = ws as unknown as { _unsub?: () => void; _clientId?: string }; - if (raw._unsub) { - raw._unsub(); - } - if (raw._clientId) { - permissionManager.unregisterClient(raw._clientId); - } - }, - }; - }), -); - -export { app }; - -// Starting port (overridable via PORT). When the port is already in use we -// bump up one at a time (3000 → 3001 → 3002, …) until we find a free one, so -// multiple dispatch instances (e.g. testing several features at once) can -// coexist without manually juggling ports. The frontend defaults to :3000 — -// point it at the chosen port via the in-app API-URL field / VITE_API_URL -// when a bump happens. -const START_PORT = Number(process.env.PORT) || 3000; - -/** - * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a - * free port is found (up to the maximum valid TCP port, 65535). Bun's - * `Bun.serve` throws synchronously when the port is taken, so we can catch and - * retry. Returns the live server (whose `.port` reflects the port actually - * bound). - */ -function serveWithPortFallback() { - let lastError: unknown; - for (let port = START_PORT; port <= 65535; port++) { - try { - const server = Bun.serve({ - port, - idleTimeout: 60, - fetch: app.fetch, - websocket, - }); - if (port !== START_PORT) { - console.warn( - `dispatch: port ${START_PORT} in use — bound to ${port} instead. ` + - `Set the frontend's API URL to http://localhost:${port}.`, - ); - } - console.log(`dispatch: API listening on http://localhost:${server.port}`); - return server; - } catch (err) { - const code = (err as NodeJS.ErrnoException)?.code; - if (code === "EADDRINUSE") { - lastError = err; - continue; - } - throw err; - } - } - console.error( - `dispatch: no free port at or above ${START_PORT}. ` + - `Free one up or set PORT to an open port.`, - ); - throw lastError ?? new Error(`No free port at or above ${START_PORT}`); -} - -// Only start the server when run as the entry point — importing this module -// (e.g. for `app`) must not bind a port. This preserves the prior -// default-export behavior where Bun served only the entry file. -if (import.meta.main) { - serveWithPortFallback(); -} diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts deleted file mode 100644 index 3a24d03..0000000 --- a/packages/api/src/permission-manager.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - type PermissionReply, - type PermissionRequest, - PermissionService, - type Ruleset, -} from "@dispatch/core"; - -/** - * Listener fired exactly once per newly-created pending prompt. Used by - * the notification dispatcher so that a permission request triggers a - * push notification on the user's phone (without re-firing every time - * the pending list mutates for an unrelated reason). - */ -export type PromptAddedListener = (prompt: { - id: string; - permission: string; - description: string; - metadata: Record<string, unknown>; -}) => void; - -export class PermissionManager { - private service = new PermissionService(); - private wsClients: Map<string, (data: unknown) => void> = new Map(); - private promptAddedListeners: Set<PromptAddedListener> = new Set(); - /** Ids that have already been broadcast as "added" — guards against re-emits. */ - private announcedPromptIds: Set<string> = new Set(); - - registerClient(id: string, send: (data: unknown) => void): void { - this.wsClients.set(id, send); - } - - unregisterClient(id: string): void { - this.wsClients.delete(id); - } - - private broadcastPending(pending: Array<{ id: string; request: PermissionRequest }>): void { - const message = { - type: "permission-prompt", - pending: pending.map((p) => ({ id: p.id, ...p.request })), - }; - for (const send of this.wsClients.values()) { - send(message); - } - - // Detect newly-added prompts (ids present now that weren't before) and - // fire `promptAddedListeners` once for each. Resolved/rejected ids are - // pruned from `announcedPromptIds` so a future prompt that reuses an - // id (theoretical, given the monotonic counter) would still notify. - const currentIds = new Set(pending.map((p) => p.id)); - for (const id of this.announcedPromptIds) { - if (!currentIds.has(id)) this.announcedPromptIds.delete(id); - } - for (const p of pending) { - if (this.announcedPromptIds.has(p.id)) continue; - this.announcedPromptIds.add(p.id); - for (const listener of this.promptAddedListeners) { - try { - listener({ - id: p.id, - permission: p.request.permission, - description: p.request.description, - metadata: p.request.metadata, - }); - } catch (err) { - console.warn( - `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } - } - - async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> { - const promise = this.service.ask(request, rulesets); - this.broadcastPending(this.service.getPending()); - return promise; - } - - reply(id: string, reply: PermissionReply): void { - this.service.reply(id, reply); - this.broadcastPending(this.service.getPending()); - } - - getPending(): Array<{ id: string; request: PermissionRequest }> { - return this.service.getPending(); - } - - getService(): PermissionService { - return this.service; - } - - /** - * Subscribe to "a new prompt is now pending" events. Fires once per - * unique prompt id, even if `broadcastPending` is called repeatedly - * for unrelated mutations. Returns an unsubscribe function. - */ - onPromptAdded(listener: PromptAddedListener): () => void { - this.promptAddedListeners.add(listener); - return () => { - this.promptAddedListeners.delete(listener); - }; - } -} diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts deleted file mode 100644 index 10ca714..0000000 --- a/packages/api/src/routes/agents.ts +++ /dev/null @@ -1,126 +0,0 @@ -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, - isReasoningEffort, - 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 || []).map((m) => ({ - key_id: m.key_id, - model_id: m.model_id, - // Keep `effort` only when it's a recognised level; drop anything else. - ...(isReasoningEffort(m.effort) ? { effort: m.effort } : {}), - })), - scope: body.scope, - slug: body.slug, - ...(body.cwd ? { cwd: body.cwd } : {}), - ...(body.is_subagent ? { is_subagent: true } : {}), - }; - 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)); - } - // Resolve relative paths against the project root - if (!path.isAbsolute(dirPath)) { - const projectDir = process.env.DISPATCH_WORKING_DIR || process.cwd(); - dirPath = path.resolve(projectDir, dirPath); - } - try { - const stat = fs.statSync(dirPath); - return c.json({ exists: stat.isDirectory(), resolved: dirPath }); - } catch { - return c.json({ exists: false, resolved: dirPath }); - } -}); - -export { agentsRoutes }; diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts deleted file mode 100644 index 65a1e2a..0000000 --- a/packages/api/src/routes/config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DispatchConfig } from "@dispatch/core"; -import { Hono } from "hono"; - -let getConfig: () => DispatchConfig = () => ({ permissions: {} }); - -export function setConfigGetter(getter: () => DispatchConfig): void { - getConfig = getter; -} - -const configRoutes = new Hono(); - -configRoutes.get("/", (c) => { - const config = getConfig(); - - // Strip env field values from keys for security - const safeConfig: DispatchConfig = { - ...config, - keys: config.keys?.map((key) => ({ - ...key, - env: "***", - })), - }; - - return c.json({ config: safeConfig }); -}); - -export { configRoutes }; diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts deleted file mode 100644 index a1700b1..0000000 --- a/packages/api/src/routes/models.ts +++ /dev/null @@ -1,1073 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import type { ModelRegistry } from "@dispatch/core"; -import { - ANTHROPIC_MODELS_FALLBACK, - buildWakeProbeBody, - type ClaudeAccount, - fetchAnthropicModels, - fetchCopilotUsage, - fetchGoogleUsage, - fetchOpencodeUsage, - getAccountUsage, - getAnthropicHeaders, - getClaudeAccountsFromDB, - getDatabase, - importCredentialsFromFile, - listApiKeys, - listStoredCredentials, - refreshAccountCredentialsAsync, - resolveApiKey, - resolveContextLimit, - resolveModelCapabilities, - selectHaikuModel, - setApiKey, - validateAccountCredentials, -} from "@dispatch/core"; -import { Hono } from "hono"; -import { - CLAUDE_RESET_OFFSET_HOURS, - isProbeSlotMinute, - nextDailyAfter, - PROBE_SLOT_MINUTES, - type ProbeSlotMinute, - recoverScheduleEntry, -} from "../wake-scheduler.js"; - -let getRegistry: () => ModelRegistry | null = () => null; -let getAccounts: () => ClaudeAccount[] = () => []; - -export function setModelsGetter(registryGetter: () => ModelRegistry | null): void { - getRegistry = registryGetter; -} - -export function setAccountsGetter(getter: () => ClaudeAccount[]): void { - getAccounts = getter; -} - -/** Load Claude accounts from the database. */ -function resolveClaudeAccounts(): ClaudeAccount[] { - return getClaudeAccountsFromDB(); -} - -export const modelsRoutes = new Hono(); - -modelsRoutes.get("/", (c) => { - const registry = getRegistry(); - if (!registry) { - return c.json({ keys: [] }); - } - - const keyStates = registry.getKeys(); - - const keys = keyStates.map((ks) => ({ - id: ks.definition.id, - provider: ks.definition.provider, - status: ks.status, - lastError: ks.lastError ?? null, - exhaustedAt: ks.exhaustedAt ?? null, - })); - - return c.json({ keys }); -}); - -// Fetch available models for a specific provider key. -modelsRoutes.get("/available", async (c) => { - const registry = getRegistry(); - if (!registry) { - return c.json({ error: "no registry configured" }, 500); - } - - const keyId = c.req.query("keyId"); - if (!keyId) { - return c.json({ error: "keyId query parameter is required" }, 400); - } - - const keyStates = registry.getKeys(); - const key = keyStates.find((ks) => ks.definition.id === keyId); - if (!key) { - return c.json({ error: `key not found: ${keyId}` }, 404); - } - - // Anthropic provider: validate credentials and fetch models dynamically - 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]); - - if (!account) { - return c.json({ error: "no Claude credentials found" }, 500); - } - - const profile = await validateAccountCredentials(account); - if (!profile) { - return c.json( - { - error: "Claude credentials are invalid or expired", - details: "Run `claude` to re-authenticate.", - }, - 401, - ); - } - - const creds = account.credentials; - let models = await fetchAnthropicModels(creds.accessToken); - if (models.length === 0) { - models = ANTHROPIC_MODELS_FALLBACK; - } - - return c.json({ - models, - subscriptionType: account.credentials.subscriptionType, - ...(profile.email ? { email: profile.email } : {}), - }); - } - - const apiKeyValue = resolveApiKey(keyId, key.definition.env); - if (!apiKeyValue) { - return c.json({ error: `no API key found for ${keyId}` }, 500); - } - - const baseUrl = key.definition.base_url.replace(/\/+$/, ""); - const url = `${baseUrl}/models`; - const headers: Record<string, string> = { - Authorization: `Bearer ${apiKeyValue}`, - }; - if (key.definition.provider === "github-copilot") { - headers["Copilot-Integration-Id"] = "vscode-chat"; - } - - let response: Response; - try { - response = await fetch(url, { headers }); - } catch (err) { - return c.json({ error: "provider API call failed", details: String(err) }, 502); - } - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return c.json( - { error: "provider API returned error", status: response.status, details: text }, - 502, - ); - } - - let data: { data: { id: string }[] }; - try { - data = await response.json(); - } catch (err) { - return c.json({ error: "failed to parse provider response", details: String(err) }, 502); - } - - const models = data.data.map((m) => m.id.replace(/^models\//, "")); - return c.json({ models }); -}); - -// Resolve a model's MAXIMUM context window (in tokens) from the models.dev -// catalog. Returns `{ contextLimit: number | null }`; `null` means the model's -// limit is unknown (unsupported provider, unknown model, or catalog offline), -// which the frontend renders without a denominator/percentage. -modelsRoutes.get("/context-limit", async (c) => { - const provider = c.req.query("provider"); - const modelId = c.req.query("modelId"); - if (!provider || !modelId) { - return c.json({ error: "provider and modelId query parameters are required" }, 400); - } - - const contextLimit = await resolveContextLimit(provider, modelId); - return c.json({ contextLimit }); -}); - -// Resolve a model's image / PDF INPUT capabilities from the models.dev catalog. -// Returns `{ capabilities: { image, pdf } | null }`. `null` means UNKNOWN — the -// provider is unmapped, the model is absent, the catalog predates the -// `modalities` field, or the catalog is offline. The frontend treats `null` as -// "can't verify" (optimistic allow) and a definitive `{ image: false }` as a -// hard block (no tokens spent). -modelsRoutes.get("/capabilities", async (c) => { - const provider = c.req.query("provider"); - const modelId = c.req.query("modelId"); - if (!provider || !modelId) { - return c.json({ error: "provider and modelId query parameters are required" }, 400); - } - - const capabilities = await resolveModelCapabilities(provider, modelId); - return c.json({ capabilities }); -}); - -// List available Claude accounts with validated credentials -modelsRoutes.get("/claude-accounts", async (c) => { - const candidates = resolveClaudeAccounts(); - - // Validate each account's credentials; only include ones with a working token - const validated: Array<{ - id: string; - label: string; - source: string; - subscriptionType: string; - expiresAt: number; - email?: string; - }> = []; - - for (const acct of candidates) { - const profile = await validateAccountCredentials(acct); - if (profile) { - validated.push({ - id: acct.id, - label: acct.label, - source: acct.source, - subscriptionType: acct.credentials.subscriptionType ?? "unknown", - expiresAt: acct.credentials.expiresAt, - ...(profile.email ? { email: profile.email } : {}), - }); - } - } - - return c.json({ accounts: validated }); -}); - -// Get usage for a specific Claude account -modelsRoutes.get("/claude-usage", async (c) => { - const accountId = c.req.query("accountId"); - const accounts = getAccounts(); - const accountAccounts = resolveClaudeAccounts(); - const allAccounts = accounts.length > 0 ? accounts : accountAccounts; - - let account: ClaudeAccount | undefined; - if (accountId) { - account = allAccounts.find((a) => a.id === accountId); - if (!account) { - return c.json({ error: `account not found: ${accountId}` }, 404); - } - } else { - account = allAccounts[0]; - } - - if (!account) { - return c.json({ error: "no Claude accounts available" }, 404); - } - - const report = await getAccountUsage(account); - if (!report) { - return c.json({ error: "failed to fetch usage data" }, 502); - } - - return c.json(report); -}); - -// Get usage for a specific key by ID -modelsRoutes.get("/key-usage", async (c) => { - const keyId = c.req.query("keyId"); - if (!keyId) { - return c.json({ error: "keyId query parameter is required" }, 400); - } - - const registry = getRegistry(); - if (!registry) { - return c.json({ error: "registry not available" }, 502); - } - - const keys = registry.getKeys(); - const key = keys.find((k) => k.definition.id === keyId); - if (!key) { - return c.json({ error: `key not found: ${keyId}` }, 404); - } - - const provider = key.definition.provider; - - try { - if (provider === "anthropic") { - const allAccounts = resolveClaudeAccounts(); - const credFile = key.definition.credentials_file; - // Match by key ID (DB accounts) or source file (file accounts) - const accounts = allAccounts.filter( - (a) => a.id === keyId || (credFile && a.source === credFile), - ); - if (accounts.length === 0 && allAccounts[0]) { - accounts.push(allAccounts[0]); - } - if (accounts.length === 0) { - return c.json({ error: "no Claude accounts available" }, 502); - } - // Fetch usage for matched accounts - const accountResults = await Promise.all( - accounts.map(async (acct) => { - const report = await getAccountUsage(acct); - return { - label: acct.label, - source: acct.source, - subscriptionType: acct.credentials.subscriptionType, - fiveHour: report?.fiveHour, - sevenDay: report?.sevenDay, - error: report ? undefined : "failed to fetch", - }; - }), - ); - return c.json({ - provider: "anthropic", - accounts: accountResults, - // Legacy single-account fields (first account) - fiveHour: accountResults[0]?.fiveHour, - sevenDay: accountResults[0]?.sevenDay, - }); - } else if (provider === "opencode-go") { - // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus - // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix). - const report = await fetchOpencodeUsage(key.definition.id); - if (report) { - return c.json({ - provider: "opencode-go", - fiveHour: report.fiveHour, - weekly: report.weekly, - monthly: report.monthly, - }); - } - // Fall back: show limits info with link to console - return c.json({ - provider: "opencode-go", - unavailable: true, - consoleUrl: "https://opencode.ai/auth", - limits: { - fiveHour: "$12", - weekly: "$30", - monthly: "$60", - }, - }); - } else if (provider === "github-copilot") { - const token = resolveApiKey(keyId, key.definition.env); - if (!token) { - return c.json({ error: `no API key found for ${keyId}` }, 502); - } - const report = await fetchCopilotUsage(token, key.definition.base_url); - if (!report) { - return c.json({ error: "failed to fetch usage data" }, 502); - } - return c.json({ - provider: "github-copilot", - tokensConsumed: report.tokensConsumed, - tokensRemaining: report.tokensRemaining, - percentUsed: report.percentUsed, - resetAt: report.resetAt, - plan: report.plan, - }); - } else if (provider === "google") { - const token = resolveApiKey(keyId, key.definition.env); - if (!token) { - return c.json({ error: `no API key found for ${keyId}. Set GOOGLE_API_KEY env var.` }, 502); - } - const report = await fetchGoogleUsage(token, key.definition.base_url); - if (!report) { - return c.json({ error: "failed to fetch Google usage data" }, 502); - } - return c.json({ - provider: "google", - models: report.models, - currentUsage: report.currentUsage, - weeklyUsage: report.weeklyUsage, - }); - } else { - return c.json({ error: "usage tracking not supported for this provider" }, 400); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return c.json({ error: `failed to fetch usage: ${message}` }, 502); - } -}); - -// ─── API key management ─────────────────────────────────────── - -modelsRoutes.post("/set-api-key", async (c) => { - const body = await c.req.json<{ keyId?: string; apiKey?: string }>(); - if (typeof body.keyId !== "string" || !body.keyId) { - return c.json({ error: "keyId is required" }, 400); - } - if (typeof body.apiKey !== "string" || !body.apiKey) { - return c.json({ error: "apiKey is required" }, 400); - } - - const registry = getRegistry(); - if (!registry) { - return c.json({ error: "registry not available" }, 502); - } - - const keys = registry.getKeys(); - const key = keys.find((k) => k.definition.id === body.keyId); - if (!key) { - return c.json({ error: `key not found: ${body.keyId}` }, 404); - } - - setApiKey(body.keyId, key.definition.provider, body.apiKey); - return c.json({ success: true, keyId: body.keyId }); -}); - -modelsRoutes.get("/api-keys-status", (c) => { - const stored = listApiKeys(); - return c.json({ keys: stored }); -}); - -// ─── Credential import ──────────────────────────────────────── - -modelsRoutes.post("/import-credentials", async (c) => { - const body = await c.req.json<{ keyId?: string }>(); - const keyId = body.keyId; - if (typeof keyId !== "string" || !keyId) { - return c.json({ error: "keyId is required" }, 400); - } - - const registry = getRegistry(); - if (!registry) { - return c.json({ error: "registry not available" }, 502); - } - - const keys = registry.getKeys(); - const key = keys.find((k) => k.definition.id === keyId); - if (!key) { - return c.json({ error: `key not found: ${keyId}` }, 404); - } - - if (key.definition.provider !== "anthropic") { - return c.json({ error: "credential import is only supported for anthropic keys" }, 400); - } - - const credFile = key.definition.credentials_file; - if (!credFile) { - return c.json({ error: "no credentials_file configured for this key" }, 400); - } - - const result = importCredentialsFromFile(keyId, key.definition.provider, credFile); - if (!result.success) { - return c.json({ error: result.error ?? "import failed" }, 400); - } - - return c.json({ success: true, keyId }); -}); - -modelsRoutes.get("/credentials-status", (c) => { - const stored = listStoredCredentials(); - const status = stored.map((cred) => ({ - keyId: cred.keyId, - provider: cred.provider, - subscriptionType: cred.subscriptionType, - sourceFile: cred.sourceFile, - importedAt: cred.importedAt, - updatedAt: cred.updatedAt, - expired: cred.expiresAt < Date.now(), - })); - return c.json({ credentials: status }); -}); - -// ─── Add key to dispatch.toml ───────────────────────────────── - -const VALID_PROVIDERS = ["anthropic", "opencode-go", "google"] as const; -type SupportedProvider = (typeof VALID_PROVIDERS)[number]; - -const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = { - anthropic: "https://api.anthropic.com/v1", - "opencode-go": "https://opencode.ai/zen/go/v1", - google: "https://generativelanguage.googleapis.com/v1beta/openai", -}; - -modelsRoutes.post("/add-key", async (c) => { - const body = await c.req.json<{ id?: unknown; provider?: unknown }>(); - - // Validate id - if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) { - return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400); - } - const id = body.id.trim(); - - // Validate provider - if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) { - return c.json({ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, 400); - } - const provider = body.provider as SupportedProvider; - const base_url = PROVIDER_BASE_URLS[provider]; - - // Read current dispatch.toml - const tomlPath = `${process.cwd()}/dispatch.toml`; - let tomlContent: string; - try { - tomlContent = readFileSync(tomlPath, "utf-8"); - } catch (err) { - return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); - } - - // Check for duplicate key id - const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m"); - if (idPattern.test(tomlContent)) { - return c.json({ error: `key with id "${id}" already exists` }, 409); - } - - // Build the new [[keys]] block - let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`; - if (provider === "anthropic") { - const credPath = `${homedir()}/.claude/.credentials-${id}.json`; - newBlock += `\ncredentials_file = "${credPath}"`; - } else { - const envVar = - provider === "google" - ? "GOOGLE_API_KEY" - : `DISPATCH_${id.toUpperCase().replace(/-/g, "_")}_KEY`; - newBlock += `\nenv = "${envVar}"`; - } - newBlock += "\n"; - - // Insert before the # ─── Permissions section if it exists, otherwise at end - const permissionsMarker = /\n# [─-]+ Permissions/; - let newContent: string; - const permMatch = permissionsMarker.exec(tomlContent); - if (permMatch) { - const insertAt = permMatch.index; - newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt); - } else { - newContent = tomlContent + newBlock; - } - - try { - writeFileSync(tomlPath, newContent, "utf-8"); - } catch (err) { - return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); - } - - const key: { id: string; provider: string; base_url: string; credentials_file?: string } = { - id, - provider, - base_url, - }; - if (provider === "anthropic") { - key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`; - } - - return c.json({ success: true, key }); -}); - -// ─── Remove key from dispatch.toml ──────────────────────────── - -modelsRoutes.post("/remove-key", async (c) => { - const body = await c.req.json<{ id?: unknown }>(); - - if (typeof body.id !== "string" || !body.id.trim()) { - return c.json({ error: "id is required" }, 400); - } - const id = body.id.trim(); - - const tomlPath = `${process.cwd()}/dispatch.toml`; - let tomlContent: string; - try { - tomlContent = readFileSync(tomlPath, "utf-8"); - } catch (err) { - return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); - } - - // Match the [[keys]] block containing this id and remove it. - // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF. - const blockPattern = new RegExp( - `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`, - "s", - ); - const match = blockPattern.exec(tomlContent); - if (!match) { - return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404); - } - - const newContent = - tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); - - try { - writeFileSync(tomlPath, newContent, "utf-8"); - } catch (err) { - return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); - } - - return c.json({ success: true }); -}); - -// ─── Shared wake function ───────────────────────────────────── - -/** Max chars of upstream error body to keep in the surfaced message. */ -const MAX_ERROR_BODY_CHARS = 200; - -/** - * Turn a non-OK probe response into a short, human-readable reason. Anthropic - * returns a JSON error envelope (`{ error: { message } }`); fall back to a - * truncated raw body, then to the bare status. Never throws. - */ -async function describeFailedResponse(res: Response): Promise<string> { - let detail = ""; - try { - const text = await res.text(); - try { - const parsed = JSON.parse(text) as { error?: { message?: unknown } }; - const message = parsed?.error?.message; - detail = typeof message === "string" ? message : text; - } catch { - detail = text; - } - } catch { - detail = ""; - } - detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS); - return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`; -} - -async function wakeAllClaudeAccounts(): Promise< - Array<{ label: string; ok: boolean; error?: string }> -> { - // Only wake accounts referenced by configured anthropic keys - const allAccounts = resolveClaudeAccounts(); - const registry = getRegistry(); - const configuredKeyIds = new Set<string>(); - if (registry) { - for (const ks of registry.getKeys()) { - if (ks.definition.provider === "anthropic") { - configuredKeyIds.add(ks.definition.id); - } - } - } - 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" }]; - } - - const results: Array<{ label: string; ok: boolean; error?: string }> = []; - - for (const acct of accounts) { - try { - const creds = await refreshAccountCredentialsAsync(acct); - if (!creds) { - results.push({ label: acct.label, ok: false, error: "token refresh failed" }); - continue; - } - - // Resolve the probe model dynamically. A fixed model id (the old - // `claude-3-5-haiku-20241022`) eventually stops being served and - // the probe 404s, so pull the live list from `/v1/models` and pick - // the current Haiku. Fall back to the well-known list if the live - // fetch comes back empty (network blip, transient upstream error). - let availableModels = await fetchAnthropicModels(creds.accessToken); - if (availableModels.length === 0) { - availableModels = ANTHROPIC_MODELS_FALLBACK; - } - const probeModel = selectHaikuModel(availableModels); - if (!probeModel) { - results.push({ - label: acct.label, - ok: false, - error: "no 'haiku' model available from /v1/models", - }); - continue; - } - - // Mirror a genuine Claude Code CLI request. These are OAuth - // (Pro/Max) subscription accounts: Anthropic validates the - // `system[]` array and rejects (401/403) any request whose system - // block lacks the verbatim Claude Code identity string. A bare - // `{ model, messages }` body — what this probe used to send — - // always failed, which is why scheduled wakes silently died with a - // blank "failed" status. `buildWakeProbeBody` produces the correct - // shape (billing header + identity); the session/request-id headers - // match what the real CLI stamps so the probe isn't flagged. - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - ...getAnthropicHeaders(creds.accessToken), - "content-type": "application/json", - "X-Claude-Code-Session-Id": randomUUID(), - "x-client-request-id": randomUUID(), - }, - body: JSON.stringify(buildWakeProbeBody(probeModel)), - }); - - if (res.ok) { - results.push({ label: acct.label, ok: true }); - } else { - // Surface WHY it failed so the panel never shows a bare - // "failed" again and breakage stays debuggable. - results.push({ - label: acct.label, - ok: false, - error: await describeFailedResponse(res), - }); - } - } catch (err) { - results.push({ - label: acct.label, - ok: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return results; -} - -modelsRoutes.post("/wake", async (c) => { - const results = await wakeAllClaudeAccounts(); - return c.json({ results }); -}); - -// ─── Wake scheduler (runs on backend, survives frontend close) ─ -// -// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30, -// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with -// its own `next_wake_at`. When multiple slots come due in the same tick we -// coalesce into a single upstream wake — no point hitting Anthropic 4× in -// the same 30-second window. - -/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */ -type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>; - -interface PendingRetry { - /** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */ - retriesLeft: number; - /** Absolute timestamp (ms) of the next retry attempt. */ - nextRetryAt: number; - /** Why we entered retry mode — surfaced on /wake-schedule. */ - reason: string; -} - -interface LastWake { - firedAt: number; - ok: boolean; - results: Array<{ label: string; ok: boolean; error?: string }>; -} - -const MAX_RETRIES = 6; -const RETRY_INTERVAL_MS = 5 * 60 * 1000; -const TICK_INTERVAL_MS = 30_000; - -function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void { - const hourEntry = schedule[hour] ?? {}; - hourEntry[minute] = ts; - schedule[hour] = hourEntry; -} - -function deleteHour(schedule: WakeSchedule, hour: number): void { - delete schedule[hour]; -} - -function countSlots(schedule: WakeSchedule): number { - let n = 0; - for (const slots of Object.values(schedule)) { - n += Object.keys(slots).length; - } - return n; -} - -function loadScheduleFromDB(): WakeSchedule { - try { - const db = getDatabase(); - const rows = db - .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule") - .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>; - const schedule: WakeSchedule = {}; - const now = Date.now(); - let needsPersist = false; - let anyShouldFire = false; - for (const row of rows) { - if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it - const recovered = recoverScheduleEntry(row.next_wake_at, now); - setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt); - if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true; - if (recovered.shouldFireNow) anyShouldFire = true; - } - if (needsPersist) persistSchedule(schedule); - if (anyShouldFire) needsBootFire = true; - return schedule; - } catch { - return {}; - } -} - -function persistSchedule(scheduleToSave?: WakeSchedule): void { - try { - const db = getDatabase(); - const data = scheduleToSave ?? wakeSchedule; - const insert = db.query( - "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)", - ); - // One atomic transaction: DELETE + every INSERT either all commit or all - // roll back. Without this, an INSERT failure (disk full, bad row, etc.) - // would leave the table empty — silently wiping the user's schedule on - // next boot since the DELETE has already committed. - const writeAll = db.transaction(() => { - db.run("DELETE FROM wake_schedule"); - for (const [hour, slots] of Object.entries(data)) { - for (const [slotMinute, nextWakeAt] of Object.entries(slots)) { - if (nextWakeAt === undefined) continue; - insert.run({ - $hour: Number(hour), - $slot: Number(slotMinute), - $nextWakeAt: nextWakeAt, - }); - } - } - }); - writeAll(); - } catch { - // Ignore DB errors — schedule still lives in-memory for this process, - // and the previously persisted snapshot stays intact thanks to the - // transaction rollback above. - } -} - -/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */ -let needsBootFire = false; -const wakeSchedule: WakeSchedule = loadScheduleFromDB(); - -/** - * A single shared retry slot. We deliberately do NOT queue one retry per - * failed wake — multiple back-to-back failures (e.g. the network is down for - * five minutes) used to spawn retries that all converged on the same instant - * and hammered the upstream. One in-flight retry covers all accounts. - */ -let pendingRetry: PendingRetry | null = null; -let lastWake: LastWake | null = null; - -// HMR-safe: track the scheduler timer on globalThis so re-imports during dev -// don't leave orphaned timers running. -const timerKey = "_dispatchWakeTimer"; -(globalThis as Record<string, unknown>)[timerKey] ??= undefined; -let isTickRunning = false; - -function recordWake(results: Array<{ label: string; ok: boolean; error?: string }>): boolean { - const ok = results.length > 0 && results.every((r) => r.ok); - lastWake = { firedAt: Date.now(), ok, results }; - return ok; -} - -function scheduleRetry(reason: string): void { - if (pendingRetry) { - // Already retrying — reset the budget so the next failure window covers - // the new incident too, but don't compound timers. - pendingRetry.retriesLeft = MAX_RETRIES; - pendingRetry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - pendingRetry.reason = reason; - return; - } - pendingRetry = { - retriesLeft: MAX_RETRIES, - nextRetryAt: Date.now() + RETRY_INTERVAL_MS, - reason, - }; -} - -async function fireWake(reason: string): Promise<void> { - try { - const results = await wakeAllClaudeAccounts(); - const ok = recordWake(results); - if (!ok) scheduleRetry(reason); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - lastWake = { - firedAt: Date.now(), - ok: false, - results: [{ label: "(scheduler)", ok: false, error: message }], - }; - scheduleRetry(reason); - } -} - -async function processPendingRetry(now: number): Promise<void> { - // Capture into a local so TS narrowing survives across awaits, and so a - // racing toggle that clears `pendingRetry` mid-flight can't NPE us. - const retry = pendingRetry; - if (!retry || retry.nextRetryAt > now) return; - try { - const results = await wakeAllClaudeAccounts(); - const ok = recordWake(results); - if (ok || retry.retriesLeft <= 1) { - pendingRetry = null; - } else { - retry.retriesLeft -= 1; - retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - lastWake = { - firedAt: Date.now(), - ok: false, - results: [{ label: "(retry)", ok: false, error: message }], - }; - if (retry.retriesLeft <= 1) { - pendingRetry = null; - } else { - retry.retriesLeft -= 1; - retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - } - } -} - -interface DueSlot { - hour: number; - minute: ProbeSlotMinute; - ts: number; -} - -/** Collect every slot whose next_wake_at is at or before `now`. */ -function collectDueSlots(now: number): DueSlot[] { - const due: DueSlot[] = []; - for (const [hourStr, slots] of Object.entries(wakeSchedule)) { - const hour = Number(hourStr); - for (const [slotStr, ts] of Object.entries(slots)) { - if (ts === undefined) continue; - const slotMinute = Number(slotStr); - if (!isProbeSlotMinute(slotMinute)) continue; - if (ts <= now) due.push({ hour, minute: slotMinute, ts }); - } - } - return due; -} - -async function schedulerTick(): Promise<void> { - // Prevent concurrent tick execution (e.g. toggle called mid-tick). - if (isTickRunning) return; - isTickRunning = true; - - try { - const now = Date.now(); - const due = collectDueSlots(now); - - let firedThisTick = false; - const bootFireRequested = needsBootFire; - if (due.length > 0 || bootFireRequested) { - needsBootFire = false; - // Advance every due slot before firing — so a slow upstream call - // can't cause us to re-fire the same slot on the next tick. - for (const slot of due) { - const next = nextDailyAfter(slot.ts, now); - setSlot(wakeSchedule, slot.hour, slot.minute, next); - } - persistSchedule(); - - const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`); - const fromBoot = bootFireRequested ? " (boot recovery)" : ""; - const reason = - reasonParts.length > 0 - ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}` - : "boot recovery"; - firedThisTick = true; - // COALESCED: one upstream call covers all slots due this tick. - await fireWake(reason); - } - - // Only attempt a retry on ticks that didn't *just* fire — otherwise we'd - // race the retry against a fresh attempt within the same loop iteration. - if (!firedThisTick) { - await processPendingRetry(Date.now()); - } - - // Keep ticking while there's anything to monitor. - if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) { - (globalThis as Record<string, unknown>)[timerKey] = setTimeout( - schedulerTick, - TICK_INTERVAL_MS, - ); - } - } finally { - isTickRunning = false; - } -} - -export function startWakeScheduler(): void { - // Clear any previous timer (HMR-safe — works with Bun's Timer objects). - const prev = (globalThis as Record<string, unknown>)[timerKey]; - if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>); - // Fire-and-forget; the tick re-arms itself. - void schedulerTick(); -} - -function scheduleSnapshot(): { - schedule: WakeSchedule; - resetOffsetHours: number; - probeSlotMinutes: readonly number[]; - lastWake: LastWake | null; - pendingRetry: PendingRetry | null; -} { - return { - schedule: wakeSchedule, - resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS, - probeSlotMinutes: PROBE_SLOT_MINUTES, - lastWake, - pendingRetry, - }; -} - -modelsRoutes.post("/wake-schedule/toggle", async (c) => { - const body = await c.req.json<{ - hour?: unknown; - action?: unknown; - timestamps?: unknown; - }>(); - const hour = body.hour; - if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) { - return c.json({ error: "hour must be a number 0-23" }, 400); - } - if (!Number.isInteger(hour)) { - return c.json({ error: "hour must be an integer 0-23" }, 400); - } - - // The action is the CLIENT'S DECLARED INTENT. Previously the server - // derived add-vs-remove from its own in-memory state, which meant a UI - // that had become stale (e.g. due to a snapshot race) would have its - // clicks silently inverted: user clicks to turn ON an hour the UI shows - // as OFF, server sees it as already-ON, deletes it. Requiring an explicit - // action makes the request idempotent and self-describing — a stale UI's - // click is now either a redundant no-op (action matches server state) or - // a recoverable replace (action="on" against an already-on hour just - // refreshes its timestamps to the new values). - const action = body.action; - if (action !== "on" && action !== "off") { - return c.json({ error: "action must be 'on' or 'off'" }, 400); - } - - if (action === "off") { - // Idempotent: removing an already-removed hour is a no-op success. - if (wakeSchedule[hour] !== undefined) { - deleteHour(wakeSchedule, hour); - } - } else { - // action === "on" — require a `timestamps` object with one absolute - // Unix ms per probe slot (0, 15, 30, 45). The client is the source - // of truth for the *local* wall-clock intent of each probe. - // Idempotent: turning ON an already-on hour replaces its timestamps - // (so a UI recovering from a desync can re-assert the correct wall- - // clock intent without first deleting). - const timestamps = body.timestamps; - if (timestamps === null || typeof timestamps !== "object") { - return c.json( - { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" }, - 400, - ); - } - const parsed: Partial<Record<ProbeSlotMinute, number>> = {}; - for (const slot of PROBE_SLOT_MINUTES) { - const raw = (timestamps as Record<string, unknown>)[String(slot)]; - // Accept any finite Unix-ms number. We deliberately do NOT reject - // past timestamps: client-server clock skew + request latency mean - // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot - // can land "in the past" by the time the server validates it. The - // scheduler tick handles past entries correctly via - // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then - // advances by 24h * N to the next future occurrence. - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400); - } - parsed[slot] = raw; - } - wakeSchedule[hour] = parsed; - } - - persistSchedule(); - startWakeScheduler(); - - return c.json(scheduleSnapshot()); -}); - -modelsRoutes.get("/wake-schedule", (c) => { - return c.json(scheduleSnapshot()); -}); diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts deleted file mode 100644 index 473e837..0000000 --- a/packages/api/src/routes/notifications.ts +++ /dev/null @@ -1,88 +0,0 @@ -// `/notifications` — ntfy.sh config + test-send route. - -import { - defaultNtfyConfig, - loadNtfyConfig, - type NotificationEventType, - NTFY_EVENT_TYPES, - type NtfyConfig, - normalizeNtfyConfig, - redactNtfyConfig, - saveNtfyConfig, - sendNtfy, -} from "@dispatch/core"; -import { Hono } from "hono"; - -export const notificationsRoutes = new Hono(); - -notificationsRoutes.get("/", (c) => { - const config = loadNtfyConfig(); - return c.json({ - config: redactNtfyConfig(config), - eventTypes: NTFY_EVENT_TYPES, - defaults: defaultNtfyConfig(), - }); -}); - -notificationsRoutes.put("/", async (c) => { - const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>(); - const existing = loadNtfyConfig(); - - // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep - // the existing token (the GET response redacts it, so the frontend doesn't - // have it to send back). Any other string ⇒ replace. - let nextAuthToken = existing.authToken; - if (typeof body.authToken === "string") nextAuthToken = body.authToken; - - const merged = normalizeNtfyConfig({ - enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, - topic: typeof body.topic === "string" ? body.topic : existing.topic, - authToken: nextAuthToken, - events: { ...existing.events, ...(body.events ?? {}) }, - notifySubagents: - typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents, - }); - - // Only validation: if notifications are turned on, the topic must be - // non-empty. Any other "is this a valid ntfy topic name?" check is - // punted to the ntfy server itself — its rules vary and have changed - // over time, and a syntactically-valid name still might be rejected - // (e.g. reserved words), so a clear server error is more useful than - // a client-side guess. - if (merged.enabled && !merged.topic.trim()) { - return c.json({ error: "Topic is required" }, 400); - } - - saveNtfyConfig(merged); - return c.json({ config: redactNtfyConfig(merged) }); -}); - -notificationsRoutes.post("/test", async (c) => { - const config = loadNtfyConfig(); - if (!config.enabled) { - return c.json({ ok: false, error: "Notifications are disabled" }, 400); - } - if (!config.topic.trim()) { - return c.json({ ok: false, error: "Topic is required" }, 400); - } - - // Use a real event type so the per-event toggle is honored when wiring - // is tested end-to-end; pick `turn-completed` since it's the most - // common enabled-by-default event. - const eventType: NotificationEventType = "turn-completed"; - if (!config.events[eventType]) { - return c.json( - { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, - 400, - ); - } - - const result = await sendNtfy(config, { - type: eventType, - title: "Dispatch test notification", - message: "If you can see this, ntfy.sh notifications are wired up correctly.", - tags: ["bell"], - }); - if (!result.ok) return c.json(result, 502); - return c.json(result); -}); diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts deleted file mode 100644 index 7696b47..0000000 --- a/packages/api/src/routes/skills.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core"; -import { Hono } from "hono"; - -let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ - skills: [], - mappings: [], -}); - -export function setSkillsGetter( - getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }, -): void { - getSkills = getter; -} - -export const skillsRoutes = new Hono(); - -skillsRoutes.get("/", (c) => { - const { skills, mappings } = getSkills(); - const skillSummaries = skills.map(({ name, description, tags, scope, directory }) => ({ - name, - description, - tags, - scope, - directory, - })); - return c.json({ skills: skillSummaries, mappings }); -}); - -skillsRoutes.get("/:name", (c) => { - const { name } = c.req.param(); - const scopeParam = c.req.query("scope") as SkillScope | undefined; - const { skills } = getSkills(); - - const matches = skills.filter((s) => s.name === name); - if (matches.length === 0) { - return c.json({ error: "Skill not found" }, 404); - } - - if (scopeParam) { - const scoped = matches.find((s) => s.scope === scopeParam); - if (!scoped) { - return c.json({ error: "Skill not found" }, 404); - } - return c.json(scoped); - } - - return c.json(matches[0]); -}); diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts deleted file mode 100644 index 2ae60ed..0000000 --- a/packages/api/src/routes/tabs.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - archiveTab, - createTab, - deleteSetting, - getChunksForTab, - getSetting, - getTab, - getTotalChunkCount, - getUsageStatsForTab, - groupRowsToMessages, - listOpenTabs, - setSetting, - updateTabModel, - updateTabPositions, - updateTabStatus, - updateTabTitle, -} from "@dispatch/core"; -import { Hono } from "hono"; - -export const tabsRoutes = new Hono(); - -let getAgentManager: () => { - stopTab(id: string): void; - deleteTab(id: string): void; - compactTab(tempTabId: string, sourceTabId: string): Promise<void>; -} | null = () => null; - -export function setTabsAgentManager( - getter: () => { - stopTab(id: string): void; - deleteTab(id: string): void; - compactTab(tempTabId: string, sourceTabId: string): Promise<void>; - } | null, -): void { - getAgentManager = getter; -} - -tabsRoutes.get("/", (c) => { - // Enrich each tab with its persisted usage aggregate so the frontend can - // seed `cacheStats` on reload without an extra round-trip. N small indexed - // queries — fine for tab counts. - const tabs = listOpenTabs().map((t) => ({ ...t, usageStats: getUsageStatsForTab(t.id) })); - 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 | null; modelId?: string | null }>(); - if (body.keyId !== undefined) { - if (body.keyId) setSetting("title_model_key_id", body.keyId); - else deleteSetting("title_model_key_id"); - } - if (body.modelId !== undefined) { - if (body.modelId) setSetting("title_model_id", body.modelId); - else deleteSetting("title_model_id"); - } - return c.json({ success: true }); -}); - -// Conversation-compaction model (key+model used to generate the summary). -// Mirrors the title-model setting. When unset, compaction falls back to the -// source tab's own key+model. -tabsRoutes.get("/settings/compaction-model", (c) => { - const keyId = getSetting("compaction_model_key_id"); - const modelId = getSetting("compaction_model_id"); - return c.json({ keyId, modelId }); -}); - -tabsRoutes.put("/settings/compaction-model", async (c) => { - const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>(); - if (body.keyId !== undefined) { - if (body.keyId) setSetting("compaction_model_key_id", body.keyId); - else deleteSetting("compaction_model_key_id"); - } - if (body.modelId !== undefined) { - if (body.modelId) setSetting("compaction_model_id", body.modelId); - else deleteSetting("compaction_model_id"); - } - return c.json({ success: true }); -}); - -// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids; -// each tab's `position` is rewritten to its index. Must be declared before the -// `/:id` routes so "reorder" isn't captured as an id param. -tabsRoutes.patch("/reorder", async (c) => { - const body = await c.req.json<{ ids?: string[] }>(); - if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) { - return c.json({ error: "ids must be an array of strings" }, 400); - } - updateTabPositions(body.ids); - 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); -}); - -// Conversation history for a tab, paginated at CHUNK granularity. The flat -// chunk log is windowed by `limit`/`before` (both chunk-`seq` cursors) so a -// single huge turn never dumps in full, then grouped into render messages. -// `before` is the oldest chunk seq the client already holds. This is what -// powers per-chunk frontend pagination / memory control. -tabsRoutes.get("/:id/messages", (c) => { - const id = c.req.param("id"); - const limitRaw = c.req.query("limit"); - const beforeRaw = c.req.query("before"); - const limit = limitRaw !== undefined ? Number(limitRaw) : undefined; - const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined; - const options = - limit !== undefined || before !== undefined - ? { - ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), - ...(before !== undefined && Number.isFinite(before) ? { before } : {}), - } - : undefined; - const chunks = getChunksForTab(id, options); - const messages = groupRowsToMessages(chunks); - // `oldestSeq` is the chunk-seq cursor the client pages backward from; null - // when the window is empty. - const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null; - const total = getTotalChunkCount(id); - return c.json({ messages, total, oldestSeq }); -}); - -// Raw chunk window for a tab — the chunk-native frontend's load/paginate -// source. Same `limit`/`before` chunk-`seq` windowing as `/messages`, but -// returns the flat `ChunkRow[]` WITHOUT server-side grouping (the frontend -// groups for render and evicts/paginates on the flat list). Dedupe on the -// client by `seq` when overlap-fetching. -tabsRoutes.get("/:id/chunks", (c) => { - const id = c.req.param("id"); - const limitRaw = c.req.query("limit"); - const beforeRaw = c.req.query("before"); - const limit = limitRaw !== undefined ? Number(limitRaw) : undefined; - const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined; - const options = - limit !== undefined || before !== undefined - ? { - ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), - ...(before !== undefined && Number.isFinite(before) ? { before } : {}), - } - : undefined; - const chunks = getChunksForTab(id, options); - const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null; - const total = getTotalChunkCount(id); - return c.json({ chunks, total, oldestSeq }); -}); - -// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab -// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being -// compacted. Fire-and-forget on the server: progress/outcome is delivered via -// the `compaction-*` WS events. Returns 202 once the run is kicked off. -tabsRoutes.post("/:id/compact", async (c) => { - const tempTabId = c.req.param("id"); - const body = await c.req - .json<{ sourceTabId?: string }>() - .catch(() => ({}) as { sourceTabId?: string }); - const sourceTabId = body.sourceTabId; - if (!sourceTabId || typeof sourceTabId !== "string") { - return c.json({ error: "sourceTabId is required" }, 400); - } - const mgr = getAgentManager(); - if (!mgr) return c.json({ error: "agent manager unavailable" }, 503); - // Run in the background; outcome is emitted over WS. - void mgr.compactTab(tempTabId, sourceTabId).catch((err) => { - console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err); - }); - return c.json({ success: true }, 202); -}); - -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); -}); - -// ─── Settings ───────────────────────────────────────────────── - -tabsRoutes.get("/settings/:key", (c) => { - const key = c.req.param("key"); - const value = getSetting(key); - return c.json({ value }); -}); - -tabsRoutes.put("/settings/:key", async (c) => { - const key = c.req.param("key"); - const body = await c.req.json<{ value?: string }>(); - if (typeof body.value !== "string") { - return c.json({ error: "value is required" }, 400); - } - setSetting(key, body.value); - return c.json({ success: true }); -}); - -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 }); -}); diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts deleted file mode 100644 index a88e41b..0000000 --- a/packages/api/src/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export types from @dispatch/core for convenience -export type { AgentEvent, AgentStatus } from "@dispatch/core"; diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts deleted file mode 100644 index 8953e9f..0000000 --- a/packages/api/src/wake-scheduler.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Pure helpers for the Claude wake scheduler. Kept side-effect-free so the - * recovery & rescheduling logic can be unit-tested without spinning up the - * Hono app or touching SQLite. - * - * Semantics — read this before editing: - * - * 1. The user marks an hour (0-23) on the frontend. Marking the hour - * schedules FOUR probes inside that hour, one per quarter-hour slot - * (:00, :15, :30, :45). Each slot is its own persisted row keyed by - * (hour, slot_minute). The frontend computes the *first* fire ms for - * each slot in **its** local timezone and sends them; that absolute - * ms is the source of truth. - * - * 2. After each fire (successful or not) we advance the slot by exactly - * 24h from the previous `next_wake_at`. This preserves the user's - * original local wall-clock intent regardless of the *server*'s - * timezone. DST can drift the fire by ±1h on transition day; it - * self-corrects the next time the user toggles the hour. - * - * 3. On server boot, any persisted slot whose `next_wake_at` is in the - * past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we - * fire it on the next tick (signal: `shouldFireNow = true`) and - * advance to the next future occurrence. If missed by more than the - * grace window we silently skip and advance. Either way the slot - * stays scheduled. - * - * 4. Multiple slots that come due in the same tick (or recover at - * boot) coalesce into a SINGLE upstream wake call. Probing four - * times in 15 minutes is fine; probing four times within the same - * 30s tick is wasteful and pointless. - */ - -/** How long after a missed fire we still consider it worth running. */ -export const MISSED_WAKE_GRACE_MS = 2 * 60 * 60 * 1000; // 2 hours - -/** Day length used when advancing recurring wakes. */ -export const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000; - -/** Fixed offset (hours) from a wake to the "Claude session reset" display. */ -export const CLAUDE_RESET_OFFSET_HOURS = 5; - -/** Minute offsets inside a marked hour where a probe fires. */ -export const PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const; -export type ProbeSlotMinute = (typeof PROBE_SLOT_MINUTES)[number]; - -/** - * Advance `previous` by 24-hour increments until strictly after `now`. - * Pure: only does math on the given numbers. - */ -export function nextDailyAfter(previous: number, now: number): number { - if (previous > now) return previous; - const deltaMs = now - previous; - // Ceiling division so the result is strictly > now. - const stepsAhead = Math.floor(deltaMs / DAILY_INTERVAL_MS) + 1; - return previous + stepsAhead * DAILY_INTERVAL_MS; -} - -export interface RecoveredEntry { - /** New `next_wake_at` to persist (always strictly in the future). */ - nextWakeAt: number; - /** True if the caller should fire a wake *right now* before scheduling. */ - shouldFireNow: boolean; -} - -/** - * Compute the post-boot state for a single persisted schedule entry. - * - * - Entry still in the future → keep as-is, no fire. - * - Missed by ≤ grace window → fire now, then advance to next day. - * - Missed by > grace window → skip the fire, advance to next day. - */ -export function recoverScheduleEntry( - storedNextWakeAt: number, - now: number, - graceMs: number = MISSED_WAKE_GRACE_MS, -): RecoveredEntry { - if (storedNextWakeAt > now) { - return { nextWakeAt: storedNextWakeAt, shouldFireNow: false }; - } - const overdueBy = now - storedNextWakeAt; - const shouldFireNow = overdueBy <= graceMs; - return { - nextWakeAt: nextDailyAfter(storedNextWakeAt, now), - shouldFireNow, - }; -} - -/** Display hour (0-23) for the "reset" label paired with a wake hour. */ -export function resetHourFor(wakeHour: number): number { - return (wakeHour + CLAUDE_RESET_OFFSET_HOURS) % 24; -} - -/** Type guard: is this number a valid probe slot minute? */ -export function isProbeSlotMinute(n: unknown): n is ProbeSlotMinute { - return n === 0 || n === 15 || n === 30 || n === 45; -} |
