@arhen/pi-core-subagent 1.3.31 → 1.3.33

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.33",
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
@@ -196,6 +196,23 @@ export class SubagentsWidget implements Component {
196
196
  }
197
197
  }
198
198
  /** Blocking-call summary: full text, because the model asked for it. */
199
+ /** Where a write child's edits went: a branch to merge, or straight into the tree. */
200
+ function worktreeLine(task: TaskSnapshot): string {
201
+ const parts: string[] = [];
202
+ if (task.branch) {
203
+ const files = task.changedFiles?.length
204
+ ? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
205
+ : "";
206
+ parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
207
+ } else if (task.isolation === "in-place") {
208
+ parts.push(
209
+ `Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
210
+ );
211
+ }
212
+ if (task.worktreeError) parts.push(`Worktree: ${task.worktreeError}`);
213
+ return parts.length ? `\n${parts.join("\n")}` : "";
214
+ }
215
+
199
216
  export function makeSummary(run: RunSnapshot): string {
200
217
  const succeeded = run.tasks.filter((t) => t.status === "completed").length;
201
218
  const failed = run.tasks.filter((t) => t.status === "failed").length;
@@ -210,7 +227,7 @@ export function makeSummary(run: RunSnapshot): string {
210
227
  // Edges are named so the leader can compare what it delegated against what came back.
211
228
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
212
229
  lines.push(
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.` : ""}`,
230
+ `\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
214
231
  );
215
232
  }
216
233
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
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 */
@@ -188,15 +188,23 @@ export default function (pi: ExtensionAPI) {
188
188
  intercom.push(...awaited.intercom);
189
189
  if (awaited.intercom.some((m) => m.kind === "ask")) break;
190
190
  }
191
- const asked = intercom.find((m) => m.kind === "ask");
191
+ // EVERY ask must surface: siblings that asked in the same wake got no
192
+ // followUp notice (the park swallowed it), so showing only the first
193
+ // leaves the rest blocked until their 10-minute timeout.
194
+ const asks = intercom.filter((m) => m.kind === "ask");
192
195
  const heard = intercom.filter((m) => m.kind !== "ask");
193
196
  const text = [
194
197
  makeSummary(run),
195
198
  heard.length > 0
196
199
  ? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
197
200
  : "",
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.`
201
+ asks.length > 0
202
+ ? `\n${asks.length} child(ren) waiting for your answer:\n${asks
203
+ .map(
204
+ (a) =>
205
+ `- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
206
+ )
207
+ .join("\n")}\nAnswer each, then await_subagent again for the result.`
200
208
  : "",
201
209
  ]
202
210
  .filter(Boolean)
@@ -321,9 +329,12 @@ export default function (pi: ExtensionAPI) {
321
329
  `Run ${run.id} — ${run.status}`,
322
330
  ...tasks.map((t) => {
323
331
  const wt = t.branch
324
- ? `\nBranch: ${t.branch}\n${t.diffStat || "(no changes committed)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
325
- : "";
326
- 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)}`;
332
+ ? `\nBranch: ${t.branch}\n${t.diffStat || "(no diff available)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
333
+ : t.isolation === "in-place"
334
+ ? `\nApplied IN PLACE (no branch) ${t.isolationReason ?? "worktree unavailable"}. The changes are already in your working tree.`
335
+ : "";
336
+ const wtErr = t.worktreeError ? `\nWorktree: ${t.worktreeError}` : "";
337
+ return `\n## ${t.agent} ${statusIcon(t.status)}\nGoal: ${truncateText(t.task, 300)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}${wt}${wtErr}\n${formatUsage(t.usage)}`;
327
338
  }),
328
339
  ].join("\n");
329
340
  return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
package/src/manager.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { writeFile } from "node:fs/promises";
4
- import { join, relative } from "node:path";
4
+ import { join, relative, sep } 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 {
@@ -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,10 @@ 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
64
+ /** Intercom messages buffered per park before the followUp path takes over. */
65
+ const PARKED_MSG_CAP = 24;
61
66
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
62
67
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
63
68
  /** Tools that can mutate the tree — their presence is what earns a worktree. */
@@ -221,7 +226,10 @@ export interface ParkedMsg {
221
226
 
222
227
  export class SubagentManager {
223
228
  private runs = new Map<string, RunSnapshot>();
224
- private settlers = new Map<string, (run: RunSnapshot) => void>();
229
+ /** Runs that are still settleable (presence = not yet settled). */
230
+ private settlers = new Map<string, true>();
231
+ /** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
232
+ private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
225
233
  private pendingReplies = new Map<string, PendingReply>();
226
234
  private liveChildren = new Map<
227
235
  string,
@@ -296,8 +304,22 @@ export class SubagentManager {
296
304
  child.dispose();
297
305
  }
298
306
  this.liveChildren.clear();
307
+ // Release anyone parked on a run before the maps go — dropping waiters would
308
+ // leave their promises pending forever (autoAwait / await_subagent hang).
309
+ for (const [runId, waiters] of this.settleWaiters) {
310
+ const run = this.runs.get(runId);
311
+ for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
312
+ }
313
+ for (const pending of this.pendingReplies.values()) {
314
+ pending.resolve("(session ended — stop work immediately)");
315
+ }
316
+ this.parked.clear();
317
+ // Ownership markers stay on disk; the next session reaps those dirs (commit,
318
+ // keep branch, drop dir) once this pid is gone.
319
+ this.liveWorktrees.clear();
299
320
  this.runs.clear();
300
321
  this.settlers.clear();
322
+ this.settleWaiters.clear();
301
323
  this.pendingReplies.clear();
302
324
  this.runControllers.clear();
303
325
  this.mailboxes = createMailbox();
@@ -508,31 +530,36 @@ export class SubagentManager {
508
530
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
509
531
  return {
510
532
  onAskParent: async (_taskId, question) => {
533
+ const key = `${run.id}:${task.id}`;
534
+ // A tool call already in flight can reach here AFTER the task ended
535
+ // (abort/timeout/cancel). Reviving it would leave a "running" task in a
536
+ // finished run — hasActiveRun() then never clears.
537
+ if (TERMINAL.includes(task.status)) {
538
+ return "(your task has already ended — stop work and return immediately)";
539
+ }
511
540
  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
- }
541
+ this.liveChildren.get(key)?.touchWatchdog();
542
+ // While the leader is parked in await_subagent the question rides the wait
543
+ // (no steering queue, no turn boundary); otherwise it goes out as a notice.
544
+ // Either way the pending reply entry must exist, or reply_subagent has
545
+ // nowhere to land and the child waits on an answer that never comes.
546
+ if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
547
+ this.notifyParent(run, "asked", { taskId: task.id, question });
528
548
  }
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);
549
+ // A waiting child is not stalled — keep the watchdog fed until the reply.
550
+ // But the wait is BOUNDED: an unanswered question would otherwise keep the
551
+ // run non-terminal forever (widget never clears, run never settles).
552
+ const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
532
553
  try {
533
- const reply = await this.awaitParentReply(run.id, task.id);
554
+ const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
555
+ // Cancel wins over a reply that arrived in the same tick: never move a
556
+ // terminal task back to "running" (that would let a canceled task be
557
+ // reported as completed).
558
+ if (TERMINAL.includes(task.status)) {
559
+ return "(your task was canceled while you waited — stop work and return immediately)";
560
+ }
534
561
  this.updateTask(run, task, { status: "running" }, ctx);
535
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
562
+ this.liveChildren.get(key)?.touchWatchdog();
536
563
  return reply;
537
564
  } finally {
538
565
  clearInterval(keepAlive);
@@ -572,16 +599,35 @@ export class SubagentManager {
572
599
  onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
573
600
  };
574
601
  }
575
- private awaitParentReply(runId: string, taskId: string): Promise<string> {
602
+ private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
603
+ const key = `${runId}:${taskId}`;
576
604
  return new Promise<string>((resolve) => {
577
- this.pendingReplies.set(`${runId}:${taskId}`, { resolve });
605
+ // Identity-tagged: two asks from one child must not delete each other's
606
+ // entry (the loser would hang until its own timer).
607
+ const entry: PendingReply = {
608
+ resolve: (message) => {
609
+ if (timer) clearTimeout(timer);
610
+ if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
611
+ resolve(message);
612
+ },
613
+ };
614
+ const timer =
615
+ timeoutMs > 0
616
+ ? setTimeout(
617
+ () =>
618
+ entry.resolve(
619
+ "The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
620
+ ),
621
+ timeoutMs,
622
+ )
623
+ : undefined;
624
+ this.pendingReplies.set(key, entry);
578
625
  });
579
626
  }
580
627
  deliverReply(runId: string, taskId: string, message: string): boolean {
581
628
  const pending = this.pendingReplies.get(`${runId}:${taskId}`);
582
629
  if (!pending) return false;
583
- this.pendingReplies.delete(`${runId}:${taskId}`);
584
- pending.resolve(message);
630
+ pending.resolve(message); // clears its own entry + timer
585
631
  return true;
586
632
  }
587
633
 
@@ -683,33 +729,9 @@ export class SubagentManager {
683
729
  const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
684
730
 
685
731
  // 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.
688
- let wt: Worktree | undefined;
689
- if (canWrite) {
690
- try {
691
- wt = createWorktree(task.cwd, run.id, task.id);
692
- } catch {
693
- wt = undefined; // git failure → in-place
694
- }
695
- }
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.
699
- let childCwd = wt?.path ?? task.cwd;
700
- if (wt) {
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
- }
708
- }
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.
732
+ // Model + thinking resolve against the pi model registry BEFORE any worktree
733
+ // exists a bad request fails the TASK with a helpful message and can't leak a
734
+ // checkout past this early return.
713
735
  let model: Model<Api> | undefined;
714
736
  try {
715
737
  model = resolveChildModel(ctx, file?.model ?? input.model);
@@ -729,6 +751,54 @@ export class SubagentManager {
729
751
  return;
730
752
  }
731
753
 
754
+ // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
755
+ // non-git repos fall back to in-place. Created BEFORE session start so the
756
+ // child's cwd + AGENTS.md context chain are the worktree's.
757
+ let wt: Worktree | undefined;
758
+ let isolationReason: string | undefined;
759
+ if (canWrite) {
760
+ try {
761
+ wt = createWorktree(task.cwd, run.id, task.id);
762
+ if (!wt) isolationReason = "not a git repository";
763
+ } catch (err) {
764
+ wt = undefined; // git failure → in-place
765
+ isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
766
+ }
767
+ }
768
+ // Map a per-task cwd subpath into the worktree so relative paths stay correct.
769
+ // Both sides go through realpath — a symlinked root would otherwise look
770
+ // "outside" the repo. If the mapping can't be trusted, drop the worktree AND
771
+ // reset the cwd (never point the child at a dir that was just removed).
772
+ let childCwd = wt?.path ?? task.cwd;
773
+ if (wt) {
774
+ const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
775
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
776
+ removeWorktree(wt);
777
+ wt = undefined;
778
+ childCwd = task.cwd;
779
+ isolationReason = "task cwd is outside the repository";
780
+ } else if (rel && rel !== ".") {
781
+ childCwd = join(wt.path, rel);
782
+ // The subpath may be gitignored/untracked, so it won't exist in a fresh
783
+ // checkout — create it rather than fail session start.
784
+ try {
785
+ mkdirSync(childCwd, { recursive: true });
786
+ } catch {
787
+ childCwd = wt.path;
788
+ }
789
+ }
790
+ }
791
+ if (canWrite) {
792
+ // Never let isolation lapse quietly: the leader must know its edits landed
793
+ // straight in the working tree with no branch to review.
794
+ task.isolation = wt ? "worktree" : "in-place";
795
+ task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
796
+ }
797
+ if (wt) {
798
+ claimWorktree(wt); // pid marker: another pi session must not reap this
799
+ this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
800
+ }
801
+
732
802
  this.updateTask(
733
803
  run,
734
804
  task,
@@ -746,6 +816,9 @@ export class SubagentManager {
746
816
  onUpdate,
747
817
  );
748
818
 
819
+ // Set once the dir must outlive this call: committed work awaiting the
820
+ // leader's merge, or a commit failure whose work exists ONLY in the dir.
821
+ let keepWorktreeDir = false;
749
822
  let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
750
823
  let unsubscribe: (() => void) | undefined;
751
824
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -863,28 +936,51 @@ export class SubagentManager {
863
936
  // until the branch is merged — cleanupMerged removes both then.
864
937
  // Commit/diff failures must NOT downgrade a completed task or destroy
865
938
  // its work: the error is reported, the status stays completed.
939
+ let committed: "committed" | "empty" | undefined;
866
940
  try {
867
- commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
868
- const { stat, files } = branchDiff(wt);
869
- this.updateTask(
870
- run,
871
- task,
872
- { branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
873
- ctx,
874
- onUpdate,
875
- );
941
+ committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
942
+ // Only a real commit is worth a branch: an empty one would send the
943
+ // leader off to review and merge nothing.
944
+ keepWorktreeDir = committed === "committed";
876
945
  } catch (commitErr) {
946
+ // Never drop a checkout whose work isn't on the branch — it would be
947
+ // unreachable once the base-tip branch is reaped as "merged".
948
+ keepWorktreeDir = true;
877
949
  this.updateTask(
878
950
  run,
879
951
  task,
880
952
  {
881
953
  branch: wt.branch,
882
- error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
954
+ worktreeError: `commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
883
955
  },
884
956
  ctx,
885
957
  onUpdate,
886
958
  );
887
959
  }
960
+ // Diff separately: a diff failure must not be reported as a lost commit.
961
+ if (committed === "committed") {
962
+ try {
963
+ const { stat, files } = branchDiff(wt);
964
+ this.updateTask(
965
+ run,
966
+ task,
967
+ { branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
968
+ ctx,
969
+ onUpdate,
970
+ );
971
+ } catch (diffErr) {
972
+ this.updateTask(
973
+ run,
974
+ task,
975
+ {
976
+ branch: wt.branch,
977
+ worktreeError: `committed, but the diff could not be read: ${diffErr instanceof Error ? diffErr.message : String(diffErr)}`,
978
+ },
979
+ ctx,
980
+ onUpdate,
981
+ );
982
+ }
983
+ }
888
984
  }
889
985
  }
890
986
  } catch (err) {
@@ -919,20 +1015,27 @@ export class SubagentManager {
919
1015
  watchdog.dispose();
920
1016
  if (timeout) clearTimeout(timeout);
921
1017
  child?.dispose();
922
- this.liveWorktrees.delete(key);
923
1018
  // Failed/aborted: let the aborted child's last writes land (its tools may
924
1019
  // still be unwinding), commit whatever partial work exists so the branch
925
- // really keeps it, then drop the checkout dir.
1020
+ // really keeps it, then drop the checkout dir. A commit FAILURE keeps the
1021
+ // dir — dropping it would make the work unreachable.
926
1022
  if (wt && task.status !== "completed") {
927
1023
  await new Promise((r) => setTimeout(r, 250));
1024
+ let partial: "committed" | "empty" | undefined;
928
1025
  try {
929
- commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
1026
+ partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
930
1027
  } catch {
931
- /* nothing to commit */
1028
+ keepWorktreeDir = true; // work exists only in the dir — keep it
1029
+ }
1030
+ // A branch is only worth reporting when it actually carries something.
1031
+ if (partial === "committed" || keepWorktreeDir) {
1032
+ this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
932
1033
  }
933
- this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
934
- removeWorktree(wt);
935
1034
  }
1035
+ if (wt && !keepWorktreeDir) removeWorktree(wt);
1036
+ // Released only after the dir is gone: while it exists, the branch must stay
1037
+ // in liveBranches() so cleanup can't reap it.
1038
+ this.liveWorktrees.delete(key);
936
1039
  }
937
1040
  }
938
1041
 
@@ -1020,7 +1123,7 @@ export class SubagentManager {
1020
1123
  });
1021
1124
  this.turnActivity = true;
1022
1125
  this.runs.set(run.id, run);
1023
- this.settlers.set(run.id, () => {});
1126
+ this.settlers.set(run.id, true);
1024
1127
  this.runControllers.set(run.id, new AbortController());
1025
1128
  for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
1026
1129
  this.emit("subagent:run-created", { run: cloneRun(run) });
@@ -1087,13 +1190,14 @@ export class SubagentManager {
1087
1190
  // Broken-upstream tasks are detected by the scheduler; mark them after the wave.
1088
1191
  for (const s of skipped) {
1089
1192
  const task = run.tasks.find((t) => t.id === s.id);
1090
- if (task) {
1193
+ if (task && !TERMINAL.includes(task.status)) {
1091
1194
  this.updateTask(
1092
1195
  run,
1093
1196
  task,
1094
1197
  {
1095
1198
  status: "aborted",
1096
- error: `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1199
+ // Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
1200
+ error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1097
1201
  endedAt: Date.now(),
1098
1202
  },
1099
1203
  ctx,
@@ -1101,6 +1205,19 @@ export class SubagentManager {
1101
1205
  );
1102
1206
  }
1103
1207
  }
1208
+ // Belt and braces: the wave loop breaks out when no frontier is ready, which
1209
+ // would otherwise leave tasks queued inside a terminal run — hasActiveRun()
1210
+ // then never clears and the widget stays pinned.
1211
+ for (const task of run.tasks) {
1212
+ if (TERMINAL.includes(task.status)) continue;
1213
+ this.updateTask(
1214
+ run,
1215
+ task,
1216
+ { status: "aborted", error: task.error || "Never ran: no runnable wave", endedAt: Date.now() },
1217
+ ctx,
1218
+ onUpdate,
1219
+ );
1220
+ }
1104
1221
 
1105
1222
  const failed = run.tasks.some((t) => t.status === "failed");
1106
1223
  const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
@@ -1195,6 +1312,8 @@ export class SubagentManager {
1195
1312
  task.status = "aborted";
1196
1313
  task.error = task.error || "Canceled from peek";
1197
1314
  task.endedAt = Date.now();
1315
+ this.pendingReplies.get(`${runId}:${taskId}`)?.resolve("(task canceled by the parent — stop work now)");
1316
+ this.pendingReplies.delete(`${runId}:${taskId}`);
1198
1317
  this.liveChildren.get(`${runId}:${taskId}`)?.abort();
1199
1318
  this.mailboxes.close(`${runId}:${taskId}`);
1200
1319
  if (ctx) this.flushWidget(run, ctx);
@@ -1208,6 +1327,14 @@ export class SubagentManager {
1208
1327
  if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
1209
1328
  let aborted = 0;
1210
1329
  this.runControllers.get(runId)?.abort();
1330
+ // Release children parked in ask_parent first — an unresolved wait would keep
1331
+ // the child alive past the abort.
1332
+ for (const [key, pending] of this.pendingReplies) {
1333
+ if (key.startsWith(`${runId}:`)) {
1334
+ this.pendingReplies.delete(key);
1335
+ pending.resolve("(run canceled by the parent — stop work and return what you have)");
1336
+ }
1337
+ }
1211
1338
  for (const [key, child] of this.liveChildren) {
1212
1339
  if (key.startsWith(`${runId}:`)) {
1213
1340
  child.abort();
@@ -1233,24 +1360,42 @@ export class SubagentManager {
1233
1360
  return { aborted };
1234
1361
  }
1235
1362
 
1236
- /** Settle-and-delete: awaiters resolve once; no leak, no closure chain. */
1363
+ /** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
1237
1364
  private settleRun(runId: string, run: RunSnapshot): void {
1238
- const s = this.settlers.get(runId);
1239
- if (!s) return;
1365
+ if (!this.settlers.has(runId)) return;
1240
1366
  this.settlers.delete(runId);
1241
- s(cloneRun(run));
1367
+ const waiters = this.settleWaiters.get(runId);
1368
+ this.settleWaiters.delete(runId);
1369
+ if (!waiters) return;
1370
+ const snapshot = cloneRun(run);
1371
+ for (const waiter of waiters) waiter(snapshot);
1242
1372
  }
1243
1373
 
1244
1374
  /** Child→leader messages collected while the parent is parked in await_subagent. */
1245
- private parked = new Map<string, { msgs: ParkedMsg[]; wake: () => void }>();
1375
+ /** Every awaiter parked on a run a SET, so two concurrent awaits can't
1376
+ * overwrite each other's buffer and silently swallow one side's intercom. */
1377
+ private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
1246
1378
 
1247
1379
  /** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
1248
1380
  private collectParked(runId: string, msg: ParkedMsg): boolean {
1249
- const p = this.parked.get(runId);
1250
- if (!p) return false;
1251
- if (p.msgs.length < 24) p.msgs.push(msg);
1252
- p.wake(); // resolve the parked await early — the leader breathes on every message
1253
- return true;
1381
+ const parked = this.parked.get(runId);
1382
+ if (!parked || parked.size === 0) return false;
1383
+ let delivered = false;
1384
+ for (const p of parked) {
1385
+ if (p.msgs.length < PARKED_MSG_CAP) {
1386
+ p.msgs.push(msg);
1387
+ delivered = true;
1388
+ } else if (msg.kind === "ask") {
1389
+ // An unanswered ask blocks a child for 10 minutes — it must never be the
1390
+ // message that gets dropped by the cap.
1391
+ p.msgs[p.msgs.length - 1] = msg;
1392
+ delivered = true;
1393
+ }
1394
+ p.wake(); // resolve the parked await early — the leader breathes on every message
1395
+ }
1396
+ // Not buffered anywhere → report undelivered so the caller falls back to a
1397
+ // followUp notice instead of assuming the leader saw it.
1398
+ return delivered;
1254
1399
  }
1255
1400
 
1256
1401
  awaitRun(
@@ -1259,8 +1404,12 @@ export class SubagentManager {
1259
1404
  ): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
1260
1405
  const run = this.runs.get(runId);
1261
1406
  if (!run) return Promise.resolve(undefined);
1407
+ let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
1262
1408
  const finish = (): void => {
1263
- this.parked.delete(runId);
1409
+ const parked = this.parked.get(runId);
1410
+ if (!parked || !entry) return;
1411
+ parked.delete(entry); // only our own park — a sibling await keeps receiving
1412
+ if (parked.size === 0) this.parked.delete(runId);
1264
1413
  };
1265
1414
  if (TERMINAL.includes(run.status)) {
1266
1415
  run.awaited = true;
@@ -1268,24 +1417,43 @@ export class SubagentManager {
1268
1417
  }
1269
1418
  const msgs: ParkedMsg[] = [];
1270
1419
  const settled = new Promise<RunSnapshot | undefined>((resolve) => {
1271
- const prev = this.settlers.get(runId);
1272
- this.settlers.set(runId, (r) => {
1273
- prev?.(r);
1420
+ // Waiters are a SET, not a chain: the autoAwait loop re-parks on every
1421
+ // child message, and wrapping the previous settler each time grew an
1422
+ // unbounded closure chain (each holding a snapshot clone).
1423
+ const waiter = (r: RunSnapshot) => {
1424
+ this.settleWaiters.get(runId)?.delete(waiter);
1274
1425
  resolve(r);
1275
- });
1426
+ };
1427
+ let waiters = this.settleWaiters.get(runId);
1428
+ if (!waiters) {
1429
+ waiters = new Set();
1430
+ this.settleWaiters.set(runId, waiters);
1431
+ }
1432
+ waiters.add(waiter);
1276
1433
  // A child→leader message while parked wakes the wait: the leader gets it
1277
1434
  // IN the await result, no steering queue, no turn boundary needed.
1278
- this.parked.set(runId, { msgs, wake: () => resolve(cloneRun(run)) });
1435
+ entry = { msgs, wake: () => waiter(cloneRun(run)) };
1436
+ let parked = this.parked.get(runId);
1437
+ if (!parked) {
1438
+ parked = new Set();
1439
+ this.parked.set(runId, parked);
1440
+ }
1441
+ parked.add(entry);
1279
1442
  });
1280
1443
  if (timeoutMs !== undefined && timeoutMs > 0) {
1444
+ let timedOut = false;
1281
1445
  return Promise.race([
1282
1446
  settled.then((r) => {
1283
1447
  finish();
1284
- run.awaited = true;
1448
+ // Only mark awaited when this call actually hands the run back to the
1449
+ // leader. A slice that already timed out is abandoned — setting it here
1450
+ // would suppress the run's completion notice the leader still needs.
1451
+ if (!timedOut) run.awaited = true;
1285
1452
  return { run: r, intercom: msgs };
1286
1453
  }),
1287
1454
  new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
1288
1455
  const timer = setTimeout(() => {
1456
+ timedOut = true;
1289
1457
  finish();
1290
1458
  resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
1291
1459
  }, timeoutMs);
package/src/types.ts CHANGED
@@ -45,6 +45,13 @@ export interface TaskSnapshot {
45
45
  branch?: string;
46
46
  diffStat?: string;
47
47
  changedFiles?: string[];
48
+ /** How a write child's edits were applied. "in-place" means NO branch: the
49
+ * changes are already in the leader's tree — always surfaced, never silent. */
50
+ isolation?: "worktree" | "in-place";
51
+ isolationReason?: string;
52
+ /** Worktree commit/diff trouble. Kept apart from `error` so a completed task
53
+ * still reports its answer. */
54
+ worktreeError?: string;
48
55
  }
49
56
 
50
57
  export interface RunSnapshot {
package/src/worktree.ts CHANGED
@@ -10,8 +10,9 @@
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";
14
- import { join } from "node:path";
13
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
+ import { hostname, uptime } from "node:os";
15
+ import { join, resolve } from "node:path";
15
16
 
16
17
  export interface Worktree {
17
18
  root: string; // repo root (main tree)
@@ -22,13 +23,33 @@ export interface Worktree {
22
23
 
23
24
  const BRANCH_PREFIX = "subagents/";
24
25
 
26
+ /** Identity + signing fallbacks: a machine without user.email (CI, fresh box) or
27
+ * with commit.gpgsign set must not fail — or worse, block on a passphrase prompt. */
28
+ const COMMIT_CONFIG = ["-c", "commit.gpgsign=false", "-c", "user.name=pi subagent", "-c", "user.email=subagent@local"];
29
+ const GIT_TIMEOUT_MS = 120_000;
30
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024;
31
+
25
32
  function git(root: string, args: string[]): string {
26
- return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
33
+ return gitRaw(root, args).trim();
34
+ }
35
+
36
+ /** Untrimmed git output — required for -z parsing, where a path may end in a space. */
37
+ function gitRaw(root: string, args: string[]): string {
38
+ return execFileSync("git", ["-C", root, ...args], {
39
+ encoding: "utf8",
40
+ timeout: GIT_TIMEOUT_MS,
41
+ maxBuffer: GIT_MAX_BUFFER,
42
+ });
27
43
  }
28
44
 
29
45
  /** Run git directly inside a directory (worktree ops). */
30
46
  function gitIn(dir: string, args: string[]): string {
31
- return execFileSync("git", [...args], { cwd: dir, encoding: "utf8" }).trim();
47
+ return execFileSync("git", [...args], {
48
+ cwd: dir,
49
+ encoding: "utf8",
50
+ timeout: GIT_TIMEOUT_MS,
51
+ maxBuffer: GIT_MAX_BUFFER,
52
+ }).trim();
32
53
  }
33
54
 
34
55
  function gitOk(root: string, args: string[]): boolean {
@@ -40,15 +61,6 @@ function gitOk(root: string, args: string[]): boolean {
40
61
  }
41
62
  }
42
63
 
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
64
  /** Compare paths through realpath so /var vs /private/var can't diverge. */
53
65
  function samePath(a: string, b: string): boolean {
54
66
  if (a === b) return true;
@@ -73,15 +85,22 @@ export function repoRoot(cwd: string): string | undefined {
73
85
  export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
74
86
  const root = repoRoot(cwd);
75
87
  if (!root) return undefined;
76
- const path = join(root, ".git", "subagents", runId, taskId);
77
- const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
88
+ // --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
89
+ // `.git` is a FILE, and joining it would make `worktree add` fail (silently
90
+ // dropping isolation).
91
+ const container = subagentsDir(root);
92
+ if (!container) return undefined;
78
93
  let base: string;
79
94
  try {
80
95
  base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
81
96
  } catch {
82
97
  return undefined; // broken repo — fall back to in-place
83
98
  }
84
- git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
99
+ const path = join(container, runId, taskId);
100
+ const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
101
+ // Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
102
+ // tree between the two would otherwise skew every later diff against base.
103
+ git(root, ["worktree", "add", "-b", branch, path, base]);
85
104
  // Deps follow the child into the worktree; anything else the task needs is
86
105
  // project content already checked out there.
87
106
  const nm = join(root, "node_modules");
@@ -98,16 +117,33 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
98
117
  /**
99
118
  * Commit the child's changes. Stages first, then commits only when something is
100
119
  * actually staged — an untracked node_modules symlink must not fake "dirty" and
101
- * turn into a failed empty commit.
120
+ * turn into a failed empty commit. Returns "committed" | "empty"; a real git
121
+ * failure THROWS, and callers must not delete the checkout in that case (the
122
+ * work would become unreachable once the base-tip branch is reaped as merged).
102
123
  */
103
- export function commitWorktree(wt: Worktree, message: string): void {
104
- commitIn(wt.path, message);
124
+ export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
125
+ return commitIn(wt.path, message, wt.branch);
105
126
  }
106
127
 
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"]);
128
+ function commitIn(dir: string, message: string, expectBranch?: string): "committed" | "empty" {
129
+ // A child that detached HEAD or switched branches would commit somewhere the
130
+ // leader is never told about refuse rather than report a branch without the work.
131
+ if (expectBranch) {
132
+ // `symbolic-ref` EXITS NON-ZERO on a detached HEAD — catch it rather than
133
+ // letting the raw git failure masquerade as a commit error.
134
+ let head = "detached";
135
+ try {
136
+ head = gitIn(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]) || "detached";
137
+ } catch {
138
+ head = "detached";
139
+ }
140
+ if (head !== expectBranch) throw new Error(`worktree HEAD is "${head}", expected ${expectBranch}`);
141
+ }
142
+ // Exclude the root dep symlink and any nested node_modules the child created.
143
+ gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
144
+ if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
145
+ gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
146
+ return "committed";
111
147
  }
112
148
 
113
149
  /** Diffstat + changed files of the branch vs its base SHA. */
@@ -129,7 +165,28 @@ export function removeByBranch(cwd: string, branch: string): void {
129
165
  if (!branch.startsWith(BRANCH_PREFIX)) return;
130
166
  const root = repoRoot(cwd);
131
167
  if (!root) return;
132
- dropDir(root, join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length)));
168
+ const container = subagentsDir(root);
169
+ if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
170
+ }
171
+
172
+ /** `<git-common-dir>/subagents` — where our worktrees live for this repo.
173
+ * `--path-format` needs git ≥ 2.31; fall back to resolving the relative form. */
174
+ function subagentsDir(root: string): string | undefined {
175
+ try {
176
+ return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
177
+ } catch {
178
+ try {
179
+ return join(resolve(root, git(root, ["rev-parse", "--git-common-dir"])), "subagents");
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Marker path lives BESIDE the checkout, never inside it — a file in the worktree
187
+ * would be staged by `add -A`, committed into the branch, and merged into main. */
188
+ function ownerFile(path: string): string {
189
+ return `${path}.owner`;
133
190
  }
134
191
 
135
192
  function dropDir(root: string, path: string): void {
@@ -138,6 +195,7 @@ function dropDir(root: string, path: string): void {
138
195
  } catch {
139
196
  if (existsSync(path)) rmSync(path, { recursive: true, force: true });
140
197
  }
198
+ rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
141
199
  prune(root);
142
200
  }
143
201
 
@@ -150,9 +208,10 @@ function prune(root: string): void {
150
208
  }
151
209
  }
152
210
 
153
- /** Is this branch checked out in some worktree right now? Checked fresh, per call. */
211
+ /** Is this branch checked out right now? "Unknown" counts as YES (never delete blind). */
154
212
  function isCheckedOut(root: string, branch: string): boolean {
155
- return worktreeBranches(root).includes(branch);
213
+ const branches = worktreeBranches(root);
214
+ return branches === undefined || branches.includes(branch);
156
215
  }
157
216
 
158
217
  /**
@@ -169,34 +228,36 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
169
228
  .split("\n")
170
229
  .map((b) => b.trim().replace(/^[+*]\s*/, ""));
171
230
  let cleaned = 0;
231
+ const container = subagentsDir(root);
172
232
  for (const branch of merged) {
173
- if (!branch.startsWith(BRANCH_PREFIX)) continue;
233
+ if (!branch.startsWith(BRANCH_PREFIX) || !container) continue;
174
234
  if (opts.skipBranches?.has(branch)) continue; // owned by a live run
175
235
  if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
176
- const path = join(root, ".git", "subagents", branch.slice(BRANCH_PREFIX.length));
236
+ const path = join(container, branch.slice(BRANCH_PREFIX.length));
237
+ if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
238
+ // Branch FIRST: `-d` refuses anything not truly merged, so a stale "merged"
239
+ // listing can no longer cost us a checkout that still holds work.
240
+ if (!gitOk(root, ["branch", "-d", branch])) continue;
177
241
  if (existsSync(path)) {
178
242
  try {
179
243
  git(root, ["worktree", "remove", "--force", path]);
180
244
  } catch {
181
- continue;
245
+ /* branch is gone; a leftover dir is swept later */
182
246
  }
183
247
  }
184
- if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
248
+ rmSync(ownerFile(path), { force: true });
249
+ cleaned += 1;
185
250
  }
186
251
  prune(root);
187
252
  return cleaned;
188
253
  }
189
254
 
190
- /** Branch names currently checked out in any worktree (incl. the main one). */
191
- function worktreeBranches(root: string): string[] {
192
- try {
193
- return git(root, ["worktree", "list", "--porcelain"])
194
- .split("\n")
195
- .filter((l) => l.startsWith("branch "))
196
- .map((l) => l.slice("branch refs/heads/".length).trim());
197
- } catch {
198
- return [];
199
- }
255
+ /** Branch names currently checked out in any worktree, or undefined when git
256
+ * couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
257
+ * or they will happily delete live checkouts. */
258
+ function worktreeBranches(root: string): string[] | undefined {
259
+ const listing = worktreeListing(root);
260
+ return listing?.filter((l) => l.startsWith("branch ")).map((l) => l.slice("branch refs/heads/".length));
200
261
  }
201
262
 
202
263
  /**
@@ -204,17 +265,20 @@ function worktreeBranches(root: string): string[] {
204
265
  * (nothing of ours can be live yet). Commit whatever the dead child left so the
205
266
  * branch keeps it, then drop the dir. Branches always survive.
206
267
  */
207
- export function reapDeadWorktrees(root: string): number {
268
+ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolean = () => false): number {
208
269
  root = realpathSync(root);
209
- const sub = join(root, ".git", "subagents");
210
- if (!existsSync(sub)) return 0;
270
+ const sub = subagentsDir(root);
271
+ if (!sub || !existsSync(sub)) return 0;
272
+ const registered = worktreePaths(root);
273
+ if (!registered) return 0; // listing failed — touch nothing
211
274
  let reaped = 0;
212
- for (const path of worktreePaths(root)) {
275
+ for (const path of registered) {
213
276
  if (!isInside(path, sub)) continue; // not ours
277
+ if (isLive(path)) continue; // another pi session owns it
214
278
  try {
215
279
  commitIn(path, "subagent (recovered after interrupted session)");
216
280
  } catch {
217
- /* nothing committable */
281
+ continue; // git failed — never drop a dir whose work isn't on the branch
218
282
  }
219
283
  dropDir(root, path);
220
284
  reaped += 1;
@@ -222,6 +286,51 @@ export function reapDeadWorktrees(root: string): number {
222
286
  return reaped;
223
287
  }
224
288
 
289
+ /**
290
+ * Ownership marker: a live worktree gets `<dir>/.subagent-owner` holding the
291
+ * owning pid. Another pi session must not reap a checkout whose owner is alive.
292
+ */
293
+ export function claimWorktree(wt: Worktree): void {
294
+ try {
295
+ writeFileSync(
296
+ ownerFile(wt.path),
297
+ JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
298
+ );
299
+ } catch {
300
+ /* best-effort: worst case another session reaps it after a crash */
301
+ }
302
+ }
303
+
304
+ /**
305
+ * True when the worktree is claimed by a process that still exists HERE.
306
+ * Guards against pid reuse across reboots (boot id) and other hosts (hostname);
307
+ * EPERM means the pid exists under another user — alive, not reapable.
308
+ */
309
+ export function ownerAlive(path: string): boolean {
310
+ let marker: { pid?: number; host?: string; boot?: string };
311
+ try {
312
+ marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
313
+ } catch {
314
+ return false; // no marker (or unreadable) → nobody claims it
315
+ }
316
+ const pid = marker.pid;
317
+ if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
318
+ if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
319
+ if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
320
+ if (pid === process.pid) return true;
321
+ try {
322
+ process.kill(pid, 0);
323
+ return true;
324
+ } catch (err) {
325
+ return (err as NodeJS.ErrnoException)?.code === "EPERM"; // exists, other user
326
+ }
327
+ }
328
+
329
+ /** Stable per-boot id, so a recycled pid from before a reboot can't look alive. */
330
+ function bootId(): string {
331
+ return String(Math.floor(Date.now() / 1000 - Math.floor(uptime())));
332
+ }
333
+
225
334
  /**
226
335
  * Remove worktree dirs that git no longer knows about (partial-crash leftovers).
227
336
  * Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
@@ -229,27 +338,42 @@ export function reapDeadWorktrees(root: string): number {
229
338
  */
230
339
  export function sweepStale(root: string): void {
231
340
  root = realpathSync(root);
232
- const sub = join(root, ".git", "subagents");
233
- if (!existsSync(sub)) return;
341
+ const sub = subagentsDir(root);
342
+ if (!sub || !existsSync(sub)) return;
234
343
  const registered = worktreePaths(root);
344
+ // Unknown registration = every dir might be live. Deleting here would be the
345
+ // single most destructive thing this module can do; bail instead.
346
+ if (!registered) return;
235
347
  for (const runDir of readDirs(sub)) {
236
348
  for (const taskDir of readDirs(join(sub, runDir))) {
237
349
  const dir = join(sub, runDir, taskDir);
238
350
  if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
351
+ if (ownerAlive(dir)) continue; // claimed by a running session
239
352
  rmSync(dir, { recursive: true, force: true });
353
+ rmSync(ownerFile(dir), { force: true });
240
354
  }
241
355
  }
242
356
  prune(root);
243
357
  }
244
358
 
245
- function worktreePaths(root: string): string[] {
359
+ /** Registered worktree paths, or undefined when the listing failed (see above). */
360
+ function worktreePaths(root: string): string[] | undefined {
361
+ const listing = worktreeListing(root);
362
+ return listing?.filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
363
+ }
364
+
365
+ /** `worktree list --porcelain -z`; `-z` needs git ≥ 2.36, so fall back to the
366
+ * newline form (which C-quotes exotic paths — those simply won't match, and a
367
+ * non-match is safe: it only ever means "treat as live"). */
368
+ function worktreeListing(root: string): string[] | undefined {
246
369
  try {
247
- return git(root, ["worktree", "list", "--porcelain"])
248
- .split("\n")
249
- .filter((l) => l.startsWith("worktree "))
250
- .map((l) => unquote(l.slice("worktree ".length).trim()));
370
+ return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
251
371
  } catch {
252
- return [];
372
+ try {
373
+ return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
374
+ } catch {
375
+ return undefined; // unknown — callers must bail out
376
+ }
253
377
  }
254
378
  }
255
379