@arhen/pi-core-subagent 1.3.31 → 1.3.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.31",
3
+ "version": "1.3.32",
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, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
33
+ import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
34
34
 
35
35
  export default function (pi: ExtensionAPI) {
36
36
  const manager = new SubagentManager(pi);
@@ -126,11 +126,11 @@ export default function (pi: ExtensionAPI) {
126
126
  }
127
127
  for (const root of roots) {
128
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);
129
+ // A registered subagent worktree is a crash leftover UNLESS another pi
130
+ // session still owns it (pid marker) — commit its work, keep the branch,
131
+ // drop the dir. Then reap merged branches and dirs git no longer tracks.
132
+ reapDeadWorktrees(root, ownerAlive);
133
+ cleanupMerged(root, { skipBranches: manager.liveBranches() });
134
134
  sweepStale(root);
135
135
  } catch {
136
136
  /* recovery is best-effort — never block session start */
package/src/manager.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from "./types.ts";
46
46
  import {
47
47
  branchDiff,
48
+ claimWorktree,
48
49
  cleanupMerged,
49
50
  commitWorktree,
50
51
  createWorktree,
@@ -58,6 +59,8 @@ export const MAX_CONCURRENCY = 8;
58
59
  /** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
59
60
  const DEFAULT_RUNTIME_MS = 0;
60
61
  const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
62
+ /** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
63
+ const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
61
64
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
62
65
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
63
66
  /** Tools that can mutate the tree — their presence is what earns a worktree. */
@@ -221,7 +224,10 @@ export interface ParkedMsg {
221
224
 
222
225
  export class SubagentManager {
223
226
  private runs = new Map<string, RunSnapshot>();
224
- private settlers = new Map<string, (run: RunSnapshot) => void>();
227
+ /** Runs that are still settleable (presence = not yet settled). */
228
+ private settlers = new Map<string, true>();
229
+ /** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
230
+ private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
225
231
  private pendingReplies = new Map<string, PendingReply>();
226
232
  private liveChildren = new Map<
227
233
  string,
@@ -296,8 +302,12 @@ export class SubagentManager {
296
302
  child.dispose();
297
303
  }
298
304
  this.liveChildren.clear();
305
+ // Ownership markers stay on disk; the next session reaps those dirs (commit,
306
+ // keep branch, drop dir) once this pid is gone.
307
+ this.liveWorktrees.clear();
299
308
  this.runs.clear();
300
309
  this.settlers.clear();
310
+ this.settleWaiters.clear();
301
311
  this.pendingReplies.clear();
302
312
  this.runControllers.clear();
303
313
  this.mailboxes = createMailbox();
@@ -508,31 +518,24 @@ export class SubagentManager {
508
518
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
509
519
  return {
510
520
  onAskParent: async (_taskId, question) => {
521
+ const key = `${run.id}:${task.id}`;
511
522
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
512
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
513
- // While the leader is parked in await_subagent, the question rides the wait
514
- // instead of the steering queue no boundary needed, no starvation.
515
- if (this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
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
- }
523
+ this.liveChildren.get(key)?.touchWatchdog();
524
+ // While the leader is parked in await_subagent the question rides the wait
525
+ // (no steering queue, no turn boundary); otherwise it goes out as a notice.
526
+ // Either way the pending reply entry must exist, or reply_subagent has
527
+ // nowhere to land and the child waits on an answer that never comes.
528
+ if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
529
+ this.notifyParent(run, "asked", { taskId: task.id, question });
528
530
  }
529
- this.notifyParent(run, "asked", { taskId: task.id, question });
530
- // M3: a waiting child is not stalled keep the watchdog fed until the reply.
531
- const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
531
+ // A waiting child is not stalled — keep the watchdog fed until the reply.
532
+ // But the wait is BOUNDED: an unanswered question would otherwise keep the
533
+ // run non-terminal forever (widget never clears, run never settles).
534
+ const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
532
535
  try {
533
- const reply = await this.awaitParentReply(run.id, task.id);
536
+ const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
534
537
  this.updateTask(run, task, { status: "running" }, ctx);
535
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
538
+ this.liveChildren.get(key)?.touchWatchdog();
536
539
  return reply;
537
540
  } finally {
538
541
  clearInterval(keepAlive);
@@ -572,9 +575,24 @@ export class SubagentManager {
572
575
  onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
573
576
  };
574
577
  }
575
- private awaitParentReply(runId: string, taskId: string): Promise<string> {
578
+ private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
579
+ const key = `${runId}:${taskId}`;
576
580
  return new Promise<string>((resolve) => {
577
- this.pendingReplies.set(`${runId}:${taskId}`, { resolve });
581
+ const timer =
582
+ timeoutMs > 0
583
+ ? setTimeout(() => {
584
+ this.pendingReplies.delete(key);
585
+ resolve(
586
+ "The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
587
+ );
588
+ }, timeoutMs)
589
+ : undefined;
590
+ this.pendingReplies.set(key, {
591
+ resolve: (message) => {
592
+ if (timer) clearTimeout(timer);
593
+ resolve(message);
594
+ },
595
+ });
578
596
  });
579
597
  }
580
598
  deliverReply(runId: string, taskId: string, message: string): boolean {
@@ -683,8 +701,31 @@ export class SubagentManager {
683
701
  const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
684
702
 
685
703
  // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
686
- // non-git repos fall back to in-place. The worktree is created BEFORE session
687
- // start so the child's cwd + AGENTS.md context chain are the worktree's.
704
+ // Model + thinking resolve against the pi model registry BEFORE any worktree
705
+ // exists a bad request fails the TASK with a helpful message and can't leak a
706
+ // checkout past this early return.
707
+ let model: Model<Api> | undefined;
708
+ try {
709
+ model = resolveChildModel(ctx, file?.model ?? input.model);
710
+ validateThinking(model, thinking);
711
+ } catch (err) {
712
+ this.updateTask(
713
+ run,
714
+ task,
715
+ {
716
+ status: "failed",
717
+ error: err instanceof Error ? err.message : String(err),
718
+ endedAt: Date.now(),
719
+ },
720
+ ctx,
721
+ onUpdate,
722
+ );
723
+ return;
724
+ }
725
+
726
+ // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
727
+ // non-git repos fall back to in-place. Created BEFORE session start so the
728
+ // child's cwd + AGENTS.md context chain are the worktree's.
688
729
  let wt: Worktree | undefined;
689
730
  if (canWrite) {
690
731
  try {
@@ -694,39 +735,23 @@ export class SubagentManager {
694
735
  }
695
736
  }
696
737
  // 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.
738
+ // Both sides go through realpath a symlinked root would otherwise look
739
+ // "outside" the repo. If the mapping can't be trusted, drop the worktree AND
740
+ // reset the cwd (never point the child at a dir that was just removed).
699
741
  let childCwd = wt?.path ?? task.cwd;
700
742
  if (wt) {
701
- const rel = relative(wt.root, safeRealPath(task.cwd));
743
+ const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
702
744
  if (rel.startsWith("..")) {
703
745
  removeWorktree(wt);
704
746
  wt = undefined;
747
+ childCwd = task.cwd;
705
748
  } else if (rel && rel !== ".") {
706
749
  childCwd = join(wt.path, rel);
707
750
  }
708
751
  }
709
- if (wt) this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
710
-
711
- // Model + thinking resolve against the pi model registry; a bad request
712
- // fails the TASK with a helpful message, not the whole run.
713
- let model: Model<Api> | undefined;
714
- try {
715
- model = resolveChildModel(ctx, file?.model ?? input.model);
716
- validateThinking(model, thinking);
717
- } catch (err) {
718
- this.updateTask(
719
- run,
720
- task,
721
- {
722
- status: "failed",
723
- error: err instanceof Error ? err.message : String(err),
724
- endedAt: Date.now(),
725
- },
726
- ctx,
727
- onUpdate,
728
- );
729
- return;
752
+ if (wt) {
753
+ claimWorktree(wt); // pid marker: another pi session must not reap this
754
+ this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
730
755
  }
731
756
 
732
757
  this.updateTask(
@@ -746,6 +771,9 @@ export class SubagentManager {
746
771
  onUpdate,
747
772
  );
748
773
 
774
+ // Set once the dir must outlive this call: committed work awaiting the
775
+ // leader's merge, or a commit failure whose work exists ONLY in the dir.
776
+ let keepWorktreeDir = false;
749
777
  let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
750
778
  let unsubscribe: (() => void) | undefined;
751
779
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -865,6 +893,7 @@ export class SubagentManager {
865
893
  // its work: the error is reported, the status stays completed.
866
894
  try {
867
895
  commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
896
+ keepWorktreeDir = true; // committed — dir stays until the leader merges
868
897
  const { stat, files } = branchDiff(wt);
869
898
  this.updateTask(
870
899
  run,
@@ -874,12 +903,15 @@ export class SubagentManager {
874
903
  onUpdate,
875
904
  );
876
905
  } catch (commitErr) {
906
+ // Never drop a checkout whose work isn't on the branch — it would be
907
+ // unreachable once the base-tip branch is reaped as "merged".
908
+ keepWorktreeDir = true;
877
909
  this.updateTask(
878
910
  run,
879
911
  task,
880
912
  {
881
913
  branch: wt.branch,
882
- error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
914
+ error: `Worktree commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
883
915
  },
884
916
  ctx,
885
917
  onUpdate,
@@ -919,20 +951,23 @@ export class SubagentManager {
919
951
  watchdog.dispose();
920
952
  if (timeout) clearTimeout(timeout);
921
953
  child?.dispose();
922
- this.liveWorktrees.delete(key);
923
954
  // Failed/aborted: let the aborted child's last writes land (its tools may
924
955
  // still be unwinding), commit whatever partial work exists so the branch
925
- // really keeps it, then drop the checkout dir.
956
+ // really keeps it, then drop the checkout dir. A commit FAILURE keeps the
957
+ // dir — dropping it would make the work unreachable.
926
958
  if (wt && task.status !== "completed") {
927
959
  await new Promise((r) => setTimeout(r, 250));
928
960
  try {
929
961
  commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
930
962
  } catch {
931
- /* nothing to commit */
963
+ keepWorktreeDir = true;
932
964
  }
933
965
  this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
934
- removeWorktree(wt);
935
966
  }
967
+ if (wt && !keepWorktreeDir) removeWorktree(wt);
968
+ // Released only after the dir is gone: while it exists, the branch must stay
969
+ // in liveBranches() so cleanup can't reap it.
970
+ this.liveWorktrees.delete(key);
936
971
  }
937
972
  }
938
973
 
@@ -1020,7 +1055,7 @@ export class SubagentManager {
1020
1055
  });
1021
1056
  this.turnActivity = true;
1022
1057
  this.runs.set(run.id, run);
1023
- this.settlers.set(run.id, () => {});
1058
+ this.settlers.set(run.id, true);
1024
1059
  this.runControllers.set(run.id, new AbortController());
1025
1060
  for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
1026
1061
  this.emit("subagent:run-created", { run: cloneRun(run) });
@@ -1195,6 +1230,8 @@ export class SubagentManager {
1195
1230
  task.status = "aborted";
1196
1231
  task.error = task.error || "Canceled from peek";
1197
1232
  task.endedAt = Date.now();
1233
+ this.pendingReplies.get(`${runId}:${taskId}`)?.resolve("(task canceled by the parent — stop work now)");
1234
+ this.pendingReplies.delete(`${runId}:${taskId}`);
1198
1235
  this.liveChildren.get(`${runId}:${taskId}`)?.abort();
1199
1236
  this.mailboxes.close(`${runId}:${taskId}`);
1200
1237
  if (ctx) this.flushWidget(run, ctx);
@@ -1208,6 +1245,14 @@ export class SubagentManager {
1208
1245
  if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
1209
1246
  let aborted = 0;
1210
1247
  this.runControllers.get(runId)?.abort();
1248
+ // Release children parked in ask_parent first — an unresolved wait would keep
1249
+ // the child alive past the abort.
1250
+ for (const [key, pending] of this.pendingReplies) {
1251
+ if (key.startsWith(`${runId}:`)) {
1252
+ this.pendingReplies.delete(key);
1253
+ pending.resolve("(run canceled by the parent — stop work and return what you have)");
1254
+ }
1255
+ }
1211
1256
  for (const [key, child] of this.liveChildren) {
1212
1257
  if (key.startsWith(`${runId}:`)) {
1213
1258
  child.abort();
@@ -1233,12 +1278,15 @@ export class SubagentManager {
1233
1278
  return { aborted };
1234
1279
  }
1235
1280
 
1236
- /** Settle-and-delete: awaiters resolve once; no leak, no closure chain. */
1281
+ /** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
1237
1282
  private settleRun(runId: string, run: RunSnapshot): void {
1238
- const s = this.settlers.get(runId);
1239
- if (!s) return;
1283
+ if (!this.settlers.has(runId)) return;
1240
1284
  this.settlers.delete(runId);
1241
- s(cloneRun(run));
1285
+ const waiters = this.settleWaiters.get(runId);
1286
+ this.settleWaiters.delete(runId);
1287
+ if (!waiters) return;
1288
+ const snapshot = cloneRun(run);
1289
+ for (const waiter of waiters) waiter(snapshot);
1242
1290
  }
1243
1291
 
1244
1292
  /** Child→leader messages collected while the parent is parked in await_subagent. */
@@ -1268,14 +1316,22 @@ export class SubagentManager {
1268
1316
  }
1269
1317
  const msgs: ParkedMsg[] = [];
1270
1318
  const settled = new Promise<RunSnapshot | undefined>((resolve) => {
1271
- const prev = this.settlers.get(runId);
1272
- this.settlers.set(runId, (r) => {
1273
- prev?.(r);
1319
+ // Waiters are a SET, not a chain: the autoAwait loop re-parks on every
1320
+ // child message, and wrapping the previous settler each time grew an
1321
+ // unbounded closure chain (each holding a snapshot clone).
1322
+ const waiter = (r: RunSnapshot) => {
1323
+ this.settleWaiters.get(runId)?.delete(waiter);
1274
1324
  resolve(r);
1275
- });
1325
+ };
1326
+ let waiters = this.settleWaiters.get(runId);
1327
+ if (!waiters) {
1328
+ waiters = new Set();
1329
+ this.settleWaiters.set(runId, waiters);
1330
+ }
1331
+ waiters.add(waiter);
1276
1332
  // A child→leader message while parked wakes the wait: the leader gets it
1277
1333
  // IN the await result, no steering queue, no turn boundary needed.
1278
- this.parked.set(runId, { msgs, wake: () => resolve(cloneRun(run)) });
1334
+ this.parked.set(runId, { msgs, wake: () => waiter(cloneRun(run)) });
1279
1335
  });
1280
1336
  if (timeoutMs !== undefined && timeoutMs > 0) {
1281
1337
  return Promise.race([
package/src/worktree.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * touching a checked-out one), `sweepStale` (dirs git no longer knows about). */
11
11
 
12
12
  import { execFileSync } from "node:child_process";
13
- import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
13
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
 
16
16
  export interface Worktree {
@@ -40,15 +40,6 @@ function gitOk(root: string, args: string[]): boolean {
40
40
  }
41
41
  }
42
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
43
  /** Compare paths through realpath so /var vs /private/var can't diverge. */
53
44
  function samePath(a: string, b: string): boolean {
54
45
  if (a === b) return true;
@@ -73,14 +64,20 @@ export function repoRoot(cwd: string): string | undefined {
73
64
  export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
74
65
  const root = repoRoot(cwd);
75
66
  if (!root) return undefined;
76
- const path = join(root, ".git", "subagents", runId, taskId);
77
- const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
67
+ // --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
68
+ // `.git` is a FILE, and joining it would make `worktree add` fail (silently
69
+ // dropping isolation).
70
+ let container: string;
78
71
  let base: string;
79
72
  try {
73
+ const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
74
+ container = join(common, "subagents");
80
75
  base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
81
76
  } catch {
82
77
  return undefined; // broken repo — fall back to in-place
83
78
  }
79
+ const path = join(container, runId, taskId);
80
+ const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
84
81
  git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
85
82
  // Deps follow the child into the worktree; anything else the task needs is
86
83
  // project content already checked out there.
@@ -98,16 +95,20 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
98
95
  /**
99
96
  * Commit the child's changes. Stages first, then commits only when something is
100
97
  * actually staged — an untracked node_modules symlink must not fake "dirty" and
101
- * turn into a failed empty commit.
98
+ * turn into a failed empty commit. Returns "committed" | "empty"; a real git
99
+ * failure THROWS, and callers must not delete the checkout in that case (the
100
+ * work would become unreachable once the base-tip branch is reaped as merged).
102
101
  */
103
- export function commitWorktree(wt: Worktree, message: string): void {
104
- commitIn(wt.path, message);
102
+ export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
103
+ return commitIn(wt.path, message);
105
104
  }
106
105
 
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
106
+ function commitIn(dir: string, message: string): "committed" | "empty" {
107
+ // Exclude the root dep symlink and any nested node_modules the child created.
108
+ gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
109
+ if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
110
110
  gitIn(dir, ["commit", "-m", message, "--no-verify"]);
111
+ return "committed";
111
112
  }
112
113
 
113
114
  /** Diffstat + changed files of the branch vs its base SHA. */
@@ -129,7 +130,17 @@ export function removeByBranch(cwd: string, branch: string): void {
129
130
  if (!branch.startsWith(BRANCH_PREFIX)) return;
130
131
  const root = repoRoot(cwd);
131
132
  if (!root) return;
132
- dropDir(root, join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length)));
133
+ const container = subagentsDir(root);
134
+ if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
135
+ }
136
+
137
+ /** `<git-common-dir>/subagents` — where our worktrees live for this repo. */
138
+ function subagentsDir(root: string): string | undefined {
139
+ try {
140
+ return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
141
+ } catch {
142
+ return undefined;
143
+ }
133
144
  }
134
145
 
135
146
  function dropDir(root: string, path: string): void {
@@ -169,11 +180,13 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
169
180
  .split("\n")
170
181
  .map((b) => b.trim().replace(/^[+*]\s*/, ""));
171
182
  let cleaned = 0;
183
+ const container = subagentsDir(root);
172
184
  for (const branch of merged) {
173
- if (!branch.startsWith(BRANCH_PREFIX)) continue;
185
+ if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
174
186
  if (opts.skipBranches?.has(branch)) continue; // owned by a live run
175
187
  if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
176
- const path = join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length));
188
+ const path = join(container, branch.slice(BRANCH_PREFIX.length));
189
+ if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
177
190
  if (existsSync(path)) {
178
191
  try {
179
192
  git(root, ["worktree", "remove", "--force", path]);
@@ -190,10 +203,10 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
190
203
  /** Branch names currently checked out in any worktree (incl. the main one). */
191
204
  function worktreeBranches(root: string): string[] {
192
205
  try {
193
- return git(root, ["worktree", "list", "--porcelain"])
194
- .split("\n")
206
+ return git(root, ["worktree", "list", "--porcelain", "-z"])
207
+ .split("\0")
195
208
  .filter((l) => l.startsWith("branch "))
196
- .map((l) => l.slice("branch refs/heads/".length).trim());
209
+ .map((l) => l.slice("branch refs/heads/".length));
197
210
  } catch {
198
211
  return [];
199
212
  }
@@ -204,17 +217,18 @@ function worktreeBranches(root: string): string[] {
204
217
  * (nothing of ours can be live yet). Commit whatever the dead child left so the
205
218
  * branch keeps it, then drop the dir. Branches always survive.
206
219
  */
207
- export function reapDeadWorktrees(root: string): number {
220
+ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
208
221
  root = realpathSync(root);
209
- const sub = join(root, ".git", "subagents");
210
- if (!existsSync(sub)) return 0;
222
+ const sub = subagentsDir(root);
223
+ if (!sub || !existsSync(sub)) return 0;
211
224
  let reaped = 0;
212
225
  for (const path of worktreePaths(root)) {
213
226
  if (!isInside(path, sub)) continue; // not ours
227
+ if (isLive(path)) continue; // another pi session owns it
214
228
  try {
215
229
  commitIn(path, "subagent (recovered after interrupted session)");
216
230
  } catch {
217
- /* nothing committable */
231
+ continue; // git failed — never drop a dir whose work isn't on the branch
218
232
  }
219
233
  dropDir(root, path);
220
234
  reaped += 1;
@@ -222,6 +236,31 @@ export function reapDeadWorktrees(root: string): number {
222
236
  return reaped;
223
237
  }
224
238
 
239
+ /**
240
+ * Ownership marker: a live worktree gets `<dir>/.subagent-owner` holding the
241
+ * owning pid. Another pi session must not reap a checkout whose owner is alive.
242
+ */
243
+ export function claimWorktree(wt: Worktree): void {
244
+ try {
245
+ writeFileSync(join(wt.path, ".subagent-owner"), String(process.pid));
246
+ } catch {
247
+ /* best-effort: worst case another session reaps it after a crash */
248
+ }
249
+ }
250
+
251
+ /** True when the worktree dir is claimed by a process that still exists. */
252
+ export function ownerAlive(path: string): boolean {
253
+ try {
254
+ const pid = Number.parseInt(readFileSync(join(path, ".subagent-owner"), "utf8").trim(), 10);
255
+ if (!Number.isFinite(pid) || pid <= 0) return false;
256
+ if (pid === process.pid) return true;
257
+ process.kill(pid, 0); // throws ESRCH when the owner is gone
258
+ return true;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
225
264
  /**
226
265
  * Remove worktree dirs that git no longer knows about (partial-crash leftovers).
227
266
  * Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
@@ -229,25 +268,27 @@ export function reapDeadWorktrees(root: string): number {
229
268
  */
230
269
  export function sweepStale(root: string): void {
231
270
  root = realpathSync(root);
232
- const sub = join(root, ".git", "subagents");
233
- if (!existsSync(sub)) return;
271
+ const sub = subagentsDir(root);
272
+ if (!sub || !existsSync(sub)) return;
234
273
  const registered = worktreePaths(root);
235
274
  for (const runDir of readDirs(sub)) {
236
275
  for (const taskDir of readDirs(join(sub, runDir))) {
237
276
  const dir = join(sub, runDir, taskDir);
238
277
  if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
278
+ if (ownerAlive(dir)) continue; // claimed by a running session
239
279
  rmSync(dir, { recursive: true, force: true });
240
280
  }
241
281
  }
242
282
  prune(root);
243
283
  }
244
284
 
285
+ /** Registered worktree paths. `-z` keeps paths verbatim (no C-quoting to undo). */
245
286
  function worktreePaths(root: string): string[] {
246
287
  try {
247
- return git(root, ["worktree", "list", "--porcelain"])
248
- .split("\n")
288
+ return git(root, ["worktree", "list", "--porcelain", "-z"])
289
+ .split("\0")
249
290
  .filter((l) => l.startsWith("worktree "))
250
- .map((l) => unquote(l.slice("worktree ".length).trim()));
291
+ .map((l) => l.slice("worktree ".length));
251
292
  } catch {
252
293
  return [];
253
294
  }