@arhen/pi-core-subagent 1.3.32 → 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.32",
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
@@ -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 {
@@ -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,16 @@ export class SubagentManager {
302
304
  child.dispose();
303
305
  }
304
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();
305
317
  // Ownership markers stay on disk; the next session reaps those dirs (commit,
306
318
  // keep branch, drop dir) once this pid is gone.
307
319
  this.liveWorktrees.clear();
@@ -519,6 +531,12 @@ export class SubagentManager {
519
531
  return {
520
532
  onAskParent: async (_taskId, question) => {
521
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
+ }
522
540
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
523
541
  this.liveChildren.get(key)?.touchWatchdog();
524
542
  // While the leader is parked in await_subagent the question rides the wait
@@ -534,6 +552,12 @@ export class SubagentManager {
534
552
  const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
535
553
  try {
536
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
+ }
537
561
  this.updateTask(run, task, { status: "running" }, ctx);
538
562
  this.liveChildren.get(key)?.touchWatchdog();
539
563
  return reply;
@@ -578,28 +602,32 @@ export class SubagentManager {
578
602
  private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
579
603
  const key = `${runId}:${taskId}`;
580
604
  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, {
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 = {
591
608
  resolve: (message) => {
592
609
  if (timer) clearTimeout(timer);
610
+ if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
593
611
  resolve(message);
594
612
  },
595
- });
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);
596
625
  });
597
626
  }
598
627
  deliverReply(runId: string, taskId: string, message: string): boolean {
599
628
  const pending = this.pendingReplies.get(`${runId}:${taskId}`);
600
629
  if (!pending) return false;
601
- this.pendingReplies.delete(`${runId}:${taskId}`);
602
- pending.resolve(message);
630
+ pending.resolve(message); // clears its own entry + timer
603
631
  return true;
604
632
  }
605
633
 
@@ -727,11 +755,14 @@ export class SubagentManager {
727
755
  // non-git repos fall back to in-place. Created BEFORE session start so the
728
756
  // child's cwd + AGENTS.md context chain are the worktree's.
729
757
  let wt: Worktree | undefined;
758
+ let isolationReason: string | undefined;
730
759
  if (canWrite) {
731
760
  try {
732
761
  wt = createWorktree(task.cwd, run.id, task.id);
733
- } catch {
762
+ if (!wt) isolationReason = "not a git repository";
763
+ } catch (err) {
734
764
  wt = undefined; // git failure → in-place
765
+ isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
735
766
  }
736
767
  }
737
768
  // Map a per-task cwd subpath into the worktree so relative paths stay correct.
@@ -741,14 +772,28 @@ export class SubagentManager {
741
772
  let childCwd = wt?.path ?? task.cwd;
742
773
  if (wt) {
743
774
  const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
744
- if (rel.startsWith("..")) {
775
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
745
776
  removeWorktree(wt);
746
777
  wt = undefined;
747
778
  childCwd = task.cwd;
779
+ isolationReason = "task cwd is outside the repository";
748
780
  } else if (rel && rel !== ".") {
749
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
+ }
750
789
  }
751
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
+ }
752
797
  if (wt) {
753
798
  claimWorktree(wt); // pid marker: another pi session must not reap this
754
799
  this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
@@ -891,17 +936,12 @@ export class SubagentManager {
891
936
  // until the branch is merged — cleanupMerged removes both then.
892
937
  // Commit/diff failures must NOT downgrade a completed task or destroy
893
938
  // its work: the error is reported, the status stays completed.
939
+ let committed: "committed" | "empty" | undefined;
894
940
  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
- );
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";
905
945
  } catch (commitErr) {
906
946
  // Never drop a checkout whose work isn't on the branch — it would be
907
947
  // unreachable once the base-tip branch is reaped as "merged".
@@ -911,12 +951,36 @@ export class SubagentManager {
911
951
  task,
912
952
  {
913
953
  branch: wt.branch,
914
- error: `Worktree commit failed (uncommitted 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)}`,
915
955
  },
916
956
  ctx,
917
957
  onUpdate,
918
958
  );
919
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
+ }
920
984
  }
921
985
  }
922
986
  } catch (err) {
@@ -957,12 +1021,16 @@ export class SubagentManager {
957
1021
  // dir — dropping it would make the work unreachable.
958
1022
  if (wt && task.status !== "completed") {
959
1023
  await new Promise((r) => setTimeout(r, 250));
1024
+ let partial: "committed" | "empty" | undefined;
960
1025
  try {
961
- commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
1026
+ partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
962
1027
  } catch {
963
- keepWorktreeDir = true;
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);
964
1033
  }
965
- this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
966
1034
  }
967
1035
  if (wt && !keepWorktreeDir) removeWorktree(wt);
968
1036
  // Released only after the dir is gone: while it exists, the branch must stay
@@ -1122,13 +1190,14 @@ export class SubagentManager {
1122
1190
  // Broken-upstream tasks are detected by the scheduler; mark them after the wave.
1123
1191
  for (const s of skipped) {
1124
1192
  const task = run.tasks.find((t) => t.id === s.id);
1125
- if (task) {
1193
+ if (task && !TERMINAL.includes(task.status)) {
1126
1194
  this.updateTask(
1127
1195
  run,
1128
1196
  task,
1129
1197
  {
1130
1198
  status: "aborted",
1131
- 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(", ")}`,
1132
1201
  endedAt: Date.now(),
1133
1202
  },
1134
1203
  ctx,
@@ -1136,6 +1205,19 @@ export class SubagentManager {
1136
1205
  );
1137
1206
  }
1138
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
+ }
1139
1221
 
1140
1222
  const failed = run.tasks.some((t) => t.status === "failed");
1141
1223
  const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
@@ -1290,15 +1372,30 @@ export class SubagentManager {
1290
1372
  }
1291
1373
 
1292
1374
  /** Child→leader messages collected while the parent is parked in await_subagent. */
1293
- 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 }>>();
1294
1378
 
1295
1379
  /** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
1296
1380
  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;
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;
1302
1399
  }
1303
1400
 
1304
1401
  awaitRun(
@@ -1307,8 +1404,12 @@ export class SubagentManager {
1307
1404
  ): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
1308
1405
  const run = this.runs.get(runId);
1309
1406
  if (!run) return Promise.resolve(undefined);
1407
+ let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
1310
1408
  const finish = (): void => {
1311
- 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);
1312
1413
  };
1313
1414
  if (TERMINAL.includes(run.status)) {
1314
1415
  run.awaited = true;
@@ -1331,17 +1432,28 @@ export class SubagentManager {
1331
1432
  waiters.add(waiter);
1332
1433
  // A child→leader message while parked wakes the wait: the leader gets it
1333
1434
  // IN the await result, no steering queue, no turn boundary needed.
1334
- this.parked.set(runId, { msgs, wake: () => waiter(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);
1335
1442
  });
1336
1443
  if (timeoutMs !== undefined && timeoutMs > 0) {
1444
+ let timedOut = false;
1337
1445
  return Promise.race([
1338
1446
  settled.then((r) => {
1339
1447
  finish();
1340
- 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;
1341
1452
  return { run: r, intercom: msgs };
1342
1453
  }),
1343
1454
  new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
1344
1455
  const timer = setTimeout(() => {
1456
+ timedOut = true;
1345
1457
  finish();
1346
1458
  resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
1347
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
@@ -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,8 +269,10 @@ 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 {
@@ -242,25 +292,45 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
242
292
  */
243
293
  export function claimWorktree(wt: Worktree): void {
244
294
  try {
245
- writeFileSync(join(wt.path, ".subagent-owner"), String(process.pid));
295
+ writeFileSync(
296
+ ownerFile(wt.path),
297
+ JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
298
+ );
246
299
  } catch {
247
300
  /* best-effort: worst case another session reaps it after a crash */
248
301
  }
249
302
  }
250
303
 
251
- /** True when the worktree dir is claimed by a process that still exists. */
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
+ */
252
309
  export function ownerAlive(path: string): boolean {
310
+ let marker: { pid?: number; host?: string; boot?: string };
253
311
  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;
312
+ marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
259
313
  } catch {
260
- return false;
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
261
326
  }
262
327
  }
263
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
+
264
334
  /**
265
335
  * Remove worktree dirs that git no longer knows about (partial-crash leftovers).
266
336
  * Registered worktrees are never touched here — `reapDeadWorktrees` owns those,
@@ -271,26 +341,39 @@ export function sweepStale(root: string): void {
271
341
  const sub = subagentsDir(root);
272
342
  if (!sub || !existsSync(sub)) return;
273
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;
274
347
  for (const runDir of readDirs(sub)) {
275
348
  for (const taskDir of readDirs(join(sub, runDir))) {
276
349
  const dir = join(sub, runDir, taskDir);
277
350
  if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
278
351
  if (ownerAlive(dir)) continue; // claimed by a running session
279
352
  rmSync(dir, { recursive: true, force: true });
353
+ rmSync(ownerFile(dir), { force: true });
280
354
  }
281
355
  }
282
356
  prune(root);
283
357
  }
284
358
 
285
- /** Registered worktree paths. `-z` keeps paths verbatim (no C-quoting to undo). */
286
- 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 {
287
369
  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));
370
+ return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
292
371
  } catch {
293
- 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
+ }
294
377
  }
295
378
  }
296
379