@arhen/pi-core-subagent 1.3.32 → 1.3.34

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.32",
3
+ "version": "1.3.34",
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/agentfile.ts CHANGED
@@ -13,6 +13,8 @@ export interface AgentFileInfo {
13
13
  model?: string;
14
14
  tools?: string[];
15
15
  description?: string;
16
+ /** The matched file path — surfaced so the leader can audit which file won. */
17
+ path?: string;
16
18
  }
17
19
 
18
20
  const AGENT_DIRS = [".agents/agents", ".claude/agents", ".pi/agents"] as const;
@@ -41,10 +43,23 @@ const STOP = new Set([
41
43
  "as",
42
44
  "how",
43
45
  "what",
44
- "when",
45
46
  "who",
46
47
  ]);
47
48
 
49
+ /** A body becomes the child's ENTIRE system prompt — an oversized reference file
50
+ * would blow the context window and kill the session with a cryptic error. */
51
+ const MAX_BODY_CHARS = 64_000;
52
+
53
+ /** Per-cwd memo of the ancestor walk (project dirs then home), since the files
54
+ * can't meaningfully change within one run and the walk costs 3 sync stats per
55
+ * ancestor dir per task otherwise. Keyed per (agentDir + cwd). */
56
+ const walkCache = new Map<string, AgentFileInfo[]>();
57
+
58
+ /** Test/diagnostic hook: flush the walk cache (files changed mid-session). */
59
+ export function clearAgentFileCache(): void {
60
+ walkCache.clear();
61
+ }
62
+
48
63
  /** Lowercase, split, drop stopwords, strip plural -s/-es. */
49
64
  function tokens(text: string): string[] {
50
65
  return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
@@ -68,65 +83,67 @@ function readAgentFile(dir: string): AgentFileInfo[] {
68
83
  const out: AgentFileInfo[] = [];
69
84
  for (const entry of readdirSync(dir)) {
70
85
  if (!entry.endsWith(".md")) continue;
71
- const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, entry), "utf8"));
86
+ const path = join(dir, entry);
87
+ const { frontmatter, body } = parseFrontmatter(readFileSync(path, "utf8"));
88
+ let trimmed = body;
89
+ if (trimmed.length > MAX_BODY_CHARS) {
90
+ trimmed = `${trimmed.slice(0, MAX_BODY_CHARS)}\n\n[truncated: agent file exceeded ${MAX_BODY_CHARS} chars — slim it down]`;
91
+ }
92
+ const toolsF = frontmatter.tools;
72
93
  const tools =
73
- typeof frontmatter.tools === "string"
74
- ? frontmatter.tools
94
+ typeof toolsF === "string"
95
+ ? toolsF
75
96
  .split(",")
76
97
  .map((t) => t.trim())
77
98
  .filter(Boolean)
78
- : Array.isArray(frontmatter.tools)
79
- ? frontmatter.tools.map(String)
99
+ : Array.isArray(toolsF)
100
+ ? toolsF.map(String)
80
101
  : undefined;
81
102
  out.push({
82
- body,
103
+ body: trimmed,
83
104
  model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
84
105
  tools: tools?.length ? tools : undefined,
85
106
  description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
107
+ path,
86
108
  });
87
109
  }
88
110
  return out;
89
111
  }
90
112
 
91
- /**
92
- * Best agent-file match for a spawn goal. Order: `.agents/agents` (single
93
- * source) → `.claude/agents` → `.pi/agents` per cwd ancestor (nearest first),
94
- * then home (`~/.agents` → `~/.claude` → `~/.pi`). Within a dir, highest
95
- * description-overlap score wins; the first dir with a match is returned.
96
- * `agentDir` is the pi agent dir (`~/.pi/agent`); home is derived from it.
97
- */
98
- export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
99
- const query = tokens(`${name} ${task}`);
113
+ /** Walk cwd's ancestors, then home, returning files in priority order (nearest
114
+ * first, each dir's 3 subdirs in AGENT_DIRS order). */
115
+ function allAgentFiles(cwd: string, agentDir: string): AgentFileInfo[] {
116
+ const out: AgentFileInfo[] = [];
100
117
  let dir = cwd;
101
118
  while (true) {
102
- for (const sub of AGENT_DIRS) {
103
- let best: AgentFileInfo | undefined;
104
- let bestScore = 0;
105
- for (const file of readAgentFile(join(dir, sub))) {
106
- const s = score(query, tokens(file.description ?? ""));
107
- if (s > bestScore) {
108
- best = file;
109
- bestScore = s;
110
- }
111
- }
112
- if (best) return best; // first dir with any match wins (priority over score)
113
- }
119
+ for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(dir, sub)));
114
120
  const parent = dirname(dir);
115
121
  if (parent === dir) break;
116
122
  dir = parent;
117
123
  }
118
124
  const home = dirname(dirname(agentDir)); // ~/.pi/agent → ~
119
- for (const sub of AGENT_DIRS) {
120
- let best: AgentFileInfo | undefined;
121
- let bestScore = 0;
122
- for (const file of readAgentFile(join(home, sub))) {
123
- const s = score(query, tokens(file.description ?? ""));
124
- if (s > bestScore) {
125
- best = file;
126
- bestScore = s;
127
- }
125
+ for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(home, sub)));
126
+ return out;
127
+ }
128
+
129
+ /** Best agent-file match for a spawn goal. Caches per (agentDir, cwd). */
130
+ export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
131
+ const query = tokens(`${name} ${task}`);
132
+ const cacheKey = `${agentDir}${cwd}`;
133
+ let files = walkCache.get(cacheKey);
134
+ if (!files) {
135
+ files = allAgentFiles(cwd, agentDir);
136
+ if (walkCache.size >= 512) walkCache.clear();
137
+ walkCache.set(cacheKey, files);
138
+ }
139
+ let best: AgentFileInfo | undefined;
140
+ let bestScore = 0;
141
+ for (const file of files) {
142
+ const s = score(query, tokens(file.description ?? ""));
143
+ if (s > 0 && s > bestScore) {
144
+ best = file;
145
+ bestScore = s;
128
146
  }
129
- if (best) return best;
130
147
  }
131
- return undefined;
148
+ return best;
132
149
  }
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;
@@ -209,8 +226,9 @@ export function makeSummary(run: RunSnapshot): string {
209
226
  for (const task of run.tasks) {
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})`;
229
+ const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
212
230
  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.` : ""}`,
231
+ `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
214
232
  );
215
233
  }
216
234
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
package/src/index.ts CHANGED
@@ -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";
3
- import { writeFile } from "node:fs/promises";
4
- import { join, relative } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { rename, writeFile } from "node:fs/promises";
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 {
@@ -61,6 +61,8 @@ const DEFAULT_RUNTIME_MS = 0;
61
61
  const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
62
62
  /** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
63
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;
64
66
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
65
67
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
66
68
  /** Tools that can mutate the tree — their presence is what earns a worktree. */
@@ -302,6 +304,28 @@ export class SubagentManager {
302
304
  child.dispose();
303
305
  }
304
306
  this.liveChildren.clear();
307
+ // Mark every non-terminal task aborted BEFORE resolving pendingReplies: the
308
+ // resumed onAskParent closure re-checks status, and a still-awaiting task
309
+ // would flip back to "running" and re-insert the run after the maps clear —
310
+ // a ghost run that widgets re-arm on and persist forever.
311
+ for (const run of this.runs.values()) {
312
+ for (const task of run.tasks) {
313
+ if (!TERMINAL.includes(task.status)) {
314
+ task.status = "aborted";
315
+ task.error = task.error ?? "(session ended)";
316
+ }
317
+ }
318
+ }
319
+ // Release anyone parked on a run before the maps go — dropping waiters would
320
+ // leave their promises pending forever (autoAwait / await_subagent hang).
321
+ for (const [runId, waiters] of this.settleWaiters) {
322
+ const run = this.runs.get(runId);
323
+ for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
324
+ }
325
+ for (const pending of this.pendingReplies.values()) {
326
+ pending.resolve("(session ended — stop work immediately)");
327
+ }
328
+ this.parked.clear();
305
329
  // Ownership markers stay on disk; the next session reaps those dirs (commit,
306
330
  // keep branch, drop dir) once this pid is gone.
307
331
  this.liveWorktrees.clear();
@@ -371,7 +395,13 @@ export class SubagentManager {
371
395
  const parentFile = getParentSessionFile(ctx);
372
396
  if (!parentFile) return;
373
397
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
374
- void writeFile(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)).catch(() => {}); // never surface as an unhandled rejection
398
+ // Write-then-rename: a plain writeFile can tear on crash and silently drop
399
+ // ALL run history for the session on the next read.
400
+ const tmp = `${sidecar}.tmp`;
401
+ const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
402
+ void writeFile(tmp, payload)
403
+ .then(() => rename(tmp, sidecar))
404
+ .catch(() => {}); // never surface as an unhandled rejection
375
405
  } catch {
376
406
  /* ignore */
377
407
  }
@@ -519,6 +549,12 @@ export class SubagentManager {
519
549
  return {
520
550
  onAskParent: async (_taskId, question) => {
521
551
  const key = `${run.id}:${task.id}`;
552
+ // A tool call already in flight can reach here AFTER the task ended
553
+ // (abort/timeout/cancel). Reviving it would leave a "running" task in a
554
+ // finished run — hasActiveRun() then never clears.
555
+ if (TERMINAL.includes(task.status)) {
556
+ return "(your task has already ended — stop work and return immediately)";
557
+ }
522
558
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
523
559
  this.liveChildren.get(key)?.touchWatchdog();
524
560
  // While the leader is parked in await_subagent the question rides the wait
@@ -534,6 +570,12 @@ export class SubagentManager {
534
570
  const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
535
571
  try {
536
572
  const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
573
+ // Cancel wins over a reply that arrived in the same tick: never move a
574
+ // terminal task back to "running" (that would let a canceled task be
575
+ // reported as completed).
576
+ if (TERMINAL.includes(task.status)) {
577
+ return "(your task was canceled while you waited — stop work and return immediately)";
578
+ }
537
579
  this.updateTask(run, task, { status: "running" }, ctx);
538
580
  this.liveChildren.get(key)?.touchWatchdog();
539
581
  return reply;
@@ -578,28 +620,32 @@ export class SubagentManager {
578
620
  private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
579
621
  const key = `${runId}:${taskId}`;
580
622
  return new Promise<string>((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, {
623
+ // Identity-tagged: two asks from one child must not delete each other's
624
+ // entry (the loser would hang until its own timer).
625
+ const entry: PendingReply = {
591
626
  resolve: (message) => {
592
627
  if (timer) clearTimeout(timer);
628
+ if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
593
629
  resolve(message);
594
630
  },
595
- });
631
+ };
632
+ const timer =
633
+ timeoutMs > 0
634
+ ? setTimeout(
635
+ () =>
636
+ entry.resolve(
637
+ "The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
638
+ ),
639
+ timeoutMs,
640
+ )
641
+ : undefined;
642
+ this.pendingReplies.set(key, entry);
596
643
  });
597
644
  }
598
645
  deliverReply(runId: string, taskId: string, message: string): boolean {
599
646
  const pending = this.pendingReplies.get(`${runId}:${taskId}`);
600
647
  if (!pending) return false;
601
- this.pendingReplies.delete(`${runId}:${taskId}`);
602
- pending.resolve(message);
648
+ pending.resolve(message); // clears its own entry + timer
603
649
  return true;
604
650
  }
605
651
 
@@ -687,6 +733,7 @@ export class SubagentManager {
687
733
  // is authoritative — body = system prompt, frontmatter model/tools win over
688
734
  // inline. No match → inline on-demand definition as usual.
689
735
  const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
736
+ if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
690
737
  const prompt = file?.body ?? input.prompt?.trim();
691
738
  const thinking = input.thinking;
692
739
  // Trust boundary: a file can NARROW the toolset (intersect with the leader's
@@ -727,11 +774,14 @@ export class SubagentManager {
727
774
  // non-git repos fall back to in-place. Created BEFORE session start so the
728
775
  // child's cwd + AGENTS.md context chain are the worktree's.
729
776
  let wt: Worktree | undefined;
777
+ let isolationReason: string | undefined;
730
778
  if (canWrite) {
731
779
  try {
732
780
  wt = createWorktree(task.cwd, run.id, task.id);
733
- } catch {
781
+ if (!wt) isolationReason = "not a git repository";
782
+ } catch (err) {
734
783
  wt = undefined; // git failure → in-place
784
+ isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
735
785
  }
736
786
  }
737
787
  // Map a per-task cwd subpath into the worktree so relative paths stay correct.
@@ -741,14 +791,28 @@ export class SubagentManager {
741
791
  let childCwd = wt?.path ?? task.cwd;
742
792
  if (wt) {
743
793
  const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
744
- if (rel.startsWith("..")) {
794
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
745
795
  removeWorktree(wt);
746
796
  wt = undefined;
747
797
  childCwd = task.cwd;
798
+ isolationReason = "task cwd is outside the repository";
748
799
  } else if (rel && rel !== ".") {
749
800
  childCwd = join(wt.path, rel);
801
+ // The subpath may be gitignored/untracked, so it won't exist in a fresh
802
+ // checkout — create it rather than fail session start.
803
+ try {
804
+ mkdirSync(childCwd, { recursive: true });
805
+ } catch {
806
+ childCwd = wt.path;
807
+ }
750
808
  }
751
809
  }
810
+ if (canWrite) {
811
+ // Never let isolation lapse quietly: the leader must know its edits landed
812
+ // straight in the working tree with no branch to review.
813
+ task.isolation = wt ? "worktree" : "in-place";
814
+ task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
815
+ }
752
816
  if (wt) {
753
817
  claimWorktree(wt); // pid marker: another pi session must not reap this
754
818
  this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
@@ -891,17 +955,12 @@ export class SubagentManager {
891
955
  // until the branch is merged — cleanupMerged removes both then.
892
956
  // Commit/diff failures must NOT downgrade a completed task or destroy
893
957
  // its work: the error is reported, the status stays completed.
958
+ let committed: "committed" | "empty" | undefined;
894
959
  try {
895
- commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
896
- keepWorktreeDir = true; // committed — dir stays until the leader merges
897
- const { stat, files } = branchDiff(wt);
898
- this.updateTask(
899
- run,
900
- task,
901
- { branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
902
- ctx,
903
- onUpdate,
904
- );
960
+ committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
961
+ // Only a real commit is worth a branch: an empty one would send the
962
+ // leader off to review and merge nothing.
963
+ keepWorktreeDir = committed === "committed";
905
964
  } catch (commitErr) {
906
965
  // Never drop a checkout whose work isn't on the branch — it would be
907
966
  // unreachable once the base-tip branch is reaped as "merged".
@@ -911,12 +970,36 @@ export class SubagentManager {
911
970
  task,
912
971
  {
913
972
  branch: wt.branch,
914
- error: `Worktree commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
973
+ worktreeError: `commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
915
974
  },
916
975
  ctx,
917
976
  onUpdate,
918
977
  );
919
978
  }
979
+ // Diff separately: a diff failure must not be reported as a lost commit.
980
+ if (committed === "committed") {
981
+ try {
982
+ const { stat, files } = branchDiff(wt);
983
+ this.updateTask(
984
+ run,
985
+ task,
986
+ { branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
987
+ ctx,
988
+ onUpdate,
989
+ );
990
+ } catch (diffErr) {
991
+ this.updateTask(
992
+ run,
993
+ task,
994
+ {
995
+ branch: wt.branch,
996
+ worktreeError: `committed, but the diff could not be read: ${diffErr instanceof Error ? diffErr.message : String(diffErr)}`,
997
+ },
998
+ ctx,
999
+ onUpdate,
1000
+ );
1001
+ }
1002
+ }
920
1003
  }
921
1004
  }
922
1005
  } catch (err) {
@@ -957,12 +1040,16 @@ export class SubagentManager {
957
1040
  // dir — dropping it would make the work unreachable.
958
1041
  if (wt && task.status !== "completed") {
959
1042
  await new Promise((r) => setTimeout(r, 250));
1043
+ let partial: "committed" | "empty" | undefined;
960
1044
  try {
961
- commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
1045
+ partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
962
1046
  } catch {
963
- keepWorktreeDir = true;
1047
+ keepWorktreeDir = true; // work exists only in the dir — keep it
1048
+ }
1049
+ // A branch is only worth reporting when it actually carries something.
1050
+ if (partial === "committed" || keepWorktreeDir) {
1051
+ this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
964
1052
  }
965
- this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
966
1053
  }
967
1054
  if (wt && !keepWorktreeDir) removeWorktree(wt);
968
1055
  // Released only after the dir is gone: while it exists, the branch must stay
@@ -1122,13 +1209,14 @@ export class SubagentManager {
1122
1209
  // Broken-upstream tasks are detected by the scheduler; mark them after the wave.
1123
1210
  for (const s of skipped) {
1124
1211
  const task = run.tasks.find((t) => t.id === s.id);
1125
- if (task) {
1212
+ if (task && !TERMINAL.includes(task.status)) {
1126
1213
  this.updateTask(
1127
1214
  run,
1128
1215
  task,
1129
1216
  {
1130
1217
  status: "aborted",
1131
- error: `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1218
+ // Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
1219
+ error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1132
1220
  endedAt: Date.now(),
1133
1221
  },
1134
1222
  ctx,
@@ -1136,6 +1224,19 @@ export class SubagentManager {
1136
1224
  );
1137
1225
  }
1138
1226
  }
1227
+ // Belt and braces: the wave loop breaks out when no frontier is ready, which
1228
+ // would otherwise leave tasks queued inside a terminal run — hasActiveRun()
1229
+ // then never clears and the widget stays pinned.
1230
+ for (const task of run.tasks) {
1231
+ if (TERMINAL.includes(task.status)) continue;
1232
+ this.updateTask(
1233
+ run,
1234
+ task,
1235
+ { status: "aborted", error: task.error || "Never ran: no runnable wave", endedAt: Date.now() },
1236
+ ctx,
1237
+ onUpdate,
1238
+ );
1239
+ }
1139
1240
 
1140
1241
  const failed = run.tasks.some((t) => t.status === "failed");
1141
1242
  const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
@@ -1290,15 +1391,37 @@ export class SubagentManager {
1290
1391
  }
1291
1392
 
1292
1393
  /** Child→leader messages collected while the parent is parked in await_subagent. */
1293
- private parked = new Map<string, { msgs: ParkedMsg[]; wake: () => void }>();
1394
+ /** Every awaiter parked on a run — a SET, so two concurrent awaits can't
1395
+ * overwrite each other's buffer and silently swallow one side's intercom. */
1396
+ private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
1294
1397
 
1295
1398
  /** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
1296
1399
  private collectParked(runId: string, msg: ParkedMsg): boolean {
1297
- const p = this.parked.get(runId);
1298
- if (!p) return false;
1299
- if (p.msgs.length < 24) p.msgs.push(msg);
1300
- p.wake(); // resolve the parked await early — the leader breathes on every message
1301
- return true;
1400
+ const parked = this.parked.get(runId);
1401
+ if (!parked || parked.size === 0) return false;
1402
+ let delivered = false;
1403
+ for (const p of parked) {
1404
+ if (p.msgs.length < PARKED_MSG_CAP) {
1405
+ p.msgs.push(msg);
1406
+ delivered = true;
1407
+ } else if (msg.kind === "ask") {
1408
+ // An unanswered ask blocks a child for 10 minutes — it must never be the
1409
+ // message that gets dropped by the cap. Evict the oldest NON-ask first
1410
+ // (asks already block children; displacing one hangs the earlier asker).
1411
+ const drop = p.msgs.findIndex((m) => m.kind !== "ask");
1412
+ if (drop !== -1) {
1413
+ p.msgs.splice(drop, 1);
1414
+ p.msgs.push(msg);
1415
+ } else {
1416
+ p.msgs[p.msgs.length - 1] = msg; // all asks — overwrite the oldest ask
1417
+ }
1418
+ delivered = true;
1419
+ }
1420
+ p.wake(); // resolve the parked await early — the leader breathes on every message
1421
+ }
1422
+ // Not buffered anywhere → report undelivered so the caller falls back to a
1423
+ // followUp notice instead of assuming the leader saw it.
1424
+ return delivered;
1302
1425
  }
1303
1426
 
1304
1427
  awaitRun(
@@ -1307,8 +1430,12 @@ export class SubagentManager {
1307
1430
  ): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
1308
1431
  const run = this.runs.get(runId);
1309
1432
  if (!run) return Promise.resolve(undefined);
1433
+ let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
1310
1434
  const finish = (): void => {
1311
- this.parked.delete(runId);
1435
+ const parked = this.parked.get(runId);
1436
+ if (!parked || !entry) return;
1437
+ parked.delete(entry); // only our own park — a sibling await keeps receiving
1438
+ if (parked.size === 0) this.parked.delete(runId);
1312
1439
  };
1313
1440
  if (TERMINAL.includes(run.status)) {
1314
1441
  run.awaited = true;
@@ -1331,17 +1458,28 @@ export class SubagentManager {
1331
1458
  waiters.add(waiter);
1332
1459
  // A child→leader message while parked wakes the wait: the leader gets it
1333
1460
  // IN the await result, no steering queue, no turn boundary needed.
1334
- this.parked.set(runId, { msgs, wake: () => waiter(cloneRun(run)) });
1461
+ entry = { msgs, wake: () => waiter(cloneRun(run)) };
1462
+ let parked = this.parked.get(runId);
1463
+ if (!parked) {
1464
+ parked = new Set();
1465
+ this.parked.set(runId, parked);
1466
+ }
1467
+ parked.add(entry);
1335
1468
  });
1336
1469
  if (timeoutMs !== undefined && timeoutMs > 0) {
1470
+ let timedOut = false;
1337
1471
  return Promise.race([
1338
1472
  settled.then((r) => {
1339
1473
  finish();
1340
- run.awaited = true;
1474
+ // Only mark awaited when this call actually hands the run back to the
1475
+ // leader. A slice that already timed out is abandoned — setting it here
1476
+ // would suppress the run's completion notice the leader still needs.
1477
+ if (!timedOut) run.awaited = true;
1341
1478
  return { run: r, intercom: msgs };
1342
1479
  }),
1343
1480
  new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
1344
1481
  const timer = setTimeout(() => {
1482
+ timedOut = true;
1345
1483
  finish();
1346
1484
  resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
1347
1485
  }, timeoutMs);
package/src/types.ts CHANGED
@@ -41,10 +41,21 @@ export interface TaskSnapshot {
41
41
  usage: UsageStats;
42
42
  /** Sibling addresses for intercom tools (send_agent_message targets). */
43
43
  roster?: string;
44
+ /** Path of the agent file that drove this child (inline definition otherwise).
45
+ * Surfaced so the leader can audit which file took over — a description-only
46
+ * match must never be invisible. */
47
+ agentFile?: string;
44
48
  /** Git worktree isolation (write agents): branch + diff of the child's changes. */
45
49
  branch?: string;
46
50
  diffStat?: string;
47
51
  changedFiles?: string[];
52
+ /** How a write child's edits were applied. "in-place" means NO branch: the
53
+ * changes are already in the leader's tree — always surfaced, never silent. */
54
+ isolation?: "worktree" | "in-place";
55
+ isolationReason?: string;
56
+ /** Worktree commit/diff trouble. Kept apart from `error` so a completed task
57
+ * still reports its answer. */
58
+ worktreeError?: string;
48
59
  }
49
60
 
50
61
  export interface RunSnapshot {
package/src/worktree.ts CHANGED
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
- import { join } from "node:path";
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 {
@@ -67,18 +88,19 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
67
88
  // --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
68
89
  // `.git` is a FILE, and joining it would make `worktree add` fail (silently
69
90
  // dropping isolation).
70
- let container: string;
91
+ const container = subagentsDir(root);
92
+ if (!container) return undefined;
71
93
  let base: string;
72
94
  try {
73
- const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
74
- container = join(common, "subagents");
75
95
  base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
76
96
  } catch {
77
97
  return undefined; // broken repo — fall back to in-place
78
98
  }
79
99
  const path = join(container, runId, taskId);
80
100
  const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
81
- git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
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]);
82
104
  // Deps follow the child into the worktree; anything else the task needs is
83
105
  // project content already checked out there.
84
106
  const nm = join(root, "node_modules");
@@ -100,14 +122,27 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
100
122
  * work would become unreachable once the base-tip branch is reaped as merged).
101
123
  */
102
124
  export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
103
- return commitIn(wt.path, message);
125
+ return commitIn(wt.path, message, wt.branch);
104
126
  }
105
127
 
106
- function commitIn(dir: string, message: string): "committed" | "empty" {
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
+ }
107
142
  // Exclude the root dep symlink and any nested node_modules the child created.
108
143
  gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
109
144
  if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
110
- gitIn(dir, ["commit", "-m", message, "--no-verify"]);
145
+ gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
111
146
  return "committed";
112
147
  }
113
148
 
@@ -134,21 +169,33 @@ export function removeByBranch(cwd: string, branch: string): void {
134
169
  if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
135
170
  }
136
171
 
137
- /** `<git-common-dir>/subagents` — where our worktrees live for this repo. */
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. */
138
174
  function subagentsDir(root: string): string | undefined {
139
175
  try {
140
176
  return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
141
177
  } catch {
142
- return undefined;
178
+ try {
179
+ return join(resolve(root, git(root, ["rev-parse", "--git-common-dir"])), "subagents");
180
+ } catch {
181
+ return undefined;
182
+ }
143
183
  }
144
184
  }
145
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`;
190
+ }
191
+
146
192
  function dropDir(root: string, path: string): void {
147
193
  try {
148
194
  git(root, ["worktree", "remove", "--force", path]);
149
195
  } catch {
150
196
  if (existsSync(path)) rmSync(path, { recursive: true, force: true });
151
197
  }
198
+ rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
152
199
  prune(root);
153
200
  }
154
201
 
@@ -161,9 +208,10 @@ function prune(root: string): void {
161
208
  }
162
209
  }
163
210
 
164
- /** 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). */
165
212
  function isCheckedOut(root: string, branch: string): boolean {
166
- return worktreeBranches(root).includes(branch);
213
+ const branches = worktreeBranches(root);
214
+ return branches === undefined || branches.includes(branch);
167
215
  }
168
216
 
169
217
  /**
@@ -187,29 +235,29 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
187
235
  if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
188
236
  const path = join(container, branch.slice(BRANCH_PREFIX.length));
189
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;
190
241
  if (existsSync(path)) {
191
242
  try {
192
243
  git(root, ["worktree", "remove", "--force", path]);
193
244
  } catch {
194
- continue;
245
+ /* branch is gone; a leftover dir is swept later */
195
246
  }
196
247
  }
197
- if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
248
+ rmSync(ownerFile(path), { force: true });
249
+ cleaned += 1;
198
250
  }
199
251
  prune(root);
200
252
  return cleaned;
201
253
  }
202
254
 
203
- /** Branch names currently checked out in any worktree (incl. the main one). */
204
- function worktreeBranches(root: string): string[] {
205
- try {
206
- return git(root, ["worktree", "list", "--porcelain", "-z"])
207
- .split("\0")
208
- .filter((l) => l.startsWith("branch "))
209
- .map((l) => l.slice("branch refs/heads/".length));
210
- } catch {
211
- return [];
212
- }
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));
213
261
  }
214
262
 
215
263
  /**
@@ -221,14 +269,30 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
221
269
  root = realpathSync(root);
222
270
  const sub = subagentsDir(root);
223
271
  if (!sub || !existsSync(sub)) return 0;
272
+ const registered = worktreePaths(root);
273
+ if (!registered) return 0; // listing failed — touch nothing
224
274
  let reaped = 0;
225
- for (const path of worktreePaths(root)) {
275
+ for (const path of registered) {
226
276
  if (!isInside(path, sub)) continue; // not ours
227
277
  if (isLive(path)) continue; // another pi session owns it
228
278
  try {
229
279
  commitIn(path, "subagent (recovered after interrupted session)");
230
280
  } catch {
231
- continue; // git failed — never drop a dir whose work isn't on the branch
281
+ // A dir git refuses to read (half-created by a timed-out worktree add,
282
+ // or corrupted) holds no recoverable work — drop it instead of retrying
283
+ // forever. The "never drop uncommitted work" rule protects readable dirs.
284
+ try {
285
+ execFileSync("git", ["-C", path, "rev-parse", "--git-dir"], {
286
+ encoding: "utf8",
287
+ timeout: GIT_TIMEOUT_MS,
288
+ maxBuffer: GIT_MAX_BUFFER,
289
+ });
290
+ continue; // git still reads it — legitimate work, keep
291
+ } catch {
292
+ dropDir(root, path);
293
+ reaped += 1;
294
+ }
295
+ continue;
232
296
  }
233
297
  dropDir(root, path);
234
298
  reaped += 1;
@@ -242,23 +306,46 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
242
306
  */
243
307
  export function claimWorktree(wt: Worktree): void {
244
308
  try {
245
- writeFileSync(join(wt.path, ".subagent-owner"), String(process.pid));
309
+ writeFileSync(
310
+ ownerFile(wt.path),
311
+ JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
312
+ );
246
313
  } catch {
247
314
  /* best-effort: worst case another session reaps it after a crash */
248
315
  }
249
316
  }
250
317
 
251
- /** True when the worktree dir is claimed by a process that still exists. */
318
+ /**
319
+ * True when the worktree is claimed by a process that still exists HERE.
320
+ * Guards against pid reuse across reboots (boot id) and other hosts (hostname);
321
+ * EPERM means the pid exists under another user — alive, not reapable.
322
+ */
252
323
  export function ownerAlive(path: string): boolean {
324
+ let marker: { pid?: number; host?: string; boot?: string };
253
325
  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;
326
+ marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
259
327
  } catch {
260
- return false;
328
+ return false; // no marker (or unreadable) → nobody claims it
261
329
  }
330
+ const pid = marker.pid;
331
+ if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
332
+ if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
333
+ if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
334
+ if (pid === process.pid) return true;
335
+ try {
336
+ process.kill(pid, 0);
337
+ return true;
338
+ } catch (err) {
339
+ return (err as NodeJS.ErrnoException)?.code === "EPERM"; // exists, other user
340
+ }
341
+ }
342
+
343
+ /** Stable per-boot id, so a recycled pid from before a reboot can't look alive.
344
+ * Compute ONE floor on the expressed seconds, not two — separate floors of
345
+ * walltime and uptime flip by ±1 around integer boundaries and would read a
346
+ * live marker as dead on a cross-second read. */
347
+ function bootId(): string {
348
+ return String(Math.floor((Date.now() - uptime() * 1000) / 1000));
262
349
  }
263
350
 
264
351
  /**
@@ -271,26 +358,49 @@ export function sweepStale(root: string): void {
271
358
  const sub = subagentsDir(root);
272
359
  if (!sub || !existsSync(sub)) return;
273
360
  const registered = worktreePaths(root);
361
+ // Unknown registration = every dir might be live. Deleting here would be the
362
+ // single most destructive thing this module can do; bail instead.
363
+ if (!registered) return;
364
+ // Empty run dirs are litter — a run dir holds nothing but its task dirs.
365
+ for (const runDir of readdirSync(sub, { withFileTypes: true })) {
366
+ if (!runDir.isDirectory()) continue;
367
+ const runPath = join(sub, runDir.name);
368
+ try {
369
+ if (readdirSync(runPath).length === 0) rmSync(runPath, { recursive: true, force: true });
370
+ } catch {
371
+ /* skip */
372
+ }
373
+ }
274
374
  for (const runDir of readDirs(sub)) {
275
375
  for (const taskDir of readDirs(join(sub, runDir))) {
276
376
  const dir = join(sub, runDir, taskDir);
277
377
  if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
278
378
  if (ownerAlive(dir)) continue; // claimed by a running session
279
379
  rmSync(dir, { recursive: true, force: true });
380
+ rmSync(ownerFile(dir), { force: true });
280
381
  }
281
382
  }
282
383
  prune(root);
283
384
  }
284
385
 
285
- /** Registered worktree paths. `-z` keeps paths verbatim (no C-quoting to undo). */
286
- function worktreePaths(root: string): string[] {
386
+ /** Registered worktree paths, or undefined when the listing failed (see above). */
387
+ function worktreePaths(root: string): string[] | undefined {
388
+ const listing = worktreeListing(root);
389
+ return listing?.filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
390
+ }
391
+
392
+ /** `worktree list --porcelain -z`; `-z` needs git ≥ 2.36, so fall back to the
393
+ * newline form (which C-quotes exotic paths — those simply won't match, and a
394
+ * non-match is safe: it only ever means "treat as live"). */
395
+ function worktreeListing(root: string): string[] | undefined {
287
396
  try {
288
- return git(root, ["worktree", "list", "--porcelain", "-z"])
289
- .split("\0")
290
- .filter((l) => l.startsWith("worktree "))
291
- .map((l) => l.slice("worktree ".length));
397
+ return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
292
398
  } catch {
293
- return [];
399
+ try {
400
+ return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
401
+ } catch {
402
+ return undefined; // unknown — callers must bail out
403
+ }
294
404
  }
295
405
  }
296
406