summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
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/tools
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/tools')
-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
20 files changed, 0 insertions, 3107 deletions
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)}`;
- }
- },
- };
-}