summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/api/src
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.ts2453
-rw-r--r--packages/api/src/app.ts278
-rw-r--r--packages/api/src/index.ts127
-rw-r--r--packages/api/src/permission-manager.ts103
-rw-r--r--packages/api/src/routes/agents.ts126
-rw-r--r--packages/api/src/routes/config.ts27
-rw-r--r--packages/api/src/routes/models.ts1073
-rw-r--r--packages/api/src/routes/notifications.ts88
-rw-r--r--packages/api/src/routes/skills.ts48
-rw-r--r--packages/api/src/routes/tabs.ts229
-rw-r--r--packages/api/src/types.ts2
-rw-r--r--packages/api/src/wake-scheduler.ts97
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;
-}