summaryrefslogtreecommitdiffhomepage
path: root/packages/core/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/core/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/core/src')
-rw-r--r--packages/core/src/agent/agent.tsbin66313 -> 0 bytes
-rw-r--r--packages/core/src/agents/index.ts11
-rw-r--r--packages/core/src/agents/loader.ts294
-rw-r--r--packages/core/src/chunks/append.ts314
-rw-r--r--packages/core/src/chunks/transform.ts271
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/config/index.ts9
-rw-r--r--packages/core/src/config/loader.ts226
-rw-r--r--packages/core/src/config/schema.ts239
-rw-r--r--packages/core/src/config/watcher.ts129
-rw-r--r--packages/core/src/credentials/anthropic-betas.ts24
-rw-r--r--packages/core/src/credentials/api-keys.ts80
-rw-r--r--packages/core/src/credentials/claude.ts694
-rw-r--r--packages/core/src/credentials/copilot.ts64
-rw-r--r--packages/core/src/credentials/google.ts178
-rw-r--r--packages/core/src/credentials/index.ts52
-rw-r--r--packages/core/src/credentials/opencode.ts126
-rw-r--r--packages/core/src/credentials/store.ts185
-rw-r--r--packages/core/src/db/chunks.ts246
-rw-r--r--packages/core/src/db/index.ts177
-rw-r--r--packages/core/src/db/settings.ts22
-rw-r--r--packages/core/src/db/tabs.ts250
-rw-r--r--packages/core/src/index.ts172
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/debug-logger.ts448
-rw-r--r--packages/core/src/llm/provider.ts180
-rw-r--r--packages/core/src/lsp/client.ts658
-rw-r--r--packages/core/src/lsp/diagnostic.ts41
-rw-r--r--packages/core/src/lsp/index.ts18
-rw-r--r--packages/core/src/lsp/language.ts72
-rw-r--r--packages/core/src/lsp/manager.ts220
-rw-r--r--packages/core/src/lsp/server.ts68
-rw-r--r--packages/core/src/models/attachments.ts151
-rw-r--r--packages/core/src/models/catalog.ts229
-rw-r--r--packages/core/src/models/index.ts24
-rw-r--r--packages/core/src/models/registry.ts86
-rw-r--r--packages/core/src/notifications/config.ts77
-rw-r--r--packages/core/src/notifications/dispatcher.ts287
-rw-r--r--packages/core/src/notifications/index.ts36
-rw-r--r--packages/core/src/notifications/ntfy.ts149
-rw-r--r--packages/core/src/notifications/types.ts108
-rw-r--r--packages/core/src/permission/evaluate.ts15
-rw-r--r--packages/core/src/permission/index.ts26
-rw-r--r--packages/core/src/permission/service.ts76
-rw-r--r--packages/core/src/permission/wildcard.ts10
-rw-r--r--packages/core/src/skills/index.ts7
-rw-r--r--packages/core/src/skills/loader.ts248
-rw-r--r--packages/core/src/skills/parser.ts60
-rw-r--r--packages/core/src/tools/bash-arity.ts48
-rw-r--r--packages/core/src/tools/key-usage.ts322
-rw-r--r--packages/core/src/tools/list-files.ts40
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/path-utils.ts55
-rw-r--r--packages/core/src/tools/read-file-slice.ts90
-rw-r--r--packages/core/src/tools/read-file.ts129
-rw-r--r--packages/core/src/tools/read-tab.ts95
-rw-r--r--packages/core/src/tools/registry.ts91
-rw-r--r--packages/core/src/tools/retrieve.ts71
-rw-r--r--packages/core/src/tools/run-shell.ts176
-rw-r--r--packages/core/src/tools/search-code.ts362
-rw-r--r--packages/core/src/tools/send-to-tab.ts198
-rw-r--r--packages/core/src/tools/shell-analyze.ts166
-rw-r--r--packages/core/src/tools/summon.ts447
-rw-r--r--packages/core/src/tools/task-list.ts149
-rw-r--r--packages/core/src/tools/truncate.ts142
-rw-r--r--packages/core/src/tools/web-search.ts106
-rw-r--r--packages/core/src/tools/write-file.ts66
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts219
-rw-r--r--packages/core/src/types/index.ts672
69 files changed, 0 insertions, 11234 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
deleted file mode 100644
index 2e2dbb2..0000000
--- a/packages/core/src/agent/agent.ts
+++ /dev/null
Binary files differ
diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts
deleted file mode 100644
index 4931162..0000000
--- a/packages/core/src/agents/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./loader.js";
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
deleted file mode 100644
index 9971803..0000000
--- a/packages/core/src/agents/loader.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
-import { type AgentDefinition, type AgentModelEntry, isReasoningEffort } from "../types/index.js";
-
-// ─── Helpers ─────────────────────────────────────────────────────
-
-/** Sanitize a slug to prevent path traversal */
-function sanitizeSlug(slug: string): string {
- // Strip directory components and ensure only safe characters
- const base = path.basename(slug);
- const clean = base
- .replace(/[^a-zA-Z0-9_-]/g, "-")
- .replace(/-+/g, "-")
- .replace(/^-|-$/g, "");
- if (!clean) throw new Error("Invalid agent slug");
- return clean;
-}
-
-// ─── Constants ───────────────────────────────────────────────────
-
-export const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
-
-export function getProjectAgentsDir(projectDir: string): string {
- return path.join(projectDir, ".dispatch", "agents");
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-/**
- * Returns the agent directories that exist or could exist.
- * Always includes global. Includes project dir if projectDir is provided.
- */
-export function getAgentDirs(
- projectDir?: string,
-): Array<{ label: string; path: string; scope: string }> {
- const dirs: Array<{ label: string; path: string; scope: string }> = [
- { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" },
- ];
- if (projectDir) {
- dirs.push({
- label: `.dispatch/agents (${path.basename(projectDir)})`,
- path: getProjectAgentsDir(projectDir),
- scope: projectDir,
- });
- }
- return dirs;
-}
-
-/**
- * Return just the absolute filesystem paths of the agent directories.
- * Used by the agent's permission gate to grant read-only access to
- * these locations by default (so any agent can list/read agent
- * definitions without prompting the user).
- */
-export function getAgentDirPaths(projectDir?: string): string[] {
- const paths = [GLOBAL_AGENTS_DIR];
- if (projectDir) paths.push(getProjectAgentsDir(projectDir));
- return paths;
-}
-
-/**
- * Load a single agent definition by slug. Searches the project-scoped
- * directory first (if `projectDir` is provided), then falls back to
- * the global directory. Returns `null` if no match is found.
- *
- * Slug matching is exact and case-sensitive; sanitization mirrors
- * `saveAgent` to keep loader and writer symmetric.
- */
-export function loadAgent(slug: string, projectDir?: string): AgentDefinition | null {
- const safeSlug = sanitizeSlug(slug);
- const agents = loadAgents(projectDir);
- return agents.find((a) => a.slug === safeSlug) ?? null;
-}
-
-/**
- * Translate the short permission-group names used by `AgentDefinition.tools`
- * (e.g. `"read"`, `"edit"`, `"bash"`) into the concrete tool-implementation
- * names registered with the agent runtime (e.g. `"read_file"`,
- * `"list_files"`, `"write_file"`, `"run_shell"`).
- *
- * The mapping mirrors the per-permission tool-creation paths in
- * `AgentManager.getOrCreateAgentForTab` so a subagent summoned with a
- * given agent definition ends up with the exact same set of registered
- * tools as a top-level tab using that definition. Tool names that aren't
- * group aliases (`summon`, `retrieve`, `web_search`, `youtube_transcribe`,
- * `todo`) are passed through unchanged.
- *
- * `"todo"` is auto-included so the summoned agent always has its task list
- * available, matching the parent-agent path which always registers `todo`.
- */
-export function expandAgentToolNames(tools: string[]): string[] {
- const expanded = new Set<string>();
- for (const t of tools) {
- switch (t) {
- case "read":
- expanded.add("read_file");
- expanded.add("read_file_slice");
- expanded.add("list_files");
- break;
- case "edit":
- expanded.add("write_file");
- break;
- case "bash":
- expanded.add("run_shell");
- break;
- default:
- // Pass through tool names that aren't permission-group
- // aliases (summon, retrieve, web_search, youtube_transcribe,
- // send_to_tab, read_tab, todo, and the granular file tools
- // themselves if a user hand-wrote them in a TOML).
- expanded.add(t);
- }
- }
- // Always include `todo` — every agent should be able to track its work,
- // and the parent-agent path adds it unconditionally.
- expanded.add("todo");
- return Array.from(expanded);
-}
-
-/**
- * Ensure the default global agent exists. Creates it if missing.
- */
-function ensureDefaultAgent(): void {
- const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml");
- if (fs.existsSync(filePath)) return;
-
- const defaultAgent: AgentDefinition = {
- name: "Default",
- description: "Default agent with all tools enabled",
- skills: [],
- tools: ["read", "edit", "bash", "summon"],
- models: [],
- scope: "global",
- slug: "default",
- };
- saveAgent(defaultAgent);
-}
-
-/**
- * Load all agent definitions from global + project directories.
- * Auto-generates the default global agent if it doesn't exist.
- */
-export function loadAgents(projectDir?: string): AgentDefinition[] {
- ensureDefaultAgent();
-
- const agents: AgentDefinition[] = [];
-
- // Global agents
- agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global"));
-
- // Project-scoped agents
- if (projectDir) {
- agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir));
- }
-
- return agents;
-}
-
-/**
- * Save (create or update) an agent definition to a TOML file.
- * The scope determines which directory:
- * - "global" -> ~/.config/dispatch/agents/
- * - any other string -> that directory path + /.dispatch/agents/
- */
-export function saveAgent(agent: AgentDefinition): void {
- if (agent.scope !== "global" && agent.scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope);
-
- fs.mkdirSync(dir, { recursive: true });
-
- const tomlContent: Record<string, unknown> = {
- name: agent.name,
- description: agent.description,
- skills: agent.skills,
- tools: agent.tools,
- };
-
- if (agent.cwd) {
- tomlContent.cwd = agent.cwd;
- }
-
- if (agent.is_subagent) {
- tomlContent.is_subagent = true;
- }
-
- // smol-toml handles [[models]] array-of-tables
- if (agent.models.length > 0) {
- tomlContent.models = agent.models.map((m) => ({
- key_id: m.key_id,
- model_id: m.model_id,
- ...(m.effort ? { effort: m.effort } : {}),
- }));
- }
-
- const content = stringifyTOML(tomlContent);
- const safeSlug = sanitizeSlug(agent.slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- fs.writeFileSync(filePath, content, "utf-8");
-}
-
-/**
- * Delete an agent TOML file.
- */
-export function deleteAgent(slug: string, scope: string): boolean {
- if (scope !== "global" && scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope);
-
- const safeSlug = sanitizeSlug(slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- return true;
- }
- return false;
-}
-
-// ─── Internal ────────────────────────────────────────────────────
-
-function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] {
- if (!fs.existsSync(dir)) return [];
-
- const results: AgentDefinition[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".toml")) continue;
-
- const filePath = path.join(dir, entry.name);
- const slug = entry.name.slice(0, -5); // remove .toml
-
- try {
- const raw = fs.readFileSync(filePath, "utf-8");
- const parsed = parseTOML(raw);
-
- const models: AgentModelEntry[] = [];
- if (Array.isArray(parsed.models)) {
- for (const m of parsed.models) {
- if (m && typeof m === "object" && "key_id" in m && "model_id" in m) {
- const rawEffort = (m as Record<string, unknown>).effort;
- models.push({
- key_id: String((m as Record<string, unknown>).key_id),
- model_id: String((m as Record<string, unknown>).model_id),
- // Only carry `effort` when it's a recognised level; an
- // unset or invalid value falls back to the per-tab /
- // default effort at the call site.
- ...(isReasoningEffort(rawEffort) ? { effort: rawEffort } : {}),
- });
- }
- }
- }
-
- const skills: string[] = [];
- if (Array.isArray(parsed.skills)) {
- for (const s of parsed.skills) {
- if (typeof s === "string") skills.push(s);
- }
- }
-
- const tools: string[] = [];
- if (Array.isArray(parsed.tools)) {
- for (const t of parsed.tools) {
- if (typeof t === "string") tools.push(t);
- }
- }
-
- results.push({
- name: typeof parsed.name === "string" ? parsed.name : slug,
- description: typeof parsed.description === "string" ? parsed.description : "",
- skills,
- tools,
- models,
- scope,
- slug,
- ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}),
- ...(parsed.is_subagent === true ? { is_subagent: true } : {}),
- });
- } catch {
- // Skip unparseable files
- }
- }
-
- return results;
-}
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
deleted file mode 100644
index 4ca6fe1..0000000
--- a/packages/core/src/chunks/append.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-/**
- * Chunk-builder helper.
- *
- * `appendEventToChunks` is the single source of truth for how a stream of
- * `AgentEvent`s collapses into an ordered `Chunk[]` on a message. Both the
- * backend (agent + agent-manager) and the frontend store call this helper
- * so the wire format stays in lockstep across the boundary.
- *
- * Open/close rules — see notes/plan-chunk-refactor.md for the full table.
- *
- * | Chunk | Opens on | Coalesces |
- * |---------------|-----------------------------------------------------------------|------------------------------------------------------------|
- * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
- * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
- * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) |
- * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
- * | `error` | every `error` event | NEVER (always single-event) |
- * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
- *
- * Side-effect events (no new chunk):
- * - `tool-result` → finds the call by `id` across all `tool-batch`
- * chunks (most-recent first) and updates its
- * `result` / `isError`.
- * - `shell-output` → appends to the most recent entry of the most
- * recent `tool-batch` chunk.
- * - `reasoning-end` → attaches `metadata` (the AI SDK v6
- * `providerMetadata` blob) to the most recent
- * UNSEALED `thinking` chunk. The metadata is also
- * the "sealed" marker — subsequent
- * `reasoning-delta`s will open a new chunk rather
- * than extending this one. Anthropic's signature
- * lives inside this blob; round-tripping it on the
- * next turn is mandatory for Anthropic to accept
- * the conversation. Orphan `reasoning-end` events
- * (no unsealed thinking chunk) are dropped.
- *
- * Ignored events:
- * - `status`, `turn-start`, `turn-sealed`, `done`, `usage`,
- * `task-list-update`, `tab-created`, `message-queued`, `message-consumed`,
- * `message-cancelled` — these are control / lifecycle events, not message
- * content.
- */
-
-import type {
- AgentEvent,
- ChatMessage,
- Chunk,
- MessageRole,
- SystemChunk,
- SystemChunkKind,
- ToolBatchChunk,
-} from "../types/index.js";
-
-/**
- * Mutates `chunks` in place based on `event`.
- *
- * Returns void; the array is the output channel.
- */
-export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
- switch (event.type) {
- case "text-delta": {
- // Open or extend the current text chunk.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "text") {
- last.text += event.delta;
- } else {
- chunks.push({ type: "text", text: event.delta });
- }
- return;
- }
-
- case "reasoning-delta": {
- // Open a new thinking chunk if the last chunk is not a thinking
- // chunk OR if it's already sealed by metadata. Anthropic emits
- // each thinking content block with its own metadata; a fresh
- // reasoning-delta after a sealed thinking chunk is the start of
- // a new block, not a continuation — extending the sealed chunk
- // would corrupt the metadata/text mapping.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "thinking" && last.metadata === undefined) {
- last.text += event.delta;
- } else {
- chunks.push({ type: "thinking", text: event.delta });
- }
- return;
- }
-
- case "reasoning-end": {
- // Attach `providerMetadata` to the most recent unsealed
- // thinking chunk. Anthropic's signature lives inside this
- // blob; without it on the next request, Anthropic rejects the
- // thinking block. The walk-back is a defensive backstop —
- // Anthropic's SSE delivers a content block's deltas strictly
- // in order and `appendEventToChunks` runs synchronously per
- // event, so the most recent thinking chunk is normally the
- // last chunk in the array.
- if (event.metadata === undefined) return;
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "thinking") continue;
- if (c.metadata !== undefined) {
- // Already sealed; the orphan metadata has no home.
- return;
- }
- c.metadata = event.metadata;
- return;
- }
- // No thinking chunk found at all — drop silently.
- return;
- }
-
- case "tool-call": {
- // Open or extend the current tool-batch chunk.
- const last = chunks[chunks.length - 1];
- const entry = {
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- };
- if (last && last.type === "tool-batch") {
- last.calls.push(entry);
- } else {
- chunks.push({ type: "tool-batch", calls: [entry] });
- }
- return;
- }
-
- case "tool-result": {
- // Find the matching call (by id) across all tool-batch chunks,
- // most-recent first. Tool results can arrive after subsequent
- // text-deltas, so we cannot rely on the *last* chunk being the
- // tool-batch — we have to search.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const call = c.calls.find((e) => e.id === event.toolResult.toolCallId);
- if (call) {
- call.result = event.toolResult.result;
- call.isError = event.toolResult.isError;
- return;
- }
- }
- // Orphan result with no matching call — drop silently.
- return;
- }
-
- case "shell-output": {
- // Append to the most recent entry of the most recent tool-batch.
- // Walk back through chunks to find the latest tool-batch; if there
- // are intervening text/thinking/etc chunks (which can happen if
- // the model streams text while a shell tool is still running),
- // we still want the most recent tool-batch.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const entry = c.calls[c.calls.length - 1];
- if (!entry) return;
- const prev = entry.shellOutput ?? { stdout: "", stderr: "" };
- entry.shellOutput = {
- stdout: prev.stdout + (event.stream === "stdout" ? event.data : ""),
- stderr: prev.stderr + (event.stream === "stderr" ? event.data : ""),
- };
- return;
- }
- // Orphan shell-output with no tool-batch in scope — drop silently.
- return;
- }
-
- case "error": {
- // Always a fresh single-event chunk — no coalescing.
- chunks.push({
- type: "error",
- message: event.error,
- ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
- });
- return;
- }
-
- case "notice": {
- chunks.push({ type: "system", kind: "notice", text: event.message });
- return;
- }
-
- case "model-changed": {
- chunks.push({
- type: "system",
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- return;
- }
-
- case "config-reload": {
- chunks.push({
- type: "system",
- kind: "config-reload",
- text: "Configuration reloaded",
- });
- return;
- }
-
- // Lifecycle / control events — no chunk emitted.
- case "status":
- case "turn-start":
- case "turn-sealed":
- case "done":
- case "usage":
- case "task-list-update":
- case "tab-created":
- case "message-queued":
- case "message-consumed":
- case "message-cancelled":
- case "compaction-started":
- case "compaction-complete":
- case "compaction-error":
- return;
-
- default: {
- // Exhaustiveness check — if a new event variant is added to
- // AgentEvent, TypeScript will complain here.
- const _exhaustive: never = event;
- void _exhaustive;
- return;
- }
- }
-}
-
-// ─── System event routing across messages ────────────────────────
-
-/**
- * Minimal shape needed by `applySystemEvent`.
- *
- * The caller (agent-manager / persistence layer) typically tracks message
- * id alongside the wire-format `ChatMessage`. This generic constraint
- * lets us keep core `ChatMessage` clean while still letting downstream
- * pass anything with an `id`.
- */
-export interface IdentifiedMessage {
- id: string;
- role: MessageRole;
- chunks: Chunk[];
-}
-
-/**
- * Describes the system event in caller-controlled terms. We let the caller
- * decide both the `kind` (so the same helper can record cancellations,
- * notices, model swaps, etc.) and the `text` (so the caller controls
- * formatting / localization).
- */
-export interface SystemEventLike {
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * Routes a system event to the right message when *no assistant turn is
- * in flight*. (When a turn IS in flight, the caller should instead use
- * `appendEventToChunks` against the in-flight message's chunks directly.)
- *
- * Routing rules (from notes/plan-chunk-refactor.md):
- *
- * 1. Most recent message is `role: "system"` → append a `system` chunk
- * to it. (Note: a second consecutive system event creates a second
- * system chunk inside the same system message — chunks themselves
- * never coalesce.)
- * 2. Otherwise → create a fresh `role: "system"` message containing one
- * `system` chunk and push it.
- *
- * Returns the `messageId` that was used (either the existing system
- * message's id or the newly-created one) so the caller can persist /
- * emit a diff to subscribers.
- *
- * `idFactory` defaults to `crypto.randomUUID()`; tests inject a
- * deterministic factory.
- */
-export function applySystemEvent<M extends IdentifiedMessage>(
- messages: M[],
- event: SystemEventLike,
- idFactory: () => string = defaultIdFactory,
-): { messageId: string } {
- const chunk: SystemChunk = { type: "system", kind: event.kind, text: event.text };
-
- const last = messages[messages.length - 1];
- if (last && last.role === "system") {
- last.chunks.push(chunk);
- return { messageId: last.id };
- }
-
- const id = idFactory();
- // We can't fabricate the full `M` shape without knowing its extra
- // fields, but `IdentifiedMessage` is the minimum we need to push.
- // Callers that extend the shape with extra fields are responsible for
- // initializing them via post-hoc patching, or by passing in their own
- // message-creation logic. In practice callers either:
- // (a) use `ChatMessage` itself (no extra fields beyond IdentifiedMessage), or
- // (b) construct messages and look them up by id after this call returns.
- const newMessage = { id, role: "system" as const, chunks: [chunk] } as unknown as M;
- messages.push(newMessage);
- return { messageId: id };
-}
-
-function defaultIdFactory(): string {
- // In Node 19+ / modern browsers, `crypto.randomUUID` is available globally.
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback: pseudo-random; not cryptographically secure, but adequate for
- // in-memory message identifiers when randomUUID is unavailable.
- return `sysmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-// ─── Re-exports for convenience ──────────────────────────────────
-
-export type { ChatMessage, Chunk, SystemChunk, SystemChunkKind, ToolBatchChunk };
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts
deleted file mode 100644
index e8f4a18..0000000
--- a/packages/core/src/chunks/transform.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-// Pure, dependency-free transforms between the render-shaped `Chunk[]` /
-// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any
-// DB (`bun:sqlite`) import so BOTH the backend persistence layer
-// (`db/chunks.ts`) and the browser frontend store can share the exact same
-// explode/group logic.
-
-import type {
- Chunk,
- ChunkRow,
- ChunkRowDraft,
- ErrorData,
- MessageRole,
- SystemData,
- TextData,
- ThinkingData,
- ToolBatchChunk,
- ToolCallData,
- ToolResultData,
-} from "../types/index.js";
-
-/**
- * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the
- * agent's in-memory history and for the frontend's render bubbles. NOT a stored
- * shape: the source of truth is the flat chunk log.
- */
-export interface MessageRow {
- id: string;
- tabId: string;
- seq: number;
- /** turn_id of the chunk rows this message was grouped from. */
- turnId: string;
- role: MessageRole;
- chunks: Chunk[];
- createdAt: number;
-}
-
-// ─── Explode: in-memory turn → flat chunk-row drafts ─────────────
-//
-// A turn's render-shaped `Chunk[]` is flattened into append-only rows.
-// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and
-// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format. `step` increments after each tool-batch: every LLM
-// round-trip emits text/thinking then (optionally) one tool-batch, so a
-// tool-batch boundary is exactly a step boundary.
-
-/** Explode a single user message's text into one row draft. */
-export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] {
- return [{ turnId, step: 0, role: "user", type: "text", data: { text } }];
-}
-
-/** Explode an assistant turn's accumulated chunks into ordered row drafts. */
-export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] {
- const drafts: ChunkRowDraft[] = [];
- let step = 0;
- for (const chunk of chunks) {
- switch (chunk.type) {
- case "text":
- drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } });
- break;
- case "thinking":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "thinking",
- data: {
- text: chunk.text,
- ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}),
- },
- });
- break;
- case "tool-batch": {
- for (const call of chunk.calls) {
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "tool_call",
- data: { callId: call.id, name: call.name, arguments: call.arguments },
- });
- }
- for (const call of chunk.calls) {
- if (call.result === undefined) continue;
- drafts.push({
- turnId,
- step,
- role: "tool",
- type: "tool_result",
- data: {
- callId: call.id,
- name: call.name,
- result: call.result,
- isError: call.isError ?? false,
- ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}),
- },
- });
- }
- // A tool-batch ends the current LLM step; subsequent chunks belong
- // to the next round-trip.
- step++;
- break;
- }
- case "error":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "error",
- data: {
- message: chunk.message,
- ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}),
- },
- });
- break;
- case "system":
- drafts.push({
- turnId,
- step,
- role: "system",
- type: "system",
- data: { kind: chunk.kind, text: chunk.text },
- });
- break;
- }
- }
- return drafts;
-}
-
-// ─── Group: flat chunk rows → derived render messages ────────────
-//
-// The inverse of explode (best-effort over an arbitrary window, so it tolerates
-// orphan tool-results whose tool-call was paged out). `tool_result` rows
-// (role=tool) merge back into the preceding assistant message's per-step
-// `tool-batch` chunk by `callId` rather than forming their own message.
-
-export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] {
- const messages: MessageRow[] = [];
-
- let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null;
- const flush = () => {
- if (current) {
- messages.push(current.msg);
- current = null;
- }
- };
- const ensureAssistant = (row: ChunkRow) => {
- if (!current) {
- current = {
- msg: {
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "assistant",
- chunks: [],
- createdAt: row.createdAt,
- },
- batches: new Map(),
- };
- }
- return current;
- };
- const ensureBatch = (step: number): ToolBatchChunk => {
- const c = current;
- if (!c) throw new Error("ensureBatch called without an assistant message");
- let batch = c.batches.get(step);
- if (!batch) {
- batch = { type: "tool-batch", calls: [] };
- c.batches.set(step, batch);
- c.msg.chunks.push(batch);
- }
- return batch;
- };
-
- for (const row of rows) {
- if (row.role === "user") {
- flush();
- const d = row.data as TextData;
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "user",
- chunks: [{ type: "text", text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
- if (row.role === "system") {
- // Coalesce consecutive system rows into one system message (multiple
- // system chunks), matching the old applySystemEvent behaviour.
- const prev = messages[messages.length - 1];
- const d = row.data as SystemData;
- if (current === null && prev && prev.role === "system") {
- prev.chunks.push({ type: "system", kind: d.kind, text: d.text });
- continue;
- }
- flush();
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "system",
- chunks: [{ type: "system", kind: d.kind, text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
-
- // Usage rows are an invisible side channel (persisted for the backend
- // aggregate only). They're already query-excluded from getChunksForTab,
- // so this is defensive insurance: never let one leak into render grouping.
- if (row.type === "usage") continue;
-
- // assistant / tool rows → part of the current assistant message
- const c = ensureAssistant(row);
- switch (row.type) {
- case "text":
- c.msg.chunks.push({ type: "text", text: (row.data as TextData).text });
- break;
- case "thinking": {
- const d = row.data as ThinkingData;
- c.msg.chunks.push({
- type: "thinking",
- text: d.text,
- ...(d.metadata !== undefined ? { metadata: d.metadata } : {}),
- });
- break;
- }
- case "error": {
- const d = row.data as ErrorData;
- c.msg.chunks.push({
- type: "error",
- message: d.message,
- ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}),
- });
- break;
- }
- case "tool_call": {
- const d = row.data as ToolCallData;
- ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments });
- break;
- }
- case "tool_result": {
- const d = row.data as ToolResultData;
- const batch = ensureBatch(row.step);
- const entry = batch.calls.find((e) => e.id === d.callId);
- if (entry) {
- entry.result = d.result;
- entry.isError = d.isError;
- if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput;
- } else {
- // Orphan result (its tool_call was paged out of this window).
- batch.calls.push({
- id: d.callId,
- name: d.name,
- arguments: {},
- result: d.result,
- isError: d.isError,
- ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}),
- });
- }
- break;
- }
- }
- }
- flush();
- return messages;
-}
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
deleted file mode 100644
index 663ccb2..0000000
--- a/packages/core/src/compaction/index.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-// Conversation compaction — summarize the older "head" of a conversation into
-// a structured anchor while preserving the most recent turns verbatim.
-//
-// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the
-// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The
-// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed
-// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns
-// verbatim rather than computing a token budget.
-//
-// This module is pure and DB-free so it can be unit-tested in isolation and
-// shared by the API orchestrator.
-
-import type { ChatMessage } from "../types/index.js";
-
-/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */
-export const DEFAULT_TAIL_TURNS = 2;
-
-/** Max characters of a single tool result fed into the summary request. */
-export const TOOL_OUTPUT_MAX_CHARS = 2_000;
-
-/**
- * Marker prefixing the seeded summary turn in a compacted conversation. Lets a
- * subsequent compaction detect a prior summary and re-summarize (anchor) it
- * instead of treating it as ordinary conversation.
- */
-export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]";
-
-/**
- * Structured Markdown template the summary must follow. Ported verbatim from
- * opencode's `SUMMARY_TEMPLATE`.
- */
-export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
-<template>
-## Goal
-- [single-sentence task summary]
-
-## Constraints & Preferences
-- [user constraints, preferences, specs, or "(none)"]
-
-## Progress
-### Done
-- [completed work or "(none)"]
-
-### In Progress
-- [current work or "(none)"]
-
-### Blocked
-- [blockers or "(none)"]
-
-## Key Decisions
-- [decision and why, or "(none)"]
-
-## Next Steps
-- [ordered next actions or "(none)"]
-
-## Critical Context
-- [important technical facts, errors, open questions, or "(none)"]
-
-## Relevant Files
-- [file or directory path: why it matters, or "(none)"]
-</template>
-
-Rules:
-- Keep every section, even when empty.
-- Use terse bullets, not prose paragraphs.
-- Preserve exact file paths, commands, error strings, and identifiers when known.
-- Do not mention the summary process or that context was compacted.`;
-
-/**
- * Build the compaction instruction. When `previousSummary` is provided, the
- * model is asked to UPDATE the anchored summary rather than create a fresh one
- * (opencode's `buildPrompt` anchor behaviour).
- */
-export function buildCompactionPrompt(input: { previousSummary?: string }): string {
- const anchor = input.previousSummary
- ? [
- "Update the anchored summary below using the conversation history above.",
- "Preserve still-true details, remove stale details, and merge in the new facts.",
- "<previous-summary>",
- input.previousSummary,
- "</previous-summary>",
- ].join("\n")
- : "Create a new anchored summary from the conversation history above.";
- return `${anchor}\n\n${SUMMARY_TEMPLATE}`;
-}
-
-/**
- * The first text chunk of a message, trimmed (empty → undefined). Used to read
- * the seeded summary out of a compacted conversation's first user turn.
- */
-function firstText(message: ChatMessage): string | undefined {
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) return t;
- }
- }
- return undefined;
-}
-
-/**
- * Extract a prior summary from the conversation head. If the first user message
- * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body
- * (marker stripped) so the next compaction can anchor on it.
- */
-export function extractPreviousSummary(messages: ChatMessage[]): string | undefined {
- const first = messages.find((m) => m.role === "user");
- if (!first) return undefined;
- const text = firstText(first);
- if (!text?.startsWith(SUMMARY_MARKER)) return undefined;
- const body = text.slice(SUMMARY_MARKER.length).trim();
- return body || undefined;
-}
-
-export interface HeadTailSelection<T extends ChatMessage = ChatMessage> {
- /** Older messages to be summarized away. */
- head: T[];
- /** Recent messages preserved verbatim in the continuation. */
- tail: T[];
-}
-
-/**
- * Split a conversation into a summarizable `head` and a preserved `tail` of the
- * last `tailTurns` turns. A "turn" begins at a user message and runs until the
- * next user message.
- *
- * When the conversation has `tailTurns` or fewer turns, `head` is empty: there
- * is nothing to compact (the caller should refuse).
- */
-export function selectHeadTail<T extends ChatMessage>(
- messages: T[],
- tailTurns: number = DEFAULT_TAIL_TURNS,
-): HeadTailSelection<T> {
- if (tailTurns <= 0) return { head: messages, tail: [] };
- const userIndices: number[] = [];
- for (let i = 0; i < messages.length; i++) {
- if (messages[i]?.role === "user") userIndices.push(i);
- }
- if (userIndices.length <= tailTurns) return { head: [], tail: messages };
- const tailStart = userIndices[userIndices.length - tailTurns];
- if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages };
- return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) };
-}
-
-/** Cap a tool result to `max` chars with a truncation marker. */
-function capToolOutput(result: string, max: number): string {
- if (result.length <= max) return result;
- const omitted = result.length - max;
- return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`;
-}
-
-/**
- * Render conversation messages into a compact, provider-agnostic plain-text
- * transcript suitable as summary-request context. Tool results are capped at
- * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior
- * summary message is skipped (its content is carried by the prompt anchor).
- * Thinking/error/system chunks are omitted as summary noise.
- */
-export function renderTranscript(
- messages: ChatMessage[],
- toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS,
-): string {
- const blocks: string[] = [];
- for (const message of messages) {
- // Skip a seeded prior-summary user turn — it's represented via the anchor.
- if (message.role === "user") {
- const t = firstText(message);
- if (t?.startsWith(SUMMARY_MARKER)) continue;
- }
-
- const lines: string[] = [];
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) lines.push(t);
- } else if (chunk.type === "tool-batch") {
- for (const call of chunk.calls) {
- let args = "";
- try {
- args = JSON.stringify(call.arguments ?? {});
- } catch {
- args = "{}";
- }
- lines.push(`[tool ${call.name} ${args}]`);
- if (call.result !== undefined) {
- const tag = call.isError ? "tool-error" : "tool-result";
- lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`);
- }
- }
- }
- }
- if (lines.length === 0) continue;
- const role =
- message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System";
- blocks.push(`## ${role}\n${lines.join("\n")}`);
- }
- return blocks.join("\n\n");
-}
-
-export interface CompactionRequest<T extends ChatMessage = ChatMessage> {
- /** Messages selected for summarization (older head). */
- head: T[];
- /** Recent messages preserved verbatim (last N turns). */
- tail: T[];
- /** Prior summary anchored on, if the conversation was compacted before. */
- previousSummary?: string;
- /**
- * The full user-message content for the summary request: rendered head
- * transcript followed by the compaction prompt/template. `undefined` when
- * there is nothing to compact (`head` empty).
- */
- prompt?: string;
-}
-
-/**
- * Assemble everything needed to run a compaction: head/tail split, prior-summary
- * extraction, and the combined summary-request prompt. Returns `prompt:
- * undefined` when the conversation is too short to compact.
- */
-export function buildCompactionRequest<T extends ChatMessage>(input: {
- messages: T[];
- tailTurns?: number;
- toolOutputMaxChars?: number;
-}): CompactionRequest<T> {
- const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS;
- const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS;
- const { head, tail } = selectHeadTail(input.messages, tailTurns);
- const previousSummary = extractPreviousSummary(input.messages);
- if (head.length === 0) {
- return { head, tail, previousSummary };
- }
- const transcript = renderTranscript(head, toolMax);
- const instruction = buildCompactionPrompt({ previousSummary });
- const prompt = `${transcript}\n\n${instruction}`;
- return { head, tail, previousSummary, prompt };
-}
-
-/**
- * Wrap a generated summary as the seeded user-turn text for the continuation
- * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can
- * anchor on it.
- */
-export function buildSummaryTurnText(summary: string): string {
- return `${SUMMARY_MARKER}\n\n${summary.trim()}`;
-}
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
deleted file mode 100644
index 7f76dd7..0000000
--- a/packages/core/src/config/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "./loader.js";
-export { validateConfig } from "./schema.js";
-export { createConfigWatcher, watchDirConfig } from "./watcher.js";
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
deleted file mode 100644
index 66f798b..0000000
--- a/packages/core/src/config/loader.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { readFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { parse } from "smol-toml";
-import type { PermissionRule, Ruleset } from "../permission/index.js";
-import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js";
-import { validateConfig } from "./schema.js";
-
-const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
-
-const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
-
-function validateAction(raw: string): "allow" | "deny" | "ask" {
- if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask";
- console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`);
- return "ask";
-}
-
-/**
- * Absolute path to the HOME-directory (global) `dispatch.toml`.
- *
- * Follows the same `~/.config/dispatch/` convention as global agents
- * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its
- * contents are merged underneath every project/working-directory config so
- * machine-wide settings (e.g. globally available LSP servers) work in any
- * repository without per-repo configuration.
- *
- * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment
- * variable, which is primarily useful for tests (point it at a temp file) but
- * also lets a user relocate the global config.
- */
-export function getGlobalConfigPath(): string {
- return (
- process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml")
- );
-}
-
-// Parse + validate a single dispatch.toml. Returns null when the file does not
-// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly
-// (callers that must stay resilient, e.g. the global loader, catch it).
-function readConfigFile(tomlPath: string): DispatchConfig | null {
- let raw: unknown;
- try {
- const content = readFileSync(tomlPath, "utf-8");
- raw = parse(content);
- } catch (err: unknown) {
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
- // File doesn't exist — signal "no config here".
- return null;
- }
- console.warn(
- `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`,
- );
- throw err;
- }
-
- const { config, errors } = validateConfig(raw);
- for (const e of errors) {
- console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
- }
- return config;
-}
-
-/**
- * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}).
- *
- * Always resilient: a missing file yields the empty default and a malformed
- * file is logged but downgraded to the empty default rather than thrown. A
- * broken global config must never break config loading for every repository on
- * the machine.
- */
-export function loadGlobalConfig(): DispatchConfig {
- try {
- return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG;
- } catch (err) {
- console.warn(
- `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- return DEFAULT_CONFIG;
- }
-}
-
-/**
- * Load the effective config for `dir`: the global config MERGED with the
- * project/working-directory `dispatch.toml`, where the LOCAL config takes
- * precedence on conflicts (see {@link mergeConfigs}). A missing local file
- * yields the global config as-is; a missing global file yields the local
- * config as-is.
- *
- * Note: a malformed LOCAL config still throws (callers may surface it), while a
- * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}.
- */
-export function loadConfig(dir: string): DispatchConfig {
- const global = loadGlobalConfig();
- const local = readConfigFile(join(dir, "dispatch.toml"));
- if (local === null) return global;
- return mergeConfigs(global, local);
-}
-
-// ─── Merge ───────────────────────────────────────────────────────
-
-/**
- * Merge two permission blocks. Local takes precedence on conflicts.
- *
- * - A key present only in one side is carried over verbatim.
- * - A key whose value is a string on either side: local replaces global.
- * - A key that is a nested `{ pattern -> action }` object on BOTH sides is
- * merged pattern-by-pattern: global patterns the local block does NOT also
- * define come first (original order), then EVERY local pattern is appended
- * last (overriding any same-named global pattern).
- *
- * Emitting all local patterns after the global ones is essential, not
- * cosmetic: `configToRuleset` flattens patterns in iteration order and
- * `evaluate` uses `findLast` (last match wins). If an overridden pattern were
- * updated in place, a more-general global pattern (e.g. "*") could remain AFTER
- * it and silently shadow the local override. Appending local patterns last
- * reproduces a clean "global rules then local rules" concatenation so local
- * always wins.
- */
-function mergePermissions(
- global: DispatchConfig["permissions"],
- local: DispatchConfig["permissions"],
-): DispatchConfig["permissions"] {
- const result: DispatchConfig["permissions"] = {};
- for (const [key, value] of Object.entries(global)) {
- result[key] = value;
- }
- for (const [key, value] of Object.entries(local)) {
- const existing = result[key];
- if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") {
- // Both nested objects — merge patterns so that ALL local patterns
- // are emitted AFTER the global ones. This matters because
- // `configToRuleset` flattens patterns in insertion order and
- // `evaluate` uses `findLast` (last match wins): a naive
- // `{ ...existing, ...value }` would update an overridden pattern
- // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting
- // AFTER it and silently shadowing the local override. We therefore
- // drop any global pattern that the local block also defines, keep the
- // remaining global patterns in their original order, then append every
- // local pattern last — reproducing a clean "global rules then local
- // rules" concatenation where local always wins.
- const merged: Record<string, string> = {};
- for (const [pattern, action] of Object.entries(existing)) {
- if (!(pattern in value)) merged[pattern] = action;
- }
- for (const [pattern, action] of Object.entries(value)) {
- merged[pattern] = action;
- }
- result[key] = merged;
- } else {
- // Local string, brand-new key, or a string/object type mismatch:
- // local replaces global wholesale.
- result[key] = value;
- }
- }
- return result;
-}
-
-/**
- * Merge two key lists by `id`. Local keys override global keys sharing the same
- * id; non-conflicting ids from both lists survive. Global keys keep their
- * relative order (overridden in place) followed by local-only keys.
- */
-function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] {
- const byId = new Map<string, KeyDefinition>();
- for (const key of global) byId.set(key.id, key);
- for (const key of local) byId.set(key.id, key);
- return Array.from(byId.values());
-}
-
-/**
- * Merge two `[lsp]` blocks by server id. Local servers override global servers
- * sharing the same id; non-conflicting ids from both sides remain active. This
- * is what lets a global config provide LSP servers to every repository while a
- * project can still override or add its own.
- */
-function mergeLsp(
- global: Record<string, LspServerConfig>,
- local: Record<string, LspServerConfig>,
-): Record<string, LspServerConfig> {
- return { ...global, ...local };
-}
-
-/**
- * Deep-merge a `global` config with a `local` (project/working-directory)
- * config, with LOCAL taking precedence on every conflict. Pure function — does
- * not touch the filesystem and never mutates its inputs.
- */
-export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig {
- const merged: DispatchConfig = {
- permissions: mergePermissions(global.permissions, local.permissions),
- };
-
- if (global.keys !== undefined || local.keys !== undefined) {
- merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []);
- }
-
- if (global.lsp !== undefined || local.lsp !== undefined) {
- merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {});
- }
-
- return merged;
-}
-
-// Convert the config's permission block to a Ruleset
-export function configToRuleset(config: DispatchConfig): Ruleset {
- const home = homedir();
- const rules: PermissionRule[] = [];
-
- for (const [permission, value] of Object.entries(config.permissions)) {
- if (typeof value === "string") {
- const action = validateAction(value);
- rules.push({ permission, pattern: "*", action });
- } else {
- for (const [rawPattern, rawAction] of Object.entries(value)) {
- const pattern = rawPattern
- .replace(/^\$HOME(?=[/\\]|$)/, home)
- .replace(/^~(?=[/\\]|$)/, home);
- const action = validateAction(rawAction);
- rules.push({ permission, pattern, action });
- }
- }
- }
-
- return rules;
-}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
deleted file mode 100644
index 304ee10..0000000
--- a/packages/core/src/config/schema.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import type {
- ConfigError,
- DispatchConfig,
- KeyDefinition,
- LspServerConfig,
-} from "../types/index.js";
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function isStringRecord(value: unknown): value is Record<string, string> {
- if (!isRecord(value)) return false;
- return Object.values(value).every((v) => typeof v === "string");
-}
-
-function isValidAction(value: string): boolean {
- return value === "allow" || value === "deny" || value === "ask";
-}
-
-function isPermissionsValue(value: unknown): value is string | Record<string, string> {
- return typeof value === "string" || isStringRecord(value);
-}
-
-function validatePermissions(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, string | Record<string, string>> {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return {};
- }
- const result: Record<string, string | Record<string, string>> = {};
- for (const [key, value] of Object.entries(raw)) {
- if (!isPermissionsValue(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: "must be a string or a flat string-keyed object",
- });
- continue;
- }
- if (typeof value === "string") {
- if (!isValidAction(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: `invalid action "${value}"; must be "allow", "deny", or "ask"`,
- });
- continue;
- }
- } else {
- let hasError = false;
- for (const [pattern, action] of Object.entries(value)) {
- if (!isValidAction(action)) {
- errors.push({
- path: `${path}.${key}.${pattern}`,
- message: `invalid action "${action}"; must be "allow", "deny", or "ask"`,
- });
- hasError = true;
- }
- }
- if (hasError) continue;
- }
- result[key] = value;
- }
- return result;
-}
-
-function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
- if (typeof raw.id !== "string") {
- errors.push({ path: `${path}.id`, message: "must be a string" });
- return null;
- }
- if (typeof raw.provider !== "string") {
- errors.push({ path: `${path}.provider`, message: "must be a string" });
- return null;
- }
- if (typeof raw.base_url !== "string") {
- errors.push({ path: `${path}.base_url`, message: "must be a string" });
- return null;
- }
-
- // "anthropic" provider uses credentials_file instead of env
- if (raw.provider === "anthropic") {
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.credentials_file === "string"
- ? ({ credentials_file: raw.credentials_file } as Pick<KeyDefinition, "credentials_file">)
- : {}),
- };
- }
-
- // Other providers: env is optional (keys can be stored in DB)
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.env === "string" ? { env: raw.env } : {}),
- };
-}
-
-function isStringArray(value: unknown): value is string[] {
- return Array.isArray(value) && value.every((v) => typeof v === "string");
-}
-
-function validateLspServer(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): LspServerConfig | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
-
- const disabled = raw.disabled === true;
-
- // `command` is required and must be a non-empty string array unless the
- // entry is explicitly disabled (a disabled entry is skipped wholesale).
- if (!disabled) {
- if (!isStringArray(raw.command) || raw.command.length === 0) {
- errors.push({
- path: `${path}.command`,
- message: "must be a non-empty array of strings",
- });
- return null;
- }
- // `extensions` is required for custom servers — without it the client
- // cannot know which files should activate the server.
- if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
- errors.push({
- path: `${path}.extensions`,
- message: 'must be a non-empty array of strings (e.g. [".luau"])',
- });
- return null;
- }
- } else {
- // Disabled entries still must not carry a malformed command/extensions
- // if present, but we do not require them.
- if (raw.command !== undefined && !isStringArray(raw.command)) {
- errors.push({ path: `${path}.command`, message: "must be an array of strings" });
- return null;
- }
- if (raw.extensions !== undefined && !isStringArray(raw.extensions)) {
- errors.push({ path: `${path}.extensions`, message: "must be an array of strings" });
- return null;
- }
- }
-
- if (raw.env !== undefined && !isStringRecord(raw.env)) {
- errors.push({
- path: `${path}.env`,
- message: "must be a flat string-keyed object",
- });
- return null;
- }
-
- if (raw.initialization !== undefined && !isRecord(raw.initialization)) {
- errors.push({
- path: `${path}.initialization`,
- message: "must be an object",
- });
- return null;
- }
-
- const server: LspServerConfig = {
- command: (raw.command as string[] | undefined) ?? [],
- extensions: (raw.extensions as string[] | undefined) ?? [],
- ...(isStringRecord(raw.env) ? { env: raw.env } : {}),
- ...(isRecord(raw.initialization)
- ? { initialization: raw.initialization as Record<string, unknown> }
- : {}),
- ...(disabled ? { disabled: true } : {}),
- };
- return server;
-}
-
-function validateLsp(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, LspServerConfig> | undefined {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return undefined;
- }
- const result: Record<string, LspServerConfig> = {};
- for (const [id, value] of Object.entries(raw)) {
- const server = validateLspServer(value, `${path}.${id}`, errors);
- if (server) result[id] = server;
- }
- return Object.keys(result).length > 0 ? result : undefined;
-}
-
-export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } {
- const errors: ConfigError[] = [];
-
- if (!isRecord(raw)) {
- errors.push({ path: "", message: "config must be an object" });
- return { config: { permissions: {} }, errors };
- }
-
- // permissions (required, but can be empty)
- const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors);
-
- // keys (optional)
- let keys: KeyDefinition[] | undefined;
- if (raw.keys !== undefined) {
- if (!Array.isArray(raw.keys)) {
- errors.push({ path: "keys", message: "must be an array" });
- } else {
- keys = [];
- for (let i = 0; i < raw.keys.length; i++) {
- const key = validateKey(raw.keys[i], `keys[${i}]`, errors);
- if (key) keys.push(key);
- }
- }
- }
-
- // lsp (optional)
- let lsp: Record<string, LspServerConfig> | undefined;
- if (raw.lsp !== undefined) {
- lsp = validateLsp(raw.lsp, "lsp", errors);
- }
-
- const config: DispatchConfig = {
- permissions,
- ...(keys !== undefined && { keys }),
- ...(lsp !== undefined && { lsp }),
- };
-
- return { config, errors };
-}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
deleted file mode 100644
index ad55804..0000000
--- a/packages/core/src/config/watcher.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { join } from "node:path";
-import { watch } from "chokidar";
-import type { DispatchConfig } from "../types/index.js";
-import { getGlobalConfigPath, loadConfig } from "./loader.js";
-
-/**
- * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working-
- * directory `dispatch.toml`. Either file changing triggers a reload that
- * re-merges global + local (via {@link loadConfig}), so hot-reload works for
- * global defaults and per-project overrides alike.
- *
- * When the global and local paths coincide (e.g. the working directory IS
- * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file)
- * the duplicate is collapsed so chokidar only watches it once.
- */
-export function createConfigWatcher(
- dir: string,
- onChange: (config: DispatchConfig) => void,
-): { close(): void } {
- const localPath = join(dir, "dispatch.toml");
- const globalPath = getGlobalConfigPath();
- const paths = globalPath === localPath ? [localPath] : [globalPath, localPath];
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(paths, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- console.log(`dispatch: reloading config (global + ${localPath})`);
- try {
- const config = loadConfig(dir);
- onChange(config);
- } catch (err) {
- console.warn(
- `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
-
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
-
-/**
- * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just
- * a debounced change signal). Used by the agent manager to invalidate its
- * per-directory LSP cache when a tab's effective working directory is a
- * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher`
- * only watches the root + global configs, so without this a nested config edit
- * would never clear `lspServersByDir[subdir]` and agents there would keep using
- * stale LSP servers until a root-config change or restart.
- *
- * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`.
- */
-export function watchDirConfig(dir: string, onChange: () => void): { close(): void } {
- const tomlPath = join(dir, "dispatch.toml");
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(tomlPath, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- try {
- onChange();
- } catch (err) {
- console.warn(
- `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts
deleted file mode 100644
index 802ebbd..0000000
--- a/packages/core/src/credentials/anthropic-betas.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// ─── Anthropic Beta Headers ───────────────────────────────────
-//
-// The set of `anthropic-beta` features the official Claude Code CLI sends on
-// every request. Kept in a dependency-free module (no DB / `bun:sqlite`
-// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in
-// without dragging the whole credentials/DB stack into its import graph.
-//
-// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the
-// Anthropic API silently ignores every `cache_control` breakpoint we place,
-// producing a 0% cache hit rate (see notes/claude-report.md). `oauth-2025-04-20`
-// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions.
-
-const BASE_BETAS = [
- "claude-code-20250219",
- "oauth-2025-04-20",
- "interleaved-thinking-2025-05-14",
- "prompt-caching-scope-2026-01-05",
- "context-management-2025-06-27",
- "advisor-tool-2026-03-01",
-];
-
-export function getAnthropicBetas(): string[] {
- return [...BASE_BETAS];
-}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
deleted file mode 100644
index ef30400..0000000
--- a/packages/core/src/credentials/api-keys.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { getDatabase } from "../db/index.js";
-
-export interface StoredApiKey {
- keyId: string;
- provider: string;
- apiKey: string;
- importedAt: number;
- updatedAt: number;
-}
-
-/**
- * Store or update an API key in the database.
- */
-export function setApiKey(keyId: string, provider: string, apiKey: string): void {
- const db = getDatabase();
- const now = Date.now();
- db.query(
- `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at)
- VALUES ($keyId, $provider, $apiKey, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- api_key = $apiKey,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $apiKey: apiKey,
- $now: now,
- });
-}
-
-/**
- * Get a stored API key by key ID. Returns the key string or null.
- */
-export function getApiKey(keyId: string): string | null {
- const db = getDatabase();
- const row = db
- .query("SELECT api_key FROM api_keys WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { api_key: string } | null;
- return row?.api_key ?? null;
-}
-
-/**
- * Resolve an API key from the database, with env var fallback.
- * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well.
- */
-export function resolveApiKey(keyId: string, envVar?: string): string | null {
- const dbKey = getApiKey(keyId);
- if (dbKey) return dbKey;
- if (envVar) return process.env[envVar] ?? null;
- return null;
-}
-
-/**
- * Delete a stored API key.
- */
-export function deleteApiKey(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all stored API keys with metadata (key value excluded for security).
- */
-export function listApiKeys(): Array<{
- keyId: string;
- provider: string;
- importedAt: number;
- updatedAt: number;
-}> {
- const db = getDatabase();
- const rows = db
- .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id")
- .all() as Array<Record<string, unknown>>;
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
deleted file mode 100644
index 050a0fc..0000000
--- a/packages/core/src/credentials/claude.ts
+++ /dev/null
@@ -1,694 +0,0 @@
-import { createHash } from "node:crypto";
-import {
- chmodSync,
- existsSync,
- mkdirSync,
- readdirSync,
- readFileSync,
- writeFileSync,
-} from "node:fs";
-import { homedir } from "node:os";
-import { basename, dirname, join } from "node:path";
-import { getDatabase } from "../db/index.js";
-import { getAnthropicBetas } from "./anthropic-betas.js";
-import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
-
-// Re-exported for backward compatibility — `getAnthropicBetas` historically
-// lived here and is surfaced through `credentials/index.ts`. The definition
-// now lives in the dependency-free `anthropic-betas.ts` module.
-export { getAnthropicBetas };
-
-export interface ClaudeCredentials {
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType?: string;
-}
-
-export interface ClaudeAccount {
- id: string;
- label: string;
- source: string;
- credentials: ClaudeCredentials;
-}
-
-const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
-const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
-const CREDENTIAL_CACHE_TTL_MS = 30_000;
-
-const CREDENTIALS_DIR = join(homedir(), ".claude");
-const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json");
-
-const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: number }>();
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (
- (creds as Record<string, unknown>).mcpOAuth &&
- !(creds as Record<string, unknown>).accessToken
- ) {
- return null;
- }
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-function readCredentialsFile(filePath: string): ClaudeCredentials | null {
- try {
- if (!existsSync(filePath)) return null;
- const raw = readFileSync(filePath, "utf-8").trim();
- if (!raw) return null;
- return parseCredentialsFile(raw);
- } catch {
- return null;
- }
-}
-
-function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void {
- let existing: Record<string, unknown> = {};
- try {
- if (existsSync(filePath)) {
- const raw = readFileSync(filePath, "utf-8").trim();
- if (raw) {
- existing = JSON.parse(raw);
- }
- }
- } catch {
- existing = {};
- }
-
- const hasWrapper = "claudeAiOauth" in existing;
- const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing;
- target.accessToken = creds.accessToken;
- target.refreshToken = creds.refreshToken;
- target.expiresAt = creds.expiresAt;
- if (creds.subscriptionType) {
- target.subscriptionType = creds.subscriptionType;
- }
-
- const dir = dirname(filePath);
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- }
- writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 });
- if (process.platform !== "win32") {
- chmodSync(filePath, 0o600);
- }
-}
-
-async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> {
- const body = new URLSearchParams({
- grant_type: "refresh_token",
- client_id: OAUTH_CLIENT_ID,
- refresh_token: refreshToken,
- });
-
- try {
- const response = await fetch(OAUTH_TOKEN_URL, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: body.toString(),
- });
-
- if (!response.ok) {
- return null;
- }
-
- const data = (await response.json()) as Record<string, unknown>;
- if (!data.access_token || typeof data.access_token !== "string") {
- return null;
- }
-
- return {
- accessToken: data.access_token as string,
- refreshToken: (data.refresh_token as string) ?? refreshToken,
- expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000,
- subscriptionType:
- typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
- };
- } catch {
- return null;
- }
-}
-
-function buildAccountLabels(accounts: ClaudeAccount[]): void {
- for (const acct of accounts) {
- acct.label = acct.credentials.subscriptionType
- ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}`
- : "Claude";
- }
-}
-
-/**
- * Load Claude accounts from the SQLite database.
- * Returns accounts for all stored anthropic credentials.
- * This is the preferred path — file-based discovery is the fallback.
- */
-export function getClaudeAccountsFromDB(): ClaudeAccount[] {
- const stored = listStoredCredentials();
- const accounts: ClaudeAccount[] = [];
-
- for (const cred of stored) {
- if (cred.provider !== "anthropic") continue;
- accounts.push({
- id: cred.keyId,
- label: "",
- source: `db:${cred.keyId}`,
- credentials: {
- accessToken: cred.accessToken,
- refreshToken: cred.refreshToken,
- expiresAt: cred.expiresAt,
- subscriptionType: cred.subscriptionType ?? undefined,
- },
- });
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function discoverClaudeAccounts(): ClaudeAccount[] {
- const accounts: ClaudeAccount[] = [];
-
- if (!existsSync(CREDENTIALS_DIR)) {
- return accounts;
- }
-
- const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE);
- if (primaryCreds) {
- accounts.push({
- id: "claude-default",
- label: "",
- source: PRIMARY_CREDENTIALS_FILE,
- credentials: primaryCreds,
- });
- }
-
- try {
- const files = readdirSync(CREDENTIALS_DIR);
- const credFiles = files.filter(
- (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json",
- );
- for (const file of credFiles) {
- const filePath = join(CREDENTIALS_DIR, file);
- const creds = readCredentialsFile(filePath);
- if (creds) {
- const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`;
- accounts.push({
- id,
- label: "",
- source: filePath,
- credentials: creds,
- });
- }
- }
- } catch {
- // ignore
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- // Synchronous refresh not available in this context, but the async version will be used
- // by getCredentialsForAccount below
- return null;
- }
-
- return null;
-}
-
-export async function refreshAccountCredentialsAsync(
- account: ClaudeAccount,
-): Promise<ClaudeCredentials | null> {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- const refreshed = await refreshViaOAuth(account.credentials.refreshToken);
- if (refreshed && refreshed.expiresAt > now + 60_000) {
- account.credentials = refreshed;
- // Update DB if this is a DB-backed account, otherwise write to file
- if (account.source.startsWith("db:")) {
- updateStoredTokens(
- account.id,
- refreshed.accessToken,
- refreshed.refreshToken,
- refreshed.expiresAt,
- );
- } else {
- writeCredentialsFile(account.source, refreshed);
- }
- accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now });
- return refreshed;
- }
- }
-
- return null;
-}
-
-// ─── Billing Header Computation ────────────────────────────────
-
-const BILLING_SALT = "59cf53e54c78";
-const CC_VERSION = "2.1.112";
-
-function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string {
- const userMsg = messages.find((m) => m.role === "user");
- if (!userMsg) return "";
- if (typeof userMsg.content === "string") return userMsg.content;
- return "";
-}
-
-function computeCch(messageText: string): string {
- return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
-}
-
-function computeVersionSuffix(messageText: string, version: string): string {
- const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join("");
- const input = `${BILLING_SALT}${sampled}${version}`;
- return createHash("sha256").update(input).digest("hex").slice(0, 3);
-}
-
-export function buildBillingHeaderValue(
- messages: Array<{ role: string; content: string }>,
-): string {
- const text = extractFirstUserMessageText(messages);
- const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION;
- const suffix = computeVersionSuffix(text, version);
- const cch = computeCch(text);
- return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`;
-}
-
-export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-/**
- * Build the request body for a Claude "wake" probe — a tiny, cheap message
- * whose only purpose is to keep the subscription's rate-limit window warm.
- *
- * This MUST mirror the shape of a genuine Claude Code CLI request, because
- * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated,
- * Claude-Code-billed requests. A bare `{ model, messages }` body (no system
- * identity) is rejected (401/403) — which is exactly how the old probe silently
- * failed. The valid shape is:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
- * ]
- * messages: [ { role: "user", content: "hi" } ]
- *
- * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user
- * turn. Pure: deterministic given its inputs (the billing header samples only
- * the user text), so it can be unit-tested without touching the network.
- */
-export function buildWakeProbeBody(model: string): {
- model: string;
- max_tokens: number;
- system: Array<{ type: "text"; text: string }>;
- messages: Array<{ role: "user"; content: string }>;
-} {
- const messages = [{ role: "user" as const, content: "hi" }];
- return {
- model,
- max_tokens: 16,
- system: [
- { type: "text", text: buildBillingHeaderValue(messages) },
- { type: "text", text: SYSTEM_IDENTITY },
- ],
- messages,
- };
-}
-
-// ─── Anthropic Request Headers ────────────────────────────────
-
-export function getAnthropicHeaders(accessToken: string): Record<string, string> {
- return {
- authorization: `Bearer ${accessToken}`,
- "anthropic-version": "2023-06-01",
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`,
- };
-}
-
-// ─── Usage Tracking ───────────────────────────────────────────
-
-export interface ClaudeUsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeUsageReport {
- fiveHour?: ClaudeUsageBucket;
- sevenDay?: ClaudeUsageBucket;
- sevenDayOpus?: ClaudeUsageBucket;
- sevenDaySonnet?: ClaudeUsageBucket;
- accountId?: string;
- email?: string;
- orgId?: string;
-}
-
-/**
- * A usage report paired with provenance: whether it came back from a fresh
- * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served
- * from the local `usage_cache` table after a failed/skipped live fetch.
- *
- * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when
- * that cached payload was last fetched FROM the source (the `usage_cache.cached_at`
- * column). `source: "live"` omits `cachedAt` (the data is current as of now).
- */
-export interface ClaudeUsageResult {
- report: ClaudeUsageReport;
- source: "live" | "cache";
- /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */
- cachedAt?: number;
-}
-
-// ─── Well-known Anthropic models ──────────────────────────────
-
-/**
- * Fetch the live list of available models from Anthropic's /v1/models endpoint.
- * Requires valid OAuth credentials with anthropic-beta headers.
- */
-export async function fetchAnthropicModels(accessToken: string): Promise<string[]> {
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json",
- };
-
- try {
- const response = await fetch("https://api.anthropic.com/v1/models", { headers });
- if (!response.ok) {
- console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`);
- return [];
- }
-
- const data = (await response.json()) as {
- data?: Array<{ id: string }>;
- models?: Array<{ id: string }>;
- };
- const entries = data.data ?? data.models ?? [];
- return entries.map((m) => m.id).filter(Boolean);
- } catch (err) {
- console.warn(
- `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`,
- );
- return [];
- }
-}
-
-/** Fallback list if /v1/models is unreachable. */
-export const ANTHROPIC_MODELS_FALLBACK = [
- "claude-sonnet-4-20250514",
- "claude-opus-4-20250514",
- "claude-3.5-sonnet-20241022",
- "claude-3.5-haiku-20241022",
- "claude-3-opus-20240229",
-];
-
-/**
- * Pick the model to use for a Claude "wake" probe from a list of model ids.
- *
- * The probe only needs a small/cheap model to register activity against the
- * subscription, so we target Haiku. Model ids change over time (the old
- * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the
- * caller fetches the live list from `/v1/models` and we resolve by substring.
- *
- * Selection: the FIRST id whose name contains "haiku" (case-insensitive).
- * Anthropic's `/v1/models` returns models newest-first, so first-match
- * naturally prefers the newest Haiku. Returns `null` when nothing matches so
- * the caller can surface a clear error instead of probing an invalid model.
- */
-export function selectHaikuModel(models: string[]): string | null {
- return models.find((id) => id.toLowerCase().includes("haiku")) ?? null;
-}
-
-// ─── Credential Validation ────────────────────────────────────
-
-export interface ClaudeProfile {
- accountId?: string;
- email?: string;
- subscriptionType?: string;
-}
-
-/**
- * Validate that Claude credentials are usable by hitting the OAuth profile endpoint.
- * Returns the profile info if valid, or null if the token is dead.
- */
-export async function validateAccountCredentials(
- account: ClaudeAccount,
-): Promise<ClaudeProfile | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return null;
-
- const url = "https://api.anthropic.com/api/oauth/profile";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(creds.accessToken),
- accept: "application/json, text/plain, */*",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
-
- const profile: ClaudeProfile = {};
- const uuid = typeof data.uuid === "string" ? data.uuid : undefined;
- const email = typeof data.email === "string" ? data.email : undefined;
-
- if (uuid) profile.accountId = uuid;
- if (email) profile.email = email;
-
- // subscriptionType comes from the credentials file, but profile may also carry it
- profile.subscriptionType = account.credentials.subscriptionType;
-
- return profile;
- } catch {
- return null;
- }
-}
-
-async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> {
- const url = "https://api.anthropic.com/api/oauth/usage";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json, text/plain, */*",
- "content-type": "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
- const data = (await response.json()) as Record<string, unknown>;
-
- const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => {
- if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined;
- const b = bucket as Record<string, unknown>;
- // API returns utilization as 0-100 percentage; normalize to 0-1 fraction
- const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined;
- const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined;
- const resetsAt =
- typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
- if (utilization === undefined && resetsAt === undefined) return undefined;
- return { utilization, resetsAt };
- };
-
- const report: ClaudeUsageReport = {
- fiveHour: parseBucket(data.five_hour),
- sevenDay: parseBucket(data.seven_day),
- sevenDayOpus: parseBucket(data.seven_day_opus),
- sevenDaySonnet: parseBucket(data.seven_day_sonnet),
- };
-
- if (orgId) report.orgId = orgId;
-
- // Try to extract identity
- const accountId =
- typeof data.account_id === "string"
- ? data.account_id
- : typeof data.user_id === "string"
- ? data.user_id
- : typeof data.org_id === "string"
- ? data.org_id
- : undefined;
- if (accountId) report.accountId = accountId;
-
- const email = typeof data.email === "string" ? data.email : undefined;
- if (email) report.email = email;
-
- return report;
- } catch {
- return null;
- }
-}
-
-/**
- * Read a cached usage report plus the epoch-ms it was last fetched from source.
- * Returns `null` when there is no cached row (or on any DB/parse error).
- */
-function getCachedUsageWithMeta(
- keyId: string,
-): { report: ClaudeUsageReport; cachedAt: number } | null {
- try {
- const db = getDatabase();
- const row = db
- .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null;
- if (!row) return null;
- return {
- report: JSON.parse(row.report_json) as ClaudeUsageReport,
- cachedAt: row.cached_at,
- };
- } catch {
- return null;
- }
-}
-
-function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void {
- try {
- const db = getDatabase();
- db.query(
- `INSERT INTO usage_cache (key_id, provider, cached_at, report_json)
- VALUES ($keyId, $provider, $cachedAt, $reportJson)
- ON CONFLICT(key_id) DO UPDATE SET
- cached_at = $cachedAt,
- report_json = $reportJson`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $cachedAt: Date.now(),
- $reportJson: JSON.stringify(report),
- });
- } catch {
- // Ignore DB errors
- }
-}
-
-/**
- * Fetch an account's usage report along with its provenance (live vs cache).
- *
- * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint;
- * on success the fresh report is cached and returned as `source: "live"`. If
- * credentials cannot be refreshed OR the live fetch returns nothing, fall back
- * to the local `usage_cache` row and return it as `source: "cache"` with the
- * `cachedAt` timestamp recording when that payload was last fetched from source.
- * Returns `null` only when neither a live report nor a cached row is available.
- */
-export async function getAccountUsageWithSource(
- account: ClaudeAccount,
-): Promise<ClaudeUsageResult | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (creds) {
- const report = await fetchClaudeUsage(creds.accessToken);
- if (report) {
- setCachedUsage(account.id, "anthropic", report);
- return { report, source: "live" };
- }
- }
- const cached = getCachedUsageWithMeta(account.id);
- if (cached) {
- return { report: cached.report, source: "cache", cachedAt: cached.cachedAt };
- }
- return null;
-}
-
-export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
- const result = await getAccountUsageWithSource(account);
- return result?.report ?? null;
-}
diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts
deleted file mode 100644
index 8baf6f4..0000000
--- a/packages/core/src/credentials/copilot.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// ─── GitHub Copilot Usage Tracking ───────────────────────────
-// Uses the internal GitHub Copilot user endpoint (same one the
-// official VS Code Copilot extension calls).
-
-export interface CopilotUsageReport {
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number; // 0-100
- resetAt?: number; // Unix timestamp ms
- plan?: string;
-}
-
-export async function fetchCopilotUsage(
- token: string,
- _baseUrl: string,
-): Promise<CopilotUsageReport | null> {
- const url = "https://api.github.com/copilot_internal/user";
- const headers: Record<string, string> = {
- authorization: `Bearer ${token}`,
- accept: "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
- const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined;
-
- const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined;
- const resetAt = resetDate ? Date.parse(resetDate) : undefined;
-
- const qs = data.quota_snapshots as Record<string, unknown> | undefined;
- const pi = qs?.premium_interactions as Record<string, unknown> | undefined;
-
- const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined;
- const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined;
- const percentRemaining =
- typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined;
-
- if (entitlement === undefined && remaining === undefined) {
- return null;
- }
-
- const tokensConsumed =
- entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined;
- const percentUsed =
- percentRemaining !== undefined
- ? Math.round((100 - percentRemaining) * 100) / 100
- : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0
- ? Math.round((tokensConsumed / entitlement) * 10000) / 100
- : undefined;
-
- return {
- tokensConsumed,
- tokensRemaining: remaining,
- percentUsed,
- resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined,
- plan,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts
deleted file mode 100644
index 17bc930..0000000
--- a/packages/core/src/credentials/google.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// ─── Google Gemini Usage Tracking ───────────────────────────
-// Two modes:
-// 1. API key → queries native Gemini models endpoint for rate limits
-// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode)
-//
-// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie
-// from gemini.google.com to see current usage percentages.
-
-export interface GoogleUsageBucket {
- percentUsed: number; // 0-100
- resetsAt?: string; // e.g. "22:40" or "30 May at 17:40"
-}
-
-export interface GoogleUsageReport {
- // Mode 1: API key rate limits
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- // Mode 2: Cookie-scraped usage from gemini.google.com
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-}
-
-// Helpers to extract HTML text between markers
-function extractBetween(html: string, after: string, before: string): string | null {
- const start = html.indexOf(after);
- if (start === -1) return null;
- const s = start + after.length;
- const end = html.indexOf(before, s);
- if (end === -1) return null;
- return html.slice(s, end).trim();
-}
-
-function extractPercent(html: string, afterMarker: string): number | null {
- const chunk = extractBetween(html, afterMarker, "%");
- if (!chunk) return null;
- const digits = chunk.replace(/\D/g, "");
- const n = parseInt(digits, 10);
- return Number.isNaN(n) ? null : n;
-}
-
-function extractResetTime(html: string, afterMarker: string): string | null {
- // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM"
- const start = html.indexOf(afterMarker);
- if (start === -1) return null;
- const chunk = html.slice(start + afterMarker.length);
- // Match time patterns
- const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i);
- if (!m?.[1] || !m[2]) return null;
- return `${m[1]} ${m[2].trim()}`;
-}
-
-async function scrapeGeminiWeb(cookie: string): Promise<{
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-} | null> {
- try {
- const response = await fetch("https://gemini.google.com/app", {
- headers: {
- cookie: `__Secure-1PSID=${cookie}`,
- "accept-language": "en-US,en;q=0.9",
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Detect auth redirect
- if (html.includes("ServiceLogin") || html.includes("sign in")) {
- return null;
- }
-
- // Look for usage data in the page
- // Pattern: "Current usage" followed by a percentage and reset time
- const currentPct = extractPercent(html, "Current usage");
- const currentReset = extractResetTime(html, "Current usage");
-
- // Weekly limit section
- const weeklyPct = extractPercent(html, "Weekly limit");
- const weeklyReset = extractResetTime(html, "Weekly limit");
-
- const result: {
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
- } = {};
-
- if (currentPct !== null) {
- result.currentUsage = {
- percentUsed: currentPct,
- resetsAt: currentReset ?? undefined,
- };
- }
-
- if (weeklyPct !== null) {
- result.weeklyUsage = {
- percentUsed: weeklyPct,
- resetsAt: weeklyReset ?? undefined,
- };
- }
-
- if (result.currentUsage || result.weeklyUsage) return result;
- return null;
- } catch {
- return null;
- }
-}
-
-async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
-}> | null> {
- const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
-
- try {
- const response = await fetch(url);
- if (!response.ok) return null;
-
- const data = (await response.json()) as {
- models?: Array<{
- name: string;
- inputTokenLimit?: number;
- outputTokenLimit?: number;
- rateLimit?: { requestsPerMinute?: number };
- limits?: { requestsPerDay?: number };
- }>;
- };
-
- return (data.models ?? [])
- .filter((m) => {
- const name = m.name?.replace(/^models\//, "") ?? "";
- return name.startsWith("gemini-");
- })
- .map((m) => ({
- name: m.name?.replace(/^models\//, "") ?? "",
- inputTokenLimit: m.inputTokenLimit ?? 0,
- outputTokenLimit: m.outputTokenLimit ?? 0,
- rpm: m.rateLimit?.requestsPerMinute ?? 0,
- requestsPerDay: m.limits?.requestsPerDay ?? 0,
- }));
- } catch {
- return null;
- }
-}
-
-export async function fetchGoogleUsage(
- apiKey: string,
- _baseUrl: string,
-): Promise<GoogleUsageReport | null> {
- const results: GoogleUsageReport = {};
-
- // Try API key mode: get model rate limits
- const models = await fetchModelsViaApiKey(apiKey);
- if (models) {
- results.models = models;
- }
-
- // Try cookie mode: scrape gemini.google.com usage
- const cookie = process.env.GEMINI_COOKIE;
- if (cookie) {
- const scraped = await scrapeGeminiWeb(cookie);
- if (scraped) {
- if (scraped.currentUsage) results.currentUsage = scraped.currentUsage;
- if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage;
- }
- }
-
- if (results.models || results.currentUsage || results.weeklyUsage) return results;
- return null;
-}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
deleted file mode 100644
index 131f035..0000000
--- a/packages/core/src/credentials/index.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-export {
- deleteApiKey,
- getApiKey,
- listApiKeys,
- resolveApiKey,
- type StoredApiKey,
- setApiKey,
-} from "./api-keys.js";
-export {
- ANTHROPIC_MODELS_FALLBACK,
- buildBillingHeaderValue,
- buildWakeProbeBody,
- type ClaudeAccount,
- type ClaudeCredentials,
- type ClaudeProfile,
- type ClaudeUsageBucket,
- type ClaudeUsageReport,
- type ClaudeUsageResult,
- discoverClaudeAccounts,
- fetchAnthropicModels,
- getAccountUsage,
- getAccountUsageWithSource,
- getAnthropicBetas,
- getAnthropicHeaders,
- getClaudeAccountsFromDB,
- refreshAccountCredentials,
- refreshAccountCredentialsAsync,
- SYSTEM_IDENTITY,
- selectHaikuModel,
- validateAccountCredentials,
-} from "./claude.js";
-export {
- type CopilotUsageReport,
- fetchCopilotUsage,
-} from "./copilot.js";
-export {
- fetchGoogleUsage,
- type GoogleUsageReport,
-} from "./google.js";
-export {
- fetchOpencodeUsage,
- type OpencodeUsageBucket,
- type OpencodeUsageReport,
-} from "./opencode.js";
-export {
- deleteStoredCredentials,
- getStoredCredentials,
- importCredentialsFromFile,
- listStoredCredentials,
- type StoredCredential,
- updateStoredTokens,
-} from "./store.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
deleted file mode 100644
index d4d4851..0000000
--- a/packages/core/src/credentials/opencode.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { resolveApiKey } from "./api-keys.js";
-
-// ─── OpenCode Usage Tracking ──────────────────────────────────
-// OpenCode has no public usage API. We scrape usage from the
-// SolidStart SSR-rendered workspace page using a session cookie.
-// Requires OPENCODE_COOKIE env var.
-// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2.
-
-export interface OpencodeUsageBucket {
- utilization?: number; // 0-1 fraction
- resetsAt?: number; // Unix timestamp ms
-}
-
-export interface OpencodeUsageReport {
- fiveHour?: OpencodeUsageBucket;
- weekly?: OpencodeUsageBucket;
- monthly?: OpencodeUsageBucket;
-}
-
-function getWorkspaceId(keyId: string): string | undefined {
- // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws"
- const match = keyId.match(/opencode-(\d+)$/i);
- if (match) {
- const num = match[1];
- const specific = resolveApiKey(`opencode-ws${num}`);
- if (specific) return specific;
- }
- return resolveApiKey("opencode-ws") ?? undefined;
-}
-
-function parseOcDouble(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let start = idx + key.length + 1;
- while (start < html.length && html[start] === " ") start++;
- let end = start;
- while (end < html.length && html[end] !== "," && html[end] !== "}") {
- end++;
- }
- const val = parseFloat(html.slice(start, end));
- return Number.isNaN(val) ? 0 : val;
-}
-
-function parseOcInt(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let i = idx + key.length + 1;
- while (i < html.length && html[i] === " ") i++;
- return parseInt(html.slice(i), 10) || 0;
-}
-
-function parseOcBucket(
- html: string,
- bucketName: string,
-): { utilization: number; resetsAt: number } | null {
- const search = `${bucketName}:`;
- const pos = html.indexOf(search);
- if (pos === -1) return null;
-
- // Find the opening brace after the bucket name
- const brace = html.indexOf("{", pos);
- if (brace === -1) return null;
-
- const resetSecs = parseOcInt(html.slice(brace), "resetInSec");
- const usagePct = parseOcDouble(html.slice(brace), "usagePercent");
-
- const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction
- const resetsAt = Date.now() + resetSecs * 1000;
-
- return { utilization, resetsAt };
-}
-
-export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> {
- const cookie = resolveApiKey("opencode-cookie");
- const wsId = getWorkspaceId(keyId);
-
- if (!cookie || !wsId) {
- return null;
- }
-
- const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`;
-
- try {
- const response = await fetch(url, {
- headers: {
- accept: "text/html",
- cookie: `auth=${cookie}`,
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Auth redirect check
- if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) {
- return null;
- }
-
- // Find the lite.subscription data block.
- // HTML contains: lite.subscription.get[\"<wsId>\"]
- // We need literal backslashes; use \x5c (hex for backslash).
- const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`;
- const wsPos = html.indexOf(wsKey);
- if (wsPos === -1) return null;
-
- // Search for the resolved data starting from the ws key position
- const minePos = html.indexOf("mine:", wsPos);
- const slice = minePos !== -1 ? html.slice(minePos) : "";
-
- const fiveHour = parseOcBucket(slice, "rollingUsage");
- const weekly = parseOcBucket(slice, "weeklyUsage");
- const monthly = parseOcBucket(slice, "monthlyUsage");
-
- if (!fiveHour && !weekly && !monthly) return null;
-
- return {
- fiveHour: fiveHour ?? undefined,
- weekly: weekly ?? undefined,
- monthly: monthly ?? undefined,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
deleted file mode 100644
index 662b322..0000000
--- a/packages/core/src/credentials/store.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { existsSync, readFileSync } from "node:fs";
-import { getDatabase } from "../db/index.js";
-import type { ClaudeCredentials } from "./claude.js";
-
-export interface StoredCredential {
- keyId: string;
- provider: string;
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType: string | null;
- sourceFile: string | null;
- importedAt: number;
- updatedAt: number;
-}
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (creds.mcpOAuth && !creds.accessToken) return null;
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-/**
- * Import credentials from a file into the database for a specific key.
- * Reads the credential file, parses it, and upserts into the credentials table.
- */
-export function importCredentialsFromFile(
- keyId: string,
- provider: string,
- filePath: string,
-): { success: boolean; error?: string } {
- if (!existsSync(filePath)) {
- return { success: false, error: `File not found: ${filePath}` };
- }
-
- let raw: string;
- try {
- raw = readFileSync(filePath, "utf-8").trim();
- } catch (e) {
- return {
- success: false,
- error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`,
- };
- }
-
- if (!raw) {
- return { success: false, error: "File is empty" };
- }
-
- const creds = parseCredentialsFile(raw);
- if (!creds) {
- return { success: false, error: "Invalid credentials format" };
- }
-
- const db = getDatabase();
- const now = Date.now();
-
- db.query(
- `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at)
- VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- access_token = $accessToken,
- refresh_token = $refreshToken,
- expires_at = $expiresAt,
- subscription_type = $subscriptionType,
- source_file = $sourceFile,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $accessToken: creds.accessToken,
- $refreshToken: creds.refreshToken,
- $expiresAt: creds.expiresAt,
- $subscriptionType: creds.subscriptionType ?? null,
- $sourceFile: filePath,
- $now: now,
- });
-
- return { success: true };
-}
-
-/**
- * Get stored credentials for a specific key from the database.
- */
-export function getStoredCredentials(keyId: string): StoredCredential | null {
- const db = getDatabase();
- const row = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
- )
- .get({ $keyId: keyId }) as Record<string, unknown> | null;
-
- if (!row) return null;
-
- return {
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-/**
- * Update tokens in the database after a refresh.
- */
-export function updateStoredTokens(
- keyId: string,
- accessToken: string,
- refreshToken: string,
- expiresAt: number,
-): void {
- const db = getDatabase();
- db.query(
- `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`,
- ).run({
- $keyId: keyId,
- $accessToken: accessToken,
- $refreshToken: refreshToken,
- $expiresAt: expiresAt,
- $now: Date.now(),
- });
-}
-
-/**
- * Delete stored credentials for a key.
- */
-export function deleteStoredCredentials(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all keys that have imported credentials, with their status.
- */
-export function listStoredCredentials(): StoredCredential[] {
- const db = getDatabase();
- const rows = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
- )
- .all() as Array<Record<string, unknown>>;
-
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
deleted file mode 100644
index b434a47..0000000
--- a/packages/core/src/db/chunks.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { randomUUID } from "node:crypto";
-import {
- explodeTurn,
- explodeUserText,
- groupRowsToMessages,
- type MessageRow,
-} from "../chunks/transform.js";
-import type {
- ChunkData,
- ChunkRow,
- ChunkRowDraft,
- TextData,
- UsageData,
- UsageStats,
-} from "../types/index.js";
-import { getDatabase } from "./index.js";
-
-// Re-export the DB-free transforms so existing barrel consumers
-// (`@dispatch/core`) keep importing them from here. The browser frontend deep-
-// imports them directly from `chunks/transform.js` to avoid the DB dependency.
-export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow };
-
-// ─── Persistence ─────────────────────────────────────────────────
-
-function mapRow(row: Record<string, unknown>): ChunkRow {
- let data: ChunkData;
- try {
- data = JSON.parse(row.data_json as string) as ChunkData;
- } catch {
- data = { text: "" } as TextData;
- }
- return {
- id: row.id as string,
- tabId: row.tab_id as string,
- seq: row.seq as number,
- turnId: row.turn_id as string,
- step: row.step as number,
- role: row.role as ChunkRow["role"],
- type: row.type as ChunkRow["type"],
- data,
- createdAt: row.created_at as number,
- };
-}
-
-/**
- * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab
- * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order.
- */
-export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] {
- if (drafts.length === 0) return [];
- const db = getDatabase();
- const maxSeq = db
- .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId")
- .get({ $tabId: tabId }) as { max_seq: number };
- let seq = (maxSeq?.max_seq ?? -1) + 1;
- const now = Date.now();
- const insert = db.query(
- `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at)
- VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`,
- );
- const out: ChunkRow[] = [];
- // Wrap the whole batch in one transaction: a turn's chunks are persisted in
- // a single `appendChunks` call, so this is one fsync per turn instead of one
- // per row — the chosen low-IO write strategy for constrained backends.
- const insertAll = db.transaction(() => {
- for (const draft of drafts) {
- const id = randomUUID();
- insert.run({
- $id: id,
- $tabId: tabId,
- $seq: seq,
- $turnId: draft.turnId,
- $step: draft.step,
- $role: draft.role,
- $type: draft.type,
- $dataJson: JSON.stringify(draft.data),
- $now: now,
- });
- out.push({
- id,
- tabId,
- seq,
- turnId: draft.turnId,
- step: draft.step,
- role: draft.role,
- type: draft.type,
- data: draft.data,
- createdAt: now,
- });
- seq++;
- }
- });
- insertAll();
- return out;
-}
-
-/**
- * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old
- * message pagination but at chunk granularity:
- * - no options → all rows;
- * - `before` → rows with `seq < before`, most-recent-first then reversed;
- * - `limit` → most recent `limit` rows, reversed to ASC.
- */
-export function getChunksForTab(
- tabId: string,
- options?: { limit?: number; before?: number },
-): ChunkRow[] {
- const db = getDatabase();
- if (!options) {
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
- }
- const { limit, before } = options;
- if (before !== undefined) {
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC",
- )
- .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
-}
-
-/**
- * Derived, grouped view of a tab's full history as messages. Used to
- * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is
- * (re)constructed. Always reads the full log (grouping a partial window would
- * be lossy for the rebuild path).
- */
-export function getMessagesForTab(tabId: string): MessageRow[] {
- return groupRowsToMessages(getChunksForTab(tabId));
-}
-
-export function getTotalChunkCount(tabId: string): number {
- const db = getDatabase();
- const row = db
- .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'")
- .get({ $tabId: tabId }) as { count: number } | null;
- return row?.count ?? 0;
-}
-
-/**
- * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows.
- *
- * Usage rows are written as an invisible side channel (one row per `usage`
- * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`,
- * so this aggregate is the read path. Because it sums server-side over every
- * row, it stays complete even after the frontend evicts/pages out old turns
- * (eviction is in-memory only). The return shape is structurally identical to
- * the frontend `CacheStats`, so reload can seed it directly.
- *
- * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`
- * = SUM over all usage rows;
- * - `requests` = COUNT of usage rows;
- * - `last` = the highest-seq usage row's split (most recent request);
- * - `null` when the tab has no usage rows.
- *
- * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on
- * `json_extract` over the freeform `data_json`.
- */
-export function getUsageStatsForTab(tabId: string): UsageStats | null {
- const db = getDatabase();
- const rows = db
- .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<{ data_json: string }>;
- if (rows.length === 0) return null;
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
- let last: UsageData | null = null;
- for (const row of rows) {
- let u: UsageData;
- try {
- u = JSON.parse(row.data_json) as UsageData;
- } catch {
- continue;
- }
- inputTokens += u.inputTokens ?? 0;
- outputTokens += u.outputTokens ?? 0;
- cacheReadTokens += u.cacheReadTokens ?? 0;
- cacheWriteTokens += u.cacheWriteTokens ?? 0;
- last = {
- inputTokens: u.inputTokens ?? 0,
- outputTokens: u.outputTokens ?? 0,
- cacheReadTokens: u.cacheReadTokens ?? 0,
- cacheWriteTokens: u.cacheWriteTokens ?? 0,
- };
- }
-
- return {
- inputTokens,
- outputTokens,
- cacheReadTokens,
- cacheWriteTokens,
- requests: rows.length,
- last,
- };
-}
-
-export function clearChunksForTab(tabId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
-}
-
-/**
- * Relocate every chunk row from one tab to another (compaction backup path).
- *
- * Used by conversation compaction to move the FULL pre-compaction history off
- * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
- * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
- * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
- * the destination since it starts empty), as are turn ids, so the relocated
- * history groups identically under its new tab. Returns the number of rows
- * moved.
- */
-export function rekeyChunks(fromTabId: string, toTabId: string): number {
- const db = getDatabase();
- const result = db
- .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
- .run({ $from: fromTabId, $to: toTabId });
- return Number(result.changes ?? 0);
-}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
deleted file mode 100644
index 93ec1f9..0000000
--- a/packages/core/src/db/index.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { Database } from "bun:sqlite";
-import { existsSync, mkdirSync } from "node:fs";
-import { homedir } from "node:os";
-import { isAbsolute, join } from "node:path";
-
-/**
- * Returns the directory for persistent Dispatch data, following XDG Base
- * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
- * `~/.local/share/dispatch`).
- */
-function getDataDir(): string {
- const xdg = process.env.XDG_DATA_HOME;
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
- return join(base, "dispatch");
-}
-
-let _db: Database | null = null;
-
-/**
- * Get (or create) the singleton SQLite database.
- *
- * - Creates the data directory if it doesn't exist.
- * - Creates `dispatch.db` if it doesn't exist.
- * - Enables WAL journal mode for concurrent read performance.
- */
-export function getDatabase(): Database {
- if (_db) return _db;
-
- const dir = getDataDir();
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true });
- }
-
- const dbPath = join(dir, "dispatch.db");
- _db = new Database(dbPath, { create: true });
-
- // WAL mode: better concurrent read performance, safe for single-writer
- _db.run("PRAGMA journal_mode = WAL;");
- // Recommended for WAL: normal synchronous is safe and faster
- _db.run("PRAGMA synchronous = NORMAL;");
- // Enable foreign keys
- _db.run("PRAGMA foreign_keys = ON;");
-
- // Create tables
- _db.run(`CREATE TABLE IF NOT EXISTS credentials (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- access_token TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- expires_at INTEGER NOT NULL,
- subscription_type TEXT,
- source_file TEXT,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
- // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
- // single-row-per-hour schema: detect by absence of the `slot_minute` column
- // and drop the old table. Other tables (credentials, api_keys, usage_cache,
- // settings, tabs, chunks) are NOT touched.
- const legacyWakeSchema = (() => {
- try {
- const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
- if (cols.length === 0) return false; // table doesn't exist yet
- return !cols.some((c) => c.name === "slot_minute");
- } catch {
- return false;
- }
- })();
- if (legacyWakeSchema) {
- _db.run("DROP TABLE IF EXISTS wake_schedule");
- }
- _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
- slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)),
- next_wake_at INTEGER NOT NULL,
- PRIMARY KEY (hour, slot_minute)
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- cached_at INTEGER NOT NULL,
- report_json TEXT NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- api_key TEXT NOT NULL,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS tabs (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- key_id TEXT,
- model_id TEXT,
- parent_tab_id TEXT,
- status TEXT NOT NULL DEFAULT 'idle',
- is_open INTEGER NOT NULL DEFAULT 1,
- position INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- try {
- _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT");
- } catch {
- // Column already exists — ignore
- }
-
- // ─── Append-only chunk log (replaces the old `messages` blob table) ──
- //
- // A conversation is stored as a flat, append-only stream of chunk rows
- // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED
- // groupings (see db/chunks.ts), never stored containers. This is what
- // powers per-chunk frontend pagination AND the stable per-step wire
- // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md).
- //
- // role : 'user' | 'assistant' | 'tool' | 'system'
- // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system'
- // step : LLM round-trip index within a turn (user/system rows = 0)
- // data_json: the type-specific payload (see ChunkData in types)
- _db.run(`CREATE TABLE IF NOT EXISTS chunks (
- id TEXT PRIMARY KEY,
- tab_id TEXT NOT NULL,
- seq INTEGER NOT NULL,
- turn_id TEXT NOT NULL,
- step INTEGER NOT NULL DEFAULT 0,
- role TEXT NOT NULL,
- type TEXT NOT NULL,
- data_json TEXT NOT NULL,
- created_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`);
-
- // One-shot migration off the legacy `messages` blob model. Beta software,
- // no backward compatibility: the old chat history is destroyed (tabs +
- // messages), while settings / credentials / api_keys / usage_cache /
- // wake_schedule are preserved. Detect the old schema by the presence of
- // the `messages` table; once dropped, this branch never runs again.
- const hasLegacyMessages = _db
- .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
- .get() as { name: string } | null;
- if (hasLegacyMessages) {
- _db.run("DROP TABLE IF EXISTS messages");
- // Clear conversation containers too (fresh slate for the new model).
- _db.run("DELETE FROM tabs");
- _db.run("DELETE FROM chunks");
- }
-
- _db.run(`CREATE TABLE IF NOT EXISTS settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- )`);
-
- return _db;
-}
-
-/** Close the database connection (e.g. on shutdown). */
-export function closeDatabase(): void {
- if (_db) {
- _db.close();
- _db = null;
- }
-}
-
-/** Returns the path where the database file lives (or will live). */
-export function getDatabasePath(): string {
- if (_db) return _db.filename;
- const dir = getDataDir();
- return join(dir, "dispatch.db");
-}
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
deleted file mode 100644
index f9d152e..0000000
--- a/packages/core/src/db/settings.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export function getSetting(key: string): string | null {
- const db = getDatabase();
- const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as {
- value: string;
- } | null;
- return row?.value ?? null;
-}
-
-export function setSetting(key: string, value: string): void {
- const db = getDatabase();
- db.query(
- `INSERT INTO settings (key, value) VALUES ($key, $value)
- ON CONFLICT(key) DO UPDATE SET value = $value`,
- ).run({ $key: key, $value: value });
-}
-
-export function deleteSetting(key: string): void {
- const db = getDatabase();
- db.query("DELETE FROM settings WHERE key = $key").run({ $key: key });
-}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
deleted file mode 100644
index f719a01..0000000
--- a/packages/core/src/db/tabs.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export interface TabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-
-function rowToTab(row: Record<string, unknown>): TabRow {
- return {
- id: row.id as string,
- title: row.title as string,
- keyId: row.key_id as string | null,
- modelId: row.model_id as string | null,
- parentTabId: (row.parent_tab_id as string) ?? null,
- status: row.status as string,
- isOpen: (row.is_open as number) === 1,
- position: row.position as number,
- createdAt: row.created_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-export function createTab(
- id: string,
- title: string,
- options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null },
-): TabRow {
- const db = getDatabase();
- const now = Date.now();
- const maxPos = db
- .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1")
- .get() as { max_pos: number };
- const position = (maxPos?.max_pos ?? -1) + 1;
- const keyId = options?.keyId ?? null;
- const modelId = options?.modelId ?? null;
- const parentTabId = options?.parentTabId ?? null;
- db.query(
- `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`,
- ).run({
- $id: id,
- $title: title,
- $keyId: keyId,
- $modelId: modelId,
- $parentTabId: parentTabId,
- $position: position,
- $now: now,
- });
- return {
- id,
- title,
- keyId,
- modelId,
- parentTabId,
- status: "idle",
- isOpen: true,
- position,
- createdAt: now,
- updatedAt: now,
- };
-}
-
-export function getTab(id: string): TabRow | null {
- const db = getDatabase();
- const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record<
- string,
- unknown
- > | null;
- return row ? rowToTab(row) : null;
-}
-
-export function listOpenTabs(): TabRow[] {
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC")
- .all() as Array<Record<string, unknown>>;
- return rows.map(rowToTab);
-}
-
-export function updateTabTitle(id: string, title: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({
- $id: id,
- $title: title,
- $now: Date.now(),
- });
-}
-
-export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void {
- const db = getDatabase();
- db.query(
- "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id",
- ).run({
- $id: id,
- $keyId: keyId,
- $modelId: modelId,
- $now: Date.now(),
- });
-}
-
-export function updateTabStatus(id: string, status: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({
- $id: id,
- $status: status,
- $now: Date.now(),
- });
-}
-
-export function updateTabPositions(idsInOrder: string[]): void {
- const db = getDatabase();
- const now = Date.now();
- const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id");
- // One transaction so a reorder is atomic: either every tab lands at its new
- // slot or none does, never a half-applied ordering.
- const applyAll = db.transaction(() => {
- idsInOrder.forEach((id, index) => {
- update.run({ $id: id, $position: index, $now: now });
- });
- });
- applyAll();
-}
-
-export function archiveTab(id: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({
- $id: id,
- $now: Date.now(),
- });
-}
-
-/**
- * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first
- * order (children before their parent). Archived descendants
- * (`is_open = 0`) and their sub-trees are skipped — closing a parent
- * shouldn't drag archived branches back into view.
- *
- * The starting `rootId` is always included in the result, even if no row
- * with that id exists in the `tabs` table (graceful handling for stale
- * references).
- *
- * Order matters for the cascade-close path: callers archive descendants
- * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on
- * partially-deleted parents.
- *
- * Cycle-safe: a `visited` set guards against accidental `parent_tab_id`
- * loops that would otherwise spin forever.
- */
-export function getDescendantIds(rootId: string): string[] {
- const db = getDatabase();
- const visited = new Set<string>();
- const order: string[] = [];
- const queue: string[] = [rootId];
- while (queue.length > 0) {
- const id = queue.shift() as string;
- if (visited.has(id)) continue;
- visited.add(id);
- order.push(id);
- const children = db
- .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1")
- .all({ $id: id }) as Array<{ id: string }>;
- for (const child of children) {
- if (!visited.has(child.id)) queue.push(child.id);
- }
- }
- return order.reverse();
-}
-
-/**
- * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`.
- * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything
- * shorter is rejected as too broad — an agent must echo at least the 4-char
- * handle shown in the UI.
- */
-export const MIN_TAB_PREFIX_LENGTH = 4;
-
-/**
- * Outcome of resolving a short tab handle (a git-style prefix of a tab's
- * UUID) back to a concrete open tab.
- *
- * - `ok` — exactly one open tab matched; `tab` is it.
- * - `none` — no open tab matched (bad/stale handle, or too-short prefix).
- * - `ambiguous` — more than one open tab shares the prefix; `matches` lists
- * them so the caller can ask for one more character (the same
- * UX as `git checkout <ambiguous-sha>`).
- */
-export type ResolveTabPrefixResult =
- | { status: "ok"; tab: TabRow }
- | { status: "none" }
- | { status: "ambiguous"; matches: TabRow[] };
-
-/**
- * Resolve a short tab handle to a single OPEN tab by prefix match — the
- * git-short-hash model. The handle is NEVER stored: it is always derived from
- * (and matched against) the canonical lowercase UUID in `tabs.id`.
- *
- * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and
- * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We
- * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also
- * ASCII-case-insensitive by default) and strip everything outside the UUID
- * alphabet `[0-9a-f-]` so no wildcard can survive into the query.
- *
- * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns
- * `none` rather than matching a large swath of tabs.
- *
- * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix
- * must not cause phantom ambiguity or resolve to a dead conversation.
- */
-export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < MIN_TAB_PREFIX_LENGTH) {
- return { status: "none" };
- }
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC")
- .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>;
- if (rows.length === 0) return { status: "none" };
- if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) };
- return { status: "ambiguous", matches: rows.map(rowToTab) };
-}
-
-/**
- * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars)
- * that identifies `tabId` among the currently OPEN tabs — the backend twin of
- * the frontend's display helper. Used when a tool needs to echo a tab's own
- * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a
- * value from the wire.
- *
- * Returns the full id if no shorter unique prefix exists (degenerate — only if
- * two open tabs share an entire id, which UUID uniqueness precludes).
- */
-export function shortestUniquePrefix(tabId: string): string {
- const db = getDatabase();
- const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>;
- const others = rows.map((r) => r.id).filter((id) => id !== tabId);
- for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
deleted file mode 100644
index e951d08..0000000
--- a/packages/core/src/index.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-// @dispatch/core — Agent runtime, LLM integration, tools
-
-// Agent & LLM
-export { Agent } from "./agent/agent.js";
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./agents/index.js";
-// Chunk helpers
-export {
- appendEventToChunks,
- applySystemEvent,
- type IdentifiedMessage,
- type SystemEventLike,
-} from "./chunks/append.js";
-// Compaction
-export {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- type CompactionRequest,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- type HeadTailSelection,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
- TOOL_OUTPUT_MAX_CHARS,
-} from "./compaction/index.js";
-// Config
-export {
- configToRuleset,
- createConfigWatcher,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
- validateConfig,
- watchDirConfig,
-} from "./config/index.js";
-// Credentials
-export * from "./credentials/index.js";
-export {
- appendChunks,
- clearChunksForTab,
- explodeTurn,
- explodeUserText,
- getChunksForTab,
- getMessagesForTab,
- getTotalChunkCount,
- getUsageStatsForTab,
- groupRowsToMessages,
- type MessageRow,
- rekeyChunks,
-} from "./db/chunks.js";
-// Database
-export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js";
-export { deleteSetting, getSetting, setSetting } from "./db/settings.js";
-// Tabs & Messages
-export {
- archiveTab,
- createTab,
- getTab,
- listOpenTabs,
- MIN_TAB_PREFIX_LENGTH,
- type ResolveTabPrefixResult,
- resolveTabPrefix,
- shortestUniquePrefix,
- type TabRow,
- updateTabModel,
- updateTabPositions,
- updateTabStatus,
- updateTabTitle,
-} from "./db/tabs.js";
-export {
- debugVerbosity,
- isDebugEnabled,
- logAgentLoop,
- logStepLifecycle,
- logStreamEvent,
-} from "./llm/debug-logger.js";
-export { createProvider } from "./llm/provider.js";
-// LSP (Language Server Protocol)
-export {
- createLspClient,
- type Diagnostic as LspDiagnostic,
- type LspClient,
- LspManager,
- type LspServerHandle,
- pretty as prettyDiagnostic,
- type ResolvedLspServer,
- report as reportDiagnostics,
- resolveServersFromConfig,
-} from "./lsp/index.js";
-// Models
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- getModelsCatalog,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- type ModelInputCapabilities,
- ModelRegistry,
- resolveContextLimit,
- resolveModelCapabilities,
- validateUserContent,
-} from "./models/index.js";
-// Notifications (ntfy.sh)
-export * from "./notifications/index.js";
-export * from "./permission/index.js";
-// Skills
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- parseSkillFile,
- resolveSkillsForAgent,
-} from "./skills/index.js";
-export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
-// Tools
-export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js";
-export { createListFilesTool } from "./tools/list-files.js";
-export { createLspTool, type LspToolContext } from "./tools/lsp.js";
-export { createReadFileTool } from "./tools/read-file.js";
-export { createReadFileSliceTool } from "./tools/read-file-slice.js";
-export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js";
-export { createToolRegistry } from "./tools/registry.js";
-export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
-export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
-export { createSearchCodeTool } from "./tools/search-code.js";
-export {
- createSendToTabTool,
- type ResolvedTabRef,
- type SendToTabCallbacks,
- type TabResolution,
-} from "./tools/send-to-tab.js";
-export { analyzeCommand } from "./tools/shell-analyze.js";
-export {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
- toAvailableSubagents,
- toAvailableUserAgents,
-} from "./tools/summon.js";
-export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js";
-export { clearSpillForTab } from "./tools/truncate.js";
-export { createWebSearchTool } from "./tools/web-search.js";
-export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js";
-export {
- BackgroundTranscriptStore,
- createYoutubeTranscribeTool,
-} from "./tools/youtube-transcribe.js";
-// Types & Permissions
-export * from "./types/index.js";
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts
deleted file mode 100644
index 467a307..0000000
--- a/packages/core/src/llm/anthropic-oauth-transform.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow.
- *
- * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code-
- * billed requests. A genuine Claude Code request looks like:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.",
- * cache_control: { type: "ephemeral" } }, // identity, separate block
- * ]
- * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ]
- *
- * i.e. ONLY the billing header and the verbatim identity string may live in
- * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions)
- * MUST be relocated into the first user message. When third-party content stays
- * in `system[]` next to the identity, Anthropic bills it as premium "extra
- * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope —
- * producing the 0% cache hit rate and ballooning cost we were seeing.
- *
- * Dispatch builds its system prompt as ONE concatenated block
- * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes
- * that into a single `system[]` text entry. This transform runs at fetch time
- * on the already-serialized JSON body and reshapes it into the structure above.
- *
- * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`),
- * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the
- * agent, so this transform leaves tools and messages (other than the relocation)
- * untouched. It is defensive: any parse/shape surprise returns the body
- * unchanged so a transform bug can never break a request.
- */
-
-const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING_PREFIX = "x-anthropic-billing-header";
-
-type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record<
- string,
- unknown
->;
-
-interface AnthropicRequestBody {
- system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- messages?: Array<{
- role?: string;
- content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- }>;
- [key: string]: unknown;
-}
-
-/**
- * Restructure a serialized Anthropic request body string for the Claude Code
- * OAuth flow. Returns the (possibly rewritten) body, or the original input
- * unchanged when it isn't a JSON string we recognize.
- */
-export function transformClaudeOAuthBody(
- body: BodyInit | null | undefined,
-): BodyInit | null | undefined {
- if (typeof body !== "string") return body;
- let parsed: AnthropicRequestBody;
- try {
- parsed = JSON.parse(body) as AnthropicRequestBody;
- } catch {
- return body;
- }
- if (!parsed || typeof parsed !== "object") return body;
- try {
- const changed = restructureSystem(parsed);
- return changed ? JSON.stringify(parsed) : body;
- } catch {
- // Never let a transform bug break a real request.
- return body;
- }
-}
-
-/**
- * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if
- * anything changed (so the caller knows to re-serialize).
- */
-function restructureSystem(parsed: AnthropicRequestBody): boolean {
- const raw = parsed.system;
- let entries: SystemBlock[];
- if (typeof raw === "string") {
- entries = [{ type: "text", text: raw }];
- } else if (Array.isArray(raw)) {
- entries = raw.map((e) =>
- typeof e === "string"
- ? { type: "text", text: e }
- : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock),
- );
- } else {
- return false; // no system field — nothing to do
- }
-
- const combined = entries.map((e) => e.text).join("\n\n");
-
- // Only act on Claude Code shaped requests (must carry the identity string).
- if (!combined.includes(SYSTEM_IDENTITY)) return false;
-
- const hadCacheControl = entries.some((e) => e.cache_control != null);
-
- // Peel the billing-header line out (it is a single line with no newlines).
- const lines = combined.split("\n");
- const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX));
- let billingLine: string | null = null;
- if (billingIdx !== -1) {
- billingLine = lines[billingIdx] ?? null;
- lines.splice(billingIdx, 1);
- }
- const afterBilling = lines.join("\n").replace(/^\n+/, "");
-
- // Split the identity prefix from the rest (Dispatch's real system prompt).
- let rest = "";
- if (afterBilling.startsWith(SYSTEM_IDENTITY)) {
- rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, "");
- } else {
- // Identity is present but not at the front (unexpected) — still isolate it.
- rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, "");
- }
-
- // Rebuild system[]: billing (no cache_control) then identity (cached).
- const newSystem: SystemBlock[] = [];
- if (billingLine) newSystem.push({ type: "text", text: billingLine });
- const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY };
- if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" };
- newSystem.push(identityBlock);
-
- // Relocate the third-party system prompt into the first user message.
- if (rest.length > 0) {
- const firstUser = Array.isArray(parsed.messages)
- ? parsed.messages.find((m) => m.role === "user")
- : undefined;
- if (firstUser) {
- if (typeof firstUser.content === "string") {
- firstUser.content = `${rest}\n\n${firstUser.content}`;
- } else if (Array.isArray(firstUser.content)) {
- firstUser.content.unshift({ type: "text", text: rest });
- } else {
- firstUser.content = rest;
- }
- } else {
- // No user message to host it — keep it as a (cached) system block so
- // the request still carries the instructions.
- const restBlock: SystemBlock = { type: "text", text: rest };
- if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" };
- newSystem.push(restBlock);
- }
- }
-
- parsed.system = newSystem;
- return true;
-}
-
-export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX };
diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts
deleted file mode 100644
index 072a7a1..0000000
--- a/packages/core/src/llm/debug-logger.ts
+++ /dev/null
@@ -1,448 +0,0 @@
-/**
- * Debug logger for LLM API requests and responses.
- *
- * Enable via environment variable: DISPATCH_DEBUG_LLM=1
- *
- * Logs every outgoing request body and incoming response body to timestamped
- * files under `DISPATCH_DEBUG_LLM_DIR` (default: /tmp/dispatch/llm-debug/).
- *
- * Each request/response pair shares a sequence number for easy correlation.
- * Files are named: `{seq}_{timestamp}_{direction}_{model}.json`
- *
- * For streaming responses (SSE), the raw chunks are captured as they arrive
- * and written out as a JSON array when the stream completes.
- *
- * Additional logging layers:
- * - Stream events: every AI SDK stream event (text-delta, tool-call, etc.)
- * - Step lifecycle: step start/end, tool execution timing
- * - Agent loop: step count, break conditions, tool call counts
- *
- * All output goes to stderr (console.error) for stream event logs, and to
- * files for request/response bodies (too large for console).
- */
-
-import { mkdirSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-
-const ENABLED = !!process.env.DISPATCH_DEBUG_LLM;
-const LOG_DIR = process.env.DISPATCH_DEBUG_LLM_DIR || "/tmp/dispatch/llm-debug";
-let seq = 0;
-
-/** Verbosity levels:
- * 1 = requests/responses only (files)
- * 2 = + stream events to stderr
- * 3 = + step lifecycle + agent loop details to stderr
- */
-const VERBOSITY = Math.max(1, Number(process.env.DISPATCH_DEBUG_LLM_VERBOSITY) || 1);
-
-function ensureDir(): void {
- try {
- mkdirSync(LOG_DIR, { recursive: true });
- } catch {
- // best effort
- }
-}
-
-function ts(): string {
- return new Date().toISOString().replace(/[:.]/g, "-");
-}
-
-function sanitizeModel(model: string): string {
- return model.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 60);
-}
-
-function sanitizeTab(tabId?: string): string {
- if (!tabId) return "notab";
- return tabId.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 40);
-}
-
-export function isDebugEnabled(): boolean {
- return ENABLED;
-}
-
-export function debugVerbosity(): number {
- return ENABLED ? VERBOSITY : 0;
-}
-
-/**
- * Allocate a fresh sequence number. Used by the fetch wrapper so the request
- * and the response share the same id without needing a separate `logRequest`
- * call from the agent loop (which doesn't see the actual HTTP body anyway).
- */
-export function nextDebugSeq(): number {
- return ++seq;
-}
-
-/**
- * Log an outgoing request to the AI model endpoint.
- * Returns a request ID for correlating with the response.
- */
-export function logRequest(data: {
- model: string;
- url?: string;
- method?: string;
- headers?: Record<string, string>;
- body: unknown;
- tabId?: string;
- step?: number;
- provider?: string;
-}): number {
- if (!ENABLED) return -1;
- ensureDir();
- const id = ++seq;
- const filename = `${String(id).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_REQ_${sanitizeModel(data.model)}.json`;
- const payload = {
- _debug: {
- seq: id,
- direction: "request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- step: data.step,
- provider: data.provider,
- },
- url: data.url,
- method: data.method ?? "POST",
- headers: data.headers,
- body: data.body,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write request log: ${err}`);
- }
- console.error(
- `[dispatch-debug] REQ #${id} → ${data.model} (step=${data.step ?? "?"}, tab=${data.tabId ?? "?"})`,
- );
- return id;
-}
-
-/**
- * Log the raw fetch-level request (the actual HTTP body sent to the provider).
- * Called from the instrumented fetch wrapper.
- */
-export function logRawFetchRequest(data: {
- requestId: number;
- url: string;
- method: string;
- headers: Record<string, string>;
- body: string | null;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_REQ.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- },
- url: data.url,
- method: data.method,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw request log: ${err}`);
- }
-}
-
-/**
- * Log the raw fetch-level response (HTTP status, headers, body).
- */
-export function logRawFetchResponse(data: {
- requestId: number;
- url: string;
- status: number;
- statusText: string;
- headers: Record<string, string>;
- body: string | null;
- isStreaming: boolean;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_RES_${data.status}.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-response",
- timestamp: new Date().toISOString(),
- isStreaming: data.isStreaming,
- tabId: data.tabId,
- },
- url: data.url,
- status: data.status,
- statusText: data.statusText,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw response log: ${err}`);
- }
-}
-
-/**
- * Accumulator for streaming response chunks. Call `addChunk()` as SSE events
- * arrive, then `flush()` when the stream ends to write them all to disk.
- */
-export class StreamResponseLogger {
- private requestId: number;
- private model: string;
- private tabId?: string;
- private chunks: Array<{ timestamp: string; data: string }> = [];
- private startTime: number;
-
- constructor(requestId: number, model: string, tabId?: string) {
- this.requestId = requestId;
- this.model = model;
- this.tabId = tabId;
- this.startTime = Date.now();
- }
-
- addChunk(rawLine: string): void {
- if (!ENABLED) return;
- this.chunks.push({
- timestamp: new Date().toISOString(),
- data: rawLine,
- });
- }
-
- flush(meta?: { finishReason?: string; error?: string }): void {
- if (!ENABLED) return;
- ensureDir();
- const elapsed = Date.now() - this.startTime;
- const filename = `${String(this.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(this.tabId)}_STREAM_RES_${sanitizeModel(this.model)}.json`;
- const payload = {
- _debug: {
- seq: this.requestId,
- direction: "stream-response",
- timestamp: new Date().toISOString(),
- tabId: this.tabId,
- model: this.model,
- elapsedMs: elapsed,
- chunkCount: this.chunks.length,
- ...meta,
- },
- chunks: this.chunks,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write stream response log: ${err}`);
- }
- console.error(
- `[dispatch-debug] STREAM #${this.requestId} complete: ${this.chunks.length} chunks in ${elapsed}ms (${this.model})`,
- );
- }
-}
-
-/**
- * Log an AI SDK stream event (text-delta, tool-call, finish-step, etc.).
- * Only logs at verbosity >= 2.
- */
-export function logStreamEvent(data: {
- requestId: number;
- step: number;
- eventType: string;
- detail?: unknown;
- tabId?: string;
-}): void {
- if (!ENABLED || VERBOSITY < 2) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STREAM_EVENT #${data.requestId} step=${data.step} ${data.eventType}${detail}`,
- );
-}
-
-/**
- * Log step lifecycle events (step start, tool execution, step end).
- * Only logs at verbosity >= 3.
- */
-export function logStepLifecycle(data: {
- tabId?: string;
- step: number;
- event: string;
- detail?: unknown;
-}): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STEP tab=${data.tabId ?? "?"} step=${data.step} ${data.event}${detail}`,
- );
-}
-
-/**
- * Log agent loop-level events (loop start, break conditions, etc.).
- * Only logs at verbosity >= 3.
- */
-export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`);
-}
-
-/**
- * Wrap a fetch function so every request/response pair is logged to disk
- * under `DISPATCH_DEBUG_LLM_DIR` when `DISPATCH_DEBUG_LLM` is set. When
- * disabled, returns the input fetch unchanged (zero overhead).
- *
- * Critical implementation note — SSE bodies: the AI SDK consumes
- * `response.body` as a `ReadableStream`. Reading it from anywhere else
- * (e.g. calling `.text()`) drains the stream and the SDK gets an empty
- * body. We therefore `response.clone()` the response and tee its body via
- * a `TransformStream` so each SSE line is forwarded to the SDK AND
- * captured into a `StreamResponseLogger`. The clone returns its own
- * Response object whose body the SDK reads normally.
- *
- * For non-streaming responses (`content-type` is not `text/event-stream`)
- * we just clone and read once via `.text()` — simpler and safe because
- * non-streaming bodies are bounded.
- */
-export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>(
- baseFetch: F,
- opts: { tabId?: string; modelHint?: string },
-): F {
- if (!ENABLED) return baseFetch;
- const wrapped = async (...args: Parameters<F>) => {
- const requestId = ++seq;
- const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined];
- const url =
- typeof input === "string"
- ? input
- : input instanceof URL
- ? input.toString()
- : (input as Request).url;
- const method =
- init?.method ??
- (typeof input === "object" && "method" in input ? (input as Request).method : "POST");
-
- // Snapshot headers as a plain object for logging.
- const headerObj: Record<string, string> = {};
- try {
- const h = new Headers(init?.headers);
- h.forEach((v, k) => {
- // Redact bearer / api-key headers — useful in shared logs.
- if (/^(authorization|x-api-key|cookie)$/i.test(k)) {
- headerObj[k] = "<redacted>";
- } else {
- headerObj[k] = v;
- }
- });
- } catch {
- // best effort
- }
-
- // Capture request body. Most providers send a JSON string here; if it's
- // a stream/blob/etc. we skip body logging (rare in our codebase).
- let bodyStr: string | null = null;
- if (typeof init?.body === "string") {
- bodyStr = init.body;
- } else if (init?.body instanceof Uint8Array) {
- bodyStr = new TextDecoder().decode(init.body);
- }
-
- logRawFetchRequest({
- requestId,
- url,
- method,
- headers: headerObj,
- body: bodyStr,
- tabId: opts.tabId,
- });
-
- const response = await (baseFetch as unknown as typeof fetch)(input, init);
-
- const respHeaders: Record<string, string> = {};
- response.headers.forEach((v, k) => {
- respHeaders[k] = v;
- });
- const contentType = response.headers.get("content-type") ?? "";
- const isStreaming = contentType.includes("text/event-stream");
-
- if (!isStreaming) {
- // Clone so we don't drain the SDK's copy. Bounded body — safe to read.
- try {
- const cloned = response.clone();
- const text = await cloned.text();
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: text,
- isStreaming: false,
- tabId: opts.tabId,
- });
- } catch (err) {
- console.error(`[dispatch-debug] Failed to clone non-stream response: ${err}`);
- }
- return response;
- }
-
- // Streaming path: write a header file with status + headers immediately
- // (the body file comes later via StreamResponseLogger.flush).
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: null,
- isStreaming: true,
- tabId: opts.tabId,
- });
-
- // Tee the body through a TransformStream so each SSE chunk is captured
- // without consuming the stream the SDK needs.
- const streamLogger = new StreamResponseLogger(
- requestId,
- opts.modelHint ?? "stream",
- opts.tabId,
- );
- const decoder = new TextDecoder();
- const tee = new TransformStream<Uint8Array, Uint8Array>({
- transform(chunk, controller) {
- try {
- streamLogger.addChunk(decoder.decode(chunk, { stream: true }));
- } catch {
- // never let logging break the stream
- }
- controller.enqueue(chunk);
- },
- flush() {
- try {
- streamLogger.flush();
- } catch {
- // best effort
- }
- },
- });
-
- // `response.body` is `ReadableStream<Uint8Array> | null`. If null (no
- // body), there's nothing to tee — return as-is.
- if (!response.body) return response;
- const teed = response.body.pipeThrough(tee);
- return new Response(teed, {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- });
- };
- return wrapped as unknown as F;
-}
-
-function tryParseJson(s: string | null): unknown {
- if (s === null) return null;
- try {
- return JSON.parse(s);
- } catch {
- return s;
- }
-}
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
deleted file mode 100644
index ca734f9..0000000
--- a/packages/core/src/llm/provider.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { createAnthropic } from "@ai-sdk/anthropic";
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
-import type { LanguageModelV3 } from "@ai-sdk/provider";
-import type { FetchFunction } from "@ai-sdk/provider-utils";
-import { getAnthropicBetas } from "../credentials/anthropic-betas.js";
-import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js";
-import { wrapFetchWithLogging } from "./debug-logger.js";
-
-export interface ProviderConfig {
- apiKey: string;
- baseURL: string;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /** Optional tab id for labelling debug logs. No effect when
- * `DISPATCH_DEBUG_LLM` is unset. */
- tabId?: string;
-}
-
-const MCP_PREFIX = "mcp_";
-
-function prefixToolName(name: string): string {
- return `${MCP_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
-}
-
-function unprefixToolName(name: string): string {
- if (name.startsWith(MCP_PREFIX)) {
- const rest = name.slice(MCP_PREFIX.length);
- return `${rest.charAt(0).toLowerCase()}${rest.slice(1)}`;
- }
- return name;
-}
-
-// Explicit factory return type so the inferred type doesn't leak references
-// into transitive `@ai-sdk/provider` paths (which would trip TS2742).
-// `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return
-// `LanguageModelV3`-spec models; `wrapLanguageModel` likewise.
-export type ModelFactory = (modelId: string) => LanguageModelV3;
-
-export function createProvider(config: ProviderConfig): ModelFactory {
- if (config.provider === "anthropic") {
- return createClaudeOAuthProvider(config);
- }
-
- if (config.provider === "opencode-anthropic") {
- return createApiKeyAnthropicProvider(config);
- }
-
- // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM,
- // Kimi, MiniMax, etc.).
- //
- // `@ai-sdk/[email protected]` handles reasoning round-tripping
- // natively: it reads `{ type: "reasoning", text }` parts from each
- // assistant message's content and emits them as `reasoning_content`
- // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs
- // lines 215-216 and 245). Our `toModelMessages` in agent.ts already
- // emits reasoning parts from `ThinkingChunk`s, so no middleware is
- // needed.
- //
- // (The v4-era `normalizeMessages` middleware that lived here was
- // actively breaking DeepSeek: it stripped reasoning parts from
- // content AND wrote them under `providerMetadata` — wrong key in v3
- // prompts, which use `providerOptions`. The result was that
- // reasoning_content never reached the wire and DeepSeek rejected the
- // follow-up turn with "must be passed back".)
- //
- // Debug logging: when DISPATCH_DEBUG_LLM is set, wrap the base fetch
- // so every wire request/response (including SSE chunks) is captured.
- // When disabled, `wrapFetchWithLogging` returns the input unchanged
- // (zero overhead).
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-zen",
- }) as unknown as FetchFunction;
-
- const provider = createOpenAICompatible({
- name: "opencode-zen",
- apiKey: config.apiKey,
- baseURL: config.baseURL,
- fetch: loggingFetch,
- });
-
- return (modelId: string) => provider(modelId);
-}
-
-/**
- * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys
- * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
- * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
- * request headers so the request bills against the user's Claude subscription.
- *
- * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits
- * an `anthropic-beta` header for betas it auto-derives from tool definitions
- * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching
- * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API
- * silently ignores every `cache_control` breakpoint we attach to messages,
- * giving a 0% cache hit rate and a massive token burn (see notes/claude-report.md).
- * The SDK folds any `anthropic-beta` it finds on the provider's config headers
- * back into its own beta set (via `getBetasFromHeaders`), so the values here
- * are merged — not overwritten — with any tool-derived betas.
- */
-function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
- // Stable per-provider session id — mirrors the Claude Code CLI, which sends
- // the same `X-Claude-Code-Session-Id` across a session's requests.
- const sessionId = randomUUID();
-
- // Wrap the base fetch FIRST so the logging wrapper sees the genuine
- // outgoing HTTP body — i.e. AFTER the OAuth body transform and AFTER the
- // Claude-Code session headers have been stamped on. Order matters: if we
- // wrapped the inner `baseFetch` instead, the logs would show the pre-
- // transform body and miss the session headers, defeating the point of
- // capturing the wire for cache/billing debugging.
- const baseFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "claude-oauth",
- });
-
- // Custom fetch that (1) restructures the request body into the genuine
- // Claude Code system layout — required for Anthropic to bill correctly and
- // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and
- // (2) stamps the Claude Code session/request id headers the real CLI sends.
- // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose
- // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy.
- const oauthFetch = (async (
- input: Parameters<FetchFunction>[0],
- init?: Parameters<FetchFunction>[1],
- ) => {
- const nextInit: RequestInit = { ...init };
- if (init?.body != null) {
- nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body;
- }
- const headers = new Headers(init?.headers);
- headers.set("X-Claude-Code-Session-Id", sessionId);
- if (!headers.has("x-client-request-id")) {
- headers.set("x-client-request-id", randomUUID());
- }
- nextInit.headers = headers;
- return baseFetch(input, nextInit);
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- baseURL: config.baseURL || "https://api.anthropic.com/v1",
- authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
- fetch: oauthFetch,
- headers: {
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
- },
- });
- return (modelId: string) => anthropic(modelId);
-}
-
-/**
- * Plain-API-key Anthropic-format provider. Used to hit gateways that speak
- * Anthropic's `/messages` protocol with a standard `x-api-key` header — most
- * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth
- * variant, no `claudeCredentials` are present, no Claude Code mimicry headers
- * are sent, and the API key is passed verbatim through the SDK's default
- * authentication path.
- */
-function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory {
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-anthropic",
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- apiKey: config.apiKey,
- baseURL: config.baseURL || "https://opencode.ai/zen/go/v1",
- fetch: loggingFetch,
- });
-
- return (modelId: string) => anthropic(modelId);
-}
-
-export { prefixToolName, unprefixToolName };
diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts
deleted file mode 100644
index da0c916..0000000
--- a/packages/core/src/lsp/client.ts
+++ /dev/null
@@ -1,658 +0,0 @@
-import type { ChildProcessWithoutNullStreams } from "node:child_process";
-import { readFile } from "node:fs/promises";
-import { extname, isAbsolute, resolve } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import {
- createMessageConnection,
- type MessageConnection,
- StreamMessageReader,
- StreamMessageWriter,
-} from "vscode-jsonrpc/node";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { languageIdForExtension } from "./language.js";
-
-export type { Diagnostic } from "vscode-languageserver-types";
-
-// ─── Timing constants (mirrors opencode) ─────────────────────────
-const DIAGNOSTICS_DEBOUNCE_MS = 150;
-const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000;
-const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000;
-const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000;
-const INITIALIZE_TIMEOUT_MS = 45_000;
-
-// ─── LSP spec constants ──────────────────────────────────────────
-const FILE_CHANGE_CREATED = 1;
-const FILE_CHANGE_CHANGED = 2;
-const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
-
-/**
- * A live spawned language-server process plus the `initializationOptions` to
- * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by
- * `createLspClient`.
- */
-export interface LspServerHandle {
- process: ChildProcessWithoutNullStreams;
- initialization?: Record<string, unknown>;
-}
-
-interface ServerCapabilities {
- textDocumentSync?: number | { change?: number };
- diagnosticProvider?: unknown;
- [key: string]: unknown;
-}
-
-interface DiagnosticRequestResult {
- handled: boolean;
- matched: boolean;
- byFile: Map<string, Diagnostic[]>;
-}
-
-interface CapabilityRegistration {
- id: string;
- method: string;
- registerOptions?: {
- identifier?: string;
- workspaceDiagnostics?: boolean;
- };
-}
-
-type DocumentDiagnosticReport = {
- items?: Diagnostic[];
- relatedDocuments?: Record<string, DocumentDiagnosticReport>;
-};
-
-type WorkspaceDiagnosticReport = {
- items?: { uri?: string; items?: Diagnostic[] }[];
-};
-
-/** Public shape of a connected LSP client. */
-export interface LspClient {
- readonly serverID: string;
- readonly root: string;
- readonly connection: MessageConnection;
- /**
- * Open (or re-sync) a file with the server. Returns the document version
- * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching
- * this exact sync.
- */
- notifyOpen(path: string): Promise<number>;
- /** Snapshot of all known diagnostics keyed by absolute file path. */
- readonly diagnostics: Map<string, Diagnostic[]>;
- /** Wait until diagnostics for `path` settle (push and/or pull). */
- waitForDiagnostics(request: {
- path: string;
- version: number;
- mode?: "document" | "full";
- after?: number;
- }): Promise<void>;
- /** Generic LSP request passthrough (hover, definition, references, …). */
- request<T = unknown>(method: string, params: unknown): Promise<T | null>;
- /** Shut the connection and child process down. */
- shutdown(): Promise<void>;
-}
-
-function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
- return new Promise<T>((resolvePromise, reject) => {
- const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms);
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolvePromise(value);
- },
- (err) => {
- clearTimeout(timer);
- reject(err);
- },
- );
- });
-}
-
-function getFilePath(uri: string): string | undefined {
- if (!uri.startsWith("file://")) return undefined;
- return fileURLToPath(uri);
-}
-
-function getSyncKind(capabilities?: ServerCapabilities): number | undefined {
- if (!capabilities) return undefined;
- const sync = capabilities.textDocumentSync;
- if (typeof sync === "number") return sync;
- return sync?.change;
-}
-
-function endPosition(text: string) {
- const lines = text.split(/\r\n|\r|\n/);
- return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 };
-}
-
-function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] {
- const seen = new Set<string>();
- return items.filter((item) => {
- const key = JSON.stringify({
- code: item.code,
- severity: item.severity,
- message: item.message,
- source: item.source,
- range: item.range,
- });
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
-
-function configurationValue(settings: unknown, section?: string): unknown {
- if (!section) return settings ?? null;
- const result = section.split(".").reduce<unknown>((acc, key) => {
- if (!acc || typeof acc !== "object" || !(key in acc)) return undefined;
- return (acc as Record<string, unknown>)[key];
- }, settings);
- return result ?? null;
-}
-
-/**
- * Create and initialize an LSP client over a spawned server's stdio.
- *
- * Performs the full `initialize`/`initialized` handshake (with a 45s timeout),
- * wires push (`textDocument/publishDiagnostics`) and pull
- * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the
- * `workspace/configuration`, `workspaceFolders`, and capability-registration
- * requests servers commonly make, and returns a small client surface used by
- * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`.
- */
-export async function createLspClient(input: {
- serverID: string;
- server: LspServerHandle;
- root: string;
- directory: string;
-}): Promise<LspClient> {
- const { serverID, server, root, directory } = input;
-
- const connection = createMessageConnection(
- new StreamMessageReader(server.process.stdout),
- new StreamMessageWriter(server.process.stdin),
- );
-
- // Server stderr is routine for many tools (luau-lsp logs sourcemap status
- // there). Keep it quiet unless debugging.
- server.process.stderr?.on("data", () => {
- /* swallowed — see opencode: stderr is mostly informational */
- });
-
- // ─── Connection state ───
- const pushDiagnostics = new Map<string, Diagnostic[]>();
- const pullDiagnostics = new Map<string, Diagnostic[]>();
- const published = new Map<string, { at: number; version?: number }>();
- const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
- const registrationListeners = new Set<() => void>();
- const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
- const files: Record<string, { version: number; text: string }> = {};
-
- const mergedDiagnostics = (filePath: string) =>
- dedupeDiagnostics([
- ...(pushDiagnostics.get(filePath) ?? []),
- ...(pullDiagnostics.get(filePath) ?? []),
- ]);
- const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pushDiagnostics.set(filePath, next);
- for (const listener of diagnosticListeners) listener({ path: filePath, serverID });
- };
- const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pullDiagnostics.set(filePath, next);
- };
- const emitRegistrationChange = () => {
- for (const listener of [...registrationListeners]) listener();
- };
-
- // ─── Notification / request handlers ───
- connection.onNotification(
- "textDocument/publishDiagnostics",
- (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => {
- const filePath = getFilePath(params.uri);
- if (!filePath) return;
- published.set(filePath, {
- at: Date.now(),
- version: typeof params.version === "number" ? params.version : undefined,
- });
- updatePushDiagnostics(filePath, params.diagnostics);
- },
- );
- connection.onRequest("window/workDoneProgress/create", () => null);
- connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => {
- const items = params.items ?? [];
- return items.map((item) => configurationValue(server.initialization, item.section));
- });
- connection.onRequest(
- "client/registerCapability",
- (params: { registrations?: CapabilityRegistration[] }) => {
- const registrations = params.registrations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.set(registration.id, registration);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest(
- "client/unregisterCapability",
- (params: { unregisterations?: { id: string; method: string }[] }) => {
- const registrations = params.unregisterations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.delete(registration.id);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest("workspace/workspaceFolders", () => [
- { name: "workspace", uri: pathToFileURL(root).href },
- ]);
- connection.onRequest("workspace/diagnostic/refresh", () => null);
- connection.listen();
-
- // ─── Initialize handshake ───
- const initialized = await withTimeout(
- connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
- rootUri: pathToFileURL(root).href,
- processId: server.process.pid ?? null,
- workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
- initializationOptions: { ...server.initialization },
- capabilities: {
- window: { workDoneProgress: true },
- workspace: {
- configuration: true,
- didChangeWatchedFiles: { dynamicRegistration: true },
- diagnostics: { refreshSupport: false },
- },
- textDocument: {
- synchronization: { didOpen: true, didChange: true },
- diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
- publishDiagnostics: { versionSupport: false },
- },
- },
- }),
- INITIALIZE_TIMEOUT_MS,
- );
-
- const syncKind = getSyncKind(initialized.capabilities);
- const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
-
- await connection.sendNotification("initialized", {});
- if (server.initialization) {
- await connection.sendNotification("workspace/didChangeConfiguration", {
- settings: server.initialization,
- });
- }
-
- // ─── Pull-diagnostics helpers ───
- const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
- const handled = results.some((r) => r.handled);
- const matched = results.some((r) => r.matched);
- if (!handled) return { handled: false, matched: false };
-
- const merged = new Map<string, Diagnostic[]>();
- for (const result of results) {
- for (const [target, items] of result.byFile.entries()) {
- merged.set(target, (merged.get(target) ?? []).concat(items));
- }
- }
- if (matched && !merged.has(filePath)) merged.set(filePath, []);
- for (const [target, items] of merged.entries()) {
- updatePullDiagnostics(target, dedupeDiagnostics(items));
- }
- return { handled, matched };
- };
-
- async function requestDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
- ...(identifier ? { identifier } : {}),
- textDocument: { uri: pathToFileURL(filePath).href },
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- const empty: DiagnosticRequestResult = {
- handled: false,
- matched: false,
- byFile: new Map(),
- };
- if (!report) return empty;
-
- const byFile = new Map<string, Diagnostic[]>();
- const push = (target: string, items: Diagnostic[]) => {
- byFile.set(target, (byFile.get(target) ?? []).concat(items));
- };
- let handled = false;
- let matched = false;
- if (Array.isArray(report.items)) {
- push(filePath, report.items);
- handled = true;
- matched = true;
- }
- for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
- const relatedPath = getFilePath(uri);
- if (!relatedPath || !Array.isArray(related.items)) continue;
- push(relatedPath, related.items);
- handled = true;
- matched = matched || relatedPath === filePath;
- }
- return { handled, matched, byFile };
- }
-
- async function requestWorkspaceDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
- ...(identifier ? { identifier } : {}),
- previousResultIds: [],
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- if (!report) return { handled: false, matched: false, byFile: new Map() };
-
- const byFile = new Map<string, Diagnostic[]>();
- let matched = false;
- for (const item of report.items ?? []) {
- const relatedPath = item.uri ? getFilePath(item.uri) : undefined;
- if (!relatedPath || !Array.isArray(item.items)) continue;
- byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items));
- matched = matched || relatedPath === filePath;
- }
- return { handled: true, matched, byFile };
- }
-
- function documentPullState() {
- const documentRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics !== true,
- );
- return {
- documentIdentifiers: [
- ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
- };
- }
-
- function workspacePullState() {
- const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics === true,
- );
- return {
- workspaceIdentifiers: [
- ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: workspaceRegistrations.length > 0,
- };
- }
-
- const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
- results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0);
-
- async function requestDiagnostics(
- filePath: string,
- requests: Promise<DiagnosticRequestResult>[],
- done: (results: DiagnosticRequestResult[]) => boolean,
- ) {
- if (!requests.length) return { handled: false, matched: false };
- const results: DiagnosticRequestResult[] = [];
- return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => {
- let pending = requests.length;
- let resolved = false;
- const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
- if (resolved) return;
- if (!force && !done(results)) return;
- resolved = true;
- resolvePromise(merged);
- };
- for (const request of requests) {
- request.then((result) => {
- results.push(result);
- pending -= 1;
- const merged = mergeResults(filePath, results);
- finish(merged);
- if (pending === 0) finish(merged, true);
- });
- }
- });
- }
-
- async function requestDocumentDiagnostics(filePath: string) {
- const state = documentPullState();
- if (!state.supported) return { handled: false, matched: false };
- return requestDiagnostics(
- filePath,
- [
- requestDiagnosticReport(filePath),
- ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ],
- (results) => hasCurrentFileDiagnostics(filePath, results),
- );
- }
-
- async function requestFullDiagnostics(filePath: string) {
- const documentState = documentPullState();
- const workspaceState = workspacePullState();
- if (!documentState.supported && !workspaceState.supported) {
- return { handled: false, matched: false };
- }
- return mergeResults(
- filePath,
- await Promise.all([
- ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
- ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
- ...workspaceState.workspaceIdentifiers.map((id) =>
- requestWorkspaceDiagnosticReport(filePath, id),
- ),
- ]),
- );
- }
-
- function waitForRegistrationChange(timeout: number) {
- if (timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (timer) clearTimeout(timer);
- registrationListeners.delete(listener);
- resolvePromise(result);
- };
- const listener = () => finish(true);
- registrationListeners.add(listener);
- timer = setTimeout(() => finish(false), timeout);
- });
- }
-
- function waitForFreshPush(request: {
- path: string;
- version: number;
- after: number;
- timeout: number;
- }) {
- if (request.timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
- let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
- let unsub: (() => void) | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (debounceTimer) clearTimeout(debounceTimer);
- if (timeoutTimer) clearTimeout(timeoutTimer);
- unsub?.();
- resolvePromise(result);
- };
- const schedule = () => {
- const hit = published.get(request.path);
- if (!hit) return;
- if (typeof hit.version === "number" && hit.version !== request.version) return;
- if (hit.at < request.after && hit.version !== request.version) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(
- () => finish(true),
- Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)),
- );
- };
- timeoutTimer = setTimeout(() => finish(false), request.timeout);
- const listener = (event: { path: string; serverID: string }) => {
- if (event.path !== request.path || event.serverID !== serverID) return;
- schedule();
- };
- diagnosticListeners.add(listener);
- unsub = () => diagnosticListeners.delete(listener);
- schedule();
- });
- }
-
- async function waitForDocumentDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
- const result = await requestDocumentDiagnostics(request.path);
- if (result.matched) return;
- const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- async function waitForFullDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
- const result = await requestFullDiagnostics(request.path);
- if (result.handled || result.matched) return;
- const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p));
-
- // ─── Public surface ───
- const client: LspClient = {
- serverID,
- root,
- connection,
- async notifyOpen(path: string) {
- const filePath = normalize(path);
- const text = await readFile(filePath, "utf8");
- const languageId = languageIdForExtension(extname(filePath));
- const uri = pathToFileURL(filePath).href;
- const document = files[filePath];
-
- if (document !== undefined) {
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CHANGED }],
- });
- const next = document.version + 1;
- files[filePath] = { version: next, text };
- await connection.sendNotification("textDocument/didChange", {
- textDocument: { uri, version: next },
- contentChanges:
- syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
- ? [
- {
- range: {
- start: { line: 0, character: 0 },
- end: endPosition(document.text),
- },
- text,
- },
- ]
- : [{ text }],
- });
- return next;
- }
-
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CREATED }],
- });
- pushDiagnostics.delete(filePath);
- pullDiagnostics.delete(filePath);
- await connection.sendNotification("textDocument/didOpen", {
- textDocument: { uri, languageId, version: 0, text },
- });
- files[filePath] = { version: 0, text };
- return 0;
- },
- get diagnostics() {
- const result = new Map<string, Diagnostic[]>();
- for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
- result.set(key, mergedDiagnostics(key));
- }
- return result;
- },
- async waitForDiagnostics(request) {
- const normalizedPath = normalize(request.path);
- if (request.mode === "document") {
- await waitForDocumentDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- return;
- }
- await waitForFullDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- },
- async request<T = unknown>(method: string, params: unknown): Promise<T | null> {
- return connection.sendRequest<T>(method, params).catch(() => null);
- },
- async shutdown() {
- try {
- connection.end();
- connection.dispose();
- } catch {
- /* connection may already be closed */
- }
- server.process.kill();
- },
- };
-
- return client;
-}
diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts
deleted file mode 100644
index 1ad4d0f..0000000
--- a/packages/core/src/lsp/diagnostic.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Diagnostic } from "vscode-languageserver-types";
-
-/**
- * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`.
- *
- * LSP positions are 0-based on the wire; we render them 1-based (editor-style)
- * so they line up with what `read_file` shows and what editors report.
- */
-
-/** Max diagnostics rendered per file before truncating with a "… and N more". */
-const MAX_PER_FILE = 20;
-
-const SEVERITY_LABEL: Record<number, string> = {
- 1: "ERROR",
- 2: "WARN",
- 3: "INFO",
- 4: "HINT",
-};
-
-/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */
-export function pretty(diagnostic: Diagnostic): string {
- const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR";
- const line = diagnostic.range.start.line + 1;
- const col = diagnostic.range.start.character + 1;
- return `${severity} [${line}:${col}] ${diagnostic.message}`;
-}
-
-/**
- * Build a `<diagnostics file="…">` block for a file's ERROR-severity
- * diagnostics, or `""` when there are none. Errors only — warnings/info/hints
- * are intentionally omitted so the model is nudged toward the things that
- * actually break the build (matching opencode's behavior).
- */
-export function report(file: string, issues: Diagnostic[]): string {
- const errors = issues.filter((item) => item.severity === 1);
- if (errors.length === 0) return "";
- const limited = errors.slice(0, MAX_PER_FILE);
- const more = errors.length - MAX_PER_FILE;
- const suffix = more > 0 ? `\n... and ${more} more` : "";
- return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`;
-}
diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts
deleted file mode 100644
index fd43c2f..0000000
--- a/packages/core/src/lsp/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// LSP (Language Server Protocol) integration.
-//
-// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]`
-// block (see `LspServerConfig` in `../types`). There is no builtin server
-// registry and no auto-download. The primary model-facing surface is
-// diagnostics-on-write (the host passes a write hook that calls `touchFile` +
-// `report`); an on-demand `lsp` tool exposes hover/definition/references too.
-
-export {
- createLspClient,
- type Diagnostic,
- type LspClient,
- type LspServerHandle,
-} from "./client.js";
-export { pretty, report } from "./diagnostic.js";
-export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js";
-export { LspManager } from "./manager.js";
-export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js";
diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts
deleted file mode 100644
index 3e9fe68..0000000
--- a/packages/core/src/lsp/language.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * File-extension → LSP `languageId` map.
- *
- * The LSP `textDocument/didOpen` notification carries a `languageId` string
- * that tells the server how to parse the document. This table is a trimmed
- * port of opencode's `lsp/language.ts`, with one critical addition for this
- * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension,
- * which standard Lua tooling does not recognise — luau-lsp expects the
- * `"luau"` languageId.
- *
- * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown
- * extensions fall back to `"plaintext"` at the call site.
- */
-export const LANGUAGE_EXTENSIONS: Record<string, string> = {
- // Luau (Roblox) — the reason this module exists. Keep first for visibility.
- ".luau": "luau",
- ".lua": "lua",
- // A pragmatic subset of common languages, mirroring opencode's table so a
- // user can point an arbitrary LSP server at this codebase and have the
- // right languageId reported.
- ".c": "c",
- ".cpp": "cpp",
- ".cc": "cpp",
- ".cxx": "cpp",
- ".h": "c",
- ".hpp": "cpp",
- ".cs": "csharp",
- ".css": "css",
- ".dart": "dart",
- ".go": "go",
- ".html": "html",
- ".htm": "html",
- ".java": "java",
- ".js": "javascript",
- ".jsx": "javascriptreact",
- ".json": "json",
- ".jsonc": "jsonc",
- ".kt": "kotlin",
- ".kts": "kotlin",
- ".md": "markdown",
- ".markdown": "markdown",
- ".php": "php",
- ".py": "python",
- ".rb": "ruby",
- ".rs": "rust",
- ".scss": "scss",
- ".sass": "sass",
- ".sh": "shellscript",
- ".bash": "shellscript",
- ".zsh": "shellscript",
- ".sql": "sql",
- ".svelte": "svelte",
- ".swift": "swift",
- ".toml": "toml",
- ".ts": "typescript",
- ".tsx": "typescriptreact",
- ".mts": "typescript",
- ".cts": "typescript",
- ".vue": "vue",
- ".xml": "xml",
- ".yaml": "yaml",
- ".yml": "yaml",
- ".zig": "zig",
-};
-
-/**
- * Resolve the LSP `languageId` for a file path's extension, falling back to
- * `"plaintext"` when the extension is unknown.
- */
-export function languageIdForExtension(extension: string): string {
- return LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
-}
diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts
deleted file mode 100644
index db8b68e..0000000
--- a/packages/core/src/lsp/manager.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { extname } from "node:path";
-import { createLspClient, type Diagnostic, type LspClient } from "./client.js";
-import type { ResolvedLspServer } from "./server.js";
-
-/**
- * Process-wide owner of LSP client lifecycles.
- *
- * Clients are keyed by `root + serverID` and spawned lazily on the first file
- * that matches a server's extensions, then reused. Concurrent spawns for the
- * same key are de-duplicated via an in-flight map, and servers that fail to
- * start are remembered in `broken` so we don't spawn-spam. Modeled on
- * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery.
- *
- * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a
- * tab's working-directory config (`resolveServersFromConfig`) and pass them in
- * alongside the `root`. This keeps per-working-directory config out of the
- * manager while letting it own all the long-lived processes for the process.
- */
-export class LspManager {
- private clients = new Map<string, LspClient>();
- private spawning = new Map<string, Promise<LspClient | undefined>>();
- private broken = new Set<string>();
-
- private key(root: string, serverID: string): string {
- return `${root}\u0000${serverID}`;
- }
-
- private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] {
- const extension = extname(file) || file;
- return servers.filter(
- (server) => server.extensions.length === 0 || server.extensions.includes(extension),
- );
- }
-
- /**
- * True if any provided server is configured to attach to this file's
- * extension (regardless of whether it has spawned yet). Used to decide
- * whether an LSP operation is even applicable to a file.
- */
- hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean {
- return this.serversForFile(file, servers).length > 0;
- }
-
- /**
- * Get (spawning if needed) all clients that should attach to `file` at
- * `root`. Spawn failures are swallowed (logged via `broken`) and simply
- * yield fewer clients — callers degrade gracefully to "no diagnostics".
- */
- async getClients(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- }): Promise<LspClient[]> {
- const { file, root, servers } = input;
- const matching = this.serversForFile(file, servers);
- const result: LspClient[] = [];
-
- for (const server of matching) {
- const key = this.key(root, server.id);
- if (this.broken.has(key)) continue;
-
- const existing = this.clients.get(key);
- if (existing) {
- result.push(existing);
- continue;
- }
-
- const inflight = this.spawning.get(key);
- if (inflight) {
- const client = await inflight;
- if (client) result.push(client);
- continue;
- }
-
- const task = this.spawn(server, root, key);
- this.spawning.set(key, task);
- task.finally(() => {
- if (this.spawning.get(key) === task) this.spawning.delete(key);
- });
- const client = await task;
- if (client) result.push(client);
- }
-
- return result;
- }
-
- private async spawn(
- server: ResolvedLspServer,
- root: string,
- key: string,
- ): Promise<LspClient | undefined> {
- let handle: ReturnType<ResolvedLspServer["spawn"]>;
- try {
- handle = server.spawn(root);
- } catch (err) {
- this.broken.add(key);
- console.warn(
- `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
-
- // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH)
- // emits `error` on the child process; mark broken so we don't retry it.
- handle.process.on("error", (err) => {
- this.broken.add(key);
- console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`);
- });
-
- try {
- const client = await createLspClient({
- serverID: server.id,
- server: handle,
- root,
- directory: root,
- });
- // A racing caller may have created the same client; prefer the
- // existing one and discard ours.
- const existing = this.clients.get(key);
- if (existing) {
- await client.shutdown();
- return existing;
- }
- this.clients.set(key, client);
- return client;
- } catch (err) {
- this.broken.add(key);
- try {
- handle.process.kill();
- } catch {
- /* already dead */
- }
- console.warn(
- `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
- }
-
- /**
- * Open/sync a file with its clients and (optionally) wait for diagnostics
- * to settle. `mode: "document"` waits for the file's own diagnostics;
- * `"full"` also waits on workspace diagnostics; omitted just syncs.
- */
- async touchFile(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- mode?: "document" | "full";
- }): Promise<void> {
- const clients = await this.getClients(input);
- await Promise.all(
- clients.map(async (client) => {
- const after = Date.now();
- const version = await client.notifyOpen(input.file);
- if (!input.mode) return;
- await client.waitForDiagnostics({
- path: input.file,
- version,
- mode: input.mode,
- after,
- });
- }),
- ).catch((err) => {
- console.warn(
- `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- }
-
- /**
- * Merged diagnostics for a single file across all of its clients, keyed by
- * absolute file path. Includes related-file diagnostics a client surfaced
- * (e.g. workspace pulls), so the result map may contain more than `file`.
- */
- getDiagnostics(input: {
- root: string;
- servers: ResolvedLspServer[];
- file: string;
- }): Record<string, Diagnostic[]> {
- const results: Record<string, Diagnostic[]> = {};
- const matching = this.serversForFile(input.file, input.servers);
- for (const server of matching) {
- const client = this.clients.get(this.key(input.root, server.id));
- if (!client) continue;
- for (const [path, diags] of client.diagnostics.entries()) {
- results[path] = (results[path] ?? []).concat(diags);
- }
- }
- return results;
- }
-
- /**
- * Run a positional LSP request (hover/definition/references/etc.) against
- * every client for the file and flatten the (non-null) results. `line`/
- * `character` are 0-based here — the caller converts from editor 1-based.
- */
- async request(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- method: string;
- params: Record<string, unknown>;
- }): Promise<unknown[]> {
- const clients = await this.getClients(input);
- const results = await Promise.all(
- clients.map((client) => client.request(input.method, input.params)),
- );
- return results.filter((r) => r !== null && r !== undefined);
- }
-
- /** Shut down every live client and clear all state. */
- async shutdownAll(): Promise<void> {
- const clients = [...this.clients.values()];
- this.clients.clear();
- this.spawning.clear();
- this.broken.clear();
- await Promise.all(clients.map((client) => client.shutdown().catch(() => {})));
- }
-}
diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts
deleted file mode 100644
index 1fb002e..0000000
--- a/packages/core/src/lsp/server.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
-import type { LspServerConfig } from "../types/index.js";
-import type { LspServerHandle } from "./client.js";
-
-/**
- * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml`
- * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server
- * registry and performs no auto-download (unlike opencode). The declared
- * executable (`command[0]`) must already be on PATH.
- */
-export interface ResolvedLspServer {
- id: string;
- /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */
- extensions: string[];
- /** Launch the server over stdio rooted at `root`. */
- spawn(root: string): LspServerHandle;
-}
-
-/**
- * Spawn a child process for an LSP server over stdio. Inherits `process.env`
- * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate)
- * and merges any `env` from the server config on top.
- */
-function spawnServer(
- command: string[],
- cwd: string,
- env: Record<string, string> | undefined,
- initialization: Record<string, unknown> | undefined,
-): LspServerHandle {
- const [cmd, ...args] = command;
- if (!cmd) throw new Error("LSP server command is empty");
- const proc = spawn(cmd, args, {
- cwd,
- env: { ...process.env, ...env },
- stdio: ["pipe", "pipe", "pipe"],
- }) as ChildProcessWithoutNullStreams;
- return {
- process: proc,
- ...(initialization ? { initialization } : {}),
- };
-}
-
-/**
- * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable
- * servers. Disabled entries are dropped. Entries with no `command`/`extensions`
- * are skipped defensively (the config validator already enforces these, but we
- * guard here too so a hand-built config object can't crash the manager).
- */
-export function resolveServersFromConfig(
- lsp: Record<string, LspServerConfig> | undefined,
-): ResolvedLspServer[] {
- if (!lsp) return [];
- const servers: ResolvedLspServer[] = [];
- for (const [id, entry] of Object.entries(lsp)) {
- if (entry.disabled) continue;
- if (!entry.command || entry.command.length === 0) continue;
- if (!entry.extensions || entry.extensions.length === 0) continue;
- const command = entry.command;
- const env = entry.env;
- const initialization = entry.initialization;
- servers.push({
- id,
- extensions: entry.extensions,
- spawn: (root: string) => spawnServer(command, root, env, initialization),
- });
- }
- return servers;
-}
diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts
deleted file mode 100644
index 5c98db4..0000000
--- a/packages/core/src/models/attachments.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// Validation + limits for multimodal user attachments (images / PDFs).
-//
-// Kept dependency-free (no DB / `bun:sqlite` import) so both the API layer
-// (`/chat` request validation) and any future caller can share the exact same
-// allowlist and size/count ceilings. The limits mirror Anthropic's documented
-// vision/PDF API constraints (the only image-capable providers Dispatch maps),
-// so a request that passes here won't be rejected by the provider for size.
-
-import type { UserAttachmentPart, UserContentPart } from "../types/index.js";
-
-/** Accepted image media types. */
-export const ACCEPTED_IMAGE_MEDIA_TYPES = [
- "image/png",
- "image/jpeg",
- "image/webp",
- "image/gif",
-] as const;
-
-/** Accepted document media types. */
-export const ACCEPTED_PDF_MEDIA_TYPE = "application/pdf";
-
-/** Every media type we accept as an attachment. */
-export const ACCEPTED_ATTACHMENT_MEDIA_TYPES = [
- ...ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
-] as const;
-
-/** Per-image byte ceiling (Anthropic: 5 MB/image). */
-export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
-
-/** Per-PDF byte ceiling (Anthropic: 32 MB/PDF). */
-export const MAX_PDF_BYTES = 32 * 1024 * 1024;
-
-/** Max attachments per message (Anthropic: 20 images/request). */
-export const MAX_ATTACHMENTS = 20;
-
-/**
- * Total attachment payload ceiling for a single request (decoded bytes). Bounds
- * the overall request size even when each individual file is within its limit.
- */
-export const MAX_TOTAL_ATTACHMENT_BYTES = 32 * 1024 * 1024;
-
-/** Whether a media type is an accepted image type. */
-export function isImageMediaType(mediaType: string): boolean {
- return (ACCEPTED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/** Whether a media type is the accepted PDF type. */
-export function isPdfMediaType(mediaType: string): boolean {
- return mediaType === ACCEPTED_PDF_MEDIA_TYPE;
-}
-
-/** Whether a media type is an accepted attachment type at all. */
-export function isAcceptedAttachmentMediaType(mediaType: string): boolean {
- return (ACCEPTED_ATTACHMENT_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/**
- * Decoded byte length of a base64 string, computed WITHOUT allocating the
- * decoded buffer. Tolerates an optional `data:<mediaType>;base64,` prefix and
- * any embedded whitespace/newlines. Returns 0 for an empty/whitespace string.
- */
-export function base64ByteLength(b64: string): number {
- // Strip a data-URI prefix if present.
- const comma = b64.indexOf(",");
- const body = b64.startsWith("data:") && comma !== -1 ? b64.slice(comma + 1) : b64;
- let len = 0;
- let pad = 0;
- for (let i = 0; i < body.length; i++) {
- const ch = body.charCodeAt(i);
- // Skip whitespace (space, \t, \n, \r).
- if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue;
- len++;
- if (body[i] === "=") pad++;
- }
- if (len === 0) return 0;
- // 4 base64 chars → 3 bytes, minus padding.
- return Math.floor((len * 3) / 4) - pad;
-}
-
-export type AttachmentValidationError =
- | { code: "unsupported-type"; mediaType: string }
- | { code: "image-too-large"; mediaType: string; bytes: number; limit: number }
- | { code: "pdf-too-large"; bytes: number; limit: number }
- | { code: "too-many"; count: number; limit: number }
- | { code: "total-too-large"; bytes: number; limit: number }
- | { code: "empty"; mediaType: string };
-
-export interface AttachmentValidationResult {
- ok: boolean;
- errors: AttachmentValidationError[];
-}
-
-/** Extract just the attachment parts from a mixed content list. */
-function attachmentsOf(content: UserContentPart[]): UserAttachmentPart[] {
- return content.filter((p): p is UserAttachmentPart => p.type === "attachment");
-}
-
-/**
- * Validate the attachments in a multimodal user content list against the
- * media-type allowlist and the size/count ceilings. Pure: never throws,
- * collects every violation so the caller can report them all at once.
- *
- * Text parts are ignored (always valid). An empty content list is valid (it's
- * just a text-only message expressed as parts).
- */
-export function validateUserContent(content: UserContentPart[]): AttachmentValidationResult {
- const errors: AttachmentValidationError[] = [];
- const attachments = attachmentsOf(content);
-
- if (attachments.length > MAX_ATTACHMENTS) {
- errors.push({ code: "too-many", count: attachments.length, limit: MAX_ATTACHMENTS });
- }
-
- let total = 0;
- for (const att of attachments) {
- if (!isAcceptedAttachmentMediaType(att.mediaType)) {
- errors.push({ code: "unsupported-type", mediaType: att.mediaType });
- continue;
- }
- const bytes = base64ByteLength(att.data);
- total += bytes;
- if (bytes === 0) {
- errors.push({ code: "empty", mediaType: att.mediaType });
- continue;
- }
- if (isPdfMediaType(att.mediaType)) {
- if (bytes > MAX_PDF_BYTES) {
- errors.push({ code: "pdf-too-large", bytes, limit: MAX_PDF_BYTES });
- }
- } else if (bytes > MAX_IMAGE_BYTES) {
- errors.push({
- code: "image-too-large",
- mediaType: att.mediaType,
- bytes,
- limit: MAX_IMAGE_BYTES,
- });
- }
- }
-
- if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
- errors.push({ code: "total-too-large", bytes: total, limit: MAX_TOTAL_ATTACHMENT_BYTES });
- }
-
- return { ok: errors.length === 0, errors };
-}
-
-/** Convenience: does the content list contain at least one attachment? */
-export function hasAttachments(content: UserContentPart[] | undefined | null): boolean {
- return !!content && content.some((p) => p.type === "attachment");
-}
diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts
deleted file mode 100644
index ac310b1..0000000
--- a/packages/core/src/models/catalog.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
-
-/**
- * models.dev-backed model catalog. Resolves a model's MAXIMUM context window
- * (`limit.context`) dynamically from the public models.dev API, mirroring how
- * opencode determines per-model context limits — no hardcoded table.
- *
- * The catalog is fetched once, cached on disk with a short TTL, and reused. On
- * fetch failure we fall back to a stale-but-present cache so the lookup keeps
- * working offline. Lookups never throw: an unknown/unreachable model resolves
- * to `null`, which the UI renders as "max unknown".
- */
-
-/** Shape of the slice of models.dev's `/api.json` we consume. */
-interface ModelsDevModel {
- limit?: {
- context?: number;
- output?: number;
- };
- /**
- * Input/output modalities the model accepts. We read `input` to decide
- * whether the model can take image / pdf attachments. Absent on older
- * catalog entries — treated as "unknown" (capability resolves to `null`).
- */
- modalities?: {
- input?: string[];
- output?: string[];
- };
-}
-
-interface ModelsDevProvider {
- id: string;
- models: Record<string, ModelsDevModel | undefined>;
-}
-
-type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>;
-
-/** Where models.dev's API lives. Overridable for tests / private mirrors. */
-const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev";
-
-/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-/** How long a cached catalog stays fresh before we re-fetch. */
-const CACHE_TTL_MS = 5 * 60 * 1000;
-
-/** Network timeout for the catalog fetch. */
-const FETCH_TIMEOUT_MS = 10_000;
-
-/**
- * After a failed fetch we memoize the fallback for this long before retrying,
- * so a sustained outage doesn't make every lookup hang on a fresh timeout.
- */
-const FETCH_PENALTY_MS = 60_000;
-
-/**
- * Dispatch provider id → models.dev provider ids to search, in priority order.
- * We only support Claude-backed providers (per product scope). `anthropic` and
- * `opencode-anthropic` are both Claude; we try the first-party `anthropic`
- * catalog first, then the `opencode` gateway catalog as a fallback.
- */
-const PROVIDER_MAP: Record<string, string[]> = {
- anthropic: ["anthropic", "opencode"],
- "opencode-anthropic": ["anthropic", "opencode"],
-};
-
-/** In-process memoized catalog promise (one fetch/parse per TTL window). */
-let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null;
-let inflight: Promise<ModelsDevCatalog> | null = null;
-
-function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null {
- try {
- const stat = statSync(CACHE_PATH);
- const text = readFileSync(CACHE_PATH, "utf-8");
- return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs };
- } catch {
- return null;
- }
-}
-
-function writeDiskCache(text: string): void {
- try {
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
- // Write-then-rename so a concurrent reader never sees a half-written
- // file (rename is atomic on the same filesystem). The temp name is
- // process-scoped to avoid two writers clobbering each other's temp.
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
- writeFileSync(tmp, text, "utf-8");
- renameSync(tmp, CACHE_PATH);
- } catch {
- // Best-effort: a read-only /tmp shouldn't break lookups.
- }
-}
-
-async function fetchCatalog(): Promise<ModelsDevCatalog> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
- try {
- const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal });
- if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`);
- const text = await res.text();
- const catalog = JSON.parse(text) as ModelsDevCatalog;
- writeDiskCache(text);
- return catalog;
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Load the models.dev catalog, preferring in-process memo, then a fresh disk
- * cache, then a network fetch. On network failure, falls back to any stale
- * disk cache; if nothing is available, returns an empty catalog.
- */
-export async function getModelsCatalog(): Promise<ModelsDevCatalog> {
- if (process.env.DISPATCH_DISABLE_MODELS_FETCH) {
- const disk = readDiskCache();
- return disk?.catalog ?? {};
- }
-
- const now = Date.now();
- if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog;
-
- // Fresh disk cache satisfies the request without a network round-trip.
- const disk = readDiskCache();
- if (disk && now - disk.mtimeMs < CACHE_TTL_MS) {
- // Inherit the file's mtime as `fetchedAt` so loading a disk cache into
- // a fresh process doesn't reset its TTL (which would otherwise double
- // the worst-case staleness across process boundaries).
- cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs };
- return disk.catalog;
- }
-
- if (!inflight) {
- inflight = fetchCatalog()
- .then((catalog) => {
- cached = { catalog, fetchedAt: Date.now() };
- return catalog;
- })
- .catch((err) => {
- // Network failed — serve a stale cache if we have one.
- console.warn(
- `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`,
- );
- const fallback = disk?.catalog ?? ({} as ModelsDevCatalog);
- // Memoize the fallback with a short "penalty" TTL so a sustained
- // outage doesn't make every lookup hang on a fresh 10s timeout.
- // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS.
- cached = {
- catalog: fallback,
- fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS,
- };
- return fallback;
- })
- .finally(() => {
- inflight = null;
- });
- }
- return inflight;
-}
-
-/**
- * Resolve a model's maximum context window (in tokens) for the given Dispatch
- * provider + model id. Returns `null` when the provider is unsupported, the
- * model is unknown, or the catalog is unavailable — callers should render that
- * as "max unknown" (no denominator / percentage).
- */
-export async function resolveContextLimit(
- provider: string,
- modelId: string,
-): Promise<number | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context;
- if (typeof ctx === "number" && ctx > 0) return ctx;
- }
- return null;
-}
-
-/**
- * Image / PDF input capabilities for a model, resolved from the models.dev
- * catalog's `modalities.input` list.
- */
-export interface ModelInputCapabilities {
- /** Model accepts image input (vision). */
- image: boolean;
- /** Model accepts PDF/document input. */
- pdf: boolean;
-}
-
-/**
- * Resolve whether a model accepts image / pdf input for the given Dispatch
- * provider + model id. Returns `null` when the capability is UNKNOWN — i.e. the
- * provider is unsupported/unmapped, the model is absent from the catalog, the
- * entry predates the `modalities` field, or the catalog is unavailable. Callers
- * should treat `null` as "can't verify" (optimistic allow) rather than a
- * definitive "no", so a temporary catalog outage never disables a known-good
- * vision model.
- *
- * A non-null result means the catalog DID describe the model's input modalities
- * — `{ image, pdf }` then reflects exactly what it advertises (a definitive
- * yes/no for each).
- */
-export async function resolveModelCapabilities(
- provider: string,
- modelId: string,
-): Promise<ModelInputCapabilities | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const input = catalog[providerId]?.models?.[modelId]?.modalities?.input;
- if (Array.isArray(input)) {
- return { image: input.includes("image"), pdf: input.includes("pdf") };
- }
- }
- return null;
-}
-
-/** Test-only: reset the in-process memo so a test can re-exercise loading. */
-export function __resetCatalogCacheForTests(): void {
- cached = null;
- inflight = null;
-}
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
deleted file mode 100644
index 15d1ee2..0000000
--- a/packages/core/src/models/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "./attachments.js";
-export {
- getModelsCatalog,
- type ModelInputCapabilities,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "./catalog.js";
-export { ModelRegistry } from "./registry.js";
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
deleted file mode 100644
index 4a24a51..0000000
--- a/packages/core/src/models/registry.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { KeyDefinition, KeyState } from "../types/index.js";
-
-export class ModelRegistry {
- private keyStates: Map<string, KeyState>;
- private keyOrder: string[];
-
- constructor(keys: KeyDefinition[]) {
- this.keyStates = new Map();
- this.keyOrder = [];
- this._initConfig(keys, new Map());
- }
-
- private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void {
- this.keyOrder = keys.map((k) => k.id);
-
- const newStates = new Map<string, KeyState>();
- for (const key of keys) {
- const existing = existingStates.get(key.id);
- if (existing) {
- // Preserve existing state but update definition
- newStates.set(key.id, { ...existing, definition: key });
- } else {
- newStates.set(key.id, { definition: key, status: "active" });
- }
- }
- this.keyStates = newStates;
- }
-
- getKeys(): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter((state): state is KeyState => state !== undefined);
- }
-
- markKeyExhausted(keyId: string, error?: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- this.keyStates.set(keyId, {
- ...state,
- status: "exhausted",
- lastError: error,
- exhaustedAt: Date.now(),
- });
- }
-
- markKeyActive(keyId: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- const updated: KeyState = {
- definition: state.definition,
- status: "active",
- };
- this.keyStates.set(keyId, updated);
- }
-
- hasAvailableKey(provider: string): boolean {
- for (const state of this.keyStates.values()) {
- if (state.definition.provider === provider && state.status === "active") {
- return true;
- }
- }
- return false;
- }
-
- allKeysExhausted(): boolean {
- for (const state of this.keyStates.values()) {
- if (state.status === "active") {
- return false;
- }
- }
- return true;
- }
-
- updateConfig(keys: KeyDefinition[]): void {
- this._initConfig(keys, this.keyStates);
- }
-
- // Internal: get ordered key states for a specific provider
- getOrderedKeysForProvider(provider: string): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter(
- (state): state is KeyState => state !== undefined && state.definition.provider === provider,
- );
- }
-}
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts
deleted file mode 100644
index 49e6ff4..0000000
--- a/packages/core/src/notifications/config.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-// Persisted ntfy config — single global JSON blob under one settings key.
-//
-// One global config (no per-user split): the rest of Dispatch's settings
-// table is global today (cf. `title_model_*`, `perm_*`), so notification
-// config follows the same pattern.
-
-import { deleteSetting, getSetting, setSetting } from "../db/settings.js";
-import type { NotificationEventType, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_EVENTS, NTFY_EVENT_TYPES } from "./types.js";
-
-export const NTFY_CONFIG_KEY = "ntfy_config";
-
-/** Defaults returned when nothing is persisted yet. */
-export function defaultNtfyConfig(): NtfyConfig {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: { ...NTFY_DEFAULT_EVENTS },
- notifySubagents: false,
- };
-}
-
-/**
- * Normalize an arbitrary parsed JSON value into a complete `NtfyConfig`.
- * Tolerant of missing / unexpected fields so a config from an older build
- * never throws — missing event toggles fall back to defaults.
- */
-export function normalizeNtfyConfig(raw: unknown): NtfyConfig {
- const base = defaultNtfyConfig();
- if (!raw || typeof raw !== "object") return base;
- const obj = raw as Record<string, unknown>;
- const out: NtfyConfig = {
- enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled,
- topic: typeof obj.topic === "string" ? obj.topic : base.topic,
- authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken,
- events: { ...base.events },
- notifySubagents:
- typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents,
- };
- const rawEvents = obj.events;
- if (rawEvents && typeof rawEvents === "object") {
- const evObj = rawEvents as Record<string, unknown>;
- for (const key of NTFY_EVENT_TYPES) {
- const v = evObj[key];
- if (typeof v === "boolean") out.events[key as NotificationEventType] = v;
- }
- }
- return out;
-}
-
-/** Load the persisted config (or defaults if none/corrupt). */
-export function loadNtfyConfig(): NtfyConfig {
- const raw = getSetting(NTFY_CONFIG_KEY);
- if (!raw) return defaultNtfyConfig();
- try {
- return normalizeNtfyConfig(JSON.parse(raw));
- } catch {
- return defaultNtfyConfig();
- }
-}
-
-/** Persist a complete config (after server-side normalization). */
-export function saveNtfyConfig(config: NtfyConfig): void {
- const normalized = normalizeNtfyConfig(config);
- setSetting(NTFY_CONFIG_KEY, JSON.stringify(normalized));
-}
-
-/** Wipe the persisted config (revert to defaults on next load). */
-export function clearNtfyConfig(): void {
- deleteSetting(NTFY_CONFIG_KEY);
-}
-
-/** Strip the auth token from a config before returning it over the API. */
-export function redactNtfyConfig(config: NtfyConfig): NtfyConfig & { hasAuthToken: boolean } {
- return { ...config, authToken: "", hasAuthToken: config.authToken.trim().length > 0 };
-}
diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts
deleted file mode 100644
index 01ce00c..0000000
--- a/packages/core/src/notifications/dispatcher.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-// NotificationDispatcher — turns high-level Dispatch events into
-// `sendNtfy(...)` calls, gated by the persisted user config.
-//
-// The dispatcher is transport-agnostic at the `notify(event)` interface
-// boundary: only `sendNtfy` is wired today, but adding another transport
-// (email, webhook, etc.) means changing this one file, not the call sites.
-//
-// All sends are non-blocking (fire-and-forget). A 10-second timeout in
-// `sendNtfy` bounds the worst case; the dispatcher additionally guards
-// every send in a try/catch so a transport bug can never propagate into
-// the agent loop.
-
-import { loadNtfyConfig } from "./config.js";
-import { type FetchLike, sendNtfy } from "./ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-
-/** Minimal shape of an `AgentManager`-style event stream we hook into. */
-export interface AgentEventSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void,
- ): () => void;
-}
-
-/** Minimal shape of a `PermissionManager`-style prompt source. */
-export interface PermissionPromptSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
-}
-
-/** Look up a human-readable tab title for nicer notification text. */
-export type TabTitleLookup = (tabId: string) => string | null;
-
-/**
- * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no
- * parent) and `undefined` when the lookup can't be performed (no DB, tab
- * not found). Both non-strings cause the dispatcher to fall back to
- * "treat as top-level" to avoid silently dropping notifications when the
- * lookup is broken.
- */
-export type TabParentLookup = (tabId: string) => string | null | undefined;
-
-export interface DispatcherOptions {
- /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */
- loadConfig?: () => NtfyConfig;
- /** Override the transport (tests). Defaults to the real `sendNtfy`. */
- send?: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */
- fetchImpl?: FetchLike;
- /** Look up a tab title for richer titles. */
- getTabTitle?: TabTitleLookup;
- /**
- * Look up a tab's `parentTabId`. Used to honour the
- * `notifySubagents` config flag — when false, `turn-completed` /
- * `turn-error` from subagent tabs (those with a parent) are
- * suppressed.
- */
- getTabParentId?: TabParentLookup;
- /**
- * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit
- * the whole pending list on every change, so dedupe is essential.
- */
- dedupeWindowMs?: number;
-}
-
-export class NotificationDispatcher {
- private loadConfig: () => NtfyConfig;
- private send: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- private getTabTitle: TabTitleLookup | undefined;
- private getTabParentId: TabParentLookup | undefined;
- private dedupeWindowMs: number;
- /** Recently-sent dedupeKey → expiresAt epoch ms. */
- private recentlySent = new Map<string, number>();
- private unsubs: Array<() => void> = [];
-
- constructor(opts: DispatcherOptions = {}) {
- this.loadConfig = opts.loadConfig ?? loadNtfyConfig;
- this.send =
- opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined));
- this.getTabTitle = opts.getTabTitle;
- this.getTabParentId = opts.getTabParentId;
- this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000;
- }
-
- /**
- * Single internal entry point — every public hook funnels through here.
- * Public so a future caller can synthesize an arbitrary notification
- * (e.g. a CLI `dispatch notify` command); kept narrow.
- */
- notify(event: NotificationEvent): void {
- const config = this.loadConfig();
- if (!config.enabled) return;
- if (!config.events[event.type]) return;
- if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return;
- if (event.dedupeKey) this.markSent(event.dedupeKey);
-
- // Fire-and-forget: never await, never throw.
- try {
- void Promise.resolve(this.send(config, event)).catch((err) => {
- console.warn(
- `[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- } catch (err) {
- // Guard the synchronous portion of `send` too.
- console.warn(
- `[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /**
- * Hook into an `AgentManager`-style event stream.
- *
- * Maps:
- * - `done` → `turn-completed`
- * - `error` → `turn-error`
- * - `tab-created` → `agent-spawned` (only top-level user-agent tabs)
- *
- * `status` events are ignored — they fire on every transition and we'd
- * either spam or duplicate the `done`/`error` notifications.
- *
- * Turn events from subagent tabs are suppressed when
- * `config.notifySubagents === false` (the default). A parent agent
- * spawning 8 subagents would otherwise produce 9 "Turn complete"
- * pushes per round; almost always noise. Permission prompts are NOT
- * gated this way — a subagent's permission request still needs human
- * input to proceed, so suppressing those would silently hang the
- * subagent.
- */
- attachToAgentManager(source: AgentEventSource): () => void {
- const unsub = source.onEvent((event) => {
- if (event.type === "done") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnCompleted(event));
- } else if (event.type === "error") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnError(event));
- } else if (event.type === "tab-created") {
- const ev = event as unknown as {
- tabId: string;
- id: string;
- title: string;
- parentTabId: string | null;
- agentSlug?: string | null;
- };
- // Only notify for top-level user-agent tabs spawned via `summon`.
- // Filtering on `agentSlug` skips "blank" new tabs the user opened
- // manually, which would be noisy.
- if (ev.parentTabId === null && ev.agentSlug) {
- this.notify(this.buildAgentSpawned(ev));
- }
- }
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Hook into a `PermissionManager`-style prompt source. */
- attachToPermissionManager(source: PermissionPromptSource): () => void {
- const unsub = source.onPromptAdded((prompt) => {
- this.notify(this.buildPermissionRequired(prompt));
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Release all hooks acquired via `attachTo*`. */
- dispose(): void {
- for (const u of this.unsubs) {
- try {
- u();
- } catch {
- // best-effort
- }
- }
- this.unsubs = [];
- this.recentlySent.clear();
- }
-
- // ─── Event builders (internal) ────────────────────────────────
-
- private buildTurnCompleted(event: { tabId: string }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- return {
- type: "turn-completed",
- title: `Turn complete — ${tabLabel}`,
- message: `Assistant finished a turn in ${tabLabel}.`,
- tabId: event.tabId,
- };
- }
-
- private buildTurnError(event: {
- tabId: string;
- error?: unknown;
- statusCode?: unknown;
- }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- const errText = typeof event.error === "string" ? event.error : "Unknown error";
- const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : "";
- return {
- type: "turn-error",
- title: `Turn failed — ${tabLabel}`,
- message: `${errText}${statusText}`,
- tabId: event.tabId,
- };
- }
-
- private buildPermissionRequired(prompt: {
- id: string;
- permission: string;
- description: string;
- }): NotificationEvent {
- return {
- type: "permission-required",
- title: `Permission required: ${prompt.permission}`,
- message: prompt.description || `Agent is requesting ${prompt.permission} permission.`,
- // Permission prompts can re-emit (e.g. another prompt arrives while
- // this one is still pending) — dedupe on the prompt id.
- dedupeKey: `permission:${prompt.id}`,
- };
- }
-
- private buildAgentSpawned(ev: {
- tabId: string;
- id: string;
- title: string;
- agentSlug?: string | null;
- }): NotificationEvent {
- return {
- type: "agent-spawned",
- title: `User agent spawned — ${ev.agentSlug ?? "agent"}`,
- message: ev.title,
- tabId: ev.tabId ?? ev.id,
- };
- }
-
- private tabLabel(tabId: string): string {
- const title = this.getTabTitle?.(tabId);
- if (title?.trim()) return title.trim();
- return `tab ${tabId.slice(0, 8)}`;
- }
-
- /**
- * Returns true when this `tabId` belongs to a subagent AND the user has
- * opted out of subagent turn notifications. On lookup failure
- * (`getTabParentId` returns `undefined` or throws) we err on the side
- * of "not a subagent" — better to over-notify than to silently drop
- * legitimate top-level events when the DB is briefly unreadable.
- */
- private isSubagentSuppressed(tabId: string): boolean {
- const config = this.loadConfig();
- if (config.notifySubagents) return false;
- if (!this.getTabParentId) return false;
- let parent: string | null | undefined;
- try {
- parent = this.getTabParentId(tabId);
- } catch {
- return false;
- }
- // Only a non-empty string parent id means "this tab is a subagent".
- return typeof parent === "string" && parent.length > 0;
- }
-
- // ─── Dedupe helpers ───────────────────────────────────────────
-
- private isDuplicate(key: string): boolean {
- const expires = this.recentlySent.get(key);
- if (expires === undefined) return false;
- if (expires <= Date.now()) {
- this.recentlySent.delete(key);
- return false;
- }
- return true;
- }
-
- private markSent(key: string): void {
- // Lazy-evict expired entries when the map gets large.
- if (this.recentlySent.size > 256) {
- const now = Date.now();
- for (const [k, exp] of this.recentlySent) {
- if (exp <= now) this.recentlySent.delete(k);
- }
- }
- this.recentlySent.set(key, Date.now() + this.dedupeWindowMs);
- }
-}
diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts
deleted file mode 100644
index d1e7891..0000000
--- a/packages/core/src/notifications/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// @dispatch/core — ntfy.sh push notifications
-
-export {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- NTFY_CONFIG_KEY,
- normalizeNtfyConfig,
- redactNtfyConfig,
- saveNtfyConfig,
-} from "./config.js";
-export {
- type AgentEventSource,
- type DispatcherOptions,
- NotificationDispatcher,
- type PermissionPromptSource,
- type TabParentLookup,
- type TabTitleLookup,
-} from "./dispatcher.js";
-export {
- buildNtfyUrl,
- type FetchLike,
- NTFY_BASE_URL,
- type NtfySendResult,
- sendNtfy,
-} from "./ntfy.js";
-export {
- type NotificationEvent,
- type NotificationEventType,
- NTFY_DEFAULT_EVENTS,
- NTFY_DEFAULT_PRIORITIES,
- NTFY_DEFAULT_TAGS,
- NTFY_EVENT_TYPES,
- type NtfyConfig,
- type NtfyPriority,
-} from "./types.js";
diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts
deleted file mode 100644
index eb5de9e..0000000
--- a/packages/core/src/notifications/ntfy.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-// ntfy.sh HTTP transport.
-//
-// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body
-// as the message and metadata passed via HTTP headers:
-// Title: notification title
-// Priority: 1..5 (3 = default)
-// Tags: comma-separated emoji shortcodes
-// Click: URL opened when the notification is tapped
-//
-// The server is hardcoded to the public ntfy.sh instance; the user only
-// configures a topic name. We intentionally use `fetch` directly — no
-// SDK, no extra deps.
-
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js";
-
-export interface NtfySendResult {
- ok: boolean;
- status?: number;
- error?: string;
-}
-
-/**
- * Lightweight fetch shape so callers (and tests) can inject a mock without
- * pulling in the DOM `fetch` type from a `Headers` instance.
- */
-export type FetchLike = (
- input: string,
- init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
-) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>;
-
-/** Base URL of the public ntfy.sh server. */
-export const NTFY_BASE_URL = "https://ntfy.sh";
-
-/**
- * Build the publish URL for a topic name.
- *
- * No client-side validation of the topic content: ntfy.sh's accepted
- * character set has changed over time and a regex here only locks users
- * out of legitimate topics. The topic is URL-encoded so the resulting
- * URL is always syntactically valid; if ntfy rejects the name the HTTP
- * error surfaces on the first send / `Send test`.
- */
-export function buildNtfyUrl(topic: string): string {
- return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`;
-}
-
-/**
- * Send a single notification to the configured ntfy topic.
- *
- * Fire-and-forget at call sites: the dispatcher uses
- * `void sendNtfy(...).catch(...)` so a slow/broken ntfy server never blocks
- * a turn. We still return a structured result so the explicit
- * `POST /notifications/test` route can surface failures back to the UI.
- *
- * Pure with respect to `config` / `event` — no DB, no module state.
- */
-export async function sendNtfy(
- config: NtfyConfig,
- event: NotificationEvent,
- fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike,
- timeoutMs = 10_000,
-): Promise<NtfySendResult> {
- if (!config.enabled) return { ok: false, error: "Notifications are disabled" };
- if (!config.topic.trim()) return { ok: false, error: "Topic is required" };
- const targetUrl = buildNtfyUrl(config.topic);
-
- const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3;
- const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? [];
- const tags = [...baseTags];
- if (event.tabId) {
- // Short, ASCII-only tag so ntfy's comma-separated header parser is happy.
- tags.push(`tab-${event.tabId.slice(0, 8)}`);
- }
-
- const headers: Record<string, string> = {
- // ntfy is tolerant of non-ASCII in the Title header but many proxies
- // aren't — sanitizeHeader strips CR/LF/control chars (injection guard)
- // and leaves UTF-8 in place. Body is sent verbatim as UTF-8.
- Title: sanitizeHeader(event.title),
- Priority: String(priority),
- "Content-Type": "text/plain; charset=utf-8",
- };
- if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(",");
- if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl);
- const authValue = buildAuthHeaderValue(config.authToken);
- if (authValue) headers.Authorization = authValue;
-
- // Per-request abort so a hung server doesn't pin a Bun worker forever.
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
-
- try {
- const res = await fetchImpl(targetUrl, {
- method: "POST",
- headers,
- body: event.message,
- signal: controller.signal,
- });
- if (!res.ok) {
- const text = await safeReadText(res);
- return {
- ok: false,
- status: res.status,
- error: `ntfy responded ${res.status} ${res.statusText ?? ""}: ${text}`.trim(),
- };
- }
- return { ok: true, status: res.status };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- return { ok: false, error: msg };
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Build the `Authorization` header value from the user-configured token.
- *
- * - Empty/blank ⇒ no header.
- * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space)
- * ⇒ used verbatim. Lets users paste a complete header for Basic auth or
- * any other scheme their private ntfy server supports.
- * - Otherwise ⇒ prefixed with `Bearer ` (the common case).
- */
-function buildAuthHeaderValue(rawToken: string): string | null {
- const trimmed = (rawToken ?? "").trim();
- if (!trimmed) return null;
- if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) {
- // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw").
- return sanitizeHeader(trimmed);
- }
- return `Bearer ${sanitizeHeader(trimmed)}`;
-}
-
-function sanitizeHeader(value: string): string {
- // Strip CR/LF (header injection guard) and other control chars, then trim.
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
- return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim();
-}
-
-async function safeReadText(res: { text(): Promise<string> }): Promise<string> {
- try {
- const t = await res.text();
- return t.length > 200 ? `${t.slice(0, 200)}…` : t;
- } catch {
- return "";
- }
-}
diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts
deleted file mode 100644
index ef6c059..0000000
--- a/packages/core/src/notifications/types.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-// ntfy.sh push notifications — types
-
-/**
- * Catalog of notification-worthy events.
- *
- * Kept intentionally small and stable: each entry is something a human
- * actually wants pushed to their phone. New event types should be added
- * with a sensible default (`NTFY_DEFAULT_EVENTS`) and a mapping in the
- * dispatcher.
- */
-export type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-/** ntfy priority levels (1=min … 5=max). */
-export type NtfyPriority = 1 | 2 | 3 | 4 | 5;
-
-/**
- * A single notification request. Synthesised by the dispatcher from a
- * higher-level event source (AgentManager / PermissionManager); fed to
- * the ntfy transport.
- *
- * `dedupeKey` lets the dispatcher suppress duplicate sends (e.g. the
- * permission system re-emits the pending list on every change).
- */
-export interface NotificationEvent {
- type: NotificationEventType;
- /** Notification title (short). */
- title: string;
- /** Notification body. */
- message: string;
- /** Optional ntfy tags (emoji shortcodes — e.g. `["white_check_mark"]`). */
- tags?: string[];
- /** Optional priority override. Defaults are per-event-type. */
- priority?: NtfyPriority;
- /** Optional URL the notification deep-links to when tapped. */
- clickUrl?: string;
- /** Origin tab id (informational; included in tags as `tab:<short>`). */
- tabId?: string;
- /**
- * Stable key for suppressing duplicates. Same key + same type within a
- * short window ⇒ dropped silently.
- */
- dedupeKey?: string;
-}
-
-/**
- * Persisted ntfy configuration. Lives in the `settings` table under a
- * single key (`ntfy_config`) — one global config, matching the codebase's
- * existing single-user assumption (cf. `title_model_*`, `perm_*`).
- *
- * - `enabled` — master switch. Off ⇒ dispatcher never sends.
- * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The
- * server is hardcoded to https://ntfy.sh; the user only picks a topic.
- * Missing ⇒ dispatcher never sends.
- * - `authToken` — optional bearer token (rarely needed against ntfy.sh
- * directly; preserved for users behind an auth-protected proxy).
- * - `events` — per-event-type enable map. Missing entries default to OFF
- * so a newly-added event type doesn't silently start firing.
- * - `notifySubagents` — when false (default), `turn-completed` and
- * `turn-error` notifications from subagent tabs (tabs with a
- * `parentTabId`) are suppressed. A parent agent that spawns 8
- * subagents would otherwise push 9 "Turn complete" notifications per
- * round — usually noise. `permission-required` is NOT gated: even a
- * subagent's permission prompt needs a human tap to proceed.
- * `agent-spawned` is already top-level-only by construction.
- */
-export interface NtfyConfig {
- enabled: boolean;
- topic: string;
- authToken: string;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-/** All event types this build knows about (the source of truth for UI). */
-export const NTFY_EVENT_TYPES: NotificationEventType[] = [
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-];
-
-/** Default per-event-type toggles. */
-export const NTFY_DEFAULT_EVENTS: Record<NotificationEventType, boolean> = {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
-};
-
-/** Default priority per event type (when the event itself doesn't override). */
-export const NTFY_DEFAULT_PRIORITIES: Record<NotificationEventType, NtfyPriority> = {
- "turn-completed": 3,
- "turn-error": 4,
- "permission-required": 4,
- "agent-spawned": 2,
-};
-
-/** Default tag (emoji) per event type. */
-export const NTFY_DEFAULT_TAGS: Record<NotificationEventType, string[]> = {
- "turn-completed": ["white_check_mark"],
- "turn-error": ["rotating_light"],
- "permission-required": ["lock"],
- "agent-spawned": ["sparkles"],
-};
diff --git a/packages/core/src/permission/evaluate.ts b/packages/core/src/permission/evaluate.ts
deleted file mode 100644
index 7a0d235..0000000
--- a/packages/core/src/permission/evaluate.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { PermissionRule, Ruleset } from "./index.js";
-import { Wildcard } from "./wildcard.js";
-
-export function evaluate(
- permission: string,
- pattern: string,
- ...rulesets: Ruleset[]
-): PermissionRule {
- const flat = ([] as PermissionRule[]).concat(...rulesets);
- const match = flat.findLast(
- (rule) => Wildcard.match(rule.permission, permission) && Wildcard.match(rule.pattern, pattern),
- );
- if (match) return match;
- return { action: "ask", permission, pattern };
-}
diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts
deleted file mode 100644
index bf6efaf..0000000
--- a/packages/core/src/permission/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export interface PermissionRule {
- permission: string;
- pattern: string;
- action: "allow" | "deny" | "ask";
-}
-
-export type Ruleset = PermissionRule[];
-
-export type PermissionReply = "once" | "always" | "reject";
-
-export interface PermissionRequest {
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface PermissionChecker {
- ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply>;
- getPending(): Array<{ id: string; request: PermissionRequest }>;
-}
-
-export { evaluate } from "./evaluate.js";
-export { PermissionService } from "./service.js";
-export { Wildcard } from "./wildcard.js";
diff --git a/packages/core/src/permission/service.ts b/packages/core/src/permission/service.ts
deleted file mode 100644
index 1c8c6f0..0000000
--- a/packages/core/src/permission/service.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { evaluate } from "./evaluate.js";
-import type { PermissionReply, PermissionRequest, PermissionRule, Ruleset } from "./index.js";
-
-export class PermissionService {
- private pending: Map<
- string,
- {
- request: PermissionRequest;
- resolve: (reply: PermissionReply) => void;
- reject: (err: Error) => void;
- }
- > = new Map();
- private approved: Ruleset = [];
- private idCounter = 0;
-
- approve(rules: PermissionRule[]): void {
- this.approved.push(...rules);
- }
-
- async ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply> {
- // Evaluate against all rulesets + approved (approved overrides) for ALL patterns
- const patterns = request.always.length > 0 ? request.always : ["*"];
- const results = patterns.map((pattern) =>
- evaluate(request.permission, pattern, ...rulesets, this.approved),
- );
-
- // Any deny → deny
- const denied = results.find((r) => r.action === "deny");
- if (denied) {
- throw new Error(`Permission denied: ${request.permission} ${denied.pattern}`);
- }
-
- // All allow → allow
- if (results.every((r) => r.action === "allow")) {
- return "once";
- }
-
- // action === "ask" — create a pending request
- const id = String(++this.idCounter);
- return new Promise<PermissionReply>((resolve, reject) => {
- this.pending.set(id, { request, resolve, reject });
- });
- }
-
- reply(id: string, reply: PermissionReply): void {
- if (reply === "reject") {
- // Reject cascade — reject all pending
- for (const [pendingId, entry] of this.pending) {
- entry.reject(new Error(`Permission rejected: ${entry.request.permission}`));
- this.pending.delete(pendingId);
- }
- return;
- }
-
- this.resolvePending(id, reply);
- }
-
- getPending(): Array<{ id: string; request: PermissionRequest }> {
- return Array.from(this.pending.entries()).map(([id, { request }]) => ({ id, request }));
- }
-
- private resolvePending(id: string, reply: PermissionReply): void {
- const entry = this.pending.get(id);
- if (!entry) return;
-
- if (reply === "always") {
- // Add rules for each pattern in request.patterns
- for (const pattern of entry.request.patterns) {
- this.approved.push({ permission: entry.request.permission, pattern, action: "allow" });
- }
- }
-
- entry.resolve(reply);
- this.pending.delete(id);
- }
-}
diff --git a/packages/core/src/permission/wildcard.ts b/packages/core/src/permission/wildcard.ts
deleted file mode 100644
index 7874680..0000000
--- a/packages/core/src/permission/wildcard.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const Wildcard = {
- match(pattern: string, value: string): boolean {
- // Escape regex special chars except * and ?
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
- // Convert wildcards
- const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
- const regex = new RegExp(`^${regexStr}$`, "s");
- return regex.test(value);
- },
-};
diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts
deleted file mode 100644
index a3fac70..0000000
--- a/packages/core/src/skills/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- resolveSkillsForAgent,
-} from "./loader.js";
-export { parseSkillFile } from "./parser.js";
diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts
deleted file mode 100644
index 5d043dd..0000000
--- a/packages/core/src/skills/loader.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import chokidar from "chokidar";
-import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js";
-import { parseSkillFile } from "./parser.js";
-
-// ─── Internal Helpers ────────────────────────────────────────────
-
-/**
- * Recursively scan a directory for .md skill files.
- * The `directory` field on each skill is the relative path from `baseDir` to the file's parent.
- * Skips the `agents/` subdirectory (handled separately).
- */
-function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] {
- if (!fs.existsSync(baseDir)) return [];
-
- const results: SkillDefinition[] = [];
-
- function walk(dir: string) {
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return;
- }
-
- for (const entry of entries) {
- const fullPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- // Skip agents/ at the top level (handled by loadAgentMappings)
- const relFromBase = path.relative(baseDir, fullPath);
- if (relFromBase === "agents") continue;
- walk(fullPath);
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
- const relDir = path.relative(baseDir, dir);
- // relDir is "" for root, "general" for general/, "general/webapps" for nested
- const directory = relDir === "." ? "" : relDir;
- try {
- const content = fs.readFileSync(fullPath, "utf-8");
- const skill = parseSkillFile(fullPath, content, scope, directory);
- results.push(skill);
- } catch {
- // Skip unreadable files
- }
- }
- }
- }
-
- walk(baseDir);
- return results;
-}
-
-function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] {
- if (!fs.existsSync(agentsDir)) {
- return [];
- }
-
- const results: AgentSkillMapping[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(agentsDir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".txt")) {
- continue;
- }
-
- const fileName = entry.name;
- let isOrchestrator = false;
- let agentType: string;
-
- if (fileName.endsWith(".o.txt")) {
- isOrchestrator = true;
- agentType = fileName.slice(0, -6); // remove ".o.txt"
- } else {
- agentType = fileName.slice(0, -4); // remove ".txt"
- }
-
- const filePath = path.join(agentsDir, fileName);
- try {
- const content = fs.readFileSync(filePath, "utf-8");
- const skills = content
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.length > 0 && !line.startsWith("#"));
-
- results.push({ agentType, isOrchestrator, skills, scope });
- } catch {
- // Skip unreadable mapping files
- }
- }
-
- return results;
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export function loadSkills(projectDir: string): {
- skills: SkillDefinition[];
- mappings: AgentSkillMapping[];
-} {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- const skills: SkillDefinition[] = [];
- const mappings: AgentSkillMapping[] = [];
-
- // 1. Scan all global skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(globalBase, "global"));
-
- // 2. Scan all project skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(projectBase, "project"));
-
- // 3. Agent mappings — global then project
- mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global"));
- mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project"));
-
- return { skills, mappings };
-}
-
-export function resolveSkillsForAgent(
- agentType: string,
- isOrchestrator: boolean,
- skills: SkillDefinition[],
- mappings: AgentSkillMapping[],
-): SkillDefinition[] {
- // Helper: project overrides global for same-named skills
- const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => {
- const seen = new Map<string, SkillDefinition>();
- for (const skill of list) {
- const existing = seen.get(skill.name);
- if (!existing || skill.scope === "project") {
- seen.set(skill.name, skill);
- }
- }
- return Array.from(seen.values());
- };
-
- // All default-directory skills (global first, then project — dedupe preserves project)
- const defaultSkills = skills.filter((s) => s.directory === "default");
-
- // Skills mapped to this agent type
- const relevantMappings = mappings.filter(
- (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator,
- );
-
- // Gather agent-specific skills in order: global mappings first, then project
- const globalMappings = relevantMappings.filter((m) => m.scope === "global");
- const projectMappings = relevantMappings.filter((m) => m.scope === "project");
-
- const agentSkillNames: string[] = [];
- for (const mapping of [...globalMappings, ...projectMappings]) {
- for (const skillFile of mapping.skills) {
- const skillName = path.basename(skillFile, path.extname(skillFile));
- agentSkillNames.push(skillName);
- }
- }
-
- const agentSpecificSkills = agentSkillNames
- .map((name) => getSkillByName(name, skills, "project"))
- .filter((s): s is SkillDefinition => s !== undefined);
-
- const combined = [...defaultSkills, ...agentSpecificSkills];
- return dedupeByName(combined);
-}
-
-export function getSkillByName(
- name: string,
- skills: SkillDefinition[],
- preferScope?: SkillScope,
-): SkillDefinition | undefined {
- const matches = skills.filter((s) => s.name === name);
- if (matches.length === 0) {
- return undefined;
- }
-
- if (preferScope) {
- const preferred = matches.find((s) => s.scope === preferScope);
- if (preferred) {
- return preferred;
- }
- }
-
- // Default: project takes precedence
- const projectMatch = matches.find((s) => s.scope === "project");
- return projectMatch ?? matches[0];
-}
-
-export function createSkillsWatcher(
- projectDir: string,
- onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void,
-): { close(): void } {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const reload = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- const result = loadSkills(projectDir);
- onChange(result);
- }, 300);
- };
-
- const watchPaths = [globalBase, projectBase];
-
- const watcher = chokidar.watch(watchPaths, {
- ignoreInitial: true,
- persistent: true,
- });
-
- watcher.on("add", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("change", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("unlink", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close();
- },
- };
-}
diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts
deleted file mode 100644
index bd1205e..0000000
--- a/packages/core/src/skills/parser.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as path from "node:path";
-import { parse } from "smol-toml";
-import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js";
-
-const FRONTMATTER_DELIMITER = "+++";
-
-export function parseSkillFile(
- filePath: string,
- content: string,
- scope: SkillScope,
- directory: SkillDirectory,
-): SkillDefinition {
- const defaultName = path.basename(filePath, path.extname(filePath));
-
- let name = defaultName;
- let description = "";
- let tags: string[] = [];
- let body = content;
-
- // Check for TOML frontmatter
- const trimmed = content.trimStart();
- if (trimmed.startsWith(FRONTMATTER_DELIMITER)) {
- const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length);
- // Find the closing +++
- const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER);
- if (closeIndex !== -1) {
- const tomlSource = afterOpen.slice(0, closeIndex);
- body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length);
-
- try {
- const frontmatter = parse(tomlSource);
-
- if (typeof frontmatter.name === "string") {
- name = frontmatter.name;
- }
- if (typeof frontmatter.description === "string") {
- description = frontmatter.description;
- }
- if (Array.isArray(frontmatter.tags)) {
- tags = (frontmatter.tags as unknown[]).filter((t): t is string => typeof t === "string");
- }
- } catch {
- // Malformed TOML — fall through with defaults
- }
- }
- }
-
- // Trim leading newline from body (handles both LF and CRLF)
- body = body.replace(/^\r?\n/, "");
-
- return {
- name,
- description,
- tags,
- content: body,
- scope,
- source: filePath,
- directory,
- };
-}
diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts
deleted file mode 100644
index 1aaba8e..0000000
--- a/packages/core/src/tools/bash-arity.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-// Hardcoded dictionary of well-known commands and their arity
-// (number of tokens that form the "human-understandable" prefix)
-const ARITY: Record<string, number> = {
- git: 2, // "git checkout", "git commit", etc.
- npm: 3, // "npm run dev", "npm install -g"
- docker: 2, // "docker compose", "docker build"
- kubectl: 2, // "kubectl get", "kubectl apply"
- bun: 2, // "bun install", "bun test"
- cargo: 2, // "cargo build", "cargo test"
- go: 2, // "go build", "go test"
- python: 2, // "python -m", "python script.py"
- python3: 2,
- pip: 2,
- pip3: 2,
- brew: 2,
- apt: 2,
- "apt-get": 2,
- dnf: 2,
- yum: 2,
- pacman: 2,
- systemctl: 2,
- journalctl: 2,
- ssh: 2,
- scp: 2,
- rsync: 2,
- curl: 2, // "curl -X", "curl https://"
- wget: 2,
- tar: 2, // "tar -xzf", "tar -czf"
- zip: 2,
- unzip: 2,
- chown: 2,
- chmod: 2,
- mount: 2,
- umount: 2,
- // Default: all other commands are arity 1
-};
-
-// Get the normalized prefix for a list of tokens
-export function prefix(tokens: string[]): string[] {
- if (tokens.length === 0) return [];
- const first = tokens[0];
- if (!first) return [];
- const arity = ARITY[first.toLowerCase()];
- if (arity === undefined) {
- return [first];
- }
- return tokens.slice(0, arity);
-}
diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts
deleted file mode 100644
index 0655ad7..0000000
--- a/packages/core/src/tools/key-usage.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { z } from "zod";
-import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js";
-import { getAccountUsageWithSource } from "../credentials/claude.js";
-import type { OpencodeUsageReport } from "../credentials/opencode.js";
-import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js";
-import type { KeyState, ToolDefinition } from "../types/index.js";
-
-/**
- * Collaborators the `key_usage` tool needs from the API layer (which owns the
- * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*`
- * hooks default to the real credential fetchers but are injectable so tests can
- * exercise the tool without network or DB access.
- */
-export interface KeyUsageCallbacks {
- /** Current key states from the model registry (definition + active/exhausted status). */
- listKeys(): KeyState[];
- /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */
- listClaudeAccounts(): ClaudeAccount[];
- /**
- * Fetch an anthropic account's usage with provenance (live vs cache).
- * Defaults to `getAccountUsageWithSource`.
- */
- fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- /**
- * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
- * local cache). Defaults to `fetchOpencodeUsage`.
- */
- fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}
-
-/** A single normalized usage window (5-hour / week / month). */
-interface UsageWindow {
- label: string;
- /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */
- remainingPercent?: number;
- /** Epoch-ms the window resets. Omitted when the source gives no reset time. */
- resetsAt?: number;
-}
-
-/** Fully normalized per-key usage, ready for rendering. */
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- status: "active" | "exhausted";
- lastError?: string;
- exhaustedAt?: number;
- /** Provenance of the usage figures: a fresh live fetch or a cached payload. */
- dataSource?: "live" | "cache";
- /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */
- cachedAt?: number;
- windows: UsageWindow[];
- /** Set when no usage figures could be obtained for an otherwise-supported key. */
- unavailableReason?: string;
- /** Set when the provider has no usage-reporting support. */
- unsupported?: boolean;
-}
-
-function clampPercent(value: number): number {
- if (value < 0) return 0;
- if (value > 100) return 100;
- return value;
-}
-
-/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */
-function toWindow(
- label: string,
- bucket?: { utilization?: number; resetsAt?: number },
-): UsageWindow | null {
- if (!bucket) return null;
- const hasUtil = typeof bucket.utilization === "number";
- const hasReset = typeof bucket.resetsAt === "number";
- if (!hasUtil && !hasReset) return null;
- return {
- label,
- ...(hasUtil
- ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) }
- : {}),
- ...(hasReset ? { resetsAt: bucket.resetsAt } : {}),
- };
-}
-
-function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.sevenDay);
- if (week) windows.push(week);
- return windows;
-}
-
-function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.weekly);
- if (week) windows.push(week);
- const month = toWindow("month", report.monthly);
- if (month) windows.push(month);
- return windows;
-}
-
-/**
- * Resolve which Claude account backs an `anthropic` key. Matches by key id or by
- * the account's source file (the key's `credentials_file`), falling back to the
- * first available account — mirrors the existing `/models/key-usage` route.
- */
-function matchAnthropicAccount(
- accounts: ClaudeAccount[],
- keyId: string,
- credFile?: string,
-): ClaudeAccount | undefined {
- const matched = accounts.find(
- (a) => a.id === keyId || (credFile != null && a.source === credFile),
- );
- return matched ?? accounts[0];
-}
-
-function iso(ms: number): string {
- return new Date(ms).toISOString();
-}
-
-/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */
-function formatDuration(ms: number): string {
- const totalSec = Math.round(Math.abs(ms) / 1000);
- const days = Math.floor(totalSec / 86400);
- const hours = Math.floor((totalSec % 86400) / 3600);
- const minutes = Math.floor((totalSec % 3600) / 60);
- const parts: string[] = [];
- if (days > 0) parts.push(`${days}d`);
- if (hours > 0) parts.push(`${hours}h`);
- if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
- return parts.join(" ");
-}
-
-function formatRelative(targetMs: number, nowMs: number): string {
- const delta = targetMs - nowMs;
- return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`;
-}
-
-function formatWindow(window: UsageWindow, now: number): string {
- const parts: string[] = [];
- if (typeof window.remainingPercent === "number") {
- parts.push(`${window.remainingPercent}% remaining`);
- }
- if (typeof window.resetsAt === "number") {
- parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`);
- }
- return `${window.label}: ${parts.join(", ")}`;
-}
-
-/**
- * Render normalized usage entries into an AI-friendly text block. Pure — `now`
- * is injected so relative timestamps are deterministic under test.
- */
-export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string {
- if (entries.length === 0) return "No API keys matched.";
-
- const lines: string[] = [];
- lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`);
-
- for (const entry of entries) {
- lines.push("");
- lines.push(`[${entry.keyId}] provider: ${entry.provider}`);
-
- if (entry.status === "exhausted") {
- const since =
- typeof entry.exhaustedAt === "number"
- ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})`
- : "";
- lines.push(`status: EXHAUSTED${since}`);
- if (entry.lastError) lines.push(`last error: ${entry.lastError}`);
- } else {
- lines.push("status: active");
- }
-
- if (entry.unsupported) {
- lines.push(
- `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`,
- );
- continue;
- }
-
- if (entry.dataSource === "live") {
- lines.push("data: live (fetched just now)");
- } else if (entry.dataSource === "cache") {
- lines.push(
- typeof entry.cachedAt === "number"
- ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})`
- : "data: cached (source timestamp unknown)",
- );
- }
-
- for (const window of entry.windows) {
- lines.push(formatWindow(window, now));
- }
-
- if (entry.unavailableReason) {
- lines.push(`usage: unavailable — ${entry.unavailableReason}`);
- }
- }
-
- return lines.join("\n");
-}
-
-async function buildEntry(
- key: KeyState,
- accounts: ClaudeAccount[],
- fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>,
- fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
-): Promise<KeyUsageEntry> {
- const def = key.definition;
- const entry: KeyUsageEntry = {
- keyId: def.id,
- provider: def.provider,
- status: key.status,
- windows: [],
- ...(key.lastError ? { lastError: key.lastError } : {}),
- ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}),
- };
-
- if (def.provider === "anthropic") {
- const account = matchAnthropicAccount(accounts, def.id, def.credentials_file);
- if (!account) {
- entry.unavailableReason = "no Claude account credentials available for this key";
- return entry;
- }
- let result: ClaudeUsageResult | null = null;
- try {
- result = await fetchAnthropic(account);
- } catch {
- result = null;
- }
- if (!result) {
- entry.unavailableReason = "no live usage data and no cached usage available";
- return entry;
- }
- entry.dataSource = result.source;
- if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt;
- entry.windows = anthropicWindows(result.report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage endpoint returned no window data";
- }
- return entry;
- }
-
- if (def.provider === "opencode-go") {
- let report: OpencodeUsageReport | null = null;
- try {
- report = await fetchOpencode(def.id);
- } catch {
- report = null;
- }
- if (!report) {
- entry.unavailableReason =
- "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)";
- return entry;
- }
- entry.dataSource = "live";
- entry.windows = opencodeWindows(report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage source returned no window data";
- }
- return entry;
- }
-
- entry.unsupported = true;
- return entry;
-}
-
-export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition {
- const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource;
- const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage;
-
- return {
- name: "key_usage",
- description: [
- "Report current usage levels for configured API keys so you can pick a key with",
- "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.",
- "",
- "For each key it returns: provider, active/exhausted status (with the last error when",
- "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where",
- "the provider exposes it), each window's reset timestamp, and whether the figures are",
- "live or served from cache (with the cache's last-fetched time).",
- "",
- "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is",
- "supported for anthropic and opencode-go keys.",
- ].join("\n"),
- parameters: z.object({
- key_id: z
- .string()
- .optional()
- .describe(
- 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.',
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined;
-
- const allKeys = callbacks.listKeys();
- if (allKeys.length === 0) {
- return "No API keys are configured.";
- }
-
- let keys = allKeys;
- if (requestedKeyId) {
- keys = allKeys.filter((k) => k.definition.id === requestedKeyId);
- if (keys.length === 0) {
- const available = allKeys.map((k) => k.definition.id).join(", ");
- return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`;
- }
- }
-
- const accounts = callbacks.listClaudeAccounts();
- const entries: KeyUsageEntry[] = [];
- for (const key of keys) {
- entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode));
- }
-
- return formatKeyUsage(entries, Date.now());
- },
- };
-}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
deleted file mode 100644
index e003099..0000000
--- a/packages/core/src/tools/list-files.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readdir } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-export function createListFilesTool(workingDirectory: string): ToolDefinition {
- return {
- name: "list_files",
- description: "List files and directories at a path relative to the working directory.",
- parameters: z.object({
- path: z
- .string()
- .optional()
- .describe("Path to list, relative to the working directory. Defaults to '.'"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const relPath = (args.path as string | undefined) ?? ".";
- // Canonicalize so a symlink-in-workdir pointing outside is detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, relPath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- try {
- const entries = await readdir(absolutePath, { withFileTypes: true });
- if (entries.length === 0) {
- return "(empty directory)";
- }
- return entries
- .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
- .join("\n");
- } catch (err) {
- return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts
deleted file mode 100644
index 3842cd4..0000000
--- a/packages/core/src/tools/lsp.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { isAbsolute, resolve } from "node:path";
-import { pathToFileURL } from "node:url";
-import { z } from "zod";
-import { report as reportDiagnostics } from "../lsp/diagnostic.js";
-import type { LspManager } from "../lsp/manager.js";
-import type { ResolvedLspServer } from "../lsp/server.js";
-import type { ToolDefinition } from "../types/index.js";
-
-const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const;
-type Operation = (typeof OPERATIONS)[number];
-
-/**
- * Context the LSP tool needs from the host: the live manager, the tab's
- * effective working directory (used as the LSP `root`), and the servers
- * resolved from that directory's `dispatch.toml`.
- */
-export interface LspToolContext {
- manager: LspManager;
- workingDirectory: string;
- servers: ResolvedLspServer[];
-}
-
-/**
- * On-demand LSP query tool. Exposes diagnostics plus the navigation
- * capabilities (hover/definition/references/documentSymbol) for a file at a
- * position. Gated behind `perm_lsp` by the host.
- *
- * Coordinates are **1-based** in this tool's API (editor-style, matching what
- * `read_file` shows); they are converted to the LSP wire's 0-based positions
- * before the request.
- */
-export function createLspTool(getContext: () => LspToolContext): ToolDefinition {
- return {
- name: "lsp",
- description:
- "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " +
- "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " +
- "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " +
- "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " +
- "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.",
- parameters: z.object({
- operation: z.enum(OPERATIONS).describe("The LSP operation to perform"),
- path: z.string().describe("Path to the file, relative to the working directory"),
- line: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Line number, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- character: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Character/column, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const { manager, workingDirectory, servers } = getContext();
- const operation = args.operation as Operation;
- const pathArg = typeof args.path === "string" ? args.path : "";
- if (!pathArg) return "Error: 'path' is required.";
-
- const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg);
-
- if (servers.length === 0) {
- return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml.";
- }
- if (!manager.hasServerForFile(file, servers)) {
- return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`;
- }
-
- // Sync the file so the server has current content, then act.
- await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" });
-
- if (operation === "diagnostics") {
- const all = manager.getDiagnostics({ root: workingDirectory, servers, file });
- const block = reportDiagnostics(file, all[file] ?? []);
- return block || `No errors reported for ${pathArg}.`;
- }
-
- if (operation === "documentSymbol") {
- const uri = pathToFileURL(file).href;
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method: "textDocument/documentSymbol",
- params: { textDocument: { uri } },
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No symbols found in ${pathArg}.`
- : JSON.stringify(flat, null, 2);
- }
-
- // Positional operations need line + character.
- const line = typeof args.line === "number" ? Math.floor(args.line) : undefined;
- const character = typeof args.character === "number" ? Math.floor(args.character) : undefined;
- if (line === undefined || character === undefined) {
- return `Error: '${operation}' requires both 'line' and 'character' (1-based).`;
- }
-
- const uri = pathToFileURL(file).href;
- // Convert editor 1-based → LSP wire 0-based.
- const position = { line: line - 1, character: character - 1 };
- const method =
- operation === "hover"
- ? "textDocument/hover"
- : operation === "definition"
- ? "textDocument/definition"
- : "textDocument/references";
- const params: Record<string, unknown> = {
- textDocument: { uri },
- position,
- ...(operation === "references" ? { context: { includeDeclaration: true } } : {}),
- };
-
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method,
- params,
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No results found for ${operation} at ${pathArg}:${line}:${character}.`
- : JSON.stringify(flat, null, 2);
- },
- };
-}
diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts
deleted file mode 100644
index 3bba0d3..0000000
--- a/packages/core/src/tools/path-utils.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { realpath } from "node:fs/promises";
-import { basename, dirname, join, resolve } from "node:path";
-
-/**
- * Resolve a path to its canonical absolute form, following symlinks at
- * every level.
- *
- * When the leaf does not exist (common for `write_file` creating a new
- * file), walks up to the nearest existing ancestor, canonicalizes that,
- * then re-appends the missing trailing segments — ensuring a symlink in
- * the *middle* of the path is still resolved. Without this, a
- * `workdir/escape-link/new-file.txt` write where `escape-link` points
- * outside the workdir would slip the containment check.
- *
- * Used everywhere we compare a user-supplied path against a trusted
- * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the
- * only reliable way to detect a symlink-in-workdir-pointing-outside
- * escape; lexical-only checks let those through.
- *
- * Argument semantics match `path.resolve(...paths)`: later absolute
- * segments override earlier ones, relative segments are joined.
- */
-export async function canonicalize(...paths: string[]): Promise<string> {
- const lexical = resolve(...paths);
-
- // Fast path: full path exists, realpath resolves all symlinks.
- try {
- return await realpath(lexical);
- } catch {
- // Path doesn't exist — fall through to ancestor walk.
- }
-
- // Walk up until we hit an existing ancestor we can realpath, then
- // re-append the missing trailing segments. This handles cases like
- // write_file creating /workdir/symlink/new/dir/file.txt where the
- // "symlink" segment exists but the rest doesn't.
- let current = lexical;
- const trailing: string[] = [];
- while (true) {
- const parent = dirname(current);
- if (parent === current) break; // hit filesystem root
- trailing.unshift(basename(current));
- try {
- const realParent = await realpath(parent);
- return join(realParent, ...trailing);
- } catch {
- current = parent;
- }
- }
-
- // No existing ancestor (pathological — e.g. the entire mount is gone).
- // Return the lexical path; downstream containment checks will still
- // catch obvious escapes via `..` etc.
- return lexical;
-}
diff --git a/packages/core/src/tools/read-file-slice.ts b/packages/core/src/tools/read-file-slice.ts
deleted file mode 100644
index 0f83bcb..0000000
--- a/packages/core/src/tools/read-file-slice.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { SPILL_ROOT } from "./truncate.js";
-
-const DEFAULT_LENGTH = 5000;
-
-export function createReadFileSliceTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description:
- "Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory."),
- line: z.number().int().min(1).describe("1-indexed line number to slice into."),
- charOffset: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe("0-indexed character offset within the line. Default: 0 (start of line)."),
- charLength: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const lineNumber = args.line as number;
- const charOffset =
- typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0;
- const charLength =
- typeof args.charLength === "number"
- ? Math.max(1, Math.floor(args.charLength))
- : DEFAULT_LENGTH;
-
- // Canonicalize all three so symlink-in-workdir escapes are detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- const lines = raw.split("\n");
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? lines.length - 1 : lines.length;
-
- if (lineNumber > totalLines) {
- return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`;
- }
-
- const line = lines[lineNumber - 1] ?? "";
- const lineLength = line.length;
-
- if (charOffset >= lineLength) {
- return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`;
- }
-
- const sliceEnd = Math.min(charOffset + charLength, lineLength);
- const slice = line.slice(charOffset, sliceEnd);
- const remaining = lineLength - sliceEnd;
-
- const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`;
- return `${header}\n${slice}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
deleted file mode 100644
index fa82dce..0000000
--- a/packages/core/src/tools/read-file.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { MAX_LINES, SPILL_ROOT } from "./truncate.js";
-
-// Per-line truncation: any single line longer than MAX_LINE_CHARS is cut and
-// replaced with a marker indicating the total length. This protects against
-// minified files / base64 blobs / massive single-line JSON. The AI can use
-// `read_file_slice` to inspect a specific char range within a long line.
-const MAX_LINE_CHARS = 2000;
-
-// Aligned with the universal truncator's MAX_LINES so a default-args read
-// returns a response that fits under the truncator's line ceiling. Without
-// this alignment, every default read of a >500-line file got returned by
-// the tool and then immediately spilled by the truncator — wasted work.
-// Char-dense files can still spill via MAX_CHARS, but that's content-
-// dependent rather than guaranteed.
-const DEFAULT_LIMIT = MAX_LINES;
-// Hard cap on lines per request even if `limit` is larger or omitted.
-// Prevents a `read_file(huge.log)` with no params from returning a million
-// lines. The universal truncator at the agent level will spill anything
-// over its own threshold, but this is a tighter first line of defense
-// scoped to the read tool itself.
-const HARD_LIMIT = 5000;
-
-export function createReadFileTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file",
- description:
- "Read a file relative to the working directory. Returns up to `limit` lines starting at line `offset` (1-indexed). Lines longer than 2000 chars are truncated mid-line with a marker showing the total length — use the `read_file_slice` tool to read a specific char range within a long line. If the response is still too large, the dispatch tool-output truncator may spill the full content to /tmp/dispatch/tool-results/.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- offset: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe("1-indexed start line. Default: 1 (start of file)."),
- limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max lines to return. Default: ${DEFAULT_LIMIT}. Hard cap: ${HARD_LIMIT}. Use a small limit when exploring a large file.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const offset = typeof args.offset === "number" ? Math.max(1, Math.floor(args.offset)) : 1;
- const requestedLimit =
- typeof args.limit === "number" ? Math.max(1, Math.floor(args.limit)) : DEFAULT_LIMIT;
- const limit = Math.min(requestedLimit, HARD_LIMIT);
-
- // Canonicalize all three so symlink-in-workdir escapes are detected:
- // a workdir-relative path that resolves through symlinks to /etc must
- // fail the containment check below.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- // A truly empty file (0 bytes) would otherwise slip through the
- // line-counting below: `"".split("\n")` is `[""]` and there's no
- // trailing newline, yielding a spurious `totalLines === 1`.
- if (raw === "") {
- return `(empty file: ${filePath})`;
- }
-
- const allLines = raw.split("\n");
- // `split("\n")` produces an extra empty entry when the file ends with a
- // newline. The total line count we report to the caller should match
- // the human-visible line count (lines that have content or terminate
- // with \n).
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? allLines.length - 1 : allLines.length;
-
- if (totalLines === 0) {
- return `(empty file: ${filePath})`;
- }
-
- if (offset > totalLines) {
- return `Error: offset ${offset} exceeds file length (${totalLines} lines).`;
- }
-
- const startIdx = offset - 1; // 0-indexed
- const endIdx = Math.min(startIdx + limit, totalLines);
- const slice = allLines.slice(startIdx, endIdx);
-
- // Apply per-line truncation. We tag truncated lines with the line
- // number and total chars so the AI knows how to call read_file_slice.
- const rendered: string[] = [];
- for (let i = 0; i < slice.length; i++) {
- const lineNumber = startIdx + i + 1;
- const line = slice[i] ?? "";
- if (line.length > MAX_LINE_CHARS) {
- const visible = line.slice(0, MAX_LINE_CHARS);
- rendered.push(
- `${visible}...[line ${lineNumber} truncated, total ${line.length.toLocaleString()} chars; use read_file_slice with path="${filePath}" line=${lineNumber} to read more]`,
- );
- } else {
- rendered.push(line);
- }
- }
-
- const header = `[file: ${filePath} — lines ${offset}-${endIdx} of ${totalLines}]`;
- return `${header}\n${rendered.join("\n")}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts
deleted file mode 100644
index e80dbd0..0000000
--- a/packages/core/src/tools/read-tab.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { z } from "zod";
-import type { AgentStatus, ToolDefinition } from "../types/index.js";
-import type { TabResolution } from "./send-to-tab.js";
-
-export interface ReadTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Return the target tab's most recent COMPLETED assistant turn as plain
- * text, plus its current status. `text` is null when the tab has no
- * completed assistant turn yet.
- */
- getLastResponse(tabId: string): { text: string | null; status: AgentStatus };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition {
- return {
- name: "read_tab",
- description: [
- "Read the most recent completed response from another tab (agent) by its short ID.",
- "",
- "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.",
- " - If the target is idle, you get its just-finished turn.",
- " - If the target is still running, you get its PREVIOUS completed turn (if any);",
- " call read_tab again later to get the newest one.",
- "",
- "Use this after send_to_tab to collect another agent's reply. IDs are git-style",
- "prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
- const { text, status } = callbacks.getLastResponse(target.id);
-
- const runningNote =
- status === "running"
- ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)"
- : "";
-
- if (text === null) {
- const reason =
- status === "running"
- ? "it is still working on its first turn"
- : "it has no assistant responses yet";
- return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`;
- }
-
- return [
- `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`,
- text,
- "</tab_response>",
- ].join("\n");
- },
- };
-}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
deleted file mode 100644
index ff6f4d1..0000000
--- a/packages/core/src/tools/registry.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { Tool } from "ai";
-import { jsonSchema, tool } from "ai";
-import { zodToJsonSchema } from "zod-to-json-schema";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * Strip JSON Schema fields that Anthropic's API does not accept from a
- * `zodToJsonSchema()` output. The Anthropic `/messages` API rejects (or
- * silently ignores) tools whose `input_schema` contains `$schema`,
- * `additionalProperties`, `default`, or `nullable` — when this happens
- * Claude never sees the tool and the model "thinks forever" instead of
- * calling it.
- *
- * The stripped fields are also harmless to remove for OpenAI-compatible
- * endpoints, so we apply this unconditionally.
- */
-function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> {
- delete schema.$schema;
- delete schema.additionalProperties;
- delete schema.default;
- delete schema.nullable;
-
- const properties = schema.properties;
- if (properties && typeof properties === "object") {
- for (const key of Object.keys(properties as Record<string, unknown>)) {
- const prop = (properties as Record<string, unknown>)[key];
- if (prop && typeof prop === "object") {
- normalizeForAnthropic(prop as Record<string, unknown>);
- }
- }
- }
-
- const items = schema.items;
- if (items && typeof items === "object") {
- normalizeForAnthropic(items as Record<string, unknown>);
- }
-
- return schema;
-}
-
-/**
- * Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6
- * `Tool` object.
- *
- * Critically, NO `execute` function is attached. The agent's manual tool
- * loop (see agent.ts) handles execution itself — for permission prompts,
- * shell-output streaming, and queued-message injection. Without `execute`,
- * the SDK never auto-runs tools; it only surfaces `tool-call` events from
- * `fullStream` that agent.ts collects and dispatches.
- */
-function toAISDKTool(def: ToolDefinition): Tool {
- const raw = zodToJsonSchema(def.parameters) as Record<string, unknown>;
- const normalized = normalizeForAnthropic(raw);
- return tool({
- description: def.description,
- inputSchema: jsonSchema(normalized),
- });
-}
-
-export function createToolRegistry(tools: ToolDefinition[]) {
- const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
-
- return {
- getTools(): ToolDefinition[] {
- return [...toolMap.values()];
- },
-
- getTool(name: string): ToolDefinition | undefined {
- return toolMap.get(name);
- },
-
- /**
- * Returns AI SDK v6 `Tool` objects keyed by tool name, for passing
- * directly to `streamText({ tools })`.
- *
- * Each tool has:
- * - `description` — forwarded verbatim from the internal definition.
- * - `inputSchema` — Zod schema converted to JSONSchema7 via
- * `zod-to-json-schema`, then wrapped with the v6
- * `jsonSchema()` helper.
- * - NO `execute` — intentional; see `toAISDKTool` above.
- */
- getAISDKTools(): Record<string, Tool> {
- const result: Record<string, Tool> = {};
- for (const [name, def] of toolMap) {
- result[name] = toAISDKTool(def);
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts
deleted file mode 100644
index 80c3715..0000000
--- a/packages/core/src/tools/retrieve.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-export interface RetrieveCallbacks {
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition {
- return {
- name: "retrieve",
- description: [
- "Wait for a child agent or backgrounded shell command to finish and retrieve its result. This tool BLOCKS until completion.",
- "",
- "Pass the ID returned by summon (agent_id) or by an interrupted run_shell (job_id). Once it finishes, the output is returned.",
- "If an error occurred, the error message is returned instead.",
- "",
- "Typical usage:",
- ' 1. summon({ task: "...", tools: [...] }) -> get agent_id',
- " 2. ... do other work or summon more agents ...",
- ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result',
- "",
- "Also used for backgrounded shell commands:",
- " If run_shell is interrupted by a user message, it returns a job_id (run_shell_...).",
- ' Use retrieve({ agent_id: "run_shell_..." }) to get the final output when ready.',
- ].join("\n"),
- parameters: z.object({
- agent_id: z.string().describe("The agent_id returned by a previous summon call."),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const agentId = args.agent_id as string;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- let outcome: { status: "done"; result: string } | { status: "error"; error: string };
-
- if (queueCallbacks) {
- const childPromise = callbacks.getResult(agentId);
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([childPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
- return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`;
- }
-
- // Child finished first — clean up the queue listener
- cancelQueueWait();
- outcome = raceResult;
- } else {
- outcome = await callbacks.getResult(agentId);
- }
-
- if (outcome.status === "done") {
- return ["<agent_result>", outcome.result, "</agent_result>"].join("\n");
- }
- return `Agent error: ${outcome.error}`;
- } catch (err) {
- return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts
deleted file mode 100644
index ec2db9c..0000000
--- a/packages/core/src/tools/run-shell.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { spawn } from "node:child_process";
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes
-
-export interface BackgroundShellJob {
- command: string;
- stdout: string;
- stderr: string;
- /** Resolves when the process exits */
- completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>;
-}
-
-/** Shared store for shell commands that were backgrounded due to user interrupt */
-export class BackgroundShellStore {
- private jobs = new Map<string, BackgroundShellJob>();
-
- register(job: BackgroundShellJob): string {
- const id = `run_shell_${randomUUID()}`;
- this.jobs.set(id, job);
- // Auto-cleanup after completion + 10 minutes
- job.completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background shell job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result: JSON.stringify(result) };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createRunShellTool(
- workingDirectory: string,
- shellStore?: BackgroundShellStore,
-): ToolDefinition {
- return {
- name: "run_shell",
- description:
- "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.",
- parameters: z.object({
- command: z.string().describe("The shell command to execute"),
- timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const command = args.command as string;
- const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
- const background = (args.background as boolean | undefined) ?? false;
-
- const [shell, shellArgs] = getShell();
- const child = spawn(shell, [...shellArgs, command], {
- cwd: workingDirectory,
- env: process.env,
- timeout,
- stdio: ["ignore", "pipe", "pipe"],
- });
-
- let stdout = "";
- let stderr = "";
-
- const completionPromise = new Promise<{
- stdout: string;
- stderr: string;
- exitCode: number;
- error?: string;
- }>((resolve) => {
- child.stdout?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stdout += chunk;
- context?.onOutput?.(chunk, "stdout");
- });
- child.stderr?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stderr += chunk;
- context?.onOutput?.(chunk, "stderr");
- });
-
- child.on("close", (exitCode) => {
- resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
- });
-
- child.on("error", (err) => {
- resolve({ stdout, stderr, exitCode: 1, error: err.message });
- });
- });
-
- // If background mode requested, register immediately and return job ID
- if (background && shellStore) {
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
- return [
- `Command started in background.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- ``,
- `Use the retrieve tool with this job_id to get the result when ready.`,
- ].join("\n");
- }
-
- const queueCallbacks = context?.queueCallbacks;
-
- if (queueCallbacks && shellStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([completionPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-running process
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Command backgrounded — still running.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- `stdout so far: ${stdout.slice(-500) || "(none)"}`,
- `stderr so far: ${stderr.slice(-500) || "(none)"}`,
- ``,
- `Use the retrieve tool with this job_id to get the final result when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Command finished before interrupt — clean up queue listener
- cancelQueueWait();
- return JSON.stringify(raceResult);
- }
-
- const result = await completionPromise;
- return JSON.stringify(result);
- },
- };
-}
-
-function getShell(): [string, string[]] {
- return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]];
-}
diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts
deleted file mode 100644
index 4350f6a..0000000
--- a/packages/core/src/tools/search-code.ts
+++ /dev/null
@@ -1,362 +0,0 @@
-import { spawn } from "node:child_process";
-import { stat } from "node:fs/promises";
-import { relative, sep } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH.
-// The deployed images build a patched, statically-linked `cs` into
-// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a
-// custom build. Read at call time so the environment can change at runtime
-// (and so tests can point it at a stub or temp build).
-function resolveCsBin(): string {
- return process.env.DISPATCH_CS_BIN || "cs";
-}
-
-const DEFAULT_RESULT_LIMIT = 20;
-const MAX_RESULT_LIMIT = 100;
-const MAX_CONTEXT = 20;
-const MIN_SNIPPET_LENGTH = 50;
-const MAX_SNIPPET_LENGTH = 2000;
-const TIMEOUT_MS = 30_000;
-// Hard cap on any single rendered snippet line. Mirrors read-file.ts so a
-// matched minified/generated line (e.g. a 2 MB bundle line) can't blow up the
-// payload. The universal truncator bounds total output; this bounds per-line.
-const MAX_LINE_CHARS = 500;
-
-/** Maps the `only` enum to the corresponding cs flag. */
-const ONLY_FLAGS: Record<string, string> = {
- code: "--only-code",
- comments: "--only-comments",
- strings: "--only-strings",
- declarations: "--only-declarations",
- usages: "--only-usages",
-};
-
-/** One line within a cs JSON match result. */
-interface CsLine {
- line_number: number;
- content: string;
- match_positions?: Array<[number, number]>;
-}
-
-/** A single file result in cs `-f json` output. */
-interface CsResult {
- filename: string;
- location: string;
- score: number;
- /** Present in "lines"/"grep" snippet modes. */
- lines?: CsLine[];
- /**
- * Present instead of `lines` in cs's "snippet" mode (the default "auto"
- * mode selects it for prose). We force a lines-based mode (see buildFlags),
- * but this is kept as a defensive fallback so a content-shape result is
- * still rendered rather than shown as a bare header.
- */
- content?: string;
- matchlocations?: Array<[number, number]>;
- language?: string;
- total_lines?: number;
-}
-
-export function createSearchCodeTool(workingDirectory: string): ToolDefinition {
- return {
- name: "search_code",
- description:
- "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " +
- "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " +
- "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " +
- "It respects .gitignore and skips hidden/binary files. " +
- 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' +
- "metadata filters like lang:Go, file:test, path:src. " +
- "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " +
- "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.",
- parameters: z.object({
- query: z
- .string()
- .describe(
- 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.',
- ),
- path: z
- .string()
- .optional()
- .describe(
- "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.",
- ),
- case_sensitive: z
- .boolean()
- .optional()
- .describe("Make the search case-sensitive. Default: false (case-insensitive)."),
- include_ext: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".',
- ),
- exclude_pattern: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".',
- ),
- context: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe(
- `Lines of context to show before and after each matching line (0-${MAX_CONTEXT}). When set, switches to a grep-style per-line window.`,
- ),
- result_limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`,
- ),
- snippet_length: z
- .number()
- .int()
- .min(MIN_SNIPPET_LENGTH)
- .optional()
- .describe(
- `Snippet size in bytes for prose/text files (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Has little effect on code files, which use a fixed line window — use 'context' to widen code snippets.`,
- ),
- only: z
- .enum(["code", "comments", "strings", "declarations", "usages"])
- .optional()
- .describe(
- "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " +
- "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " +
- "unavailable for unsupported languages (which fall back to plain text ranking).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = typeof args.query === "string" ? args.query : "";
- if (query.trim() === "") {
- return "Error: query is required (a non-empty string).";
- }
-
- // Resolve and contain the optional search path within the workdir.
- // Canonicalize so a symlink-in-workdir pointing outside is detected,
- // matching the containment semantics of list_files / read_file.
- const relPath = asString(args.path) ?? ".";
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const searchDir = await canonicalize(workingDirectory, relPath);
- if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- // cs's --dir expects a directory; pointing it at a file silently
- // returns no matches. Catch that and give an actionable hint instead
- // of a misleading "No matches found".
- if (relPath !== ".") {
- try {
- const st = await stat(searchDir);
- if (!st.isDirectory()) {
- return `Error: Path "${relPath}" is a file, not a directory. The 'path' parameter scopes the search to a directory; use read_file to read a single file.`;
- }
- } catch {
- return `Error: Path "${relPath}" does not exist in the working directory.`;
- }
- }
-
- const flags = buildFlags(args, searchDir);
- // `--` terminates cs flag parsing so a query that begins with "-"
- // (e.g. "-hello" or "--foo") is treated as the positional search term
- // rather than parsed as a (possibly invalid) cs flag.
- const spawnArgs = [...flags, "--", query];
-
- let stdout = "";
- let stderr = "";
- const result = await new Promise<{
- code: number | null;
- signal: NodeJS.Signals | null;
- error?: string;
- errorCode?: string;
- }>((resolve) => {
- const child = spawn(resolveCsBin(), spawnArgs, {
- cwd: workingDirectory,
- env: process.env,
- timeout: TIMEOUT_MS,
- stdio: ["ignore", "pipe", "pipe"],
- });
- child.stdout?.on("data", (d: Buffer) => {
- stdout += d.toString();
- });
- child.stderr?.on("data", (d: Buffer) => {
- stderr += d.toString();
- });
- child.on("close", (code, signal) => resolve({ code, signal }));
- child.on("error", (err) =>
- resolve({
- code: null,
- signal: null,
- error: err.message,
- errorCode: (err as NodeJS.ErrnoException).code,
- }),
- );
- });
-
- if (result.error) {
- // The binary is missing or not executable — give an actionable hint.
- if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) {
- return missingBinaryError();
- }
- return `Error: failed to run cs: ${result.error}`;
- }
-
- // A signal kill (e.g. SIGTERM from the spawn timeout) or a non-zero
- // exit means cs failed — surface it (with stderr) instead of silently
- // reporting "No matches found". cs exits 0 even when there are no
- // matches, so a clean exit always falls through to the parsing below.
- if (result.signal) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- if (result.signal === "SIGTERM") {
- return `Error: cs search timed out after ${TIMEOUT_MS / 1000}s. Try a narrower query or a smaller path.${detail}`;
- }
- return `Error: cs was terminated by signal ${result.signal}.${detail}`;
- }
- if (result.code !== 0) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- return `Error: cs exited with code ${result.code}.${detail}`;
- }
-
- // cs prints `null` (and exit 0) when there are no matches.
- const trimmed = stdout.trim();
- if (trimmed === "" || trimmed === "null") {
- return "No matches found.";
- }
-
- let parsed: CsResult[];
- try {
- parsed = JSON.parse(trimmed) as CsResult[];
- } catch {
- // Couldn't parse — surface what cs produced so the caller isn't blind.
- const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : "";
- return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`;
- }
-
- if (!Array.isArray(parsed) || parsed.length === 0) {
- return "No matches found.";
- }
-
- return formatResults(parsed, absoluteWorkDir);
- },
- };
-}
-
-/** Build the cs CLI flags (everything except the trailing query). */
-function buildFlags(args: Record<string, unknown>, searchDir: string): string[] {
- const flags: string[] = ["-f", "json", "--dir", searchDir];
-
- if (args.case_sensitive === true) flags.push("-c");
-
- const includeExt = asString(args.include_ext);
- if (includeExt) flags.push("-i", includeExt);
-
- const excludePattern = asString(args.exclude_pattern);
- if (excludePattern) flags.push("-x", excludePattern);
-
- // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for
- // code but a single `content` string for prose (.md/.html/…), which our
- // renderer can't show — so prose results would come back as bare headers.
- // It also ignores -C/--context entirely in auto/lines mode.
- //
- // - No `context` given → force "lines": every file type (code AND prose)
- // returns a `lines[]` window, so prose snippets render too.
- // - `context` given → use "grep": the only mode where -C actually widens
- // the window; it likewise returns `lines[]` for all file types.
- if (typeof args.context === "number") {
- const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT);
- flags.push("--snippet-mode", "grep", "-C", String(context));
- } else {
- flags.push("--snippet-mode", "lines");
- }
-
- const requestedLimit =
- typeof args.result_limit === "number"
- ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT)
- : DEFAULT_RESULT_LIMIT;
- flags.push("--result-limit", String(requestedLimit));
-
- if (typeof args.snippet_length === "number") {
- const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH);
- flags.push("-n", String(snippet));
- }
-
- const only = asString(args.only);
- if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]);
-
- return flags;
-}
-
-/** Render cs JSON results into compact, readable per-file blocks. */
-function formatResults(results: CsResult[], absoluteWorkDir: string): string {
- const blocks: string[] = [];
- // Match the workdir only at a path boundary so a sibling dir that merely
- // shares the prefix (e.g. workdir "/app" vs "/app-secrets") isn't treated
- // as nested and rendered as a "../app-secrets/..." relative path.
- const workdirPrefix = absoluteWorkDir.endsWith(sep) ? absoluteWorkDir : absoluteWorkDir + sep;
- for (const r of results) {
- // Present paths relative to the workdir so output is portable and compact.
- const insideWorkdir = r.location === absoluteWorkDir || r.location.startsWith(workdirPrefix);
- const rel = insideWorkdir ? relative(absoluteWorkDir, r.location) || r.filename : r.location;
- const lang = r.language ? ` [${r.language}]` : "";
- const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : "";
- const header = `${rel}${lang}${score}`;
-
- let body: string[];
- if (r.lines && r.lines.length > 0) {
- body = r.lines.map((l) => {
- const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " ";
- return ` ${marker} ${l.line_number}: ${truncateLine(l.content)}`;
- });
- } else if (r.content && r.content.trim() !== "") {
- // Fallback for cs's "snippet"-mode shape (no per-line numbers): show
- // the snippet text itself so the result isn't a bare header.
- body = r.content.split("\n").map((line) => ` ${truncateLine(line)}`);
- } else {
- body = [" (match in file; no snippet available)"];
- }
-
- blocks.push([header, ...body].join("\n"));
- }
-
- const count = results.length;
- const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`;
- return [heading, "", blocks.join("\n\n")].join("\n");
-}
-
-function clamp(n: number, min: number, max: number): number {
- return Math.min(max, Math.max(min, n));
-}
-
-/** Cap an individual snippet line so a minified/generated line can't bloat output. */
-function truncateLine(line: string): string {
- if (line.length <= MAX_LINE_CHARS) return line;
- return `${line.slice(0, MAX_LINE_CHARS)}… [line truncated, ${line.length.toLocaleString()} chars]`;
-}
-
-/**
- * Coerce a tool argument to a trimmed string, or undefined. Guards against a
- * model hallucinating a non-string (e.g. an array `["ts","go"]`) for a
- * string-typed param: returning undefined makes the flag a no-op instead of
- * throwing `x.trim is not a function` and crashing the tool call.
- */
-function asString(v: unknown): string | undefined {
- if (typeof v !== "string") return undefined;
- const t = v.trim();
- return t === "" ? undefined : t;
-}
-
-function missingBinaryError(): string {
- return [
- "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.",
- "Install it with: go install github.com/boyter/cs/[email protected]",
- "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.",
- "(In the official Docker images cs is bundled at /usr/local/bin/cs.)",
- ].join("\n");
-}
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
deleted file mode 100644
index eae6bfa..0000000
--- a/packages/core/src/tools/send-to-tab.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools
- * are intentionally decoupled from the DB `TabRow` shape — the AgentManager
- * maps `resolveTabPrefix(...)` results down to this minimal projection so the
- * tools (and their unit tests) never depend on the persistence layer.
- */
-export interface ResolvedTabRef {
- /** The tab's canonical full UUID. */
- id: string;
- /** The tab's display title (for disambiguation hints). */
- title: string;
- /** The tab's current short handle (shortest unique prefix). */
- handle: string;
-}
-
-/**
- * Outcome of resolving a short tab handle. Mirrors core's
- * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection.
- */
-export type TabResolution =
- | { status: "ok"; tab: ResolvedTabRef }
- | { status: "none" }
- | { status: "ambiguous"; matches: ResolvedTabRef[] };
-
-export interface SendToTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Deliver `message` to `tabId`. If the target is mid-turn the message is
- * queued (same path as a user message); if idle/errored it wakes the tab
- * and starts a new turn. Returns quickly — does NOT block on the turn.
- */
- deliver(
- tabId: string,
- message: string,
- ):
- | Promise<{ status: "queued" | "started" | "suppressed" }>
- | { status: "queued" | "started" | "suppressed" };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
- /** The calling tab's own id + handle — used to block self-sends and to
- * stamp provenance onto the delivered message. */
- self: { id: string; handle: string };
- /**
- * Whether THIS calling tab also has the `read_tab` tool granted. The
- * tab-messaging permissions are split, so a tab can hold `send_to_tab`
- * without `read_tab`. When false, the tool must NOT tell the agent to use
- * `read_tab` (it doesn't have it) — replies only arrive on their own.
- */
- canReadTab: boolean;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
- // The `read_tab` follow-up hint is only truthful when this tab actually
- // holds the `read_tab` tool (the permissions are split). When it doesn't,
- // the only honest guidance is that a reply will wake it as a new message — never tell
- // the agent to call a tool it wasn't granted.
- const waitLine = callbacks.canReadTab
- ? "money. If the target replies it will WAKE you with a new message in a later turn; you"
- : "money. If the target replies it will WAKE you with a new message in a later turn.";
- const readTabLine = callbacks.canReadTab
- ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"]
- : [];
- const keepGoingLine = callbacks.canReadTab
- ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now."
- : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now.";
- return {
- name: "send_to_tab",
- description: [
- "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.",
- "",
- "Behaviour mirrors a user sending a message:",
- " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.",
- " - If the target tab is idle, your message WAKES it and starts a new turn.",
- "",
- "This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
- "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and",
- waitLine,
- ...readTabLine,
- keepGoingLine,
- "",
- "Your tab ID is auto-added to the top of the message so the recipient knows who to reply",
- "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;",
- "a plain text response reaches only their own user, not you.",
- "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- "If the ID is ambiguous you'll be asked to add a character.",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).",
- ),
- message: z
- .string()
- .describe("The message to deliver to the target tab, exactly as a user would type it."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
- const message = (args.message as string | undefined) ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
- if (!message.trim()) {
- return "Error: message must not be empty.";
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
-
- if (target.id === callbacks.self.id) {
- return "Error: cannot send a message to your own tab.";
- }
-
- // Stamp provenance so the recipient (and the watching user) can see
- // which tab the message came from and how to reply. The header makes
- // clear this is a PEER AGENT, not the recipient's own user, and the
- // footer states the reply contract: a reply (only if warranted) must
- // go back through `send_to_tab`, since a plain text answer reaches
- // only the recipient's own user — not this sender.
- const delivered = [
- `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`,
- "",
- message,
- "",
- `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
- ].join("\n");
-
- try {
- const result = await callbacks.deliver(target.id, delivered);
- if (result.status === "suppressed") {
- // The target hit its automatic agent-to-agent wake limit. The
- // message was preserved (queued) but did NOT start a turn — a
- // human must step in. Tell the sender plainly so it stops
- // hammering the target and creating a runaway loop.
- return [
- `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`,
- `That tab has reached its automatic agent-to-agent message limit, so it will not`,
- `auto-respond again until a human sends it a message. Do not keep resending:`,
- `your message is already queued and will be seen when a human resumes that tab.`,
- ].join("\n");
- }
- const verb =
- result.status === "queued"
- ? "queued (target is busy; it will be picked up next turn)"
- : "delivered (target was idle; a new turn has started)";
- const tail = callbacks.canReadTab
- ? [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`,
- "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
- "waiting for this reply, end your turn now.",
- ]
- : [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- "will WAKE you with a new message later. Keep working if you have other tasks; if",
- "you are ONLY waiting for this reply, end your turn now.",
- ];
- return [
- `Message ${verb}. Target tab: ${target.handle} (${target.title}).`,
- "",
- ...tail,
- ].join("\n");
- } catch (err) {
- return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts
deleted file mode 100644
index b70108b..0000000
--- a/packages/core/src/tools/shell-analyze.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { createRequire } from "node:module";
-import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
-import * as BashArity from "./bash-arity.js";
-
-// Commands that touch files — triggers external_directory check.
-// Includes any command that takes file paths as arguments and could leak
-// information about external directories.
-//
-// Known gaps (not currently checked):
-// - Redirections: `echo x > /etc/file` — the redirect target is not inspected
-// - `cd` state changes: we don't track cwd mutations across pipeline stages
-// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely
-const FILE_COMMANDS = new Set([
- "rm",
- "cp",
- "mv",
- "mkdir",
- "touch",
- "chmod",
- "chown",
- "cat",
- "ls",
- "find",
- "grep",
- "head",
- "tail",
- "less",
- "more",
- "wc",
- "diff",
- "file",
- "stat",
- "du",
- "df",
-]);
-
-// Lazy-initialized parser
-let parserPromise: Promise<Parsers> | null = null;
-
-interface Parsers {
- bash: import("web-tree-sitter").Parser;
-}
-
-async function getParser(): Promise<Parsers> {
- if (parserPromise) return parserPromise;
- parserPromise = initParser();
- return parserPromise;
-}
-
-async function initParser(): Promise<Parsers> {
- const { Parser, Language } = await import("web-tree-sitter");
-
- // Load the main WASM binary from node_modules
- const require = createRequire(import.meta.url);
- const webTreeSitterPath = require.resolve("web-tree-sitter/web-tree-sitter.wasm");
- const wasmBinary = await readFile(webTreeSitterPath);
- await Parser.init({ wasmBinary });
-
- const bashWasmPath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
- const bashLang = await Language.load(bashWasmPath);
-
- const bash = new Parser();
- bash.setLanguage(bashLang);
-
- return { bash };
-}
-
-// Analyze a shell command and return permission patterns
-export async function analyzeCommand(
- command: string,
- workingDirectory: string,
-): Promise<{ dirs: string[]; patterns: string[]; always: string[] }> {
- try {
- const parsers = await getParser();
- const tree = parsers.bash.parse(command);
- if (!tree) return { dirs: [], patterns: [command], always: [] };
-
- return collect(tree.rootNode, command, workingDirectory);
- } catch {
- // Parse failure — return basic patterns
- return { dirs: [], patterns: [command], always: [] };
- }
-}
-
-function collect(
- node: import("web-tree-sitter").Node,
- _source: string,
- wd: string,
-): { dirs: string[]; patterns: string[]; always: string[] } {
- const dirs: string[] = [];
- const patterns: string[] = [];
- const always: string[] = [];
-
- // Walk all command nodes
- const commands = node.descendantsOfType("command");
-
- for (const cmd of commands) {
- const parts = extractParts(cmd);
- const name = parts[0]?.toLowerCase();
- if (!name) continue;
-
- // Get the command source text
- const cmdText = cmd.text;
- patterns.push(cmdText);
-
- // Normalize to always pattern
- always.push(`${BashArity.prefix(parts).join(" ")} *`);
-
- // Check if this is a file-touching command
- if (FILE_COMMANDS.has(name)) {
- // Extract path arguments (skip flags starting with -)
- const pathArgs = parts.slice(1).filter((a) => !a.startsWith("-"));
- for (const arg of pathArgs) {
- const resolved = resolvePath(arg, wd);
- if (resolved && !isInsideWorkspace(resolved, wd)) {
- const parent = dirname(resolved);
- dirs.push(parent);
- }
- }
- }
- }
-
- return {
- dirs: [...new Set(dirs)],
- patterns: [...new Set(patterns)],
- always: [...new Set(always)],
- };
-}
-
-// Helper to extract command parts from a command AST node
-function extractParts(cmd: import("web-tree-sitter").Node): string[] {
- const parts: string[] = [];
- for (const child of cmd.children) {
- if (
- child.type === "command_name" ||
- child.type === "word" ||
- child.type === "string" ||
- child.type === "raw_string"
- ) {
- const text = child.text.replace(/^['"]|['"]$/g, "");
- if (text) parts.push(text);
- }
- }
- return parts;
-}
-
-function resolvePath(arg: string, wd: string): string | null {
- try {
- if (isAbsolute(arg)) return arg;
- return resolve(wd, arg);
- } catch {
- return null;
- }
-}
-
-function isInsideWorkspace(filePath: string, wd: string): boolean {
- const normalizedWd = resolve(wd);
- const rel = relative(normalizedWd, filePath);
- // rel === "" means filePath IS the workspace root — that is inside.
- // If relative path starts with "../" or is ".." exactly, or is an absolute path
- // (on Windows when drives differ), the file is outside the workspace.
- const isOutside =
- rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel);
- return !isOutside;
-}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
deleted file mode 100644
index 2a076e6..0000000
--- a/packages/core/src/tools/summon.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { z } from "zod";
-import type { AgentDefinition, ToolDefinition } from "../types/index.js";
-
-export interface SummonCallbacks {
- spawn(options: {
- task: string;
- tools: string[];
- workingDirectory?: string;
- /**
- * Optional slug of an `AgentDefinition` (loaded from
- * `~/.config/dispatch/agents/` or `<projectDir>/.dispatch/agents/`)
- * to use as the basis for the spawned child. When provided,
- * the definition's tools, models, and cwd override the
- * `tools` and `workingDirectory` parameters passed alongside.
- */
- agentSlug?: string;
- /**
- * When true, spawn the agent as an independent top-level "user
- * agent" tab (no parent, persistent, fire-and-forget) instead of
- * a subagent child tab. Only honoured when the spawning agent has
- * the `perm_user_agent` permission.
- */
- topLevel?: boolean;
- }): Promise<string>;
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-/**
- * Summary of an agent definition surfaced to the calling LLM in the
- * summon tool's description. The shape is intentionally minimal — full
- * TOML inspection is done by reading the definition file directly,
- * which all agents are allowed to do by default.
- */
-export interface AvailableAgent {
- slug: string;
- name: string;
- description: string;
- /** Filesystem path of the TOML the agent can read for full details. */
- path: string;
-}
-
-/**
- * Render a labelled list of agents. Returns an empty array when there
- * are no agents so callers can omit the group entirely.
- */
-function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] {
- if (agents.length === 0) return [];
- const lines: string[] = [label];
- for (const a of agents) {
- const desc = a.description ? ` — ${a.description}` : "";
- lines.push(` - ${a.slug}: ${a.name}${desc}`);
- }
- return lines;
-}
-
-/**
- * Build the prose paragraph that lists available agent definitions plus
- * the disk locations where they live, injected into the summon tool's
- * description.
- *
- * `subagentEnabled` and `userAgentEnabled` independently control which
- * groups are shown — they mirror the `perm_summon` and `perm_user_agent`
- * permissions respectively:
- * - subagents only → generic "Available agents" heading;
- * - user agents only → a single user-agent group (top_level is implied);
- * - both → two labelled groups so the LLM understands which slugs
- * require `top_level=true`.
- *
- * Returns a compact "no agents defined" notice when nothing is visible.
- */
-function buildAgentsCatalog(
- subagents: AvailableAgent[],
- userAgents: AvailableAgent[],
- agentDirs: string[],
- userAgentEnabled: boolean,
- subagentEnabled: boolean,
-): string {
- const lines: string[] = [];
- lines.push("");
- lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:");
- for (const d of agentDirs) {
- lines.push(` - ${d}`);
- }
-
- const visibleSubagents = subagentEnabled ? subagents : [];
- const visibleUserAgents = userAgentEnabled ? userAgents : [];
- if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) {
- lines.push("");
- lines.push("No agent definitions are currently defined.");
- return lines.join("\n");
- }
-
- lines.push("");
- lines.push("To summon a specific agent, pass its slug as the 'agent' parameter.");
- lines.push("When 'agent' is set, the child inherits that definition's tools, models,");
- lines.push("and working directory; the 'tools' parameter is ignored.");
- lines.push("");
-
- // User-agent-only mode: list just the user agents. top_level is implied
- // (it is the only thing this grant can spawn), so the heading omits it.
- if (!subagentEnabled && userAgentEnabled) {
- lines.push(
- ...renderAgentGroup(
- "User agents (spawned as independent top-level tabs):",
- visibleUserAgents,
- ),
- );
- return lines.join("\n");
- }
-
- // Subagent-only mode: single generic heading.
- if (!userAgentEnabled) {
- lines.push(...renderAgentGroup("Available agents:", visibleSubagents));
- return lines.join("\n");
- }
-
- // Both enabled: two labelled groups.
- const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents);
- const userAgentLines = renderAgentGroup(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- visibleUserAgents,
- );
- if (subagentLines.length > 0) {
- lines.push(...subagentLines);
- }
- if (userAgentLines.length > 0) {
- if (subagentLines.length > 0) lines.push("");
- lines.push(...userAgentLines);
- }
- return lines.join("\n");
-}
-
-/**
- * Factory for the `summon` tool. Accepts a snapshot of agent definitions
- * available at the time the tool is registered so the LLM's view of
- * which agents exist matches what `spawnChildAgent` can actually load.
- *
- * `agentDirs` is the list of filesystem paths the catalog references in
- * its description; this is information-only — the runtime resolves
- * slugs through `loadAgent` independently.
- *
- * `userAgentEnabled` mirrors the `perm_user_agent` permission and
- * `subagentEnabled` mirrors the `perm_summon` permission. They are
- * independent: the tool is registered whenever at least one is granted.
- * - subagentEnabled only → spawn ordinary subagents (no `top_level`);
- * - userAgentEnabled only → spawn ONLY top-level user agents
- * (`top_level` is forced on, the `background` knob is dropped, and
- * the catalog lists user agents only);
- * - both → full behavior (subagents plus `top_level` user agents).
- */
-export function createSummonTool(
- _defaultWorkingDirectory: string,
- callbacks: SummonCallbacks,
- availableSubagents: AvailableAgent[] = [],
- availableUserAgents: AvailableAgent[] = [],
- agentDirs: string[] = [],
- userAgentEnabled = false,
- subagentEnabled = true,
-): ToolDefinition {
- // When only the user-agent permission is granted the tool spawns user
- // agents exclusively: `top_level` is implied (and forced), subagent
- // mechanics (background, retrieve, parallel work) are irrelevant.
- const userAgentOnly = userAgentEnabled && !subagentEnabled;
-
- const catalog = buildAgentsCatalog(
- availableSubagents,
- availableUserAgents,
- agentDirs,
- userAgentEnabled,
- subagentEnabled,
- );
- const subagentSlugs = availableSubagents.map((a) => a.slug);
- const userAgentSlugs = availableUserAgents.map((a) => a.slug);
- const allSlugs = userAgentOnly
- ? userAgentSlugs
- : userAgentEnabled
- ? [...subagentSlugs, ...userAgentSlugs]
- : subagentSlugs;
-
- const toolNamesList = [
- "The 'tools' parameter controls what the child can do. Available tool names:",
- " - read_file: Read file contents",
- " - read_file_slice: Read a character-range slice of a single line",
- " - list_files: List files and directories",
- " - write_file: Write/edit files",
- " - run_shell: Execute shell commands",
- " - search_code: Search the codebase with the cs ranked code-search engine",
- " - todo: Track work items",
- " - summon: Spawn its own child agents (enables nesting)",
- " - retrieve: Collect results from its children (required if summon is given)",
- " - web_search: Search the web",
- " - youtube_transcribe: Fetch YouTube video transcripts",
- " - send_to_tab: Send a message to another tab/agent by its ID",
- " - read_tab: Read another tab/agent's latest response by its ID",
- ];
-
- const description = userAgentOnly
- ? [
- "Spawn an independent top-level user agent to work on a task.",
- "",
- "User agents are first-class top-level tabs with no parent. They are",
- "fire-and-forget: you get an agent_id back but cannot retrieve their result.",
- "The user agent runs in its own tab visible to the user.",
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n")
- : [
- "Spawn a new child agent to work on a task independently.",
- "",
- "By default, blocks until the child agent finishes and returns the result directly.",
- "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
- "",
- "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
- "",
- "Pattern for parallel work:",
- " 1. Call summon multiple times with background=true to start several agents",
- " 2. Do your own work or wait",
- " 3. Call retrieve for each agent_id to collect results",
- ...(userAgentEnabled
- ? [
- "",
- "Set top_level=true to spawn an independent user agent — a first-class",
- "top-level tab with no parent. User agents are fire-and-forget: you get",
- "an agent_id back but cannot retrieve their result. top_level requires an",
- "'agent' definition listed under 'User agents' below.",
- ]
- : []),
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n");
-
- const parametersShape = {
- task: z
- .string()
- .describe(
- "Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
- ),
- agent: z
- .string()
- .describe(
- [
- "Slug of an agent definition to use as the basis for the child agent.",
- "Required. The child inherits the definition's tools, models, and",
- "working directory; the 'tools' parameter only narrows them further.",
- "Inspect the agent directories listed above to discover which slugs",
- "are available and what each one does.",
- allSlugs.length > 0 ? `Available slugs: ${allSlugs.join(", ")}.` : "",
- ]
- .filter(Boolean)
- .join(" "),
- ),
- // `top_level` is only an explicit choice when BOTH subagents and user
- // agents are available. In user-agent-only mode it is implied (forced
- // on), so the knob is omitted entirely.
- ...(userAgentEnabled && !userAgentOnly
- ? {
- top_level: z
- .boolean()
- .optional()
- .describe(
- [
- "If true, spawn the agent as an independent top-level user agent tab",
- "instead of a child subagent. User agents have no parent, persist on",
- "their own, and are fire-and-forget (cannot be retrieved). Requires an",
- "'agent' definition listed under 'User agents'. The 'background' option",
- "is ignored when top_level is true.",
- ].join(" "),
- ),
- }
- : {}),
- tools: z
- .array(
- z.enum([
- "read_file",
- "read_file_slice",
- "list_files",
- "write_file",
- "run_shell",
- "search_code",
- "key_usage",
- "todo",
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "send_to_tab",
- "read_tab",
- ]),
- )
- .optional()
- .describe(
- "Tool names to give the child. Defaults to the agent definition's tools. Intersected with the spawning agent's tools (you can't grant capabilities you don't have).",
- ),
- working_directory: z
- .string()
- .optional()
- .describe(
- "Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).",
- ),
- // `background` is meaningless for fire-and-forget user agents, so the
- // knob is omitted in user-agent-only mode.
- ...(userAgentOnly
- ? {}
- : {
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.",
- ),
- }),
- };
-
- return {
- name: "summon",
- description,
- parameters: z.object(parametersShape),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const task = args.task as string;
- const agentSlug = args.agent as string | undefined;
- const tools = args.tools as string[] | undefined;
- const workingDirectory = args.working_directory as string | undefined;
- const background = (args.background as boolean | undefined) ?? false;
- // User-agent-only mode always spawns top-level user agents. When both
- // capabilities are present the caller chooses via `top_level`. When
- // only subagents are available, top-level spawning is unavailable.
- const topLevel = userAgentOnly
- ? true
- : userAgentEnabled
- ? ((args.top_level as boolean | undefined) ?? false)
- : false;
-
- try {
- const agentId = await callbacks.spawn({
- task,
- tools: tools ?? [],
- ...(workingDirectory ? { workingDirectory } : {}),
- ...(agentSlug ? { agentSlug } : {}),
- ...(topLevel ? { topLevel: true } : {}),
- });
-
- if (topLevel) {
- // User agents are always fire-and-forget — never block on a
- // result and make it explicit that retrieve won't work.
- return [
- `User agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The user agent is now working independently in its own top-level tab.`,
- `It is fire-and-forget — you cannot retrieve its result.`,
- ].join("\n");
- }
-
- if (!background) {
- // Block until the child agent completes. Always prefix the
- // result with `agent_id: <uuid>` so the frontend's
- // ToolCallDisplay regex (`agent_id:\s*([a-f0-9-]+)`) can
- // surface the "Open Tab" button for foreground summons too —
- // not just background ones. The child's tab still exists and
- // holds the full conversation, so the user should always be
- // able to open it.
- const result = await callbacks.getResult(agentId);
- if (result.status === "done") {
- return `agent_id: ${agentId}\n\n${result.result}`;
- }
- return `agent_id: ${agentId}\n\nError from child agent: ${result.error}`;
- }
-
- return [
- `Agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The child agent is now working on the task in its own tab.`,
- `Use the retrieve tool with this agent_id to get the result when ready.`,
- ].join("\n");
- } catch (err) {
- return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
-
-/**
- * Build the `AvailableAgent[]` projection of an `AgentDefinition` list,
- * deriving each entry's readable `path` from its scope+slug.
- */
-function toAvailableAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return defs.map((d) => {
- const baseDir =
- d.scope === "global"
- ? globalDir
- : projectDir
- ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
- : globalDir;
- return {
- slug: d.slug,
- name: d.name,
- description: d.description,
- path: `${baseDir}/${d.slug}.toml`,
- };
- });
-}
-
-/**
- * Subagent definitions (`is_subagent === true`) — spawned as child tabs.
- */
-export function toAvailableSubagents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent),
- globalDir,
- projectDir,
- );
-}
-
-/**
- * User-agent definitions (`is_subagent !== true`) — spawnable as
- * independent top-level tabs when `perm_user_agent` is granted.
- */
-export function toAvailableUserAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent !== true),
- globalDir,
- projectDir,
- );
-}
diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts
deleted file mode 100644
index 98dcf01..0000000
--- a/packages/core/src/tools/task-list.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { z } from "zod";
-import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js";
-
-/**
- * Valid task statuses. Matches opencode's todo lifecycle:
- * - pending not started
- * - in_progress actively working (exactly ONE at a time)
- * - completed finished successfully
- * - cancelled no longer needed
- */
-const VALID_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
-]);
-
-function normalizeStatus(value: unknown): TaskStatus {
- return typeof value === "string" && VALID_STATUSES.has(value as TaskStatus)
- ? (value as TaskStatus)
- : "pending";
-}
-
-/**
- * Declarative, whole-list task store (ported from opencode's `todowrite`).
- *
- * The model never sees ids and never issues per-item mutations. Instead it
- * sends the ENTIRE desired list on every call and {@link setTasks} rebuilds the
- * stored list, assigning fresh positional ids. This is idempotent and
- * eliminates the id-bookkeeping / "task not found" / delta-reasoning failure
- * modes of the old imperative CRUD interface.
- */
-export class TaskList {
- private tasks: TaskItem[] = [];
- private listeners: Array<(tasks: TaskItem[]) => void> = [];
-
- private notify(): void {
- const snapshot = this.getTasks();
- for (const listener of this.listeners) {
- listener(snapshot);
- }
- }
-
- getTasks(): TaskItem[] {
- return this.tasks.map((t) => ({ ...t }));
- }
-
- /**
- * Replace the entire list. Each item is assigned a fresh positional id
- * (`task-1`, `task-2`, …). Invalid/missing statuses fall back to
- * `pending`; an empty array clears the list. Always notifies listeners.
- */
- setTasks(items: Array<{ content: string; status?: unknown }>): TaskItem[] {
- this.tasks = items.map((item, index) => ({
- id: `task-${index + 1}`,
- content: item.content,
- status: normalizeStatus(item.status),
- }));
- this.notify();
- return this.getTasks();
- }
-
- onChange(callback: (tasks: TaskItem[]) => void): () => void {
- this.listeners.push(callback);
- return () => {
- this.listeners = this.listeners.filter((l) => l !== callback);
- };
- }
-}
-
-/**
- * Rich tool description adapted from opencode's `todowrite.txt`. Teaches the
- * declarative whole-list cadence and the status lifecycle.
- */
-export const TODO_DESCRIPTION = `Create and maintain a structured todo list for the current session to track progress and surface your plan to the user.
-
-This is a DECLARATIVE, whole-list tool. There are no ids and no per-item actions: every call sends the ENTIRE list in the \`todos\` parameter and REPLACES the previous list. To change one item, resend the whole list with that item changed. To clear the list, send an empty array.
-
-## When to use
-- The task requires 3+ distinct steps and benefits from planning
-- The user provides 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 or partial, keep it in_progress and add a follow-up todo describing the blocker
-- Items should be specific and actionable; break large work into smaller steps`;
-
-export function createTaskListTool(taskList: TaskList): ToolDefinition {
- return {
- name: "todo",
- description: TODO_DESCRIPTION,
- parameters: z.object({
- todos: z
- .array(
- z.object({
- content: z.string().describe("Brief, actionable description of the task"),
- status: z
- .enum(["pending", "in_progress", "completed", "cancelled"])
- .describe("Current status of the task"),
- }),
- )
- .describe("The complete, updated todo list. Replaces the previous list entirely."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawTodos = args.todos;
- if (!Array.isArray(rawTodos)) {
- return "Error: 'todos' must be an array of { content, status } items (send the whole list).";
- }
-
- const items: Array<{ content: string; status?: unknown }> = [];
- for (const entry of rawTodos) {
- if (!entry || typeof entry !== "object") {
- return "Error: each todo must be an object with a 'content' string and a 'status'.";
- }
- const content = (entry as Record<string, unknown>).content;
- if (typeof content !== "string" || content.trim() === "") {
- return "Error: each todo requires a non-empty 'content' string.";
- }
- items.push({
- content,
- status: (entry as Record<string, unknown>).status,
- });
- }
-
- const stored = taskList.setTasks(items);
- // Echo the canonical stored list back WITHOUT ids — the model must
- // never start tracking ids; it always resends the whole list.
- const echo = stored.map((t) => ({ content: t.content, status: t.status }));
- return JSON.stringify(echo);
- },
- };
-}
diff --git a/packages/core/src/tools/truncate.ts b/packages/core/src/tools/truncate.ts
deleted file mode 100644
index 8c62174..0000000
--- a/packages/core/src/tools/truncate.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-
-// ─── Constants ───────────────────────────────────────────────────
-//
-// A tool result that exceeds *either* MAX_CHARS or MAX_LINES is treated
-// as oversized: the full content is written to a spill file under
-// /tmp/dispatch/tool-results/<tabId>/<callId>.txt and the model receives
-// HEAD_CHARS from the start + TAIL_CHARS from the end with a notice
-// in between. These are deliberate hardcoded defaults — see the design
-// discussion in notes/plan.md for the rationale.
-
-export const MAX_CHARS = 10_000;
-export const MAX_LINES = 500;
-export const HEAD_CHARS = 1500;
-export const TAIL_CHARS = 1500;
-
-/** Base directory for all tool-result spill files. Per-tab subdirectories live inside. */
-export const SPILL_ROOT = "/tmp/dispatch/tool-results";
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export interface TruncationContext {
- /** Tab the tool call belongs to. Used to scope the spill directory. */
- tabId: string;
- /** Tool call ID, used as the spill file basename. */
- callId: string;
- /** Tool name, included in the truncation notice for human-readable hints. */
- toolName: string;
-}
-
-export interface TruncationResult {
- /** Final string sent to the model. Either the original (when under threshold) or the head+notice+tail excerpt. */
- displayResult: string;
- /** When truncation happened, the absolute path the full output was spilled to. Undefined otherwise. */
- spillPath?: string;
-}
-
-/**
- * Apply universal truncation to a tool result string.
- *
- * If the result is under both the character and line caps, returns it
- * unchanged with no side effects.
- *
- * If the result exceeds either cap:
- * 1. Writes the full content to `<SPILL_ROOT>/<tabId>/<callId>.txt`.
- * 2. Builds a display string consisting of HEAD_CHARS from the start,
- * a multi-line truncation notice that includes the spill path, and
- * TAIL_CHARS from the end.
- *
- * The notice instructs the model to use `read_file` (with offset/limit)
- * or `read_file_slice` to inspect the full content. Every tool result
- * flows through this function via `Agent.executeToolWithStreaming`, so
- * any new tool that returns a string automatically gets the protection.
- */
-export function applyTruncation(result: string, ctx: TruncationContext): TruncationResult {
- const totalChars = result.length;
- const totalLines = countLines(result);
-
- if (totalChars <= MAX_CHARS && totalLines <= MAX_LINES) {
- return { displayResult: result };
- }
-
- const spillPath = join(SPILL_ROOT, ctx.tabId, `${ctx.callId}.txt`);
- try {
- mkdirSync(dirname(spillPath), { recursive: true, mode: 0o700 });
- writeFileSync(spillPath, result, { encoding: "utf-8", mode: 0o600 });
- } catch (err) {
- // If we can't spill (disk full, perms, etc.) fall back to hard-truncating
- // the head + tail without a spill path reference. The model loses the
- // ability to inspect the middle but won't be blocked outright.
- const message = err instanceof Error ? err.message : String(err);
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath: null,
- spillError: message,
- }),
- };
- }
-
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath,
- spillError: null,
- }),
- spillPath,
- };
-}
-
-/** Delete the entire spill directory for a tab. Best-effort, errors swallowed. */
-export function clearSpillForTab(tabId: string): void {
- const dir = join(SPILL_ROOT, tabId);
- try {
- rmSync(dir, { recursive: true, force: true });
- } catch {
- // Ignore — tab close should not fail on cleanup errors
- }
-}
-
-// ─── Internal helpers ────────────────────────────────────────────
-
-function countLines(s: string): number {
- if (s.length === 0) return 0;
- let count = 1;
- for (let i = 0; i < s.length; i++) {
- if (s.charCodeAt(i) === 10 /* \n */) count++;
- }
- return count;
-}
-
-function buildExcerpt(
- result: string,
- totalChars: number,
- totalLines: number,
- ctx: TruncationContext,
- spill: { spillPath: string | null; spillError: string | null },
-): string {
- // Guard against pathological case where HEAD+TAIL overlap. If the result
- // is between MAX_CHARS and HEAD+TAIL (rare), slice cleanly so we don't
- // emit overlapping content.
- const head = result.slice(0, HEAD_CHARS);
- const tailStart = Math.max(HEAD_CHARS, totalChars - TAIL_CHARS);
- const tail = result.slice(tailStart);
-
- const omittedChars = Math.max(0, totalChars - head.length - tail.length);
-
- const notice: string[] = [
- "",
- `[output truncated by dispatch — tool=${ctx.toolName}, total ${totalChars.toLocaleString()} chars / ${totalLines.toLocaleString()} lines; showing first ${head.length.toLocaleString()} and last ${tail.length.toLocaleString()}, ${omittedChars.toLocaleString()} omitted]`,
- ];
- if (spill.spillPath) {
- notice.push(
- `[full output saved to: ${spill.spillPath}]`,
- `[use read_file with offset/limit (lines), or read_file_slice (chars within a single line), to inspect specific sections]`,
- );
- } else if (spill.spillError) {
- notice.push(`[failed to spill full output to disk: ${spill.spillError}]`);
- }
- notice.push("");
-
- return `${head}${notice.join("\n")}${tail}`;
-}
diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts
deleted file mode 100644
index 7f061a5..0000000
--- a/packages/core/src/tools/web-search.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-const FIRECRAWL_URL = "http://100.102.55.49:31329/v1/search";
-const MAX_OUTPUT_CHARS = 60000;
-const TIMEOUT_MS = 30000;
-
-export function createWebSearchTool(): ToolDefinition {
- return {
- name: "web_search",
- description:
- "Search the web via a self-hosted Firecrawl instance. Returns a list of results with titles, URLs, and descriptions. Optionally scrapes the full markdown content of each result page.",
- parameters: z.object({
- query: z.string().describe("The search query"),
- limit: z
- .number()
- .optional()
- .default(7)
- .describe("Maximum number of results to return (default 7)"),
- scrape: z
- .boolean()
- .optional()
- .default(false)
- .describe("Whether to also scrape the full markdown content of each result page"),
- lang: z.string().optional().describe('Language code to filter results (e.g. "en")'),
- country: z.string().optional().describe('Country code to filter results (e.g. "us")'),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = args.query as string;
- const limit = (args.limit as number | undefined) ?? 7;
- const scrape = (args.scrape as boolean | undefined) ?? false;
- const lang = args.lang as string | undefined;
- const country = args.country as string | undefined;
-
- const body: Record<string, unknown> = { query, limit };
- if (lang !== undefined) body.lang = lang;
- if (country !== undefined) body.country = country;
- if (scrape) {
- body.scrapeOptions = { formats: ["markdown"], onlyMainContent: true };
- }
-
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
-
- let response: Response;
- try {
- response = await fetch(FIRECRAWL_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to Firecrawl timed out after 30 seconds.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to Firecrawl at http://100.102.55.49:31329. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- } finally {
- clearTimeout(timeout);
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`;
- }
-
- let json: {
- data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>;
- };
- try {
- json = await response.json();
- } catch {
- return "Error: Failed to parse Firecrawl response as JSON";
- }
-
- const results = json.data ?? [];
- if (results.length === 0) {
- return "No results found.";
- }
-
- const parts: string[] = [];
- for (const result of results) {
- const title = result.title ?? "(no title)";
- const url = result.url ?? "";
- const description = result.description ?? "";
- let section = `### ${title}\n${url}\n\n${description}`;
- if (result.markdown) {
- section += `\n\n${result.markdown}`;
- }
- parts.push(section);
- }
-
- let output = parts.join("\n\n---\n\n");
- if (output.length > MAX_OUTPUT_CHARS) {
- output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated]`;
- }
- return output;
- },
- };
-}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
deleted file mode 100644
index 8a73352..0000000
--- a/packages/core/src/tools/write-file.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { dirname } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-/**
- * Optional hook invoked AFTER a successful write, with the canonicalized
- * absolute path of the file just written. Its returned string (when non-empty)
- * is appended to the tool result. This is how LSP diagnostics are surfaced
- * back to the model on write without coupling `@dispatch/core`'s tools to the
- * API layer or the LSP manager — the host wires an implementation that touches
- * the file through the LSP and formats any diagnostics. Errors thrown here are
- * swallowed so a flaky LSP never fails the write itself.
- */
-export type AfterWriteHook = (absolutePath: string) => Promise<string>;
-
-export function createWriteFileTool(
- workingDirectory: string,
- onAfterWrite?: AfterWriteHook,
-): ToolDefinition {
- return {
- name: "write_file",
- description: "Write content to a file relative to the working directory.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- content: z.string().describe("Content to write to the file"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const content = args.content as string;
- // Canonicalize so a workdir-relative path that resolves through
- // symlinks to outside the workdir is detected and blocked. The
- // canonicalize walks up to the nearest existing ancestor when the
- // leaf doesn't exist (typical for write_file), so a path like
- // `workdir/escape-link/new-file.txt` where `escape-link` symlinks
- // to /etc still resolves through the symlink and is caught here.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- try {
- await mkdir(dirname(absolutePath), { recursive: true });
- await writeFile(absolutePath, content, "utf8");
- } catch (err) {
- return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- let result = `Successfully wrote to "${filePath}".`;
- // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a
- // hook failure turn a successful write into an error.
- if (onAfterWrite) {
- try {
- const extra = await onAfterWrite(absolutePath);
- if (extra) result += `\n\n${extra}`;
- } catch {
- /* ignore — diagnostics are advisory */
- }
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts
deleted file mode 100644
index 3a26d6f..0000000
--- a/packages/core/src/tools/youtube-transcribe.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const TRANSCRIBER_BASE = "http://100.102.55.49:41090";
-const MAX_OUTPUT_CHARS = 60000;
-const REQUEST_TIMEOUT_MS = 30000;
-const MAX_WAIT_MS = 10 * 60 * 1000; // give up after 10 minutes of polling
-
-interface TranscriptResponse {
- status: string;
- video_id?: string;
- full_text?: string;
- segments?: Array<{ text: string; start: number; duration: number }>;
- position?: number;
- estimated_seconds?: number;
- error?: string;
- error_type?: string;
-}
-
-async function fetchTranscript(url: string): Promise<TranscriptResponse> {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
- try {
- const apiUrl = `${TRANSCRIBER_BASE}/api/transcript?url=${encodeURIComponent(url)}`;
- const response = await fetch(apiUrl, { signal: controller.signal });
- if (!response.ok) {
- throw new Error(`Transcriber returned HTTP ${response.status} ${response.statusText}`);
- }
- return (await response.json()) as TranscriptResponse;
- } finally {
- clearTimeout(timeout);
- }
-}
-
-function formatTime(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
-}
-
-function formatTranscript(data: TranscriptResponse): string {
- const segments = data.segments ?? [];
- const segmentsText = segments.map((seg) => `[${formatTime(seg.start)}] ${seg.text}`).join("\n");
-
- const output = [
- `Video ID: ${data.video_id}`,
- "",
- "## Transcript",
- "",
- data.full_text ?? "",
- "",
- "## Timestamped Segments",
- "",
- segmentsText,
- ].join("\n");
-
- return output.length > MAX_OUTPUT_CHARS
- ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Transcript truncated]`
- : output;
-}
-
-function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-/** Polls until the transcript is ready, fails, or times out. */
-async function pollUntilReady(url: string): Promise<string> {
- const startTime = Date.now();
-
- while (Date.now() - startTime < MAX_WAIT_MS) {
- const data = await fetchTranscript(url);
-
- if (data.status === "completed") {
- return formatTranscript(data);
- }
-
- if (data.status === "failed") {
- return `Error: Transcription failed for video ${data.video_id ?? "unknown"}: [${data.error_type ?? "unknown"}] ${data.error ?? "no details"}`;
- }
-
- if (data.status === "queued" || data.status === "processing") {
- const estimate = data.estimated_seconds ?? 30;
- const waitMs = Math.max((estimate - 2) * 1000, 2000);
- await sleep(waitMs);
- continue;
- }
-
- return `Error: Unexpected transcriber response status: ${data.status}`;
- }
-
- return "Error: Timed out waiting for transcript after 10 minutes.";
-}
-
-/** Store for transcript polls backgrounded due to user interrupt. */
-export class BackgroundTranscriptStore {
- private jobs = new Map<string, { url: string; completion: Promise<string> }>();
-
- register(url: string, completion: Promise<string>): string {
- const id = `youtube_transcribe_${randomUUID()}`;
- this.jobs.set(id, { url, completion });
- // Auto-cleanup 10 minutes after completion
- completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background transcript job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createYoutubeTranscribeTool(
- transcriptStore?: BackgroundTranscriptStore,
-): ToolDefinition {
- return {
- name: "youtube_transcribe",
- description: [
- "Fetch the transcript/subtitles for a YouTube video. This tool blocks until the transcript is ready.",
- "If the video hasn't been transcribed yet, it will be queued and this tool waits for it automatically.",
- "If the user interrupts while waiting, the request continues in the background and you receive a job ID.",
- "Use the retrieve tool with that ID to get the transcript later.",
- "",
- "Accepted URL formats:",
- " - youtube.com/watch?v=",
- " - youtu.be/",
- " - youtube.com/embed/",
- " - youtube.com/shorts/",
- ].join("\n"),
- parameters: z.object({
- url: z.string().describe("The YouTube video URL to fetch the transcript for."),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the transcription request starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the transcript later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const url = args.url as string;
- const background = (args.background as boolean | undefined) ?? false;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- const pollPromise = pollUntilReady(url);
-
- // If background mode requested, register immediately and return job ID
- if (background && transcriptStore) {
- const jobId = transcriptStore.register(url, pollPromise);
- return [
- `Transcript request started in background.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ].join("\n");
- }
-
- if (queueCallbacks && transcriptStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([pollPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-polling request
- const jobId = transcriptStore.register(url, pollPromise);
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Transcript request backgrounded — still waiting for transcription.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Poll finished before interrupt
- cancelQueueWait();
- return raceResult;
- }
-
- return await pollPromise;
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to YouTube transcriber timed out.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to YouTube transcriber at ${TRANSCRIBER_BASE}. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
deleted file mode 100644
index e878813..0000000
--- a/packages/core/src/types/index.ts
+++ /dev/null
@@ -1,672 +0,0 @@
-import type { ZodType } from "zod";
-import type { PermissionChecker, Ruleset } from "../permission/index.js";
-
-// ─── Message Types ───────────────────────────────────────────────
-
-export type MessageRole = "user" | "assistant" | "system";
-
-/**
- * A single ordered chunk of content inside a message. The chunk list
- * preserves the actual temporal ordering of text, reasoning, tool calls,
- * system notices, and errors as they arrived from the model.
- *
- * Coalescing rules (see notes/plan-chunk-refactor.md):
- * - `text` and `thinking` coalesce on consecutive same-type deltas.
- * - `tool-batch` coalesces on consecutive `tool-call` events
- * (appends a new entry to `calls`).
- * - `error` and `system` are always single-event chunks (no coalescing).
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Full Anthropic `providerMetadata` blob captured from the v6
- * `reasoning-end` stream event (typically `{ anthropic: { signature
- * } }` plus any other provider-side metadata). Round-tripped verbatim
- * as `providerOptions` on the `ReasoningPart` of the next request so
- * Anthropic can validate the thinking block's signature.
- *
- * Also acts as a "sealed" marker for `appendEventToChunks`: once
- * `metadata` is set, the next `reasoning-delta` opens a new thinking
- * chunk rather than extending this one (each Anthropic content block
- * gets its own metadata, so two consecutive thinking blocks must not
- * be coalesced).
- *
- * Optional: non-Anthropic models produce no metadata, and pre-v6
- * persisted chunks have neither field.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-export interface ChatMessage {
- role: MessageRole;
- chunks: Chunk[];
- /**
- * Ephemeral ORDERED multimodal content for a user turn (interleaved text +
- * image/pdf attachments). Set ONLY transiently on the in-flight user message
- * so `toModelMessages` can emit multimodal `ImagePart`/`FilePart` content to
- * the provider. Never persisted (the chunk log stores only the text, with
- * `[image]`/`[pdf]` markers), so it's absent on history-rebuilt messages.
- * When absent, the message is plain text built from its `chunks`.
- */
- content?: UserContentPart[];
-}
-
-// ─── Multimodal user content (image / PDF attachments) ───────────
-//
-// When a user pastes one or more images/PDFs into the chat input, the turn's
-// user message carries an ORDERED list of content parts instead of a plain
-// string. The ordering is meaningful — the user can interleave text and
-// attachments ("here is image A: <A>, here is image B: <B>") and the model
-// sees them in exactly that sequence.
-//
-// These parts are EPHEMERAL: they are forwarded to the model for the turn that
-// produced them but are NOT persisted as raw bytes in the chunk log. History
-// stores only the user's text (with `[image]` / `[pdf]` markers in place of
-// each attachment), so a later reload re-renders the text but never re-sends
-// the binary payload. This keeps the persisted log small and avoids re-billing
-// image tokens on every subsequent turn.
-
-/** A plain-text segment of a multimodal user message. */
-export interface UserTextPart {
- type: "text";
- text: string;
-}
-
-/**
- * A binary attachment (image or PDF) in a multimodal user message. `data` is a
- * base64-encoded payload (no `data:` URI prefix); `mediaType` is the IANA media
- * type (e.g. `image/png`, `application/pdf`). `name` is an optional original
- * filename, used only for PDF `filename` passthrough and diagnostics.
- */
-export interface UserAttachmentPart {
- type: "attachment";
- /** IANA media type, e.g. `image/png`, `image/jpeg`, `application/pdf`. */
- mediaType: string;
- /** Base64-encoded bytes WITHOUT a `data:` URI prefix. */
- data: string;
- /** Optional original filename (mainly for PDFs). */
- name?: string;
-}
-
-/** One ordered part of a multimodal user message. */
-export type UserContentPart = UserTextPart | UserAttachmentPart;
-
-// ─── Append-only chunk log (persisted model) ─────────────────────
-//
-// The DB stores a conversation as a flat stream of `ChunkRow`s (see
-// db/chunks.ts). The render-facing `Chunk`/`ChatMessage` shapes above are
-// DERIVED from these rows by grouping (turn_id + step + role). Tool calls
-// and their results are SEPARATE rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format.
-
-/** Role of a persisted chunk row. `tool` rows hold tool results. */
-export type ChunkRole = "user" | "assistant" | "tool" | "system";
-
-/** Discriminator for a persisted chunk row's payload. */
-export type ChunkType =
- | "text"
- | "thinking"
- | "tool_call"
- | "tool_result"
- | "error"
- | "system"
- | "usage";
-
-export interface TextData {
- text: string;
-}
-export interface ThinkingData {
- text: string;
- metadata?: Record<string, unknown>;
-}
-export interface ToolCallData {
- callId: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-export interface ToolResultData {
- callId: string;
- name: string;
- result: string;
- isError: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-export interface ErrorData {
- message: string;
- statusCode?: number;
-}
-export interface SystemData {
- kind: SystemChunkKind;
- text: string;
-}
-/**
- * Per-request token usage persisted as a SIDE-CHANNEL chunk row (one row per
- * `usage` AgentEvent, i.e. one per LLM round-trip). These rows are deliberately
- * EXCLUDED from `getChunksForTab`/`getTotalChunkCount` so they never enter the
- * render, pagination, eviction, or agent-history-rebuild paths — they exist
- * only to feed the backend aggregate `getUsageStatsForTab`, which seeds the
- * frontend's `cacheStats` on reload. `inputTokens` is the TOTAL prompt
- * (cached + fresh); `cacheReadTokens`/`cacheWriteTokens` are Anthropic's
- * prompt-cache split. Mirrors the `usage` AgentEvent payload.
- */
-export interface UsageData {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
-}
-
-/**
- * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted
- * `usage` rows, the request count, and the most recent request's split. This is
- * the server-side source of truth (complete regardless of frontend
- * eviction/pagination) returned by `getUsageStatsForTab`. Structurally
- * identical to the frontend `CacheStats` so it can seed it directly. `null` when
- * the tab has no usage rows.
- */
-export interface UsageStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage rows) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-export type ChunkData =
- | TextData
- | ThinkingData
- | ToolCallData
- | ToolResultData
- | ErrorData
- | SystemData
- | UsageData;
-
-/**
- * A persisted chunk row — the append-only unit of conversation storage and
- * the unit of frontend pagination. `seq` is per-tab monotonic and is both the
- * ordering key and the pagination cursor.
- */
-export interface ChunkRow {
- id: string;
- tabId: string;
- seq: number;
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
- createdAt: number;
-}
-
-/**
- * A chunk-row draft (no `seq`/`tabId`/`createdAt`/`id` yet) used when
- * exploding an in-memory turn into rows for persistence.
- */
-export interface ChunkRowDraft {
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
-}
-
-export interface ToolCall {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-
-export interface ToolResult {
- toolCallId: string;
- toolName: string;
- result: string;
- isError: boolean;
-}
-
-// ─── Agent Status & Events ───────────────────────────────────────
-
-export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
-
-/**
- * Per-tab snapshot of live state, sent on WS connect and via
- * `GET /status`. Carries enough information for a freshly-loaded
- * frontend to reconstruct any in-flight assistant message.
- *
- * - `status` — always present; mirrors the in-memory `TabAgent.status`.
- * - `currentChunks` — the live in-flight `Chunk[]` for the running
- * assistant turn. Present iff `status === "running"` AND
- * `TabAgent.currentChunks` is non-null. Defensively copied at
- * snapshot time; the consumer owns the array.
- * - `currentAssistantId` — DB id of the in-flight assistant message
- * (the row that the eventual `flushAssistant` call will write/update).
- * Present iff `status === "running"` AND `TabAgent.currentAssistantId`
- * is set. The frontend uses this to align its local assistant
- * message id with the persisted id so subsequent `done` and reload
- * paths line up.
- *
- * Not part of `AgentEvent` itself: the `statuses` payload is a WS-
- * connect-level snapshot, not an event the `Agent` emits. The frontend
- * mirrors this type in `packages/frontend/src/lib/types.ts`.
- */
-export interface TabStatusSnapshot {
- status: AgentStatus;
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /**
- * `turn_id` of the in-flight turn. Present iff `status === "running"`.
- * Lets a frontend that reconnects mid-stream key its live chunks the same
- * way `turn-start` would, so they reconcile cleanly when the turn seals.
- */
- currentTurnId?: string;
- /**
- * The tab's current todo list. Included for ALL tabs (not just running
- * ones) so a freshly-reloaded frontend rehydrates the Tasks panel from the
- * backend instead of blanking it. Omitted when the list is empty.
- */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: AgentStatus }
- /**
- * Emitted once at the start of a turn (`processMessage`), before any
- * content deltas. Carries the `turn_id` shared by this turn's user message
- * and every assistant/tool chunk row. The frontend tags its in-flight
- * (live) chunks with this id so they key-match the sealed rows on
- * turn-completion reconcile (no remount/flicker). Display/sync only — not
- * conversation content.
- */
- | { type: "turn-start"; turnId: string }
- /**
- * Emitted once after a turn has fully settled AND its chunks have been
- * persisted (after `flushAssistant`). Signals the frontend that the turn's
- * rows — with real `seq`s — are now durable and can be reloaded, so it can
- * fold its transient live representation into the sealed chunk log. Emitted
- * after `status: idle`/`error` (which fire before the DB write). Display/sync
- * only — not conversation content.
- *
- * Carries `usageStats`: the tab's authoritative usage aggregate read from the
- * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds)
- * its live `cacheStats` with this, reconciling the live accumulator to the
- * persisted truth every turn. This self-heals the live overshoot that occurs
- * when a rate-limited fallback attempt's usage is streamed live but then
- * discarded server-side (never persisted). `null` ⇒ tab has no usage rows;
- * absent ⇒ leave `cacheStats` untouched (back-compat).
- */
- | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- /**
- * Emitted on the v6 `reasoning-end` stream event when it carries
- * `providerMetadata`. `appendEventToChunks` attaches the metadata to
- * the most recent unsealed `thinking` chunk; `toModelMessages` reads
- * it back as `providerOptions` on the next request's `ReasoningPart`.
- */
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | { type: "tool-call"; toolCall: ToolCall }
- | { type: "tool-result"; toolResult: ToolResult }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- /**
- * Per-request token usage, emitted once per LLM round-trip (each
- * `streamText` step) from the AI SDK `finish` stream event. `inputTokens`
- * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is
- * the portion served from Anthropic's prompt cache (a cache HIT) and
- * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache
- * Rate" view aggregates these to show the prompt-cache hit rate. Non-
- * caching providers report zero for the cache fields.
- */
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string; statusCode?: number }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "done"; message: ChatMessage }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- /**
- * Conversation-compaction lifecycle (UI-driven, not an agent tool). A
- * compaction summarizes a tab's older history into an anchored summary while
- * preserving the most recent turns verbatim.
- *
- * `compaction-started` fires on the temporary placeholder tab when the
- * summary request begins. `compaction-complete` fires when the summary has
- * been generated and the history relocated: the compacted continuation now
- * lives on `sourceTabId` (the canonical id, with its key/model/working-dir
- * preserved), the FULL pre-compaction history was moved to `backupTabId`, and
- * `tempTabId` (the placeholder) should be discarded by the frontend.
- * `compaction-error` reports a failure (or cancellation) on `tempTabId`.
- */
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-// ─── Tool Types ──────────────────────────────────────────────────
-
-export interface ToolExecuteContext {
- onOutput?: (data: string, stream: "stdout" | "stderr") => void;
- queueCallbacks?: QueueCallbacks;
-}
-
-export interface ToolDefinition {
- name: string;
- description: string;
- parameters: ZodType;
- execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>;
-}
-
-// ─── Agent Configuration ─────────────────────────────────────────
-
-/**
- * Canonical, ordered list of reasoning-effort levels — the SINGLE SOURCE OF
- * TRUTH for effort values across the whole codebase (core LLM call site, API
- * validation, agent TOML persistence, and the frontend UI). Ordered from least
- * to most effort.
- *
- * `none` disables reasoning. `low`/`medium`/`high`/`xhigh` are forwarded
- * verbatim to providers that accept them (OpenAI-compatible `reasoning_effort`,
- * Anthropic adaptive `effort`) — `xhigh` is accepted by newer OpenAI reasoning
- * models. `max` is Dispatch's own top tier, mapped per-provider at the call
- * site (e.g. classic-thinking Claude budget tokens).
- */
-export const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const;
-
-export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
-
-/**
- * Default effort applied when nothing more specific is configured (no per-model
- * effort and no per-tab selection). Resolution order is
- * per-model → per-tab → this default.
- */
-export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
-
-/** Human-readable labels for each effort level (UI display). */
-export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
- none: "Off",
- low: "Low",
- medium: "Medium",
- high: "High",
- xhigh: "X-High",
- max: "Max",
-};
-
-/** Runtime type guard for narrowing an arbitrary value to a `ReasoningEffort`. */
-export function isReasoningEffort(value: unknown): value is ReasoningEffort {
- return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value);
-}
-
-export interface AgentConfig {
- model: string;
- apiKey: string;
- baseURL: string;
- systemPrompt: string;
- tools: ToolDefinition[];
- workingDirectory: string;
- permissionChecker?: PermissionChecker;
- ruleset?: Ruleset;
- reasoningEffort?: ReasoningEffort;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /**
- * Tab ID the agent runs on. Used to scope per-tab side effects, namely
- * the tool-output spill directory (`/tmp/dispatch/tool-results/<tabId>/`).
- * Optional so legacy callers and tests can construct an Agent without one;
- * a fallback ID is generated when absent.
- */
- tabId?: string;
-}
-
-// ─── Config Types (dispatch.toml) ────────────────────────────────
-
-export interface DispatchConfig {
- keys?: KeyDefinition[];
- permissions: Record<string, string | Record<string, string>>;
- /**
- * Language Server Protocol servers, keyed by an arbitrary server id (e.g.
- * `"luau-lsp"`). Resolved by merging the HOME-directory global
- * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
- * `dispatch.toml` in a tab's effective working directory — local entries
- * override global ones sharing the same id, and global-only servers stay
- * active in every repository. Re-consulted when either config (or the
- * directory) changes. Config-driven only — there is no builtin server
- * registry and no auto-download; the declared `command[0]` must be on PATH.
- */
- lsp?: Record<string, LspServerConfig>;
-}
-
-/**
- * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]`
- * block. Mirrors opencode's custom-server schema so the Roblox Luau config
- * (and any other server) is portable between the two.
- *
- * Example (`dispatch.toml`):
- * ```toml
- * [lsp.luau-lsp]
- * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"]
- * extensions = [".luau"]
- *
- * [lsp.luau-lsp.initialization.luau-lsp.platform]
- * type = "roblox"
- * ```
- */
-export interface LspServerConfig {
- /**
- * Argv to launch the server over stdio. `command[0]` is the executable
- * (resolved via PATH); the rest are arguments. Required for every non-
- * disabled entry.
- */
- command: string[];
- /**
- * File extensions (with leading dot, e.g. `".luau"`) this server attaches
- * to. Required for custom servers — without it the client never knows which
- * files should activate the server.
- */
- extensions: string[];
- /** Extra environment variables merged onto `process.env` for the child. */
- env?: Record<string, string>;
- /**
- * `initializationOptions` forwarded verbatim in the LSP `initialize`
- * request (and echoed back for `workspace/configuration` /
- * `didChangeConfiguration`). For luau-lsp this carries the
- * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block.
- */
- initialization?: Record<string, unknown>;
- /** When true, the entry is parsed but skipped (no server launched). */
- disabled?: boolean;
-}
-
-export interface KeyDefinition {
- id: string;
- provider: string;
- env?: string;
- base_url: string;
- /** For "anthropic" provider: path to credentials file (default: ~/.claude/.credentials.json) */
- credentials_file?: string;
-}
-
-export type KeyStatus = "active" | "exhausted";
-
-export interface KeyState {
- definition: KeyDefinition;
- status: KeyStatus;
- lastError?: string;
- exhaustedAt?: number;
-}
-
-// ─── Skills Types ────────────────────────────────────────────────
-
-export type SkillScope = "global" | "project";
-export type SkillDirectory = string;
-
-export interface SkillDefinition {
- name: string;
- description: string;
- tags: string[];
- content: string;
- scope: SkillScope;
- source: string;
- directory: SkillDirectory;
-}
-
-export interface AgentSkillMapping {
- agentType: string;
- isOrchestrator: boolean;
- skills: string[];
- scope: SkillScope;
-}
-
-// ─── Task List Types ─────────────────────────────────────────────
-
-export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";
-
-export interface TaskItem {
- /**
- * Stable positional id used purely for UI keying and the
- * `task-list-update` event contract. It is NEVER exposed to the model:
- * the `todo` tool is a declarative whole-list write (the model sends the
- * entire desired list every call), so there are no ids for the model to
- * track. Ids are reassigned positionally on every `setTasks`.
- */
- id: string;
- content: string;
- status: TaskStatus;
-}
-
-// ─── Config Validation ───────────────────────────────────────────
-
-export interface ConfigError {
- path: string;
- message: string;
-}
-
-// ─── Message Queue Types ─────────────────────────────────────────
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface QueueCallbacks {
- dequeueMessages: () => QueuedMessage[];
- waitForQueuedMessage: () => { promise: Promise<void>; cancel: () => void };
-}
-
-// ─── Agent Definition Types ──────────────────────────────────────
-
-export interface AgentModelEntry {
- key_id: string;
- model_id: string;
- /**
- * Per-model/key reasoning effort. When set, overrides the per-tab effort
- * selector for generations that use this entry (resolution order:
- * per-model → per-tab → DEFAULT_REASONING_EFFORT). Omitted when unset.
- */
- effort?: ReasoningEffort;
-}
-
-export interface AgentDefinition {
- /** Human-readable name */
- name: string;
- /** Short description of what this agent does */
- description: string;
- /** Skills to auto-include, as "scope:name" strings */
- skills: string[];
- /** Allowed tools (allowlist) */
- tools: string[];
- /** Key+model fallback hierarchy, tried in order */
- models: AgentModelEntry[];
- /** Where the TOML was loaded from: "global" or a directory path */
- scope: string;
- /** The slug (filename without .toml) */
- slug: string;
- /** Default working directory for this agent (optional, absolute path) */
- cwd?: string;
- /** Whether this agent is a subagent (hidden from Chat Settings) */
- is_subagent?: boolean;
-}