@arhen/pi-core-subagent 1.3.28 → 1.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,6 +137,18 @@ You are a strict API reviewer. Check auth, rate limiting, and error handling. Ci
137
137
 
138
138
  Within a directory the file with the highest description-overlap score wins (≥2 shared meaningful tokens). A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). Files without a `description` frontmatter never match.
139
139
 
140
+ ## Worktree isolation (write agents)
141
+
142
+ In a git repo, a `write: true` subagent runs in an **isolated git worktree** at `<repo>/.git/subagents/<run>/<task>` on branch `subagents/<run>/<task>` — the child's cwd is the worktree, so project context (AGENTS.md chain) still loads, `node_modules` is symlinked, and the main tree stays clean while the child works. Parallel write agents can't collide on files.
143
+
144
+ On completion the extension commits the child's changes (the child is told not to touch branches) and reports **branch + diffstat + changed files** in the result. The leader reviews, then merges:
145
+
146
+ ```
147
+ git merge --no-ff subagents/<run>/<task>
148
+ ```
149
+
150
+ Merged branches + their worktree dirs are cleaned automatically after the run. Failed/canceled tasks keep the branch (partial work survives for manual merging) but drop the worktree dir. Crash leftovers are swept at session start — worktree dirs are removed, branches are kept. Non-git repos fall back to in-place edits.
151
+
140
152
  ## Graph mode — `needs`
141
153
 
142
154
  `parallel` runs everything at once; `chain` runs everything one at a time. Most real work is neither. Give a task an `id` and list the ids it `needs`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.28",
3
+ "version": "1.3.30",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -101,18 +101,7 @@ function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string
101
101
  * unknown/custom tools then read fine too. Add a case only if one reads badly.
102
102
  */
103
103
  // Order matters: the most specific arg wins (grep's pattern beats its path).
104
- const ARG_KEYS = [
105
- "pattern",
106
- "query",
107
- "command",
108
- "path",
109
- "file_path",
110
- "filePath",
111
- "url",
112
- "name",
113
- "subject",
114
- "task",
115
- ];
104
+ const ARG_KEYS = ["pattern", "query", "command", "path", "file_path", "filePath", "url", "name", "subject", "task"];
116
105
  export function describeCall(toolName: string, args: unknown, cwd?: string): string {
117
106
  const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
118
107
  const obj = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
@@ -221,7 +210,7 @@ export function makeSummary(run: RunSnapshot): string {
221
210
  // Edges are named so the leader can compare what it delegated against what came back.
222
211
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
223
212
  lines.push(
224
- `\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`,
213
+ `\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${task.branch ? `\nBranch: ${task.branch}${task.changedFiles?.length ? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})` : ""} — merge with \`git merge --no-ff ${task.branch}\` after review.` : ""}`,
225
214
  );
226
215
  }
227
216
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
@@ -229,9 +218,14 @@ export function makeSummary(run: RunSnapshot): string {
229
218
  }
230
219
  /** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
231
220
  export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: string): string {
221
+ const goal = truncateText(task.task, 120);
232
222
  const detail = task.error ? task.error : truncateText(task.finalText || "(no output)", 200);
223
+ const wt = task.branch
224
+ ? ` · branch ${task.branch}${task.changedFiles?.length ? `, ${task.changedFiles.length} file(s)` : ""}`
225
+ : "";
233
226
  return [
234
- `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}`,
227
+ `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
228
+ `Goal: ${goal}`,
235
229
  `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
236
230
  ].join("\n");
237
231
  }
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
18
18
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
19
19
  import { compactLines, formatUsage, makeSummary, statusIcon, taskLine, truncateText } from "./format.ts";
20
20
  import { waveNotation } from "./graph.ts";
21
- import { cloneRun, SubagentManager } from "./manager.ts";
21
+ import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
22
22
  import { createPeekPane, type PeekTask } from "./peek.ts";
23
23
  import {
24
24
  AwaitParam,
@@ -30,6 +30,7 @@ import {
30
30
  type SubagentParamsShape,
31
31
  } from "./schemas.ts";
32
32
  import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
33
+ import { cleanupMerged, repoRoot, sweepStale } from "./worktree.ts";
33
34
 
34
35
  export default function (pi: ExtensionAPI) {
35
36
  const manager = new SubagentManager(pi);
@@ -109,6 +110,24 @@ export default function (pi: ExtensionAPI) {
109
110
 
110
111
  pi.on("session_start", async (_event, ctx) => {
111
112
  await manager.restoreFromSidecar(ctx);
113
+ // Crash leftovers: remove stale worktree dirs (branches survive for merging).
114
+ // Also reap branches the leader merged in a previous session (H1: cleanup can't
115
+ // fire at run end — the leader merges after).
116
+ const roots = new Set<string>();
117
+ const cwdRoot = repoRoot(ctx.cwd);
118
+ if (cwdRoot) roots.add(cwdRoot);
119
+ for (const run of manager.listRuns()) {
120
+ for (const task of run.tasks) {
121
+ if (task.branch) {
122
+ const root = repoRoot(task.cwd);
123
+ if (root) roots.add(root);
124
+ }
125
+ }
126
+ }
127
+ for (const root of roots) {
128
+ sweepStale(root);
129
+ cleanupMerged(root);
130
+ }
112
131
  });
113
132
  pi.on("session_shutdown", async (_event, ctx) => {
114
133
  if (ctx?.hasUI) {
@@ -127,7 +146,7 @@ export default function (pi: ExtensionAPI) {
127
146
  // ponytail: this string is billed on every request. No example block — an example
128
147
  // biases the model toward one shape; guidelines + JSON schema describe all of them.
129
148
  description:
130
- "Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply, inline prompt/model/tools ignored. No match → the inline definition stands. Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
149
+ "Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply, inline prompt/model/tools ignored. No match → the inline definition stands. Write agents run in an isolated git worktree: on completion the result reports the branch + changed files — review, then merge with `git merge --no-ff <branch>` (merged branches are cleaned automatically). Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
131
150
  promptSnippet: "Define and delegate work to specialized subagents.",
132
151
  promptGuidelines: [
133
152
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
@@ -135,6 +154,7 @@ export default function (pi: ExtensionAPI) {
135
154
  "Order comes from `needs`, not from separate calls: give tasks an `id`, list the ids each depends on. Tasks with no unmet needs run in parallel; dependents receive their upstream outputs automatically — do not restate them.",
136
155
  "Prefer flat `tasks` (plain parallel) unless a real dependency exists — only add `needs` edges when ordering genuinely matters.",
137
156
  "End each task with a runnable check, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's claim of success is not evidence.",
157
+ "For write agents (write:true) in a git repo, the child works in an isolated worktree and its changes are committed to a branch — the result reports branch + changed files. Review the diff, then merge with `git merge --no-ff <branch>`; merged branches are cleaned up automatically. Never leave a worktree branch unmerged at the end of the task.",
138
158
  "Define each agent yourself: invented name, focused system prompt, and read-only (default) or write:true. Prefer read-only. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents` — project first, then home) whose `description` matches the spawn goal (name + task) takes over: its body is the system prompt, frontmatter `model`/`tools` apply and are validated against the model registry. Matching is by description, not name — name the agent whatever fits the goal.",
139
159
  "When you need a run's result before your next step, spawn with autoAwait:true — the call returns runId + final result in one response. Otherwise spawn background and settle results (await_subagent / subagent_result) before continuing dependent work.",
140
160
  "For long multi-task runs, don't autoAwait the whole run: spawn background, then loop await_subagent with short timeoutMs slices (e.g. 20s), processing whichever tasks completed in each slice while the rest keep running. You get incremental results instead of one big wait.",
@@ -146,13 +166,23 @@ export default function (pi: ExtensionAPI) {
146
166
  const typed = params as SubagentParamsShape;
147
167
  const details = manager.startInBackground(typed, ctx);
148
168
  if (typed.autoAwait) {
149
- // awaitRun wakes on every child→leader message (ask/notify/done) — that's the
150
- // slice-loop feature. autoAwait wants the final result: re-park until terminal.
169
+ // awaitRun wakes on every child→leader message (ask/notify/done). Re-park
170
+ // until terminal but surface an ask_parent: the child is waiting on the
171
+ // leader, so break out, reply via reply_subagent, then await again.
151
172
  let run = details.run;
173
+ let intercom: ParkedMsg[] = [];
152
174
  while (!TERMINAL.includes(run.status)) {
153
- run = (await manager.awaitRun(details.run.id))?.run ?? run;
175
+ const awaited = await manager.awaitRun(details.run.id);
176
+ if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
177
+ if (awaited.run) run = awaited.run;
178
+ intercom = awaited.intercom;
179
+ if (intercom.some((m) => m.kind === "ask")) break;
154
180
  }
155
- return { content: [{ type: "text", text: makeSummary(run) }], details: { run } };
181
+ const asked = intercom.find((m) => m.kind === "ask");
182
+ const text = asked
183
+ ? `${makeSummary(run)}\n\nA child is waiting for your answer (${asked.agent}, ${asked.taskId}): ${asked.text}\nReply with reply_subagent(runId: "${run.id}", taskId: "${asked.taskId}", message: ...), then await_subagent again for the result.`
184
+ : makeSummary(run);
185
+ return { content: [{ type: "text", text }], details: { run } };
156
186
  }
157
187
  return {
158
188
  content: [
@@ -270,10 +300,12 @@ export default function (pi: ExtensionAPI) {
270
300
  const tasks = taskId ? run.tasks.filter((t) => t.id === taskId) : run.tasks;
271
301
  const text = [
272
302
  `Run ${run.id} — ${run.status}`,
273
- ...tasks.map(
274
- (t) =>
275
- `\n## ${t.agent} ${statusIcon(t.status)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}\n${formatUsage(t.usage)}`,
276
- ),
303
+ ...tasks.map((t) => {
304
+ const wt = t.branch
305
+ ? `\nBranch: ${t.branch}\n${t.diffStat || "(no changes committed)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
306
+ : "";
307
+ return `\n## ${t.agent} ${statusIcon(t.status)}\nGoal: ${truncateText(t.task, 300)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}${wt}\n${formatUsage(t.usage)}`;
308
+ }),
277
309
  ].join("\n");
278
310
  return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
279
311
  },
package/src/manager.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { writeFile } from "node:fs/promises";
4
- import { join } from "node:path";
4
+ import { join, relative } from "node:path";
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
6
  import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
7
7
  import {
@@ -43,6 +43,17 @@ import {
43
43
  TERMINAL,
44
44
  type UsageStats,
45
45
  } from "./types.ts";
46
+ import {
47
+ branchDiff,
48
+ cleanupMerged,
49
+ commitWorktree,
50
+ createWorktree,
51
+ removeByBranch,
52
+ removeWorktree,
53
+ repoRoot,
54
+ sweepStale,
55
+ type Worktree,
56
+ } from "./worktree.ts";
46
57
 
47
58
  export const DEFAULT_CONCURRENCY = 3;
48
59
  export const MAX_CONCURRENCY = 8;
@@ -51,6 +62,8 @@ const DEFAULT_RUNTIME_MS = 0;
51
62
  const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
52
63
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
53
64
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
65
+ /** Task ids become git refs + filesystem paths. */
66
+ const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
54
67
  const WIDGET_THROTTLE_MS = 150;
55
68
 
56
69
  // ── helpers ──────────────────────────────────────────────────────────────
@@ -638,8 +651,33 @@ export class SubagentManager {
638
651
  const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
639
652
  const prompt = file?.body ?? input.prompt?.trim();
640
653
  const thinking = input.thinking;
641
- const baseTools = file?.tools ?? input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
654
+ // Trust boundary: a file can NARROW the toolset (intersect with the leader's
655
+ // intent) but never widen it — a repo-planted agent file can't grant write.
656
+ const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
657
+ const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
658
+ const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
642
659
  const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
660
+ // Worktree whenever the child can write — whether the leader said write:true
661
+ // or a file granted write-capable tools.
662
+ const canWrite = input.write || (file?.tools?.some((t) => WRITE_TOOLS.includes(t)) ?? false);
663
+
664
+ // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
665
+ // non-git repos fall back to in-place. The worktree is created BEFORE session
666
+ // start so the child's cwd + AGENTS.md context chain are the worktree's.
667
+ let wt: Worktree | undefined;
668
+ if (canWrite) {
669
+ try {
670
+ wt = createWorktree(task.cwd, run.id, task.id);
671
+ } catch {
672
+ wt = undefined; // git failure → in-place
673
+ }
674
+ }
675
+ // Map a per-task cwd subpath into the worktree so relative paths stay correct.
676
+ let childCwd = wt?.path ?? task.cwd;
677
+ if (wt) {
678
+ const rel = relative(wt.root, task.cwd);
679
+ if (rel && !rel.startsWith("..") && rel !== ".") childCwd = join(wt.path, rel);
680
+ }
643
681
 
644
682
  // Model + thinking resolve against the pi model registry; a bad request
645
683
  // fails the TASK with a helpful message, not the whole run.
@@ -689,11 +727,11 @@ export class SubagentManager {
689
727
  const key = `${run.id}:${task.id}`;
690
728
  try {
691
729
  const subagentInstruction = run.allowIntercom
692
- ? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.`
693
- : `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.`;
730
+ ? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`
731
+ : `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`;
694
732
 
695
733
  const loader = new DefaultResourceLoader({
696
- cwd: task.cwd,
734
+ cwd: childCwd,
697
735
  agentDir: getAgentDir(),
698
736
  noExtensions: true,
699
737
  appendSystemPromptOverride: (base) => [
@@ -708,11 +746,11 @@ export class SubagentManager {
708
746
  : [];
709
747
 
710
748
  const created = await createAgentSession({
711
- cwd: task.cwd,
749
+ cwd: childCwd,
712
750
  agentDir: getAgentDir(),
713
751
  modelRuntime: await createChildModelRuntime(ctx),
714
752
  resourceLoader: loader,
715
- sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
753
+ sessionManager: SessionManager.create(childCwd, undefined, { parentSession: getParentSessionFile(ctx) }),
716
754
  model,
717
755
  thinkingLevel: thinking as ThinkingLevel | undefined,
718
756
  tools,
@@ -790,6 +828,35 @@ export class SubagentManager {
790
828
  truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
791
829
  if (task.status !== "aborted") {
792
830
  this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
831
+ if (wt) {
832
+ // Commit the child's changes, then report the branch + diff so the
833
+ // leader can review and merge (PR-style). The worktree dir stays
834
+ // until the branch is merged — cleanupMerged removes both then.
835
+ // Commit/diff failures must NOT downgrade a completed task or destroy
836
+ // its work: the error is reported, the status stays completed.
837
+ try {
838
+ commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
839
+ const { stat, files } = branchDiff(wt);
840
+ this.updateTask(
841
+ run,
842
+ task,
843
+ { branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
844
+ ctx,
845
+ onUpdate,
846
+ );
847
+ } catch (commitErr) {
848
+ this.updateTask(
849
+ run,
850
+ task,
851
+ {
852
+ branch: wt.branch,
853
+ error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
854
+ },
855
+ ctx,
856
+ onUpdate,
857
+ );
858
+ }
859
+ }
793
860
  }
794
861
  } catch (err) {
795
862
  if (timeout) clearTimeout(timeout);
@@ -823,6 +890,17 @@ export class SubagentManager {
823
890
  watchdog.dispose();
824
891
  if (timeout) clearTimeout(timeout);
825
892
  child?.dispose();
893
+ // Failed/aborted: commit whatever partial work exists FIRST (so the branch
894
+ // really keeps it), then drop the checkout dir.
895
+ if (wt && task.status !== "completed") {
896
+ try {
897
+ commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
898
+ } catch {
899
+ /* nothing to commit */
900
+ }
901
+ this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
902
+ removeWorktree(wt);
903
+ }
826
904
  }
827
905
  }
828
906
 
@@ -854,13 +932,25 @@ export class SubagentManager {
854
932
  ? params.tasks!
855
933
  : params.chain!;
856
934
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
935
+ // Task ids become git refs + filesystem paths — refuse anything unsafe.
936
+ // Explicit ids are checked against each other; generated ones are checked
937
+ // against explicit ones so a collision can't silently fall back to in-place.
857
938
  const ids = new Set<string>();
858
939
  for (const input of inputs) {
859
940
  if (input.id !== undefined) {
941
+ if (!SAFE_TASK_ID.test(input.id)) {
942
+ throw new Error(`Unsafe task id: "${input.id}" (allowed: letters, digits, _ and - only).`);
943
+ }
860
944
  if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
861
945
  ids.add(input.id);
862
946
  }
863
947
  }
948
+ for (let i = 0; i < inputs.length; i++) {
949
+ const generated = `task_${i + 1}`;
950
+ if (inputs[i]?.id === undefined && ids.has(generated)) {
951
+ throw new Error(`Generated task id ${generated} collides with an explicit id — rename the explicit id.`);
952
+ }
953
+ }
864
954
  const edges = resolveNeeds(inputs, mode);
865
955
 
866
956
  const run: RunSnapshot = {
@@ -924,14 +1014,19 @@ export class SubagentManager {
924
1014
  for (const task of run.tasks) {
925
1015
  if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
926
1016
  }
1017
+ // id → input, immune to filtered-array index drift (C4).
1018
+ const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
927
1019
 
928
1020
  const { skipped } = await runWaveScheduler(
929
1021
  run.tasks.filter((t) => !TERMINAL.includes(t.status)),
930
1022
  run.mode === "single" ? 1 : run.concurrency,
931
1023
  outputs,
932
1024
  settled,
933
- async (task, index) => {
934
- const input = inputs[index]!;
1025
+ async (task) => {
1026
+ // The scheduler passes the index into the FILTERED list — never use it
1027
+ // against the unfiltered inputs. Look the input up by task id instead.
1028
+ const input = inputById.get(task.id);
1029
+ if (!input) return;
935
1030
  await this.runChild(
936
1031
  run,
937
1032
  task,
@@ -986,6 +1081,13 @@ export class SubagentManager {
986
1081
  this.runControllers.delete(run.id);
987
1082
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
988
1083
  this.persist(ctx);
1084
+ // Branches merged by the leader since the run ended: drop worktree dir + branch.
1085
+ for (const task of run.tasks) {
1086
+ if (task.branch) {
1087
+ const root = repoRoot(task.cwd);
1088
+ if (root) cleanupMerged(root);
1089
+ }
1090
+ }
989
1091
  }
990
1092
 
991
1093
  /** Spawn a run that keeps executing after this call returns. Every run is background. */
@@ -1062,6 +1164,7 @@ export class SubagentManager {
1062
1164
  task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
1063
1165
  task.endedAt = Date.now();
1064
1166
  aborted += 1;
1167
+ if (task.branch) removeByBranch(task.cwd, task.branch); // dir only; branch keeps partial work
1065
1168
  }
1066
1169
  run.status = "aborted";
1067
1170
  run.endedAt = Date.now();
package/src/types.ts CHANGED
@@ -41,6 +41,10 @@ export interface TaskSnapshot {
41
41
  usage: UsageStats;
42
42
  /** Sibling addresses for intercom tools (send_agent_message targets). */
43
43
  roster?: string;
44
+ /** Git worktree isolation (write agents): branch + diff of the child's changes. */
45
+ branch?: string;
46
+ diffStat?: string;
47
+ changedFiles?: string[];
44
48
  }
45
49
 
46
50
  export interface RunSnapshot {
@@ -0,0 +1,206 @@
1
+ /** Git worktree isolation for write subagents.
2
+ * Worktrees live inside `<repo>/.git/subagents/<runId>/<taskId>` so the child's
3
+ * ancestor walk still finds the project AGENTS.md chain. node_modules is
4
+ * symlinked from the main tree. The extension commits the child's changes on
5
+ * completion; the leader reviews and merges the branch manually; merged
6
+ * branches are cleaned automatically, crash leftovers are swept at session
7
+ * start (dir removed, branch kept — the work survives). */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ export interface Worktree {
14
+ root: string; // repo root (main tree)
15
+ path: string; // worktree checkout dir
16
+ branch: string; // subagents/<runId>/<taskId>
17
+ base: string; // SHA the branch was created from
18
+ }
19
+
20
+ function git(root: string, args: string[]): string {
21
+ return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
22
+ }
23
+
24
+ /** Run git directly inside a directory (worktree ops). */
25
+ function gitIn(dir: string, args: string[]): string {
26
+ return execFileSync("git", [...args], { cwd: dir, encoding: "utf8" }).trim();
27
+ }
28
+
29
+ function gitOk(root: string, args: string[]): boolean {
30
+ try {
31
+ git(root, args);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
39
+ export function repoRoot(cwd: string): string | undefined {
40
+ if (!existsSync(cwd)) return undefined;
41
+ try {
42
+ return git(cwd, ["rev-parse", "--show-toplevel"]);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ /** Create an isolated worktree for a write task. Returns undefined when not a git repo. */
49
+ export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
50
+ const root = repoRoot(cwd);
51
+ if (!root) return undefined;
52
+ const path = join(root, ".git", "subagents", runId, taskId);
53
+ const branch = `subagents/${runId}/${taskId}`;
54
+ let base: string;
55
+ try {
56
+ base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
57
+ } catch {
58
+ return undefined; // broken repo — fall back to in-place
59
+ }
60
+ git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
61
+ // Deps follow the child into the worktree; anything else the task needs is
62
+ // project content already checked out there.
63
+ const nm = join(root, "node_modules");
64
+ if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
65
+ try {
66
+ symlinkSync(nm, join(path, "node_modules"));
67
+ } catch {
68
+ /* non-fatal: task may not need deps */
69
+ }
70
+ }
71
+ return { root, path, branch, base };
72
+ }
73
+
74
+ /** Commit all child changes. No-op when the worktree is already clean. */
75
+ export function commitWorktree(wt: Worktree, message: string): void {
76
+ if (gitIn(wt.path, ["status", "--porcelain"]).length === 0) return;
77
+ gitIn(wt.path, ["add", "-A", "--", ".", ":(exclude)node_modules"]); // never stage the dep symlink
78
+ gitIn(wt.path, ["commit", "-m", message, "--no-verify"]);
79
+ }
80
+
81
+ /** Diffstat + changed files of the branch vs its base SHA. */
82
+ export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
83
+ const files = git(wt.root, ["diff", "--name-only", `${wt.base}...${wt.branch}`])
84
+ .split("\n")
85
+ .filter(Boolean);
86
+ const stat = git(wt.root, ["diff", "--stat", `${wt.base}...${wt.branch}`]);
87
+ return { stat, files };
88
+ }
89
+
90
+ /** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
91
+ export function removeWorktree(wt: Worktree): void {
92
+ try {
93
+ git(wt.root, ["worktree", "remove", "--force", wt.path]);
94
+ } catch {
95
+ /* already gone */
96
+ }
97
+ prune(wt.root);
98
+ }
99
+
100
+ /** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
101
+ export function removeByBranch(cwd: string, branch: string): void {
102
+ const root = repoRoot(cwd);
103
+ if (!root) return;
104
+ const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
105
+ try {
106
+ git(root, ["worktree", "remove", "--force", path]);
107
+ } catch {
108
+ /* already gone */
109
+ }
110
+ prune(root);
111
+ }
112
+
113
+ /** Drop git's stale worktree admin entries (they pile up under .git/worktrees). */
114
+ function prune(root: string): void {
115
+ try {
116
+ git(root, ["worktree", "prune"]);
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Delete branch + worktree for branches already merged into `target`.
124
+ * SAFETY: branches checked out in a LIVE worktree (concurrent run) are skipped —
125
+ * their tip equals the base until the child commits, so they look "merged".
126
+ */
127
+ export function cleanupMerged(root: string, target = "HEAD"): number {
128
+ root = realpathSync(root);
129
+ const merged = git(root, ["branch", "--merged", target])
130
+ .split("\n")
131
+ .map((b) => b.trim().replace(/^[+*]\s*/, ""));
132
+ const live = new Set(worktreeBranches(root));
133
+ let cleaned = 0;
134
+ for (const branch of merged) {
135
+ if (!branch.startsWith("subagents/") || live.has(branch)) continue;
136
+ const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
137
+ if (existsSync(path)) {
138
+ try {
139
+ git(root, ["worktree", "remove", "--force", path]);
140
+ } catch {
141
+ continue;
142
+ }
143
+ }
144
+ if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
145
+ }
146
+ prune(root);
147
+ return cleaned;
148
+ }
149
+
150
+ /** Branch names currently checked out in any worktree (incl. the main one). */
151
+ function worktreeBranches(root: string): string[] {
152
+ try {
153
+ return git(root, ["worktree", "list", "--porcelain"])
154
+ .split("\n")
155
+ .filter((l) => l.startsWith("branch "))
156
+ .map((l) => l.slice("branch refs/heads/".length).trim());
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Remove worktree dirs that are NOT registered worktrees (crash leftovers).
164
+ * A crash leaves the dir AND the branch, so branch-existence can't identify
165
+ * leftovers — worktree registration can. Branches always survive.
166
+ */
167
+ export function sweepStale(root: string): void {
168
+ root = realpathSync(root);
169
+ const sub = join(root, ".git", "subagents");
170
+ if (!existsSync(sub)) return;
171
+ const registered = new Set(worktreePaths(root));
172
+ for (const runDir of readDirs(sub)) {
173
+ for (const taskDir of readDirs(join(sub, runDir))) {
174
+ const dir = join(sub, runDir, taskDir);
175
+ if (registered.has(dir)) continue; // live worktree
176
+ try {
177
+ git(root, ["worktree", "remove", "--force", dir]);
178
+ } catch {
179
+ // dir not a registered worktree (partial crash) — plain rm
180
+ rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ }
183
+ }
184
+ prune(root);
185
+ }
186
+
187
+ function worktreePaths(root: string): string[] {
188
+ try {
189
+ return git(root, ["worktree", "list", "--porcelain"])
190
+ .split("\n")
191
+ .filter((l) => l.startsWith("worktree "))
192
+ .map((l) => l.slice("worktree ".length).trim());
193
+ } catch {
194
+ return [];
195
+ }
196
+ }
197
+
198
+ function readDirs(dir: string): string[] {
199
+ try {
200
+ return readdirSync(dir, { withFileTypes: true })
201
+ .filter((d) => d.isDirectory())
202
+ .map((d) => d.name);
203
+ } catch {
204
+ return [];
205
+ }
206
+ }