v0.6.0: sub-agent orchestration, plan mode, memory, and the Claude-Code feature gap

Closes the gap to Claude Code across four upgrade rounds (all verified
green: typecheck, 388 tests, build 313 KB).

Sub-agent orchestration:
- Resumable sub-agents (send_message) + MaxIterations "continue" path
- Configurable multi-depth sub-agent nesting
- Parallel fan-out for pure agent/agent__* batches, each writable delegation
  in its own throwaway git worktree (no file collisions)
- Named specialist fleet: explore, code-reviewer, planner, debugger,
  test-writer (read-only types skip worktree isolation)
- Named teammates: agent tool `name` arg + list_teammates + send_message
  by name (synchronous, addressable handle — not true async background)

Plan mode:
- True in-turn pause/resume on approval (no synthetic proceed turn)
- exit_plan_mode tool + side-by-side diff rendering (/diff)

Persistent memory:
- Typed file-per-fact memory under config dir, index folded into the
  system prompt (bounded), memory + memory_write tools, /memory command

Toolset + UI:
- multi_edit, notebook_edit, web_search/web_fetch, workflow orchestration
  primitive (agent/parallel/pipeline/phase/log + worktree isolation +
  best-effort structured output with recursive schema validation)
- Structured task system (task_create/list/get/update with dependency
  graph + ownership) replacing flat todo_write
- Cron/scheduled tasks (cron_create/list/delete, schedule_wakeup) with
  durable persistence and idle-gated ticking
- Interactive worktree session (enter_worktree/exit_worktree) with a
  live-cwd UI switch that survives /model changes
- AskUserQuestion tool + QuestionPrompt UI
- Permission rule granularity + user/project settings merge
- Color diffs, sub-agent activity panel, /undo, dashboard refinements

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-16 11:35:05 +09:00
co-authored by Claude
parent 6677ac1e9e
commit f347c150a4
81 changed files with 8404 additions and 441 deletions
+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 };
},
};