v0.6.0: sub-agent orchestration, plan mode, memory, and the Claude-Code feature gap
Closes the gap to Claude Code across four upgrade rounds (all verified green: typecheck, 388 tests, build 313 KB). Sub-agent orchestration: - Resumable sub-agents (send_message) + MaxIterations "continue" path - Configurable multi-depth sub-agent nesting - Parallel fan-out for pure agent/agent__* batches, each writable delegation in its own throwaway git worktree (no file collisions) - Named specialist fleet: explore, code-reviewer, planner, debugger, test-writer (read-only types skip worktree isolation) - Named teammates: agent tool `name` arg + list_teammates + send_message by name (synchronous, addressable handle — not true async background) Plan mode: - True in-turn pause/resume on approval (no synthetic proceed turn) - exit_plan_mode tool + side-by-side diff rendering (/diff) Persistent memory: - Typed file-per-fact memory under config dir, index folded into the system prompt (bounded), memory + memory_write tools, /memory command Toolset + UI: - multi_edit, notebook_edit, web_search/web_fetch, workflow orchestration primitive (agent/parallel/pipeline/phase/log + worktree isolation + best-effort structured output with recursive schema validation) - Structured task system (task_create/list/get/update with dependency graph + ownership) replacing flat todo_write - Cron/scheduled tasks (cron_create/list/delete, schedule_wakeup) with durable persistence and idle-gated ticking - Interactive worktree session (enter_worktree/exit_worktree) with a live-cwd UI switch that survives /model changes - AskUserQuestion tool + QuestionPrompt UI - Permission rule granularity + user/project settings merge - Color diffs, sub-agent activity panel, /undo, dashboard refinements Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+162
-22
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "locode",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "locode",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^2.1.233",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"commander": "^13.0.0",
|
||||
@@ -40,7 +41,7 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
@@ -83,6 +84,145 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.233.tgz",
|
||||
"integrity": "sha512-WS0ZSsNu2zkQonC+rW7HdByMCkPQ2l+hO1G0LdvWTj40kiYr0qAiSJjCBNRIbi0foBol4IFTCKwLHAN83qxxUQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"bin": {
|
||||
"claude": "bin/claude.exe"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-code-darwin-arm64": "2.1.233",
|
||||
"@anthropic-ai/claude-code-darwin-x64": "2.1.233",
|
||||
"@anthropic-ai/claude-code-linux-arm64": "2.1.233",
|
||||
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.233",
|
||||
"@anthropic-ai/claude-code-linux-x64": "2.1.233",
|
||||
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.233",
|
||||
"@anthropic-ai/claude-code-win32-arm64": "2.1.233",
|
||||
"@anthropic-ai/claude-code-win32-x64": "2.1.233"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.233.tgz",
|
||||
"integrity": "sha512-mB2FyJQ0a+FTWbBTSQ3ZTAmm6Qxr5fSU2jA8JpHQ7XslcoKzmDV+/zN8CdGkshwY3kRLx432kDiHcBoTQuc/Dg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.233.tgz",
|
||||
"integrity": "sha512-uS5jiOm+JNcoPGHHQ0Z5V/8eWYWjPdUHY5LOKPEVaFNR+bDkQcPcsnBZk9oHOiex15TnDsFsTpIWTNLRs2Bv1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.233.tgz",
|
||||
"integrity": "sha512-G7Te22sph7qywNyfIcQq7Li7bbQp8zBHZV4mDBBP5ZXswkGDJSip8MSNU2OlHsoVgcLtTzNlm0U25wys/r0jag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.233.tgz",
|
||||
"integrity": "sha512-XmMTZ3U4hhAUMvOQ794tmkz0/Mga39ecBB7vf+CdSm4HirdzOaGAvW18l7tWhcPRXH3M7Tx1bDefhyRdlSEc5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.233.tgz",
|
||||
"integrity": "sha512-ubMVvBBlsks5NE0EmucELB2h/XZ64L86JgmMBUWShLgDAkrrzCh1zIf5qX1+SPskXAnMfKBFTMNeaT6UruxRkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.233.tgz",
|
||||
"integrity": "sha512-KlgySdzFyoGmBLr6ReSBHclRwAzEuRLBmmYSHq2j9BLlXTy1kt0mhJoEffYkw1Hs5dasSK9sGEVgNt6wJ/PMmA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.233.tgz",
|
||||
"integrity": "sha512-bB6YrEv+PtQG9aPhPyAwhJZpPlBqFrqshcHPj+0S1gOkj6CwjtbnTDOMhywsd593XsiI5lWaWi4ZUo0FDZjzaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-x64": {
|
||||
"version": "2.1.233",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.233.tgz",
|
||||
"integrity": "sha512-8tg7+eDee3JfjBdNgxhVYrYr9eweqprRxten1OW+hrSTvKV5YjZb0CheFqtaqilDLILLt8LRQ0n/YeweQeycyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -536,9 +676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "1.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
|
||||
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -2394,9 +2534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2679,9 +2819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.27",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
|
||||
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
|
||||
"version": "4.13.2",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
|
||||
"integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -2985,9 +3125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -3363,9 +3503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3644,9 +3784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3664,7 +3804,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "locode",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"description": "Agentic coding CLI for local models served via Ollama and LM Studio",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
@@ -20,6 +20,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^2.1.233",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"commander": "^13.0.0",
|
||||
@@ -47,5 +48,10 @@
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@anthropic-ai/claude-code@2.1.233": true,
|
||||
"esbuild@0.27.7": true,
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
|
||||
+14
-7
@@ -1,4 +1,4 @@
|
||||
import type { TodoItem } from "../tools/types.js";
|
||||
import type { TaskSummary } from "../tools/task.js";
|
||||
|
||||
export type AgentEvent =
|
||||
| { type: "text_delta"; delta: string }
|
||||
@@ -8,19 +8,26 @@ export type AgentEvent =
|
||||
* retried non-streaming, so the UI doesn't carry the stale partial into the retry's output. */
|
||||
| { type: "stream_discard" }
|
||||
| { type: "tool_call"; label: string }
|
||||
| { type: "tool_result"; summary: string; isError: boolean }
|
||||
| { type: "tool_result"; summary: string; isError: boolean; diff?: string }
|
||||
/** The model finished a turn in plan mode with a prose plan. Rendered as a distinct `plan`
|
||||
* HistoryItem (not a plain assistant message) and followed by an approve/reject prompt built
|
||||
* on the same confirm primitive tool permissions use — see maybePresentPlan in agent/loop.ts. */
|
||||
| { type: "plan_presented"; text: string }
|
||||
/** The user approved the presented plan. loop.ts has already exited plan mode; the UI drives
|
||||
* implementation by injecting a "proceed" follow-up turn (see App.tsx submitTurn). */
|
||||
| { type: "plan_approved"; text: string }
|
||||
/** A sub-agent's tool call or result, forwarded to the parent so its work is visible while it
|
||||
* runs headless. Routed to the dedicated sub-agent panel below the input (not the main
|
||||
* scrollback) — see App.tsx. */
|
||||
* runs headless. Rendered inline in the main scrollback (tagged with the sub-agent's
|
||||
* description) — see App.tsx and HistoryItemView. */
|
||||
| { type: "subagent"; description: string; line: SubagentLine }
|
||||
/** A hook (see hooks/runner.ts) blocked something or failed non-fatally — surfaced as a notice. */
|
||||
| { type: "hook_notice"; text: string; isError: boolean }
|
||||
/** A general informational notice from the loop itself (not tied to a hook) — e.g. a mid-turn
|
||||
* auto-compaction. Surfaced the same way as hook_notice. */
|
||||
| { type: "notice"; text: string; isError: boolean }
|
||||
/** The `todo_write` tool replaced the session's task checklist — carries the full new list so
|
||||
* the UI can render it as a standalone checklist item rather than raw JSON tool output. */
|
||||
| { type: "todos_update"; todos: TodoItem[] };
|
||||
/** A task_create/task_update mutation changed the session's task store — carries a snapshot so
|
||||
* the UI can render the current checklist as a standalone item rather than raw JSON tool output. */
|
||||
| { type: "tasks_update"; tasks: TaskSummary[] };
|
||||
|
||||
export type SubagentLine =
|
||||
| { kind: "call"; label: string }
|
||||
|
||||
+1235
-13
File diff suppressed because it is too large
Load Diff
+591
-184
@@ -8,17 +8,22 @@ import type { ChatCompletionContentPart, ChatCompletionMessageParam } from "open
|
||||
export type ChatCompletionUserContent = ChatCompletionContentPart[];
|
||||
import type { AgentEventHandler } from "./events.js";
|
||||
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||
import type { SubAgentOverrides, SubAgentTask, TodoItem } from "../tools/types.js";
|
||||
import type { SubAgentOverrides, SubAgentResult, SubAgentTask } from "../tools/types.js";
|
||||
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
|
||||
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
||||
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
|
||||
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { looksLikeDiff } from "../utils/diff.js";
|
||||
import { createWorktree, hasUncommittedChanges } from "../utils/worktree.js";
|
||||
import { isReadOnlyAgentType } from "../tools/agentTypes.js";
|
||||
import { TaskStore } from "../tools/task.js";
|
||||
import { runHooksForEvent } from "../hooks/runner.js";
|
||||
import { resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "../config/config.js";
|
||||
import { MAX_CONTINUATION_ROUNDS } from "../config/defaults.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
import type { Session } from "./session.js";
|
||||
import { setPermissionMode, type Session } from "./session.js";
|
||||
|
||||
function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
|
||||
for (const warning of warnings) {
|
||||
@@ -34,6 +39,180 @@ async function fireStopHook(session: Session, emit: AgentEventHandler, finalText
|
||||
emitHookWarnings(result.warnings, emit);
|
||||
}
|
||||
|
||||
/** Serializes a critical section across concurrent sub-agents onto the session's confirm-mutex
|
||||
* chain. Each call appends to the chain and awaits the previous link, so concurrent confirms (from
|
||||
* parallel `agent` delegations) queue one at a time — the UI has a single PendingPermission slot
|
||||
* and can't show two prompts at once. The chain swallows rejections so a failed confirm doesn't
|
||||
* poison later locks; the caller still sees the real rejection via the returned promise. */
|
||||
async function withConfirmLock<T>(session: Session, fn: () => Promise<T>): Promise<T> {
|
||||
// The mutex is a shared *holder* object (session.confirmMutex.chain), not a bare promise: a bare
|
||||
// promise would be reassigned per-session, and parallel sub-sessions (which all reference the
|
||||
// parent's holder) would each install their own chain and stop serializing. Mutating .chain on the
|
||||
// shared holder keeps every concurrent sub-agent queued on the same lock.
|
||||
const next = session.confirmMutex.chain.then(() => fn());
|
||||
session.confirmMutex.chain = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Runs a batch of tool calls issued in one assistant message. If every call is a pure sub-agent
|
||||
* delegation (`agent`/`agent__*`) and there are at least two, runs them concurrently with
|
||||
* Promise.all — each delegation spawns its own isolated sub-session, so they're independent and the
|
||||
* wall-clock cost is the slowest one rather than the sum. Otherwise runs sequentially (mixed batches
|
||||
* with non-agent tools stay sequential to keep shared-state mutations ordered). Results come back in
|
||||
* input order so the caller pushes tool-result messages deterministically. Concurrent delegations'
|
||||
* confirmation prompts are serialized by the session's confirm mutex (see withConfirmLock). When an
|
||||
* `isolate` hook is supplied, each parallel delegation also gets its own throwaway git worktree
|
||||
* (createWorktree) so concurrent file-writing sub-agents can't collide; the cwd is passed to runOne
|
||||
* and the worktree is cleaned up after the batch resolves. */
|
||||
async function runBatch<T, R>(
|
||||
items: T[],
|
||||
getName: (t: T) => string,
|
||||
runOne: (t: T, isolationCwd?: string) => Promise<R>,
|
||||
/** Best-effort per-item isolation for parallel pure-delegation batches. Returns a worktree cwd (or
|
||||
* undefined when isolation isn't possible, e.g. not a git repo) and a cleanup callback. Only
|
||||
* invoked for pure-delegation batches of ≥2; sequential/single calls are never isolated. */
|
||||
isolate?: (t: T) => Promise<{ cwd: string | undefined; cleanup: () => Promise<void> }>,
|
||||
): Promise<R[]> {
|
||||
const pureDelegation =
|
||||
items.length >= 2 &&
|
||||
items.every((t) => {
|
||||
const n = getName(t);
|
||||
return n === "agent" || n.startsWith("agent__");
|
||||
});
|
||||
if (!pureDelegation) {
|
||||
const out: R[] = [];
|
||||
for (const item of items) out.push(await runOne(item));
|
||||
return out;
|
||||
}
|
||||
if (!isolate) {
|
||||
return Promise.all(items.map((item) => runOne(item)));
|
||||
}
|
||||
// Create one worktree per parallel delegation up front (best-effort: a non-git repo yields
|
||||
// cwd=undefined and a noop cleanup, so that item falls back to the shared session cwd).
|
||||
const isos = await Promise.all(items.map((item) => isolate(item).catch(() => ({ cwd: undefined, cleanup: async () => {} }))));
|
||||
try {
|
||||
return await Promise.all(items.map((item, i) => runOne(item, isos[i]!.cwd)));
|
||||
} finally {
|
||||
await Promise.all(isos.map((iso) => iso.cleanup()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the `isolate` hook for runBatch. Each parallel sub-agent delegation gets its own throwaway
|
||||
* git worktree of the session's cwd — UNLESS the delegation's agentType is read-only (explore /
|
||||
* code-reviewer), in which case it runs in the shared cwd so it sees the current working state
|
||||
* (including uncommitted changes) instead of the last commit, and skips a wasteful checkout.
|
||||
* Best-effort — createWorktree returns cwd=undefined when the cwd isn't a git repo, so the item
|
||||
* falls back to the shared cwd. Sequential/single delegations never call this (runBatch only
|
||||
* isolates pure-delegation batches of ≥2). */
|
||||
function isolateSubAgent<T>(
|
||||
session: Session,
|
||||
/** Extracts the tool-call's parsed args object from the batch item, so the agentType can be read.
|
||||
* Returns undefined for a malformed/unparseable call (treated as non-read-only → isolated, safe). */
|
||||
extractArgs: (t: T) => unknown,
|
||||
): (t: T) => Promise<{ cwd: string | undefined; cleanup: () => Promise<void> }> {
|
||||
return (t) => {
|
||||
const args = extractArgs(t);
|
||||
const agentType = args && typeof args === "object" ? (args as { agentType?: string }).agentType : undefined;
|
||||
if (isReadOnlyAgentType(agentType)) {
|
||||
return Promise.resolve({ cwd: undefined, cleanup: async () => {} });
|
||||
}
|
||||
return createWorktree(session.cwd);
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses a native tool-call's JSON arguments string into an object, tolerating malformed input. */
|
||||
function parseNativeArgs(raw: string | undefined): unknown {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of {@link maybePresentPlan}: how the caller should react to a plan-mode final answer.
|
||||
* - `"none"` — this wasn't a plan-mode presentation (not in plan mode); the caller does its normal
|
||||
* final-text handling (emit text_done, push the assistant message, fire the stop hook, return).
|
||||
* - `"return"` — a plan was presented and the turn should end now (the user rejected it; plan mode
|
||||
* stays active so they can refine). The caller returns the plan text.
|
||||
* - `"continue"` — the turn paused for approval and has now resumed in the SAME loop: either the
|
||||
* user approved (mode switched to default, an in-band approval signal was appended) or the model
|
||||
* stopped empty in plan mode (a nudge to actually present a plan was appended). The caller
|
||||
* `continue`s to the next iteration so the model produces the next response in this turn. */
|
||||
type PlanAction = "none" | "return" | "continue";
|
||||
|
||||
/** Shared plan-approval core used by both the prose path (`maybePresentPlan`, for a model that
|
||||
* presents a plan as its final message) and the structured `exit_plan_mode` tool. Emits
|
||||
* `plan_presented`, asks the user via the same confirm primitive tool permissions use (toolName
|
||||
* "plan" → the UI's two-option Approve/Reject prompt), and on approval switches the session out of
|
||||
* plan mode and emits `plan_approved`. Returns whether the user approved; on rejection plan mode
|
||||
* stays active and a "staying in plan mode" notice is emitted. The caller decides what to do with a
|
||||
* rejection (the prose path ends the turn; the tool path returns a "refine and try again" result so
|
||||
* the model can continue in the same turn). */
|
||||
async function presentPlanForApproval(
|
||||
session: Session,
|
||||
plan: string,
|
||||
emit: AgentEventHandler,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<{ approved: boolean }> {
|
||||
emit({ type: "plan_presented", text: plan });
|
||||
// Reuse the tool-permission confirm prompt (UI arms it via session.confirm). toolName "plan" tells
|
||||
// the UI to render a two-option Approve/Reject prompt instead of the three-option tool prompt.
|
||||
const decision = await session.confirm({ toolName: "plan", args: { text: plan }, preview: plan, signal });
|
||||
if (decision === "deny") {
|
||||
emit({ type: "notice", text: "Plan rejected — staying in plan mode. Refine the plan or explore more, then present it again.", isError: false });
|
||||
return { approved: false };
|
||||
}
|
||||
setPermissionMode(session, "default");
|
||||
emit({ type: "plan_approved", text: plan });
|
||||
return { approved: true };
|
||||
}
|
||||
|
||||
/** If the session is in plan mode, handle a final answer as a plan presentation: record it as an
|
||||
* assistant message, emit `plan_presented` (via presentPlanForApproval), and ask the user to
|
||||
* approve. On approval the session is switched out of plan mode and an in-band "approved —
|
||||
* implement now" user message is appended so the turn resumes in the same loop (the model
|
||||
* implements the plan as a continuation of this turn, not a separate UI-driven "proceed" turn). On
|
||||
* rejection plan mode stays active, the stop hook fires, and the turn ends so the user can refine.
|
||||
* An empty stop in plan mode nudges the model to present a plan (or call exit_plan_mode) instead of
|
||||
* ending the turn with no output. This is the FALLBACK path for models that present a plan as prose
|
||||
* without calling the `exit_plan_mode` tool; the tool path (handler → presentPlanForApproval) is
|
||||
* preferred but both reach the same approval prompt. */
|
||||
async function maybePresentPlan(
|
||||
session: Session,
|
||||
fullText: string,
|
||||
emit: AgentEventHandler,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<PlanAction> {
|
||||
if (session.permissions.getMode() !== "plan") return "none";
|
||||
if (!fullText.trim()) {
|
||||
// Empty stop in plan mode — the model produced no plan. Nudge it to present one rather than
|
||||
// silently ending the turn (which would leave the UI with nothing to approve). Bounded by the
|
||||
// turn's step budget like any other retry, so a model that keeps empty-stopping still terminates.
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"You are in plan mode but didn't present a plan. Reply with the plan in prose — which files you'd change and how — or call the exit_plan_mode tool with the plan, without calling any other tools.",
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
const { approved } = await presentPlanForApproval(session, fullText, emit, signal);
|
||||
if (!approved) {
|
||||
await fireStopHook(session, emit, fullText);
|
||||
return "return";
|
||||
}
|
||||
// In-band resume signal: the turn paused at the confirm and now resumes in the same loop, so the
|
||||
// model implements the just-approved plan as a continuation of THIS turn — no separate "proceed"
|
||||
// turn injected by the UI. (The stop hook is intentionally NOT fired here: the turn isn't over.)
|
||||
session.messages.push({ role: "user", content: "The plan above was approved. Proceed to implement it now." });
|
||||
return "continue";
|
||||
}
|
||||
|
||||
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
|
||||
* gets folded straight into the system prompt as extra context (e.g. current git status, a TODO
|
||||
* list). Returns non-blocking warnings for the caller to surface however it displays notices. */
|
||||
@@ -48,8 +227,13 @@ function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeo
|
||||
|
||||
export async function fireSessionStartHook(session: Session): Promise<string[]> {
|
||||
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
|
||||
if (result.additionalContext || result.jsonContext?.length) {
|
||||
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
|
||||
const systemMessage = session.messages[0];
|
||||
if (
|
||||
(result.additionalContext || result.jsonContext?.length) &&
|
||||
systemMessage &&
|
||||
systemMessage.role === "system" &&
|
||||
typeof systemMessage.content === "string"
|
||||
) {
|
||||
systemMessage.content = foldHookContext(systemMessage.content, result);
|
||||
}
|
||||
return result.warnings;
|
||||
@@ -122,10 +306,11 @@ export function createIdleAbort(
|
||||
|
||||
const MAX_MALFORMED_RETRIES = 2;
|
||||
|
||||
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
|
||||
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
|
||||
// that hold even if that filtering were ever bypassed or a future change loosened it.
|
||||
const MAX_SUBAGENT_DEPTH = 1;
|
||||
// Sub-agent safety limits. The toolset excludes `agent` for sub-agents once they reach the depth
|
||||
// cap (so a model can't spawn nested sub-agents through normal tool use), but these are
|
||||
// independent, explicit backstops that hold even if that filtering were ever bypassed or a future
|
||||
// change loosened it. The depth cap is per-session (session.subagentMaxDepth) rather than a module
|
||||
// constant so it's configurable and testable without touching env/config.
|
||||
const SUBAGENT_TIMEOUT_MS = resolveSubagentTimeoutMs();
|
||||
|
||||
function updateContextTracking(session: Session, promptTokens: number | null | undefined): void {
|
||||
@@ -355,6 +540,10 @@ async function gateAndRun(
|
||||
* prompt its mutating call is waiting on (via confirm) and kill an in-flight bash child (via the
|
||||
* tool ctx). Undefined for top-level turns, which have no timeout to abort on. */
|
||||
signal?: AbortSignal,
|
||||
/** When set, sub-agents spawned by this tool call (agent/agent__*) run against this cwd instead of
|
||||
* session.cwd — used by the parallel-fan-out dispatch to give each concurrent sub-agent its own
|
||||
* throwaway git worktree. A handler-supplied cwd override (SubAgentOverrides.cwd) wins over this. */
|
||||
subAgentCwdOverride?: string,
|
||||
): Promise<unknown> {
|
||||
session.stats.toolCalls++;
|
||||
if ("error" in resolved) {
|
||||
@@ -371,16 +560,55 @@ async function gateAndRun(
|
||||
const backgroundControl = tool.name === "bash" ? { requested: false } : undefined;
|
||||
session.activeBackground = backgroundControl ?? null;
|
||||
|
||||
// The task store mutates in-place via the task_* tools; wire its change emitter to this turn's
|
||||
// emit so each create/update broadcasts a tasks_update snapshot to the UI.
|
||||
session.taskStore.setEmitter((tasks) => emit({ type: "tasks_update", tasks }));
|
||||
|
||||
try {
|
||||
const ctx = {
|
||||
cwd: session.cwd,
|
||||
runSubAgent: (task: SubAgentTask, overrides?: SubAgentOverrides) => runSubAgentTurn(session, task, overrides, emit),
|
||||
runSubAgent: (task: SubAgentTask, overrides?: SubAgentOverrides) =>
|
||||
runSubAgentTurn(session, task, { ...overrides, cwd: overrides?.cwd ?? subAgentCwdOverride }, emit),
|
||||
resumeSubAgent: (agentId: string, message: string) => resumeSubAgentTurn(session, agentId, message, emit),
|
||||
// Named-teammate roster: a name → agentId index over resumable sub-agent sessions, so the
|
||||
// `agent` tool (with a `name`) can register one and `send_message`/`list_teammates` can address
|
||||
// it by name. Built on the same subAgentSessions map — this just adds the name lookup.
|
||||
registerTeammate: (name: string, agentId: string) => {
|
||||
session.namedAgents.set(name, agentId);
|
||||
},
|
||||
resolveTeammate: (name: string) => session.namedAgents.get(name),
|
||||
listTeammates: () => [...session.namedAgents.entries()].map(([name, agentId]) => ({ name, agentId })),
|
||||
// Interactive worktree session: enter_worktree/exit_worktree switch the session cwd into (and
|
||||
// out of) an isolated git worktree. setCwd updates session.cwd + notifies the UI; the worktree
|
||||
// tracking is stored on the session and mirrored to the App via onWorktreeChange so a /model
|
||||
// switch can re-attach it.
|
||||
setCwd: (newCwd: string) => {
|
||||
session.cwd = newCwd;
|
||||
session.onCwdChange?.(newCwd);
|
||||
},
|
||||
getWorktree: () => session.worktree,
|
||||
setWorktree: (worktree: { dir: string; branch: string; originalCwd: string } | undefined) => {
|
||||
session.worktree = worktree;
|
||||
session.onWorktreeChange?.(worktree ?? null);
|
||||
},
|
||||
backgroundControl,
|
||||
signal,
|
||||
setTodos: (todos: TodoItem[]) => {
|
||||
session.todos = todos;
|
||||
emit({ type: "todos_update", todos });
|
||||
taskStore: session.taskStore,
|
||||
cronStore: session.cronStore,
|
||||
setLastEdit: (edit: { path: string; previousContent: string }) => {
|
||||
session.lastEdit = edit;
|
||||
},
|
||||
setUserMemory: (memory: string | null) => {
|
||||
session.userMemory = memory;
|
||||
},
|
||||
emitNotice: (text: string, isError?: boolean) => emit({ type: "notice", text, isError: isError ?? false }),
|
||||
// Only wired in plan mode so a stray exit_plan_mode call outside plan mode fails with a clear
|
||||
// "only available in plan mode" error instead of presenting an approval prompt with no plan.
|
||||
exitPlanMode:
|
||||
session.permissions.getMode() === "plan"
|
||||
? (plan: string) => presentPlanForApproval(session, plan, emit, signal)
|
||||
: undefined,
|
||||
askQuestion: session.askQuestion,
|
||||
};
|
||||
|
||||
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
|
||||
@@ -393,20 +621,35 @@ async function gateAndRun(
|
||||
return { error: `Blocked by hook: ${preHook.reason}` };
|
||||
}
|
||||
|
||||
// Explicit permission rules (user config + project .locode/settings.json): a deny rule blocks
|
||||
// the call outright (no prompt), for any tool — not just mutating ones. An allow rule is
|
||||
// consulted by isAutoApproved below to skip the confirmation prompt for a matching mutating tool.
|
||||
if (session.permissions.checkRules(tool.name, args) === "deny") {
|
||||
const message = `Blocked by permission rule (deny) for ${tool.name}.`;
|
||||
emit({ type: "tool_result", summary: message, isError: true });
|
||||
return { error: message };
|
||||
}
|
||||
|
||||
// Plan mode blocks every mutating tool outright — no prompt, since the whole point is that
|
||||
// nothing changes until the user reviews the plan and explicitly exits plan mode (Shift+Tab or
|
||||
// /perm). Checked ahead of the normal auto-approve/confirm gate so a prior "allow for session"
|
||||
// grant or auto-accept mode can't bypass it.
|
||||
if (tool.mutating && session.permissions.getMode() === "plan") {
|
||||
const message =
|
||||
"Blocked: plan mode is active, so mutating tools can't run. Describe what you'd do in your final answer " +
|
||||
"instead of calling this tool — the user can exit plan mode (Shift+Tab or /perm) once they approve the plan.";
|
||||
"Blocked: plan mode is active, so mutating tools can't run. Stop calling mutating tools and " +
|
||||
"instead present your implementation plan via the exit_plan_mode tool (which files you'd change " +
|
||||
"and how); the user approves through that, after which plan mode ends and you can implement.";
|
||||
emit({ type: "tool_result", summary: message, isError: true });
|
||||
return { error: message };
|
||||
}
|
||||
|
||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
||||
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||
// Compute the diff preview once for any mutating tool. It's shown in the confirmation prompt
|
||||
// below (when one is needed) AND threaded into the tool_result so the scrollback displays the
|
||||
// change after the tool runs — including in auto-edit/auto-accept mode, where no prompt appears
|
||||
// but the user still wants to see what changed in the transcript.
|
||||
const preview = tool.mutating && tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||
|
||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name, args)) {
|
||||
const permissionHook = await runHooksForEvent(
|
||||
"PermissionRequest",
|
||||
hookCtx,
|
||||
@@ -418,7 +661,9 @@ async function gateAndRun(
|
||||
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
|
||||
return { error: `Blocked by hook: ${permissionHook.reason}` };
|
||||
}
|
||||
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
|
||||
// Serialize the prompt across concurrent sub-agents (runBatch fan-out) so two delegations
|
||||
// can't race for the UI's single PendingPermission slot — see withConfirmLock.
|
||||
const decision = await withConfirmLock(session, () => session.confirm({ toolName: tool.name, args, preview, signal }));
|
||||
if (decision === "deny") {
|
||||
emit({ type: "tool_result", summary: "Denied by user", isError: true });
|
||||
return { error: "Denied by user." };
|
||||
@@ -440,7 +685,11 @@ async function gateAndRun(
|
||||
.then((r) => emitHookWarnings(r.warnings, emit))
|
||||
.catch(() => {});
|
||||
}
|
||||
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
|
||||
// Thread the already-computed diff preview (for mutating tools that produce one) into the
|
||||
// tool_result so the scrollback shows the change after the tool runs — not only in the
|
||||
// pre-confirmation prompt. Only when the preview is actually a unified diff (bash previews are
|
||||
// the command text, not a diff, so they aren't color-rendered as one).
|
||||
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError, diff: looksLikeDiff(preview) ? preview : undefined });
|
||||
|
||||
// PostToolUse can't *veto* a tool call that already ran (unlike PreToolUse), but it IS awaited:
|
||||
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
|
||||
@@ -462,9 +711,9 @@ async function gateAndRun(
|
||||
* `agent` tool's result) — so mutating tool calls it makes still go through the shared
|
||||
* PermissionManager/confirm and will still prompt the user, they just aren't shown mid-flight.
|
||||
*
|
||||
* Bounded by two independent safety limits (see MAX_SUBAGENT_DEPTH/SUBAGENT_TIMEOUT_MS above):
|
||||
* an explicit depth check (on top of the toolset already excluding `agent` for sub-agents) and a
|
||||
* hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely.
|
||||
* Bounded by two independent safety limits (see SUBAGENT_TIMEOUT_MS above and session.subagentMaxDepth):
|
||||
* an explicit depth check (on top of the toolset excluding `agent` for sub-agents at the depth cap)
|
||||
* and a hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely.
|
||||
*/
|
||||
async function runSubAgentTurn(
|
||||
parent: Session,
|
||||
@@ -475,32 +724,68 @@ async function runSubAgentTurn(
|
||||
* the sense that its streamed text isn't shown (its final text comes back via the agent tool's
|
||||
* result), but its file reads/greps are forwarded so the user can see it working. */
|
||||
emit: AgentEventHandler,
|
||||
): Promise<string> {
|
||||
if (parent.subAgentDepth >= MAX_SUBAGENT_DEPTH) {
|
||||
throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${MAX_SUBAGENT_DEPTH}).`);
|
||||
): Promise<SubAgentResult> {
|
||||
const maxDepth = parent.subagentMaxDepth;
|
||||
if (parent.subAgentDepth >= maxDepth) {
|
||||
throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${maxDepth}).`);
|
||||
}
|
||||
|
||||
// Derived from the parent's own toolset (so MCP tools carry over) minus `agent`/plugin-agent
|
||||
// tools — sub-agents can't spawn further sub-agents, keeping recursion bounded to one level.
|
||||
// A plugin agent's `tools:` frontmatter further restricts this to a named subset.
|
||||
const inheritable = parent.toolset.tools.filter((t) => t.name !== "agent" && !t.name.startsWith("agent__"));
|
||||
// Derived from the parent's own toolset (so MCP tools carry over). A sub-agent may itself spawn
|
||||
// further sub-agents only if its depth (parent.subAgentDepth + 1) is still below the cap; once at
|
||||
// the cap, `agent`/plugin-agent tools are stripped so recursion stays bounded. A plugin agent's
|
||||
// `tools:` frontmatter further restricts the result to a named subset.
|
||||
// Task tools (task_create/list/get/update) are excluded from sub-agents — task tracking is the
|
||||
// orchestrator's job, and a headless sub-agent creating top-level tasks would muddle the checklist.
|
||||
// Scheduling tools (cron_*/schedule_wakeup) are excluded too — only the main session (with its UI
|
||||
// idle-enqueue loop) should schedule prompts; a headless sub-agent scheduling wakeups would fire
|
||||
// into nothing.
|
||||
const isOrchestratorOnly = (name: string) =>
|
||||
name === "exit_plan_mode" ||
|
||||
name === "ask_user_question" ||
|
||||
name.startsWith("task_") ||
|
||||
name.startsWith("cron_") ||
|
||||
name === "schedule_wakeup" ||
|
||||
// Interactive worktree sessions switch the orchestrator's cwd — a headless sub-agent (which has
|
||||
// its own session) shouldn't redirect its own cwd or the user's working context. The orchestrator
|
||||
// (or the user via the model) decides when to enter/leave a worktree.
|
||||
name === "enter_worktree" ||
|
||||
name === "exit_worktree";
|
||||
const canSpawnFurther = parent.subAgentDepth + 1 < maxDepth;
|
||||
const inheritable = canSpawnFurther
|
||||
? parent.toolset.tools.filter((t) => !isOrchestratorOnly(t.name))
|
||||
: parent.toolset.tools.filter((t) => t.name !== "agent" && !t.name.startsWith("agent__") && !isOrchestratorOnly(t.name));
|
||||
const restricted = overrides?.toolNames ? inheritable.filter((t) => overrides.toolNames!.includes(t.name)) : inheritable;
|
||||
const subToolset = buildToolSet(restricted);
|
||||
// A parallel-fan-out delegation passes a throwaway git worktree as the cwd (see runBatch +
|
||||
// createWorktree) so concurrent sub-agents can't collide on files; a single/sequential delegation
|
||||
// omits it and runs in the parent's cwd, persisting edits. The sub-agent's own tool hooks
|
||||
// (PreToolUse etc.) run inside runTurn with this cwd, so they see the worktree path too.
|
||||
const subCwd = overrides?.cwd ?? parent.cwd;
|
||||
const isolated = overrides?.cwd !== undefined && overrides.cwd !== parent.cwd;
|
||||
const systemPrompt = overrides?.systemPrompt
|
||||
? `${overrides.systemPrompt}\n\nAvailable tools:\n${subToolset.tools.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
|
||||
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
|
||||
: overrides?.systemPromptAddendum
|
||||
? `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions, parent.userMemory)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.\n\n${overrides.systemPromptAddendum}`
|
||||
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions, parent.userMemory)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
|
||||
const subMessages: ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }];
|
||||
const subSession: Session = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
client: parent.client,
|
||||
model: parent.model,
|
||||
cwd: parent.cwd,
|
||||
cwd: subCwd,
|
||||
mode: parent.mode,
|
||||
messages: subMessages,
|
||||
maxIterations: parent.maxIterations,
|
||||
maxIterations: parent.subagentMaxIterations,
|
||||
// A sub-agent can spawn further sub-agents only below the depth cap (see runSubAgentTurn); this
|
||||
// is inherited so the cap applies uniformly across every nesting level.
|
||||
subagentMaxIterations: parent.subagentMaxIterations,
|
||||
subagentMaxDepth: parent.subagentMaxDepth,
|
||||
permissions: parent.permissions,
|
||||
confirm: parent.confirm,
|
||||
// Shared with the parent so concurrent sub-agents fanned out from one parent turn serialize
|
||||
// their confirmation prompts on a single lock (see runBatch + gateAndRun).
|
||||
confirmMutex: parent.confirmMutex,
|
||||
toolset: subToolset,
|
||||
subAgentDepth: parent.subAgentDepth + 1,
|
||||
contextWindow: parent.contextWindow,
|
||||
@@ -516,15 +801,25 @@ async function runSubAgentTurn(
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
projectInstructions: parent.projectInstructions,
|
||||
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
|
||||
// checklist has nowhere to render even if it called todo_write.
|
||||
todos: [],
|
||||
// Inherited so a sub-agent sees the same user memory/project conventions as the parent.
|
||||
userMemory: parent.userMemory,
|
||||
// Independent from the parent's — a sub-agent runs headless (see class doc above) and task_*
|
||||
// tools are stripped from its toolset, so its own task store would have nowhere to render anyway.
|
||||
taskStore: new TaskStore(),
|
||||
// Shared with the parent so a sub-agent's file edits can be undone from the main session too.
|
||||
lastEdit: parent.lastEdit,
|
||||
// A sub-agent may itself spawn resumable sub-agents (below the depth cap); give it its own map
|
||||
// rather than sharing the parent's, so a nested send_message can't reach into a sibling's session.
|
||||
subAgentSessions: new Map(),
|
||||
// A sub-agent gets its own name → agentId index too, so a nested named teammate is addressable
|
||||
// by name within that sub-agent's own roster (symmetric with subAgentSessions above).
|
||||
namedAgents: new Map(),
|
||||
};
|
||||
|
||||
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
|
||||
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
|
||||
// sub-agent from running instead of only being logged.
|
||||
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
|
||||
const hookCtx = { sessionId: parent.id, cwd: subCwd };
|
||||
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
|
||||
if (startHook.warnings.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -534,6 +829,67 @@ async function runSubAgentTurn(
|
||||
throw new Error(startHook.reason ?? "Sub-agent blocked by SubagentStart hook.");
|
||||
}
|
||||
|
||||
const agentId = subSession.id;
|
||||
try {
|
||||
const answer = await runOneSubAgentTurn(subSession, task.prompt, task.description, emit);
|
||||
// If this sub-agent ran in a throwaway worktree and left file changes behind, those changes will
|
||||
// be discarded when the dispatch path cleans up the worktree (rightly — parallel delegations are
|
||||
// for analysis whose deliverable is the returned answer, not repo edits). Surface that explicitly
|
||||
// so the model doesn't believe its edits landed and blindly move on; it should re-delegate the
|
||||
// implementation as a single (sequential) agent to persist it. An isolated agent is never resumable
|
||||
// (its cwd is gone after the batch), so its agentId is withheld.
|
||||
if (isolated && (await hasUncommittedChanges(subCwd))) {
|
||||
return {
|
||||
agentId,
|
||||
result: `${answer}\n\n[This sub-agent ran in an isolated throwaway worktree, so its file changes were discarded. To persist edits to the repo, re-delegate the implementation as a single (non-parallel) agent call.]`,
|
||||
resumable: false,
|
||||
};
|
||||
}
|
||||
// A shared-cwd (non-isolated) sub-agent is resumable: keep its session so a later `send_message`
|
||||
// tool call can continue it with full context. Worktree-isolated agents are fire-and-forget.
|
||||
if (!isolated) parent.subAgentSessions.set(agentId, subSession);
|
||||
return { agentId, result: answer, resumable: !isolated };
|
||||
} catch (err) {
|
||||
// MaxIterationsError's message ("send another message to continue") is written for the top-level
|
||||
// session, where the human can literally do that. For a sub-agent the right response depends on
|
||||
// resumability: a shared-cwd sub-agent IS saved (above), so the model can continue it via
|
||||
// send_message rather than re-burning the full budget; an isolated (worktree) sub-agent can't be
|
||||
// continued, so redirect toward narrowing scope instead.
|
||||
if (err instanceof MaxIterationsError) {
|
||||
if (!isolated) {
|
||||
parent.subAgentSessions.set(agentId, subSession);
|
||||
return {
|
||||
agentId,
|
||||
result:
|
||||
`Sub-agent "${task.description}" used all ${subSession.maxIterations} steps of its budget without finishing. ` +
|
||||
`Its progress is saved — continue it with send_message (agentId: "${agentId}") instead of re-delegating from scratch, ` +
|
||||
`or split the remaining work into smaller sub-agents.`,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`Sub-agent "${task.description}" ran out of its step budget (${subSession.maxIterations} steps) before finishing. ` +
|
||||
`Its work isn't saved for a follow-up call — retrying with the same prompt will hit the same wall. Instead, split ` +
|
||||
`the task into smaller, more specific sub-agents (e.g. one per file or directory) or narrow this one's scope.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a single bounded turn for an already-built sub-agent session: arms the wall-clock timeout,
|
||||
* forwards the sub-agent's tool calls to the parent UI as `subagent` events, races the turn against
|
||||
* the timeout, and runs the SubagentStop hook in `finally`. Shared by runSubAgentTurn (first run)
|
||||
* and resumeSubAgentTurn (continuation) so both get identical timeout/forwarding/cleanup. Returns
|
||||
* the sub-agent's final answer text; propagates MaxIterationsError (and other errors) to the caller,
|
||||
* which decides how to surface them based on resumability. */
|
||||
async function runOneSubAgentTurn(
|
||||
subSession: Session,
|
||||
prompt: string,
|
||||
description: string,
|
||||
emit: AgentEventHandler,
|
||||
): Promise<string> {
|
||||
const hookCtx = { sessionId: subSession.id, cwd: subSession.cwd };
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const ac = new AbortController();
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
@@ -553,37 +909,22 @@ async function runSubAgentTurn(
|
||||
// text and notices — the sub-agent's final answer is returned via the agent tool's result, and
|
||||
// surfacing its partial text would both duplicate that and clutter the transcript.
|
||||
if (event.type === "tool_call") {
|
||||
emit({ type: "subagent", description: task.description, line: { kind: "call", label: event.label } });
|
||||
emit({ type: "subagent", description, line: { kind: "call", label: event.label } });
|
||||
} else if (event.type === "tool_result") {
|
||||
emit({ type: "subagent", description: task.description, line: { kind: "result", summary: event.summary, isError: event.isError } });
|
||||
emit({ type: "subagent", description, line: { kind: "result", summary: event.summary, isError: event.isError } });
|
||||
}
|
||||
};
|
||||
const turnPromise = runTurn(subSession, task.prompt, subEmit, subToolset, ac.signal);
|
||||
// Sub-agents intentionally do NOT auto-continue across multiple rounds: their session is
|
||||
// ephemeral, so if a task is too large for one sub-agent budget the right response is to tell the
|
||||
// parent to split the task rather than silently burning more rounds inside a black box.
|
||||
const turnPromise = runTurn(subSession, prompt, subEmit, subSession.toolset, ac.signal, 1);
|
||||
try {
|
||||
const answer = await Promise.race([turnPromise, timeout]);
|
||||
return answer;
|
||||
} catch (err) {
|
||||
// MaxIterationsError's message ("send another message to continue") is written for the
|
||||
// top-level session, where the human can literally do that and resume the same (persisted)
|
||||
// history. A sub-agent's session is thrown away the moment this call returns — there's nothing
|
||||
// to "continue". Without this, the calling model's only visible option is to re-invoke `agent`
|
||||
// with the same prompt, paying the full step budget again for a task that already proved too
|
||||
// big for it (this is exactly what caused two back-to-back identical "Find bugs in
|
||||
// plugins/MCP/hooks" sub-agent calls to each burn 50 steps for nothing). Redirect toward
|
||||
// narrowing scope instead.
|
||||
if (err instanceof MaxIterationsError) {
|
||||
throw new Error(
|
||||
`Sub-agent "${task.description}" ran out of its step budget (${subSession.maxIterations} steps) before finishing. ` +
|
||||
`Its work isn't saved for a follow-up call — retrying with the same prompt will hit the same wall. Instead, split ` +
|
||||
`the task into smaller, more specific sub-agents (e.g. one per file or directory) or narrow this one's scope.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
return await Promise.race([turnPromise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
ac.abort(); // no-op if the turn finished on its own; cancels a still-pending request otherwise
|
||||
turnPromise.catch(() => {});
|
||||
runHooksForEvent("SubagentStop", hookCtx, { description: task.description, result: subSession.messages.at(-1)?.content })
|
||||
runHooksForEvent("SubagentStop", hookCtx, { description, result: subSession.messages.at(-1)?.content })
|
||||
.then((r) => {
|
||||
// Sub-agent runs headless; warnings can't be emitted to a parent UI that isn't listening.
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -593,6 +934,27 @@ async function runSubAgentTurn(
|
||||
}
|
||||
}
|
||||
|
||||
/** Continues a previously-spawned resumable sub-agent (one stored in parent.subAgentSessions, i.e. a
|
||||
* shared-cwd agent that returned an agentId) with a follow-up message, preserving its context. Runs
|
||||
* one bounded turn (maxContinuationRounds=1) with the same timeout/UI-forwarding as the first run.
|
||||
* Rejects if the agentId is unknown or wasn't resumable (e.g. a worktree-isolated parallel agent,
|
||||
* whose cwd is gone). */
|
||||
async function resumeSubAgentTurn(
|
||||
parent: Session,
|
||||
agentId: string,
|
||||
message: string,
|
||||
emit: AgentEventHandler,
|
||||
): Promise<string> {
|
||||
const subSession = parent.subAgentSessions.get(agentId);
|
||||
if (!subSession) {
|
||||
throw new Error(
|
||||
`No resumable sub-agent with agentId "${agentId}" was found. It may have been a parallel (worktree-isolated) ` +
|
||||
`agent that can't be continued, or the session was cleared. Re-delegate the work as a new agent call instead.`,
|
||||
);
|
||||
}
|
||||
return runOneSubAgentTurn(subSession, message, `resume:${agentId.slice(0, 8)}`, emit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a completed (non-streaming) response — handles both native tool_calls
|
||||
* and plain text, shared by the streaming path (after accumulation) and the
|
||||
@@ -614,10 +976,13 @@ async function handleCompletedMessage(
|
||||
} as ChatCompletionMessageParam);
|
||||
|
||||
const pendingImages: ImageAttachment[] = [];
|
||||
for (const call of message.tool_calls) {
|
||||
const nativeBatch = await runBatch(message.tool_calls as any[], (call: any) => (call.type === "function" ? call.function.name : call.type), async (call: any, isolationCwd?: string) => {
|
||||
const resolved = resolveToolCall(call as any, toolset.registry);
|
||||
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal, isolationCwd);
|
||||
return { call, resolved, result };
|
||||
}, isolateSubAgent(session, (call: any) => parseNativeArgs(call?.function?.arguments)));
|
||||
for (const { call, resolved, result } of nativeBatch) {
|
||||
const image = pushToolResultMessage(session, "native", call.id, call.type === "function" ? call.function.name : call.type, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
@@ -635,10 +1000,13 @@ async function handleCompletedMessage(
|
||||
|
||||
if (parsed.calls.length) {
|
||||
session.messages.push({ role: "assistant", content: text });
|
||||
for (const call of parsed.calls) {
|
||||
const completedFallbackBatch = await runBatch(parsed.calls, (call) => call.name, async (call, isolationCwd?: string) => {
|
||||
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal, isolationCwd);
|
||||
return { call, resolved, result };
|
||||
}, isolateSubAgent(session, (call) => call.arguments));
|
||||
for (const { call, resolved, result } of completedFallbackBatch) {
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
}
|
||||
@@ -662,137 +1030,157 @@ export async function runTurn(
|
||||
/** Optional abort signal — a parent sub-agent passes its timeout's controller so a runaway turn's
|
||||
* in-flight HTTP request can be cancelled rather than streaming forever in the background. */
|
||||
signal?: AbortSignal,
|
||||
/** How many internal rounds to chain when a single round exhausts its step budget without
|
||||
* producing a final answer. Defaults to MAX_CONTINUATION_ROUNDS for top-level turns; sub-agents
|
||||
* pass 1 so they keep their existing "exhausted budget → tell the parent to split the task" behavior. */
|
||||
maxContinuationRounds: number = MAX_CONTINUATION_ROUNDS,
|
||||
): Promise<string> {
|
||||
session.messages.push({ role: "user", content: userInput } as ChatCompletionMessageParam);
|
||||
if (session.subAgentDepth === 0) session.stats.turns++;
|
||||
session.mutationCommitLength = null;
|
||||
let malformedRetries = 0;
|
||||
|
||||
for (let i = 0; i < session.maxIterations; i++) {
|
||||
pruneOldImages(session);
|
||||
|
||||
// A single turn that makes many tool calls in a row (e.g. reading dozens of files) can blow
|
||||
// past the context window entirely within one runTurn call — the caller (App.tsx) only checks
|
||||
// shouldAutoCompact *between* turns, so without this a long tool-heavy turn had no compaction
|
||||
// safety net at all, and would keep sending an ever-growing prompt until the backend choked on
|
||||
// it or hung trying to process it. Check on every iteration, including the first, since a prior
|
||||
// turn's post-turn compaction may not have run (e.g. if it errored).
|
||||
if (shouldAutoCompact(session)) {
|
||||
try {
|
||||
await compactSession(session);
|
||||
emit({ type: "notice", text: "Context was getting full — auto-compacted mid-turn.", isError: false });
|
||||
// compactSession leaves the history as [system, assistant-recap] with no user turn. Sending
|
||||
// that to the model gives it nothing to respond to — local models routinely answer with an
|
||||
// empty stop (which runTurn then returns as ""), which is exactly why sub-agents that
|
||||
// compacted mid-task came back as "Sub-agent finished (0 chars)", and why a tool-heavy main
|
||||
// turn appeared to hang/stop after compacting. Re-add a user turn so the model resumes the
|
||||
// task instead of going empty. (Between-turn compaction in App.tsx doesn't need this — the
|
||||
// user's next message supplies the turn.)
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"Continue with your current task. (The conversation so far was just compacted to save context — " +
|
||||
"pick up from where the summary above left off, and give your final answer once the task is done.)",
|
||||
});
|
||||
// compactSession replaced session.messages wholesale, so any previously-recorded
|
||||
// mutationCommitLength now indexes into an array that no longer exists — a later error in
|
||||
// this same turn would roll back to a stale, out-of-bounds length (App.tsx's
|
||||
// `session.messages.length = commitLength ?? rollbackLength`), padding the array with empty
|
||||
// slots instead of truncating it. Re-anchor AFTER the synthetic user turn above, so a
|
||||
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
|
||||
// the very bug this user message exists to prevent).
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
} catch {
|
||||
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
|
||||
// aborting the whole turn — the idle-abort guard on the next request still protects against
|
||||
// an outright hang, it'll just be a slower/costlier request.
|
||||
}
|
||||
for (let round = 0; round < maxContinuationRounds; round++) {
|
||||
let malformedRetries = 0;
|
||||
if (round > 0) {
|
||||
const stepsSoFar = round * session.maxIterations;
|
||||
emit({
|
||||
type: "notice",
|
||||
text: `Continuing after ${stepsSoFar} steps — the assistant is still working on this turn.`,
|
||||
isError: false,
|
||||
});
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"Continue with your current task. (You have already used a large number of tool calls this turn — " +
|
||||
"keep going until you have a complete answer, then stop with a concise final message.)",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Streaming path ---
|
||||
let fullText = "";
|
||||
let finishReason: string | null = null;
|
||||
const accumulatedToolCalls: AccumulatedToolCall[] = [];
|
||||
for (let step = 0; step < session.maxIterations; step++) {
|
||||
pruneOldImages(session);
|
||||
|
||||
let usage: CompletionUsage | undefined;
|
||||
const requestStart = Date.now();
|
||||
|
||||
const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
|
||||
try {
|
||||
const stream = await session.client.chat.completions.create(
|
||||
{
|
||||
model: session.model,
|
||||
messages: session.messages,
|
||||
tools: session.mode === "native" ? toolset.openaiTools : undefined,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: 4096,
|
||||
},
|
||||
{ signal: idleGuard.signal },
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// The usage-carrying final chunk has an empty `choices` array per spec — read it before
|
||||
// the early-continue below would otherwise skip straight past it.
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
// Some backends/proxies send periodic empty "heartbeat" chunks (no delta content, no
|
||||
// tool_calls, no finish_reason) to keep a long-lived connection alive through intermediaries
|
||||
// during slow generation. Poking the idle guard unconditionally on every yielded chunk would
|
||||
// let those reset the timer forever, defeating it entirely — a generation truly stuck for
|
||||
// 30+ minutes would never trip if the transport keeps trickling empty chunks the whole time.
|
||||
// Only chunks carrying real progress (usage, text, tool-call deltas, or a finish reason)
|
||||
// count as the backend being alive and working.
|
||||
const isMeaningfulChunk = !!chunk.usage || !!choice?.delta?.content || !!choice?.delta?.tool_calls || !!choice?.finish_reason;
|
||||
if (isMeaningfulChunk) idleGuard.poke();
|
||||
|
||||
if (!choice) continue;
|
||||
|
||||
const delta = choice.delta;
|
||||
|
||||
// Stream text content
|
||||
if (delta?.content) {
|
||||
fullText += delta.content;
|
||||
emit({ type: "text_delta", delta: delta.content });
|
||||
// A single turn that makes many tool calls in a row (e.g. reading dozens of files) can blow
|
||||
// past the context window entirely within one runTurn call — the caller (App.tsx) only checks
|
||||
// shouldAutoCompact *between* turns, so without this a long tool-heavy turn had no compaction
|
||||
// safety net at all, and would keep sending an ever-growing prompt until the backend choked on
|
||||
// it or hung trying to process it. Check on every iteration, including the first, since a prior
|
||||
// turn's post-turn compaction may not have run (e.g. if it errored).
|
||||
if (shouldAutoCompact(session)) {
|
||||
try {
|
||||
await compactSession(session);
|
||||
emit({ type: "notice", text: "Context was getting full — auto-compacted mid-turn.", isError: false });
|
||||
// compactSession leaves the history as [system, assistant-recap] with no user turn. Sending
|
||||
// that to the model gives it nothing to respond to — local models routinely answer with an
|
||||
// empty stop (which runTurn then returns as ""), which is exactly why sub-agents that
|
||||
// compacted mid-task came back as "Sub-agent finished (0 chars)", and why a tool-heavy main
|
||||
// turn appeared to hang/stop after compacting. Re-add a user turn so the model resumes the
|
||||
// task instead of going empty. (Between-turn compaction in App.tsx doesn't need this — the
|
||||
// user's next message supplies the turn.)
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"Continue with your current task. (The conversation so far was just compacted to save context — " +
|
||||
"pick up from where the summary above left off, and give your final answer once the task is done.)",
|
||||
});
|
||||
// compactSession replaced session.messages wholesale, so any previously-recorded
|
||||
// mutationCommitLength now indexes into an array that no longer exists — a later error in
|
||||
// this same turn would roll back to a stale, out-of-bounds length (App.tsx's
|
||||
// `session.messages.length = commitLength ?? rollbackLength`), padding the array with empty
|
||||
// slots instead of truncating it. Re-anchor AFTER the synthetic user turn above, so a
|
||||
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
|
||||
// the very bug this user message exists to prevent).
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
} catch {
|
||||
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
|
||||
// aborting the whole turn — the idle-abort guard on the next request still protects against
|
||||
// an outright hang, it'll just be a slower/costlier request.
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate native tool call deltas
|
||||
if (session.mode === "native" && delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = {
|
||||
id: tc.id ?? "",
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: tc.function?.arguments ?? "",
|
||||
};
|
||||
} else {
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id;
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name = tc.function.name;
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments;
|
||||
// --- Streaming path ---
|
||||
let fullText = "";
|
||||
let finishReason: string | null = null;
|
||||
const accumulatedToolCalls: AccumulatedToolCall[] = [];
|
||||
|
||||
let usage: CompletionUsage | undefined;
|
||||
const requestStart = Date.now();
|
||||
|
||||
const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
|
||||
try {
|
||||
const stream = await session.client.chat.completions.create(
|
||||
{
|
||||
model: session.model,
|
||||
messages: session.messages,
|
||||
tools: session.mode === "native" ? toolset.openaiTools : undefined,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: 4096,
|
||||
},
|
||||
{ signal: idleGuard.signal },
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// The usage-carrying final chunk has an empty `choices` array per spec — read it before
|
||||
// the early-continue below would otherwise skip straight past it.
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
// Some backends/proxies send periodic empty "heartbeat" chunks (no delta content, no
|
||||
// tool_calls, no finish_reason) to keep a long-lived connection alive through intermediaries
|
||||
// during slow generation. Poking the idle guard unconditionally on every yielded chunk would
|
||||
// let those reset the timer forever, defeating it entirely — a generation truly stuck for
|
||||
// 30+ minutes would never trip if the transport keeps trickling empty chunks the whole time.
|
||||
// Only chunks carrying real progress (usage, text, tool-call deltas, or a finish reason)
|
||||
// count as the backend being alive and working.
|
||||
const isMeaningfulChunk = !!chunk.usage || !!choice?.delta?.content || !!choice?.delta?.tool_calls || !!choice?.finish_reason;
|
||||
if (isMeaningfulChunk) idleGuard.poke();
|
||||
|
||||
if (!choice) continue;
|
||||
|
||||
const delta = choice.delta;
|
||||
|
||||
// Stream text content
|
||||
if (delta?.content) {
|
||||
fullText += delta.content;
|
||||
emit({ type: "text_delta", delta: delta.content });
|
||||
}
|
||||
|
||||
// Accumulate native tool call deltas
|
||||
if (session.mode === "native" && delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = {
|
||||
id: tc.id ?? "",
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: tc.function?.arguments ?? "",
|
||||
};
|
||||
} else {
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id;
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name = tc.function.name;
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
finishReason = choice.finish_reason;
|
||||
if (choice.finish_reason) {
|
||||
finishReason = choice.finish_reason;
|
||||
}
|
||||
}
|
||||
} catch (streamErr) {
|
||||
// Stream error — emit whatever text we have and re-throw
|
||||
if (fullText) {
|
||||
emit({ type: "text_done", fullText });
|
||||
}
|
||||
if (idleGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
);
|
||||
}
|
||||
throw annotateIfImageRelated(streamErr, session.messages);
|
||||
} finally {
|
||||
idleGuard.dispose();
|
||||
}
|
||||
} catch (streamErr) {
|
||||
// Stream error — emit whatever text we have and re-throw
|
||||
if (fullText) {
|
||||
emit({ type: "text_done", fullText });
|
||||
}
|
||||
if (idleGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
);
|
||||
}
|
||||
throw annotateIfImageRelated(streamErr, session.messages);
|
||||
} finally {
|
||||
idleGuard.dispose();
|
||||
}
|
||||
|
||||
// An idle-triggered abort can also surface as a clean (chunk-less) end of the async iterator
|
||||
// instead of a thrown error, depending on how far into the stream it landed — check unconditionally
|
||||
@@ -861,6 +1249,9 @@ export async function runTurn(
|
||||
if (result.hadToolCalls) continue;
|
||||
|
||||
// Non-tool-call text from the retry
|
||||
const retryPlanAction = await maybePresentPlan(session, result.text, emit, signal);
|
||||
if (retryPlanAction === "return") return result.text;
|
||||
if (retryPlanAction === "continue") continue;
|
||||
emit({ type: "text_done", fullText: result.text });
|
||||
session.messages.push({ role: "assistant", content: result.text });
|
||||
await fireStopHook(session, emit, result.text);
|
||||
@@ -881,13 +1272,18 @@ export async function runTurn(
|
||||
} as ChatCompletionMessageParam);
|
||||
|
||||
const pendingImages: ImageAttachment[] = [];
|
||||
for (const tc of accumulatedToolCalls) {
|
||||
// A pure batch of agent/agent__* delegations runs concurrently (runBatch); mixed/other
|
||||
// batches run sequentially. Tool-result messages are pushed after, in input order.
|
||||
const batchResults = await runBatch(accumulatedToolCalls, (tc) => tc.name, async (tc, isolationCwd?: string) => {
|
||||
const resolved = resolveToolCall(
|
||||
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
|
||||
toolset.registry,
|
||||
);
|
||||
const label = `${tc.name}(${tc.arguments})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal, isolationCwd);
|
||||
return { tc, resolved, result };
|
||||
}, isolateSubAgent(session, (tc) => parseNativeArgs(tc.arguments)));
|
||||
for (const { tc, resolved, result } of batchResults) {
|
||||
const image = pushToolResultMessage(session, "native", tc.id, tc.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
@@ -905,10 +1301,13 @@ export async function runTurn(
|
||||
if (parsed.calls.length) {
|
||||
emit({ type: "text_done", fullText });
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
for (const call of parsed.calls) {
|
||||
const fallbackBatch = await runBatch(parsed.calls, (call) => call.name, async (call, isolationCwd?: string) => {
|
||||
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal, isolationCwd);
|
||||
return { call, resolved, result };
|
||||
}, isolateSubAgent(session, (call) => call.arguments));
|
||||
for (const { call, resolved, result } of fallbackBatch) {
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
}
|
||||
@@ -919,12 +1318,18 @@ export async function runTurn(
|
||||
malformedRetries++;
|
||||
emit({ type: "text_done", fullText });
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
session.messages.push({ role: "user", content: FALLBACK_RETRY_NUDGE });
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content: `${FALLBACK_RETRY_NUDGE}\n\nIf the last tool was a read or edit, re-read the file first to make sure your arguments are current.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Final text answer
|
||||
const planAction = await maybePresentPlan(session, fullText, emit, signal);
|
||||
if (planAction === "return") return fullText;
|
||||
if (planAction === "continue") continue;
|
||||
emit({ type: "text_done", fullText });
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
await fireStopHook(session, emit, fullText);
|
||||
@@ -934,8 +1339,10 @@ export async function runTurn(
|
||||
// Empty response with no tool calls and no text — this shouldn't happen normally
|
||||
throw new AgentError("Empty response from model.");
|
||||
}
|
||||
}
|
||||
|
||||
const totalBudget = maxContinuationRounds * session.maxIterations;
|
||||
throw new MaxIterationsError(
|
||||
`Paused after ${session.maxIterations} steps in this turn. Everything done so far (including any file edits) is saved — send another message to continue.`,
|
||||
`Paused after exhausting the turn budget (${totalBudget} tool calls). Everything done so far (including any file edits) is saved — send another message to continue.`,
|
||||
);
|
||||
}
|
||||
+130
-13
@@ -2,13 +2,16 @@ import { randomUUID } from "node:crypto";
|
||||
import type OpenAI from "openai";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_SUBAGENT_MAX_DEPTH, DEFAULT_SUBAGENT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import type { SessionRecord } from "../persistence/sessionStore.js";
|
||||
import { PermissionManager } from "../permissions/permissionManager.js";
|
||||
import type { ConfirmFn } from "../permissions/types.js";
|
||||
import { TOOLS } from "../tools/index.js";
|
||||
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||
import type { TodoItem, ToolDef } from "../tools/types.js";
|
||||
import type { AskQuestionSpec, AskQuestionAnswer, ToolDef } from "../tools/types.js";
|
||||
import { TaskStore } from "../tools/task.js";
|
||||
import type { CronStore } from "../scheduler/cron.js";
|
||||
import type { PermissionMode, PermissionRule } from "../permissions/types.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
|
||||
@@ -45,12 +48,34 @@ export interface Session {
|
||||
mode: ToolCallMode;
|
||||
messages: ChatCompletionMessageParam[];
|
||||
maxIterations: number;
|
||||
/** Max tool calls in a single sub-agent turn launched from this session. Intentionally smaller
|
||||
* than maxIterations (sub-agents run one focused task, one round, no auto-continue) so a runaway
|
||||
* sub-agent fails fast and surfaces a "split the task" hint instead of burning a large budget. */
|
||||
subagentMaxIterations: number;
|
||||
/** Max nesting depth for sub-agents launched from this session. The main session is depth 0;
|
||||
* a sub-agent it spawns is depth 1, and so on. A sub-agent at the cap has `agent` excluded from
|
||||
* its toolset (with an explicit depth-check backstop in agent/loop.ts) so it can't delegate
|
||||
* further. Configurable so it can be lowered in tests without touching env/config. */
|
||||
subagentMaxDepth: number;
|
||||
permissions: PermissionManager;
|
||||
confirm: ConfirmFn;
|
||||
/** Optional structured-question callback wired by the UI so the `ask_user_question` tool can
|
||||
* prompt the user with multiple-choice options. Absent in headless/non-UI contexts (sub-agents),
|
||||
* in which case the tool returns a clear "can't ask" error instead of hanging. */
|
||||
askQuestion?: (questions: AskQuestionSpec[]) => Promise<AskQuestionAnswer[]>;
|
||||
/** Promise-chain lock serializing calls to `confirm` across concurrent sub-agents. When a parent
|
||||
* turn fans out multiple `agent` delegations in parallel (see runBatch in agent/loop.ts), each
|
||||
* sub-agent shares this same mutex *holder* (subSession.confirmMutex = parent.confirmMutex, by
|
||||
* reference) so their mutating-tool confirmation prompts queue one at a time instead of racing
|
||||
* for the UI's single PendingPermission slot. The holder wraps a `chain` promise that
|
||||
* withConfirmLock reassigns on each confirm; sharing the holder (rather than the promise itself)
|
||||
* keeps every concurrent sub-agent queued on the same lock even as the chain advances. */
|
||||
confirmMutex: { chain: Promise<void> };
|
||||
/** Local tools plus any dynamically-discovered ones (currently: MCP) available for this session. */
|
||||
toolset: ToolSet;
|
||||
/** 0 for a normal session; incremented for each level of sub-agent nesting (capped at
|
||||
* MAX_SUBAGENT_DEPTH in agent/loop.ts, independent of the toolset already excluding `agent`). */
|
||||
* session.subagentMaxDepth in agent/loop.ts, independent of the toolset already excluding `agent`
|
||||
* for sub-agents at the depth cap). */
|
||||
subAgentDepth: number;
|
||||
/** The model's context window in tokens — auto-detected where possible (backend/contextWindow.ts),
|
||||
* otherwise a configured/hardcoded fallback (see contextWindowIsEstimate). */
|
||||
@@ -83,9 +108,48 @@ export interface Session {
|
||||
* project's CLAUDE.md/AGENTS.md conventions survive everything that regenerates messages[0].
|
||||
* Null when neither file exists. */
|
||||
projectInstructions: string | null;
|
||||
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
|
||||
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
|
||||
todos: TodoItem[];
|
||||
/** The user's personal memory file (config dir / memory.md), folded into the system prompt
|
||||
* alongside projectInstructions so learned preferences/feedback survive across sessions and repos.
|
||||
* Null when the file is absent or empty. See utils/userMemory.ts. */
|
||||
userMemory: string | null;
|
||||
/** The session's structured task store (dependency graph + ownership), surfaced to the model via
|
||||
* the task_create/list/get/update tools and to the UI via `tasks_update` events. Session-scoped —
|
||||
* tasks are a progress snapshot, not part of the model-visible conversation, so not persisted. */
|
||||
taskStore: TaskStore;
|
||||
/** The session's cron/wakeup scheduler, set by the App after creating the session (the App owns its
|
||||
* lifecycle: starts the tick with enqueue/isIdle callbacks, stops it on unmount). Absent in
|
||||
* non-UI contexts. Used by the cron_create/list/delete and schedule_wakeup tools. */
|
||||
cronStore?: CronStore;
|
||||
/** Tracks the most recent file mutation (write_file or edit_file) so the user can roll it back
|
||||
* with the /undo slash command. The path is stored as the user-supplied path rather than a
|
||||
* resolved absolute path, so the undo re-uses the same relative path logic as the original edit. */
|
||||
lastEdit: { path: string; previousContent: string } | null;
|
||||
/** Resumable sub-agent sessions keyed by their agentId, so the parent can continue one with a
|
||||
* follow-up message via the `send_message` tool (ctx.resumeSubAgent). Only sub-agents that ran in
|
||||
* the shared cwd are stored here — worktree-isolated parallel agents' cwd is cleaned up after the
|
||||
* batch, so they're fire-and-forget and never added. Cleared on resetSession. */
|
||||
subAgentSessions: Map<string, Session>;
|
||||
/** Named teammates: maps a teammate name (supplied via the `agent` tool's `name` arg) to its
|
||||
* agentId, so `send_message` can address it by name and `list_teammates` can roster it. Backed by
|
||||
* the same resumable sub-agent sessions as subAgentSessions — this is just a name → agentId index
|
||||
* over them. Independent per session (a sub-agent gets its own map for nested teammates). Cleared
|
||||
* on resetSession alongside subAgentSessions. */
|
||||
namedAgents: Map<string, string>;
|
||||
/** The active interactive worktree session, set by `enter_worktree` and cleared by `exit_worktree`.
|
||||
* While set, `cwd` points at the worktree dir (an isolated checkout on its own branch) and file
|
||||
* tools operate there; `originalCwd` is restored on exit. Undefined when not in a worktree session.
|
||||
* Not persisted — worktree sessions don't survive an app restart (the branch stays in git, so the
|
||||
* work itself isn't lost; re-enter via git if needed). */
|
||||
worktree?: { dir: string; branch: string; originalCwd: string };
|
||||
/** App-provided callback invoked when the session's cwd changes (currently only via
|
||||
* `enter_worktree`/`exit_worktree`), so the UI can update its live cwd display, /undo resolution,
|
||||
* git-info refresh, and @mention resolution. Absent in non-UI contexts (a headless sub-agent
|
||||
* switching its own cwd just has no UI to notify). */
|
||||
onCwdChange?: (newCwd: string) => void;
|
||||
/** App-provided callback invoked when the worktree session is entered/exited, so the App can keep a
|
||||
* ref that survives a /model switch (which recreates the session) and re-attach the worktree
|
||||
* tracking to the new session. Absent in non-UI contexts. */
|
||||
onWorktreeChange?: (worktree: { dir: string; branch: string; originalCwd: string } | null) => void;
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
@@ -100,10 +164,14 @@ export function createSession(
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
projectInstructions: string | null = null,
|
||||
subagentMaxIterations: number = DEFAULT_SUBAGENT_MAX_ITERATIONS,
|
||||
userMemory: string | null = null,
|
||||
subagentMaxDepth: number = DEFAULT_SUBAGENT_MAX_DEPTH,
|
||||
permissionRules: PermissionRule[] = [],
|
||||
): Session {
|
||||
const toolset = buildToolSet(tools);
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions) },
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, userMemory) },
|
||||
];
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -114,8 +182,11 @@ export function createSession(
|
||||
mode,
|
||||
messages,
|
||||
maxIterations,
|
||||
permissions: new PermissionManager(),
|
||||
subagentMaxIterations,
|
||||
subagentMaxDepth,
|
||||
permissions: new PermissionManager(permissionRules),
|
||||
confirm,
|
||||
confirmMutex: { chain: Promise.resolve() },
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
@@ -127,7 +198,11 @@ export function createSession(
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
projectInstructions,
|
||||
todos: [],
|
||||
userMemory,
|
||||
taskStore: new TaskStore(),
|
||||
lastEdit: null,
|
||||
subAgentSessions: new Map(),
|
||||
namedAgents: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,12 +219,23 @@ export function createSessionFromRecord(
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
projectInstructions: string | null = null,
|
||||
subagentMaxIterations: number = DEFAULT_SUBAGENT_MAX_ITERATIONS,
|
||||
userMemory: string | null = null,
|
||||
subagentMaxDepth: number = DEFAULT_SUBAGENT_MAX_DEPTH,
|
||||
permissionRules: PermissionRule[] = [],
|
||||
): Session {
|
||||
const toolset = buildToolSet(tools);
|
||||
// Build the system prompt with the *restored* permission mode (not the default) so a resumed
|
||||
// plan-mode session gets plan instructions in its prompt from the first request, rather than
|
||||
// only learning it's in plan mode from tool-rejection errors.
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions) },
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, userMemory, record.permissionMode) },
|
||||
...record.messages,
|
||||
];
|
||||
const permissions = new PermissionManager(permissionRules);
|
||||
// Restore the saved permission mode so plan/auto-edit/auto-accept survive a resume instead of
|
||||
// always resetting to default. Older saved sessions omit the field → default.
|
||||
if (record.permissionMode) permissions.setMode(record.permissionMode);
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
@@ -159,8 +245,11 @@ export function createSessionFromRecord(
|
||||
mode: record.mode,
|
||||
messages,
|
||||
maxIterations,
|
||||
permissions: new PermissionManager(),
|
||||
subagentMaxIterations,
|
||||
subagentMaxDepth,
|
||||
permissions,
|
||||
confirm,
|
||||
confirmMutex: { chain: Promise.resolve() },
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
@@ -172,7 +261,11 @@ export function createSessionFromRecord(
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
projectInstructions,
|
||||
todos: [],
|
||||
userMemory,
|
||||
taskStore: new TaskStore(),
|
||||
lastEdit: null,
|
||||
subAgentSessions: new Map(),
|
||||
namedAgents: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +278,7 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
|
||||
baseURL,
|
||||
model: session.model,
|
||||
mode: session.mode,
|
||||
permissionMode: session.permissions.getMode(),
|
||||
messages: session.messages.slice(1),
|
||||
};
|
||||
}
|
||||
@@ -193,9 +287,32 @@ export function resetSession(session: Session): void {
|
||||
session.messages = [session.messages[0] as ChatCompletionMessageParam];
|
||||
session.lastContextTokens = estimateTokens(session.messages);
|
||||
session.lastContextTokensIsEstimate = true;
|
||||
// Drop any resumable sub-agent sessions — their context references the old conversation and would
|
||||
// be stale after a /clear.
|
||||
session.subAgentSessions.clear();
|
||||
// Drop the name → agentId index over those sessions too.
|
||||
session.namedAgents.clear();
|
||||
}
|
||||
|
||||
export function setMode(session: Session, mode: ToolCallMode): void {
|
||||
session.mode = mode;
|
||||
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions) };
|
||||
// Re-apply the current permission mode so plan-mode instructions survive a tool-call-mode switch
|
||||
// (native ↔ fallback) rather than being dropped from the rebuilt prompt.
|
||||
session.messages[0] = {
|
||||
role: "system",
|
||||
content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions, session.userMemory, session.permissions.getMode()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Switch the session's permission mode and rebuild the system prompt so the model is told about
|
||||
* the new mode (e.g. entering plan mode injects the plan-only research instructions). This is the
|
||||
* permission-mode counterpart to setMode (which handles tool-call mode). UI paths that change the
|
||||
* permission mode (/perm, Shift+Tab) should call this instead of `session.permissions.setMode` alone,
|
||||
* which would leave the prompt stale. */
|
||||
export function setPermissionMode(session: Session, mode: PermissionMode): void {
|
||||
session.permissions.setMode(mode);
|
||||
session.messages[0] = {
|
||||
role: "system",
|
||||
content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.userMemory, mode),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { buildSystemPrompt, } from "./systemPrompt.js";
|
||||
import { createSession, setMode, setPermissionMode } from "./session.js";
|
||||
import { buildToolSet } from "../tools/toolset.js";
|
||||
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
|
||||
const dummyTool: ToolDef = {
|
||||
name: "noop",
|
||||
description: "does nothing",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async () => ({ ok: true }),
|
||||
};
|
||||
const toolset = buildToolSet([dummyTool]);
|
||||
|
||||
function systemPromptOf(session: { messages: { content?: unknown }[] }): string {
|
||||
return String(session.messages[0]!.content);
|
||||
}
|
||||
|
||||
describe("buildSystemPrompt plan-mode injection", () => {
|
||||
it("omits plan instructions in the default mode", () => {
|
||||
const prompt = buildSystemPrompt(toolset.tools, "native");
|
||||
expect(prompt).not.toContain("Plan mode is ACTIVE");
|
||||
});
|
||||
|
||||
it("injects plan instructions when permissionMode is 'plan'", () => {
|
||||
const prompt = buildSystemPrompt(toolset.tools, "native", null, null, "plan");
|
||||
expect(prompt).toContain("Plan mode is ACTIVE");
|
||||
expect(prompt).toContain("present a concrete implementation plan");
|
||||
});
|
||||
|
||||
it("does not inject plan instructions for auto-edit/auto-accept", () => {
|
||||
expect(buildSystemPrompt(toolset.tools, "native", null, null, "auto-edit")).not.toContain("Plan mode is ACTIVE");
|
||||
expect(buildSystemPrompt(toolset.tools, "native", null, null, "auto-accept")).not.toContain("Plan mode is ACTIVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPermissionMode / setMode prompt rebuild", () => {
|
||||
// A fake OpenAI client — these tests never make requests, createSession just needs a client.
|
||||
const fakeClient = {} as any;
|
||||
|
||||
it("setPermissionMode('plan') rebuilds the system prompt with plan instructions, and back to default removes them", () => {
|
||||
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [dummyTool]);
|
||||
expect(systemPromptOf(session)).not.toContain("Plan mode is ACTIVE");
|
||||
|
||||
setPermissionMode(session, "plan");
|
||||
expect(systemPromptOf(session)).toContain("Plan mode is ACTIVE");
|
||||
|
||||
setPermissionMode(session, "default");
|
||||
expect(systemPromptOf(session)).not.toContain("Plan mode is ACTIVE");
|
||||
});
|
||||
|
||||
it("setMode (tool-call mode switch) preserves plan instructions while plan mode is active", () => {
|
||||
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [dummyTool]);
|
||||
setPermissionMode(session, "plan");
|
||||
// Switch tool-call mode to fallback while still in plan mode — the rebuilt prompt must carry
|
||||
// BOTH the fallback tool instructions and the plan instructions.
|
||||
setMode(session, "fallback");
|
||||
const prompt = systemPromptOf(session);
|
||||
expect(prompt).toContain(FALLBACK_TOOL_INSTRUCTIONS);
|
||||
expect(prompt).toContain("Plan mode is ACTIVE");
|
||||
});
|
||||
});
|
||||
+81
-10
@@ -1,21 +1,92 @@
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import type { PermissionMode } from "../permissions/types.js";
|
||||
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
|
||||
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null): string {
|
||||
/** Injected into the system prompt only while the session is in plan mode. Tells the model it must
|
||||
* research read-only and present a plan rather than attempt changes (which would be blocked at the
|
||||
* tool gate anyway). Without this, the model only learns it's in plan mode from tool-rejection
|
||||
* errors, after it has already tried (and failed) to mutate. */
|
||||
const PLAN_INSTRUCTIONS = `Plan mode is ACTIVE. In this mode:
|
||||
- Do NOT call any mutating tool (write_file, edit_file, multi_edit, notebook_edit, git_commit, or a bash command that changes state). They are blocked and will return an error — that is expected.
|
||||
- Explore read-only first: use grep, list_files, read_file, and git_status to fully understand the request and the code it touches.
|
||||
- Then present a concrete implementation plan: the files you would change, the approach for each, and the key edits. Do not make the changes yet. Call the \`exit_plan_mode\` tool with the plan once you're ready for the user to approve it — on approval plan mode ends and you implement in this same turn; on rejection, refine and call it again. (If you can't call tools, present the plan as prose instead.)
|
||||
- Keep the plan focused and actionable so the user can review it.
|
||||
- Only ask a clarifying question if the request is still genuinely ambiguous after you've explored.`;
|
||||
|
||||
export function buildSystemPrompt(
|
||||
tools: ToolDef[],
|
||||
mode: ToolCallMode,
|
||||
projectInstructions?: string | null,
|
||||
userMemory?: string | null,
|
||||
permissionMode?: PermissionMode,
|
||||
): string {
|
||||
const toolList = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
|
||||
const base = `You are a helpful local coding assistant with access to tools for exploring a codebase on the user's machine.
|
||||
|
||||
const base = `You are a capable local coding assistant with access to tools on the user's machine.
|
||||
|
||||
Available tools:
|
||||
${toolList}
|
||||
|
||||
Guidelines:
|
||||
- Inspect files with tools before answering; don't guess contents.
|
||||
- Call at most one tool at a time.
|
||||
- Mutating tools (write_file, edit_file, bash, git_commit) require user confirmation.
|
||||
- Use edit_file for small edits; write_file for new files or full rewrites.
|
||||
- Respond in plain text when you have enough information. Keep answers concise.`;
|
||||
Core workflow:
|
||||
1. Understand the user's goal before acting. Ask clarifying questions if the request is ambiguous or could destroy data.
|
||||
2. Explore the codebase efficiently: use grep to locate symbols/patterns, list_files to understand structure, and read_file only on the files or page ranges you actually need.
|
||||
3. Read files before editing them. Make the smallest change that solves the problem.
|
||||
4. Test your assumptions when possible (run typecheck/tests, read related code, verify file contents).
|
||||
5. Respond in plain text once you have enough information. Be concise; avoid restating obvious context.
|
||||
|
||||
const withMode = mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
|
||||
return projectInstructions ? `${withMode}\n\n${projectInstructions}` : withMode;
|
||||
Editing guidelines:
|
||||
- Prefer edit_file for small, targeted changes. Include enough surrounding context in old_string to make the match unique.
|
||||
- Use write_file for new files or when you are replacing most of a file's content.
|
||||
- Never invent file contents you haven't read; if unsure, read the file first.
|
||||
- When edit_file fails with "old_string not found", re-read the file and try again with a more precise match.
|
||||
|
||||
Task tracking:
|
||||
- For non-trivial multi-step work (3+ steps), create tasks with task_create so progress is visible. Mark a task in_progress when you start it and completed when done.
|
||||
- Express dependencies with addBlocks/addBlockedBy (task ids) when one step must finish before another can start; check a task's blockedBy via task_get before starting it.
|
||||
- Set owner when a sub-agent or teammate will claim a specific task. Keep task subjects short and imperative.
|
||||
|
||||
Named teammates:
|
||||
- When you'll send a sub-agent several messages across the conversation, give the 'agent' call a 'name' (e.g. "researcher", "implementer") to create a named teammate. Then continue it with send_message using that 'name' instead of tracking its agentId, and use list_teammates to see your roster.
|
||||
- A named teammate runs in the shared working directory (a single, non-parallel delegation) and is resumable; parallel (worktree-isolated) delegations are fire-and-forget and ignore the name.
|
||||
- Teammate names must be unique per session — re-using an existing name returns an error (don't clobber a teammate in use); address the existing one via send_message instead.
|
||||
- Note: a named teammate still runs synchronously within your turn (it blocks until it answers). It is a stable, re-addressable handle, not a truly background process.
|
||||
|
||||
Worktree sessions:
|
||||
- To try changes without touching the main working tree, call enter_worktree (optionally with a name). It creates an isolated git worktree on a new branch at the current HEAD and switches your working directory into it — every file tool then operates there, and the worktree starts from the last commit so the user's uncommitted changes aren't carried over.
|
||||
- When done, call exit_worktree. Use action "keep" to preserve the work on its branch (recoverable later via git), or "remove" to discard it entirely (deletes the worktree and the branch). A remove is refused if the worktree has uncommitted changes unless you pass discardChanges: true.
|
||||
- You can only be in one worktree session at a time — exit before entering another. Don't switch models mid-worktree-session. /worktree shows the current worktree session.
|
||||
|
||||
Bash guidelines:
|
||||
- Destructive commands (rm, git push, git reset --hard, etc.) require explicit user confirmation via the tool's confirmation prompt.
|
||||
- For long-running commands, increase timeout_ms or press Ctrl+B while the command is running to background it; then use bash_output with the returned jobId.
|
||||
- Prefer git_status and git_commit for git work rather than raw git commands in bash.
|
||||
|
||||
Tool-use discipline:
|
||||
- Call at most one tool at a time. Exception: when a single response delegates several independent sub-tasks to sub-agents, you may emit multiple 'agent' or 'agent__*' calls together — they run in parallel and their results come back in order. Writable parallel delegations (general-purpose, debugger, test-writer, or plugin agents) each run in an isolated throwaway git worktree at the last commit, so their file changes are discarded and only the returned answer matters. Read-only parallel delegations (explore, code-reviewer, planner) run in the shared working directory so they see current uncommitted state. Use parallel batches for research/review/planning; for implementation that must persist, delegate a single (sequential) agent call. Do not batch any other tool combinations. A single (non-parallel) delegation runs in the shared working directory, persists its edits, and is resumable — its result includes an agentId you can pass to send_message to continue it (refine the answer, ask a follow-up, or resume one that ran out of budget) without re-delegating from scratch. Parallel/worktree-isolated delegations are fire-and-forget and don't return an agentId.
|
||||
- Mutating tools (write_file, edit_file, bash, git_commit) require user confirmation unless the user has changed the permission mode or chosen "Yes, and don't ask again this session".
|
||||
- Use grep first when searching across many files; do not read_file dozens of files blindly.
|
||||
- If a tool returns an error or empty result, adapt: refine your grep pattern, check the path, or ask the user.
|
||||
- The session auto-continues a long tool chain internally up to a large per-turn budget. If it ever pauses because the budget was exhausted, briefly report progress and the user can continue with another message.
|
||||
- For deterministic multi-agent orchestration the user explicitly asks for ('use a workflow', 'fan out agents'), use the 'workflow' tool with a JS script that calls agent/parallel/pipeline/phase/log. Don't invoke it for ordinary single delegations — that's just the 'agent' tool. When fanning out writable agents (general-purpose, debugger, test-writer, or plugin agents) in parallel, pass each agent() call opts.isolation: 'worktree' so each runs in its own throwaway git worktree and their file writes can't collide (edits are discarded; only the returned answer matters). Read-only agents (explore, code-reviewer, planner) should NOT use isolation — they run in the shared cwd to see current uncommitted state.
|
||||
|
||||
Slash commands the user can type:
|
||||
- /undo — roll back your most recent write_file or edit_file to its previous content.
|
||||
- /summary — ask you to summarize the conversation without replacing history.
|
||||
|
||||
When to ask the user:
|
||||
- The request is ambiguous or underspecified.
|
||||
- A change would delete or overwrite significant user data.
|
||||
- You are about to push commits, force-delete branches, or run commands with side effects outside the project.
|
||||
- You cannot complete the task with the available tools or information.
|
||||
- For a decision that is genuinely the user's to make and that you can't resolve from the code or a sensible default, call the 'ask_user_question' tool with a short multiple-choice question (1-4 questions, 2-4 options each). Don't offload decisions you could make yourself — explore and pick a reasonable default first, and only ask when the choice truly changes what you do next.
|
||||
|
||||
Personal memory:
|
||||
- A lightweight index of saved memory facts is included below when present; each line is 'name (type) — description'. Call the 'memory' tool with a name to read that fact's full body when its hook looks relevant to the current task.
|
||||
- 'memory_write' saves (action='write') or removes (action='delete') a typed fact: pick a kebab-case 'name', a one-line 'description' (the recall hook), a 'type' (user/feedback/project/reference), and the 'content' body. Proactively save durable facts the user states — preferences, working-style feedback, corrections worth remembering. Do not save transient per-task notes.`;
|
||||
|
||||
const withToolMode = mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
|
||||
const withPlan = permissionMode === "plan" ? `${withToolMode}\n\n${PLAN_INSTRUCTIONS}` : withToolMode;
|
||||
const withProject = projectInstructions ? `${withPlan}\n\n${projectInstructions}` : withPlan;
|
||||
return userMemory ? `${withProject}\n\n${userMemory}` : withProject;
|
||||
}
|
||||
|
||||
+18
-2
@@ -114,7 +114,7 @@ configCmd
|
||||
|
||||
configCmd
|
||||
.command("set <key> <value>")
|
||||
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs)")
|
||||
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, subagentMaxIterations, subagentMaxDepth, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs)")
|
||||
.action((key: string, value: string) => {
|
||||
if (
|
||||
key !== "backend" &&
|
||||
@@ -122,12 +122,14 @@ configCmd
|
||||
key !== "baseUrl" &&
|
||||
key !== "contextWindow" &&
|
||||
key !== "maxIterations" &&
|
||||
key !== "subagentMaxIterations" &&
|
||||
key !== "subagentMaxDepth" &&
|
||||
key !== "autoCompactThreshold" &&
|
||||
key !== "requestTimeoutMs" &&
|
||||
key !== "subagentTimeoutMs"
|
||||
) {
|
||||
console.error(
|
||||
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs`,
|
||||
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, subagentMaxIterations, subagentMaxDepth, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -139,6 +141,20 @@ configCmd
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "subagentMaxIterations") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 1000) {
|
||||
console.error(`subagentMaxIterations must be between 1 and 1000, got "${value}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "subagentMaxDepth") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 10) {
|
||||
console.error(`subagentMaxDepth must be between 0 and 10, got "${value}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "autoCompactThreshold") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0.1 || n > 0.95) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { _setConfigFilePathForTest, loadStoredConfig, saveStoredConfig } from "./store.js";
|
||||
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "./config.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
|
||||
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveRequestTimeoutMs, resolveSubagentMaxDepth, resolveSubagentMaxIterations, resolveSubagentTimeoutMs } from "./config.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_MAX_DEPTH, DEFAULT_SUBAGENT_MAX_ITERATIONS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
|
||||
|
||||
// Isolate the persisted config to a temp directory so the suite never reads or overwrites the
|
||||
// user's real ~/.config/locode/config.json (the previous afterEach { saveStoredConfig({}) } wiped
|
||||
@@ -26,6 +26,8 @@ describe("config resolution", () => {
|
||||
delete process.env.LOCODE_CONTEXT_WINDOW;
|
||||
delete process.env.LOCODE_MAX_ITERATIONS;
|
||||
delete process.env.LOCODE_REQUEST_TIMEOUT_MS;
|
||||
delete process.env.LOCODE_SUBAGENT_MAX_ITERATIONS;
|
||||
delete process.env.LOCODE_SUBAGENT_MAX_DEPTH;
|
||||
delete process.env.LOCODE_SUBAGENT_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
@@ -79,6 +81,52 @@ describe("config resolution", () => {
|
||||
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("resolves sub-agent max iterations default", () => {
|
||||
expect(resolveSubagentMaxIterations()).toBe(DEFAULT_SUBAGENT_MAX_ITERATIONS);
|
||||
});
|
||||
|
||||
it("reads sub-agent max iterations from env", () => {
|
||||
process.env.LOCODE_SUBAGENT_MAX_ITERATIONS = "30";
|
||||
expect(resolveSubagentMaxIterations()).toBe(30);
|
||||
});
|
||||
|
||||
it("reads sub-agent max iterations from stored config", () => {
|
||||
saveStoredConfig({ subagentMaxIterations: 25 });
|
||||
expect(resolveSubagentMaxIterations()).toBe(25);
|
||||
});
|
||||
|
||||
it("rejects out-of-range sub-agent max iterations", () => {
|
||||
process.env.LOCODE_SUBAGENT_MAX_ITERATIONS = "0"; // below floor
|
||||
expect(resolveSubagentMaxIterations()).toBe(DEFAULT_SUBAGENT_MAX_ITERATIONS);
|
||||
process.env.LOCODE_SUBAGENT_MAX_ITERATIONS = "5000"; // above cap
|
||||
expect(resolveSubagentMaxIterations()).toBe(DEFAULT_SUBAGENT_MAX_ITERATIONS);
|
||||
saveStoredConfig({ subagentMaxIterations: 0 }); // stored below floor
|
||||
expect(resolveSubagentMaxIterations()).toBe(DEFAULT_SUBAGENT_MAX_ITERATIONS);
|
||||
});
|
||||
|
||||
it("resolves sub-agent max depth default", () => {
|
||||
expect(resolveSubagentMaxDepth()).toBe(DEFAULT_SUBAGENT_MAX_DEPTH);
|
||||
});
|
||||
|
||||
it("reads sub-agent max depth from env", () => {
|
||||
process.env.LOCODE_SUBAGENT_MAX_DEPTH = "4";
|
||||
expect(resolveSubagentMaxDepth()).toBe(4);
|
||||
});
|
||||
|
||||
it("reads sub-agent max depth from stored config", () => {
|
||||
saveStoredConfig({ subagentMaxDepth: 3 });
|
||||
expect(resolveSubagentMaxDepth()).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects out-of-range sub-agent max depth", () => {
|
||||
process.env.LOCODE_SUBAGENT_MAX_DEPTH = "-1"; // below floor
|
||||
expect(resolveSubagentMaxDepth()).toBe(DEFAULT_SUBAGENT_MAX_DEPTH);
|
||||
process.env.LOCODE_SUBAGENT_MAX_DEPTH = "99"; // above cap
|
||||
expect(resolveSubagentMaxDepth()).toBe(DEFAULT_SUBAGENT_MAX_DEPTH);
|
||||
saveStoredConfig({ subagentMaxDepth: -1 }); // stored below floor
|
||||
expect(resolveSubagentMaxDepth()).toBe(DEFAULT_SUBAGENT_MAX_DEPTH);
|
||||
});
|
||||
|
||||
it("resolves sub-agent timeout default", () => {
|
||||
expect(resolveSubagentTimeoutMs()).toBe(DEFAULT_SUBAGENT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
DEFAULT_SUBAGENT_MAX_DEPTH,
|
||||
DEFAULT_SUBAGENT_MAX_ITERATIONS,
|
||||
DEFAULT_SUBAGENT_TIMEOUT_MS,
|
||||
KNOWN_BACKENDS,
|
||||
type BackendName,
|
||||
@@ -65,6 +67,31 @@ export function resolveMaxIterations(): number {
|
||||
return DEFAULT_MAX_ITERATIONS;
|
||||
}
|
||||
|
||||
/** Max tool calls in a single sub-agent turn (see DEFAULT_SUBAGENT_MAX_ITERATIONS). Bounded to
|
||||
* 1–1000 to reject pathological values. */
|
||||
export function resolveSubagentMaxIterations(): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_SUBAGENT_MAX_ITERATIONS);
|
||||
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 1000) return envValue;
|
||||
if (typeof stored.subagentMaxIterations === "number" && stored.subagentMaxIterations >= 1 && stored.subagentMaxIterations <= 1000) {
|
||||
return stored.subagentMaxIterations;
|
||||
}
|
||||
return DEFAULT_SUBAGENT_MAX_ITERATIONS;
|
||||
}
|
||||
|
||||
/** Max nesting depth for sub-agents (see DEFAULT_SUBAGENT_MAX_DEPTH). Bounded to 0–10: 0 lets the
|
||||
* main session still delegate (depth 1) but blocks that delegate from delegating further; values
|
||||
* above ~10 serve no real purpose and just invite runaway recursion. */
|
||||
export function resolveSubagentMaxDepth(): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_SUBAGENT_MAX_DEPTH);
|
||||
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 10) return envValue;
|
||||
if (typeof stored.subagentMaxDepth === "number" && stored.subagentMaxDepth >= 0 && stored.subagentMaxDepth <= 10) {
|
||||
return stored.subagentMaxDepth;
|
||||
}
|
||||
return DEFAULT_SUBAGENT_MAX_DEPTH;
|
||||
}
|
||||
|
||||
/** Wall-clock budget for a single sub-agent turn (see DEFAULT_SUBAGENT_TIMEOUT_MS). Bounded to
|
||||
* 1s–1h to reject pathological values. */
|
||||
export function resolveSubagentTimeoutMs(): number {
|
||||
|
||||
+26
-5
@@ -12,11 +12,32 @@ export type BackendName = keyof typeof KNOWN_BACKENDS;
|
||||
* and the user hasn't configured one — a conservative size common among smaller local models. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
|
||||
/** Max tool calls per turn before locode gives up rather than looping forever. 50 gives real
|
||||
* multi-file tasks room to breathe (local models often issue one tool call per turn, so a
|
||||
* multi-file edit + verify sequence can easily run past 25); still bounded so a genuinely stuck
|
||||
* model fails fast, and hitting the cap is a soft pause, not a failure (see MaxIterationsError). */
|
||||
export const DEFAULT_MAX_ITERATIONS = 50;
|
||||
/** Max tool calls per *round* within a turn. Local models often issue one tool call per round, so
|
||||
* a multi-file edit + verify sequence can easily run past 50; 100 gives real tasks room to breathe
|
||||
* while still bounding a genuinely stuck model per round. runTurn automatically chains up to
|
||||
* MAX_CONTINUATION_ROUNDS rounds before pausing, so the effective per-turn budget is much larger. */
|
||||
export const DEFAULT_MAX_ITERATIONS = 100;
|
||||
|
||||
/** Number of internal rounds runTurn will chain automatically when a single round exhausts its
|
||||
* step budget without producing a final answer. This matches Claude Code's behavior of continuing
|
||||
* a long tool chain rather than stopping after every N steps and asking the user to continue.
|
||||
* Effective per-turn budget = DEFAULT_MAX_ITERATIONS * MAX_CONTINUATION_ROUNDS. */
|
||||
export const MAX_CONTINUATION_ROUNDS = 5;
|
||||
|
||||
/** Max tool calls in a single sub-agent turn. Sub-agents run headless (one round, no
|
||||
* auto-continue — see runSubAgentTurn) and are meant for one focused task, so this is intentionally
|
||||
* smaller than the parent's per-round budget (DEFAULT_MAX_ITERATIONS): a runaway sub-agent fails
|
||||
* fast and tells the parent to split the task rather than silently burning a large black-box
|
||||
* budget. Configurable via `LOCODE_SUBAGENT_MAX_ITERATIONS` / `locode config set subagentMaxIterations`. */
|
||||
export const DEFAULT_SUBAGENT_MAX_ITERATIONS = 50;
|
||||
|
||||
/** Max nesting depth for sub-agents. Depth 0 is the main session; a sub-agent it spawns is depth
|
||||
* 1, a sub-agent that one spawns is depth 2, and so on. A sub-agent at the cap can't spawn further
|
||||
* sub-agents (the toolset excludes `agent` for it, with an explicit depth-check backstop in
|
||||
* agent/loop.ts). The default of 2 allows one level of delegation plus a focused sub-task under
|
||||
* that, while keeping recursion shallow enough that a misbehaving model can't fan out
|
||||
* uncontrollably. Configurable via `LOCODE_SUBAGENT_MAX_DEPTH` / `locode config set subagentMaxDepth`. */
|
||||
export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
|
||||
|
||||
/** Fraction of the context window at which locode automatically summarizes the conversation.
|
||||
* User-configurable via `locode config set autoCompactThreshold`. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import envPaths from "env-paths";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PermissionRule } from "../permissions/types.js";
|
||||
|
||||
export interface StoredConfig {
|
||||
backend?: string;
|
||||
@@ -10,12 +11,20 @@ export interface StoredConfig {
|
||||
contextWindow?: number;
|
||||
/** Max tool calls allowed per turn before locode gives up rather than looping forever. */
|
||||
maxIterations?: number;
|
||||
/** Max tool calls in a single sub-agent turn (smaller than maxIterations — sub-agents are bounded
|
||||
* to one focused task with no auto-continue). */
|
||||
subagentMaxIterations?: number;
|
||||
/** Max nesting depth for sub-agents (0 = main session, 1 = first delegation, …). */
|
||||
subagentMaxDepth?: number;
|
||||
/** Fraction of the context window (0.0–1.0) at which locode auto-compacts the conversation. */
|
||||
autoCompactThreshold?: number;
|
||||
/** Milliseconds to wait on a single chat completion request before giving up. */
|
||||
requestTimeoutMs?: number;
|
||||
/** Milliseconds of wall-clock budget for a single sub-agent turn. */
|
||||
subagentTimeoutMs?: number;
|
||||
/** User-level permission rules (auto-approve/deny tool calls without prompting). Merged with
|
||||
* project-level rules from .locode/settings.json; deny wins across layers. See PermissionRule. */
|
||||
permissionRules?: PermissionRule[];
|
||||
}
|
||||
|
||||
const paths = envPaths("locode", { suffix: "" });
|
||||
@@ -29,6 +38,13 @@ export function configFilePath(): string {
|
||||
return configFileOverride ?? configFile;
|
||||
}
|
||||
|
||||
/** Directory holding the user-level config files (config.json, hooks.json, mcp.json, plugins.json,
|
||||
* and the freeform memory.md). Derived from configFilePath() so the test override
|
||||
* (_setConfigFilePathForTest) redirects this too. */
|
||||
export function configDirPath(): string {
|
||||
return path.dirname(configFilePath());
|
||||
}
|
||||
|
||||
/** @internal For tests only — redirect config persistence to `path` (pass undefined to reset). */
|
||||
export function _setConfigFilePathForTest(p: string | undefined): void {
|
||||
configFileOverride = p;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionManager } from "./permissionManager.js";
|
||||
import type { PermissionRule } from "./types.js";
|
||||
|
||||
describe("PermissionManager rules", () => {
|
||||
describe("checkRules", () => {
|
||||
it("returns null when no rule matches the tool", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: true }]);
|
||||
expect(pm.checkRules("edit_file", { command: "ls" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns allow for a matching allow rule without argPattern", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: true }]);
|
||||
expect(pm.checkRules("bash", { command: "rm -rf /" })).toBe("allow");
|
||||
});
|
||||
|
||||
it("returns deny for a matching deny rule without argPattern", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: false }]);
|
||||
expect(pm.checkRules("bash", { command: "ls" })).toBe("deny");
|
||||
});
|
||||
|
||||
it("deny wins over allow regardless of order", () => {
|
||||
const allowFirst: PermissionRule[] = [
|
||||
{ tool: "bash", allow: true },
|
||||
{ tool: "bash", allow: false },
|
||||
];
|
||||
const denyFirst: PermissionRule[] = [
|
||||
{ tool: "bash", allow: false },
|
||||
{ tool: "bash", allow: true },
|
||||
];
|
||||
expect(new PermissionManager(allowFirst).checkRules("bash", {})).toBe("deny");
|
||||
expect(new PermissionManager(denyFirst).checkRules("bash", {})).toBe("deny");
|
||||
});
|
||||
|
||||
it("deny with argPattern only denies matching args, falling through otherwise", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", argPattern: "rm\\s+-rf", allow: false }]);
|
||||
expect(pm.checkRules("bash", { command: "rm -rf /" })).toBe("deny");
|
||||
// Non-matching args: no rule fires → null.
|
||||
expect(pm.checkRules("bash", { command: "ls" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allow with argPattern auto-approves only matching args", () => {
|
||||
// argPattern is a regex tested against JSON.stringify(args), so for {command:"npm test"} the
|
||||
// serialized text is {"command":"npm test"} — match the substring (no ^ anchor, which would
|
||||
// bind to the leading brace).
|
||||
const pm = new PermissionManager([{ tool: "bash", argPattern: "npm (test|run)", allow: true }]);
|
||||
expect(pm.checkRules("bash", { command: "npm test" })).toBe("allow");
|
||||
expect(pm.checkRules("bash", { command: "npm install" })).toBeNull();
|
||||
});
|
||||
|
||||
it("argPattern is tested against JSON.stringify(args), so nested fields match", () => {
|
||||
const pm = new PermissionManager([{ tool: "edit_file", argPattern: "secret", allow: false }]);
|
||||
expect(pm.checkRules("edit_file", { path: "/safe", old_string: "secret" })).toBe("deny");
|
||||
expect(pm.checkRules("edit_file", { path: "/safe", old_string: "public" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an invalid regex in argPattern is skipped, not thrown", () => {
|
||||
const pm = new PermissionManager([
|
||||
{ tool: "bash", argPattern: "(", allow: false }, // invalid regex
|
||||
{ tool: "bash", allow: true },
|
||||
]);
|
||||
// The invalid deny rule is skipped, so the allow rule fires.
|
||||
expect(pm.checkRules("bash", { command: "ls" })).toBe("allow");
|
||||
});
|
||||
|
||||
it("treats undefined args as an empty string for pattern matching", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", argPattern: "^$", allow: true }]);
|
||||
expect(pm.checkRules("bash", undefined)).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAutoApproved", () => {
|
||||
it("auto-approves when an allow rule matches", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", argPattern: "npm test", allow: true }]);
|
||||
expect(pm.isAutoApproved("bash", { command: "npm test" })).toBe(true);
|
||||
expect(pm.isAutoApproved("bash", { command: "rm -rf" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not auto-approve when a deny rule matches (deny handled in the gate, not here)", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: false }]);
|
||||
expect(pm.isAutoApproved("bash", {})).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-edit mode auto-approves write_file/edit_file only", () => {
|
||||
const pm = new PermissionManager([]);
|
||||
pm.setMode("auto-edit");
|
||||
expect(pm.isAutoApproved("write_file", { path: "x" })).toBe(true);
|
||||
expect(pm.isAutoApproved("edit_file", { path: "x" })).toBe(true);
|
||||
// bash is mutating but not a file-edit tool — still prompts even in auto-edit.
|
||||
expect(pm.isAutoApproved("bash", { command: "ls" })).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-accept mode also only covers file-edit tools, not bash/git", () => {
|
||||
const pm = new PermissionManager([]);
|
||||
pm.setMode("auto-accept");
|
||||
expect(pm.isAutoApproved("write_file", {})).toBe(true);
|
||||
expect(pm.isAutoApproved("bash", {})).toBe(false);
|
||||
});
|
||||
|
||||
it("default mode falls back to the session-allowed list", () => {
|
||||
const pm = new PermissionManager([]);
|
||||
pm.allowForSession("edit_file");
|
||||
expect(pm.isAutoApproved("edit_file")).toBe(true);
|
||||
expect(pm.isAutoApproved("write_file")).toBe(false);
|
||||
});
|
||||
|
||||
it("an allow rule overrides even default mode (no session-allow needed)", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: true }]);
|
||||
expect(pm.isAutoApproved("bash", {})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRules / listRules", () => {
|
||||
it("setRules replaces the active rule set", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: true }]);
|
||||
pm.setRules([{ tool: "edit_file", allow: false }]);
|
||||
expect(pm.checkRules("bash", {})).toBeNull();
|
||||
expect(pm.checkRules("edit_file", {})).toBe("deny");
|
||||
});
|
||||
|
||||
it("listRules returns a defensive copy", () => {
|
||||
const pm = new PermissionManager([{ tool: "bash", allow: true }]);
|
||||
const out = pm.listRules();
|
||||
expect(out).toEqual([{ tool: "bash", allow: true }]);
|
||||
out.push({ tool: "edit_file", allow: false });
|
||||
// Mutating the returned array must not affect the manager.
|
||||
expect(pm.listRules()).toEqual([{ tool: "bash", allow: true }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { PermissionMode } from "./types.js";
|
||||
import type { PermissionMode, PermissionRule } from "./types.js";
|
||||
import { AUTO_EDIT_TOOLS } from "./types.js";
|
||||
|
||||
export class PermissionManager {
|
||||
private allowedForSession = new Set<string>();
|
||||
private mode: PermissionMode = "default";
|
||||
private rules: PermissionRule[] = [];
|
||||
|
||||
constructor(rules: PermissionRule[] = []) {
|
||||
this.rules = rules;
|
||||
}
|
||||
|
||||
getMode(): PermissionMode {
|
||||
return this.mode;
|
||||
@@ -13,8 +18,44 @@ export class PermissionManager {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/** Replace the active permission-rule set (used after loading/merging user + project rules). */
|
||||
setRules(rules: PermissionRule[]): void {
|
||||
this.rules = rules;
|
||||
}
|
||||
|
||||
listRules(): PermissionRule[] {
|
||||
return this.rules.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
/** Evaluate the persistent rules against a tool call. Returns `"deny"` if any deny rule matches
|
||||
* (deny wins regardless of order or layer), `"allow"` if an allow rule matches and no deny does,
|
||||
* or `null` when no rule matches (the caller falls through to mode/session logic and prompting).
|
||||
* `argPattern` (when present) is a regex tested against JSON.stringify(args); an invalid regex is
|
||||
* skipped rather than crashing the turn. */
|
||||
checkRules(toolName: string, args: unknown): "deny" | "allow" | null {
|
||||
const serialized = args === undefined ? "" : JSON.stringify(args);
|
||||
let allowMatch = false;
|
||||
for (const r of this.rules) {
|
||||
if (r.tool !== toolName) continue;
|
||||
if (r.argPattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(r.argPattern).test(serialized)) continue;
|
||||
} catch {
|
||||
// Invalid regex in a rule — skip it rather than blocking every call to this tool.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!r.allow) return "deny";
|
||||
allowMatch = true;
|
||||
}
|
||||
return allowMatch ? "allow" : null;
|
||||
}
|
||||
|
||||
/** Check whether a mutating tool should be auto-approved (no confirmation needed). */
|
||||
isAutoApproved(toolName: string): boolean {
|
||||
isAutoApproved(toolName: string, args?: unknown): boolean {
|
||||
// An explicit allow rule auto-approves (a deny rule is handled separately, in the gate, before
|
||||
// this is reached — so we only need to check for "allow" here).
|
||||
if (this.checkRules(toolName, args) === "allow") return true;
|
||||
// auto-accept only covers the same file-edit tools as auto-edit, not arbitrary mutating tools
|
||||
// such as bash or git_commit. This prevents a user who intended "approve edits" from silently
|
||||
// approving every dangerous operation.
|
||||
|
||||
@@ -5,6 +5,19 @@ export type PermissionMode = "default" | "auto-edit" | "auto-accept" | "plan";
|
||||
/** Tools that are auto-accepted in "auto-edit" mode. */
|
||||
export const AUTO_EDIT_TOOLS = new Set(["write_file", "edit_file"]);
|
||||
|
||||
/** A persistent permission rule (from user config or project .locode/settings.json) that either
|
||||
* auto-approves or outright blocks a tool call, without prompting. When `argPattern` is omitted the
|
||||
* rule matches any call to `tool`; when present it's a regex tested against JSON.stringify(args), so
|
||||
* e.g. `{ tool: "bash", argPattern: "npm (test|run)", allow: true }` auto-approves test/run
|
||||
* commands while still prompting for others (the regex matches a substring of the serialized args,
|
||||
* so don't anchor with `^` — that would bind to the leading `{` of the JSON). Deny rules take
|
||||
* precedence over allow rules. */
|
||||
export interface PermissionRule {
|
||||
tool: string;
|
||||
argPattern?: string;
|
||||
allow: boolean;
|
||||
}
|
||||
|
||||
export type ConfirmFn = (opts: {
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import type { PermissionMode } from "../permissions/types.js";
|
||||
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
|
||||
|
||||
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
|
||||
@@ -15,6 +16,9 @@ export interface SessionRecord {
|
||||
baseURL: string;
|
||||
model: string;
|
||||
mode: ToolCallMode;
|
||||
/** Permission mode at save time, so plan/auto-edit/auto-accept survives a resume instead of
|
||||
* always resetting to default. Omitted by older saved sessions — treated as "default". */
|
||||
permissionMode?: PermissionMode;
|
||||
messages: ChatCompletionMessageParam[];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildPluginAgentTool(agent: PluginAgentDef): ToolDef<{ prompt: s
|
||||
{ description: agent.name, prompt },
|
||||
{ systemPrompt: agent.systemPrompt, toolNames: agent.tools },
|
||||
);
|
||||
return { agent: agent.name, result };
|
||||
return { agent: agent.name, result: result.result, ...(result.resumable ? { agentId: result.agentId } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ const CLAUDE_TOOL_NAME_MAP: Record<string, string> = {
|
||||
webfetch: "web_fetch",
|
||||
websearch: "web_search",
|
||||
task: "agent",
|
||||
todowrite: "todo_write",
|
||||
taskcreate: "task_create",
|
||||
tasklist: "task_list",
|
||||
taskget: "task_get",
|
||||
taskupdate: "task_update",
|
||||
};
|
||||
|
||||
export function resolveToolName(name: string): string {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { CronStore, parseCron } from "./cron.js";
|
||||
import { cronCreateTool, cronDeleteTool, cronListTool, scheduleWakeupTool } from "../tools/cron.js";
|
||||
import type { ToolContext } from "../tools/types.js";
|
||||
|
||||
function ctxWith(store?: CronStore): ToolContext {
|
||||
return { cwd: "/x", ...(store ? { cronStore: store } : {}) };
|
||||
}
|
||||
|
||||
describe("parseCron", () => {
|
||||
it("parses a 5-field expression into allowed values", () => {
|
||||
const spec = parseCron("0 9 * * 1-5");
|
||||
expect(spec.minute).toEqual([0]);
|
||||
expect(spec.hour).toEqual([9]);
|
||||
expect(spec.dom).toEqual(Array.from({ length: 31 }, (_, i) => i + 1));
|
||||
expect(spec.month).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
expect(spec.dow).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(spec.domStar).toBe(true);
|
||||
expect(spec.dowStar).toBe(false);
|
||||
});
|
||||
|
||||
it("supports */N step and comma-lists", () => {
|
||||
const spec = parseCron("*/15 8-17 * * 0,6");
|
||||
expect(spec.minute).toEqual([0, 15, 30, 45]);
|
||||
expect(spec.hour).toEqual([8, 9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
expect(spec.dow).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it("throws on a wrong field count", () => {
|
||||
expect(() => parseCron("0 9 * *")).toThrow(/5 fields/);
|
||||
expect(() => parseCron("0 9 * * * *")).toThrow(/5 fields/);
|
||||
});
|
||||
|
||||
it("throws on out-of-range values", () => {
|
||||
expect(() => parseCron("60 9 * * *")).toThrow(/out of range/);
|
||||
expect(() => parseCron("0 24 * * *")).toThrow(/out of range/);
|
||||
expect(() => parseCron("0 9 32 * *")).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it("matches a Date correctly (weekday cron with dom=* → dow governs)", () => {
|
||||
// Every Monday: dom=* (star), dow=1. 2026-08-17 is a Monday; 2026-08-16 is a Sunday.
|
||||
const s = parseCron("0 0 * * 1");
|
||||
const mon = new Date(2026, 7, 17, 0, 0);
|
||||
const sun = new Date(2026, 7, 16, 0, 0);
|
||||
// Inline the Vixie-cron matcher logic (the store uses it internally).
|
||||
const domMatch = (d: Date) => s.dom.includes(d.getDate());
|
||||
const dowMatch = (d: Date) => s.dow.includes(d.getDay());
|
||||
const ok = (d: Date) => (s.domStar ? dowMatch(d) : s.dowStar ? domMatch(d) : domMatch(d) || dowMatch(d));
|
||||
expect(ok(mon)).toBe(true);
|
||||
expect(ok(sun)).toBe(false);
|
||||
});
|
||||
|
||||
it("fires on EITHER dom OR dow when both are restricted (Vixie semantics)", () => {
|
||||
// dom=15, dow=0 (Sunday): fires on the 15th of any month OR any Sunday.
|
||||
const s = parseCron("0 0 15 * 0");
|
||||
const domMatch = (d: Date) => s.dom.includes(d.getDate());
|
||||
const dowMatch = (d: Date) => s.dow.includes(d.getDay());
|
||||
const ok = (d: Date) => (s.domStar ? dowMatch(d) : s.dowStar ? domMatch(d) : domMatch(d) || dowMatch(d));
|
||||
// 2026-08-16 is Sunday the 16th (not the 15th) → matches via dow.
|
||||
expect(ok(new Date(2026, 7, 16, 0, 0))).toBe(true);
|
||||
// 2026-08-15 is Saturday the 15th → matches via dom.
|
||||
expect(ok(new Date(2026, 7, 15, 0, 0))).toBe(true);
|
||||
// 2026-08-14 is Friday the 14th → no match.
|
||||
expect(ok(new Date(2026, 7, 14, 0, 0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CronStore", () => {
|
||||
it("create validates the cron expression up front", () => {
|
||||
const s = new CronStore();
|
||||
expect(() => s.create({ cron: "bad expr", prompt: "x" })).toThrow();
|
||||
});
|
||||
|
||||
it("create/list/delete round-trips jobs with sequential ids", () => {
|
||||
const s = new CronStore();
|
||||
const a = s.create({ cron: "0 9 * * *", prompt: "morning" });
|
||||
const b = s.create({ cron: "0 10 * * *", prompt: "late", recurring: false });
|
||||
expect(a.id).toBe("c1");
|
||||
expect(b.id).toBe("c2");
|
||||
expect(s.list()).toHaveLength(2);
|
||||
expect(s.delete("c1")).toBe(true);
|
||||
expect(s.list()).toHaveLength(1);
|
||||
expect(s.delete("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("tick fires due recurring jobs (deduped within the minute) and enqueues the prompt", () => {
|
||||
const s = new CronStore();
|
||||
const enqueued: string[] = [];
|
||||
s.start({ enqueue: (p) => enqueued.push(p), isIdle: () => true });
|
||||
// A cron that matches every minute, so it's definitely due now.
|
||||
s.create({ cron: "* * * * *", prompt: "tick" });
|
||||
// Call the private tick via a cast (the real loop uses setInterval).
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual(["tick"]);
|
||||
// A second tick in the same minute must NOT re-fire (deduped by lastFiredMinute).
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual(["tick"]);
|
||||
s.stop();
|
||||
});
|
||||
|
||||
it("tick does not fire while isIdle is false", () => {
|
||||
const s = new CronStore();
|
||||
const enqueued: string[] = [];
|
||||
s.start({ enqueue: (p) => enqueued.push(p), isIdle: () => false });
|
||||
s.create({ cron: "* * * * *", prompt: "tick" });
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual([]);
|
||||
s.stop();
|
||||
});
|
||||
|
||||
it("one-shot jobs (recurring:false) are deleted after firing once", () => {
|
||||
const s = new CronStore();
|
||||
const enqueued: string[] = [];
|
||||
s.start({ enqueue: (p) => enqueued.push(p), isIdle: () => true });
|
||||
s.create({ cron: "* * * * *", prompt: "once", recurring: false });
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual(["once"]);
|
||||
expect(s.list()).toHaveLength(0); // removed after the one fire
|
||||
s.stop();
|
||||
});
|
||||
|
||||
it("scheduleWakeup clamps delay to [60,3600] and fires once then is removed", () => {
|
||||
const s = new CronStore();
|
||||
const enqueued: string[] = [];
|
||||
s.start({ enqueue: (p) => enqueued.push(p), isIdle: () => true });
|
||||
const res = s.scheduleWakeup({ delaySeconds: 5, prompt: "wake" }) as { id: string; fireAt: number };
|
||||
expect(res.id).toMatch(/^w/);
|
||||
// Clamped to 60s, so a tick right now (well before fireAt) must not enqueue.
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual([]);
|
||||
// Force the wakeup into the past (mutate the STORE's internal entry, not the listWakeups copy)
|
||||
// and tick again so the wakeup is now due.
|
||||
const internal = (s as unknown as { wakeups: Map<string, { fireAt: number }> }).wakeups;
|
||||
const id = [...internal.keys()][0]!;
|
||||
internal.get(id)!.fireAt = Date.now() - 1000;
|
||||
(s as unknown as { tick: () => void }).tick();
|
||||
expect(enqueued).toEqual(["wake"]);
|
||||
expect(s.listWakeups()).toHaveLength(0);
|
||||
s.stop();
|
||||
});
|
||||
|
||||
it("scheduleWakeup with stop:true clears all wakeups", () => {
|
||||
const s = new CronStore();
|
||||
s.scheduleWakeup({ delaySeconds: 60, prompt: "a" });
|
||||
s.scheduleWakeup({ delaySeconds: 120, prompt: "b" });
|
||||
expect(s.listWakeups()).toHaveLength(2);
|
||||
const res = s.scheduleWakeup({ delaySeconds: 60, prompt: "", stop: true });
|
||||
expect(res).toEqual({ stopped: true });
|
||||
expect(s.listWakeups()).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("durable persistence", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "locode-cron-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* leave for OS temp sweep */
|
||||
}
|
||||
});
|
||||
|
||||
it("persists durable jobs to scheduled_tasks.json and reloads them on construction", () => {
|
||||
const s1 = new CronStore(dir);
|
||||
s1.create({ cron: "0 9 * * *", prompt: "morning", durable: true });
|
||||
s1.create({ cron: "0 10 * * *", prompt: "ephemeral" }); // not durable
|
||||
expect(existsSync(path.join(dir, "scheduled_tasks.json"))).toBe(true);
|
||||
const raw = JSON.parse(readFileSync(path.join(dir, "scheduled_tasks.json"), "utf-8")) as {
|
||||
jobs: { prompt: string; durable: boolean }[];
|
||||
};
|
||||
expect(raw.jobs).toHaveLength(1);
|
||||
expect(raw.jobs[0]!.prompt).toBe("morning");
|
||||
|
||||
// A fresh store pointed at the same dir reloads the durable job only.
|
||||
const s2 = new CronStore(dir);
|
||||
expect(s2.list()).toHaveLength(1);
|
||||
expect(s2.list()[0]!.prompt).toBe("morning");
|
||||
// The reloaded id shouldn't collide with a new one (seq was bumped past it).
|
||||
const next = s2.create({ cron: "0 11 * * *", prompt: "next" });
|
||||
expect(next.id).not.toBe("c1");
|
||||
});
|
||||
|
||||
it("non-durable jobs are NOT persisted", () => {
|
||||
const s1 = new CronStore(dir);
|
||||
s1.create({ cron: "0 9 * * *", prompt: "ephemeral", durable: false });
|
||||
const s2 = new CronStore(dir);
|
||||
expect(s2.list()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron/schedule tools", () => {
|
||||
it("cron_create returns the job id and snapshot, validating cron syntax", async () => {
|
||||
const store = new CronStore();
|
||||
const result = (await cronCreateTool.handler(
|
||||
{ cron: "0 9 * * 1-5", prompt: "weekday standup", recurring: true },
|
||||
ctxWith(store),
|
||||
)) as { id: string; job: { cron: string; recurring: boolean } };
|
||||
expect(result.id).toBe("c1");
|
||||
expect(result.job.cron).toBe("0 9 * * 1-5");
|
||||
});
|
||||
|
||||
it("cron_create returns an error for invalid cron syntax", async () => {
|
||||
const store = new CronStore();
|
||||
const result = (await cronCreateTool.handler(
|
||||
{ cron: "not cron", prompt: "x" },
|
||||
ctxWith(store),
|
||||
)) as { error: string };
|
||||
expect(result.error).toMatch(/field|5 fields|range/i);
|
||||
});
|
||||
|
||||
it("cron_create returns an error when no store is available", async () => {
|
||||
const result = (await cronCreateTool.handler({ cron: "0 9 * * *", prompt: "x" }, ctxWith(undefined))) as {
|
||||
error: string;
|
||||
};
|
||||
expect(result.error).toMatch(/not available/i);
|
||||
});
|
||||
|
||||
it("cron_list returns the jobs; empty (not error) when no store", async () => {
|
||||
const store = new CronStore();
|
||||
store.create({ cron: "0 9 * * *", prompt: "x" });
|
||||
const result = (await cronListTool.handler({}, ctxWith(store))) as { jobs: { id: string }[] };
|
||||
expect(result.jobs).toHaveLength(1);
|
||||
const empty = (await cronListTool.handler({}, ctxWith(undefined))) as { jobs: unknown[] };
|
||||
expect(empty.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
it("cron_delete removes a job and returns { deleted }", async () => {
|
||||
const store = new CronStore();
|
||||
store.create({ cron: "0 9 * * *", prompt: "x" });
|
||||
const result = (await cronDeleteTool.handler({ id: "c1" }, ctxWith(store))) as { deleted: string };
|
||||
expect(result.deleted).toBe("c1");
|
||||
expect(store.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cron_delete returns an error for an unknown id", async () => {
|
||||
const store = new CronStore();
|
||||
const result = (await cronDeleteTool.handler({ id: "c99" }, ctxWith(store))) as { error: string };
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it("schedule_wakeup returns a wakeup id, or { stopped } when stop is true", async () => {
|
||||
const store = new CronStore();
|
||||
const result = (await scheduleWakeupTool.handler(
|
||||
{ delaySeconds: 120, prompt: "check back" },
|
||||
ctxWith(store),
|
||||
)) as { id: string; fireAt: number };
|
||||
expect(result.id).toMatch(/^w/);
|
||||
const stop = (await scheduleWakeupTool.handler(
|
||||
{ delaySeconds: 60, prompt: "", stop: true },
|
||||
ctxWith(store),
|
||||
)) as { stopped: boolean };
|
||||
expect(stop.stopped).toBe(true);
|
||||
});
|
||||
|
||||
it("all cron/schedule tools are non-mutating (no confirmation prompt)", () => {
|
||||
expect(cronCreateTool.mutating).toBe(false);
|
||||
expect(cronListTool.mutating).toBe(false);
|
||||
expect(cronDeleteTool.mutating).toBe(false);
|
||||
expect(scheduleWakeupTool.mutating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron tool schema validation", () => {
|
||||
it("requires a cron expression and prompt on cron_create", () => {
|
||||
expect(() => cronCreateTool.schema.parse({ cron: "", prompt: "x" })).toThrow();
|
||||
expect(() => cronCreateTool.schema.parse({ cron: "0 9 * * *", prompt: "" })).toThrow();
|
||||
});
|
||||
it("requires an id on cron_delete", () => {
|
||||
expect(() => cronDeleteTool.schema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
it("requires a positive integer delaySeconds on schedule_wakeup", () => {
|
||||
expect(() => scheduleWakeupTool.schema.parse({ delaySeconds: 0, prompt: "x" })).toThrow();
|
||||
expect(() => scheduleWakeupTool.schema.parse({ delaySeconds: 1.5, prompt: "x" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** A scheduled, recurring cron job (5-field cron expression in the user's LOCAL timezone, matching
|
||||
* Claude Code). `recurring: false` is a one-shot that fires once then auto-deletes. `durable` jobs
|
||||
* are persisted to disk so they survive a restart; session-only jobs die with the process. */
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
cron: string;
|
||||
prompt: string;
|
||||
recurring: boolean;
|
||||
durable: boolean;
|
||||
/** Epoch ms the job was created — used for the 7-day auto-expiry on recurring jobs. */
|
||||
createdAt: number;
|
||||
/** Epoch-minute of the most recent fire, so a job doesn't re-fire within the same minute. */
|
||||
lastFiredMinute?: number;
|
||||
/** Set true once the 7-day expiry has fired its final run, so the tick deletes it after enqueue. */
|
||||
expired?: boolean;
|
||||
}
|
||||
|
||||
/** A one-shot delayed prompt (the ScheduleWakeup primitive), used for self-paced loops. Fires once
|
||||
* at fireAt (epoch ms) then is removed. Session-only — never persisted. */
|
||||
export interface Wakeup {
|
||||
id: string;
|
||||
fireAt: number;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/** Parsed 5-field cron spec. `domStar`/`dowStar` record whether the day-of-month / day-of-week fields
|
||||
* were `*` — needed for Vixie-cron semantics (when both are restricted, fire on EITHER match). */
|
||||
interface CronSpec {
|
||||
minute: number[];
|
||||
hour: number[];
|
||||
dom: number[];
|
||||
month: number[];
|
||||
dow: number[];
|
||||
domStar: boolean;
|
||||
dowStar: boolean;
|
||||
}
|
||||
|
||||
const FIELD_RANGES: Record<string, [number, number]> = {
|
||||
minute: [0, 59],
|
||||
hour: [0, 23],
|
||||
dom: [1, 31],
|
||||
month: [1, 12],
|
||||
dow: [0, 6],
|
||||
};
|
||||
|
||||
// Parses a single cron field into the sorted list of allowed values. Supports `*`, the `*/N` step
|
||||
// form, `N`, `N-M`, `N-M/S`, and comma-lists of any of these. Throws on out-of-range or unparseable
|
||||
// input. (Line comments, not JSDoc, because the `*/N` step syntax contains a `*/` that would close a
|
||||
// block comment prematurely.)
|
||||
function parseField(field: string, range: [number, number]): { values: number[]; isStar: boolean } {
|
||||
const min = range[0];
|
||||
const max = range[1];
|
||||
const out = new Set<number>();
|
||||
const isStar = field === "*" || field === "*/1";
|
||||
for (const part of field.split(",")) {
|
||||
const slashIdx = part.indexOf("/");
|
||||
let rangePart = part;
|
||||
let step = 1;
|
||||
if (slashIdx !== -1) {
|
||||
rangePart = part.slice(0, slashIdx);
|
||||
step = Number(part.slice(slashIdx + 1));
|
||||
}
|
||||
let lo: number;
|
||||
let hi: number;
|
||||
if (rangePart === "*") {
|
||||
lo = min;
|
||||
hi = max;
|
||||
} else if (rangePart.includes("-")) {
|
||||
const [a, b] = rangePart.split("-");
|
||||
lo = Number(a);
|
||||
hi = Number(b);
|
||||
} else {
|
||||
lo = hi = Number(rangePart);
|
||||
}
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi) || !Number.isFinite(step) || step < 1) {
|
||||
throw new Error(`invalid cron field "${field}"`);
|
||||
}
|
||||
if (lo < min || hi > max || lo > hi) {
|
||||
throw new Error(`cron field "${field}" out of range [${min}-${max}]`);
|
||||
}
|
||||
for (let v = lo; v <= hi; v += step) out.add(v);
|
||||
}
|
||||
return { values: [...out].sort((a, b) => a - b), isStar };
|
||||
}
|
||||
|
||||
/** Parses a 5-field cron expression into a matcher spec. Throws on malformed input. */
|
||||
export function parseCron(expr: string): CronSpec {
|
||||
const fields = expr.trim().split(/\s+/);
|
||||
if (fields.length !== 5) throw new Error(`cron expression must have 5 fields, got ${fields.length}`);
|
||||
const minute = fields[0]!;
|
||||
const hour = fields[1]!;
|
||||
const dom = fields[2]!;
|
||||
const month = fields[3]!;
|
||||
const dow = fields[4]!;
|
||||
const m = parseField(minute, FIELD_RANGES.minute!);
|
||||
const h = parseField(hour, FIELD_RANGES.hour!);
|
||||
const dm = parseField(dom, FIELD_RANGES.dom!);
|
||||
const mo = parseField(month, FIELD_RANGES.month!);
|
||||
const dw = parseField(dow, FIELD_RANGES.dow!);
|
||||
return { minute: m.values, hour: h.values, dom: dm.values, month: mo.values, dow: dw.values, domStar: dm.isStar, dowStar: dw.isStar };
|
||||
}
|
||||
|
||||
/** Whether a cron spec matches a given local Date. */
|
||||
function cronMatches(spec: CronSpec, d: Date): boolean {
|
||||
if (!spec.minute.includes(d.getMinutes())) return false;
|
||||
if (!spec.hour.includes(d.getHours())) return false;
|
||||
if (!spec.month.includes(d.getMonth() + 1)) return false;
|
||||
const domMatch = spec.dom.includes(d.getDate());
|
||||
const dowMatch = spec.dow.includes(d.getDay());
|
||||
// Vixie-cron: when both day fields are restricted, fire on EITHER; when one is *, the other governs.
|
||||
if (spec.domStar && spec.dowStar) return true;
|
||||
if (spec.domStar) return dowMatch;
|
||||
if (spec.dowStar) return domMatch;
|
||||
return domMatch || dowMatch;
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const TICK_INTERVAL_MS = 15_000;
|
||||
|
||||
/** In-memory scheduler for cron jobs and one-shot wakeups. Held by the Session; the App starts it
|
||||
* with an `enqueue` callback (submit a turn) and an `isIdle` predicate (true when no turn is
|
||||
* running) so jobs only fire while the REPL is idle, matching Claude Code. Durable jobs persist
|
||||
* to `<configDir>/scheduled_tasks.json`; session-only jobs don't. */
|
||||
export class CronStore {
|
||||
private jobs = new Map<string, CronJob>();
|
||||
private wakeups = new Map<string, Wakeup>();
|
||||
private seq = 0;
|
||||
private wakeSeq = 0;
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private enqueue: ((prompt: string) => void) | undefined;
|
||||
private isIdle: (() => boolean) | undefined;
|
||||
private readonly file: string | undefined;
|
||||
|
||||
constructor(configDir?: string) {
|
||||
if (configDir) {
|
||||
this.file = path.join(configDir, "scheduled_tasks.json");
|
||||
this.loadDurable();
|
||||
}
|
||||
}
|
||||
|
||||
private nextId(): string {
|
||||
this.seq += 1;
|
||||
return `c${this.seq}`;
|
||||
}
|
||||
|
||||
private nextWakeupId(): string {
|
||||
this.wakeSeq += 1;
|
||||
return `w${this.wakeSeq}`;
|
||||
}
|
||||
|
||||
/** Begins the tick loop. Called once by the App after the session is wired. */
|
||||
start(opts: { enqueue: (prompt: string) => void; isIdle: () => boolean }): void {
|
||||
this.enqueue = opts.enqueue;
|
||||
this.isIdle = opts.isIdle;
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
||||
// setInterval keeps the event loop alive; unref so the process can still exit naturally when the
|
||||
// UI closes (the App stops the store on unmount anyway, but this is a backstop).
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Stops the tick loop (e.g. on session/App teardown). */
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
if (!this.enqueue || !this.isIdle) return;
|
||||
if (!this.isIdle()) return; // only fire while the REPL is idle
|
||||
const now = Date.now();
|
||||
const nowMinute = Math.floor(now / 60_000);
|
||||
const nowDate = new Date(now);
|
||||
const toDelete: string[] = [];
|
||||
for (const job of this.jobs.values()) {
|
||||
const age = now - job.createdAt;
|
||||
// 7-day auto-expiry: recurring jobs fire one final time then are deleted.
|
||||
if (job.recurring && age >= SEVEN_DAYS_MS) {
|
||||
this.enqueue(job.prompt);
|
||||
toDelete.push(job.id);
|
||||
continue;
|
||||
}
|
||||
if (job.lastFiredMinute === nowMinute) continue;
|
||||
const spec = parseCron(job.cron);
|
||||
if (cronMatches(spec, nowDate)) {
|
||||
this.enqueue(job.prompt);
|
||||
job.lastFiredMinute = nowMinute;
|
||||
if (!job.recurring) toDelete.push(job.id); // one-shot fires once then is removed
|
||||
}
|
||||
}
|
||||
for (const id of toDelete) this.delete(id);
|
||||
|
||||
// Wakeups: fire when their time has come.
|
||||
const wakeDelete: string[] = [];
|
||||
for (const w of this.wakeups.values()) {
|
||||
if (now >= w.fireAt) {
|
||||
this.enqueue(w.prompt);
|
||||
wakeDelete.push(w.id);
|
||||
}
|
||||
}
|
||||
for (const id of wakeDelete) this.wakeups.delete(id);
|
||||
}
|
||||
|
||||
create(input: { cron: string; prompt: string; recurring?: boolean; durable?: boolean }): CronJob {
|
||||
parseCron(input.cron); // validate syntax up front
|
||||
const recurring = input.recurring ?? true;
|
||||
const durable = input.durable ?? false;
|
||||
const job: CronJob = {
|
||||
id: this.nextId(),
|
||||
cron: input.cron,
|
||||
prompt: input.prompt,
|
||||
recurring,
|
||||
durable,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.jobs.set(job.id, job);
|
||||
if (durable) this.persist();
|
||||
return job;
|
||||
}
|
||||
|
||||
list(): CronJob[] {
|
||||
return [...this.jobs.values()].map((j) => ({ ...j }));
|
||||
}
|
||||
|
||||
get(id: string): CronJob | undefined {
|
||||
const j = this.jobs.get(id);
|
||||
return j ? { ...j } : undefined;
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const existed = this.jobs.delete(id);
|
||||
if (existed) this.persist();
|
||||
return existed;
|
||||
}
|
||||
|
||||
/** Schedules a one-shot wakeup `delaySeconds` from now. If `stop` is true, cancels ALL wakeups
|
||||
* instead (used to end a self-paced loop). Returns the wakeup id, or `{ stopped: true }`. */
|
||||
scheduleWakeup(input: { delaySeconds: number; prompt: string; stop?: boolean }):
|
||||
| { id: string; fireAt: number }
|
||||
| { stopped: true } {
|
||||
if (input.stop) {
|
||||
this.wakeups.clear();
|
||||
return { stopped: true };
|
||||
}
|
||||
const delay = Math.max(60, Math.min(3600, input.delaySeconds));
|
||||
const id = this.nextWakeupId();
|
||||
const fireAt = Date.now() + delay * 1000;
|
||||
this.wakeups.set(id, { id, fireAt, prompt: input.prompt });
|
||||
return { id, fireAt };
|
||||
}
|
||||
|
||||
listWakeups(): Wakeup[] {
|
||||
return [...this.wakeups.values()].map((w) => ({ ...w }));
|
||||
}
|
||||
|
||||
private loadDurable(): void {
|
||||
if (!this.file || !existsSync(this.file)) return;
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(this.file, "utf-8")) as { jobs?: CronJob[] };
|
||||
for (const j of data.jobs ?? []) {
|
||||
// Only durable jobs are persisted; skip any that slipped in without the flag.
|
||||
if (!j.durable) continue;
|
||||
this.jobs.set(j.id, { ...j });
|
||||
// Bump the seq past any restored ids so new ids don't collide.
|
||||
const n = Number(j.id.replace(/^c/, ""));
|
||||
if (Number.isFinite(n) && n > this.seq) this.seq = n;
|
||||
}
|
||||
} catch {
|
||||
// A corrupt persistence file shouldn't block startup — just start with no durable jobs.
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.file) return;
|
||||
try {
|
||||
const dir = path.dirname(this.file);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const durable = [...this.jobs.values()].filter((j) => j.durable).map((j) => ({
|
||||
id: j.id,
|
||||
cron: j.cron,
|
||||
prompt: j.prompt,
|
||||
recurring: j.recurring,
|
||||
durable: j.durable,
|
||||
createdAt: j.createdAt,
|
||||
}));
|
||||
writeFileSync(this.file, JSON.stringify({ jobs: durable }, null, 2), "utf-8");
|
||||
} catch {
|
||||
// Persistence is best-effort; a write failure must not crash the scheduler.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFallbackToolCalls } from "./fallbackParser.js";
|
||||
|
||||
describe("parseFallbackToolCalls", () => {
|
||||
it("parses a plain tool_call block", () => {
|
||||
const result = parseFallbackToolCalls("```tool_call\n{\"name\": \"read_file\", \"arguments\": {\"path\": \"src/x.ts\"}}\n```");
|
||||
expect(result.malformed).toBe(false);
|
||||
expect(result.calls).toHaveLength(1);
|
||||
expect(result.calls[0]).toEqual({ name: "read_file", arguments: { path: "src/x.ts" } });
|
||||
});
|
||||
|
||||
it("strips inner ```json fences", () => {
|
||||
const result = parseFallbackToolCalls(
|
||||
"```tool_call\n```json\n{\"name\": \"read_file\", \"arguments\": {\"path\": \"src/x.ts\"}}\n```\n```",
|
||||
);
|
||||
expect(result.calls).toHaveLength(1);
|
||||
expect(result.calls[0]).toEqual({ name: "read_file", arguments: { path: "src/x.ts" } });
|
||||
});
|
||||
|
||||
it("marks top-level arguments (not nested in 'arguments') as malformed", () => {
|
||||
const result = parseFallbackToolCalls("```tool_call\n{\"name\": \"read_file\", \"path\": \"src/x.ts\"}\n```");
|
||||
expect(result.malformed).toBe(true);
|
||||
expect(result.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks invalid JSON as malformed", () => {
|
||||
const result = parseFallbackToolCalls("```tool_call\nnot json\n```");
|
||||
expect(result.malformed).toBe(true);
|
||||
expect(result.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ export interface FallbackParseResult {
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
const BLOCK_RE = /```tool_call\s*([\s\S]*?)```/g;
|
||||
// Opening fence is ```tool_call on its own line; closing fence is ``` on its own line.
|
||||
// This prevents ```json inside the block from being mistaken for the terminator.
|
||||
const BLOCK_RE = /^```tool_call\s*\n([\s\S]*?)\n```(?:\n|$)/gm;
|
||||
|
||||
export function parseFallbackToolCalls(content: string): FallbackParseResult {
|
||||
const calls: FallbackToolCall[] = [];
|
||||
@@ -16,10 +18,13 @@ export function parseFallbackToolCalls(content: string): FallbackParseResult {
|
||||
|
||||
for (const match of content.matchAll(BLOCK_RE)) {
|
||||
const raw = match[1]?.trim() ?? "";
|
||||
// Some local models emit markdown fences inside the tool_call block (e.g. ```json ... ```).
|
||||
// Strip them so the inner JSON can be parsed.
|
||||
const cleaned = raw.replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.name === "string" && typeof parsed.arguments === "object") {
|
||||
calls.push({ name: parsed.name, arguments: parsed.arguments ?? {} });
|
||||
const parsed = JSON.parse(cleaned || raw);
|
||||
if (parsed && typeof parsed.name === "string" && typeof parsed.arguments === "object" && parsed.arguments !== null) {
|
||||
calls.push({ name: parsed.name, arguments: parsed.arguments });
|
||||
} else {
|
||||
malformed = true;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
export const FALLBACK_TOOL_INSTRUCTIONS = `This model does not support native function calling. To call a tool, write a fenced code block:
|
||||
export const FALLBACK_TOOL_INSTRUCTIONS = `This model does not support native function calling. To call a tool, write exactly one fenced code block of the form:
|
||||
|
||||
\`\`\`tool_call
|
||||
{"name": "read_file", "arguments": {"path": "src/index.ts"}}
|
||||
{"name": "TOOL_NAME", "arguments": {"arg1": "value1", "arg2": "value2"}}
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- One tool call per response. Wait for the result before calling another.
|
||||
- Must contain valid JSON with "name" and "arguments" keys.
|
||||
- If no tool is needed, answer normally without a fenced block.
|
||||
- One tool call per response. Wait for the \`\`\`tool_result\`\`\` before calling another.
|
||||
- The fenced block must contain a single JSON object with exactly two keys: "name" and "arguments".
|
||||
- "arguments" must be an object matching the tool's schema. Do not put the arguments at the top level.
|
||||
- If no tool is needed, answer normally without any \`\`\`tool_call\`\`\` block.
|
||||
|
||||
The result is returned in a \`\`\`tool_result\`\`\` block. Then answer normally or call another tool.`;
|
||||
Example:
|
||||
\`\`\`tool_call
|
||||
{"name": "read_file", "arguments": {"path": "src/index.ts", "limit": 50}}
|
||||
\`\`\`
|
||||
|
||||
export const FALLBACK_RETRY_NUDGE = `Your last \`tool_call\` block wasn't valid JSON with "name" and "arguments" keys. Try again using the correct format, or answer without a tool call.`;
|
||||
When a tool result shows an error or empty output, do not repeat the exact same call. Adjust your arguments or ask the user.`;
|
||||
|
||||
export const FALLBACK_RETRY_NUDGE = `Your last \`tool_call\` block was invalid. Check:
|
||||
- It must be a single JSON object inside the fence, not plain text or multiple objects.
|
||||
- It must have "name" (string) and "arguments" (object) keys.
|
||||
- Argument values must match the tool's expected types.
|
||||
Try again with the correct format, or answer without a tool call.`;
|
||||
|
||||
+63
-4
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { AGENT_TYPE_NAMES, getAgentType } from "./agentTypes.js";
|
||||
import type { SubAgentOverrides } from "./types.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -8,6 +10,27 @@ const schema = z.object({
|
||||
.describe(
|
||||
"Full, self-contained task description. The sub-agent has no conversation memory and cannot ask follow-ups.",
|
||||
),
|
||||
agentType: z
|
||||
.enum(AGENT_TYPE_NAMES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Specialist sub-agent type. 'general-purpose' (default) has full tool access and may edit files. " +
|
||||
"'explore' is read-only research (locate code, map structure). 'code-reviewer' is read-only review " +
|
||||
"(find bugs, verify claims, report findings). 'planner' is read-only planning (design an implementation " +
|
||||
"plan with steps and tradeoffs). 'debugger' reproduces and fixes a bug with a minimal, verified fix. " +
|
||||
"'test-writer' writes focused tests, runs them, and iterates until they pass. Read-only types " +
|
||||
"(explore, code-reviewer, planner) cannot modify files.",
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional name for a shared-cwd (single, non-parallel) delegation. Naming it creates a named TEAMMATE: " +
|
||||
"you can then continue it with send_message by name and see it via list_teammates, without tracking its " +
|
||||
"agentId. Use this when you'll send a teammate several messages (e.g. a 'researcher' you'll re-query). " +
|
||||
"Names must be unique within your session — re-using an existing name returns an error instead of " +
|
||||
"clobbering the teammate. Parallel (worktree-isolated) delegations are fire-and-forget and ignore the name.",
|
||||
),
|
||||
});
|
||||
|
||||
export const agentTool: ToolDef<z.infer<typeof schema>> = {
|
||||
@@ -15,14 +38,50 @@ export const agentTool: ToolDef<z.infer<typeof schema>> = {
|
||||
description:
|
||||
"Delegate a task to a sub-agent with its own tool loop (no nested agents). Only the final answer is returned. " +
|
||||
"For many files, split into multiple sub-agents. Sub-agents have a smaller step budget; if one runs out, " +
|
||||
"narrow the task rather than retrying.",
|
||||
"narrow the task rather than retrying. Pick an agentType: general-purpose (full access, may edit), explore " +
|
||||
"(read-only research), code-reviewer (read-only review), planner (read-only design of an implementation plan), " +
|
||||
"debugger (reproduce and fix a bug with a minimal verified fix), or test-writer (write and run focused tests). " +
|
||||
"A single delegation runs in your working directory " +
|
||||
"and persists its edits; when you emit several 'agent'/'agent__*' calls in one response they run in parallel, " +
|
||||
"each in an isolated throwaway git worktree whose file changes are discarded — use parallel batches for " +
|
||||
"research/review/analysis (the returned answer is the deliverable), and a single call for implementation. " +
|
||||
"A single (non-parallel) delegation is resumable: it returns an agentId you can pass to send_message to " +
|
||||
"continue it. Pass `name` to give it a stable teammate name you can address by name instead of the agentId.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.runSubAgent) {
|
||||
throw new Error("Sub-agents are not available in this context.");
|
||||
}
|
||||
const result = await ctx.runSubAgent(args);
|
||||
return { description: args.description, result };
|
||||
// Pre-flight a name collision so we never clobber an existing teammate (and never waste a
|
||||
// delegation whose name we'd refuse to register). If resolveTeammate is absent (a non-session
|
||||
// context), skip the check — there's no roster to clobber and nothing to register later either.
|
||||
if (args.name && ctx.resolveTeammate?.(args.name)) {
|
||||
return {
|
||||
error: `A teammate named "${args.name}" already exists. Use send_message with name "${args.name}" to continue it, or pick a different name for this new delegation.`,
|
||||
};
|
||||
}
|
||||
const spec = getAgentType(args.agentType);
|
||||
// general-purpose inherits the full toolset and uses the generic prompt (no overrides). Specialist
|
||||
// types restrict tools and add their identity as a prompt addendum on top of the generic prompt.
|
||||
const overrides: SubAgentOverrides | undefined =
|
||||
spec.name === "general-purpose" ? undefined : { toolNames: spec.toolNames, systemPromptAddendum: spec.systemPromptAddendum };
|
||||
const result = await ctx.runSubAgent({ description: args.description, prompt: args.prompt }, overrides);
|
||||
// Register the name on the session's roster only when the sub-agent is resumable (shared-cwd, not
|
||||
// worktree-isolated) AND a roster is wired. Isolated parallel agents are fire-and-forget, so a
|
||||
// name wouldn't be addressable — the result simply omits agentId and name, signalling
|
||||
// non-resumability. We only echo `name` when it was actually registered, so the model never sees a
|
||||
// name that send_message can't resolve (e.g. a non-session context with no roster).
|
||||
let registered = false;
|
||||
if (args.name && result.resumable && ctx.registerTeammate) {
|
||||
ctx.registerTeammate(args.name, result.agentId);
|
||||
registered = true;
|
||||
}
|
||||
return {
|
||||
description: args.description,
|
||||
result: result.result,
|
||||
...(result.resumable ? { agentId: result.agentId } : {}),
|
||||
...(registered ? { name: args.name } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_TYPES, AGENT_TYPE_NAMES, getAgentType, isReadOnlyAgentType } from "./agentTypes.js";
|
||||
|
||||
describe("agentTypes registry", () => {
|
||||
it("exposes the six built-in types", () => {
|
||||
expect(AGENT_TYPE_NAMES).toEqual(["general-purpose", "explore", "code-reviewer", "planner", "debugger", "test-writer"]);
|
||||
expect(AGENT_TYPES).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("every type has a non-empty addendum", () => {
|
||||
for (const t of AGENT_TYPES) expect(t.systemPromptAddendum.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("getAgentType resolves known names and falls back to general-purpose", () => {
|
||||
expect(getAgentType("explore").name).toBe("explore");
|
||||
expect(getAgentType("code-reviewer").name).toBe("code-reviewer");
|
||||
expect(getAgentType("planner").name).toBe("planner");
|
||||
expect(getAgentType("debugger").name).toBe("debugger");
|
||||
expect(getAgentType("test-writer").name).toBe("test-writer");
|
||||
expect(getAgentType("general-purpose").name).toBe("general-purpose");
|
||||
expect(getAgentType(undefined).name).toBe("general-purpose");
|
||||
expect(getAgentType("bogus").name).toBe("general-purpose");
|
||||
});
|
||||
|
||||
it("read-only types (explore, code-reviewer, planner) vs mutating types (general-purpose, debugger, test-writer)", () => {
|
||||
expect(isReadOnlyAgentType("explore")).toBe(true);
|
||||
expect(isReadOnlyAgentType("code-reviewer")).toBe(true);
|
||||
expect(isReadOnlyAgentType("planner")).toBe(true);
|
||||
expect(isReadOnlyAgentType("general-purpose")).toBe(false);
|
||||
expect(isReadOnlyAgentType("debugger")).toBe(false);
|
||||
expect(isReadOnlyAgentType("test-writer")).toBe(false);
|
||||
expect(isReadOnlyAgentType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("read-only toolsets contain only known non-mutating tools", () => {
|
||||
const explore = getAgentType("explore");
|
||||
const reviewer = getAgentType("code-reviewer");
|
||||
const planner = getAgentType("planner");
|
||||
expect(explore.toolNames).not.toContain("write_file");
|
||||
expect(explore.toolNames).not.toContain("bash");
|
||||
expect(explore.toolNames).toContain("read_file");
|
||||
expect(reviewer.toolNames).toContain("git_status");
|
||||
expect(reviewer.toolNames).not.toContain("edit_file");
|
||||
expect(planner.toolNames).toContain("git_status");
|
||||
expect(planner.toolNames).not.toContain("edit_file");
|
||||
expect(planner.toolNames).not.toContain("bash");
|
||||
});
|
||||
|
||||
it("debugger and test-writer can mutate (reproduce/fix and write/run tests)", () => {
|
||||
const debugger_ = getAgentType("debugger");
|
||||
const testWriter = getAgentType("test-writer");
|
||||
expect(debugger_.toolNames).toContain("bash");
|
||||
expect(debugger_.toolNames).toContain("edit_file");
|
||||
expect(testWriter.toolNames).toContain("write_file");
|
||||
expect(testWriter.toolNames).toContain("bash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
/** A built-in specialist sub-agent type the model can request via the `agent` tool's `agentType`
|
||||
* field. Each restricts the sub-agent's toolset (read-only types can't mutate) and prepends a
|
||||
* specialist addendum to the generic sub-agent system prompt. Plugin agents (agent__*) are a
|
||||
* separate, full-replacement mechanism; these built-in types layer on top of the generic prompt so
|
||||
* the standard tool discipline still applies. */
|
||||
export interface AgentTypeSpec {
|
||||
name: string;
|
||||
/** One-line summary surfaced to the model via the schema enum description. */
|
||||
description: string;
|
||||
/** Tools the sub-agent may use (by ToolDef name). Omit to inherit the parent's full toolset minus
|
||||
* further-nesting agent tools (same as a generic sub-agent). */
|
||||
toolNames?: string[];
|
||||
/** Prepended (as an addendum) to the generic sub-agent system prompt — NOT a full replacement, so
|
||||
* the standard tool-use discipline survives. */
|
||||
systemPromptAddendum: string;
|
||||
}
|
||||
|
||||
export const AGENT_TYPES: AgentTypeSpec[] = [
|
||||
{
|
||||
name: "general-purpose",
|
||||
description: "Full tool access (default). Use for implementation and any task that may edit files.",
|
||||
// toolNames omitted → inherit the parent's full toolset.
|
||||
systemPromptAddendum:
|
||||
"You are a general-purpose sub-agent. You may read, search, and edit files to complete the delegated task.",
|
||||
},
|
||||
{
|
||||
name: "explore",
|
||||
description: "Read-only research: locate code and map structure across many files. Cannot modify anything.",
|
||||
toolNames: ["read_file", "list_files", "grep", "web_search", "web_fetch"],
|
||||
systemPromptAddendum:
|
||||
"You are an Explore agent — a read-only research specialist. Your job is to locate code, map structure, and gather " +
|
||||
"facts across the codebase to answer a specific question. You have only read/search tools and must not modify anything. " +
|
||||
"Read excerpts rather than whole files; report conclusions with the file:line references that back them, not file dumps. " +
|
||||
"If the answer isn't findable, say so plainly.",
|
||||
},
|
||||
{
|
||||
name: "code-reviewer",
|
||||
description: "Read-only code review: find bugs, verify claims against code, report ranked findings. Cannot modify.",
|
||||
toolNames: ["read_file", "list_files", "grep", "git_status", "web_search", "web_fetch"],
|
||||
systemPromptAddendum:
|
||||
"You are a code-review specialist. Review the relevant code for correctness, edge cases, and likely bugs. You have only " +
|
||||
"read/search tools. Verify every claim against the actual code rather than assuming. Report concrete findings with " +
|
||||
"file:line anchors, ranked most-severe first; if you find nothing wrong, say so rather than inventing issues. Do not " +
|
||||
"modify code — report only.",
|
||||
},
|
||||
{
|
||||
name: "planner",
|
||||
description: "Read-only planning: design an implementation plan with files to change, steps, and tradeoffs. Cannot modify.",
|
||||
toolNames: ["read_file", "list_files", "grep", "git_status", "web_search", "web_fetch"],
|
||||
systemPromptAddendum:
|
||||
"You are a planning specialist. Investigate the codebase enough to design a concrete implementation plan — which files to " +
|
||||
"change, in what order, and how, with the key code anchors (file:line) that justify each step. Surface tradeoffs and " +
|
||||
"risks between approaches, and call out anything you'd need to verify before implementing. You have only read/search " +
|
||||
"tools and must not modify anything. Return a step-by-step plan, not code dumps.",
|
||||
},
|
||||
{
|
||||
name: "debugger",
|
||||
description: "Reproduce and fix a bug: form hypotheses, read code, run commands to reproduce, apply a minimal fix, verify.",
|
||||
toolNames: ["read_file", "list_files", "grep", "bash", "bash_output", "edit_file", "multi_edit"],
|
||||
systemPromptAddendum:
|
||||
"You are a debugging specialist. Investigate a reported bug by forming a hypothesis, reading the relevant code, and " +
|
||||
"reproducing it with shell commands before touching anything. Apply the minimal fix that addresses the root cause (not " +
|
||||
"the symptom), then verify the fix actually resolves the reproduction. Prefer a small, targeted edit over a rewrite. " +
|
||||
"If you can't reproduce the bug, say so and report what you found instead of guessing at a fix.",
|
||||
},
|
||||
{
|
||||
name: "test-writer",
|
||||
description: "Write focused tests for a feature or bug fix, run them, and iterate until they pass.",
|
||||
toolNames: ["read_file", "list_files", "grep", "write_file", "edit_file", "bash", "bash_output"],
|
||||
systemPromptAddendum:
|
||||
"You are a test-writing specialist. Write focused, meaningful tests (not trivial smoke tests) for the delegated feature " +
|
||||
"or fix, following the project's existing test conventions and runner. Run the tests with shell commands and iterate " +
|
||||
"until they pass — a test that's never run is unfinished. Cover the important edge cases, but don't over-test. If the " +
|
||||
"code under test is wrong, fix it minimally rather than writing a test around the bug.",
|
||||
},
|
||||
];
|
||||
|
||||
export const AGENT_TYPE_NAMES = AGENT_TYPES.map((t) => t.name) as [string, ...string[]];
|
||||
|
||||
/** Looks up a built-in agent type by name. Falls back to general-purpose for an unknown/missing
|
||||
* name so a model that omits the field or typo's it still gets a working sub-agent. */
|
||||
export function getAgentType(name?: string): AgentTypeSpec {
|
||||
if (name) {
|
||||
const found = AGENT_TYPES.find((t) => t.name === name);
|
||||
if (found) return found;
|
||||
}
|
||||
return AGENT_TYPES[0]!;
|
||||
}
|
||||
|
||||
/** Whether a given agent type is read-only (no mutating tools), used to decide worktree isolation:
|
||||
* read-only parallel agents don't need isolation and should see the current working state. */
|
||||
export function isReadOnlyAgentType(name?: string): boolean {
|
||||
const spec = getAgentType(name);
|
||||
return spec.toolNames !== undefined && !spec.toolNames.some((n) => MUTATING_TOOL_NAMES.has(n));
|
||||
}
|
||||
|
||||
/** Mutating tool names, for isReadOnlyAgentType. Kept here (not imported from the tool defs) so this
|
||||
* stays a static decision without instantiating tools. */
|
||||
const MUTATING_TOOL_NAMES = new Set([
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"multi_edit",
|
||||
"notebook_edit",
|
||||
"bash",
|
||||
"bash_kill",
|
||||
"git_commit",
|
||||
"memory_write",
|
||||
]);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { askQuestionTool } from "./askQuestion.js";
|
||||
import type { AskQuestionAnswer, AskQuestionSpec, ToolContext } from "./types.js";
|
||||
|
||||
const validArgs = {
|
||||
questions: [
|
||||
{
|
||||
question: "Which auth method?",
|
||||
header: "Auth method",
|
||||
options: [
|
||||
{ label: "OAuth", description: "delegate to provider" },
|
||||
{ label: "API key", description: "simple header token" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function ctxWith(askQuestion?: ToolContext["askQuestion"]): ToolContext {
|
||||
return { cwd: "/x", ...(askQuestion ? { askQuestion } : {}) };
|
||||
}
|
||||
|
||||
describe("ask_user_question tool", () => {
|
||||
it("parses a well-formed single question", () => {
|
||||
expect(() => askQuestionTool.schema.parse(validArgs)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects fewer than 2 options per question", () => {
|
||||
expect(() =>
|
||||
askQuestionTool.schema.parse({ questions: [{ question: "q?", header: "h", options: [{ label: "only" }] }] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects more than 4 options per question", () => {
|
||||
const opts = Array.from({ length: 5 }, (_, i) => ({ label: `o${i}` }));
|
||||
expect(() => askQuestionTool.schema.parse({ questions: [{ question: "q?", header: "h", options: opts }] })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects zero questions", () => {
|
||||
expect(() => askQuestionTool.schema.parse({ questions: [] })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects more than 4 questions", () => {
|
||||
const qs = Array.from({ length: 5 }, (_, i) => ({
|
||||
question: `q${i}?`,
|
||||
header: `h${i}`,
|
||||
options: [{ label: "a" }, { label: "b" }],
|
||||
}));
|
||||
expect(() => askQuestionTool.schema.parse({ questions: qs })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a header longer than 12 chars", () => {
|
||||
expect(() =>
|
||||
askQuestionTool.schema.parse({
|
||||
questions: [{ question: "q?", header: "this is too long", options: [{ label: "a" }, { label: "b" }] }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("returns the user's answers when askQuestion is wired", async () => {
|
||||
const answers: AskQuestionAnswer[] = [{ question: "Which auth method?", selected: ["OAuth"] }];
|
||||
const askQuestion = vi.fn(async (_questions: AskQuestionSpec[]) => answers);
|
||||
const result = await askQuestionTool.handler(validArgs as any, ctxWith(askQuestion));
|
||||
expect(askQuestion).toHaveBeenCalledTimes(1);
|
||||
// The spec passed to the callback preserves question/header/options and omits undefined fields.
|
||||
const first = askQuestion.mock.calls[0]![0][0]!;
|
||||
expect(first.question).toBe("Which auth method?");
|
||||
expect(first.header).toBe("Auth method");
|
||||
expect(first.options[0]).toEqual({ label: "OAuth", description: "delegate to provider" });
|
||||
expect("multiSelect" in first).toBe(false);
|
||||
expect(result).toEqual({ answers });
|
||||
});
|
||||
|
||||
it("preserves multiSelect when set", async () => {
|
||||
const askQuestion = vi.fn(async (_questions: AskQuestionSpec[]) => [{ question: "q?", selected: ["a", "b"] }]);
|
||||
await askQuestionTool.handler(
|
||||
{ questions: [{ question: "q?", header: "h", options: [{ label: "a" }, { label: "b" }], multiSelect: true }] } as any,
|
||||
ctxWith(askQuestion),
|
||||
);
|
||||
expect(askQuestion.mock.calls[0]![0][0]!.multiSelect).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a clear error (not a hang) when no interactive UI is available", async () => {
|
||||
const result = await askQuestionTool.handler(validArgs as any, ctxWith(undefined));
|
||||
expect("error" in (result as object)).toBe(true);
|
||||
expect((result as { error: string }).error).toMatch(/no interactive UI|Can't ask/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
import type { AskQuestionAnswer, AskQuestionSpec, ToolDef } from "./types.js";
|
||||
|
||||
const optionSchema = z.object({
|
||||
label: z.string().describe("A concise (1-5 word) label for the option."),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Explanation of what this option means or the trade-off it implies, shown dimmed under the label."),
|
||||
});
|
||||
|
||||
const questionSchema = z.object({
|
||||
question: z.string().describe("The complete question to ask, ending with a question mark."),
|
||||
header: z
|
||||
.string()
|
||||
.max(12)
|
||||
.describe("A very short label (max ~12 chars) shown as a chip beside the question, e.g. \"Auth method\"."),
|
||||
options: z
|
||||
.array(optionSchema)
|
||||
.min(2)
|
||||
.max(4)
|
||||
.describe("Two to four mutually exclusive options (unless multiSelect). The user can also type a custom \"Other\" answer."),
|
||||
multiSelect: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Set true to allow several options to be selected instead of just one."),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
questions: z
|
||||
.array(questionSchema)
|
||||
.min(1)
|
||||
.max(4)
|
||||
.describe("One to four questions to ask. The UI asks them one at a time and returns all answers together."),
|
||||
});
|
||||
|
||||
/** Lets the model ask the user a structured multiple-choice question when it is blocked on a decision
|
||||
* that is genuinely the user's to make — one it can't resolve from the code, request, or sensible
|
||||
* defaults. Non-mutating (it changes nothing on the filesystem), so it's allowed in every permission
|
||||
* mode including plan mode. The UI shows each question with its options (plus an implicit "Other"
|
||||
* path for a freeform answer) and returns the selected label(s); in a headless context with no UI
|
||||
* (sub-agents) the callback is absent and the tool fails with a clear "can't ask" error instead of
|
||||
* hanging. Reserve this for real decision points — don't ask questions you could answer yourself by
|
||||
* reading the code or following an obvious default. */
|
||||
export const askQuestionTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "ask_user_question",
|
||||
description:
|
||||
"Ask the user a structured multiple-choice question when blocked on a decision only they can make. " +
|
||||
"Pass 1-4 questions, each with 2-4 options and a short header chip. The user can pick an option or type a " +
|
||||
"custom \"Other\" answer. Use this instead of a prose question when a discrete choice would clarify the path. " +
|
||||
"Only ask when the request is genuinely ambiguous after you've explored — don't offload decisions you could " +
|
||||
"make yourself.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.askQuestion) {
|
||||
return { error: "Can't ask the user a question in this context (no interactive UI). Make a sensible default choice and proceed, or explain the trade-off in prose." };
|
||||
}
|
||||
const specs: AskQuestionSpec[] = args.questions.map((q) => ({
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
options: q.options.map((o) => ({ label: o.label, ...(o.description ? { description: o.description } : {}) })),
|
||||
...(q.multiSelect ? { multiSelect: true } : {}),
|
||||
}));
|
||||
const answers: AskQuestionAnswer[] = await ctx.askQuestion(specs);
|
||||
return { answers };
|
||||
},
|
||||
};
|
||||
+14
-7
@@ -5,6 +5,7 @@ import { registerBackgroundJob } from "./backgroundJobs.js";
|
||||
import { killProcessTree } from "../utils/processTree.js";
|
||||
import { truncate } from "../utils/truncate.js";
|
||||
import { resolveShell } from "../utils/shell.js";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -21,12 +22,16 @@ function delay(ms: number): Promise<"pending"> {
|
||||
|
||||
export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "bash",
|
||||
description: "Run a shell command and return its stdout, stderr, and exit code.",
|
||||
description:
|
||||
"Run a shell command and return its stdout, stderr, and exit code. Be careful with destructive " +
|
||||
"operations (rm, git push, etc.). For long-running commands, increase timeout_ms or use Ctrl+B " +
|
||||
"to background the command while it's running.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ command, cwd }) => `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`,
|
||||
handler: async ({ command, cwd, timeout_ms }, ctx) => {
|
||||
const workDir = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
|
||||
if (cwd) assertWithinWorkspace(workDir, ctx.cwd, cwd);
|
||||
// Timeout is enforced by our own timer rather than execa's built-in `timeout` option, so that
|
||||
// backgrounding via Ctrl+B can cancel it below — execa's own timeout kills the process on a
|
||||
// fixed schedule regardless of what happens to it afterward, which would silently kill a
|
||||
@@ -72,12 +77,9 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
for (;;) {
|
||||
if (ctx.backgroundControl?.requested) {
|
||||
clearTimeout(foregroundTimer);
|
||||
// Detach our own capture listeners before handing the streams to the background registry —
|
||||
// otherwise both this closure's listeners and registerBackgroundJob's keep appending to
|
||||
// separate buffers forever, doubling the work and growing memory without bound for a
|
||||
// long-running backgrounded job. The buffers captured so far seed the job.
|
||||
child.stdout?.off("data", onStdout);
|
||||
child.stderr?.off("data", onStderr);
|
||||
// Hand the streams off to the background registry; the finally block below will detach
|
||||
// our own capture listeners so both closures don't keep appending to separate buffers
|
||||
// forever, doubling the work and growing memory without bound for a long-running job.
|
||||
const job = registerBackgroundJob(command, workDir, child, stdout, stderr);
|
||||
return {
|
||||
backgrounded: true,
|
||||
@@ -97,6 +99,11 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Detach our capture listeners on every exit path so a long-running command doesn't keep
|
||||
// orphaned handlers alive after the tool returns. On backgrounding this also stops the
|
||||
// foreground closure from competing with the background registry for stream data.
|
||||
child.stdout?.off("data", onStdout);
|
||||
child.stderr?.off("data", onStderr);
|
||||
// Remove the abort listener on every exit path. On backgrounding this is what stops a
|
||||
// sub-agent timeout from killing a job the user explicitly chose to keep running; on normal
|
||||
// completion it's just cleanup. (The listener is `{ once: true }`, but it may never fire.)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
export const cronCreateTool: ToolDef<z.infer<typeof cronCreateSchema>> = {
|
||||
name: "cron_create",
|
||||
description:
|
||||
"Schedule a prompt to run on a recurring cron schedule (5-field cron in the user's LOCAL timezone: minute hour " +
|
||||
"day-of-month month day-of-week, e.g. '0 9 * * 1-5' = weekdays at 9am). Use for recurring checks, reminders, or " +
|
||||
"self-paced loops. The prompt fires only while the REPL is idle. Recurring jobs auto-expire after 7 days. Set " +
|
||||
"recurring: false for a one-shot that fires once then deletes itself. Set durable: true to persist across restarts. " +
|
||||
"Returns the new job id.",
|
||||
schema: z.object({
|
||||
cron: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("5-field cron expression (minute hour day-of-month month day-of-week) in local time."),
|
||||
prompt: z.string().min(1).describe("The prompt to enqueue when the job fires."),
|
||||
recurring: z.boolean().optional().describe("True (default) to fire on every match; false to fire once then delete."),
|
||||
durable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("True to persist the job to disk so it survives a restart (default false = session-only)."),
|
||||
}),
|
||||
// Scheduling is reversible (cron_delete) and not a destructive filesystem op — no confirmation prompt.
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.cronStore) return { error: "Scheduling is not available in this context." };
|
||||
try {
|
||||
const job = ctx.cronStore.create(args);
|
||||
return { id: job.id, job };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const cronCreateSchema = z.object({
|
||||
cron: z.string().min(1),
|
||||
prompt: z.string().min(1),
|
||||
recurring: z.boolean().optional(),
|
||||
durable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const cronListTool: ToolDef<z.infer<typeof cronListSchema>> = {
|
||||
name: "cron_list",
|
||||
description: "List all scheduled cron jobs with their id, schedule, prompt, and whether they're recurring/durable.",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async (_args, ctx) => {
|
||||
return { jobs: ctx.cronStore?.list() ?? [] };
|
||||
},
|
||||
};
|
||||
|
||||
const cronListSchema = z.object({});
|
||||
|
||||
export const cronDeleteTool: ToolDef<z.infer<typeof cronDeleteSchema>> = {
|
||||
name: "cron_delete",
|
||||
description: "Cancel a scheduled cron job by id (from cron_list or cron_create's return). Returns { deleted: id } on success.",
|
||||
schema: z.object({ id: z.string().min(1) }),
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const store = ctx.cronStore;
|
||||
if (!store) return { error: "Scheduling is not available in this context." };
|
||||
return store.delete(args.id) ? { deleted: args.id } : { error: `Job ${args.id} not found.` };
|
||||
},
|
||||
};
|
||||
|
||||
const cronDeleteSchema = z.object({ id: z.string().min(1) });
|
||||
|
||||
export const scheduleWakeupTool: ToolDef<z.infer<typeof scheduleWakeupSchema>> = {
|
||||
name: "schedule_wakeup",
|
||||
description:
|
||||
"Schedule a one-shot prompt to fire after delaySeconds (60-3600), for self-paced loops that check back on external " +
|
||||
"state. Pass stop: true to cancel ALL pending wakeups and end the loop. The prompt fires once then is removed. " +
|
||||
"Only fires while the REPL is idle.",
|
||||
schema: z.object({
|
||||
delaySeconds: z.number().int().min(1),
|
||||
prompt: z.string().min(1),
|
||||
stop: z.boolean().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.cronStore) return { error: "Scheduling is not available in this context." };
|
||||
const result = ctx.cronStore.scheduleWakeup(args);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
const scheduleWakeupSchema = z.object({
|
||||
delaySeconds: z.number().int().min(1),
|
||||
prompt: z.string().min(1),
|
||||
stop: z.boolean().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
+11
-3
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -12,11 +13,11 @@ const schema = z.object({
|
||||
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
|
||||
});
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
export function countOccurrences(haystack: string, needle: string): number {
|
||||
return needle === "" ? 0 : haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
|
||||
export function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
|
||||
// Use split/join for both paths instead of String.prototype.replace, whose replacement string
|
||||
// interprets special $-tokens ($$, $&, $`, $', $<name>, $1–$9) even when the *pattern* is a plain
|
||||
// string — which would silently corrupt edits whose replacement text contains a literal "$".
|
||||
@@ -27,11 +28,14 @@ function applyEdit(original: string, oldString: string, newString: string, repla
|
||||
export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Replace exact text in a file. old_string must match exactly. Unless replace_all is set, it must be unique — include enough context.",
|
||||
"Replace exact text in a file. old_string must match exactly. Read the file first, then use enough " +
|
||||
"surrounding context in old_string to make it unique. Unless replace_all is set, duplicate matches are " +
|
||||
"rejected. Use this for small, targeted changes; prefer write_file for new files or full rewrites.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
let original: string;
|
||||
try {
|
||||
original = await fsReadFile(resolved, "utf-8");
|
||||
@@ -50,6 +54,7 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
},
|
||||
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
const original = await fsReadFile(resolved, "utf-8");
|
||||
const occurrences = countOccurrences(original, old_string);
|
||||
if (occurrences === 0) {
|
||||
@@ -75,6 +80,9 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
await fsUnlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (ctx.setLastEdit) {
|
||||
ctx.setLastEdit({ path: filePath, previousContent: original });
|
||||
}
|
||||
return { path: resolved, replacements: replace_all ? occurrences : 1 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
plan: z
|
||||
.string()
|
||||
.describe(
|
||||
"The full implementation plan in prose: the files you would change, the approach for each, and the key edits. " +
|
||||
"Be concrete and actionable so the user can review it at a glance.",
|
||||
),
|
||||
});
|
||||
|
||||
/** The structured way to exit plan mode: the model calls this once it has finished researching and
|
||||
* has a concrete plan, instead of presenting the plan as a final prose message. The handler shows
|
||||
* the plan to the user via the same Approve/Reject prompt the prose path uses (see maybePresentPlan
|
||||
* in agent/loop.ts); on approval plan mode ends and the model proceeds to implement in the same
|
||||
* turn, on rejection it stays in plan mode and can refine. The prose path remains as a fallback for
|
||||
* models that present a plan without calling this tool. Non-mutating: it changes permission mode, not
|
||||
* the filesystem, so it passes the plan-mode mutating-tool gate. Only callable in plan mode — the
|
||||
* ctx callback is withheld otherwise, so a call at the wrong time returns a clear error. */
|
||||
export const exitPlanModeTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "exit_plan_mode",
|
||||
description:
|
||||
"Exit plan mode by presenting your implementation plan for the user's approval. Call this once you've " +
|
||||
"finished researching and have a concrete plan (which files you'd change and how). On approval, plan mode " +
|
||||
"ends and you implement the plan in this same turn. On rejection, stay in plan mode, refine the plan (explore " +
|
||||
"more if needed), and call exit_plan_mode again. Only available in plan mode — don't call it otherwise.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.exitPlanMode) {
|
||||
return { error: "exit_plan_mode is only available while plan mode is active." };
|
||||
}
|
||||
const { approved } = await ctx.exitPlanMode(args.plan);
|
||||
if (approved) {
|
||||
return { result: "Plan approved. Plan mode is now off — proceed to implement the plan now." };
|
||||
}
|
||||
return {
|
||||
result:
|
||||
"The user rejected the plan. Stay in plan mode, refine it (explore more if needed), and call " +
|
||||
"exit_plan_mode again when ready. Do not call any mutating tool yet.",
|
||||
};
|
||||
},
|
||||
};
|
||||
+4
-2
@@ -29,7 +29,8 @@ const statusSchema = z.object({
|
||||
export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
|
||||
name: "git_status",
|
||||
description:
|
||||
"Inspect the git repo: `status`, `diff`, `log`, `show`, or `branches`. Read-only — no confirmation needed.",
|
||||
"Inspect the git repo: `status`, `diff`, `log`, `show`, or `branches`. Read-only — no confirmation needed. " +
|
||||
"Always check status/diff before mutating git_commit operations.",
|
||||
schema: statusSchema,
|
||||
mutating: false,
|
||||
handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => {
|
||||
@@ -109,7 +110,8 @@ export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
|
||||
name: "git_commit",
|
||||
description:
|
||||
"Git operations: `add`, `commit`, `create_branch`, `checkout`, `push`, `reset`, `stash`, `merge`, `rebase`, " +
|
||||
"`delete_branch`. Mutating operations require user confirmation with a preview.",
|
||||
"`delete_branch`. Mutating operations require user confirmation with a preview. For commit, run git_status " +
|
||||
"or git_commit add first, then provide a clear, concise message.",
|
||||
schema: commitSchema,
|
||||
mutating: true,
|
||||
preview: async (args, ctx) => buildPreview(args, ctx.cwd),
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ const schema = z.object({
|
||||
|
||||
export const grepTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "grep",
|
||||
description: "Search file contents for a regular expression pattern using ripgrep.",
|
||||
description:
|
||||
"Search file contents for a regular expression pattern using ripgrep. This is the best first step " +
|
||||
"when exploring a codebase: use it to find where a symbol, function, or pattern is used, then " +
|
||||
"read only the relevant files. If results are too broad, refine with `path` or `glob`.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ pattern, path: searchPath, glob, case_insensitive, max_results }, ctx) => {
|
||||
|
||||
+30
-2
@@ -1,15 +1,25 @@
|
||||
import { agentTool } from "./agentTool.js";
|
||||
import { askQuestionTool } from "./askQuestion.js";
|
||||
import { bashTool } from "./bash.js";
|
||||
import { bashKillTool } from "./bashKill.js";
|
||||
import { bashOutputTool } from "./bashOutput.js";
|
||||
import { cronCreateTool, cronDeleteTool, cronListTool, scheduleWakeupTool } from "./cron.js";
|
||||
import { editFileTool } from "./editFile.js";
|
||||
import { exitPlanModeTool } from "./exitPlanMode.js";
|
||||
import { gitCommitTool, gitStatusTool } from "./git.js";
|
||||
import { grepTool } from "./grep.js";
|
||||
import { listFilesTool } from "./listFiles.js";
|
||||
import { memoryTool, memoryWriteTool } from "./memory.js";
|
||||
import { multiEditTool } from "./multiEdit.js";
|
||||
import { notebookEditTool } from "./notebookEdit.js";
|
||||
import { readFileTool } from "./readFile.js";
|
||||
import { todoWriteTool } from "./todoWrite.js";
|
||||
import { sendMessageTool } from "./sendMessage.js";
|
||||
import { listTeammatesTool } from "./teammates.js";
|
||||
import { taskCreateTool, taskGetTool, taskListTool, taskUpdateTool } from "./task.js";
|
||||
import { webFetchTool } from "./webFetch.js";
|
||||
import { workflowTool } from "./workflow.js";
|
||||
import { webSearchTool } from "./webSearch.js";
|
||||
import { enterWorktreeTool, exitWorktreeTool } from "./worktreeSession.js";
|
||||
import { writeFileTool } from "./writeFile.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
@@ -22,12 +32,30 @@ export const TOOLS: ToolDef[] = [
|
||||
gitStatusTool,
|
||||
writeFileTool,
|
||||
editFileTool,
|
||||
multiEditTool,
|
||||
notebookEditTool,
|
||||
bashTool,
|
||||
bashOutputTool,
|
||||
bashKillTool,
|
||||
gitCommitTool,
|
||||
todoWriteTool,
|
||||
taskCreateTool,
|
||||
taskListTool,
|
||||
taskGetTool,
|
||||
taskUpdateTool,
|
||||
memoryTool,
|
||||
memoryWriteTool,
|
||||
agentTool,
|
||||
sendMessageTool,
|
||||
listTeammatesTool,
|
||||
workflowTool,
|
||||
exitPlanModeTool,
|
||||
askQuestionTool,
|
||||
cronCreateTool,
|
||||
cronListTool,
|
||||
cronDeleteTool,
|
||||
scheduleWakeupTool,
|
||||
enterWorktreeTool,
|
||||
exitWorktreeTool,
|
||||
];
|
||||
|
||||
export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
|
||||
@@ -17,7 +17,10 @@ const MAX_MATCHES = 500;
|
||||
|
||||
export const listFilesTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "list_files",
|
||||
description: "List files matching a glob pattern.",
|
||||
description:
|
||||
"List files matching a glob pattern. Use this to understand directory structure or find files " +
|
||||
"by name. For searching file contents, use grep instead. Large result sets are truncated; narrow " +
|
||||
"the pattern if you get too many matches.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ pattern, cwd }, ctx) => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { _setConfigFilePathForTest } from "../config/store.js";
|
||||
import { userMemoryDir, userMemoryIndexPath } from "../utils/userMemory.js";
|
||||
import { memoryTool, memoryWriteTool } from "./memory.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
let tempDir: string;
|
||||
const noCtx = {} as ToolContext;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(os.tmpdir(), "locode-memory-tool-"));
|
||||
_setConfigFilePathForTest(path.join(tempDir, "config.json"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_setConfigFilePathForTest(undefined);
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("memory tool (read)", () => {
|
||||
it("reports empty memory when nothing is saved", async () => {
|
||||
const result = await memoryTool.handler({}, noCtx);
|
||||
expect(result).toEqual({ content: "(memory is empty)", count: 0 });
|
||||
});
|
||||
|
||||
it("reads a specific fact by name", async () => {
|
||||
await memoryWriteTool.handler(
|
||||
{ action: "write", name: "prefers-concise", description: "short answers", type: "user", content: "Be brief." },
|
||||
noCtx,
|
||||
);
|
||||
const result = (await memoryTool.handler({ name: "prefers-concise" }, noCtx)) as { content: string };
|
||||
expect(result.content).toContain("Be brief.");
|
||||
expect(result.content).toContain("name: prefers-concise");
|
||||
});
|
||||
|
||||
it("returns a not-found message for an unknown name", async () => {
|
||||
const result = (await memoryTool.handler({ name: "nope" }, noCtx)) as { content: string };
|
||||
expect(result.content).toContain("No memory fact named 'nope'");
|
||||
});
|
||||
|
||||
it("lists all facts with no argument", async () => {
|
||||
await memoryWriteTool.handler({ action: "write", name: "a-fact", description: "a", type: "user", content: "aa" }, noCtx);
|
||||
await memoryWriteTool.handler({ action: "write", name: "b-fact", description: "b", type: "project", content: "bb" }, noCtx);
|
||||
const result = (await memoryTool.handler({}, noCtx)) as { content: string; count: number };
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.content).toContain("aa");
|
||||
expect(result.content).toContain("bb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory_write tool (write/delete)", () => {
|
||||
it("creates a typed fact file + index on write", async () => {
|
||||
const result = await memoryWriteTool.handler(
|
||||
{ action: "write", name: "react-stack", description: "uses react", type: "project", content: "Stack is React + vitest." },
|
||||
noCtx,
|
||||
);
|
||||
expect(result).toEqual({ written: "react-stack", type: "project", indexUpdated: true });
|
||||
expect(existsSync(path.join(userMemoryDir(), "react-stack.md"))).toBe(true);
|
||||
expect(readFileSync(userMemoryIndexPath(), "utf-8")).toContain("react-stack.md");
|
||||
});
|
||||
|
||||
it("overwrites an existing fact on write", async () => {
|
||||
await memoryWriteTool.handler({ action: "write", name: "flip", description: "old", type: "user", content: "old body" }, noCtx);
|
||||
await memoryWriteTool.handler({ action: "write", name: "flip", description: "new", type: "reference", content: "new body" }, noCtx);
|
||||
const file = readFileSync(path.join(userMemoryDir(), "flip.md"), "utf-8");
|
||||
expect(file).toContain("new body");
|
||||
expect(file).toContain("description: new");
|
||||
// Index has a single line for flip.
|
||||
expect(readFileSync(userMemoryIndexPath(), "utf-8").match(/flip\.md/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a write missing required fields", async () => {
|
||||
const result = await memoryWriteTool.handler({ action: "write", name: "x", content: "y" }, noCtx);
|
||||
expect(result).toEqual({ error: "write requires non-empty 'description', 'type', and 'content'." });
|
||||
});
|
||||
|
||||
it("deletes an existing fact and its index line", async () => {
|
||||
await memoryWriteTool.handler({ action: "write", name: "gone", description: "x", type: "user", content: "yy" }, noCtx);
|
||||
const result = await memoryWriteTool.handler({ action: "delete", name: "gone" }, noCtx);
|
||||
expect(result).toEqual({ deleted: "gone" });
|
||||
expect(existsSync(path.join(userMemoryDir(), "gone.md"))).toBe(false);
|
||||
expect(readFileSync(userMemoryIndexPath(), "utf-8")).not.toContain("gone");
|
||||
});
|
||||
|
||||
it("reports an error deleting a missing fact", async () => {
|
||||
const result = await memoryWriteTool.handler({ action: "delete", name: "nope" }, noCtx);
|
||||
expect(result).toEqual({ error: "No memory fact named 'nope' to delete." });
|
||||
});
|
||||
|
||||
it("produces a diff preview for a write", async () => {
|
||||
const preview = await memoryWriteTool.preview!(
|
||||
{ action: "write", name: "fresh", description: "d", type: "user", content: "body text" },
|
||||
noCtx,
|
||||
);
|
||||
expect(preview).toContain("Create memory fact fresh.md");
|
||||
expect(preview).toContain("body text");
|
||||
});
|
||||
|
||||
it("produces a diff preview when overwriting an existing fact", async () => {
|
||||
await memoryWriteTool.handler({ action: "write", name: "p", description: "d", type: "user", content: "old" }, noCtx);
|
||||
const preview = await memoryWriteTool.preview!(
|
||||
{ action: "write", name: "p", description: "d", type: "user", content: "new" },
|
||||
noCtx,
|
||||
);
|
||||
expect(preview).toContain("@@");
|
||||
expect(preview).toContain("+new");
|
||||
expect(preview).toContain("-old");
|
||||
});
|
||||
|
||||
it("refreshes the session user-memory cache via setUserMemory after a write", async () => {
|
||||
const setUserMemory = vi.fn();
|
||||
const ctx = { setUserMemory } as unknown as ToolContext;
|
||||
await memoryWriteTool.handler({ action: "write", name: "cached", description: "d", type: "user", content: "remember this" }, ctx);
|
||||
expect(setUserMemory).toHaveBeenCalledOnce();
|
||||
// The refreshed cache is the bounded index form loadUserMemory produces, carrying the new line.
|
||||
const arg = setUserMemory.mock.calls[0]![0] as string | null;
|
||||
expect(arg).toContain("Personal memory index");
|
||||
expect(arg).toContain("cached");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createPatch } from "diff";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
deleteMemoryEntry,
|
||||
listMemoryEntries,
|
||||
loadUserMemory,
|
||||
MEMORY_TYPES,
|
||||
readMemoryEntry,
|
||||
writeMemoryEntry,
|
||||
type MemoryType,
|
||||
} from "../utils/userMemory.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
// The memory files live in the user's locode config dir (see utils/userMemory.ts), NOT under the
|
||||
// project workspace. So unlike write_file/edit_file these tools do NOT call assertWithinWorkspace —
|
||||
// they always operate on files under the fixed `memory/` dir resolved via userMemoryDir() (test-
|
||||
// override-aware), and the only user-supplied identifier is a kebab-case `name` validated against
|
||||
// [a-z0-9-]+, so there's no traversal surface.
|
||||
//
|
||||
// Split into two tools because ToolDef.mutating is a static per-tool flag (the confirmation/plan-mode
|
||||
// gate keys off it): `memory` is a read-only no-prompt tool usable even in plan mode, while
|
||||
// `memory_write` mutates the user-level files and goes through the normal confirm gate.
|
||||
|
||||
async function readIndexOrLegacy(): Promise<string> {
|
||||
const loaded = await loadUserMemory();
|
||||
return loaded ?? "(memory is empty)";
|
||||
}
|
||||
|
||||
export const memoryTool: ToolDef<{ name?: string }> = {
|
||||
name: "memory",
|
||||
description:
|
||||
"Read the user's personal memory (the typed per-fact files in the locode config dir's memory/ directory). " +
|
||||
"With no arguments, lists every saved fact (name, type, description, and full body). With a 'name', reads just " +
|
||||
"that one fact. The lightweight index is already in your system prompt each turn — use this tool to pull a " +
|
||||
"fact's full body when the index hook tells you it's relevant. Read-only — use memory_write to save or delete.",
|
||||
schema: z.object({
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The slug name of a specific fact to read. Omit to list all saved facts."),
|
||||
}),
|
||||
mutating: false,
|
||||
handler: async (args) => {
|
||||
if (args.name) {
|
||||
const entry = await readMemoryEntry(args.name);
|
||||
if (!entry) return { content: `No memory fact named '${args.name}'.` };
|
||||
return { content: `---\nname: ${entry.name}\ndescription: ${entry.description}\ntype: ${entry.type}\n---\n\n${entry.body}` };
|
||||
}
|
||||
const entries = await listMemoryEntries();
|
||||
if (entries.length === 0) {
|
||||
// No typed facts — surface the legacy freeform file if one exists so the model isn't blind to it.
|
||||
const legacy = await readIndexOrLegacy();
|
||||
return { content: legacy, count: 0 };
|
||||
}
|
||||
const rendered = entries.map((e) => `${e.name} (${e.type}): ${e.description}\n${e.body}`).join("\n\n---\n\n");
|
||||
return { content: rendered, count: entries.length };
|
||||
},
|
||||
};
|
||||
|
||||
const writeSchema = z.object({
|
||||
action: z
|
||||
.enum(["write", "delete"])
|
||||
.describe("write: create or overwrite a typed memory fact; delete: remove one."),
|
||||
name: z
|
||||
.string()
|
||||
.describe("The fact's slug (kebab-case, [a-z0-9-]+). Used as the filename and the frontmatter 'name'."),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("One-line summary used as the recall hook in the always-in-prompt index. Required for action='write'."),
|
||||
type: z
|
||||
.enum(MEMORY_TYPES as [MemoryType, ...MemoryType[]])
|
||||
.optional()
|
||||
.describe("Fact category: user (who the user is), feedback (working-style guidance), project (ongoing work), reference (external pointers). Required for action='write'."),
|
||||
content: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The fact body. Required for action='write'. Keep it a concise, self-contained fact; for feedback/project include a 'Why:' and 'How to apply:' line."),
|
||||
});
|
||||
|
||||
export const memoryWriteTool: ToolDef<z.infer<typeof writeSchema>> = {
|
||||
name: "memory_write",
|
||||
description:
|
||||
"Write or delete a typed personal memory fact (file under the locode config dir's memory/ directory). Each fact " +
|
||||
"is one file with frontmatter (name/description/type) + a body; a one-line index is folded into every future " +
|
||||
"session's system prompt so you can recall it. Use 'write' to save a durable fact worth remembering across " +
|
||||
"sessions (a stated preference, a correction, a project convention) and 'delete' to remove one. Do not use this " +
|
||||
"for transient per-task notes.",
|
||||
schema: writeSchema,
|
||||
mutating: true,
|
||||
preview: async (args) => {
|
||||
if (args.action === "delete") {
|
||||
const existing = await readMemoryEntry(args.name);
|
||||
if (!existing) return `No memory fact named '${args.name}' — nothing to delete.`;
|
||||
return createPatch(`${args.name}.md`, serializeForPreview(existing), "", "", "");
|
||||
}
|
||||
if (!args.description || !args.type || !args.content) {
|
||||
return "write requires 'name', 'description', 'type', and 'content'.";
|
||||
}
|
||||
const existing = await readMemoryEntry(args.name);
|
||||
const next = serializeForPreview({ name: args.name, description: args.description, type: args.type, body: args.content });
|
||||
if (!existing) return `Create memory fact ${args.name}.md:\n${next}`;
|
||||
return createPatch(`${args.name}.md`, serializeForPreview(existing), next, "", "");
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
if (args.action === "delete") {
|
||||
const removed = await deleteMemoryEntry(args.name);
|
||||
if (!removed) return { error: `No memory fact named '${args.name}' to delete.` };
|
||||
if (ctx.setUserMemory) ctx.setUserMemory(await loadUserMemory());
|
||||
return { deleted: args.name };
|
||||
}
|
||||
// write
|
||||
if (!args.description || !args.type || !args.content) {
|
||||
return { error: "write requires non-empty 'description', 'type', and 'content'." };
|
||||
}
|
||||
const content = args.content.trim();
|
||||
if (!content) return { error: "'content' must not be empty." };
|
||||
const entry = await writeMemoryEntry({
|
||||
name: args.name,
|
||||
description: args.description.trim(),
|
||||
type: args.type,
|
||||
body: content,
|
||||
});
|
||||
if (ctx.setUserMemory) ctx.setUserMemory(await loadUserMemory());
|
||||
return { written: entry.name, type: entry.type, indexUpdated: true };
|
||||
},
|
||||
};
|
||||
|
||||
function serializeForPreview(entry: { name: string; description: string; type: MemoryType; body: string }): string {
|
||||
return `---\nname: ${entry.name}\ndescription: ${entry.description}\ntype: ${entry.type}\n---\n\n${entry.body.trim()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { multiEditTool } from "./multiEdit.js";
|
||||
|
||||
async function makeCwd(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "locode-multiedit-"));
|
||||
}
|
||||
|
||||
describe("multiEditTool", () => {
|
||||
it("rejects paths that escape the working directory", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
await expect(
|
||||
multiEditTool.handler({ path: "../outside.txt", edits: [{ old_string: "a", new_string: "b" }] }, { cwd }),
|
||||
).rejects.toThrow("Path resolves outside the working directory");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies several edits to one file in order, as a single atomic write", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
await writeFile(file, "alpha\nbeta\ngamma\n");
|
||||
const result = (await multiEditTool.handler(
|
||||
{
|
||||
path: "src.txt",
|
||||
edits: [
|
||||
{ old_string: "alpha", new_string: "ALPHA" },
|
||||
{ old_string: "beta", new_string: "BETA" },
|
||||
{ old_string: "gamma", new_string: "GAMMA" },
|
||||
],
|
||||
},
|
||||
{ cwd },
|
||||
)) as { path: string; applied: number };
|
||||
expect(result.applied).toBe(3);
|
||||
expect(await readFile(file, "utf-8")).toBe("ALPHA\nBETA\nGAMMA\n");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies later edits to the result of earlier ones", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
await writeFile(file, "foo\n");
|
||||
// First edit renames the line; second edit matches the renamed text.
|
||||
await multiEditTool.handler(
|
||||
{
|
||||
path: "src.txt",
|
||||
edits: [
|
||||
{ old_string: "foo", new_string: "bar" },
|
||||
{ old_string: "bar", new_string: "baz" },
|
||||
],
|
||||
},
|
||||
{ cwd },
|
||||
);
|
||||
expect(await readFile(file, "utf-8")).toBe("baz\n");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails on the first non-unique match and writes nothing", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
const original = "dup\ndup\nunique\n";
|
||||
await writeFile(file, original);
|
||||
await expect(
|
||||
multiEditTool.handler(
|
||||
{
|
||||
path: "src.txt",
|
||||
edits: [
|
||||
{ old_string: "dup", new_string: "x" }, // ambiguous, no replace_all
|
||||
{ old_string: "unique", new_string: "UNIQUE" },
|
||||
],
|
||||
},
|
||||
{ cwd },
|
||||
),
|
||||
).rejects.toThrow(/appears 2 times/);
|
||||
// The failed batch must not have written anything — the file is unchanged.
|
||||
expect(await readFile(file, "utf-8")).toBe(original);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects replace_all within a batch edit", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
await writeFile(file, "dup\ndup\n");
|
||||
await multiEditTool.handler(
|
||||
{ path: "src.txt", edits: [{ old_string: "dup", new_string: "x", replace_all: true }] },
|
||||
{ cwd },
|
||||
);
|
||||
expect(await readFile(file, "utf-8")).toBe("x\nx\n");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("produces a diff preview spanning all edits", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
await writeFile(file, "a\nb\n");
|
||||
const preview = await multiEditTool.preview!(
|
||||
{ path: "src.txt", edits: [{ old_string: "a", new_string: "A" }, { old_string: "b", new_string: "B" }] },
|
||||
{ cwd },
|
||||
);
|
||||
expect(preview).toContain("@@");
|
||||
expect(preview).toContain("+A");
|
||||
expect(preview).toContain("+B");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records the pre-edit content for /undo via setLastEdit", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "src.txt");
|
||||
const original = "a\nb\n";
|
||||
await writeFile(file, original);
|
||||
let captured: { path: string; previousContent: string } | undefined;
|
||||
await multiEditTool.handler(
|
||||
{ path: "src.txt", edits: [{ old_string: "a", new_string: "A" }] },
|
||||
{ cwd, setLastEdit: (e: { path: string; previousContent: string }) => (captured = e) } as any,
|
||||
);
|
||||
expect(captured).toEqual({ path: "src.txt", previousContent: original });
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createPatch } from "diff";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import { applyEdit, countOccurrences } from "./editFile.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
// A single edit within a multi_edit batch. Mirrors edit_file's args minus `path` (which is shared
|
||||
// across the whole batch). Each edit is applied in array order to the result of the previous one.
|
||||
const editSchema = z.object({
|
||||
old_string: z.string().describe("Exact text to replace. Must match the current file content exactly at this point in the batch."),
|
||||
new_string: z.string().describe("Replacement text."),
|
||||
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
path: z.string().describe("File path to edit, relative to the working directory or absolute."),
|
||||
edits: z.array(editSchema).min(1).describe("Ordered list of edits to apply to the same file, one after another."),
|
||||
});
|
||||
|
||||
/** Applies a batch of edits to an in-memory string, validating each. Throws on the first edit that
|
||||
* doesn't match uniquely (unless its replace_all is set) or doesn't match at all. Edits apply to the
|
||||
* running result, so an earlier edit can change the text a later edit matches. */
|
||||
function applyBatch(original: string, edits: { old_string: string; new_string: string; replace_all?: boolean }[], filePath: string): string {
|
||||
let current = original;
|
||||
edits.forEach((edit, i) => {
|
||||
const occurrences = countOccurrences(current, edit.old_string);
|
||||
if (occurrences === 0) {
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: old_string not found in ${filePath}. Earlier edits may have shifted the text — re-read the file and adjust. Make sure it matches exactly, including whitespace.`,
|
||||
);
|
||||
}
|
||||
if (occurrences > 1 && !edit.replace_all) {
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
|
||||
);
|
||||
}
|
||||
current = applyEdit(current, edit.old_string, edit.new_string, edit.replace_all);
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
export const multiEditTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "multi_edit",
|
||||
description:
|
||||
"Apply several edits to the same file in one call, in order. Each edit is {old_string, new_string, replace_all?}. " +
|
||||
"Use this instead of repeated edit_file calls when you have multiple distinct changes to one file — it's one confirmation " +
|
||||
"and one atomic write. Each old_string must match uniquely at its point in the batch (unless replace_all is set). Read the file first.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, edits }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
let original: string;
|
||||
try {
|
||||
original = await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return `File ${resolved} does not exist.`;
|
||||
}
|
||||
try {
|
||||
const updated = applyBatch(original, edits, filePath);
|
||||
return createPatch(resolved, original, updated, "", "");
|
||||
} catch (err) {
|
||||
return `Warning: ${(err as Error).message} — this edit will fail.`;
|
||||
}
|
||||
},
|
||||
handler: async ({ path: filePath, edits }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
const original = await fsReadFile(resolved, "utf-8");
|
||||
const updated = applyBatch(original, edits, filePath);
|
||||
// Atomic write via temp+rename (same rationale as edit_file): a crash mid-write can't leave the
|
||||
// user's source file half-overwritten — the live file stays intact until the rename swaps in the
|
||||
// full new content. Clean up the temp file if anything fails.
|
||||
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
await fsWriteFile(tmp, updated, "utf-8");
|
||||
await fsRename(tmp, resolved);
|
||||
} catch (err) {
|
||||
await fsUnlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (ctx.setLastEdit) {
|
||||
ctx.setLastEdit({ path: filePath, previousContent: original });
|
||||
}
|
||||
return { path: resolved, applied: edits.length };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { notebookEditTool } from "./notebookEdit.js";
|
||||
|
||||
/** A minimal valid nbformat 4 notebook with two code cells. */
|
||||
function minimalNotebook(): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
metadata: {},
|
||||
cells: [
|
||||
{ cell_type: "code", id: "c1", source: ["print('a')\n"], metadata: {}, outputs: [], execution_count: null },
|
||||
{ cell_type: "code", id: "c2", source: ["print('b')\n"], metadata: {}, outputs: [], execution_count: null },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n";
|
||||
}
|
||||
|
||||
async function makeCwd(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "locode-notebook-"));
|
||||
}
|
||||
|
||||
function parseCells(content: string): { cell_type: string; id?: string; source: string[] }[] {
|
||||
return (JSON.parse(content) as { cells: { cell_type: string; id?: string; source: string[] }[] }).cells;
|
||||
}
|
||||
|
||||
describe("notebookEditTool", () => {
|
||||
it("rejects paths that escape the working directory", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
await expect(
|
||||
notebookEditTool.handler(
|
||||
{ notebook_path: "../outside.ipynb", edit_mode: "delete" },
|
||||
{ cwd },
|
||||
),
|
||||
).rejects.toThrow("Path resolves outside the working directory");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a cell source by cell_index", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells[0]!.source).toEqual(["print('A')\n"]);
|
||||
expect(cells).toHaveLength(2);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a cell source by cell_id", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_id: "c2", edit_mode: "replace", new_source: "print('B2')" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells[1]!.source).toEqual(["print('B2')"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts a new markdown cell at a position, shifting later cells down", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_index: 1, cell_type: "markdown", new_source: "# heading\n\ntext" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(cells[1]!.cell_type).toBe("markdown");
|
||||
expect(cells[1]!.source).toEqual(["# heading\n", "\n", "text"]);
|
||||
// Original second cell (c2) is now at index 2.
|
||||
expect(cells[2]!.id).toBe("c2");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends a cell when insert omits cell_index", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_type: "code", new_source: "x = 1" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(cells[2]!.source).toEqual(["x = 1"]);
|
||||
expect(cells[2]!.cell_type).toBe("code");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes a cell by cell_id and writes nothing if not found", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
const original = minimalNotebook();
|
||||
await writeFile(file, original);
|
||||
await notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "c1", edit_mode: "delete" }, { cwd });
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells[0]!.id).toBe("c2");
|
||||
|
||||
// A missing id fails and leaves the file unchanged.
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "nope", edit_mode: "delete" }, { cwd }),
|
||||
).rejects.toThrow(/not found/);
|
||||
expect(await readFile(file, "utf-8")).not.toBe(original);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects insert without cell_type, and replace without new_source", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", edit_mode: "insert", new_source: "x" }, { cwd }),
|
||||
).rejects.toThrow(/cell_type/);
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace" }, { cwd }),
|
||||
).rejects.toThrow(/new_source/);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("produces a diff preview for an edit", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
const preview = await notebookEditTool.preview!(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
|
||||
{ cwd },
|
||||
);
|
||||
expect(preview).toContain("@@");
|
||||
// The diff is over the notebook JSON, so the changed source appears JSON-quoted/indented
|
||||
// on +/- lines rather than as bare text — assert on the content, not the leading marker.
|
||||
expect(preview).toContain("print('A')");
|
||||
expect(preview).toContain("print('a')");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records pre-edit content for /undo via setLastEdit", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
const original = minimalNotebook();
|
||||
await writeFile(file, original);
|
||||
let captured: { path: string; previousContent: string } | undefined;
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 1, edit_mode: "delete" },
|
||||
{ cwd, setLastEdit: (e: { path: string; previousContent: string }) => (captured = e) } as any,
|
||||
);
|
||||
expect(captured).toEqual({ path: "nb.ipynb", previousContent: original });
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createPatch } from "diff";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
// .ipynb is a JSON document (nbformat 4): { nbformat, nbformat_minor, metadata, cells: Cell[] }.
|
||||
// Each cell is { cell_type: "code"|"markdown"|"raw", id?, source, metadata, outputs?, execution_count? }.
|
||||
// `source` is a list of strings where every line except the last carries a trailing "\n" (nbformat
|
||||
// convention). We convert the model's single-string new_source to/from that array form.
|
||||
|
||||
type Notebook = { nbformat: number; nbformat_minor: number; metadata: Record<string, unknown>; cells: Cell[] };
|
||||
type Cell = { cell_type: string; id?: string; source: string[]; metadata: Record<string, unknown>; outputs?: unknown[]; execution_count?: unknown };
|
||||
|
||||
const schema = z.object({
|
||||
notebook_path: z.string().describe("Path to the .ipynb notebook to edit, relative to the working directory or absolute."),
|
||||
cell_id: z.string().optional().describe("The id of the cell to replace or delete. Ignored for insert."),
|
||||
cell_index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("0-based index of the cell to replace or delete; for insert, the position to insert at (defaults to append)."),
|
||||
cell_type: z.enum(["code", "markdown", "raw"]).optional().describe("Required for insert. For replace, overrides the existing cell's type if given."),
|
||||
edit_mode: z.enum(["replace", "insert", "delete"]).default("replace").describe("Whether to replace a cell, insert a new one, or delete."),
|
||||
new_source: z.string().optional().describe("The new cell source as a single string (required for replace and insert)."),
|
||||
});
|
||||
|
||||
/** Converts a plain multi-line string into nbformat's source array: each line carries a trailing
|
||||
* "\n" except the last, and a trailing newline in the input is preserved (so "a\n" => ["a\n"], not
|
||||
* ["a\n", ""]). An empty source becomes an empty array. Round-trips: array.join("") === input. */
|
||||
function toSourceArray(source: string): string[] {
|
||||
if (!source) return [];
|
||||
let lines = source.split("\n");
|
||||
// split("a\n") => ["a", ""] — the trailing "" is an artifact of the trailing newline, not a real
|
||||
// empty last line. Drop it and remember the input ended with \n so the now-last line keeps its \n.
|
||||
const endedWithNewline = lines.length > 1 && lines[lines.length - 1] === "";
|
||||
if (endedWithNewline) lines = lines.slice(0, -1);
|
||||
return lines.map((line, i) => (i < lines.length - 1 || endedWithNewline ? line + "\n" : line));
|
||||
}
|
||||
|
||||
/** Finds a cell's index by id (if present) falling back to the explicit index. Returns -1 if not
|
||||
* found. Used by replace/delete. */
|
||||
function findCellIndex(notebook: Notebook, cellId: string | undefined, cellIndex: number | undefined): number {
|
||||
if (cellId !== undefined) {
|
||||
return notebook.cells.findIndex((c) => c.id === cellId);
|
||||
}
|
||||
if (cellIndex !== undefined) {
|
||||
return cellIndex >= 0 && cellIndex < notebook.cells.length ? cellIndex : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function applyNotebookEdit(notebook: Notebook, args: z.infer<typeof schema>, notebookPath: string): Notebook {
|
||||
const mode = args.edit_mode ?? "replace";
|
||||
if (mode === "insert") {
|
||||
if (!args.cell_type) throw new Error("insert requires 'cell_type'.");
|
||||
if (args.new_source === undefined) throw new Error("insert requires 'new_source'.");
|
||||
const cell: Cell = {
|
||||
cell_type: args.cell_type,
|
||||
source: toSourceArray(args.new_source),
|
||||
metadata: {},
|
||||
};
|
||||
if (args.cell_type === "code") {
|
||||
cell.execution_count = null;
|
||||
cell.outputs = [];
|
||||
}
|
||||
const insertAt = args.cell_index ?? notebook.cells.length;
|
||||
if (insertAt < 0 || insertAt > notebook.cells.length) {
|
||||
throw new Error(`insert cell_index ${insertAt} is out of range (0–${notebook.cells.length}).`);
|
||||
}
|
||||
notebook.cells.splice(insertAt, 0, cell);
|
||||
return notebook;
|
||||
}
|
||||
|
||||
const idx = findCellIndex(notebook, args.cell_id, args.cell_index);
|
||||
if (idx === -1) {
|
||||
const where = args.cell_id !== undefined ? `cell_id "${args.cell_id}"` : `cell_index ${args.cell_index}`;
|
||||
throw new Error(`${mode}: ${where} not found in ${notebookPath}.`);
|
||||
}
|
||||
if (mode === "delete") {
|
||||
notebook.cells.splice(idx, 1);
|
||||
return notebook;
|
||||
}
|
||||
// replace
|
||||
if (args.new_source === undefined) throw new Error("replace requires 'new_source'.");
|
||||
const cell = notebook.cells[idx]!;
|
||||
if (args.cell_type) cell.cell_type = args.cell_type;
|
||||
cell.source = toSourceArray(args.new_source);
|
||||
// Switching to a non-code cell type drops code-only fields; switching to code adds them.
|
||||
if (cell.cell_type === "code") {
|
||||
cell.execution_count ??= null;
|
||||
cell.outputs ??= [];
|
||||
} else {
|
||||
delete cell.execution_count;
|
||||
delete cell.outputs;
|
||||
}
|
||||
return notebook;
|
||||
}
|
||||
|
||||
export const notebookEditTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "notebook_edit",
|
||||
description:
|
||||
"Edit a Jupyter (.ipynb) notebook cell-aware: replace, insert, or delete a cell by cell_id or cell_index. " +
|
||||
"new_source is the full new cell source as a single string. Read the notebook first (read_file shows the JSON). " +
|
||||
"Prefer this over edit_file/write_file for .ipynb so the JSON structure stays valid.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async (args, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, args.notebook_path);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, args.notebook_path);
|
||||
let original: string;
|
||||
try {
|
||||
original = await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return `Notebook ${resolved} does not exist.`;
|
||||
}
|
||||
let notebook: Notebook;
|
||||
try {
|
||||
notebook = JSON.parse(original) as Notebook;
|
||||
} catch {
|
||||
return `Warning: ${resolved} is not valid JSON — this edit will fail.`;
|
||||
}
|
||||
try {
|
||||
const updated = applyNotebookEdit(structuredClone(notebook), args, args.notebook_path);
|
||||
return createPatch(resolved, original, JSON.stringify(updated, null, 2) + "\n", "", "");
|
||||
} catch (err) {
|
||||
return `Warning: ${(err as Error).message} — this edit will fail.`;
|
||||
}
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, args.notebook_path);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, args.notebook_path);
|
||||
const original = await fsReadFile(resolved, "utf-8");
|
||||
let notebook: Notebook;
|
||||
try {
|
||||
notebook = JSON.parse(original) as Notebook;
|
||||
} catch {
|
||||
throw new Error(`${resolved} is not valid JSON — can't edit as a notebook.`);
|
||||
}
|
||||
if (!Array.isArray(notebook.cells)) throw new Error(`${resolved} has no cells array — not a valid .ipynb.`);
|
||||
applyNotebookEdit(notebook, args, args.notebook_path);
|
||||
const updated = JSON.stringify(notebook, null, 2) + "\n";
|
||||
// Atomic write via temp+rename (same rationale as edit_file/multi_edit).
|
||||
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
await fsWriteFile(tmp, updated, "utf-8");
|
||||
await fsRename(tmp, resolved);
|
||||
} catch (err) {
|
||||
await fsUnlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (ctx.setLastEdit) {
|
||||
ctx.setLastEdit({ path: args.notebook_path, previousContent: original });
|
||||
}
|
||||
return { path: resolved, edit_mode: args.edit_mode ?? "replace", cell_count: notebook.cells.length };
|
||||
},
|
||||
};
|
||||
@@ -2,13 +2,9 @@ import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { imageMimeType, MAX_IMAGE_BYTES } from "../utils/image.js";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
function withinWorkspace(resolved: string, workspace: string): boolean {
|
||||
const rel = path.relative(workspace, resolved);
|
||||
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// Cap on how much text a single read_file call returns, so a huge file can't blow up the context
|
||||
// in one call. Cut on a line boundary (never mid-line) and report the exact next offset, so the
|
||||
// model can page through the rest with `offset` instead of re-reading the same truncated prefix in
|
||||
@@ -26,14 +22,14 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a local file. Text files return 1-indexed lines; large files are paginated (use nextOffset for next page). " +
|
||||
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model).",
|
||||
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model). " +
|
||||
"When you need to inspect many files, use grep first to find the relevant ones and only read_file the files or page ranges you actually need — " +
|
||||
"the session has a per-turn tool-call budget, and unnecessary full-file reads burn through it quickly.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ path: filePath, offset, limit }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
if (!withinWorkspace(resolved, ctx.cwd)) {
|
||||
throw new Error(`File ${filePath} resolves outside the workspace.`);
|
||||
}
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
|
||||
const mimeType = imageMimeType(resolved);
|
||||
if (mimeType) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
agentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The agentId returned by a prior 'agent'/'agent__*' call. Only resumable agents (single, " +
|
||||
"shared-cwd delegations) return an agentId — parallel (worktree-isolated) agents do not. " +
|
||||
"Provide this OR `name`.",
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The name of a teammate you created by passing `name` to a prior `agent` call. Addressing a " +
|
||||
"teammate by name avoids tracking its agentId. Provide this OR `agentId`. Use list_teammates " +
|
||||
"to see your named teammates.",
|
||||
),
|
||||
message: z
|
||||
.string()
|
||||
.describe("The follow-up instruction for the sub-agent. It retains the context of the original delegation."),
|
||||
})
|
||||
.refine((d) => d.agentId || d.name, {
|
||||
message: "Provide either an `agentId` (returned by a prior agent call) or a `name` (of a named teammate).",
|
||||
});
|
||||
|
||||
/** Continues a previously-spawned resumable sub-agent with a follow-up message, preserving its
|
||||
* context — the cheaper alternative to re-delegating from scratch when a sub-agent's first answer
|
||||
* was close but needs a correction, or when it ran out of budget mid-task. Only sub-agents that ran
|
||||
* in the shared cwd (single/sequential delegations) are resumable and return an agentId; parallel
|
||||
* worktree-isolated agents are fire-and-forget. The target may be identified by its agentId OR, if
|
||||
* it was spawned with a `name`, by that name. Read-only: it has no filesystem side effects beyond
|
||||
* what the continued sub-agent itself does (and those still go through the normal confirm gate). */
|
||||
export const sendMessageTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "send_message",
|
||||
description:
|
||||
"Continue a previously-spawned resumable sub-agent (one that returned an agentId, or one you named via " +
|
||||
"the `agent` tool's `name` arg) with a follow-up message, preserving its context. Cheaper than " +
|
||||
"re-delegating from scratch. Use it to refine a sub-agent's answer, ask a follow-up, or continue one that " +
|
||||
"ran out of its step budget. Identify it by `agentId` OR by `name` (a teammate name). Only shared-cwd agents " +
|
||||
"are resumable; parallel worktree-isolated agents don't expose an agentId.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.resumeSubAgent) {
|
||||
throw new Error("Sub-agent continuation is not available in this context.");
|
||||
}
|
||||
// Resolve the target: a name takes precedence (it's the human-friendly handle), but fall back to
|
||||
// an explicit agentId. If a name is given but not on the roster, return a clean error instead of
|
||||
// calling resumeSubAgent with an undefined agentId.
|
||||
let agentId = args.agentId;
|
||||
if (args.name) {
|
||||
const resolved = ctx.resolveTeammate?.(args.name);
|
||||
if (!resolved) {
|
||||
return {
|
||||
error: `No teammate named "${args.name}" was found. Use list_teammates to see named teammates, or pass the agentId returned by the original agent call.`,
|
||||
};
|
||||
}
|
||||
agentId = resolved;
|
||||
}
|
||||
if (!agentId) {
|
||||
return {
|
||||
error: "Provide either a `name` (of a named teammate) or an `agentId` (returned by a prior agent call) to identify the sub-agent to continue.",
|
||||
};
|
||||
}
|
||||
const result = await ctx.resumeSubAgent(agentId, args.message);
|
||||
return { agentId, result, ...(args.name ? { name: args.name } : {}) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TaskStore, taskCreateTool, taskGetTool, taskListTool, taskUpdateTool } from "./task.js";
|
||||
import type { Task, TaskSummary } from "./task.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
function ctxWith(store?: TaskStore): ToolContext {
|
||||
return { cwd: "/x", ...(store ? { taskStore: store } : {}) };
|
||||
}
|
||||
|
||||
describe("TaskStore", () => {
|
||||
it("creates tasks with sequential ids starting at t1 and pending status", () => {
|
||||
const s = new TaskStore();
|
||||
const a = s.create({ subject: "A", description: "do A" });
|
||||
const b = s.create({ subject: "B", description: "do B", activeForm: "doing B" });
|
||||
expect(a.id).toBe("t1");
|
||||
expect(b.id).toBe("t2");
|
||||
expect(a.status).toBe("pending");
|
||||
expect(b.activeForm).toBe("doing B");
|
||||
expect(a.blocks).toEqual([]);
|
||||
expect(a.blockedBy).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a snapshot via the change emitter after each mutation", () => {
|
||||
const s = new TaskStore();
|
||||
const snaps: ReturnType<TaskStore["list"]>[] = [];
|
||||
s.setEmitter((tasks) => snaps.push(tasks));
|
||||
s.create({ subject: "A", description: "x" });
|
||||
s.create({ subject: "B", description: "y" });
|
||||
expect(snaps).toHaveLength(2);
|
||||
expect(snaps[1]!.map((t) => t.id)).toEqual(["t1", "t2"]);
|
||||
});
|
||||
|
||||
it("updates status, subject, owner, and activeForm", () => {
|
||||
const s = new TaskStore();
|
||||
const t = s.create({ subject: "A", description: "x" });
|
||||
const u = s.update(t.id, { status: "in_progress", owner: "agent-1", activeForm: "working" });
|
||||
expect(u?.status).toBe("in_progress");
|
||||
expect(u?.owner).toBe("agent-1");
|
||||
expect(u?.activeForm).toBe("working");
|
||||
});
|
||||
|
||||
it("links dependencies via addBlocks/addBlockedBy, ignoring self-refs, unknown ids, and duplicates", () => {
|
||||
const s = new TaskStore();
|
||||
const a = s.create({ subject: "A", description: "x" });
|
||||
const b = s.create({ subject: "B", description: "y" });
|
||||
// A blocks B: add B's blockedBy=[A] and A's blocks=[B].
|
||||
s.update(b.id, { addBlockedBy: [a.id] });
|
||||
s.update(a.id, { addBlocks: [b.id] });
|
||||
expect(s.get(a.id)!.blocks).toEqual([b.id]);
|
||||
expect(s.get(b.id)!.blockedBy).toEqual([a.id]);
|
||||
// Self-ref, unknown id, and duplicate are all ignored.
|
||||
s.update(a.id, { addBlocks: [a.id, "t99", b.id] });
|
||||
expect(s.get(a.id)!.blocks).toEqual([b.id]);
|
||||
});
|
||||
|
||||
it("prevents a direct 2-cycle when adding a blockedBy dependency", () => {
|
||||
const s = new TaskStore();
|
||||
const a = s.create({ subject: "A", description: "x" });
|
||||
const b = s.create({ subject: "B", description: "y" });
|
||||
s.update(a.id, { addBlockedBy: [b.id] }); // A waits on B
|
||||
// Now B waiting on A would create a 2-cycle — skipped silently.
|
||||
s.update(b.id, { addBlockedBy: [a.id] });
|
||||
expect(s.get(b.id)!.blockedBy).toEqual([]);
|
||||
});
|
||||
|
||||
it("merge-patches metadata, deleting keys set to null", () => {
|
||||
const s = new TaskStore();
|
||||
const t = s.create({ subject: "A", description: "x", metadata: { keep: 1, drop: 2 } });
|
||||
s.update(t.id, { metadata: { added: 3, drop: null } });
|
||||
expect(s.get(t.id)!.metadata).toEqual({ keep: 1, added: 3 });
|
||||
});
|
||||
|
||||
it("deletes a task (status: 'deleted') and prunes dangling block/blockedBy refs", () => {
|
||||
const s = new TaskStore();
|
||||
const a = s.create({ subject: "A", description: "x" });
|
||||
const b = s.create({ subject: "B", description: "y" });
|
||||
s.update(b.id, { addBlockedBy: [a.id] });
|
||||
s.update(a.id, { addBlocks: [b.id] });
|
||||
expect(s.update(a.id, { status: "deleted" })).toBeUndefined();
|
||||
expect(s.get(a.id)).toBeUndefined();
|
||||
// B's blockedBy no longer references the deleted A.
|
||||
expect(s.get(b.id)!.blockedBy).toEqual([]);
|
||||
expect(s.get(b.id)!.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("update returns undefined for an unknown id", () => {
|
||||
const s = new TaskStore();
|
||||
expect(s.update("t99", { status: "in_progress" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("task_create tool", () => {
|
||||
it("is non-mutating (no confirmation prompt)", () => {
|
||||
expect(taskCreateTool.mutating).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a task and returns its id + snapshot", async () => {
|
||||
const store = new TaskStore();
|
||||
const result = (await taskCreateTool.handler(
|
||||
{ subject: "Fix bug", description: "root-cause then patch", activeForm: "Fixing bug" },
|
||||
ctxWith(store),
|
||||
)) as { id: string; task: Task };
|
||||
expect(result.id).toBe("t1");
|
||||
expect(result.task.subject).toBe("Fix bug");
|
||||
expect(result.task.activeForm).toBe("Fixing bug");
|
||||
// Mutating the returned snapshot must not affect the store.
|
||||
result.task.blocks.push("t99");
|
||||
expect(store.get("t1")!.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a clear error when no task store is available", async () => {
|
||||
const result = (await taskCreateTool.handler({ subject: "x", description: "y" }, ctxWith(undefined))) as {
|
||||
error: string;
|
||||
};
|
||||
expect(result.error).toMatch(/not available/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task_list tool", () => {
|
||||
it("returns summaries of all tasks", async () => {
|
||||
const store = new TaskStore();
|
||||
store.create({ subject: "A", description: "x" });
|
||||
store.create({ subject: "B", description: "y" });
|
||||
const result = (await taskListTool.handler({}, ctxWith(store))) as { tasks: TaskSummary[] };
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
expect(result.tasks.map((t) => t.subject)).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("returns an empty list (not an error) when no store is available", async () => {
|
||||
const result = (await taskListTool.handler({}, ctxWith(undefined))) as { tasks: TaskSummary[] };
|
||||
expect(result.tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task_get tool", () => {
|
||||
it("returns the full task including dependencies and metadata", async () => {
|
||||
const store = new TaskStore();
|
||||
const a = store.create({ subject: "A", description: "x" });
|
||||
const b = store.create({ subject: "B", description: "y" });
|
||||
store.update(b.id, { addBlockedBy: [a.id] });
|
||||
const result = (await taskGetTool.handler({ taskId: b.id }, ctxWith(store))) as { task?: Task };
|
||||
expect(result.task!.blockedBy).toEqual([a.id]);
|
||||
});
|
||||
|
||||
it("returns an error for an unknown id", async () => {
|
||||
const store = new TaskStore();
|
||||
const result = (await taskGetTool.handler({ taskId: "t99" }, ctxWith(store))) as { error: string };
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task_update tool", () => {
|
||||
it("updates status and returns the updated task", async () => {
|
||||
const store = new TaskStore();
|
||||
const t = store.create({ subject: "A", description: "x" });
|
||||
const result = (await taskUpdateTool.handler({ taskId: t.id, status: "completed" }, ctxWith(store))) as {
|
||||
task?: Task;
|
||||
};
|
||||
expect(result.task!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("deletes when status is 'deleted' and returns { deleted }", async () => {
|
||||
const store = new TaskStore();
|
||||
const t = store.create({ subject: "A", description: "x" });
|
||||
const result = (await taskUpdateTool.handler({ taskId: t.id, status: "deleted" }, ctxWith(store))) as {
|
||||
deleted: string;
|
||||
};
|
||||
expect(result.deleted).toBe(t.id);
|
||||
expect(store.get(t.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds dependencies via addBlockedBy", async () => {
|
||||
const store = new TaskStore();
|
||||
const a = store.create({ subject: "A", description: "x" });
|
||||
const b = store.create({ subject: "B", description: "y" });
|
||||
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctxWith(store));
|
||||
expect(store.get(b.id)!.blockedBy).toEqual([a.id]);
|
||||
});
|
||||
|
||||
it("returns an error for an unknown id", async () => {
|
||||
const store = new TaskStore();
|
||||
const result = (await taskUpdateTool.handler({ taskId: "t99", status: "in_progress" }, ctxWith(store))) as {
|
||||
error: string;
|
||||
};
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it("returns a clear error when no task store is available", async () => {
|
||||
const result = (await taskUpdateTool.handler({ taskId: "t1", status: "in_progress" }, ctxWith(undefined))) as {
|
||||
error: string;
|
||||
};
|
||||
expect(result.error).toMatch(/not available/i);
|
||||
});
|
||||
|
||||
it("the store emitter fires after a tool-driven update", async () => {
|
||||
const store = new TaskStore();
|
||||
const fired = vi.fn();
|
||||
store.setEmitter(fired);
|
||||
const t = store.create({ subject: "A", description: "x" });
|
||||
fired.mockClear(); // create already fired once; isolate the update
|
||||
await taskUpdateTool.handler({ taskId: t.id, status: "in_progress" }, ctxWith(store));
|
||||
expect(fired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task tool schema validation", () => {
|
||||
it("rejects an empty subject on task_create", () => {
|
||||
expect(() => taskCreateTool.schema.parse({ subject: "", description: "x" })).toThrow();
|
||||
});
|
||||
it("rejects an empty taskId on task_get/task_update", () => {
|
||||
expect(() => taskGetTool.schema.parse({ taskId: "" })).toThrow();
|
||||
expect(() => taskUpdateTool.schema.parse({ taskId: "" })).toThrow();
|
||||
});
|
||||
it("rejects an unknown status value on task_update", () => {
|
||||
expect(() => taskUpdateTool.schema.parse({ taskId: "t1", status: "done" })).toThrow();
|
||||
});
|
||||
it("accepts 'deleted' as a status on task_update", () => {
|
||||
expect(() => taskUpdateTool.schema.parse({ taskId: "t1", status: "deleted" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is
|
||||
* never a stored status. */
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy`
|
||||
* (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form
|
||||
* metadata. Unlike the old flat todo list, tasks are created and updated incrementally (not
|
||||
* replaced wholesale) so dependencies and ownership can be expressed. */
|
||||
export interface Task {
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
/** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */
|
||||
activeForm?: string;
|
||||
status: TaskStatus;
|
||||
/** Who has claimed the task (an agent name). Unset = unclaimed. */
|
||||
owner?: string;
|
||||
/** Ids of tasks THIS task blocks (i.e. that depend on it). */
|
||||
blocks: string[];
|
||||
/** Ids of tasks that must be completed before this one can start. */
|
||||
blockedBy: string[];
|
||||
/** Free-form metadata; merge-patched on update (set a key to null to delete it). */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A compact, list-view projection of a Task — enough to render the checklist without the long
|
||||
* description/metadata. */
|
||||
export interface TaskSummary {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: TaskStatus;
|
||||
owner?: string;
|
||||
blockedBy: string[];
|
||||
}
|
||||
|
||||
/** In-memory task store held by the Session. The tools below operate on it via `ctx.taskStore`.
|
||||
* Mutations emit a snapshot to the UI through the `onChange` emitter the loop wires up, so each
|
||||
* create/update renders a fresh checklist item in the scrollback (matching the old todo behavior). */
|
||||
export class TaskStore {
|
||||
private tasks = new Map<string, Task>();
|
||||
private seq = 0;
|
||||
private emitter: ((tasks: TaskSummary[]) => void) | undefined;
|
||||
|
||||
/** Wired by the agent loop so the store can broadcast a snapshot after each mutation. */
|
||||
setEmitter(emit: (tasks: TaskSummary[]) => void): void {
|
||||
this.emitter = emit;
|
||||
}
|
||||
|
||||
private nextId(): string {
|
||||
this.seq += 1;
|
||||
return `t${this.seq}`;
|
||||
}
|
||||
|
||||
private snapshot(): TaskSummary[] {
|
||||
return [...this.tasks.values()].map((t) => ({
|
||||
id: t.id,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
owner: t.owner,
|
||||
blockedBy: [...t.blockedBy],
|
||||
}));
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.emitter?.(this.snapshot());
|
||||
}
|
||||
|
||||
create(input: {
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Task {
|
||||
const task: Task = {
|
||||
id: this.nextId(),
|
||||
subject: input.subject,
|
||||
description: input.description,
|
||||
activeForm: input.activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: input.metadata ? { ...input.metadata } : undefined,
|
||||
};
|
||||
this.tasks.set(task.id, task);
|
||||
this.emit();
|
||||
return task;
|
||||
}
|
||||
|
||||
list(): TaskSummary[] {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
get(id: string): Task | undefined {
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
|
||||
/** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy
|
||||
* refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */
|
||||
update(
|
||||
id: string,
|
||||
updates: {
|
||||
status?: TaskStatus | "deleted";
|
||||
subject?: string;
|
||||
description?: string;
|
||||
activeForm?: string;
|
||||
owner?: string;
|
||||
addBlocks?: string[];
|
||||
addBlockedBy?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Task | undefined {
|
||||
const task = this.tasks.get(id);
|
||||
if (!task) return undefined;
|
||||
if (updates.status === "deleted") {
|
||||
this.remove(id);
|
||||
return undefined;
|
||||
}
|
||||
if (updates.status) task.status = updates.status;
|
||||
if (updates.subject !== undefined) task.subject = updates.subject;
|
||||
if (updates.description !== undefined) task.description = updates.description;
|
||||
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
|
||||
if (updates.owner !== undefined) task.owner = updates.owner;
|
||||
if (updates.addBlocks) {
|
||||
for (const b of updates.addBlocks) {
|
||||
// Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates.
|
||||
if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b);
|
||||
}
|
||||
}
|
||||
if (updates.addBlockedBy) {
|
||||
for (const b of updates.addBlockedBy) {
|
||||
if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue;
|
||||
// Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other.
|
||||
const other = this.tasks.get(b);
|
||||
if (other && other.blockedBy.includes(id)) continue;
|
||||
task.blockedBy.push(b);
|
||||
}
|
||||
}
|
||||
if (updates.metadata) {
|
||||
task.metadata = mergeMetadata(task.metadata, updates.metadata);
|
||||
}
|
||||
this.emit();
|
||||
return task;
|
||||
}
|
||||
|
||||
private remove(id: string): void {
|
||||
this.tasks.delete(id);
|
||||
// Prune dangling dependency refs in surviving tasks.
|
||||
for (const other of this.tasks.values()) {
|
||||
other.blocks = other.blocks.filter((b) => b !== id);
|
||||
other.blockedBy = other.blockedBy.filter((b) => b !== id);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
|
||||
function mergeMetadata(
|
||||
existing: Record<string, unknown> | undefined,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const out: Record<string, unknown> = { ...(existing ?? {}) };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null) delete out[k];
|
||||
else out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller
|
||||
* mutate the store's internal object. */
|
||||
function serializeTask(t: Task): Task {
|
||||
return {
|
||||
...t,
|
||||
blocks: [...t.blocks],
|
||||
blockedBy: [...t.blockedBy],
|
||||
metadata: t.metadata ? { ...t.metadata } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataSchema = z.record(z.string(), z.any()).optional();
|
||||
|
||||
export const taskCreateTool: ToolDef<z.infer<typeof taskCreateSchema>> = {
|
||||
name: "task_create",
|
||||
description:
|
||||
"Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " +
|
||||
"visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " +
|
||||
"task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.",
|
||||
schema: z.object({
|
||||
subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."),
|
||||
description: z.string().describe("What needs to be done, in enough detail to act on."),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."),
|
||||
metadata: metadataSchema,
|
||||
}),
|
||||
// Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt.
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.taskStore) return { error: "Task tracking is not available in this context." };
|
||||
const task = ctx.taskStore.create(args);
|
||||
return { id: task.id, task: serializeTask(task) };
|
||||
},
|
||||
};
|
||||
|
||||
const taskCreateSchema = z.object({
|
||||
subject: z.string().min(1),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
export const taskListTool: ToolDef<z.infer<typeof taskListSchema>> = {
|
||||
name: "task_list",
|
||||
description:
|
||||
"List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " +
|
||||
"find the next available task to claim.",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async (_args, ctx) => {
|
||||
return { tasks: ctx.taskStore?.list() ?? [] };
|
||||
},
|
||||
};
|
||||
|
||||
const taskListSchema = z.object({});
|
||||
|
||||
export const taskGetTool: ToolDef<z.infer<typeof taskGetSchema>> = {
|
||||
name: "task_get",
|
||||
description:
|
||||
"Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " +
|
||||
"verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.",
|
||||
schema: z.object({ taskId: z.string().min(1) }),
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const task = ctx.taskStore?.get(args.taskId);
|
||||
return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` };
|
||||
},
|
||||
};
|
||||
|
||||
const taskGetSchema = z.object({ taskId: z.string().min(1) });
|
||||
|
||||
export const taskUpdateTool: ToolDef<z.infer<typeof taskUpdateSchema>> = {
|
||||
name: "task_update",
|
||||
description:
|
||||
"Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " +
|
||||
"set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " +
|
||||
"to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " +
|
||||
"before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.",
|
||||
schema: z.object({
|
||||
taskId: z.string().min(1),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
addBlocks: z.array(z.string()).optional(),
|
||||
addBlockedBy: z.array(z.string()).optional(),
|
||||
metadata: metadataSchema,
|
||||
}),
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const store = ctx.taskStore;
|
||||
if (!store) return { error: "Task tracking is not available in this context." };
|
||||
if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` };
|
||||
if (args.status === "deleted") {
|
||||
store.update(args.taskId, args);
|
||||
return { deleted: args.taskId };
|
||||
}
|
||||
const task = store.update(args.taskId, args);
|
||||
return task ? { task: serializeTask(task) } : { deleted: args.taskId };
|
||||
},
|
||||
};
|
||||
|
||||
const taskUpdateSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
addBlocks: z.array(z.string()).optional(),
|
||||
addBlockedBy: z.array(z.string()).optional(),
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { agentTool } from "./agentTool.js";
|
||||
import { sendMessageTool } from "./sendMessage.js";
|
||||
import { listTeammatesTool } from "./teammates.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
// A mock ctx whose teammate roster is a real Map, so registration/resolution/list behave like the
|
||||
// session-backed wiring in loop.ts (which sets registerTeammate/resolveTeammate/listTeammates over
|
||||
// session.namedAgents). runSubAgent/resumeSubAgent are vi mocks the individual tests program.
|
||||
function ctxWithRoster(opts?: {
|
||||
runSubAgent?: ToolContext["runSubAgent"];
|
||||
resumeSubAgent?: ToolContext["resumeSubAgent"];
|
||||
}): { ctx: ToolContext; roster: Map<string, string> } {
|
||||
const roster = new Map<string, string>();
|
||||
const ctx: ToolContext = {
|
||||
cwd: "/x",
|
||||
registerTeammate: (name, agentId) => {
|
||||
roster.set(name, agentId);
|
||||
},
|
||||
resolveTeammate: (name) => roster.get(name),
|
||||
listTeammates: () => [...roster.entries()].map(([name, agentId]) => ({ name, agentId })),
|
||||
...(opts?.runSubAgent ? { runSubAgent: opts.runSubAgent } : {}),
|
||||
...(opts?.resumeSubAgent ? { resumeSubAgent: opts.resumeSubAgent } : {}),
|
||||
};
|
||||
return { ctx, roster };
|
||||
}
|
||||
|
||||
describe("agent tool — named teammates", () => {
|
||||
it("registers a name on the roster when the delegation is resumable, and echoes name + agentId", async () => {
|
||||
const runSubAgent = vi.fn().mockResolvedValue({ agentId: "agent-1", result: "did it", resumable: true });
|
||||
const { ctx, roster } = ctxWithRoster({ runSubAgent });
|
||||
const result = (await agentTool.handler(
|
||||
{ description: "research X", prompt: "find X", name: "researcher" },
|
||||
ctx,
|
||||
)) as { name: string; agentId: string; result: string };
|
||||
expect(result.name).toBe("researcher");
|
||||
expect(result.agentId).toBe("agent-1");
|
||||
expect(roster.get("researcher")).toBe("agent-1");
|
||||
expect(runSubAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT register a name (and omits name/agentId) when the delegation is NOT resumable (parallel/isolated)", async () => {
|
||||
const runSubAgent = vi.fn().mockResolvedValue({ agentId: "agent-2", result: "analyzed", resumable: false });
|
||||
const { ctx, roster } = ctxWithRoster({ runSubAgent });
|
||||
const result = (await agentTool.handler(
|
||||
{ description: "review Y", prompt: "review Y", name: "reviewer" },
|
||||
ctx,
|
||||
)) as { result: string; name?: string; agentId?: string };
|
||||
expect(result.result).toBe("analyzed");
|
||||
expect(result.name).toBeUndefined();
|
||||
expect(result.agentId).toBeUndefined();
|
||||
expect(roster.has("reviewer")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns an error WITHOUT running when the name is already a teammate (no clobber)", async () => {
|
||||
const runSubAgent = vi.fn();
|
||||
const { ctx, roster } = ctxWithRoster({ runSubAgent });
|
||||
roster.set("researcher", "agent-1");
|
||||
const result = (await agentTool.handler(
|
||||
{ description: "research Z", prompt: "find Z", name: "researcher" },
|
||||
ctx,
|
||||
)) as { error: string };
|
||||
expect(result.error).toMatch(/already exists/i);
|
||||
expect(runSubAgent).not.toHaveBeenCalled();
|
||||
// The existing teammate is untouched.
|
||||
expect(roster.get("researcher")).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("still runs (and silently ignores the name) when no roster is wired (non-session context)", async () => {
|
||||
// No resolveTeammate/registerTeammate on ctx — simulates a hand-built, non-session context.
|
||||
const runSubAgent = vi.fn().mockResolvedValue({ agentId: "agent-9", result: "ok", resumable: true });
|
||||
const ctx: ToolContext = { cwd: "/x", runSubAgent };
|
||||
const result = (await agentTool.handler(
|
||||
{ description: "solo", prompt: "do solo", name: "lonely" },
|
||||
ctx,
|
||||
)) as { agentId: string; name?: string };
|
||||
expect(runSubAgent).toHaveBeenCalledOnce();
|
||||
expect(result.agentId).toBe("agent-9");
|
||||
// No roster to register on → name simply not echoed as a teammate handle.
|
||||
expect(result.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("send_message — name vs agentId", () => {
|
||||
it("resolves a teammate by name, then calls resumeSubAgent with its agentId", async () => {
|
||||
const resumeSubAgent = vi.fn().mockResolvedValue("followed up");
|
||||
const { ctx, roster } = ctxWithRoster({ resumeSubAgent });
|
||||
roster.set("researcher", "agent-1");
|
||||
const result = (await sendMessageTool.handler({ name: "researcher", message: "go deeper" }, ctx)) as {
|
||||
agentId: string;
|
||||
name: string;
|
||||
result: string;
|
||||
};
|
||||
expect(resumeSubAgent).toHaveBeenCalledWith("agent-1", "go deeper");
|
||||
expect(result.agentId).toBe("agent-1");
|
||||
expect(result.name).toBe("researcher");
|
||||
expect(result.result).toBe("followed up");
|
||||
});
|
||||
|
||||
it("returns a clean error (without resuming) for an unknown name", async () => {
|
||||
const resumeSubAgent = vi.fn();
|
||||
const { ctx } = ctxWithRoster({ resumeSubAgent });
|
||||
const result = (await sendMessageTool.handler({ name: "ghost", message: "boo" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/No teammate named "ghost"/);
|
||||
expect(resumeSubAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to an explicit agentId when no name is given", async () => {
|
||||
const resumeSubAgent = vi.fn().mockResolvedValue("resumed by id");
|
||||
const { ctx } = ctxWithRoster({ resumeSubAgent });
|
||||
const result = (await sendMessageTool.handler({ agentId: "agent-7", message: "continue" }, ctx)) as {
|
||||
agentId: string;
|
||||
result: string;
|
||||
name?: string;
|
||||
};
|
||||
expect(resumeSubAgent).toHaveBeenCalledWith("agent-7", "continue");
|
||||
expect(result.agentId).toBe("agent-7");
|
||||
expect(result.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a clean error when neither name nor agentId is provided", async () => {
|
||||
const resumeSubAgent = vi.fn();
|
||||
const { ctx } = ctxWithRoster({ resumeSubAgent });
|
||||
const result = (await sendMessageTool.handler({ message: "to whom?" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/either/i);
|
||||
expect(resumeSubAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list_teammates tool", () => {
|
||||
it("returns the roster from ctx.listTeammates", async () => {
|
||||
const { ctx, roster } = ctxWithRoster();
|
||||
roster.set("researcher", "agent-1");
|
||||
roster.set("implementer", "agent-2");
|
||||
const result = (await listTeammatesTool.handler({}, ctx)) as { teammates: { name: string; agentId: string }[] };
|
||||
expect(result.teammates).toEqual([
|
||||
{ name: "researcher", agentId: "agent-1" },
|
||||
{ name: "implementer", agentId: "agent-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list when no roster is wired (non-session context)", async () => {
|
||||
const result = (await listTeammatesTool.handler({}, { cwd: "/x" })) as { teammates: unknown[] };
|
||||
expect(result.teammates).toEqual([]);
|
||||
});
|
||||
|
||||
it("is non-mutating (no confirmation prompt)", () => {
|
||||
expect(listTeammatesTool.mutating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teammate tool schema validation", () => {
|
||||
it("send_message requires at least one of name/agentId", () => {
|
||||
expect(() => sendMessageTool.schema.parse({ message: "x" })).toThrow();
|
||||
});
|
||||
it("send_message accepts a name only", () => {
|
||||
expect(() => sendMessageTool.schema.parse({ name: "researcher", message: "x" })).not.toThrow();
|
||||
});
|
||||
it("send_message accepts an agentId only", () => {
|
||||
expect(() => sendMessageTool.schema.parse({ agentId: "agent-1", message: "x" })).not.toThrow();
|
||||
});
|
||||
it("list_teammates takes no arguments", () => {
|
||||
expect(() => listTeammatesTool.schema.parse({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({}).describe("Takes no arguments.");
|
||||
|
||||
/** Lists the session's named teammates — sub-agents you spawned via the `agent` tool with a `name`
|
||||
* that are still resumable. Each entry is `{ name, agentId }`; address one with `send_message` by
|
||||
* its `name` (or its `agentId`). Returns an empty list when there are none, or in a non-session
|
||||
* context where no roster is maintained. Non-mutating: it only reads the name → agentId index. */
|
||||
export const listTeammatesTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "list_teammates",
|
||||
description:
|
||||
"List your named teammates — sub-agents you spawned with a `name` via the `agent` tool that are still " +
|
||||
"resumable. Each has a name and agentId; continue one with send_message by its name. Returns an empty list " +
|
||||
"when none exist or in a non-session context.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (_args, ctx) => ({ teammates: ctx.listTeammates?.() ?? [] }),
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { todoWriteTool } from "./todoWrite.js";
|
||||
|
||||
describe("todo_write", () => {
|
||||
it("is non-mutating (no confirmation prompt)", () => {
|
||||
expect(todoWriteTool.mutating).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards the full list to ctx.setTodos and echoes it back", async () => {
|
||||
const setTodos = vi.fn();
|
||||
const todos = [
|
||||
{ content: "read the config", status: "completed" as const },
|
||||
{ content: "write the fix", status: "in_progress" as const },
|
||||
{ content: "run tests", status: "pending" as const },
|
||||
];
|
||||
const result = await todoWriteTool.handler({ todos }, { cwd: "/tmp", setTodos });
|
||||
expect(setTodos).toHaveBeenCalledWith(todos);
|
||||
expect(result).toEqual({ todos });
|
||||
});
|
||||
|
||||
it("doesn't throw when setTodos is absent from the context", async () => {
|
||||
const todos = [{ content: "a task", status: "pending" as const }];
|
||||
await expect(todoWriteTool.handler({ todos }, { cwd: "/tmp" })).resolves.toEqual({ todos });
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const todoItemSchema = z.object({
|
||||
content: z.string().describe("Short description of the task."),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
todos: z
|
||||
.array(todoItemSchema)
|
||||
.describe("Full checklist (replaces previous list, not a diff)."),
|
||||
});
|
||||
|
||||
export const todoWriteTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "todo_write",
|
||||
description:
|
||||
"Show a task checklist for multi-step work (3+ steps). One item 'in_progress' at a time, " +
|
||||
"mark 'completed' when done. Pass the full list each time. Skip for trivial requests.",
|
||||
schema,
|
||||
// Purely informational (like Claude Code's TodoWrite) — never touches the filesystem or asks
|
||||
// the user anything, so it shouldn't interrupt the flow with a confirmation prompt.
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
ctx.setTodos?.(args.todos);
|
||||
return { todos: args.todos };
|
||||
},
|
||||
};
|
||||
+106
-7
@@ -1,4 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
import type { TaskStore } from "./task.js";
|
||||
import type { CronStore } from "../scheduler/cron.js";
|
||||
|
||||
export interface SubAgentTask {
|
||||
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
|
||||
@@ -12,23 +14,54 @@ export interface SubAgentOverrides {
|
||||
* (agents/*.md) that ship their own identity/instructions instead of the generic "delegate a
|
||||
* task" framing. */
|
||||
systemPrompt?: string;
|
||||
/** Prepended (as an addendum) to the generic sub-agent system prompt instead of replacing it, so
|
||||
* the standard tool-use discipline survives. Used by built-in specialist agent types
|
||||
* (agentTypes.ts) like explore/code-reviewer. Ignored when `systemPrompt` is also set. */
|
||||
systemPromptAddendum?: string;
|
||||
/** Restricts the sub-agent's toolset to tools with these names (unknown names are silently
|
||||
* ignored); omit to inherit the parent's full toolset minus `agent`/plugin-agent tools. */
|
||||
toolNames?: string[];
|
||||
/** Working directory the sub-agent runs against. Set by the parallel-fan-out dispatch path to a
|
||||
* throwaway git worktree (see utils/worktree.ts) so concurrent sub-agents in one batch can't
|
||||
* collide on files. Omit (the default for a single/sequential delegation) to run in the parent's
|
||||
* cwd and persist edits. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
/** Result of a sub-agent run: the agent's id (for later continuation via `resumeSubAgent`/the
|
||||
* `send_message` tool), its final answer text, and whether it's resumable. A sub-agent is resumable
|
||||
* only when it ran in the shared cwd (sequential single call, or a read-only parallel agent) — a
|
||||
* worktree-isolated parallel agent's cwd is cleaned up after the batch, so it can't be continued. */
|
||||
export interface SubAgentResult {
|
||||
agentId: string;
|
||||
result: string;
|
||||
resumable: boolean;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
cwd: string;
|
||||
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
|
||||
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<string>;
|
||||
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
|
||||
* future tool context is built without one — every session-backed context provides it. */
|
||||
setTodos?: (todos: TodoItem[]) => void;
|
||||
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<SubAgentResult>;
|
||||
/** Continues a previously-spawned resumable sub-agent (one that returned an agentId) with a
|
||||
* follow-up message, preserving its context. Used by the `send_message` tool. Rejects with a
|
||||
* clear error if the agentId is unknown or wasn't resumable. */
|
||||
resumeSubAgent?: (agentId: string, message: string) => Promise<string>;
|
||||
/** Registers a named teammate: maps `name` → `agentId` on the session's roster so `send_message`
|
||||
* can address it by name and `list_teammates` can show it. Used by the `agent` tool when the
|
||||
* model supplies a `name` and the delegation is resumable (shared-cwd). Absent in non-session
|
||||
* contexts, in which case the name is silently ignored (the agent still runs, just unnamed). */
|
||||
registerTeammate?: (name: string, agentId: string) => void;
|
||||
/** Resolves a teammate name to its agentId (or undefined if no such named teammate exists). Used by
|
||||
* `send_message` to address a teammate by name instead of agentId, and by the `agent` tool to
|
||||
* pre-flight a name collision before spawning a new teammate. Absent in non-session contexts. */
|
||||
resolveTeammate?: (name: string) => string | undefined;
|
||||
/** Lists all named teammates ({ name, agentId }) for the `list_teammates` tool. Absent in
|
||||
* non-session contexts — the tool returns an empty list then. */
|
||||
listTeammates?: () => { name: string; agentId: string }[];
|
||||
/** The session's structured task store, used by the `task_create`/`task_list`/`task_get`/
|
||||
* `task_update` tools to track multi-step work with dependencies and ownership. Absent only in a
|
||||
* non-session context (e.g. a hand-built test context) — the task tools return a clear error then. */
|
||||
taskStore?: TaskStore;
|
||||
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
|
||||
* polls `requested` and, once true, detaches into the background job registry instead of
|
||||
* awaiting completion. Absent for tools that don't support backgrounding. */
|
||||
@@ -38,6 +71,72 @@ export interface ToolContext {
|
||||
* tree so the command can't keep running (and keep the event loop alive on exit) after the
|
||||
* parent has already abandoned the turn. Undefined for top-level turns. */
|
||||
signal?: AbortSignal;
|
||||
/** Records the previous content of a file edited or written by this tool, so the user can later
|
||||
* roll back the most recent mutation via the /undo slash command. Only present in session-backed
|
||||
* contexts that provide a Session object. */
|
||||
setLastEdit?: (edit: { path: string; previousContent: string }) => void;
|
||||
/** Refreshes the session's cached user memory after the `memory_write` tool changes memory.md,
|
||||
* so a later system-prompt rebuild (compaction, /mode or /perm switch) re-folds the new content
|
||||
* instead of the pre-write snapshot. Only present in session-backed contexts. */
|
||||
setUserMemory?: (memory: string | null) => void;
|
||||
/** Surfaces a notice line to the parent UI's scrollback. Used by long-running, headless tools
|
||||
* (notably `workflow`) to report progress — `log()`/`phase()` inside a workflow script reach the
|
||||
* user through this. Absent in non-session contexts. */
|
||||
emitNotice?: (text: string, isError?: boolean) => void;
|
||||
/** Presents a plan for user approval and, on approval, exits plan mode so the caller can implement.
|
||||
* Used by the `exit_plan_mode` tool. Returns whether the user approved; on rejection plan mode
|
||||
* stays active so the model can refine and re-present. Absent outside plan mode (so the tool fails
|
||||
* cleanly with "only available in plan mode" if the model calls it at the wrong time). */
|
||||
exitPlanMode?: (plan: string) => Promise<{ approved: boolean }>;
|
||||
/** Asks the user a structured multiple-choice question (or a short sequence of them) when the model
|
||||
* is blocked on a decision only the user can make. Used by the `ask_user_question` tool. Resolves
|
||||
* with the selected option label(s) per question; an empty selection means the user skipped
|
||||
* (treated as "Other"/custom input in the UI, surfaced back as the typed text). Absent in
|
||||
* non-session contexts (headless sub-agents can't prompt). */
|
||||
askQuestion?: (questions: AskQuestionSpec[]) => Promise<AskQuestionAnswer[]>;
|
||||
/** The session's cron/wakeup scheduler, used by the `cron_create`/`cron_list`/`cron_delete`/
|
||||
* `schedule_wakeup` tools. Absent in a non-session context (e.g. a hand-built test context) — the
|
||||
* scheduling tools return a clear error then. */
|
||||
cronStore?: CronStore;
|
||||
/** Switches the session's working directory to `newCwd` (used by `enter_worktree`/`exit_worktree`)
|
||||
* and notifies the UI so its live cwd display, /undo resolution, git-info, and @mention handling
|
||||
* follow the switch. Absent in non-session contexts — the worktree tools fail cleanly then. */
|
||||
setCwd?: (newCwd: string) => void;
|
||||
/** Reads the active interactive worktree tracking ({ dir, branch, originalCwd }) or undefined when
|
||||
* not in a worktree session. Used by `enter_worktree` (refuse re-entry) and `exit_worktree`
|
||||
* (restore cwd + remove). */
|
||||
getWorktree?: () => { dir: string; branch: string; originalCwd: string } | undefined;
|
||||
/** Sets or clears the active worktree tracking on the session (and notifies the App so a /model
|
||||
* switch can re-attach it). `enter_worktree` sets it; `exit_worktree` passes undefined to clear. */
|
||||
setWorktree?: (worktree: { dir: string; branch: string; originalCwd: string } | undefined) => void;
|
||||
}
|
||||
|
||||
/** One selectable option in an {@link AskQuestionSpec}. `description` is shown dimmed under the label
|
||||
* to explain a trade-off or implication, so the user can compare options at a glance. */
|
||||
export interface AskQuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A single question the model asks the user via the `ask_user_question` tool. `header` is a short
|
||||
* (≤ ~12 char) chip shown beside the question for scanability; `options` is 2-4 choices; when
|
||||
* `multiSelect` is true the user may pick several (otherwise exactly one). The UI also offers an
|
||||
* implicit "Other" path so the user can type a custom answer not in the list. */
|
||||
export interface AskQuestionSpec {
|
||||
question: string;
|
||||
header: string;
|
||||
options: AskQuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
/** The user's answer to one {@link AskQuestionSpec}: the labels of the selected option(s), in the
|
||||
* order the model listed them. An empty array with `custom` set means the user typed a freeform
|
||||
* answer via "Other" instead of picking a listed option. */
|
||||
export interface AskQuestionAnswer {
|
||||
question: string;
|
||||
selected: string[];
|
||||
/** A freeform answer the user typed via "Other" instead of picking a listed option. */
|
||||
custom?: string;
|
||||
}
|
||||
|
||||
export interface ToolDef<T = any> {
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { execa } from "execa";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { stripExports, extractJson, validateAgainstSchema, runWorkflow } from "./workflow.js";
|
||||
import type { SubAgentResult } from "./types.js";
|
||||
|
||||
describe("stripExports", () => {
|
||||
it("strips leading export keywords so module-style scripts run as plain scripts", () => {
|
||||
expect(stripExports("export const meta = { name: 'x' };\nconst y = 1;")).toBe("const meta = { name: 'x' };\nconst y = 1;");
|
||||
expect(stripExports("export function f() {}\nexport default 1;")).toBe("function f() {}\n1;");
|
||||
// Non-export lines are untouched.
|
||||
expect(stripExports("const a = 1;\n// export b\nconst c = 3;")).toBe("const a = 1;\n// export b\nconst c = 3;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractJson", () => {
|
||||
it("parses plain JSON", () => {
|
||||
expect(extractJson('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(extractJson("[1,2,3]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("extracts JSON from a ```json fence", () => {
|
||||
expect(extractJson("Here you go:\n```json\n{\"a\":1}\n```\nthanks")).toEqual({ a: 1 });
|
||||
});
|
||||
it("extracts JSON from a fence with an arbitrary language tag (```javascript)", () => {
|
||||
expect(extractJson("```javascript\n{\"a\":1}\n```")).toEqual({ a: 1 });
|
||||
expect(extractJson("```ts\n{\"a\":1}\n```")).toEqual({ a: 1 });
|
||||
});
|
||||
it("strips trailing commas before } (a common local-model artifact)", () => {
|
||||
expect(extractJson('{"a":1,"b":2,}')).toEqual({ a: 1, b: 2 });
|
||||
expect(extractJson('```json\n{\n "a": 1,\n "b": 2,\n}\n```')).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
it("strips trailing commas before ] in arrays", () => {
|
||||
expect(extractJson("[1,2,3,]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("extracts the first JSON object from surrounding prose", () => {
|
||||
expect(extractJson('The answer is {"a":1,"b":2} as shown.')).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
it("throws when no JSON is present", () => {
|
||||
expect(() => extractJson("no json here")).toThrow(/valid JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAgainstSchema", () => {
|
||||
const sch = { type: "object" as const, required: ["a", "b"], properties: { a: { type: "number" }, b: { type: "string" } } };
|
||||
it("passes a valid object", () => {
|
||||
expect(() => validateAgainstSchema({ a: 1, b: "x" }, sch)).not.toThrow();
|
||||
});
|
||||
it("rejects a missing required field", () => {
|
||||
expect(() => validateAgainstSchema({ a: 1 }, sch)).toThrow(/missing required field: b/);
|
||||
});
|
||||
it("rejects a wrong property type", () => {
|
||||
expect(() => validateAgainstSchema({ a: "notnum", b: "x" }, sch)).toThrow(/field a: expected number/);
|
||||
});
|
||||
it("rejects a non-object when object is required", () => {
|
||||
expect(() => validateAgainstSchema([1, 2], sch)).toThrow(/expected a JSON object/);
|
||||
});
|
||||
it("validates array item types via items", () => {
|
||||
const arrSch = { type: "array", items: { type: "string" } };
|
||||
expect(() => validateAgainstSchema(["a", "b"], arrSch)).not.toThrow();
|
||||
expect(() => validateAgainstSchema(["a", 2], arrSch)).toThrow(/item\[1\]: expected string/);
|
||||
});
|
||||
it("validates enum membership", () => {
|
||||
const enumSch = { type: "string", enum: ["low", "med", "high"] };
|
||||
expect(() => validateAgainstSchema("med", enumSch)).not.toThrow();
|
||||
expect(() => validateAgainstSchema("nope", enumSch)).toThrow(/not in allowed enum/);
|
||||
});
|
||||
it("validates boolean and integer property types", () => {
|
||||
const s = { type: "object", properties: { ok: { type: "boolean" }, n: { type: "integer" } } };
|
||||
expect(() => validateAgainstSchema({ ok: true, n: 3 }, s)).not.toThrow();
|
||||
expect(() => validateAgainstSchema({ ok: "yes", n: 3 }, s)).toThrow(/field ok: expected boolean/);
|
||||
// integer must reject a non-integer number.
|
||||
expect(() => validateAgainstSchema({ ok: true, n: 1.5 }, s)).toThrow(/field n: expected integer/);
|
||||
});
|
||||
it("validates nested object properties", () => {
|
||||
const s = {
|
||||
type: "object",
|
||||
properties: { outer: { type: "object", required: ["inner"], properties: { inner: { type: "number" } } } },
|
||||
};
|
||||
expect(() => validateAgainstSchema({ outer: { inner: 5 } }, s)).not.toThrow();
|
||||
expect(() => validateAgainstSchema({ outer: {} }, s)).toThrow(/missing required field: inner/);
|
||||
expect(() => validateAgainstSchema({ outer: { inner: "x" } }, s)).toThrow(/field outer.inner: expected number/);
|
||||
});
|
||||
it("rejects additional properties when additionalProperties is false", () => {
|
||||
const s = { type: "object", properties: { a: { type: "number" } }, additionalProperties: false };
|
||||
expect(() => validateAgainstSchema({ a: 1 }, s)).not.toThrow();
|
||||
expect(() => validateAgainstSchema({ a: 1, extra: 2 }, s)).toThrow(/unexpected additional property: extra/);
|
||||
});
|
||||
});
|
||||
|
||||
function mockSubAgent(result: string): { runSubAgent: ReturnType<typeof vi.fn> } {
|
||||
return { runSubAgent: vi.fn(async (): Promise<SubAgentResult> => ({ agentId: "id", result, resumable: false })) };
|
||||
}
|
||||
|
||||
describe("runWorkflow", () => {
|
||||
it("runs a script that returns a value, calling agent() through ctx.runSubAgent", async () => {
|
||||
const ctx = mockSubAgent("the answer");
|
||||
const out = await runWorkflow(
|
||||
`const r = await agent("do something", { label: "worker" });\nreturn r;`,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
expect(out).toBe("the answer");
|
||||
expect(ctx.runSubAgent).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.runSubAgent.mock.calls[0]![0].description).toBe("worker");
|
||||
});
|
||||
|
||||
it("exposes args to the script", async () => {
|
||||
const ctx = mockSubAgent("ok");
|
||||
const out = await runWorkflow(
|
||||
`const items = args;\nconst r = await agent("process " + items.join(","));\nreturn r;`,
|
||||
["a", "b", "c"],
|
||||
ctx,
|
||||
);
|
||||
expect(out).toBe("ok");
|
||||
expect(ctx.runSubAgent.mock.calls[0]![0].prompt).toBe("process a,b,c");
|
||||
});
|
||||
|
||||
it("parallel() runs thunks concurrently and turns failures into null", async () => {
|
||||
const ctx = mockSubAgent("ok");
|
||||
const out = (await runWorkflow(
|
||||
`const results = await parallel([
|
||||
() => agent("task A").then(r => r + "!"),
|
||||
() => Promise.reject(new Error("boom")),
|
||||
() => agent("task C"),
|
||||
]);
|
||||
return results;`,
|
||||
undefined,
|
||||
ctx,
|
||||
)) as (string | null)[];
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[0]).toBe("ok!");
|
||||
expect(out[1]).toBeNull();
|
||||
expect(out[2]).toBe("ok");
|
||||
});
|
||||
|
||||
it("pipeline() runs each item through all stages, no barrier between stages", async () => {
|
||||
// A stage-2 item can finish before a slow stage-1 item — but for determinism in this test we
|
||||
// just assert each item passes through both stages in order and results land in input order.
|
||||
const ctx = mockSubAgent("ok");
|
||||
const out = (await runWorkflow(
|
||||
`const out = await pipeline(
|
||||
["a", "b", "c"],
|
||||
async (item) => item + "1",
|
||||
async (item) => item + "2",
|
||||
);
|
||||
return out;`,
|
||||
undefined,
|
||||
ctx,
|
||||
)) as string[];
|
||||
expect(out).toEqual(["a12", "b12", "c12"]);
|
||||
});
|
||||
|
||||
it("a pipeline stage that throws drops just that item to null", async () => {
|
||||
const ctx = mockSubAgent("ok");
|
||||
const out = (await runWorkflow(
|
||||
`const out = await pipeline(
|
||||
["a", "b", "c"],
|
||||
async (item) => { if (item === "b") throw new Error("nope"); return item + "1"; },
|
||||
async (item) => item + "2",
|
||||
);
|
||||
return out;`,
|
||||
undefined,
|
||||
ctx,
|
||||
)) as (string | null)[];
|
||||
expect(out).toEqual(["a12", null, "c12"]);
|
||||
});
|
||||
|
||||
it("agent() with a schema returns a parsed, validated object (retrying once on bad JSON)", async () => {
|
||||
// First call returns non-JSON; the retry (nudge prompt) returns valid JSON matching the schema.
|
||||
const calls: string[] = [];
|
||||
const ctx = {
|
||||
runSubAgent: vi.fn(async (task: { prompt: string }): Promise<SubAgentResult> => {
|
||||
calls.push(task.prompt);
|
||||
if (task.prompt.includes("not valid JSON")) {
|
||||
return { agentId: "id", result: '{"answer": 42}', resumable: false };
|
||||
}
|
||||
return { agentId: "id", result: "I think the answer is 42.", resumable: false };
|
||||
}),
|
||||
};
|
||||
const out = await runWorkflow(
|
||||
`const r = await agent("what is the answer", {
|
||||
schema: { type: "object", required: ["answer"], properties: { answer: { type: "number" } } },
|
||||
});
|
||||
return r;`,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
expect(out).toEqual({ answer: 42 });
|
||||
expect(ctx.runSubAgent).toHaveBeenCalledTimes(2);
|
||||
// The retry carried a nudge.
|
||||
expect(calls[1]).toContain("not valid JSON");
|
||||
});
|
||||
|
||||
it("agent() with a schema throws if the retry still doesn't validate", async () => {
|
||||
const ctx = mockSubAgent("still not json at all");
|
||||
await expect(
|
||||
runWorkflow(
|
||||
`await agent("x", { schema: { type: "object", required: ["answer"] } });`,
|
||||
undefined,
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/valid JSON/);
|
||||
});
|
||||
|
||||
it("agent() retry nudge includes the specific validation error from the first attempt", async () => {
|
||||
const calls: string[] = [];
|
||||
const ctx = {
|
||||
runSubAgent: vi.fn(async (task: { prompt: string }): Promise<SubAgentResult> => {
|
||||
calls.push(task.prompt);
|
||||
if (calls.length === 2) {
|
||||
// Retry returns valid JSON.
|
||||
return { agentId: "id", result: '{"answer": 42}', resumable: false };
|
||||
}
|
||||
// First call returns JSON with a wrong type (answer is a string, schema wants number).
|
||||
return { agentId: "id", result: '{"answer": "forty-two"}', resumable: false };
|
||||
}),
|
||||
};
|
||||
const out = await runWorkflow(
|
||||
`const r = await agent("what is the answer", {
|
||||
schema: { type: "object", required: ["answer"], properties: { answer: { type: "number" } } },
|
||||
});
|
||||
return r;`,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
expect(out).toEqual({ answer: 42 });
|
||||
// The retry nudge quoted the first attempt's validation error (wrong type for `answer`).
|
||||
expect(calls[1]).toContain("not valid JSON");
|
||||
expect(calls[1]).toMatch(/answer.*expected number|expected number.*answer/);
|
||||
});
|
||||
|
||||
it("caps concurrency so a fan-out doesn't exceed the limit", async () => {
|
||||
process.env.LOCODE_WORKFLOW_CONCURRENCY = "2";
|
||||
try {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const ctx = {
|
||||
runSubAgent: vi.fn(async (): Promise<SubAgentResult> => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
active--;
|
||||
return { agentId: "id", result: "ok", resumable: false };
|
||||
}),
|
||||
};
|
||||
await runWorkflow(
|
||||
`await parallel([
|
||||
() => agent("1"), () => agent("2"), () => agent("3"),
|
||||
() => agent("4"), () => agent("5"), () => agent("6"),
|
||||
]);`,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.runSubAgent).toHaveBeenCalledTimes(6);
|
||||
expect(maxActive).toBeLessThanOrEqual(2);
|
||||
} finally {
|
||||
delete process.env.LOCODE_WORKFLOW_CONCURRENCY;
|
||||
}
|
||||
});
|
||||
|
||||
it("log() and phase() surface notices through ctx.emitNotice", async () => {
|
||||
const notices: { text: string; isError?: boolean }[] = [];
|
||||
const ctx = {
|
||||
runSubAgent: vi.fn(async (): Promise<SubAgentResult> => ({ agentId: "id", result: "ok", resumable: false })),
|
||||
emitNotice: (text: string, isError?: boolean) => notices.push({ text, isError }),
|
||||
};
|
||||
await runWorkflow(
|
||||
`phase("Review");\nlog("halfway");\nconst r = await agent("x");\nlog("done");\nreturn r;`,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
expect(notices).toContainEqual({ text: "▶ Review", isError: undefined });
|
||||
expect(notices.map((n) => n.text)).toEqual(["▶ Review", "halfway", "done"]);
|
||||
});
|
||||
|
||||
it("a thrown error fails the workflow", async () => {
|
||||
const ctx = mockSubAgent("ok");
|
||||
await expect(runWorkflow(`throw new Error("script broke");`, undefined, ctx)).rejects.toThrow("script broke");
|
||||
});
|
||||
|
||||
it("nested workflow() is rejected", async () => {
|
||||
const ctx = mockSubAgent("ok");
|
||||
await expect(runWorkflow(`await workflow();`, undefined, ctx)).rejects.toThrow(/cannot be nested/);
|
||||
});
|
||||
|
||||
it("resolves agentType to the specialist's toolset+addendum via overrides", async () => {
|
||||
const ctx = {
|
||||
runSubAgent: vi.fn(async (_task: unknown, overrides?: unknown): Promise<SubAgentResult> => {
|
||||
// Stash the overrides for assertion; return a plain result.
|
||||
(ctx as any).__overrides = overrides;
|
||||
return { agentId: "id", result: "ok", resumable: false };
|
||||
}),
|
||||
};
|
||||
await runWorkflow(`await agent("explore the repo", { agentType: "explore" });`, undefined, ctx);
|
||||
const overrides = (ctx as any).__overrides as { toolNames?: string[]; systemPromptAddendum?: string };
|
||||
expect(overrides?.toolNames).toContain("read_file");
|
||||
expect(overrides?.systemPromptAddendum).toContain("Explore agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWorkflow worktree isolation", () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(path.join(tmpdir(), "locode-wf-wt-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await execa("git", ["worktree", "prune"], { cwd: repo, reject: false }).catch(() => {});
|
||||
try {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* leave for the OS temp sweep */
|
||||
}
|
||||
});
|
||||
|
||||
async function gitInit(r: string): Promise<void> {
|
||||
await execa("git", ["init", "-q"], { cwd: r });
|
||||
await execa("git", ["config", "user.email", "t@t"], { cwd: r });
|
||||
await execa("git", ["config", "user.name", "t"], { cwd: r });
|
||||
writeFileSync(path.join(r, "README.md"), "hello\n");
|
||||
await execa("git", ["add", "."], { cwd: r });
|
||||
await execa("git", ["commit", "-q", "-m", "init"], { cwd: r });
|
||||
}
|
||||
|
||||
it("agent({isolation:'worktree'}) runs in a real worktree cwd distinct from the repo, cleaned up after", async () => {
|
||||
await gitInit(repo);
|
||||
let agentCwd: string | undefined;
|
||||
const ctx = {
|
||||
cwd: repo,
|
||||
runSubAgent: vi.fn(async (_task: unknown, overrides?: { cwd?: string }): Promise<SubAgentResult> => {
|
||||
agentCwd = overrides?.cwd;
|
||||
return { agentId: "id", result: "ok", resumable: false };
|
||||
}),
|
||||
};
|
||||
await runWorkflow(`await agent("edit things", { isolation: "worktree" });`, undefined, ctx);
|
||||
|
||||
// The agent ran in a throwaway worktree, not the shared repo cwd.
|
||||
expect(agentCwd).toBeDefined();
|
||||
expect(agentCwd).not.toBe(repo);
|
||||
expect(existsSync(agentCwd!)).toBe(false); // cleaned up after the agent finished
|
||||
});
|
||||
|
||||
it("agent({isolation:'worktree'}) on a non-git cwd falls back to the shared cwd (no isolation)", async () => {
|
||||
// repo exists but has no .git (gitInit not called) → createWorktree returns cwd:undefined.
|
||||
let agentCwd: string | undefined;
|
||||
const ctx = {
|
||||
cwd: repo,
|
||||
runSubAgent: vi.fn(async (_task: unknown, overrides?: { cwd?: string }): Promise<SubAgentResult> => {
|
||||
agentCwd = overrides?.cwd;
|
||||
return { agentId: "id", result: "ok", resumable: false };
|
||||
}),
|
||||
};
|
||||
await runWorkflow(`await agent("edit things", { isolation: "worktree" });`, undefined, ctx);
|
||||
// No git → no worktree → the agent runs in the shared cwd (undefined override = inherit parent).
|
||||
expect(agentCwd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("agent({isolation:'worktree'}) still cleans up the worktree even when the agent throws", async () => {
|
||||
await gitInit(repo);
|
||||
let agentCwd: string | undefined;
|
||||
const ctx = {
|
||||
cwd: repo,
|
||||
runSubAgent: vi.fn(async (_task: unknown, overrides?: { cwd?: string }): Promise<SubAgentResult> => {
|
||||
agentCwd = overrides?.cwd;
|
||||
throw new Error("agent exploded");
|
||||
}),
|
||||
};
|
||||
await expect(
|
||||
runWorkflow(`await agent("edit things", { isolation: "worktree" });`, undefined, ctx),
|
||||
).rejects.toThrow("agent exploded");
|
||||
expect(agentCwd).toBeDefined();
|
||||
expect(existsSync(agentCwd!)).toBe(false); // the finally cleaned up despite the throw
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import vm from "node:vm";
|
||||
import { z } from "zod";
|
||||
import { getAgentType } from "./agentTypes.js";
|
||||
import type { SubAgentOverrides, SubAgentResult, ToolDef } from "./types.js";
|
||||
import { createWorktree, type WorktreeIsolation } from "../utils/worktree.js";
|
||||
|
||||
/** Default cap on concurrent in-flight sub-agents inside a workflow. Local backends (Ollama, LM
|
||||
* Studio) typically serve a single model and can't truly parallelize many concurrent request
|
||||
* streams — an unbounded fan-out would queue a large burst and risk OOM/timeout. The cap keeps the
|
||||
* burst bounded; the model server serializes what it can't concurrentize. Override via
|
||||
* LOCODE_WORKFLOW_CONCURRENCY. */
|
||||
function workflowConcurrency(): number {
|
||||
const raw = Number(process.env.LOCODE_WORKFLOW_CONCURRENCY);
|
||||
if (Number.isFinite(raw) && raw >= 1) return Math.floor(raw);
|
||||
return 4;
|
||||
}
|
||||
|
||||
/** Wall-clock backstop for the whole workflow (the script itself is fast between agent calls; the
|
||||
* real time is in the agents, each already bounded by SUBAGENT_TIMEOUT_MS). This catches a script
|
||||
* that loops forever spawning agents. Override via LOCODE_WORKFLOW_TIMEOUT_MS. */
|
||||
function workflowTimeoutMs(): number {
|
||||
const raw = Number(process.env.LOCODE_WORKFLOW_TIMEOUT_MS);
|
||||
if (Number.isFinite(raw) && raw >= 1000) return Math.floor(raw);
|
||||
return 15 * 60 * 1000;
|
||||
}
|
||||
|
||||
/** A JSON-Schema-ish spec for forcing structured output from a sub-agent. Minimal validation only
|
||||
* (required keys, property/item types, enums, nested objects) — locode can't force a native tool
|
||||
* call for structured output the way Claude Code can, so this is best-effort: the sub-agent is told
|
||||
* to return JSON, we parse it, and retry once if it doesn't validate. */
|
||||
interface JsonSchemaProperty {
|
||||
type?: string;
|
||||
enum?: unknown[];
|
||||
items?: JsonSchemaProperty;
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
}
|
||||
|
||||
interface JsonSchema extends JsonSchemaProperty {}
|
||||
|
||||
const schema = z.object({
|
||||
script: z
|
||||
.string()
|
||||
.describe(
|
||||
"A self-contained JavaScript workflow script. It may begin with `const meta = { name, description, phases }` " +
|
||||
"(metadata only). Use the provided globals to orchestrate: agent(prompt, opts?) runs a sub-agent and returns its " +
|
||||
"answer (or a validated object when opts.schema is given); parallel([() => ..., () => ...]) runs thunks concurrently " +
|
||||
"(failures become null); pipeline(items, stage1, stage2, ...) runs each item through all stages with no barrier between " +
|
||||
"stages; phase(title) marks a progress group; log(message) surfaces a progress line to the user. Return a value to make " +
|
||||
"it the workflow's result. No filesystem or Node APIs; no Date.now/Math.random. A thrown error fails the workflow.",
|
||||
),
|
||||
args: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe("Free-form value exposed to the script as the global `args` (pass arrays/objects, not a stringified string)."),
|
||||
});
|
||||
|
||||
/** Strips ES-module `export` keywords so a script written in Claude-Code's `export const meta` style
|
||||
* runs as plain script source inside the vm. Handles `export const/function/default` at line starts. */
|
||||
function stripExports(src: string): string {
|
||||
return src.replace(/^export\s+(default\s+)?/gm, "");
|
||||
}
|
||||
|
||||
/** Extracts the first balanced JSON value (object or array) from text that may have surrounding
|
||||
* prose/code fences — local models often wrap JSON in ```json … ``` (or ```javascript, ```ts, …)
|
||||
* and add commentary. Tolerates trailing commas, which local models emit frequently even though
|
||||
* they're invalid JSON. */
|
||||
function extractJson(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
// Strip a surrounding ```lang ... ``` fence with any language tag (json, javascript, ts, …).
|
||||
// Local models label fences with whatever language they think the content is, so accept any tag.
|
||||
const fenced = trimmed.match(/```[a-zA-Z0-9+#]*\s*([\s\S]*?)```/);
|
||||
const candidate = (fenced ? fenced[1]! : trimmed).trim();
|
||||
// Local models frequently emit trailing commas before } or ] (invalid JSON). Strip them.
|
||||
const sanitized = stripTrailingCommas(candidate);
|
||||
try {
|
||||
return JSON.parse(sanitized);
|
||||
} catch {
|
||||
// Fall back to the first {...} or [...] span, also comma-sanitized.
|
||||
const span = candidate.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
|
||||
if (span) {
|
||||
try {
|
||||
return JSON.parse(stripTrailingCommas(span[1]!));
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Sub-agent did not return valid JSON for structured output.");
|
||||
}
|
||||
|
||||
/** Removes trailing commas that immediately precede a closing } or ] — a common local-model
|
||||
* artifact that makes otherwise-valid JSON unparseable. */
|
||||
function stripTrailingCommas(s: string): string {
|
||||
return s.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
|
||||
/** Minimal JSON-Schema validation: checks the top-level shape (object/array), required keys,
|
||||
* property types, array item types, enum membership, and nested object properties. Intentionally
|
||||
* not a full validator — just enough to catch an obviously-wrong shape and trigger the one retry
|
||||
* in agentFn. Error messages are kept stable (e.g. "missing required field: x") so the retry nudge
|
||||
* can quote them back to the sub-agent. */
|
||||
function validateAgainstSchema(value: unknown, schema: JsonSchema): void {
|
||||
// Top-level shape checks keep the stable, friendly messages callers (and the retry nudge) rely on.
|
||||
if (schema.type === "object") {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("expected a JSON object");
|
||||
}
|
||||
} else if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) throw new Error("expected a JSON array");
|
||||
}
|
||||
// Deeper checks (required keys, property types, items, enum, nested objects) share validateProperty.
|
||||
validateProperty(value, schema, "");
|
||||
}
|
||||
|
||||
/** Validates a value against a single property/schema descriptor. `path` is the dotted location used
|
||||
* in error messages ("" at the top level, "field a" / "field a.b" / "item[2]" beneath it). */
|
||||
function validateProperty(value: unknown, prop: JsonSchemaProperty, path: string): void {
|
||||
if (prop.type) checkType(value, prop.type, path);
|
||||
if (prop.enum && !prop.enum.includes(value)) {
|
||||
throw new Error(`${path || "value"}: value not in allowed enum`);
|
||||
}
|
||||
if (prop.type === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of prop.required ?? []) {
|
||||
if (!(key in obj)) throw new Error(`${path ? path + ": " : ""}missing required field: ${key}`);
|
||||
}
|
||||
if (prop.properties) {
|
||||
for (const [key, sub] of Object.entries(prop.properties)) {
|
||||
if (key in obj) validateProperty(obj[key], sub, path ? `${path}.${key}` : `field ${key}`);
|
||||
}
|
||||
}
|
||||
if (prop.additionalProperties === false && prop.properties) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!(key in prop.properties)) throw new Error(`${path ? path + ": " : ""}unexpected additional property: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prop.type === "array" && Array.isArray(value) && prop.items) {
|
||||
value.forEach((item, i) => validateProperty(item, prop.items!, path ? `${path}[${i}]` : `item[${i}]`));
|
||||
}
|
||||
}
|
||||
|
||||
function checkType(v: unknown, type: string, field: string): void {
|
||||
const jsType = Array.isArray(v) ? "array" : v === null ? "null" : typeof v;
|
||||
// integer: must be a number AND a whole number. number: any number (incl. floats).
|
||||
if (type === "integer") {
|
||||
if (typeof v !== "number" || !Number.isInteger(v)) throw new Error(`field ${field}: expected integer`);
|
||||
return;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof v !== "number") throw new Error(`field ${field}: expected number`);
|
||||
return;
|
||||
}
|
||||
if (jsType !== type) throw new Error(`field ${field}: expected ${type}, got ${jsType}`);
|
||||
}
|
||||
|
||||
interface AgentOpts {
|
||||
label?: string;
|
||||
schema?: JsonSchema;
|
||||
toolNames?: string[];
|
||||
systemPromptAddendum?: string;
|
||||
cwd?: string;
|
||||
agentType?: string;
|
||||
/** Opt the agent into running inside a throwaway git worktree (detached at HEAD), so its file
|
||||
* writes can't collide with the main repo or with other concurrently-running agents. Use this for
|
||||
* writable agents (general-purpose, debugger, test-writer, or plugin agents) that you fan out in
|
||||
* parallel — their edits are discarded and only the returned answer matters. If the parent cwd
|
||||
* isn't a git repo, isolation is best-effort and the agent runs in the shared cwd. Don't use it
|
||||
* for read-only agents (explore, code-reviewer, planner) — they should see current uncommitted
|
||||
* state, so run them in the shared cwd. */
|
||||
isolation?: "worktree";
|
||||
}
|
||||
|
||||
/** Builds the SubAgentOverrides for an agent() call, resolving agentType to its toolset+addendum
|
||||
* unless the script explicitly supplies either. */
|
||||
function buildOverrides(opts: AgentOpts): SubAgentOverrides | undefined {
|
||||
if (opts.toolNames || opts.systemPromptAddendum) {
|
||||
return { toolNames: opts.toolNames, systemPromptAddendum: opts.systemPromptAddendum, cwd: opts.cwd };
|
||||
}
|
||||
if (opts.agentType && opts.agentType !== "general-purpose") {
|
||||
const spec = getAgentType(opts.agentType);
|
||||
return { toolNames: spec.toolNames, systemPromptAddendum: spec.systemPromptAddendum, cwd: opts.cwd };
|
||||
}
|
||||
return opts.cwd ? { cwd: opts.cwd } : undefined;
|
||||
}
|
||||
|
||||
/** Runs a workflow script with the orchestration globals in scope. Returns whatever the script
|
||||
* returns (or throws). agent/parallel/pipeline run sub-agents via ctx.runSubAgent, bounded by the
|
||||
* concurrency cap and the overall timeout. */
|
||||
async function runWorkflow(
|
||||
script: string,
|
||||
args: unknown,
|
||||
ctx: {
|
||||
cwd?: string;
|
||||
runSubAgent?: (task: { description: string; prompt: string }, overrides?: SubAgentOverrides) => Promise<SubAgentResult>;
|
||||
emitNotice?: (text: string, isError?: boolean) => void;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
if (!ctx.runSubAgent) throw new Error("Sub-agents are not available in this context.");
|
||||
|
||||
const concurrency = workflowConcurrency();
|
||||
|
||||
// A bounded concurrency runner: at most `limit` thunks in flight at once. Used by parallel() and
|
||||
// pipeline() so a fan-out over many items doesn't dump a huge burst onto a local model server.
|
||||
async function runBounded<I, O>(limit: number, items: I[], fn: (item: I, index: number) => Promise<O>): Promise<O[]> {
|
||||
const results: O[] = new Array(items.length);
|
||||
let next = 0;
|
||||
async function worker(): Promise<void> {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await fn(items[i]!, i);
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function agentFn(prompt: string, opts: AgentOpts = {}): Promise<unknown> {
|
||||
const description = opts.label ?? "workflow-agent";
|
||||
// Opt-in worktree isolation for writable parallel agents (see AgentOpts.isolation). Created here
|
||||
// and cleaned up in the finally below — runSubAgentTurn treats a cwd != parent.cwd as "isolated"
|
||||
// (edits discarded, not resumable) and appends the discard-notice itself. Best-effort: if the
|
||||
// parent cwd isn't a git repo, createWorktree returns cwd:undefined and we run in the shared cwd.
|
||||
let isolation: WorktreeIsolation | undefined;
|
||||
if (opts.isolation === "worktree") isolation = await createWorktree(ctx.cwd ?? ".");
|
||||
const worktreeCwd = isolation?.cwd;
|
||||
try {
|
||||
const overrides = buildOverrides({ ...opts, cwd: opts.cwd ?? worktreeCwd });
|
||||
const structuredAddendum = opts.schema
|
||||
? `\n\nReturn ONLY a JSON ${opts.schema.type ?? "object"} matching this schema (no prose, no code fences):\n${JSON.stringify(opts.schema)}`
|
||||
: undefined;
|
||||
const callOverrides = structuredAddendum
|
||||
? {
|
||||
...overrides,
|
||||
systemPromptAddendum: overrides?.systemPromptAddendum
|
||||
? `${overrides.systemPromptAddendum}\n${structuredAddendum}`
|
||||
: structuredAddendum,
|
||||
}
|
||||
: overrides;
|
||||
|
||||
const r1 = await ctx.runSubAgent!({ description, prompt }, callOverrides);
|
||||
if (!opts.schema) return r1.result;
|
||||
// Structured output: parse + validate, retry once with a targeted nudge if it fails. The nudge
|
||||
// includes the actual validation error so the sub-agent can correct the specific defect.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = extractJson(r1.result);
|
||||
validateAgainstSchema(parsed, opts.schema);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
const why = (err as Error).message;
|
||||
const required = opts.schema.required ?? Object.keys(opts.schema.properties ?? {});
|
||||
const nudge = `${prompt}\n\nYour previous response was not valid JSON matching the schema (${why}). Return ONLY a JSON object with these fields: ${JSON.stringify(required)}. No prose, no code fences, no trailing commas.`;
|
||||
const r2 = await ctx.runSubAgent!({ description, prompt: nudge }, callOverrides);
|
||||
parsed = extractJson(r2.result);
|
||||
validateAgainstSchema(parsed, opts.schema);
|
||||
return parsed;
|
||||
}
|
||||
} finally {
|
||||
await isolation?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function parallelFn<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>> {
|
||||
if (!Array.isArray(thunks)) throw new Error("parallel() expects an array of thunks");
|
||||
return runBounded<() => Promise<T>, T | null>(concurrency, thunks, async (thunk) => {
|
||||
try {
|
||||
return await thunk();
|
||||
} catch (err) {
|
||||
// A thunk that throws (or whose agent errors) resolves to null — the call itself never
|
||||
// rejects, so one failing branch doesn't abort the whole parallel batch.
|
||||
ctx.emitNotice?.(`parallel branch failed: ${(err as Error).message ?? String(err)}`, true);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pipelineFn<T, R>(items: T[], ...stages: Array<(prev: unknown, original: T, index: number) => Promise<unknown>>): Promise<Array<R | null>> {
|
||||
if (!Array.isArray(items)) throw new Error("pipeline() expects an array of items");
|
||||
if (stages.length === 0) throw new Error("pipeline() needs at least one stage");
|
||||
return runBounded<T, R | null>(concurrency, items, async (item, index) => {
|
||||
try {
|
||||
let val: unknown = item;
|
||||
for (const stage of stages) {
|
||||
val = await stage(val, item, index);
|
||||
}
|
||||
return val as R;
|
||||
} catch (err) {
|
||||
// A stage that throws drops just this item to null (skipping its remaining stages).
|
||||
ctx.emitNotice?.(`pipeline item ${index} failed: ${(err as Error).message ?? String(err)}`, true);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function phaseFn(title: string): void {
|
||||
ctx.emitNotice?.(`▶ ${title}`);
|
||||
}
|
||||
|
||||
function logFn(message: string): void {
|
||||
ctx.emitNotice?.(String(message));
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
agent: agentFn,
|
||||
parallel: parallelFn,
|
||||
pipeline: pipelineFn,
|
||||
phase: phaseFn,
|
||||
log: logFn,
|
||||
args,
|
||||
// Nested workflows are one level only (matches Claude Code); there's no child context to run in.
|
||||
workflow: () => {
|
||||
throw new Error("workflow() cannot be nested.");
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = `(async () => {\n${stripExports(script)}\n})()`;
|
||||
const context = vm.createContext(sandbox);
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`Workflow timed out after ${Math.round(workflowTimeoutMs() / 1000)}s.`)),
|
||||
workflowTimeoutMs(),
|
||||
);
|
||||
});
|
||||
try {
|
||||
const promise = vm.runInContext(wrapped, context, { filename: "workflow.js" }) as Promise<unknown>;
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "workflow",
|
||||
description:
|
||||
"Run a multi-agent workflow from a self-contained JavaScript script that deterministically orchestrates sub-agents — " +
|
||||
"for being comprehensive (decompose and cover in parallel), confident (independent perspectives + adversarial checks " +
|
||||
"before committing), or scaling work one context can't hold (migrations, audits). Use it only when the user asks for " +
|
||||
"multi-agent orchestration (e.g. 'use a workflow', 'fan out agents'); a single agent() call inside is just a delegation. " +
|
||||
"The script globals are agent, parallel, pipeline, phase, log, args. agent(prompt, opts) accepts opts.isolation: 'worktree' " +
|
||||
"to run a writable agent in a throwaway git worktree so parallel writers don't collide (edits discarded; only the answer " +
|
||||
"matters) — use it for parallel general-purpose/debugger/test-writer agents, not for read-only explore/code-reviewer ones. " +
|
||||
"Runs headless; the return value is the result.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const result = await runWorkflow(args.script, args.args, ctx);
|
||||
return { result };
|
||||
},
|
||||
};
|
||||
|
||||
// Exported for tests.
|
||||
export { runWorkflow, extractJson, validateAgainstSchema, stripExports };
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { execa } from "execa";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { enterWorktreeTool, exitWorktreeTool } from "./worktreeSession.js";
|
||||
import { removeInteractiveWorktree } from "../utils/worktree.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
// These tests shell out to real git for the create/remove paths, and use a mocked ctx to observe
|
||||
// the setCwd/setWorktree/getWorktree wiring without a full Session.
|
||||
|
||||
async function gitInit(repo: string): Promise<void> {
|
||||
await execa("git", ["init", "-q"], { cwd: repo });
|
||||
await execa("git", ["config", "user.email", "t@t"], { cwd: repo });
|
||||
await execa("git", ["config", "user.name", "t"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
await execa("git", ["add", "."], { cwd: repo });
|
||||
await execa("git", ["commit", "-q", "-m", "init"], { cwd: repo });
|
||||
}
|
||||
|
||||
// A mock ctx that records setCwd calls and tracks the worktree over a shared object, mirroring how
|
||||
// gateAndRun wires these over the Session. `cwd` is the current working dir (the repo, or the
|
||||
// worktree once switched).
|
||||
function ctxFor(cwd: string): { ctx: ToolContext; state: { cwd: string; worktree?: { dir: string; branch: string; originalCwd: string } } } {
|
||||
const state: { cwd: string; worktree?: { dir: string; branch: string; originalCwd: string } } = { cwd };
|
||||
const ctx: ToolContext = {
|
||||
cwd,
|
||||
setCwd: (newCwd) => {
|
||||
state.cwd = newCwd;
|
||||
},
|
||||
getWorktree: () => state.worktree,
|
||||
setWorktree: (wt) => {
|
||||
state.worktree = wt;
|
||||
},
|
||||
};
|
||||
return { ctx, state };
|
||||
}
|
||||
|
||||
describe("enter_worktree / exit_worktree tool wiring", () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(path.join(os.tmpdir(), "locode-wt-tool-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(repo)) {
|
||||
await execa("git", ["worktree", "prune"], { cwd: repo, reject: false }).catch(() => {});
|
||||
try {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* leave for the OS temp sweep */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("enter_worktree throws when the ctx doesn't support worktree sessions", async () => {
|
||||
const ctx: ToolContext = { cwd: repo };
|
||||
await expect(enterWorktreeTool.handler({}, ctx)).rejects.toThrow(/not available/i);
|
||||
});
|
||||
|
||||
it("enter_worktree refuses when already in a worktree session", async () => {
|
||||
await gitInit(repo);
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
state.worktree = { dir: "/tmp/prev", branch: "locode-wt-prev", originalCwd: repo };
|
||||
const result = (await enterWorktreeTool.handler({ name: "second" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/already in a worktree/i);
|
||||
});
|
||||
|
||||
it("enter_worktree returns an error (without switching) for a non-git cwd", async () => {
|
||||
// repo dir exists but no git init.
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
const result = (await enterWorktreeTool.handler({ name: "x" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/Not a git repository/i);
|
||||
expect(state.worktree).toBeUndefined();
|
||||
expect(state.cwd).toBe(repo);
|
||||
});
|
||||
|
||||
it("enter_worktree creates a worktree, switches cwd, and records the worktree", async () => {
|
||||
await gitInit(repo);
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
const result = (await enterWorktreeTool.handler({ name: "feature" }, ctx)) as {
|
||||
dir: string;
|
||||
branch: string;
|
||||
message: string;
|
||||
};
|
||||
expect(result.branch).toBe("locode-wt-feature");
|
||||
expect(existsSync(result.dir)).toBe(true);
|
||||
// The tool switched the cwd into the worktree and recorded the tracking (with the original cwd).
|
||||
expect(state.cwd).toBe(result.dir);
|
||||
expect(state.worktree).toEqual({ dir: result.dir, branch: "locode-wt-feature", originalCwd: repo });
|
||||
// Clean up the created worktree+branch so afterEach's repo removal is clean.
|
||||
await removeInteractiveWorktree(repo, result.dir, result.branch);
|
||||
});
|
||||
|
||||
it("exit_worktree returns an error when not in a worktree session", async () => {
|
||||
const { ctx } = ctxFor(repo);
|
||||
const result = (await exitWorktreeTool.handler({ action: "keep" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/not in a worktree/i);
|
||||
});
|
||||
|
||||
it("exit_worktree(keep) restores the original cwd and clears the worktree tracking", async () => {
|
||||
await gitInit(repo);
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
const enter = (await enterWorktreeTool.handler({ name: "keepme" }, ctx)) as { dir: string; branch: string };
|
||||
expect(state.cwd).toBe(enter.dir);
|
||||
const result = (await exitWorktreeTool.handler({ action: "keep" }, ctx)) as {
|
||||
action: string;
|
||||
restoredCwd: string;
|
||||
};
|
||||
expect(result.action).toBe("keep");
|
||||
expect(result.restoredCwd).toBe(repo);
|
||||
expect(state.cwd).toBe(repo);
|
||||
expect(state.worktree).toBeUndefined();
|
||||
// keep leaves the worktree dir + branch in place.
|
||||
expect(existsSync(enter.dir)).toBe(true);
|
||||
// Clean up the kept worktree so afterEach can remove the repo.
|
||||
await removeInteractiveWorktree(repo, enter.dir, enter.branch);
|
||||
});
|
||||
|
||||
it("exit_worktree(remove) refuses a dirty worktree without discardChanges (no removal, no restore)", async () => {
|
||||
await gitInit(repo);
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
const enter = (await enterWorktreeTool.handler({ name: "dirty" }, ctx)) as { dir: string; branch: string };
|
||||
writeFileSync(path.join(enter.dir, "README.md"), "changed\n"); // uncommitted change
|
||||
const result = (await exitWorktreeTool.handler({ action: "remove" }, ctx)) as { error: string };
|
||||
expect(result.error).toMatch(/uncommitted changes/i);
|
||||
// Refusal leaves everything in place: still in the worktree, worktree still tracked, dir still exists.
|
||||
expect(state.cwd).toBe(enter.dir);
|
||||
expect(state.worktree).toBeDefined();
|
||||
expect(existsSync(enter.dir)).toBe(true);
|
||||
// Now opt in to discard → removes + restores.
|
||||
const ok = (await exitWorktreeTool.handler({ action: "remove", discardChanges: true }, ctx)) as {
|
||||
action: string;
|
||||
restoredCwd: string;
|
||||
};
|
||||
expect(ok.action).toBe("remove");
|
||||
expect(ok.restoredCwd).toBe(repo);
|
||||
expect(state.cwd).toBe(repo);
|
||||
expect(state.worktree).toBeUndefined();
|
||||
expect(existsSync(enter.dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("exit_worktree(remove) on a clean worktree removes + restores without discardChanges", async () => {
|
||||
await gitInit(repo);
|
||||
const { ctx, state } = ctxFor(repo);
|
||||
const enter = (await enterWorktreeTool.handler({ name: "clean" }, ctx)) as { dir: string; branch: string };
|
||||
const result = (await exitWorktreeTool.handler({ action: "remove" }, ctx)) as {
|
||||
action: string;
|
||||
restoredCwd: string;
|
||||
};
|
||||
expect(result.action).toBe("remove");
|
||||
expect(state.cwd).toBe(repo);
|
||||
expect(state.worktree).toBeUndefined();
|
||||
expect(existsSync(enter.dir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktree tool flags + schema", () => {
|
||||
it("enter_worktree is non-mutating; exit_worktree is mutating (destructive on remove)", () => {
|
||||
expect(enterWorktreeTool.mutating).toBe(false);
|
||||
expect(exitWorktreeTool.mutating).toBe(true);
|
||||
});
|
||||
it("exit_worktree action is required and must be keep|remove", () => {
|
||||
expect(() => exitWorktreeTool.schema.parse({})).toThrow();
|
||||
expect(() => exitWorktreeTool.schema.parse({ action: "other" })).toThrow();
|
||||
expect(() => exitWorktreeTool.schema.parse({ action: "keep" })).not.toThrow();
|
||||
});
|
||||
it("enter_worktree name is optional", () => {
|
||||
expect(() => enterWorktreeTool.schema.parse({})).not.toThrow();
|
||||
expect(() => enterWorktreeTool.schema.parse({ name: "feature" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
import {
|
||||
createInteractiveWorktree,
|
||||
hasUncommittedChanges,
|
||||
removeInteractiveWorktree,
|
||||
} from "../utils/worktree.js";
|
||||
|
||||
const enterSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional name for the worktree and its branch (locode-wt-<name>). Letters, digits, dot, " +
|
||||
"underscore, dash, starting alphanumeric, max 64 chars. Omit to auto-generate. Must be unique — " +
|
||||
"re-using an existing branch name returns an error.",
|
||||
),
|
||||
});
|
||||
|
||||
const exitSchema = z.object({
|
||||
action: z
|
||||
.enum(["keep", "remove"])
|
||||
.describe(
|
||||
"'keep' leaves the worktree directory and branch in place (restored to the original working " +
|
||||
"directory; the branch is preserved in git). 'remove' deletes the worktree directory AND the " +
|
||||
"branch (irreversible).",
|
||||
),
|
||||
discardChanges: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Only matters for action 'remove'. If the worktree has uncommitted changes, removal is refused " +
|
||||
"unless this is true (the changes are then discarded along with the worktree and branch).",
|
||||
),
|
||||
});
|
||||
|
||||
/** Enters an interactive git worktree session: creates a new branch `locode-wt-<name>` at HEAD in a
|
||||
* throwaway directory, switches the session's working directory into it, and remembers the original
|
||||
* cwd so `exit_worktree` can restore it. While in the worktree, all file tools operate there (the
|
||||
* workspace root becomes the worktree), so experiments can't touch the user's uncommitted work in
|
||||
* the main repo. Non-mutating: it creates an isolated copy, not an edit to user files. Refuses if
|
||||
* already in a worktree (exit first) or the cwd isn't a git repo. */
|
||||
export const enterWorktreeTool: ToolDef<z.infer<typeof enterSchema>> = {
|
||||
name: "enter_worktree",
|
||||
description:
|
||||
"Create an isolated git worktree on a new branch (locode-wt-<name>) at the current HEAD and switch the " +
|
||||
"session's working directory into it. Use it to try changes without touching the main working tree — " +
|
||||
"the worktree starts from the last commit, so uncommitted changes in the main repo don't carry over. " +
|
||||
"While inside, every file tool (read_file, edit_file, grep, bash, etc.) operates in the worktree. Leave " +
|
||||
"with exit_worktree (keep preserves the branch; remove discards it). Refuses if already in a worktree " +
|
||||
"(exit first) or the cwd isn't a git repo. Only the main session should use this (not sub-agents).",
|
||||
schema: enterSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.setCwd || !ctx.setWorktree || !ctx.getWorktree) {
|
||||
throw new Error("Worktree sessions are not available in this context.");
|
||||
}
|
||||
if (ctx.getWorktree()) {
|
||||
return { error: "Already in a worktree session. Use exit_worktree (keep or remove) before entering another." };
|
||||
}
|
||||
const parentCwd = ctx.cwd;
|
||||
try {
|
||||
const wt = await createInteractiveWorktree(parentCwd, args.name);
|
||||
ctx.setWorktree({ dir: wt.dir, branch: wt.branch, originalCwd: parentCwd });
|
||||
ctx.setCwd(wt.dir);
|
||||
return {
|
||||
dir: wt.dir,
|
||||
branch: wt.branch,
|
||||
message:
|
||||
`Switched into worktree at ${wt.dir} on branch ${wt.branch}. File tools now operate there. ` +
|
||||
`Use exit_worktree to leave (keep the branch, or remove it).`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Leaves the active worktree session, restoring the session's working directory to the original cwd.
|
||||
* `action: "keep"` leaves the worktree dir + branch in place (the branch persists in git; the temp dir
|
||||
* remains until the process/OS reclaims it). `action: "remove"` deletes the worktree dir AND the
|
||||
* branch — refused if the worktree has uncommitted changes unless `discardChanges: true`. Mutating
|
||||
* (a remove is destructive), so the user is asked to confirm. Refuses if not in a worktree session. */
|
||||
export const exitWorktreeTool: ToolDef<z.infer<typeof exitSchema>> = {
|
||||
name: "exit_worktree",
|
||||
description:
|
||||
"Leave the active worktree session, restoring the working directory to where it was before enter_worktree. " +
|
||||
"action 'keep' preserves the worktree directory and branch (the branch stays in git — recover the work via " +
|
||||
"git checkout/worktree add later); action 'remove' deletes the worktree directory AND the branch. A remove " +
|
||||
"is refused when the worktree has uncommitted changes unless discardChanges is true. Use this only after " +
|
||||
"enter_worktree; it returns an error if you're not in a worktree session.",
|
||||
schema: exitSchema,
|
||||
mutating: true,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.setCwd || !ctx.setWorktree || !ctx.getWorktree) {
|
||||
throw new Error("Worktree sessions are not available in this context.");
|
||||
}
|
||||
const wt = ctx.getWorktree();
|
||||
if (!wt) {
|
||||
return { error: "Not in a worktree session — nothing to exit." };
|
||||
}
|
||||
if (args.action === "remove") {
|
||||
// Refuse to silently destroy uncommitted work; the model must opt in via discardChanges.
|
||||
let dirty = false;
|
||||
try {
|
||||
dirty = await hasUncommittedChanges(wt.dir);
|
||||
} catch {
|
||||
dirty = false;
|
||||
}
|
||||
if (dirty && !args.discardChanges) {
|
||||
return {
|
||||
error:
|
||||
`Worktree "${wt.branch}" has uncommitted changes. Re-run with discardChanges: true to discard them ` +
|
||||
`along with the worktree and branch, or use action: "keep" to preserve them.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await removeInteractiveWorktree(wt.originalCwd, wt.dir, wt.branch);
|
||||
} catch (err) {
|
||||
return { error: `Failed to remove worktree: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
// Restore the session cwd for both keep and remove, and clear the tracking.
|
||||
ctx.setCwd(wt.originalCwd);
|
||||
ctx.setWorktree(undefined);
|
||||
return {
|
||||
action: args.action,
|
||||
restoredCwd: wt.originalCwd,
|
||||
message:
|
||||
args.action === "keep"
|
||||
? `Left worktree ${wt.dir} in place (branch ${wt.branch} is preserved in git). Restored working directory to ${wt.originalCwd}.`
|
||||
: `Removed worktree ${wt.dir} and deleted branch ${wt.branch}. Restored working directory to ${wt.originalCwd}.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeFileTool } from "./writeFile.js";
|
||||
|
||||
describe("writeFileTool", () => {
|
||||
it("rejects paths that escape the working directory", async () => {
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "locode-writefile-"));
|
||||
try {
|
||||
await expect(
|
||||
writeFileTool.handler({ path: "../outside.txt", content: "x" }, { cwd }),
|
||||
).rejects.toThrow("Path resolves outside the working directory");
|
||||
await expect(
|
||||
writeFileTool.handler({ path: "sub/../../outside.txt", content: "x" }, { cwd }),
|
||||
).rejects.toThrow("Path resolves outside the working directory");
|
||||
await expect(
|
||||
writeFileTool.handler({ path: "/etc/passwd", content: "x" }, { cwd }),
|
||||
).rejects.toThrow("Path resolves outside the working directory");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes files inside the working directory", async () => {
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "locode-writefile-"));
|
||||
try {
|
||||
const result = (await writeFileTool.handler({ path: "nested/file.txt", content: "hello" }, { cwd })) as { path: string };
|
||||
expect(result.path).toBe(path.join(cwd, "nested/file.txt"));
|
||||
const content = await readFile(path.join(cwd, "nested/file.txt"), "utf-8");
|
||||
expect(content).toBe("hello");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -2,6 +2,7 @@ import { createPatch } from "diff";
|
||||
import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { assertWithinWorkspace } from "../utils/path.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -19,11 +20,14 @@ async function readExisting(resolved: string): Promise<string | null> {
|
||||
|
||||
export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "write_file",
|
||||
description: "Create or overwrite a file with the given content.",
|
||||
description:
|
||||
"Create or overwrite a file with the given content. Use this for new files or when rewriting " +
|
||||
"most of a file; prefer edit_file for small, targeted changes.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, content }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
const existing = await readExisting(resolved);
|
||||
if (existing === null) {
|
||||
return `Create new file ${resolved} (${content.length} chars)`;
|
||||
@@ -32,8 +36,13 @@ export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
},
|
||||
handler: async ({ path: filePath, content }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
assertWithinWorkspace(resolved, ctx.cwd, filePath);
|
||||
const previousContent = await readExisting(resolved);
|
||||
await mkdir(path.dirname(resolved), { recursive: true });
|
||||
await fsWriteFile(resolved, content, "utf-8");
|
||||
if (ctx.setLastEdit) {
|
||||
ctx.setLastEdit({ path: filePath, previousContent: previousContent ?? "" });
|
||||
}
|
||||
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
|
||||
},
|
||||
};
|
||||
|
||||
+266
-41
@@ -16,6 +16,7 @@ import {
|
||||
createSessionFromRecord,
|
||||
resetSession,
|
||||
setMode,
|
||||
setPermissionMode,
|
||||
toSessionRecord,
|
||||
type Session,
|
||||
} from "../../agent/session.js";
|
||||
@@ -25,7 +26,7 @@ import type { ToolCallMode } from "../../backend/capabilityProbe.js";
|
||||
import { setCachedMode } from "../../backend/capabilityCache.js";
|
||||
import { resolveContextWindow } from "../../backend/contextWindow.js";
|
||||
import { resolveToolCallMode } from "../../backend/resolveMode.js";
|
||||
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
|
||||
import { resolveAutoCompactThreshold, resolveMaxIterations, resolveSubagentMaxDepth, resolveSubagentMaxIterations } from "../../config/config.js";
|
||||
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
|
||||
import { getMcpStatuses } from "../../mcp/manager.js";
|
||||
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
|
||||
@@ -38,6 +39,10 @@ import { findSkillCollisions } from "../../plugins/skillTool.js";
|
||||
import { buildImportContent } from "../../utils/importFile.js";
|
||||
import { extractMentionedFiles } from "../../utils/mentions.js";
|
||||
import { loadProjectInstructions } from "../../utils/projectInstructions.js";
|
||||
import { loadProjectPermissionRules, mergePermissionRules } from "../../utils/projectSettings.js";
|
||||
import { loadStoredConfig, configDirPath } from "../../config/store.js";
|
||||
import { listMemoryEntries, loadUserMemory, userMemoryDir } from "../../utils/userMemory.js";
|
||||
import { CronStore } from "../../scheduler/cron.js";
|
||||
import {
|
||||
deriveTitle,
|
||||
listSessions,
|
||||
@@ -48,12 +53,17 @@ import {
|
||||
} from "../../persistence/sessionStore.js";
|
||||
import { onBackgroundJobDone } from "../../tools/backgroundJobs.js";
|
||||
import { TOOLS } from "../../tools/index.js";
|
||||
import type { ToolDef } from "../../tools/types.js";
|
||||
import type { AskQuestionAnswer, AskQuestionSpec, ToolDef } from "../../tools/types.js";
|
||||
import { assertWithinWorkspace } from "../../utils/path.js";
|
||||
import { writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ChatInput } from "./ChatInput.js";
|
||||
import { ExportPrompt } from "./ExportPrompt.js";
|
||||
import { HistoryItemView } from "./HistoryItemView.js";
|
||||
import { ModelSelect } from "./ModelSelect.js";
|
||||
import { PermissionPrompt } from "./PermissionPrompt.js";
|
||||
import { PlanApprovalPrompt } from "./PlanApprovalPrompt.js";
|
||||
import { QuestionPrompt } from "./QuestionPrompt.js";
|
||||
import { SessionSelect } from "./SessionSelect.js";
|
||||
import { StatusBar } from "./StatusBar.js";
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
||||
@@ -121,9 +131,39 @@ export function App({
|
||||
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
// Whether diff previews (pre-confirmation and in the scrollback) render side-by-side (old | new)
|
||||
// instead of unified. Toggled with /diff. Off by default so narrow terminals and the pre-approval
|
||||
// preview stay compact; the user opts in when they have the width.
|
||||
const [diffSideBySide, setDiffSideBySide] = useState(false);
|
||||
// A structured question the model asked via the `ask_user_question` tool, awaiting the user's
|
||||
// selection. Mirrors the PendingPermission pattern: the tool handler resolves a promise the UI
|
||||
// holds, and handleQuestionSubmit fulfills it with the chosen answers.
|
||||
const [pendingQuestion, setPendingQuestion] = useState<{
|
||||
questions: AskQuestionSpec[];
|
||||
resolve: (answers: AskQuestionAnswer[]) => void;
|
||||
} | null>(null);
|
||||
|
||||
const baseURLRef = useRef(initialBaseURL);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
// The live working directory. Starts at the `cwd` prop (the CLI-supplied repo root) and tracks
|
||||
// enter_worktree/exit_worktree via session.onCwdChange. Kept separate from the `cwd` prop so that:
|
||||
// (a) the session-creation effects (which depend on `cwd`, the prop) don't re-fire when a worktree
|
||||
// switches the live cwd mid-session (that would recreate the session), and
|
||||
// (b) the StatusBar, ChatInput, /undo resolution, @mention resolution, and git-info refresh all
|
||||
// follow the worktree switch.
|
||||
const [liveCwd, setLiveCwd] = useState(cwd);
|
||||
// The active worktree session, mirrored from session.worktree so a /model switch (which recreates
|
||||
// the session) can re-attach it to the new session. A ref (not state) because the UI reads it via
|
||||
// sessionRef.current.worktree, not via re-render — it just needs to survive session recreation.
|
||||
const worktreeRef = useRef<{ dir: string; branch: string; originalCwd: string } | null>(null);
|
||||
// Cron/wakeup scheduler. Created once and shared across session re-creations (model switches,
|
||||
// resumes) so scheduled jobs survive a /model change. Started with an enqueue callback that
|
||||
// submits a turn for the current session, gated on isThinking so jobs only fire while idle.
|
||||
const cronStoreRef = useRef<CronStore | null>(null);
|
||||
// Ref mirrors so the scheduler's enqueue/isIdle closures (created once) read live values instead
|
||||
// of the stale state captured at first render.
|
||||
const isThinkingRef = useRef(false);
|
||||
const submitTurnRef = useRef<((session: Session, input: string | ChatCompletionUserContent) => Promise<void>) | null>(null);
|
||||
// Wraps whichever branch the bottom ternary renders (permission/export prompt, a picker, or the
|
||||
// normal StatusBar+ChatInput column) — measured (height only) so the history viewport above it
|
||||
// knows exactly how much vertical space is left (see historyHeight).
|
||||
@@ -188,8 +228,8 @@ export function App({
|
||||
}, []);
|
||||
|
||||
const refreshGitInfo = useCallback(() => {
|
||||
getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null));
|
||||
}, [cwd]);
|
||||
getGitInfo(liveCwd).then(setGitInfo).catch(() => setGitInfo(null));
|
||||
}, [liveCwd]);
|
||||
|
||||
// Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit,
|
||||
// bash) can switch branches or change the dirty state mid-session.
|
||||
@@ -197,6 +237,34 @@ export function App({
|
||||
refreshGitInfo();
|
||||
}, [refreshGitInfo]);
|
||||
|
||||
// Mirror isThinking into a ref so the cron scheduler's `isIdle` closure (created once at mount)
|
||||
// reads the live value instead of the stale first-render capture.
|
||||
useEffect(() => {
|
||||
isThinkingRef.current = isThinking;
|
||||
}, [isThinking]);
|
||||
|
||||
// Stop the cron scheduler when the App unmounts so its setInterval doesn't leak.
|
||||
useEffect(() => () => cronStoreRef.current?.stop(), []);
|
||||
|
||||
/** Lazily creates and starts the shared cron/wakeup scheduler on first use, then returns it. The
|
||||
* enqueue closure submits a turn for the current session; isIdle gates firing to when no turn is
|
||||
* running, matching Claude Code's "only fire while the REPL is idle" rule. */
|
||||
function ensureCronStore(): CronStore {
|
||||
if (!cronStoreRef.current) {
|
||||
const store = new CronStore(configDirPath());
|
||||
cronStoreRef.current = store;
|
||||
store.start({
|
||||
enqueue: (prompt) => {
|
||||
const s = sessionRef.current;
|
||||
const fn = submitTurnRef.current;
|
||||
if (s && fn) void fn(s, prompt);
|
||||
},
|
||||
isIdle: () => !isThinkingRef.current,
|
||||
});
|
||||
}
|
||||
return cronStoreRef.current;
|
||||
}
|
||||
|
||||
// A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has
|
||||
// ended — this is how its completion still reaches the transcript.
|
||||
useEffect(() => {
|
||||
@@ -283,15 +351,18 @@ export function App({
|
||||
new Promise<PermissionDecision>((resolve) => {
|
||||
setPermission({ ...opts, resolve });
|
||||
});
|
||||
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
|
||||
const [extraTools, contextWindow, projectInstructions, userMemory, projectRules] = await Promise.all([
|
||||
extraToolsPromise,
|
||||
resolveContextWindow(baseURLRef.current, model),
|
||||
loadProjectInstructions(cwd),
|
||||
loadProjectInstructions(liveCwd),
|
||||
loadUserMemory(),
|
||||
loadProjectPermissionRules(liveCwd),
|
||||
]);
|
||||
const permissionRules = mergePermissionRules(loadStoredConfig().permissionRules ?? [], projectRules);
|
||||
sessionRef.current = createSession(
|
||||
client,
|
||||
model,
|
||||
cwd,
|
||||
liveCwd,
|
||||
confirmFn,
|
||||
mode,
|
||||
[...TOOLS, ...extraTools],
|
||||
@@ -300,10 +371,26 @@ export function App({
|
||||
resolveMaxIterations(),
|
||||
resolveAutoCompactThreshold(),
|
||||
projectInstructions,
|
||||
resolveSubagentMaxIterations(),
|
||||
userMemory,
|
||||
resolveSubagentMaxDepth(),
|
||||
permissionRules,
|
||||
);
|
||||
onSessionIdChange?.(sessionRef.current.id);
|
||||
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
|
||||
sessionRef.current.askQuestion = (questions: AskQuestionSpec[]) =>
|
||||
new Promise<AskQuestionAnswer[]>((resolve) => setPendingQuestion({ questions, resolve }));
|
||||
// Wire the shared cron/wakeup scheduler (created lazily, survives model switches).
|
||||
sessionRef.current.cronStore = ensureCronStore();
|
||||
// Follow the live cwd (worktree enter/exit) and re-attach any in-flight worktree session so a
|
||||
// /model switch (which recreates the session) keeps you in the worktree with working exit_worktree.
|
||||
sessionRef.current.onCwdChange = (newCwd) => setLiveCwd(newCwd);
|
||||
sessionRef.current.onWorktreeChange = (wt) => {
|
||||
worktreeRef.current = wt ?? null;
|
||||
};
|
||||
if (worktreeRef.current) sessionRef.current.worktree = worktreeRef.current;
|
||||
if (projectInstructions) push({ kind: "notice", text: "Loaded project instructions from CLAUDE.md/AGENTS.md." });
|
||||
if (userMemory) push({ kind: "notice", text: "Loaded user memory index (use /memory to view, the memory tool to read facts)." });
|
||||
if (permissionRules.length) push({ kind: "notice", text: `Loaded ${permissionRules.length} permission rule(s) from user config + .locode/settings.json.` });
|
||||
notifyIfContextWindowGuessed(model, contextWindow);
|
||||
for (const warning of await fireSessionStartHook(sessionRef.current)) {
|
||||
push({ kind: "notice", text: `Hook warning: ${warning}` });
|
||||
@@ -322,7 +409,7 @@ export function App({
|
||||
}
|
||||
}
|
||||
},
|
||||
[cwd, toolModeOverride, modelList, exit, push, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
|
||||
[liveCwd, toolModeOverride, modelList, exit, push, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
|
||||
);
|
||||
|
||||
const initSessionFromRecord = useCallback(
|
||||
@@ -335,15 +422,18 @@ export function App({
|
||||
new Promise<PermissionDecision>((resolve) => {
|
||||
setPermission({ ...opts, resolve });
|
||||
});
|
||||
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
|
||||
const [extraTools, contextWindow, projectInstructions, userMemory, projectRules] = await Promise.all([
|
||||
extraToolsPromise,
|
||||
resolveContextWindow(record.baseURL, record.model),
|
||||
loadProjectInstructions(cwd),
|
||||
loadProjectInstructions(liveCwd),
|
||||
loadUserMemory(),
|
||||
loadProjectPermissionRules(liveCwd),
|
||||
]);
|
||||
const permissionRules = mergePermissionRules(loadStoredConfig().permissionRules ?? [], projectRules);
|
||||
sessionRef.current = createSessionFromRecord(
|
||||
client,
|
||||
record,
|
||||
cwd,
|
||||
liveCwd,
|
||||
confirmFn,
|
||||
[...TOOLS, ...extraTools],
|
||||
contextWindow.value,
|
||||
@@ -351,11 +441,29 @@ export function App({
|
||||
resolveMaxIterations(),
|
||||
resolveAutoCompactThreshold(),
|
||||
projectInstructions,
|
||||
resolveSubagentMaxIterations(),
|
||||
userMemory,
|
||||
resolveSubagentMaxDepth(),
|
||||
permissionRules,
|
||||
);
|
||||
onSessionIdChange?.(sessionRef.current.id);
|
||||
sessionRef.current.askQuestion = (questions: AskQuestionSpec[]) =>
|
||||
new Promise<AskQuestionAnswer[]>((resolve) => setPendingQuestion({ questions, resolve }));
|
||||
// Wire the shared cron/wakeup scheduler (created lazily, survives model switches).
|
||||
sessionRef.current.cronStore = ensureCronStore();
|
||||
// Follow the live cwd (worktree enter/exit) and re-attach any in-flight worktree session.
|
||||
sessionRef.current.onCwdChange = (newCwd) => setLiveCwd(newCwd);
|
||||
sessionRef.current.onWorktreeChange = (wt) => {
|
||||
worktreeRef.current = wt ?? null;
|
||||
};
|
||||
if (worktreeRef.current) sessionRef.current.worktree = worktreeRef.current;
|
||||
// Sync the UI's permission-mode mirror with the mode restored from the saved session
|
||||
// (createSessionFromRecord reapplies record.permissionMode), so the status bar shows the
|
||||
// right badge immediately instead of "default" until the next /perm or Shift+Tab.
|
||||
setPermMode(sessionRef.current.permissions.getMode());
|
||||
push({
|
||||
kind: "banner",
|
||||
cwd,
|
||||
cwd: liveCwd,
|
||||
model: record.model,
|
||||
backend: record.baseURL,
|
||||
resumedTitle: deriveTitle(record.messages),
|
||||
@@ -383,7 +491,7 @@ export function App({
|
||||
fetchModelsForPicker();
|
||||
}
|
||||
},
|
||||
[cwd, push, fetchModelsForPicker, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
|
||||
[liveCwd, push, fetchModelsForPicker, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
|
||||
);
|
||||
|
||||
// Decide the startup path once on mount: resume a specific session, show a resume
|
||||
@@ -465,7 +573,7 @@ export function App({
|
||||
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
|
||||
notifyIfContextWindowGuessed(name, newContextWindow);
|
||||
persistCurrentSession();
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "model", previous: previousModel, value: name }).catch(
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd: liveCwd }, { key: "model", previous: previousModel, value: name }).catch(
|
||||
() => {},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -498,7 +606,7 @@ export function App({
|
||||
});
|
||||
notifyIfContextWindowGuessed(session.model, newContextWindow);
|
||||
persistCurrentSession();
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "backend", previous: previousBaseURL, value: baseURLRef.current }).catch(
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd: liveCwd }, { key: "backend", previous: previousBaseURL, value: baseURLRef.current }).catch(
|
||||
() => {},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -510,6 +618,10 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the ref the cron scheduler reads live (it's created once at mount and would otherwise hold
|
||||
// the first-render instance of submitTurn).
|
||||
submitTurnRef.current = submitTurn;
|
||||
|
||||
async function submitTurn(session: Session, input: string | ChatCompletionUserContent) {
|
||||
const rollbackLength = session.messages.length;
|
||||
setIsThinking(true);
|
||||
@@ -564,11 +676,32 @@ export function App({
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_call", label: event.label } as HistoryItem]);
|
||||
} else if (event.type === "tool_result") {
|
||||
setRunningToolIsBash(false);
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError, diff: event.diff } as HistoryItem]);
|
||||
} else if (event.type === "plan_presented") {
|
||||
// The model presented a plan in plan mode. Clear streaming (same flush text_done does) and
|
||||
// render it as a distinct `plan` entry — the approve/reject prompt is armed separately by
|
||||
// loop.ts's session.confirm call (rendered below as PlanApprovalPrompt).
|
||||
if (streamRafRef.current !== null) {
|
||||
clearTimeout(streamRafRef.current);
|
||||
streamRafRef.current = null;
|
||||
}
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "plan", text: event.text } as HistoryItem]);
|
||||
} else if (event.type === "plan_approved") {
|
||||
// loop.ts has switched the session out of plan mode and resumed the SAME turn to implement
|
||||
// the approved plan (no separate "proceed" turn is injected from the UI anymore). Surface a
|
||||
// notice so the user sees the approval landed and implementation is underway.
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: "Plan approved — implementing.", isError: false } as HistoryItem]);
|
||||
} else if (event.type === "hook_notice" || event.type === "notice") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
|
||||
} else if (event.type === "todos_update") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "todos", todos: event.todos } as HistoryItem]);
|
||||
} else if (event.type === "subagent") {
|
||||
// Sub-agent tool calls/results are forwarded by runSubAgentTurn (loop.ts subEmit) so a
|
||||
// long-running delegation isn't a silent void — render them inline in the scrollback,
|
||||
// tagged with the sub-agent's description so multiple concurrent delegations stay legible.
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "subagent", description: event.description, line: event.line } as HistoryItem]);
|
||||
} else if (event.type === "tasks_update") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tasks", tasks: event.tasks } as HistoryItem]);
|
||||
}
|
||||
});
|
||||
// text_done already added the assistant message to staticItems
|
||||
@@ -666,7 +799,7 @@ export function App({
|
||||
model: session.model,
|
||||
baseURL: baseURLRef.current,
|
||||
mode: session.mode,
|
||||
cwd,
|
||||
cwd: liveCwd,
|
||||
sessionId: session.id,
|
||||
contextTokens: session.lastContextTokens,
|
||||
contextWindow: session.contextWindow,
|
||||
@@ -724,7 +857,7 @@ export function App({
|
||||
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
|
||||
try {
|
||||
const content = await buildImportContent(cwd, filePathArg, caption);
|
||||
const content = await buildImportContent(liveCwd, filePathArg, caption);
|
||||
await submitTurn(session, content);
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
|
||||
@@ -735,14 +868,65 @@ export function App({
|
||||
push({ kind: "tools", tools: session.toolset.tools });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/memory") {
|
||||
// Show the folded memory index (what's in every system prompt) plus the on-disk directory, so
|
||||
// the user can see what the model has persisted. Each typed fact is one file under memory/;
|
||||
// the model revises them via the memory_write tool, or the user can edit the files directly.
|
||||
const memDir = userMemoryDir();
|
||||
try {
|
||||
const content = await loadUserMemory();
|
||||
const entries = await listMemoryEntries();
|
||||
const header = entries.length
|
||||
? `Memory index (${entries.length} fact${entries.length === 1 ? "" : "s"} in ${memDir}):`
|
||||
: `No typed memory facts in ${memDir}.`;
|
||||
push({
|
||||
kind: "notice",
|
||||
text: content ? `${header}\n\n${content}` : `${header}\nThe model can create facts via the memory_write tool, or you can add files manually.`,
|
||||
});
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Failed to read user memory: ${(err as Error).message}`, isError: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/permissions") {
|
||||
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
|
||||
push({ kind: "permissions", allowed: session.permissions.listAllowed(), rules: session.permissions.listRules() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/diff") {
|
||||
const next = !diffSideBySide;
|
||||
setDiffSideBySide(next);
|
||||
push({ kind: "notice", text: `Diff view: ${next ? "side-by-side (old │ new)" : "unified"}.` });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/sessions") {
|
||||
push({ kind: "sessions", sessions: listSessions() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/cron") {
|
||||
const jobs = sessionRef.current?.cronStore?.list() ?? [];
|
||||
if (jobs.length === 0) {
|
||||
push({ kind: "notice", text: "No scheduled cron jobs (use the cron_create tool to add one)." });
|
||||
} else {
|
||||
const lines = jobs.map(
|
||||
(j) =>
|
||||
` ${j.id} ${j.cron} ${j.recurring ? "recurring" : "one-shot"}${j.durable ? " (durable)" : ""} — ${j.prompt.slice(0, 60)}`,
|
||||
);
|
||||
push({ kind: "notice", text: `Scheduled jobs (${jobs.length}):\n${lines.join("\n")}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/worktree") {
|
||||
const wt = sessionRef.current?.worktree;
|
||||
if (!wt) {
|
||||
push({ kind: "notice", text: "Not in a worktree session (use the enter_worktree tool to start one)." });
|
||||
} else {
|
||||
push({
|
||||
kind: "notice",
|
||||
text: `In worktree session: branch ${wt.branch} at ${wt.dir}\n (original working directory: ${wt.originalCwd}; use exit_worktree to leave)`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/mcp") {
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
return;
|
||||
@@ -752,7 +936,7 @@ export function App({
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/hooks") {
|
||||
push({ kind: "hooks", config: loadMergedHooks(cwd) });
|
||||
push({ kind: "hooks", config: loadMergedHooks(liveCwd) });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/skills") {
|
||||
@@ -794,12 +978,12 @@ export function App({
|
||||
const currentIdx = modes.indexOf(permMode);
|
||||
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||
setPermMode(nextMode);
|
||||
session.permissions.setMode(nextMode);
|
||||
setPermissionMode(session, nextMode);
|
||||
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
|
||||
} else if (validModes[name]) {
|
||||
const newMode = validModes[name];
|
||||
setPermMode(newMode);
|
||||
session.permissions.setMode(newMode);
|
||||
setPermissionMode(session, newMode);
|
||||
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
|
||||
} else {
|
||||
push({
|
||||
@@ -822,12 +1006,33 @@ export function App({
|
||||
setCachedMode(baseURLRef.current, session.model, name);
|
||||
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
||||
persistCurrentSession();
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd: liveCwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/undo") {
|
||||
const lastEdit = session.lastEdit;
|
||||
if (!lastEdit) {
|
||||
push({ kind: "notice", text: "Nothing to undo — no file edits have been made this session.", isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resolved = path.resolve(liveCwd, lastEdit.path);
|
||||
assertWithinWorkspace(resolved, liveCwd, lastEdit.path);
|
||||
await fsWriteFile(resolved, lastEdit.previousContent, "utf-8");
|
||||
push({ kind: "notice", text: `Rolled back ${lastEdit.path} to its content before the last edit.` });
|
||||
session.lastEdit = null;
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Undo failed: ${(err as Error).message}`, isError: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/summary") {
|
||||
await submitTurn(session, "Summarize the conversation so far concisely — what we've discussed, what changes we've made, and what remains open. Do not edit any files.");
|
||||
return;
|
||||
}
|
||||
|
||||
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
|
||||
// resolution — a hook can block the message outright, or inject extra context (appended
|
||||
@@ -879,10 +1084,10 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
const mentionedFiles = extractMentionedFiles(trimmed, cwd);
|
||||
const mentionedFiles = extractMentionedFiles(trimmed, liveCwd);
|
||||
if (mentionedFiles.length > 0) {
|
||||
try {
|
||||
const attachments = await Promise.all(mentionedFiles.map((f) => buildImportContent(cwd, f, "")));
|
||||
const attachments = await Promise.all(mentionedFiles.map((f) => buildImportContent(liveCwd, f, "")));
|
||||
const parts = [{ type: "text" as const, text: trimmed }, ...attachments.flat()];
|
||||
if (extraContext) parts.push({ type: "text" as const, text: extraContext });
|
||||
await submitTurn(session, parts);
|
||||
@@ -899,9 +1104,22 @@ export function App({
|
||||
const pending = permission;
|
||||
setPermission(null);
|
||||
setIsThinking(true);
|
||||
if (pending && decision === "session") {
|
||||
const session = sessionRef.current;
|
||||
if (session) {
|
||||
session.permissions.allowForSession(pending.toolName);
|
||||
}
|
||||
}
|
||||
pending?.resolve(decision);
|
||||
}
|
||||
|
||||
function handleQuestionSubmit(answers: AskQuestionAnswer[]) {
|
||||
const pending = pendingQuestion;
|
||||
setPendingQuestion(null);
|
||||
setIsThinking(true);
|
||||
pending?.resolve(answers);
|
||||
}
|
||||
|
||||
async function handleExportSubmit(filename: string) {
|
||||
setExportPrompt(null);
|
||||
const session = sessionRef.current;
|
||||
@@ -912,7 +1130,7 @@ export function App({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName);
|
||||
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, liveCwd, trimmedName);
|
||||
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
|
||||
@@ -931,7 +1149,7 @@ export function App({
|
||||
const currentIdx = modes.indexOf(permMode);
|
||||
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||
setPermMode(nextMode);
|
||||
session.permissions.setMode(nextMode);
|
||||
setPermissionMode(session, nextMode);
|
||||
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
|
||||
}
|
||||
|
||||
@@ -954,25 +1172,32 @@ export function App({
|
||||
>
|
||||
<Box flexDirection="column" ref={historyContentRef}>
|
||||
{staticItems.map((item) => (
|
||||
<HistoryItemView key={item.id} item={item} />
|
||||
<HistoryItemView key={item.id} item={item} sideBySide={diffSideBySide} />
|
||||
))}
|
||||
{streamingText !== null && (
|
||||
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
|
||||
)}
|
||||
{isThinking && streamingText === null && !permission && !exportPrompt && (
|
||||
{isThinking && streamingText === null && !permission && !exportPrompt && !pendingQuestion && (
|
||||
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" ref={bottomSectionRef}>
|
||||
{permission ? (
|
||||
<PermissionPrompt
|
||||
toolName={permission.toolName}
|
||||
args={permission.args}
|
||||
preview={permission.preview}
|
||||
onSelect={handlePermissionSelect}
|
||||
/>
|
||||
{pendingQuestion ? (
|
||||
<QuestionPrompt questions={pendingQuestion.questions} onSubmit={handleQuestionSubmit} />
|
||||
) : permission ? (
|
||||
permission.toolName === "plan" ? (
|
||||
<PlanApprovalPrompt onSelect={handlePermissionSelect} />
|
||||
) : (
|
||||
<PermissionPrompt
|
||||
toolName={permission.toolName}
|
||||
args={permission.args}
|
||||
preview={permission.preview}
|
||||
sideBySide={diffSideBySide}
|
||||
onSelect={handlePermissionSelect}
|
||||
/>
|
||||
)
|
||||
) : exportPrompt ? (
|
||||
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
|
||||
) : phase === "starting" ? (
|
||||
@@ -992,7 +1217,7 @@ export function App({
|
||||
model={sessionRef.current.model}
|
||||
mode={sessionRef.current.mode}
|
||||
permMode={permMode}
|
||||
cwd={cwd}
|
||||
cwd={liveCwd}
|
||||
contextTokens={sessionRef.current.lastContextTokens}
|
||||
contextWindow={sessionRef.current.contextWindow}
|
||||
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
|
||||
@@ -1017,7 +1242,7 @@ export function App({
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onCyclePermMode={cyclePermMode}
|
||||
cwd={cwd}
|
||||
cwd={liveCwd}
|
||||
history={history}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getGraphemeBoundaries, nextGraphemeBoundary } from "./ChatInput.js";
|
||||
|
||||
describe("getGraphemeBoundaries", () => {
|
||||
it("returns [0, length] for an empty string", () => {
|
||||
expect(getGraphemeBoundaries("")).toEqual([0]);
|
||||
});
|
||||
|
||||
it("returns boundaries for plain ASCII", () => {
|
||||
expect(getGraphemeBoundaries("abc")).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("treats surrogate pairs (emoji/Hangul) as single graphemes", () => {
|
||||
// "a👍b" — thumbs up is a surrogate pair (2 JS indices, 1 displayed cell).
|
||||
const boundaries = getGraphemeBoundaries("a👍b");
|
||||
expect(boundaries).toEqual([0, 1, 3, 4]);
|
||||
});
|
||||
|
||||
it("treats ZWJ emoji sequences as single graphemes", () => {
|
||||
// "👨👩👧👦" is a family emoji made of multiple code points joined with ZWJs.
|
||||
const str = "👨👩👧👦";
|
||||
const boundaries = getGraphemeBoundaries(str);
|
||||
expect(boundaries).toHaveLength(2);
|
||||
expect(boundaries).toContain(0);
|
||||
expect(boundaries).toContain(str.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextGraphemeBoundary", () => {
|
||||
it("moves right past a surrogate-pair emoji", () => {
|
||||
// "a👍b", cursor after "a" (index 1) should jump to index 3 (after emoji).
|
||||
expect(nextGraphemeBoundary("a👍b", 1, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it("moves left past a surrogate-pair emoji", () => {
|
||||
// cursor at index 3 (after emoji) should jump back to index 1 (before emoji).
|
||||
expect(nextGraphemeBoundary("a👍b", 3, -1)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not move past the start or end", () => {
|
||||
expect(nextGraphemeBoundary("ab", 0, -1)).toBe(0);
|
||||
expect(nextGraphemeBoundary("ab", 2, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it("snaps an invalid offset to the next boundary when moving right", () => {
|
||||
// index 2 is inside the emoji surrogate pair.
|
||||
expect(nextGraphemeBoundary("a👍b", 2, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it("snaps an invalid offset to the previous boundary when moving left", () => {
|
||||
// index 2 is inside the emoji surrogate pair.
|
||||
expect(nextGraphemeBoundary("a👍b", 2, -1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,40 @@ const PROMPT_WIDTH = 2;
|
||||
// Border (1 col each side) + paddingX={1} (1 col each side) around the bordered box's content.
|
||||
const BOX_CHROME_WIDTH = 4;
|
||||
|
||||
/** Return every grapheme-cluster boundary index in `str`, including 0 and str.length. Uses
|
||||
* Intl.Segmenter when available so emoji/ZWJ/combining marks move as one unit; falls back to
|
||||
* surrogate-pair aware boundaries otherwise. */
|
||||
export function getGraphemeBoundaries(str: string): number[] {
|
||||
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
||||
return [
|
||||
...Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(str)).map((s) => s.index),
|
||||
str.length,
|
||||
];
|
||||
}
|
||||
// Fallback: boundary before each code point (handling surrogate pairs as one step).
|
||||
const boundaries = [0];
|
||||
for (let i = 0; i < str.length; ) {
|
||||
const cp = str.codePointAt(i)!;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
boundaries.push(i);
|
||||
}
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
/** Move `offset` one grapheme cluster in `direction`. If `offset` isn't already on a boundary,
|
||||
* snap to the nearest one in that direction first. */
|
||||
export function nextGraphemeBoundary(str: string, offset: number, direction: 1 | -1): number {
|
||||
const boundaries = getGraphemeBoundaries(str);
|
||||
const idx = boundaries.indexOf(offset);
|
||||
if (idx === -1) {
|
||||
if (direction === 1) {
|
||||
return boundaries.find((b) => b > offset) ?? str.length;
|
||||
}
|
||||
return boundaries.slice().reverse().find((b) => b < offset) ?? 0;
|
||||
}
|
||||
return boundaries[idx + direction] ?? (direction === 1 ? str.length : 0);
|
||||
}
|
||||
|
||||
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, history = [] }: Props) {
|
||||
const [allFiles, setAllFiles] = useState<string[] | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -71,6 +105,15 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
};
|
||||
}, [mention !== null, allFiles, cwd]);
|
||||
|
||||
// Keep the "draft before history recall" in sync with the live input whenever we aren't
|
||||
// currently browsing history, so returning to it with the down-arrow always restores the latest
|
||||
// draft rather than a stale snapshot.
|
||||
useEffect(() => {
|
||||
if (historyIndex === -1) {
|
||||
setTempValue(value);
|
||||
}
|
||||
}, [value, historyIndex]);
|
||||
|
||||
// The full match set (capped at MAX_MATCHES for sanity) — separate from what's actually
|
||||
// rendered, since only a VISIBLE_SUGGESTIONS-tall window of it is shown at once (see `visible`).
|
||||
const query = mention?.query ?? "";
|
||||
@@ -210,16 +253,24 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
setCursorOffset((o) => Math.max(0, o - 1));
|
||||
setCursorOffset((o) => nextGraphemeBoundary(value, o, -1));
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursorOffset((o) => Math.min(value.length, o + 1));
|
||||
setCursorOffset((o) => nextGraphemeBoundary(value, o, 1));
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
if (key.backspace) {
|
||||
if (cursorOffset > 0) {
|
||||
replaceValue(value.slice(0, cursorOffset - 1) + value.slice(cursorOffset), cursorOffset - 1);
|
||||
const start = nextGraphemeBoundary(value, cursorOffset, -1);
|
||||
replaceValue(value.slice(0, start) + value.slice(cursorOffset), start);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.delete) {
|
||||
if (cursorOffset < value.length) {
|
||||
const end = nextGraphemeBoundary(value, cursorOffset, 1);
|
||||
replaceValue(value.slice(0, cursorOffset) + value.slice(end), cursorOffset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clip, pairHunk } from "./DiffView.js";
|
||||
|
||||
describe("clip", () => {
|
||||
it("returns the string unchanged when it fits", () => {
|
||||
expect(clip("abc", 10)).toBe("abc");
|
||||
expect(clip("abc", 3)).toBe("abc");
|
||||
});
|
||||
|
||||
it("returns empty for non-positive width", () => {
|
||||
expect(clip("abc", 0)).toBe("");
|
||||
});
|
||||
|
||||
it("truncates with an ellipsis when overflowing", () => {
|
||||
expect(clip("abcdef", 4)).toBe("abc…");
|
||||
expect(clip("abcdef", 5)).toBe("abcd…");
|
||||
});
|
||||
|
||||
it("handles a string exactly one longer than the width", () => {
|
||||
expect(clip("abcd", 3)).toBe("ab…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairHunk", () => {
|
||||
it("pairs a context line to both sides", () => {
|
||||
expect(pairHunk([" unchanged"])).toEqual([
|
||||
{ left: "unchanged", leftKind: "ctx", right: "unchanged", rightKind: "ctx" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs a deletion immediately followed by an addition as one change row", () => {
|
||||
expect(pairHunk(["-old", "+new"])).toEqual([
|
||||
{ left: "old", leftKind: "del", right: "new", rightKind: "add" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a pure deletion with an empty right side", () => {
|
||||
expect(pairHunk(["-gone"])).toEqual([
|
||||
{ left: "gone", leftKind: "del", right: "", rightKind: "empty" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a pure insertion with an empty left side", () => {
|
||||
expect(pairHunk(["+fresh"])).toEqual([
|
||||
{ left: "", leftKind: "empty", right: "fresh", rightKind: "add" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs a delete-then-add block row by row, padding the overflow", () => {
|
||||
// Two deletions then two additions: first delete has no addition to pair (next is a delete), so
|
||||
// it becomes a pure-deletion row; the second delete pairs with the first addition; the second
|
||||
// addition is a pure insertion.
|
||||
const rows = pairHunk(["-a", "-b", "+c", "+d"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "a", leftKind: "del", right: "", rightKind: "empty" },
|
||||
{ left: "b", leftKind: "del", right: "c", rightKind: "add" },
|
||||
{ left: "", leftKind: "empty", right: "d", rightKind: "add" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs one deletion with two additions as a change row plus a pure insertion", () => {
|
||||
const rows = pairHunk(["-old", "+new1", "+new2"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "old", leftKind: "del", right: "new1", rightKind: "add" },
|
||||
{ left: "", leftKind: "empty", right: "new2", rightKind: "add" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a sequence of context, change, insertion, context in order", () => {
|
||||
const rows = pairHunk([" keep", "-bad", "+good", "+extra", " tail"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "keep", leftKind: "ctx", right: "keep", rightKind: "ctx" },
|
||||
{ left: "bad", leftKind: "del", right: "good", rightKind: "add" },
|
||||
{ left: "", leftKind: "empty", right: "extra", rightKind: "add" },
|
||||
{ left: "tail", leftKind: "ctx", right: "tail", rightKind: "ctx" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an unrecognized/blank line as context on both sides", () => {
|
||||
const rows = pairHunk(["\\ No newline at end of file"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "\\ No newline at end of file", leftKind: "ctx", right: "\\ No newline at end of file", rightKind: "ctx" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import type { ReactNode } from "react";
|
||||
import { DIFF_ADD_HEX, DIFF_REMOVE_HEX } from "../theme.js";
|
||||
|
||||
/** A single row of a side-by-side diff: the old (left) and new (right) versions of one line, plus
|
||||
* each side's kind so the renderer can color it. `empty` is a padding row used when one side has a
|
||||
* line the other doesn't (a pure insertion or deletion). */
|
||||
interface DiffRow {
|
||||
left: string;
|
||||
leftKind: "ctx" | "add" | "del" | "empty";
|
||||
right: string;
|
||||
rightKind: "ctx" | "add" | "del" | "empty";
|
||||
}
|
||||
|
||||
/** Splits a unified-diff hunk body (the lines after a `@@ … @@` header, each starting with ` `,
|
||||
* `+`, or `-`) into paired old/new rows. A deletion immediately followed by an addition is treated
|
||||
* as a change and shown on one row (old left, new right); runs of pure deletions or additions are
|
||||
* padded with `empty` on the opposite side so the two columns stay aligned. Exported for testing. */
|
||||
export function pairHunk(body: string[]): DiffRow[] {
|
||||
const rows: DiffRow[] = [];
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const line = body[i] ?? "";
|
||||
const tag = line[0];
|
||||
const rest = line.slice(1);
|
||||
if (tag === " ") {
|
||||
rows.push({ left: rest, leftKind: "ctx", right: rest, rightKind: "ctx" });
|
||||
} else if (tag === "-") {
|
||||
const next = body[i + 1] ?? "";
|
||||
if (next[0] === "+") {
|
||||
rows.push({ left: rest, leftKind: "del", right: next.slice(1), rightKind: "add" });
|
||||
i++; // consume the paired addition
|
||||
} else {
|
||||
rows.push({ left: rest, leftKind: "del", right: "", rightKind: "empty" });
|
||||
}
|
||||
} else if (tag === "+") {
|
||||
// An addition not preceded by a paired deletion (the deletion branch above consumes its
|
||||
// paired addition) — a pure insertion.
|
||||
rows.push({ left: "", leftKind: "empty", right: rest, rightKind: "add" });
|
||||
} else {
|
||||
// Blank or unexpected line (e.g. a trailing "\ No newline at end of file") — render as context.
|
||||
rows.push({ left: line, leftKind: "ctx", right: line, rightKind: "ctx" });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Truncates `s` to `width` columns with an ellipsis when it overflows, so the two side-by-side
|
||||
* columns stay row-aligned even when a line is longer than half the terminal. Exported for testing. */
|
||||
export function clip(s: string, width: number): string {
|
||||
if (width <= 0) return "";
|
||||
if (s.length <= width) return s;
|
||||
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
function colorFor(kind: DiffRow["leftKind"]): string | undefined {
|
||||
if (kind === "add") return DIFF_ADD_HEX;
|
||||
if (kind === "del") return DIFF_REMOVE_HEX;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Renders a unified-diff string with color-coded lines: additions green, removals red, hunk
|
||||
* headers (`@@ … @@`) and the `Index:`/`--- `/`+++ ` file headers dimmed. In `sideBySide` mode the
|
||||
* old and new versions are shown in two columns (old left, new right, separated by a dim ` │ `),
|
||||
* with deletions red on the left and additions green on the right — easier to compare at a glance
|
||||
* than a unified diff, at the cost of horizontal space. Used both for the pre-confirmation
|
||||
* permission preview and the post-confirmation scrollback entry. */
|
||||
export function DiffView({ diff, sideBySide = false }: { diff: string; sideBySide?: boolean }) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const lines = diff.split("\n");
|
||||
|
||||
if (!sideBySide) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, i) => {
|
||||
let color: string | undefined;
|
||||
let dim = false;
|
||||
const c = line[0];
|
||||
if (c === "+" && !line.startsWith("+++")) {
|
||||
color = DIFF_ADD_HEX;
|
||||
} else if (c === "-" && !line.startsWith("---")) {
|
||||
color = DIFF_REMOVE_HEX;
|
||||
} else if (c === "@") {
|
||||
dim = true; // hunk header
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
dim = true; // file headers
|
||||
}
|
||||
return (
|
||||
<Text key={i} color={color} dimColor={dim}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Side-by-side: reserve 3 columns for the " │ " gutter; split the rest evenly. Guard against the
|
||||
// no-stdout / very-narrow case so a tiny terminal doesn't produce negative widths. Each cell keeps
|
||||
// a 1-column sign prefix (` ` context, `-` deletion, `+` addition) so the kind is legible without
|
||||
// relying on color alone.
|
||||
const colWidth = Math.max(10, Math.floor((columns - 3) / 2));
|
||||
|
||||
// Walk the unified diff and group lines under their hunk headers, pairing each hunk body into rows.
|
||||
const rendered: ReactNode[] = [];
|
||||
let hunkBody: string[] = [];
|
||||
let hunkIdx = 0;
|
||||
const flushHunk = () => {
|
||||
if (!hunkBody.length) return;
|
||||
const rows = pairHunk(hunkBody);
|
||||
rows.forEach((row, r) => {
|
||||
const key = `${hunkIdx}-${r}`;
|
||||
rendered.push(
|
||||
<Box key={key} flexDirection="row">
|
||||
<Text color={colorFor(row.leftKind)} dimColor={row.leftKind === "ctx"}>
|
||||
{row.leftKind === "empty" ? "" : `${row.leftKind === "del" ? "-" : " "}${clip(row.left, colWidth - 1)}`}
|
||||
</Text>
|
||||
<Text dimColor> │ </Text>
|
||||
<Text color={colorFor(row.rightKind)} dimColor={row.rightKind === "ctx"}>
|
||||
{row.rightKind === "empty" ? "" : `${row.rightKind === "add" ? "+" : " "}${clip(row.right, colWidth - 1)}`}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
hunkBody = [];
|
||||
hunkIdx++;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`h${hunkIdx}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`f${rendered.length}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
hunkBody.push(line);
|
||||
}
|
||||
}
|
||||
flushHunk();
|
||||
|
||||
return <Box flexDirection="column">{rendered}</Box>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { renderMarkdown } from "../render.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import type { HistoryItem } from "./types.js";
|
||||
|
||||
const HELP_LINES = [
|
||||
@@ -13,7 +14,10 @@ const HELP_LINES = [
|
||||
" /dashboard show session stats: token I/O, elapsed/model time, turns, tool calls",
|
||||
" /tools list available tools",
|
||||
" /permissions list mutating tools allowed for the rest of this session",
|
||||
" /diff toggle diff rendering (unified vs side-by-side old │ new)",
|
||||
" /sessions list saved conversations you can resume with --resume",
|
||||
" /cron list scheduled cron jobs (created via the cron_create tool)",
|
||||
" /worktree show the active worktree session (created via the enter_worktree tool)",
|
||||
" /mcp show connected MCP servers and their tool counts",
|
||||
" /plugins show installed Claude Code-compatible plugins (commands, agents, MCP servers)",
|
||||
" /hooks show configured hooks per lifecycle event",
|
||||
@@ -41,7 +45,7 @@ function formatDuration(ms: number): string {
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
export function HistoryItemView({ item, sideBySide = false }: { item: HistoryItem; sideBySide?: boolean }) {
|
||||
switch (item.kind) {
|
||||
case "banner":
|
||||
return (
|
||||
@@ -198,9 +202,43 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
|
||||
case "tool_result":
|
||||
return (
|
||||
<Text color={item.isError ? "yellowBright" : undefined} dimColor={!item.isError}>
|
||||
{" ⎿ "}
|
||||
{item.summary}
|
||||
<Box flexDirection="column">
|
||||
<Text color={item.isError ? "yellowBright" : undefined} dimColor={!item.isError}>
|
||||
{" ⎿ "}
|
||||
{item.summary}
|
||||
</Text>
|
||||
{item.diff ? <DiffView diff={item.diff} sideBySide={sideBySide} /> : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
case "plan":
|
||||
// The model's proposed plan in plan mode. Bordered/labelled so it reads as a plan to review,
|
||||
// not just another assistant message — followed (in the UI) by an approve/reject prompt.
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold color={ACCENT_HEX}>
|
||||
✻ Plan
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>{renderMarkdown(item.text)}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case "subagent":
|
||||
// A sub-agent's forwarded tool call/result. Kept compact and dimmed so a delegation's internal
|
||||
// activity reads as nested under the parent's `agent` tool call, not as top-level work.
|
||||
if (item.line.kind === "call") {
|
||||
return (
|
||||
<Text color="magentaBright">
|
||||
{" ↳ "}
|
||||
{item.description}: {item.line.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text color={item.line.isError ? "yellowBright" : undefined} dimColor={!item.line.isError}>
|
||||
{" ⎿ "}
|
||||
{item.line.summary}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -211,20 +249,26 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
</Text>
|
||||
);
|
||||
|
||||
case "todos": {
|
||||
case "tasks": {
|
||||
const icon = { pending: "☐", in_progress: "◐", completed: "☑" } as const;
|
||||
const color = { pending: undefined, in_progress: "cyan", completed: "green" } as const;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{item.todos.length === 0 ? (
|
||||
<Text dimColor>Todos: (cleared)</Text>
|
||||
{item.tasks.length === 0 ? (
|
||||
<Text dimColor>Tasks: (cleared)</Text>
|
||||
) : (
|
||||
item.todos.map((t, i) => (
|
||||
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
{" "}
|
||||
{icon[t.status]} {t.content}
|
||||
</Text>
|
||||
))
|
||||
item.tasks.map((t, i) => {
|
||||
const blocked = t.blockedBy.length > 0 ? <Text dimColor> (blocked by {t.blockedBy.join(", ")})</Text> : null;
|
||||
const owner = t.owner ? <Text dimColor> @{t.owner}</Text> : null;
|
||||
return (
|
||||
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
{" "}
|
||||
{icon[t.status]} {t.id}: {t.subject}
|
||||
{owner}
|
||||
{blocked}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -233,8 +277,8 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
case "help":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{HELP_LINES.map((line) => (
|
||||
<Text key={line}>{line}</Text>
|
||||
{HELP_LINES.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -255,11 +299,19 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
|
||||
case "permissions":
|
||||
return (
|
||||
<Text dimColor>
|
||||
{item.allowed.length
|
||||
? `Allowed for the rest of this session: ${item.allowed.join(", ")}`
|
||||
: "No mutating tools have been allowed for the rest of this session yet."}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>
|
||||
{item.allowed.length
|
||||
? `Allowed for the rest of this session: ${item.allowed.join(", ")}`
|
||||
: "No mutating tools have been allowed for the rest of this session yet."}
|
||||
</Text>
|
||||
{item.rules.length > 0 && (
|
||||
<Text dimColor>
|
||||
{" Permission rules: "}
|
||||
{item.rules.map((r) => `${r.allow ? "allow" : "deny"} ${r.tool}${r.argPattern ? ` /${r.argPattern}/` : ""}`).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
case "sessions":
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Box, Text } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import type { PermissionDecision } from "../../permissions/types.js";
|
||||
import { looksLikeDiff } from "../../utils/diff.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
preview?: string;
|
||||
sideBySide?: boolean;
|
||||
onSelect: (decision: PermissionDecision) => void;
|
||||
}
|
||||
|
||||
@@ -32,16 +35,19 @@ function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: s
|
||||
);
|
||||
}
|
||||
|
||||
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
|
||||
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
|
||||
export function PermissionPrompt({ toolName, args, preview, sideBySide = false, onSelect }: Props) {
|
||||
const previewText = preview ?? JSON.stringify(args);
|
||||
const isDiff = looksLikeDiff(previewText);
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold>{toolName}</Text>
|
||||
<Text> </Text>
|
||||
{previewLines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
{isDiff ? (
|
||||
<DiffView diff={previewText} sideBySide={sideBySide} />
|
||||
) : (
|
||||
previewText.split("\n").map((line, i) => <Text key={i}>{line}</Text>)
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text>Do you want to proceed?</Text>
|
||||
<SelectInput
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Text } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { PermissionDecision } from "../../permissions/types.js";
|
||||
|
||||
/** Two-option approve/reject prompt shown when the model presents a plan in plan mode. Reuses the
|
||||
* same PendingPermission/confirm plumbing as tool permissions (loop.ts calls session.confirm with
|
||||
* toolName "plan"); Approve maps to the "once" decision, Reject to "deny". The plan text itself is
|
||||
* already rendered above in the scrollback as a `plan` HistoryItem, so the prompt just asks the
|
||||
* question — no body preview to duplicate it. */
|
||||
const OPTIONS: Array<{ label: string; value: PermissionDecision }> = [
|
||||
{ label: "Approve — implement the plan", value: "once" },
|
||||
{ label: "Reject — stay in plan mode", value: "deny" },
|
||||
];
|
||||
|
||||
export function PlanApprovalPrompt({ onSelect }: { onSelect: (decision: PermissionDecision) => void }) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold color={ACCENT_HEX}>
|
||||
Plan presented
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>Approve this plan to exit plan mode and let the assistant implement it?</Text>
|
||||
<Text> </Text>
|
||||
<SelectInput items={OPTIONS} onSelect={(item) => onSelect(item.value)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import TextInput from "ink-text-input";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { AskQuestionAnswer, AskQuestionOption, AskQuestionSpec } from "../../tools/types.js";
|
||||
|
||||
const OTHER_LABEL = "✏ Other (type your own)";
|
||||
|
||||
type Mode = "select" | "other";
|
||||
|
||||
/** Renders the model's `ask_user_question` prompts: one question at a time, each with its options
|
||||
* (single-select via arrow keys + enter, multi-select via space to toggle + enter to confirm) and
|
||||
* an "Other" entry that switches to a freeform text input for a custom answer not in the list. Once
|
||||
* all questions are answered the selected labels (and any custom text) are returned to the tool
|
||||
* handler via onSubmit. Built to coexist with the existing PendingPermission slot — the App renders
|
||||
* this in the same bottom area when a question is pending. */
|
||||
export function QuestionPrompt({
|
||||
questions,
|
||||
onSubmit,
|
||||
}: {
|
||||
questions: AskQuestionSpec[];
|
||||
onSubmit: (answers: AskQuestionAnswer[]) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<AskQuestionAnswer[]>([]);
|
||||
const [mode, setMode] = useState<Mode>("select");
|
||||
const [otherText, setOtherText] = useState("");
|
||||
// Multi-select state for the current question: which option labels are toggled on.
|
||||
const [toggled, setToggled] = useState<Set<string>>(new Set());
|
||||
const [cursor, setCursor] = useState(0);
|
||||
|
||||
const q = questions[index];
|
||||
const items: AskQuestionOption[] = q ? [...q.options, { label: OTHER_LABEL }] : [];
|
||||
|
||||
// One key handler at the top so the hook order is stable across all render branches. Each branch
|
||||
// is guarded so keys only act in the mode that's currently rendered (e.g. arrows only navigate the
|
||||
// multi-select list, not the single-select which SelectInput owns, and not the freeform text box).
|
||||
useInput((input, key) => {
|
||||
if (!q) return;
|
||||
if (key.escape && mode === "other") {
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
if (mode !== "select" || !q.multiSelect) return;
|
||||
if (key.upArrow) setCursor((c) => (c - 1 + items.length) % items.length);
|
||||
else if (key.downArrow) setCursor((c) => (c + 1) % items.length);
|
||||
else if (input === " ") {
|
||||
setToggled((prev) => {
|
||||
const next = new Set(prev);
|
||||
const label = items[cursor]!.label;
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
} else if (key.return) {
|
||||
if (toggled.has(OTHER_LABEL)) setMode("other");
|
||||
else commit({ question: q.question, selected: [...toggled] });
|
||||
}
|
||||
});
|
||||
|
||||
if (!q) return null;
|
||||
|
||||
function commit(answer: AskQuestionAnswer) {
|
||||
const nextAnswers = [...answers, answer];
|
||||
if (index + 1 >= questions.length) {
|
||||
onSubmit(nextAnswers);
|
||||
return;
|
||||
}
|
||||
setAnswers(nextAnswers);
|
||||
setIndex(index + 1);
|
||||
setMode("select");
|
||||
setOtherText("");
|
||||
setToggled(new Set());
|
||||
setCursor(0);
|
||||
}
|
||||
|
||||
// --- Freeform "Other" entry (single or multi) ---
|
||||
if (mode === "other") {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold color={ACCENT_HEX}>
|
||||
{q.header}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>{q.question}</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Custom answer (enter to submit, esc to pick from the list):</Text>
|
||||
<TextInput
|
||||
value={otherText}
|
||||
onChange={setOtherText}
|
||||
onSubmit={(value) => commit({ question: q.question, selected: [...toggled].filter((l) => l !== OTHER_LABEL), custom: value || "(blank)" })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Single-select: SelectInput handles its own arrow-key navigation ---
|
||||
if (!q.multiSelect) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold color={ACCENT_HEX}>
|
||||
{q.header}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>{q.question}</Text>
|
||||
<Text> </Text>
|
||||
<SelectInput
|
||||
items={items.map((o) => ({ label: o.label, value: o.label }))}
|
||||
onSelect={(item) => {
|
||||
if (item.value === OTHER_LABEL) setMode("other");
|
||||
else commit({ question: q.question, selected: [item.value] });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Multi-select: hand-rolled list (navigation handled by the top useInput above) ---
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold color={ACCENT_HEX}>
|
||||
{q.header}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>{q.question}</Text>
|
||||
<Text dimColor> (space to toggle, enter to confirm)</Text>
|
||||
<Text> </Text>
|
||||
{items.map((o, i) => {
|
||||
const isOn = toggled.has(o.label);
|
||||
const isCursor = i === cursor;
|
||||
return (
|
||||
<Text key={o.label} color={isCursor ? ACCENT_HEX : undefined} bold={isCursor}>
|
||||
{isCursor ? "❯ " : " "}
|
||||
{isOn ? "☑ " : "☐ "}
|
||||
{o.label}
|
||||
{o.description ? <Text dimColor> — {o.description}</Text> : null}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { useEffect, useState } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { PermissionMode } from "../../permissions/types.js";
|
||||
import type { GitInfo } from "../../utils/gitInfo.js";
|
||||
import { getMcpStatuses } from "../../mcp/manager.js";
|
||||
import { getLoadedPlugins } from "../../plugins/registry.js";
|
||||
import { listBackgroundJobs } from "../../tools/backgroundJobs.js";
|
||||
|
||||
const MODE_LABELS: Record<PermissionMode, string> = {
|
||||
default: "default",
|
||||
@@ -94,6 +97,10 @@ export function StatusBar({
|
||||
const elapsedMinutes = elapsedMs / 60_000;
|
||||
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
|
||||
|
||||
const mcpCount = getMcpStatuses().filter((s) => s.status === "connected").length;
|
||||
const pluginCount = getLoadedPlugins().length;
|
||||
const bgJobs = listBackgroundJobs().filter((j) => j.status === "running").length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" paddingX={1}>
|
||||
<Box gap={1}>
|
||||
@@ -122,10 +129,17 @@ export function StatusBar({
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`}</Text>
|
||||
</Box>
|
||||
<Box gap={1}>
|
||||
<Text dimColor>● MCP:{mcpCount}</Text>
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>● plugins:{pluginCount}</Text>
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>● bg:{bgJobs}</Text>
|
||||
<Text dimColor>│</Text>
|
||||
<Text color={MODE_COLORS[permMode]}>⏵⏵ {MODE_LABELS[permMode]}</Text>
|
||||
</Box>
|
||||
<Box gap={1} justifyContent="space-between">
|
||||
<Text color={MODE_COLORS[permMode]}>
|
||||
⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle)
|
||||
</Text>
|
||||
<Text dimColor>shift+tab to cycle mode</Text>
|
||||
<Text color="yellow">/export · /compact · /help · /exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
+5
-3
@@ -35,12 +35,14 @@ export type HistoryItem =
|
||||
| { id: string; kind: "assistant"; text: string }
|
||||
| { id: string; kind: "streaming_text"; text: string }
|
||||
| { id: string; kind: "tool_call"; label: string }
|
||||
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
|
||||
| { id: string; kind: "tool_result"; summary: string; isError: boolean; diff?: string }
|
||||
| { id: string; kind: "plan"; text: string }
|
||||
| { id: string; kind: "subagent"; description: string; line: import("../../agent/events.js").SubagentLine }
|
||||
| { id: string; kind: "notice"; text: string; isError?: boolean }
|
||||
| { id: string; kind: "todos"; todos: import("../../tools/types.js").TodoItem[] }
|
||||
| { id: string; kind: "tasks"; tasks: import("../../tools/task.js").TaskSummary[] }
|
||||
| { id: string; kind: "help" }
|
||||
| { id: string; kind: "tools"; tools: import("../../tools/types.js").ToolDef[] }
|
||||
| { id: string; kind: "permissions"; allowed: string[] }
|
||||
| { id: string; kind: "permissions"; allowed: string[]; rules: import("../../permissions/types.js").PermissionRule[] }
|
||||
| { id: string; kind: "sessions"; sessions: import("../../persistence/sessionStore.js").SessionSummary[] }
|
||||
| { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] }
|
||||
| {
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export const ACCENT_HEX = "#D97757";
|
||||
|
||||
/** Diff line colors — used by DiffView for the pre-confirmation preview and the post-confirmation
|
||||
* scrollback. Kept as hex constants so the preview and the history render identically. */
|
||||
export const DIFF_ADD_HEX = "#3fb950";
|
||||
export const DIFF_REMOVE_HEX = "#f85149";
|
||||
|
||||
@@ -13,6 +13,8 @@ export function formatCallLabel(name: string, args: unknown): string {
|
||||
return `WebFetch(${a.url ?? ""})`;
|
||||
case "agent":
|
||||
return `Agent(${a.description ?? ""})`;
|
||||
case "ask_user_question":
|
||||
return `Ask(${(a.questions as any[])?.map((q) => q.header).join("/") ?? ""})`;
|
||||
case "git_status":
|
||||
case "git_commit":
|
||||
return `Git(${a.operation ?? ""})`;
|
||||
@@ -55,6 +57,14 @@ export function summarizeToolResult(toolName: string, result: unknown): string {
|
||||
return typeof r.status === "number" ? `Fetched (HTTP ${r.status})` : "Fetched page";
|
||||
case "agent":
|
||||
return typeof r.result === "string" ? `Sub-agent finished (${r.result.length} chars)` : "Sub-agent finished";
|
||||
case "ask_user_question": {
|
||||
const ans = Array.isArray(r.answers) ? (r.answers as { question?: string; selected?: string[]; custom?: string }[]) : [];
|
||||
const parts = ans.map((a) => {
|
||||
const sel = a.custom ? `"${a.custom}"` : (a.selected ?? []).join(", ");
|
||||
return `${a.question ?? "??"}: ${sel || "(none)"}`;
|
||||
});
|
||||
return parts.length ? `Answered — ${parts.join("; ")}` : "No answer";
|
||||
}
|
||||
case "git_status":
|
||||
return typeof r.output === "string" ? `${r.output.split("\n").filter(Boolean).length} line(s)` : "Ran git query";
|
||||
case "git_commit":
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeDiff } from "./diff.js";
|
||||
|
||||
describe("looksLikeDiff", () => {
|
||||
it("treats undefined/empty/null as non-diff", () => {
|
||||
expect(looksLikeDiff(undefined)).toBe(false);
|
||||
expect(looksLikeDiff("")).toBe(false);
|
||||
expect(looksLikeDiff(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects plain command lines even with a stray dash flag", () => {
|
||||
expect(looksLikeDiff("ls -la src/")).toBe(false);
|
||||
expect(looksLikeDiff("Create new file: src/foo.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes full createPatch output (Index: header + hunk)", () => {
|
||||
const patch = "Index: src/foo.ts\n===\n--- src/foo.ts\n+++ src/foo.ts\n@@ -1,3 +1,3 @@\n-old\n+new\n";
|
||||
expect(looksLikeDiff(patch)).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a bare hunk starting with @@", () => {
|
||||
expect(looksLikeDiff("@@ -1,1 +1,1 @@\n-old\n+new\n")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** True if `s` looks like a unified diff (the output of `createPatch` from the `diff` package),
|
||||
* vs a plain preview like a bash command line or "Create new file …". Used to decide whether to
|
||||
* render a preview with +/- color coding or as plain text. createPatch output starts with an
|
||||
* `Index:`/`---`/`+++` header and always contains at least one `@@` hunk marker when there's a
|
||||
* real change, so the hunk marker is the most reliable discriminator (a command line containing
|
||||
* a stray `-` won't false-positive). */
|
||||
export function looksLikeDiff(s: string | undefined | null): boolean {
|
||||
if (!s) return false;
|
||||
return s.includes("\n@@") || s.startsWith("@@") || s.startsWith("Index:") || s.startsWith("--- ");
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { ChatCompletionUserContent } from "../agent/loop.js";
|
||||
import { imageMimeType, MAX_IMAGE_BYTES } from "./image.js";
|
||||
import { truncate } from "./truncate.js";
|
||||
import { assertWithinWorkspace } from "./path.js";
|
||||
|
||||
/** Hard cap for text imports — matching the image budget. A larger file is rejected outright (it
|
||||
* would balloon memory and the context window); users should `read_file` large files instead, which
|
||||
@@ -17,6 +18,7 @@ const MAX_IMPORT_TEXT_CHARS = 50_000;
|
||||
* read_file itself), which is the most broadly-supported way for vision models to receive images. */
|
||||
export async function buildImportContent(cwd: string, filePathArg: string, caption: string): Promise<ChatCompletionUserContent> {
|
||||
const resolved = path.resolve(cwd, filePathArg);
|
||||
assertWithinWorkspace(resolved, cwd, filePathArg);
|
||||
const mimeType = imageMimeType(resolved);
|
||||
|
||||
if (mimeType) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** Throws if `resolved` is outside `workspace`. Accepts both absolute paths. The requestedPath
|
||||
* argument is used in the error message so the user sees what they actually typed. */
|
||||
export function assertWithinWorkspace(resolved: string, workspace: string, requestedPath: string): void {
|
||||
const rel = path.relative(path.resolve(workspace), path.resolve(resolved));
|
||||
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path resolves outside the working directory: ${requestedPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadProjectPermissionRules, mergePermissionRules } from "./projectSettings.js";
|
||||
import type { PermissionRule } from "../permissions/types.js";
|
||||
|
||||
describe("loadProjectPermissionRules", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "locode-settings-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns [] when .locode/settings.json is absent", async () => {
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when .locode/settings.json is invalid JSON", async () => {
|
||||
mkdirSync(path.join(dir, ".locode"));
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), "{not json", "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when permissionRules is missing or not an array", async () => {
|
||||
mkdirSync(path.join(dir, ".locode"));
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), JSON.stringify({ other: 1 }), "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([]);
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), JSON.stringify({ permissionRules: "nope" }), "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads well-formed allow and deny rules", async () => {
|
||||
const rules: PermissionRule[] = [
|
||||
{ tool: "bash", argPattern: "^npm test", allow: true },
|
||||
{ tool: "bash", argPattern: "rm\\s+-rf", allow: false },
|
||||
];
|
||||
mkdirSync(path.join(dir, ".locode"));
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), JSON.stringify({ permissionRules: rules }), "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual(rules);
|
||||
});
|
||||
|
||||
it("drops malformed rules but keeps well-formed ones", async () => {
|
||||
const raw = [
|
||||
{ tool: "bash", allow: true }, // good
|
||||
{ tool: "", allow: true }, // empty tool name → dropped
|
||||
{ tool: "edit_file", allow: "yes" }, // non-boolean allow → dropped
|
||||
{ allow: false }, // missing tool → dropped
|
||||
null, // not an object → dropped
|
||||
{ tool: "write_file", allow: false, argPattern: "secret" }, // good with argPattern
|
||||
];
|
||||
mkdirSync(path.join(dir, ".locode"));
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), JSON.stringify({ permissionRules: raw }), "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([
|
||||
{ tool: "bash", allow: true },
|
||||
{ tool: "write_file", allow: false, argPattern: "secret" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves other top-level keys in settings.json", async () => {
|
||||
mkdirSync(path.join(dir, ".locode"));
|
||||
const json = JSON.stringify({ model: "x", permissionRules: [{ tool: "bash", allow: true }] });
|
||||
writeFileSync(path.join(dir, ".locode", "settings.json"), json, "utf-8");
|
||||
expect(await loadProjectPermissionRules(dir)).toEqual([{ tool: "bash", allow: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePermissionRules", () => {
|
||||
it("concatenates user rules then project rules", () => {
|
||||
const user: PermissionRule[] = [{ tool: "bash", allow: true }];
|
||||
const project: PermissionRule[] = [{ tool: "edit_file", allow: false }];
|
||||
expect(mergePermissionRules(user, project)).toEqual([
|
||||
{ tool: "bash", allow: true },
|
||||
{ tool: "edit_file", allow: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns just user rules when project has none", () => {
|
||||
const user: PermissionRule[] = [{ tool: "bash", allow: true }];
|
||||
expect(mergePermissionRules(user, [])).toEqual(user);
|
||||
});
|
||||
|
||||
it("returns just project rules when user has none", () => {
|
||||
const project: PermissionRule[] = [{ tool: "bash", allow: false }];
|
||||
expect(mergePermissionRules([], project)).toEqual(project);
|
||||
});
|
||||
|
||||
it("returns [] when both are empty", () => {
|
||||
expect(mergePermissionRules([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not mutate its inputs", () => {
|
||||
const user: PermissionRule[] = [{ tool: "bash", allow: true }];
|
||||
const project: PermissionRule[] = [{ tool: "edit_file", allow: false }];
|
||||
mergePermissionRules(user, project);
|
||||
expect(user).toEqual([{ tool: "bash", allow: true }]);
|
||||
expect(project).toEqual([{ tool: "edit_file", allow: false }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { PermissionRule } from "../permissions/types.js";
|
||||
|
||||
/** Project-level locode settings (sibling to CLAUDE.md/AGENTS.md at the project root), currently
|
||||
* holding permission rules that layer on top of the user-level config.json rules. Deny wins across
|
||||
* layers (see PermissionManager.checkRules), so a project can tighten what the user loosened and
|
||||
* vice versa. Only the project root is checked, not parent dirs (locode sessions are cwd-scoped). */
|
||||
const PROJECT_SETTINGS_FILE = ".locode/settings.json";
|
||||
|
||||
interface ProjectSettings {
|
||||
permissionRules?: PermissionRule[];
|
||||
}
|
||||
|
||||
/** Reads .locode/settings.json from `cwd` and returns its permission rules (empty if the file is
|
||||
* absent or unreadable). Malformed rules are dropped rather than crashing the session — a bad
|
||||
* project settings file shouldn't block the user from starting locode. */
|
||||
export async function loadProjectPermissionRules(cwd: string): Promise<PermissionRule[]> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path.join(cwd, PROJECT_SETTINGS_FILE), "utf-8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let parsed: ProjectSettings;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as ProjectSettings;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rules = parsed.permissionRules;
|
||||
if (!Array.isArray(rules)) return [];
|
||||
// Keep only well-formed rules (must name a tool and choose allow/deny). argPattern is optional.
|
||||
return rules.filter(
|
||||
(r): r is PermissionRule =>
|
||||
r && typeof r === "object" && typeof r.tool === "string" && r.tool.length > 0 && typeof r.allow === "boolean",
|
||||
);
|
||||
}
|
||||
|
||||
/** Merges user-level and project-level permission rules into one array (user first, then project).
|
||||
* Order within the array doesn't affect the outcome — checkRules gives deny precedence regardless —
|
||||
* but concatenating preserves both layers so listRules() can report the full active set. */
|
||||
export function mergePermissionRules(user: PermissionRule[], project: PermissionRule[]): PermissionRule[] {
|
||||
return [...user, ...project];
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { _setConfigFilePathForTest } from "../config/store.js";
|
||||
import {
|
||||
deleteMemoryEntry,
|
||||
indexLine,
|
||||
isValidMemoryName,
|
||||
listMemoryEntries,
|
||||
loadUserMemory,
|
||||
parseMemoryFile,
|
||||
readMemoryEntry,
|
||||
userMemoryDir,
|
||||
userMemoryIndexPath,
|
||||
userMemoryPath,
|
||||
writeMemoryEntry,
|
||||
} from "./userMemory.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
describe("userMemory typed system", () => {
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(os.tmpdir(), "locode-memory-"));
|
||||
_setConfigFilePathForTest(path.join(tempDir, "config.json"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_setConfigFilePathForTest(undefined);
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("name validation + (de)serialization", () => {
|
||||
it("accepts kebab-case slugs, rejects traversal / odd chars", () => {
|
||||
expect(isValidMemoryName("prefer-concise-answers")).toBe(true);
|
||||
expect(isValidMemoryName("a")).toBe(true);
|
||||
expect(isValidMemoryName("UPPER")).toBe(false);
|
||||
expect(isValidMemoryName("../etc")).toBe(false);
|
||||
expect(isValidMemoryName("with space")).toBe(false);
|
||||
expect(isValidMemoryName("dot.dot")).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips a memory file through serialize/parse", () => {
|
||||
const entry = { name: "prefer-concise", description: "User likes short answers", type: "user" as const, body: "Keep it brief." };
|
||||
const text = `---\nname: ${entry.name}\ndescription: ${entry.description}\ntype: ${entry.type}\n---\n\n${entry.body}\n`;
|
||||
const parsed = parseMemoryFile(text);
|
||||
expect(parsed).toEqual(entry);
|
||||
});
|
||||
|
||||
it("parseMemoryFile rejects malformed frontmatter", () => {
|
||||
expect(parseMemoryFile("no frontmatter at all")).toBeNull();
|
||||
expect(parseMemoryFile("---\nname: Bad Name\n---\nbody")).toBeNull(); // invalid name
|
||||
expect(parseMemoryFile("---\nname: ok\ntype: bogus\n---\nbody")).toBeNull(); // invalid type
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadUserMemory", () => {
|
||||
it("returns null when nothing exists", async () => {
|
||||
expect(await loadUserMemory()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the bounded index when typed facts exist", async () => {
|
||||
await writeMemoryEntry({ name: "prefers-concise", description: "short answers", type: "user", body: "Be brief." });
|
||||
await writeMemoryEntry({ name: "react-stack", description: "uses react+vitest", type: "project", body: "Stack is React + vitest." });
|
||||
const result = await loadUserMemory();
|
||||
expect(result).toContain("Personal memory index");
|
||||
expect(result).toContain(indexLine({ name: "prefers-concise", description: "short answers", type: "user" }));
|
||||
expect(result).toContain(indexLine({ name: "react-stack", description: "uses react+vitest", type: "project" }));
|
||||
// Bodies are NOT in the prompt — only the one-line index.
|
||||
expect(result).not.toContain("Be brief.");
|
||||
});
|
||||
|
||||
it("falls back to the legacy freeform memory.md when no typed index exists", async () => {
|
||||
writeFileSync(userMemoryPath(), "Prefer concise answers.");
|
||||
const result = await loadUserMemory();
|
||||
expect(result).toContain("User memory (from memory.md)");
|
||||
expect(result).toContain("Prefer concise answers.");
|
||||
});
|
||||
|
||||
it("prefers the typed index over the legacy file when both exist", async () => {
|
||||
writeFileSync(userMemoryPath(), "legacy junk");
|
||||
await writeMemoryEntry({ name: "typed-fact", description: "wins", type: "user", body: "typed body" });
|
||||
const result = await loadUserMemory();
|
||||
expect(result).toContain("Personal memory index");
|
||||
expect(result).not.toContain("legacy junk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("write / read / list / delete", () => {
|
||||
it("writeMemoryEntry creates the file + index, readMemoryEntry reads it back", async () => {
|
||||
await writeMemoryEntry({ name: "test-fact", description: "a fact", type: "feedback", body: "Why: x\nHow to apply: y" });
|
||||
expect(existsSync(path.join(userMemoryDir(), "test-fact.md"))).toBe(true);
|
||||
expect(existsSync(userMemoryIndexPath())).toBe(true);
|
||||
const entry = await readMemoryEntry("test-fact");
|
||||
expect(entry?.body).toBe("Why: x\nHow to apply: y");
|
||||
expect(entry?.type).toBe("feedback");
|
||||
});
|
||||
|
||||
it("writeMemoryEntry overwrites an existing fact and reconciles the index", async () => {
|
||||
await writeMemoryEntry({ name: "flip", description: "old", type: "user", body: "old body" });
|
||||
await writeMemoryEntry({ name: "flip", description: "new", type: "reference", body: "new body" });
|
||||
const entry = await readMemoryEntry("flip");
|
||||
expect(entry?.description).toBe("new");
|
||||
expect(entry?.body).toBe("new body");
|
||||
// Index has exactly one line for this fact.
|
||||
const list = await listMemoryEntries();
|
||||
expect(list.filter((e) => e.name === "flip")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("listMemoryEntries returns all well-formed facts, skipping malformed index lines", async () => {
|
||||
await writeMemoryEntry({ name: "a-fact", description: "a", type: "user", body: "aa" });
|
||||
await writeMemoryEntry({ name: "b-fact", description: "b", type: "project", body: "bb" });
|
||||
const list = await listMemoryEntries();
|
||||
expect(list.map((e) => e.name).sort()).toEqual(["a-fact", "b-fact"]);
|
||||
});
|
||||
|
||||
it("deleteMemoryEntry removes the file and drops its index line", async () => {
|
||||
await writeMemoryEntry({ name: "gone", description: "x", type: "user", body: "yy" });
|
||||
expect(await deleteMemoryEntry("gone")).toBe(true);
|
||||
expect(await readMemoryEntry("gone")).toBeNull();
|
||||
expect((await listMemoryEntries()).map((e) => e.name)).not.toContain("gone");
|
||||
});
|
||||
|
||||
it("deleteMemoryEntry returns false for a missing fact", async () => {
|
||||
expect(await deleteMemoryEntry("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("readMemoryEntry rejects an invalid name (no traversal)", async () => {
|
||||
await writeMemoryEntry({ name: "real", description: "x", type: "user", body: "y" });
|
||||
// An invalid name can't read a sibling file even if it exists on disk.
|
||||
expect(await readMemoryEntry("../real")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths", () => {
|
||||
it("userMemoryDir + index path live in the (overridable) config dir", () => {
|
||||
expect(userMemoryDir()).toBe(path.join(tempDir, "memory"));
|
||||
expect(userMemoryIndexPath()).toBe(path.join(tempDir, "memory", "MEMORY.md"));
|
||||
expect(userMemoryPath()).toBe(path.join(tempDir, "memory.md"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { configDirPath } from "../config/store.js";
|
||||
|
||||
// locode's personal memory mirrors Claude Code's file-based memory: one file per fact under a
|
||||
// `memory/` directory in the config dir, each with YAML-ish frontmatter (name / description / type)
|
||||
// and a body holding the fact, plus a `memory/MEMORY.md` index with one pointer line per fact.
|
||||
//
|
||||
// The KEY difference from the previous single freeform `memory.md`: only the lightweight index is
|
||||
// folded into the system prompt every turn (one line per fact, bounded), NOT every fact's full
|
||||
// body. The model reads a specific fact's body on demand via the `memory` tool. This bounds token
|
||||
// cost as memory grows — critical for local models with small context windows — instead of
|
||||
// re-sending an ever-growing freeform blob each turn.
|
||||
//
|
||||
// The older single `memory.md` file is still honored as a legacy fallback when no `memory/` index
|
||||
// exists, so existing users don't lose what they saved; new writes always go to the typed system.
|
||||
|
||||
const MEMORY_DIR = "memory";
|
||||
const MEMORY_INDEX = "MEMORY.md";
|
||||
const LEGACY_MEMORY_FILE = "memory.md";
|
||||
|
||||
// Cap on the index text folded into the system prompt. One line per fact means this accommodates
|
||||
// hundreds of facts; a body is never in the prompt, so this is the only per-turn memory cost.
|
||||
const MAX_INDEX_CHARS = 8_000;
|
||||
|
||||
export type MemoryType = "user" | "feedback" | "project" | "reference";
|
||||
|
||||
export const MEMORY_TYPES: readonly MemoryType[] = ["user", "feedback", "project", "reference"];
|
||||
|
||||
export interface MemoryEntry {
|
||||
/** Filename slug (kebab-case), also the `name:` frontmatter value. */
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Directory holding the per-fact .md files and the MEMORY.md index. */
|
||||
export function userMemoryDir(): string {
|
||||
return path.join(configDirPath(), MEMORY_DIR);
|
||||
}
|
||||
|
||||
/** Path to the MEMORY.md index (always-in-prompt summary, one line per fact). */
|
||||
export function userMemoryIndexPath(): string {
|
||||
return path.join(userMemoryDir(), MEMORY_INDEX);
|
||||
}
|
||||
|
||||
/** Path to the legacy single-file memory.md (pre-typed-system). Kept for the load fallback and the
|
||||
* `/memory` command so nothing already saved is lost. */
|
||||
export function userMemoryPath(): string {
|
||||
return path.join(configDirPath(), LEGACY_MEMORY_FILE);
|
||||
}
|
||||
|
||||
// A name is a kebab-case slug used as both the frontmatter `name` and the filename. Validating it
|
||||
// here (and in the tool schema) prevents path traversal and filename surprises — the file path is
|
||||
// built as `memory/<name>.md`, so a name containing `/`, `..`, or separators would escape the dir.
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function isValidMemoryName(name: string): boolean {
|
||||
return NAME_RE.test(name);
|
||||
}
|
||||
|
||||
/** Parses the flat frontmatter block (---\nkey: value\n---\n) plus body from a memory file. Returns
|
||||
* null if the file isn't a well-formed memory file (no/empty frontmatter). The frontmatter is a
|
||||
* deliberately tiny subset of YAML — only flat `key: value` lines we control — so no YAML dep. */
|
||||
export function parseMemoryFile(text: string): MemoryEntry | null {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const raw = match[1]!;
|
||||
const body = match[2] ?? "";
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const m = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
||||
if (m) fields[m[1]!] = m[2]!.trim();
|
||||
}
|
||||
const name = fields["name"];
|
||||
const description = fields["description"] ?? "";
|
||||
const type = (fields["type"] as MemoryType) ?? "user";
|
||||
if (!name || !isValidMemoryName(name)) return null;
|
||||
if (!MEMORY_TYPES.includes(type)) return null;
|
||||
return { name, description, type, body: body.trim() };
|
||||
}
|
||||
|
||||
export function serializeMemoryFile(entry: MemoryEntry): string {
|
||||
return `---\nname: ${entry.name}\ndescription: ${entry.description}\ntype: ${entry.type}\n---\n\n${entry.body.trim()}\n`;
|
||||
}
|
||||
|
||||
/** One index line per fact, in the Claude Code `MEMORY.md` convention: a markdown link plus a hook.
|
||||
* We fold the type in too so the always-in-prompt index carries enough to judge relevance without
|
||||
* reading each body. */
|
||||
export function indexLine(entry: Pick<MemoryEntry, "name" | "description" | "type">): string {
|
||||
const desc = entry.description || "(no description)";
|
||||
return `- [${entry.name}](${entry.name}.md) — (${entry.type}) ${desc}`;
|
||||
}
|
||||
|
||||
function parseIndexLine(line: string): { name: string } | null {
|
||||
const m = /^-\s+\[([^\]]+)\]\(([^)]+)\)/.exec(line.trim());
|
||||
if (!m) return null;
|
||||
// The link text and target filename should both be the slug; trust the target.
|
||||
const name = path.basename(m[2]!, ".md");
|
||||
return isValidMemoryName(name) ? { name } : null;
|
||||
}
|
||||
|
||||
/** Reads the MEMORY.md index and returns the raw index text (the lines as stored), bounded to
|
||||
* MAX_INDEX_CHARS. Returns null if the index is absent or empty. */
|
||||
async function readIndexRaw(): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(userMemoryIndexPath(), "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > MAX_INDEX_CHARS ? `${trimmed.slice(0, MAX_INDEX_CHARS)}\n... [truncated]` : trimmed;
|
||||
}
|
||||
|
||||
/** Loads the user's personal memory for folding into the system prompt. Returns the bounded index
|
||||
* text (one line per fact) when the typed `memory/` system is in use; falls back to the legacy
|
||||
* freeform `memory.md` only when no typed index exists. Null when neither is present. */
|
||||
export async function loadUserMemory(): Promise<string | null> {
|
||||
const index = await readIndexRaw();
|
||||
if (index) {
|
||||
return `Personal memory index (call the 'memory' tool with a name to read a fact's full content):\n\n${index}`;
|
||||
}
|
||||
// Legacy fallback: a pre-typed-system single freeform file.
|
||||
let legacy: string;
|
||||
try {
|
||||
legacy = await readFile(userMemoryPath(), "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = legacy.trim();
|
||||
if (!trimmed) return null;
|
||||
const capped = trimmed.length > MAX_INDEX_CHARS ? `${trimmed.slice(0, MAX_INDEX_CHARS)}\n... [truncated]` : trimmed;
|
||||
return `User memory (from ${LEGACY_MEMORY_FILE}):\n\n${capped}`;
|
||||
}
|
||||
|
||||
/** Lists every typed memory entry (frontmatter + body) by reading the index then each file. Used by
|
||||
* the read-only `memory` tool (no-arg/list form) so the model can see all facts at once when it
|
||||
* prefers that over the index. Skips files whose frontmatter is malformed or missing. */
|
||||
export async function listMemoryEntries(): Promise<MemoryEntry[]> {
|
||||
const index = await readIndexRaw();
|
||||
if (!index) return [];
|
||||
const names = index
|
||||
.split(/\r?\n/)
|
||||
.map(parseIndexLine)
|
||||
.filter((n): n is { name: string } => n !== null)
|
||||
.map((n) => n.name);
|
||||
const entries: MemoryEntry[] = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const text = await readFile(path.join(userMemoryDir(), `${name}.md`), "utf-8");
|
||||
const entry = parseMemoryFile(text);
|
||||
if (entry) entries.push(entry);
|
||||
} catch {
|
||||
// Index points at a missing file — skip it; the index will be reconciled on next write/delete.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Reads a single memory entry by name. Returns null if the file is absent or malformed. */
|
||||
export async function readMemoryEntry(name: string): Promise<MemoryEntry | null> {
|
||||
if (!isValidMemoryName(name)) return null;
|
||||
try {
|
||||
const text = await readFile(path.join(userMemoryDir(), `${name}.md`), "utf-8");
|
||||
return parseMemoryFile(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the MEMORY.md index from every well-formed memory file on disk. Called after each write
|
||||
* / delete so the index never drifts from the files (and drops lines pointing at missing files). */
|
||||
async function rebuildIndex(existingNames: string[]): Promise<void> {
|
||||
const entries: MemoryEntry[] = [];
|
||||
for (const name of existingNames) {
|
||||
try {
|
||||
const text = await readFile(path.join(userMemoryDir(), `${name}.md`), "utf-8");
|
||||
const entry = parseMemoryFile(text);
|
||||
if (entry) entries.push(entry);
|
||||
} catch {
|
||||
// Missing file — omit from the rebuilt index.
|
||||
}
|
||||
}
|
||||
// Stable order by name so the index diff is deterministic and previews are clean.
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const body = entries.map(indexLine).join("\n");
|
||||
await writeFile(userMemoryIndexPath(), body ? `${body}\n` : "", "utf-8");
|
||||
}
|
||||
|
||||
async function listExistingNames(): Promise<string[]> {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(userMemoryDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return files.filter((f) => f.endsWith(".md") && f !== MEMORY_INDEX).map((f) => f.slice(0, -3));
|
||||
}
|
||||
|
||||
/** Creates or overwrites a typed memory fact: writes `memory/<name>.md` and reconciles the index.
|
||||
* Returns the entry written. */
|
||||
export async function writeMemoryEntry(entry: MemoryEntry): Promise<MemoryEntry> {
|
||||
await mkdir(userMemoryDir(), { recursive: true });
|
||||
await writeFile(path.join(userMemoryDir(), `${entry.name}.md`), serializeMemoryFile(entry), "utf-8");
|
||||
const names = new Set(await listExistingNames());
|
||||
names.add(entry.name);
|
||||
await rebuildIndex([...names]);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Deletes a typed memory fact and reconciles the index. Returns true if a file was removed. */
|
||||
export async function deleteMemoryEntry(name: string): Promise<boolean> {
|
||||
if (!isValidMemoryName(name)) return false;
|
||||
const file = path.join(userMemoryDir(), `${name}.md`);
|
||||
try {
|
||||
await rm(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await rebuildIndex((await listExistingNames()).filter((n) => n !== name));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { execa } from "execa";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createWorktree, createInteractiveWorktree, hasUncommittedChanges, removeInteractiveWorktree } from "./worktree.js";
|
||||
|
||||
// These tests shell out to real git. The repo is a fresh temp dir each time and cleaned up after.
|
||||
|
||||
async function gitInit(repo: string): Promise<void> {
|
||||
await execa("git", ["init", "-q"], { cwd: repo });
|
||||
await execa("git", ["config", "user.email", "t@t"], { cwd: repo });
|
||||
await execa("git", ["config", "user.name", "t"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
await execa("git", ["add", "."], { cwd: repo });
|
||||
await execa("git", ["commit", "-q", "-m", "init"], { cwd: repo });
|
||||
}
|
||||
|
||||
describe("createWorktree", () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(path.join(os.tmpdir(), "locode-wt-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(repo)) {
|
||||
// Prune any leftover worktrees first (awaited, so git releases its admin-dir locks before we
|
||||
// rm the repo), then best-effort remove. Windows occasionally EPERMs on .git internals that
|
||||
// git is still flushing — that's a test-harness cleanup detail, not a logic failure, so swallow.
|
||||
await execa("git", ["worktree", "prune"], { cwd: repo, reject: false }).catch(() => {});
|
||||
try {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* leave it for the OS temp sweep */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns cwd=undefined for a non-git directory", async () => {
|
||||
const iso = await createWorktree(repo); // repo has no .git yet
|
||||
expect(iso.cwd).toBeUndefined();
|
||||
await iso.cleanup(); // noop, must not throw
|
||||
});
|
||||
|
||||
it("creates a real worktree at a distinct path and cleans it up", async () => {
|
||||
await gitInit(repo);
|
||||
const iso = await createWorktree(repo);
|
||||
expect(iso.cwd).toBeDefined();
|
||||
expect(iso.cwd).not.toBe(repo);
|
||||
expect(existsSync(iso.cwd!)).toBe(true);
|
||||
// The worktree checked out the committed file.
|
||||
expect(existsSync(path.join(iso.cwd!, "README.md"))).toBe(true);
|
||||
|
||||
await iso.cleanup();
|
||||
expect(existsSync(iso.cwd!)).toBe(false);
|
||||
// Pruned from git's worktree list.
|
||||
const list = await execa("git", ["worktree", "list"], { cwd: repo });
|
||||
expect(list.stdout).not.toContain(iso.cwd!);
|
||||
});
|
||||
|
||||
it("cleanup is idempotent", async () => {
|
||||
await gitInit(repo);
|
||||
const iso = await createWorktree(repo);
|
||||
await iso.cleanup();
|
||||
await expect(iso.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("two worktrees are independent paths", async () => {
|
||||
await gitInit(repo);
|
||||
const a = await createWorktree(repo);
|
||||
const b = await createWorktree(repo);
|
||||
expect(a.cwd).not.toBe(b.cwd);
|
||||
await a.cleanup();
|
||||
await b.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUncommittedChanges", () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(path.join(os.tmpdir(), "locode-wt-dirty-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(repo)) rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false for a clean repo and true after a change", async () => {
|
||||
await gitInit(repo);
|
||||
expect(await hasUncommittedChanges(repo)).toBe(false);
|
||||
writeFileSync(path.join(repo, "README.md"), "changed\n");
|
||||
expect(await hasUncommittedChanges(repo)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a non-git directory", async () => {
|
||||
expect(await hasUncommittedChanges(repo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInteractiveWorktree / removeInteractiveWorktree", () => {
|
||||
let repo: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(path.join(os.tmpdir(), "locode-iwt-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(repo)) {
|
||||
await execa("git", ["worktree", "prune"], { cwd: repo, reject: false }).catch(() => {});
|
||||
try {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* leave for the OS temp sweep */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a worktree on a named branch from HEAD and removes it", async () => {
|
||||
await gitInit(repo);
|
||||
const wt = await createInteractiveWorktree(repo, "feature");
|
||||
expect(wt.branch).toBe("locode-wt-feature");
|
||||
expect(existsSync(wt.dir)).toBe(true);
|
||||
// The worktree checked out the committed file (started from HEAD, no uncommitted carryover).
|
||||
expect(existsSync(path.join(wt.dir, "README.md"))).toBe(true);
|
||||
// The branch exists.
|
||||
const br = await execa("git", ["rev-parse", "--verify", wt.branch], { cwd: repo, reject: false });
|
||||
expect(br.exitCode).toBe(0);
|
||||
|
||||
await removeInteractiveWorktree(repo, wt.dir, wt.branch);
|
||||
expect(existsSync(wt.dir)).toBe(false);
|
||||
// Branch is gone after remove.
|
||||
const gone = await execa("git", ["rev-parse", "--verify", "--quiet", wt.branch], { cwd: repo, reject: false });
|
||||
expect(gone.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it("auto-generates a name when none is given", async () => {
|
||||
await gitInit(repo);
|
||||
const wt = await createInteractiveWorktree(repo);
|
||||
expect(wt.branch).toMatch(/^locode-wt-wt-/);
|
||||
await removeInteractiveWorktree(repo, wt.dir, wt.branch);
|
||||
});
|
||||
|
||||
it("refuses an invalid name", async () => {
|
||||
await gitInit(repo);
|
||||
await expect(createInteractiveWorktree(repo, "bad name!")).rejects.toThrow(/Invalid worktree name/i);
|
||||
await expect(createInteractiveWorktree(repo, "-leading-dash")).rejects.toThrow(/Invalid worktree name/i);
|
||||
});
|
||||
|
||||
it("refuses a duplicate branch name (re-using an existing name)", async () => {
|
||||
await gitInit(repo);
|
||||
const wt = await createInteractiveWorktree(repo, "dup");
|
||||
await removeInteractiveWorktree(repo, wt.dir, wt.branch); // clean up the worktree but the test below recreates the same branch
|
||||
// Now create a branch with the same name manually so createInteractiveWorktree hits the exists-check.
|
||||
await execa("git", ["branch", "locode-wt-dup"], { cwd: repo });
|
||||
await expect(createInteractiveWorktree(repo, "dup")).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("refuses when the cwd is not a git repo", async () => {
|
||||
await expect(createInteractiveWorktree(repo, "x")).rejects.toThrow(/Not a git repository/i);
|
||||
});
|
||||
|
||||
it("remove discards a dirty worktree (force) and deletes the branch", async () => {
|
||||
await gitInit(repo);
|
||||
const wt = await createInteractiveWorktree(repo, "dirty");
|
||||
writeFileSync(path.join(wt.dir, "README.md"), "changed\n"); // uncommitted change in the worktree
|
||||
expect(await hasUncommittedChanges(wt.dir)).toBe(true);
|
||||
await removeInteractiveWorktree(repo, wt.dir, wt.branch); // --force removes it anyway
|
||||
expect(existsSync(wt.dir)).toBe(false);
|
||||
const gone = await execa("git", ["rev-parse", "--verify", "--quiet", wt.branch], { cwd: repo, reject: false });
|
||||
expect(gone.exitCode).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { execa } from "execa";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface WorktreeIsolation {
|
||||
/** The worktree's working directory to run the sub-agent against, or undefined when isolation
|
||||
* isn't possible (e.g. the parent cwd isn't a git repo) — in which case the caller runs in the
|
||||
* shared parent cwd with no changes. */
|
||||
cwd: string | undefined;
|
||||
/** Remove the worktree and prune it. Safe to call multiple times; a noop when cwd is undefined. */
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function gitOk(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const r = await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd, reject: false });
|
||||
return r.exitCode === 0 && r.stdout.trim() === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a throwaway git worktree of the parent repo (on a detached HEAD at the current commit) so
|
||||
* a sub-agent can run against an isolated copy of the working tree. Parallel sub-agents each get
|
||||
* their own worktree, so their file writes can't collide with each other or with the main repo.
|
||||
*
|
||||
* The worktree is detached (no branch) and removed on cleanup — file changes made inside it do NOT
|
||||
* land in the main repo. This is the right thing for parallel delegations whose deliverable is the
|
||||
* returned answer (research, review, analysis); for tasks that must edit the repo, delegate a
|
||||
* single (sequential) agent instead, which runs in the shared cwd and persists edits.
|
||||
*
|
||||
* Returns `{ cwd: undefined, noop cleanup }` when the parent cwd isn't a git repo, so callers can
|
||||
* treat isolation as best-effort and fall back to the shared cwd without special-casing. */
|
||||
export async function createWorktree(parentCwd: string): Promise<WorktreeIsolation> {
|
||||
if (!(await gitOk(parentCwd))) {
|
||||
return { cwd: undefined, cleanup: async () => {} };
|
||||
}
|
||||
// Place the worktree under the OS temp dir (NOT inside the repo's .git) so it never shows up in
|
||||
// the user's working tree or status, and so cleanup is a single rm even if git prune races.
|
||||
const wtPath = path.join(os.tmpdir(), `locode-wt-${randomUUID()}`);
|
||||
try {
|
||||
const add = await execa("git", ["worktree", "add", "--detach", wtPath, "HEAD"], { cwd: parentCwd, reject: false });
|
||||
if (add.exitCode !== 0) {
|
||||
return { cwd: undefined, cleanup: async () => {} };
|
||||
}
|
||||
} catch {
|
||||
return { cwd: undefined, cleanup: async () => {} };
|
||||
}
|
||||
let cleaned = false;
|
||||
const cleanup = async () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
// `--force` so an untracked/dirty worktree (a sub-agent left changes behind) is still removed.
|
||||
await execa("git", ["worktree", "remove", "--force", wtPath], { cwd: parentCwd, reject: false }).catch(() => {});
|
||||
await execa("git", ["worktree", "prune"], { cwd: parentCwd, reject: false }).catch(() => {});
|
||||
if (existsSync(wtPath)) await rm(wtPath, { recursive: true, force: true }).catch(() => {});
|
||||
};
|
||||
return { cwd: wtPath, cleanup };
|
||||
}
|
||||
|
||||
/** Reports whether a directory has uncommitted changes (staged or unstaged), used to tell the model
|
||||
* when its isolated-worktree edits were discarded. Returns false on any git error (treat as clean). */
|
||||
export async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const r = await execa("git", ["status", "--porcelain"], { cwd, reject: false });
|
||||
return r.exitCode === 0 && r.stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Valid characters for an interactive worktree name (also used as the branch suffix). Matches Claude
|
||||
// Code's allowance: alphanumeric, dot, underscore, dash; must start alphanumeric; max 64 chars.
|
||||
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
/** Creates an INTERACTIVE worktree the user/model works in via `enter_worktree`: a new branch
|
||||
* (`locode-wt-<name>`) at HEAD, checked out into a throwaway directory under the OS temp dir. Unlike
|
||||
* the parallel-sub-agent `createWorktree` (detached, discarded), this one is on a NAMED BRANCH so the
|
||||
* work survives on exit with action "keep" — the branch persists in the repo even after the temp dir
|
||||
* is gone, recoverable via `git worktree add`/`git checkout`.
|
||||
*
|
||||
* The worktree starts from the last commit (HEAD), so uncommitted changes in the main repo do NOT
|
||||
* carry over — that's the isolation point: experiments in the worktree can't touch the user's
|
||||
* in-progress work. Returns `{ dir, branch }`. Throws on a non-git cwd, an invalid/duplicate name,
|
||||
* or a git failure. */
|
||||
export async function createInteractiveWorktree(parentCwd: string, name?: string): Promise<{ dir: string; branch: string }> {
|
||||
if (!(await gitOk(parentCwd))) {
|
||||
throw new Error("Not a git repository — can't create a worktree outside a git repo.");
|
||||
}
|
||||
const slug = name?.trim();
|
||||
if (name !== undefined) {
|
||||
if (!slug || !WORKTREE_NAME_RE.test(slug)) {
|
||||
throw new Error(
|
||||
`Invalid worktree name "${name}". Use letters, digits, dot, underscore, or dash (max 64 chars), starting with a letter or digit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const suffix = slug ?? `wt-${randomUUID().slice(0, 8)}`;
|
||||
const branch = `locode-wt-${suffix}`;
|
||||
// Refuse to clobber an existing branch (a prior kept worktree, or a user branch with this name).
|
||||
const exists = await execa("git", ["rev-parse", "--verify", "--quiet", branch], { cwd: parentCwd, reject: false });
|
||||
if (exists.exitCode === 0) {
|
||||
throw new Error(`A branch named "${branch}" already exists. Pick a different worktree name.`);
|
||||
}
|
||||
// A short random tail avoids collisions when two locode sessions pick the same name (each gets its
|
||||
// own dir + branch — the branch carries the name, the dir just needs to be unique on disk).
|
||||
const dir = path.join(os.tmpdir(), `locode-wt-${suffix}-${randomUUID().slice(0, 6)}`);
|
||||
const add = await execa("git", ["worktree", "add", "-b", branch, dir, "HEAD"], { cwd: parentCwd, reject: false });
|
||||
if (add.exitCode !== 0) {
|
||||
throw new Error(`Failed to create worktree: ${(add.stderr || add.stdout).trim() || "git error"}`);
|
||||
}
|
||||
return { dir, branch };
|
||||
}
|
||||
|
||||
/** Removes an interactive worktree created by {@link createInteractiveWorktree}: removes the worktree
|
||||
* dir, prunes, and deletes the branch. `--force` on the worktree remove so a dirty worktree still
|
||||
* goes; `git branch -D` force-deletes the branch (it's typically not merged). Best-effort — a git
|
||||
* failure on one step still lets the others proceed. The caller must pass the MAIN repo cwd
|
||||
* (originalCwd), not the worktree dir. */
|
||||
export async function removeInteractiveWorktree(parentCwd: string, dir: string, branch: string): Promise<void> {
|
||||
await execa("git", ["worktree", "remove", "--force", dir], { cwd: parentCwd, reject: false }).catch(() => {});
|
||||
await execa("git", ["worktree", "prune"], { cwd: parentCwd, reject: false }).catch(() => {});
|
||||
if (existsSync(dir)) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
await execa("git", ["branch", "-D", branch], { cwd: parentCwd, reject: false }).catch(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user