Compare commits

1 Commits
Author SHA1 Message Date
kimandClaude f347c150a4 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>
2026-08-16 11:35:05 +09:00
81 changed files with 8404 additions and 441 deletions
+162 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+591 -184
View File
@@ -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
View File
@@ -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),
};
}
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+50 -2
View File
@@ -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);
});
+27
View File
@@ -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
View File
@@ -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`. */
+16
View File
@@ -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;
+130
View File
@@ -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 }]);
});
});
});
+43 -2
View File
@@ -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.
+13
View File
@@ -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;
+4
View File
@@ -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[];
}
+1 -1
View File
@@ -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 } : {}) };
},
};
}
+4 -1
View File
@@ -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 {
+281
View File
@@ -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();
});
});
+296
View File
@@ -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.
}
}
}
+31
View File
@@ -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);
});
});
+9 -4
View File
@@ -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;
}
+17 -7
View File
@@ -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
View File
@@ -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 } : {}),
};
},
};
};
+57
View File
@@ -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");
});
});
+110
View File
@@ -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",
]);
+87
View File
@@ -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);
});
});
+68
View File
@@ -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
View File
@@ -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.)
+95
View File
@@ -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
View File
@@ -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 };
},
};
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]));
+4 -1
View File
@@ -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) => {
+123
View File
@@ -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");
});
});
+131
View File
@@ -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`;
}
+141
View File
@@ -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 });
}
});
});
+90
View File
@@ -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 };
},
};
+190
View File
@@ -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 });
}
});
});
+159
View File
@@ -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 };
},
};
+5 -9
View File
@@ -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) {
+72
View File
@@ -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 } : {}) };
},
};
+220
View File
@@ -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();
});
});
+287
View File
@@ -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,
});
+165
View File
@@ -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();
});
});
+19
View File
@@ -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?.() ?? [] }),
};
-25
View File
@@ -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 });
});
});
-28
View File
@@ -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
View File
@@ -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> {
+377
View File
@@ -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
});
});
+358
View File
@@ -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 };
+174
View File
@@ -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();
});
});
+135
View File
@@ -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}.`,
};
},
};
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
)}
+54
View File
@@ -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);
});
});
+55 -4
View File
@@ -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;
}
+85
View File
@@ -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" },
]);
});
});
+151
View File
@@ -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>;
}
+72 -20
View File
@@ -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":
+11 -5
View File
@@ -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
+28
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+17 -3
View File
@@ -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
View File
@@ -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[] }
| {
+5
View File
@@ -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";
+10
View File
@@ -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":
+24
View File
@@ -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);
});
});
+10
View File
@@ -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("--- ");
}
+2
View File
@@ -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) {
+10
View File
@@ -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}`);
}
}
+101
View File
@@ -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 }]);
});
});
+45
View File
@@ -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];
}
+142
View File
@@ -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"));
});
});
});
+225
View File
@@ -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;
}
+174
View File
@@ -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);
});
});
+128
View File
@@ -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(() => {});
}