@arhen/pi-core-subagent 1.3.30 → 1.3.31

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
@@ -147,7 +147,17 @@ On completion the extension commits the child's changes (the child is told not t
147
147
  git merge --no-ff subagents/<run>/<task>
148
148
  ```
149
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.
150
+ Isolation follows the toolset the child actually receives: explicit `tools: ["bash", "edit", "write"]` earns a worktree even without `write: true`, and an agent file that narrows the child to read-only gets no branch at all.
151
+
152
+ Cleanup, in order of trust:
153
+
154
+ | When | What |
155
+ |---|---|
156
+ | Session start | Registered worktrees can't be live yet — an interrupted child's uncommitted work is committed, the branch kept, the dir dropped. Then merged branches are reaped and dirs git no longer tracks are removed. |
157
+ | After a run | Merged branches + their dirs, never touching a branch a live run owns (checkout state re-checked per branch). |
158
+ | Task failed/canceled | Partial work is committed first, so the branch keeps it; the dir is dropped. |
159
+
160
+ Non-git repos fall back to in-place edits.
151
161
 
152
162
  ## Graph mode — `needs`
153
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.30",
3
+ "version": "1.3.31",
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/index.ts CHANGED
@@ -30,7 +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
+ import { cleanupMerged, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
34
34
 
35
35
  export default function (pi: ExtensionAPI) {
36
36
  const manager = new SubagentManager(pi);
@@ -125,8 +125,16 @@ export default function (pi: ExtensionAPI) {
125
125
  }
126
126
  }
127
127
  for (const root of roots) {
128
- sweepStale(root);
129
- cleanupMerged(root);
128
+ try {
129
+ // Nothing of ours can be live at session start: every registered subagent
130
+ // worktree is a crash leftover — commit its work, keep the branch, drop the
131
+ // dir. Then reap merged branches and dirs git no longer tracks.
132
+ reapDeadWorktrees(root);
133
+ cleanupMerged(root);
134
+ sweepStale(root);
135
+ } catch {
136
+ /* recovery is best-effort — never block session start */
137
+ }
130
138
  }
131
139
  });
132
140
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -170,18 +178,29 @@ export default function (pi: ExtensionAPI) {
170
178
  // until terminal — but surface an ask_parent: the child is waiting on the
171
179
  // leader, so break out, reply via reply_subagent, then await again.
172
180
  let run = details.run;
173
- let intercom: ParkedMsg[] = [];
181
+ // Each park gets a FRESH msgs array — accumulate, or every wake but the
182
+ // last is lost (they were consumed by the park, never sent as followUp).
183
+ const intercom: ParkedMsg[] = [];
174
184
  while (!TERMINAL.includes(run.status)) {
175
185
  const awaited = await manager.awaitRun(details.run.id);
176
186
  if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
177
187
  if (awaited.run) run = awaited.run;
178
- intercom = awaited.intercom;
179
- if (intercom.some((m) => m.kind === "ask")) break;
188
+ intercom.push(...awaited.intercom);
189
+ if (awaited.intercom.some((m) => m.kind === "ask")) break;
180
190
  }
181
191
  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);
192
+ const heard = intercom.filter((m) => m.kind !== "ask");
193
+ const text = [
194
+ makeSummary(run),
195
+ heard.length > 0
196
+ ? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
197
+ : "",
198
+ asked
199
+ ? `\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.`
200
+ : "",
201
+ ]
202
+ .filter(Boolean)
203
+ .join("\n");
185
204
  return { content: [{ type: "text", text }], details: { run } };
186
205
  }
187
206
  return {
package/src/manager.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { writeFile } from "node:fs/promises";
4
4
  import { join, relative } from "node:path";
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -48,10 +48,8 @@ import {
48
48
  cleanupMerged,
49
49
  commitWorktree,
50
50
  createWorktree,
51
- removeByBranch,
52
51
  removeWorktree,
53
52
  repoRoot,
54
- sweepStale,
55
53
  type Worktree,
56
54
  } from "./worktree.ts";
57
55
 
@@ -62,6 +60,8 @@ const DEFAULT_RUNTIME_MS = 0;
62
60
  const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
63
61
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
64
62
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
63
+ /** Tools that can mutate the tree — their presence is what earns a worktree. */
64
+ const WRITE_CAPABLE = ["bash", "edit", "write"];
65
65
  /** Task ids become git refs + filesystem paths. */
66
66
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
67
67
  const WIDGET_THROTTLE_MS = 150;
@@ -86,6 +86,14 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
86
86
  }
87
87
  return total;
88
88
  }
89
+ /** realpath when possible; the raw path otherwise (cwd may not exist yet). */
90
+ function safeRealPath(path: string): string {
91
+ try {
92
+ return realpathSync(path);
93
+ } catch {
94
+ return path;
95
+ }
96
+ }
89
97
  function getParentSessionFile(ctx: ExtensionContext): string | undefined {
90
98
  try {
91
99
  return ctx.sessionManager.getSessionFile?.();
@@ -220,6 +228,9 @@ export class SubagentManager {
220
228
  { abort: () => void; dispose: () => void; touchWatchdog: () => void; steer: (message: string) => void }
221
229
  >();
222
230
  private mailboxes: Mailbox = createMailbox();
231
+ /** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
232
+ * cleanup from touching a branch that a running child owns. */
233
+ private liveWorktrees = new Map<string, Worktree>();
223
234
  private runControllers = new Map<string, AbortController>();
224
235
  private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
225
236
  private widgetRuns: RunSnapshot[] = [];
@@ -502,7 +513,18 @@ export class SubagentManager {
502
513
  // While the leader is parked in await_subagent, the question rides the wait
503
514
  // instead of the steering queue — no boundary needed, no starvation.
504
515
  if (this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
505
- return "Your question was delivered to the parent (they're waiting on this run). Keep working; the answer arrives via the pending reply.";
516
+ // The parked leader gets the question inside its await result and answers
517
+ // with reply_subagent — so the pending entry MUST exist here too, or the
518
+ // reply lands nowhere and the child waits on an answer that never comes.
519
+ const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
520
+ try {
521
+ const reply = await this.awaitParentReply(run.id, task.id);
522
+ this.updateTask(run, task, { status: "running" }, ctx);
523
+ this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
524
+ return reply;
525
+ } finally {
526
+ clearInterval(keepAlive);
527
+ }
506
528
  }
507
529
  this.notifyParent(run, "asked", { taskId: task.id, question });
508
530
  // M3: a waiting child is not stalled — keep the watchdog fed until the reply.
@@ -518,13 +540,13 @@ export class SubagentManager {
518
540
  },
519
541
  onNotifyParent: (_taskId, message, level) => {
520
542
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
543
+ // Parked leader gets it through the wait; otherwise queue it. `awaited` must
544
+ // NOT gate this — between two parks the leader is awaited but listening.
521
545
  if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
522
- if (!run.awaited) {
523
- try {
524
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
525
- } catch {
526
- /* parent mid-stream */
527
- }
546
+ try {
547
+ this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
548
+ } catch {
549
+ /* parent mid-stream */
528
550
  }
529
551
  },
530
552
  onSendMessage: (_taskId, to, text) => {
@@ -537,12 +559,10 @@ export class SubagentManager {
537
559
  message: text,
538
560
  });
539
561
  if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
540
- if (!run.awaited) {
541
- try {
542
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
543
- } catch {
544
- /* parent mid-stream */
545
- }
562
+ try {
563
+ this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
564
+ } catch {
565
+ /* parent mid-stream */
546
566
  }
547
567
  return true;
548
568
  }
@@ -657,9 +677,10 @@ export class SubagentManager {
657
677
  const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
658
678
  const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
659
679
  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);
680
+ // Isolation follows the DELIVERED toolset, never the raw request: explicit
681
+ // tools: [bash] without write:true still gets a worktree, and a file that
682
+ // narrowed the child to read-only never gets the commit/merge ceremony.
683
+ const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
663
684
 
664
685
  // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
665
686
  // non-git repos fall back to in-place. The worktree is created BEFORE session
@@ -673,11 +694,19 @@ export class SubagentManager {
673
694
  }
674
695
  }
675
696
  // Map a per-task cwd subpath into the worktree so relative paths stay correct.
697
+ // If the mapping can't be trusted (cwd outside the repo via symlink), drop the
698
+ // worktree rather than run in the main tree while reporting a branch.
676
699
  let childCwd = wt?.path ?? task.cwd;
677
700
  if (wt) {
678
- const rel = relative(wt.root, task.cwd);
679
- if (rel && !rel.startsWith("..") && rel !== ".") childCwd = join(wt.path, rel);
701
+ const rel = relative(wt.root, safeRealPath(task.cwd));
702
+ if (rel.startsWith("..")) {
703
+ removeWorktree(wt);
704
+ wt = undefined;
705
+ } else if (rel && rel !== ".") {
706
+ childCwd = join(wt.path, rel);
707
+ }
680
708
  }
709
+ if (wt) this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
681
710
 
682
711
  // Model + thinking resolve against the pi model registry; a bad request
683
712
  // fails the TASK with a helpful message, not the whole run.
@@ -890,9 +919,12 @@ export class SubagentManager {
890
919
  watchdog.dispose();
891
920
  if (timeout) clearTimeout(timeout);
892
921
  child?.dispose();
893
- // Failed/aborted: commit whatever partial work exists FIRST (so the branch
894
- // really keeps it), then drop the checkout dir.
922
+ this.liveWorktrees.delete(key);
923
+ // Failed/aborted: let the aborted child's last writes land (its tools may
924
+ // still be unwinding), commit whatever partial work exists so the branch
925
+ // really keeps it, then drop the checkout dir.
895
926
  if (wt && task.status !== "completed") {
927
+ await new Promise((r) => setTimeout(r, 250));
896
928
  try {
897
929
  commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
898
930
  } catch {
@@ -1026,7 +1058,18 @@ export class SubagentManager {
1026
1058
  // The scheduler passes the index into the FILTERED list — never use it
1027
1059
  // against the unfiltered inputs. Look the input up by task id instead.
1028
1060
  const input = inputById.get(task.id);
1029
- if (!input) return;
1061
+ if (!input) {
1062
+ // Impossible unless ids drift from inputs — fail loudly instead of
1063
+ // leaving the task queued forever (hasActiveRun would never clear).
1064
+ this.updateTask(
1065
+ run,
1066
+ task,
1067
+ { status: "failed", error: `No input for task ${task.id}`, endedAt: Date.now() },
1068
+ ctx,
1069
+ onUpdate,
1070
+ );
1071
+ return;
1072
+ }
1030
1073
  await this.runChild(
1031
1074
  run,
1032
1075
  task,
@@ -1082,14 +1125,26 @@ export class SubagentManager {
1082
1125
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1083
1126
  this.persist(ctx);
1084
1127
  // Branches merged by the leader since the run ended: drop worktree dir + branch.
1085
- for (const task of run.tasks) {
1086
- if (task.branch) {
1128
+ // Once per repo, never for a branch another live run owns, never fatal — a
1129
+ // throw here would re-settle an already-finished run as failed.
1130
+ try {
1131
+ const roots = new Set<string>();
1132
+ for (const task of run.tasks) {
1133
+ if (!task.branch) continue;
1087
1134
  const root = repoRoot(task.cwd);
1088
- if (root) cleanupMerged(root);
1135
+ if (root) roots.add(root);
1089
1136
  }
1137
+ for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
1138
+ } catch {
1139
+ /* cleanup is best-effort; the run outcome must stand */
1090
1140
  }
1091
1141
  }
1092
1142
 
1143
+ /** Branches owned by worktrees of still-running children. */
1144
+ liveBranches(): Set<string> {
1145
+ return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
1146
+ }
1147
+
1093
1148
  /** Spawn a run that keeps executing after this call returns. Every run is background. */
1094
1149
  startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
1095
1150
  const { run, inputs } = this.createRun(params, ctx);
@@ -1164,7 +1219,10 @@ export class SubagentManager {
1164
1219
  task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
1165
1220
  task.endedAt = Date.now();
1166
1221
  aborted += 1;
1167
- if (task.branch) removeByBranch(task.cwd, task.branch); // dir only; branch keeps partial work
1222
+ // The branch is recorded here so the leader can still merge partial work;
1223
+ // runChild's finally commits + drops the dir (it owns the live worktree).
1224
+ const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
1225
+ if (wt) task.branch = wt.branch;
1168
1226
  }
1169
1227
  run.status = "aborted";
1170
1228
  run.endedAt = Date.now();
@@ -1219,7 +1277,7 @@ export class SubagentManager {
1219
1277
  // IN the await result, no steering queue, no turn boundary needed.
1220
1278
  this.parked.set(runId, { msgs, wake: () => resolve(cloneRun(run)) });
1221
1279
  });
1222
- if (timeoutMs) {
1280
+ if (timeoutMs !== undefined && timeoutMs > 0) {
1223
1281
  return Promise.race([
1224
1282
  settled.then((r) => {
1225
1283
  finish();
package/src/worktree.ts CHANGED
@@ -2,9 +2,12 @@
2
2
  * Worktrees live inside `<repo>/.git/subagents/<runId>/<taskId>` so the child's
3
3
  * ancestor walk still finds the project AGENTS.md chain. node_modules is
4
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). */
5
+ * completion; the leader reviews and merges the branch manually.
6
+ *
7
+ * Cleanup, in order of trust: `reapDeadWorktrees` (session start nothing can
8
+ * be live yet, so every registered worktree is a crash leftover: commit its
9
+ * work, drop the dir, keep the branch), `cleanupMerged` (merged branches, never
10
+ * touching a checked-out one), `sweepStale` (dirs git no longer knows about). */
8
11
 
9
12
  import { execFileSync } from "node:child_process";
10
13
  import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
@@ -17,6 +20,8 @@ export interface Worktree {
17
20
  base: string; // SHA the branch was created from
18
21
  }
19
22
 
23
+ const BRANCH_PREFIX = "subagents/";
24
+
20
25
  function git(root: string, args: string[]): string {
21
26
  return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
22
27
  }
@@ -35,6 +40,25 @@ function gitOk(root: string, args: string[]): boolean {
35
40
  }
36
41
  }
37
42
 
43
+ /** git C-quotes porcelain paths containing spaces/specials — undo that. */
44
+ function unquote(path: string): string {
45
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
46
+ return path
47
+ .slice(1, -1)
48
+ .replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(Number.parseInt(o, 8)))
49
+ .replace(/\\(.)/g, (_, c) => ({ n: "\n", t: "\t", r: "\r" })[c as string] ?? c);
50
+ }
51
+
52
+ /** Compare paths through realpath so /var vs /private/var can't diverge. */
53
+ function samePath(a: string, b: string): boolean {
54
+ if (a === b) return true;
55
+ try {
56
+ return realpathSync(a) === realpathSync(b);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
38
62
  /** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
39
63
  export function repoRoot(cwd: string): string | undefined {
40
64
  if (!existsSync(cwd)) return undefined;
@@ -50,7 +74,7 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
50
74
  const root = repoRoot(cwd);
51
75
  if (!root) return undefined;
52
76
  const path = join(root, ".git", "subagents", runId, taskId);
53
- const branch = `subagents/${runId}/${taskId}`;
77
+ const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
54
78
  let base: string;
55
79
  try {
56
80
  base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
@@ -71,11 +95,19 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
71
95
  return { root, path, branch, base };
72
96
  }
73
97
 
74
- /** Commit all child changes. No-op when the worktree is already clean. */
98
+ /**
99
+ * Commit the child's changes. Stages first, then commits only when something is
100
+ * actually staged — an untracked node_modules symlink must not fake "dirty" and
101
+ * turn into a failed empty commit.
102
+ */
75
103
  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"]);
104
+ commitIn(wt.path, message);
105
+ }
106
+
107
+ function commitIn(dir: string, message: string): void {
108
+ gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules"]); // never stage the dep symlink
109
+ if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return; // nothing to commit
110
+ gitIn(dir, ["commit", "-m", message, "--no-verify"]);
79
111
  }
80
112
 
81
113
  /** Diffstat + changed files of the branch vs its base SHA. */
@@ -89,23 +121,22 @@ export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
89
121
 
90
122
  /** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
91
123
  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);
124
+ dropDir(wt.root, wt.path);
98
125
  }
99
126
 
100
127
  /** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
101
128
  export function removeByBranch(cwd: string, branch: string): void {
129
+ if (!branch.startsWith(BRANCH_PREFIX)) return;
102
130
  const root = repoRoot(cwd);
103
131
  if (!root) return;
104
- const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
132
+ dropDir(root, join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length)));
133
+ }
134
+
135
+ function dropDir(root: string, path: string): void {
105
136
  try {
106
137
  git(root, ["worktree", "remove", "--force", path]);
107
138
  } catch {
108
- /* already gone */
139
+ if (existsSync(path)) rmSync(path, { recursive: true, force: true });
109
140
  }
110
141
  prune(root);
111
142
  }
@@ -119,21 +150,30 @@ function prune(root: string): void {
119
150
  }
120
151
  }
121
152
 
153
+ /** Is this branch checked out in some worktree right now? Checked fresh, per call. */
154
+ function isCheckedOut(root: string, branch: string): boolean {
155
+ return worktreeBranches(root).includes(branch);
156
+ }
157
+
122
158
  /**
123
159
  * 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".
160
+ * SAFETY: a branch checked out in a LIVE worktree is skipped a fresh branch's
161
+ * tip equals its base until the child commits, so it looks "merged". The
162
+ * registration is re-checked immediately before each removal (a concurrent run
163
+ * may have created its worktree after the first listing).
126
164
  */
127
- export function cleanupMerged(root: string, target = "HEAD"): number {
165
+ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>; target?: string } = {}): number {
128
166
  root = realpathSync(root);
167
+ const target = opts.target ?? "HEAD";
129
168
  const merged = git(root, ["branch", "--merged", target])
130
169
  .split("\n")
131
170
  .map((b) => b.trim().replace(/^[+*]\s*/, ""));
132
- const live = new Set(worktreeBranches(root));
133
171
  let cleaned = 0;
134
172
  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));
173
+ if (!branch.startsWith(BRANCH_PREFIX)) continue;
174
+ if (opts.skipBranches?.has(branch)) continue; // owned by a live run
175
+ if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
176
+ const path = join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length));
137
177
  if (existsSync(path)) {
138
178
  try {
139
179
  git(root, ["worktree", "remove", "--force", path]);
@@ -160,25 +200,43 @@ function worktreeBranches(root: string): string[] {
160
200
  }
161
201
 
162
202
  /**
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.
203
+ * Session-start recovery: every registered subagent worktree is a crash leftover
204
+ * (nothing of ours can be live yet). Commit whatever the dead child left so the
205
+ * branch keeps it, then drop the dir. Branches always survive.
206
+ */
207
+ export function reapDeadWorktrees(root: string): number {
208
+ root = realpathSync(root);
209
+ const sub = join(root, ".git", "subagents");
210
+ if (!existsSync(sub)) return 0;
211
+ let reaped = 0;
212
+ for (const path of worktreePaths(root)) {
213
+ if (!isInside(path, sub)) continue; // not ours
214
+ try {
215
+ commitIn(path, "subagent (recovered after interrupted session)");
216
+ } catch {
217
+ /* nothing committable */
218
+ }
219
+ dropDir(root, path);
220
+ reaped += 1;
221
+ }
222
+ return reaped;
223
+ }
224
+
225
+ /**
226
+ * Remove worktree dirs that git no longer knows about (partial-crash leftovers).
227
+ * Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
228
+ * and a live child's checkout must survive.
166
229
  */
167
230
  export function sweepStale(root: string): void {
168
231
  root = realpathSync(root);
169
232
  const sub = join(root, ".git", "subagents");
170
233
  if (!existsSync(sub)) return;
171
- const registered = new Set(worktreePaths(root));
234
+ const registered = worktreePaths(root);
172
235
  for (const runDir of readDirs(sub)) {
173
236
  for (const taskDir of readDirs(join(sub, runDir))) {
174
237
  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
- }
238
+ if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
239
+ rmSync(dir, { recursive: true, force: true });
182
240
  }
183
241
  }
184
242
  prune(root);
@@ -189,12 +247,26 @@ function worktreePaths(root: string): string[] {
189
247
  return git(root, ["worktree", "list", "--porcelain"])
190
248
  .split("\n")
191
249
  .filter((l) => l.startsWith("worktree "))
192
- .map((l) => l.slice("worktree ".length).trim());
250
+ .map((l) => unquote(l.slice("worktree ".length).trim()));
193
251
  } catch {
194
252
  return [];
195
253
  }
196
254
  }
197
255
 
256
+ function isInside(path: string, dir: string): boolean {
257
+ const p = safeReal(path);
258
+ const d = safeReal(dir);
259
+ return p === d || p.startsWith(`${d}/`);
260
+ }
261
+
262
+ function safeReal(path: string): string {
263
+ try {
264
+ return realpathSync(path);
265
+ } catch {
266
+ return path;
267
+ }
268
+ }
269
+
198
270
  function readDirs(dir: string): string[] {
199
271
  try {
200
272
  return readdirSync(dir, { withFileTypes: true })