@arhen/pi-core-subagent 1.3.38 → 1.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,12 @@ On completion the extension commits the child's changes (the child is told not t
147
147
  git merge --no-ff subagents/<run>/<task>
148
148
  ```
149
149
 
150
+ **Branch relationships — read this before merging.** A write task that `needs` a completed write task is **stacked**: its worktree branches from the upstream's branch, so the child actually sees the files its upstream wrote (basing on main `HEAD` would hand it a tree without them, and its merge would revert the upstream). The summary says `Stacked on <branch> — merge that branch FIRST`; merge in dependency order.
151
+
152
+ Same-wave write tasks are **siblings**: both branch from the same base, so they are independent, not stacked. When two siblings changed the same file the summary emits `CONFLICT RISK` naming the overlap — the second `git merge` will be a real 3-way. Siblings that touched *different* but coupled files (a schema and its consumer) merge cleanly and can still break at runtime; that one is on you.
153
+
154
+ **Dependencies are shared, not isolated.** `node_modules` is symlinked to the main checkout, so dependency writes escape the worktree: children are instructed never to install, upgrade, or delete deps. A task that genuinely needs a dependency change should edit the manifest and say so.
155
+
150
156
  Isolation follows the toolset the child actually receives: explicit `tools: ["bash", "edit", "write"]` earns a worktree even without `write: true`, and an agent file that narrows the child to read-only gets no branch at all.
151
157
 
152
158
  Cleanup, in order of trust:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.38",
3
+ "version": "1.3.39",
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
@@ -197,13 +197,23 @@ export class SubagentsWidget implements Component {
197
197
  }
198
198
  /** Blocking-call summary: full text, because the model asked for it. */
199
199
  /** Where a write child's edits went: a branch to merge, or straight into the tree. */
200
- function worktreeLine(task: TaskSnapshot): string {
200
+ function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
201
201
  const parts: string[] = [];
202
202
  if (task.branch) {
203
203
  const files = task.changedFiles?.length
204
204
  ? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
205
205
  : "";
206
206
  parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
207
+ if (task.stackedOn) parts.push(`Stacked on ${task.stackedOn} — merge that branch FIRST.`);
208
+ // Sibling branches are independent, not stacked: overlapping files mean the
209
+ // second merge is a real 3-way, and non-overlapping-but-coupled edits break
210
+ // silently. Both are computable from changedFiles, so say so.
211
+ const overlap = (siblings ?? [])
212
+ .filter((s) => s.id !== task.id && s.branch && !s.stackedOn && !task.stackedOn)
213
+ .flatMap((s) => (s.changedFiles ?? []).filter((f) => task.changedFiles?.includes(f)).map((f) => `${s.id}:${f}`));
214
+ if (overlap.length > 0) {
215
+ parts.push(`CONFLICT RISK — sibling branches touched the same file(s): ${truncateText(overlap.join(", "), 200)}`);
216
+ }
207
217
  } else if (task.isolation === "in-place") {
208
218
  parts.push(
209
219
  `Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
@@ -228,7 +238,7 @@ export function makeSummary(run: RunSnapshot): string {
228
238
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
229
239
  const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
230
240
  lines.push(
231
- `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
241
+ `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
232
242
  );
233
243
  }
234
244
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
package/src/index.ts CHANGED
@@ -129,7 +129,10 @@ export default function (pi: ExtensionAPI) {
129
129
  // A registered subagent worktree is a crash leftover UNLESS another pi
130
130
  // session still owns it (pid marker) — commit its work, keep the branch,
131
131
  // drop the dir. Then reap merged branches and dirs git no longer tracks.
132
- reapDeadWorktrees(root, ownerAlive);
132
+ // Pass real ownership: in a long-lived process (pi `/new`) the previous
133
+ // session's markers carry THIS pid, and trusting them made those dirs
134
+ // unreapable until the process exited.
135
+ reapDeadWorktrees(root, (p) => ownerAlive(p, manager.ownsWorktree));
133
136
  cleanupMerged(root, { skipBranches: manager.liveBranches() });
134
137
  sweepStale(root);
135
138
  } catch {
package/src/manager.ts CHANGED
@@ -57,7 +57,10 @@ import {
57
57
  export const DEFAULT_CONCURRENCY = 3;
58
58
  export const MAX_CONCURRENCY = 8;
59
59
  /** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
60
- const DEFAULT_RUNTIME_MS = 0;
60
+ /** Hard wall-clock ceiling per child. The stall watchdog is touched by every
61
+ * event, so a child stuck in a retry/compaction livelock emits forever and is
62
+ * never "stalled" — only a cap that events CANNOT reset bounds that. */
63
+ const DEFAULT_RUNTIME_MS = 3_600_000; // 1 h
61
64
  /** Last-resort hang detector, not a latency budget. A healthy child can be
62
65
  * silent for minutes (big-context upload, non-streamed reasoning, provider
63
66
  * retry backoff), so this is deliberately far above any normal quiet window —
@@ -248,9 +251,14 @@ export class SubagentManager {
248
251
  private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
249
252
  private widgetRuns: RunSnapshot[] = [];
250
253
  private eventSeq = 0;
251
- /** Sidecar write ordering: unique tmp names + last-writer-wins by sequence. */
254
+ /** Sidecar writes are serialized on this chain — ordering the DECISION isn't
255
+ * enough, two renames in flight can still land out of order. */
252
256
  private persistSeq = 0;
253
257
  private persistedSeq = 0;
258
+ private persistChain: Promise<unknown> = Promise.resolve();
259
+ /** Distinguishes managers sharing a pid (tests, SDK hosts with two sessions)
260
+ * so their tmp paths can't collide. */
261
+ private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
254
262
  /** Set by clearRuns — blocks late persists from erasing the sidecar. */
255
263
  private cleared = false;
256
264
 
@@ -425,18 +433,22 @@ export class SubagentManager {
425
433
  // ALL run history for the session on the next read. The tmp name must be
426
434
  // UNIQUE per write — a shared one lets two concurrent persists interleave
427
435
  // their bytes (unparseable sidecar) or rename an older snapshot last.
428
- const tmp = `${sidecar}.${process.pid}.${this.persistSeq++}.tmp`;
436
+ const seq = ++this.persistSeq;
437
+ const tmp = `${sidecar}.${process.pid}.${this.instanceNonce}.${seq}.tmp`;
429
438
  const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
430
- const seq = this.persistSeq;
431
- void writeFile(tmp, payload)
432
- .then(async () => {
433
- // Drop a snapshot that a newer persist already superseded, so a slow
434
- // write can't rename stale data over fresh data.
435
- if (seq < this.persistedSeq) return rm(tmp, { force: true });
439
+ // Serialized: each write+rename runs after the previous one finishes, so two
440
+ // renames can never be in flight and land out of order.
441
+ this.persistChain = this.persistChain.then(async () => {
442
+ // A newer snapshot already landed — this one is stale, don't write it.
443
+ if (seq < this.persistedSeq) return;
444
+ try {
445
+ await writeFile(tmp, payload);
446
+ await rename(tmp, sidecar);
436
447
  this.persistedSeq = seq;
437
- return rename(tmp, sidecar);
438
- })
439
- .catch(() => rm(tmp, { force: true }).catch(() => {})); // never leak a tmp, never throw
448
+ } catch {
449
+ await rm(tmp, { force: true }).catch(() => {}); // never leak a tmp, never throw
450
+ }
451
+ });
440
452
  } catch {
441
453
  /* ignore */
442
454
  }
@@ -825,7 +837,15 @@ export class SubagentManager {
825
837
  let isolationReason: string | undefined;
826
838
  if (canWrite) {
827
839
  try {
828
- wt = createWorktree(task.cwd, run.id, task.id);
840
+ // Stack on the upstream write task's branch, so a chained writer actually
841
+ // SEES the work it was told to build on. Read-only upstreams have no
842
+ // branch, so those stay based on HEAD.
843
+ const upstream = (task.needs ?? [])
844
+ .map((id) => run.tasks.find((t) => t.id === id))
845
+ .filter((t) => t?.branch && t.status === "completed")
846
+ .at(-1);
847
+ wt = createWorktree(task.cwd, run.id, task.id, upstream?.branch);
848
+ if (wt && upstream?.branch) task.stackedOn = upstream.branch;
829
849
  if (!wt) isolationReason = "not a git repository";
830
850
  } catch (err) {
831
851
  wt = undefined; // git failure → in-place
@@ -866,24 +886,31 @@ export class SubagentManager {
866
886
  this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
867
887
  }
868
888
 
869
- this.updateTask(
870
- run,
871
- task,
872
- {
873
- status: "starting",
874
- startedAt: Date.now(),
875
- // Upstream outputs were spliced in by the scheduler; the snapshot must show
876
- // the prompt the child actually receives.
877
- task: input.task,
878
- // RESOLVED model, not the request — an agent file's `model:` overrides it,
879
- // and this patch used to clobber the resolved id recorded above.
880
- model: model?.id ?? input.model,
881
- thinking,
882
- tools,
883
- },
884
- ctx,
885
- onUpdate,
886
- );
889
+ // Guarded: a throwing event listener or widget failure here would escape
890
+ // runChild BEFORE the try/finally that releases the worktree — leaking a
891
+ // checkout that every reaper then skips (registered + owned by a live pid).
892
+ try {
893
+ this.updateTask(
894
+ run,
895
+ task,
896
+ {
897
+ status: "starting",
898
+ startedAt: Date.now(),
899
+ // Upstream outputs were spliced in by the scheduler; the snapshot must show
900
+ // the prompt the child actually receives.
901
+ task: input.task,
902
+ // RESOLVED model, not the request — an agent file's `model:` overrides it,
903
+ // and this patch used to clobber the resolved id recorded above.
904
+ model: model?.id ?? input.model,
905
+ thinking,
906
+ tools,
907
+ },
908
+ ctx,
909
+ onUpdate,
910
+ );
911
+ } catch {
912
+ /* a listener/widget failure must not strand the checkout */
913
+ }
887
914
 
888
915
  // Set once the dir must outlive this call: committed work awaiting the
889
916
  // leader's merge, or a commit failure whose work exists ONLY in the dir.
@@ -897,9 +924,15 @@ export class SubagentManager {
897
924
 
898
925
  const key = `${run.id}:${task.id}`;
899
926
  try {
927
+ // node_modules is a SHARED symlink to the leader's real tree, so dep writes
928
+ // escape the worktree entirely and `rm -rf node_modules/` destroys the
929
+ // project's deps. The child is the only thing that can avoid that.
930
+ const worktreeNote = wt
931
+ ? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
932
+ : "";
900
933
  const subagentInstruction = run.allowIntercom
901
- ? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`
902
- : `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`;
934
+ ? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`
935
+ : `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${worktreeNote}`;
903
936
 
904
937
  const loader = new DefaultResourceLoader({
905
938
  cwd: childCwd,
@@ -977,8 +1010,10 @@ export class SubagentManager {
977
1010
  ),
978
1011
  });
979
1012
 
980
- // auto-limit off = strip leader-imposed caps; tasks run unlimited until done.
981
- const maxRuntimeMs = this.autoLimit ? (input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS) : 0;
1013
+ // auto-limit off = drop the DEFAULT ceiling, but never an explicit request:
1014
+ // discarding the leader's own maxRuntimeMs removed the last escape from a
1015
+ // livelocked child.
1016
+ const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : 0);
982
1017
  const promptPromise = child.prompt(task.task, { source: "extension" });
983
1018
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
984
1019
  if (maxRuntimeMs > 0) {
@@ -1000,6 +1035,11 @@ export class SubagentManager {
1000
1035
  // TERMINAL, not just "aborted": a cancel/timeout that landed while the
1001
1036
  // final text was being assembled must not be overwritten with "completed"
1002
1037
  // (which would also skip the partial commit and drop the child's work).
1038
+ // `awaiting_parent` also means the child hadn't finished talking — marking
1039
+ // it completed would publish a truncated finalText to every dependent.
1040
+ if (task.status === "awaiting_parent") {
1041
+ this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
1042
+ }
1003
1043
  if (!TERMINAL.includes(task.status)) {
1004
1044
  this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
1005
1045
  if (wt) {
@@ -1134,13 +1174,14 @@ export class SubagentManager {
1134
1174
  // Single-mode-only fields alongside an array mode are silently dropped
1135
1175
  // otherwise: `write: true` next to tasks:[...] produced read-only children
1136
1176
  // that reported they "cannot edit files", with nothing explaining why.
1177
+ // `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
1178
+ // maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
1179
+ // defaults instead of being refused. The rest genuinely describe one agent.
1137
1180
  if (hasChain || hasTasks) {
1138
- const stray = (["write", "prompt", "tools", "model", "thinking", "cwd", "maxRuntimeMs"] as const).filter(
1139
- (k) => params[k] !== undefined,
1140
- );
1181
+ const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
1141
1182
  if (stray.length > 0) {
1142
1183
  throw new Error(
1143
- `${stray.join(", ")} ${stray.length > 1 ? "are" : "is"} only read in single mode. Set ${stray.length > 1 ? "them" : "it"} on each item of ${hasChain ? "chain" : "tasks"} instead.`,
1184
+ `${stray.join(", ")} ${stray.length > 1 ? "describe" : "describes"} a single agent. Set ${stray.length > 1 ? "them" : "it"} on each item of ${hasChain ? "chain" : "tasks"} instead.`,
1144
1185
  );
1145
1186
  }
1146
1187
  }
@@ -1160,9 +1201,12 @@ export class SubagentManager {
1160
1201
  maxRuntimeMs: params.maxRuntimeMs,
1161
1202
  },
1162
1203
  ]
1163
- : hasTasks
1164
- ? params.tasks!
1165
- : params.chain!;
1204
+ : (hasTasks ? params.tasks! : params.chain!).map((item) => ({
1205
+ // Run-wide defaults; a per-task value always wins.
1206
+ ...item,
1207
+ cwd: item.cwd ?? params.cwd,
1208
+ maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
1209
+ }));
1166
1210
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
1167
1211
  // Task ids become git refs + filesystem paths — refuse anything unsafe.
1168
1212
  // Explicit ids are checked against each other; generated ones are checked
@@ -1184,6 +1228,10 @@ export class SubagentManager {
1184
1228
  }
1185
1229
  }
1186
1230
  const edges = resolveNeeds(inputs, mode);
1231
+ // A new run exists, so persisting is meaningful again. Without this, a host
1232
+ // that emits session_shutdown with no following session_start (SIGTERM, SDK
1233
+ // reuse, tests) leaves every later persist a silent no-op.
1234
+ this.cleared = false;
1187
1235
  // Pre-flight every task's model BEFORE the run exists. A matched agent file
1188
1236
  // overrides the requested model, so an unresolvable one is the leader's
1189
1237
  // mistake to see NOW — not N children dying one by one on their first turn
@@ -1376,6 +1424,12 @@ export class SubagentManager {
1376
1424
  }
1377
1425
 
1378
1426
  /** Branches owned by worktrees of still-running children. */
1427
+ /** True only for checkouts THIS manager still runs — a marker carrying our own
1428
+ * pid from a previous session in the same process is not proof of life. */
1429
+ ownsWorktree = (path: string): boolean => {
1430
+ for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
1431
+ return false;
1432
+ };
1379
1433
  liveBranches(): Set<string> {
1380
1434
  return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
1381
1435
  }
package/src/types.ts CHANGED
@@ -53,6 +53,9 @@ export interface TaskSnapshot {
53
53
  * changes are already in the leader's tree — always surfaced, never silent. */
54
54
  isolation?: "worktree" | "in-place";
55
55
  isolationReason?: string;
56
+ /** Upstream branch this one was built on top of. Merge that one FIRST — these
57
+ * are stacked, not independent. Absent = branched from the base tree. */
58
+ stackedOn?: string;
56
59
  /** Worktree commit/diff trouble. Kept apart from `error` so a completed task
57
60
  * still reports its answer. */
58
61
  worktreeError?: string;
package/src/worktree.ts CHANGED
@@ -81,8 +81,15 @@ export function repoRoot(cwd: string): string | undefined {
81
81
  }
82
82
  }
83
83
 
84
- /** Create an isolated worktree for a write task. Returns undefined when not a git repo. */
85
- export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
84
+ /**
85
+ * Create an isolated worktree for a write task. Returns undefined when not a git repo.
86
+ *
87
+ * @param baseRef Branch/SHA to start from. A chained write task MUST pass its
88
+ * upstream's branch: basing on main HEAD hands the child a tree without the
89
+ * upstream's edits, so it "builds on" work it cannot see and its merge reverts
90
+ * the upstream.
91
+ */
92
+ export function createWorktree(cwd: string, runId: string, taskId: string, baseRef?: string): Worktree | undefined {
86
93
  const root = repoRoot(cwd);
87
94
  if (!root) return undefined;
88
95
  // --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
@@ -92,17 +99,30 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
92
99
  if (!container) return undefined;
93
100
  let base: string;
94
101
  try {
95
- base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
102
+ // Resolve to a SHA so a later commit on the ref can't skew the diff base.
103
+ base = git(root, ["rev-parse", baseRef ?? "HEAD"]);
96
104
  } catch {
97
- return undefined; // broken repo — fall back to in-place
105
+ if (!baseRef) return undefined; // broken repo — fall back to in-place
106
+ try {
107
+ base = git(root, ["rev-parse", "HEAD"]); // upstream branch gone — fall back to HEAD
108
+ } catch {
109
+ return undefined;
110
+ }
98
111
  }
99
112
  const path = join(container, runId, taskId);
100
113
  const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
101
114
  // Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
102
115
  // tree between the two would otherwise skew every later diff against base.
103
116
  git(root, ["worktree", "add", "-b", branch, path, base]);
104
- // Deps follow the child into the worktree; anything else the task needs is
105
- // project content already checked out there.
117
+ // Deps follow the child into the worktree so it can build without a reinstall.
118
+ //
119
+ // ponytail: this is a SHARED symlink to the main tree's node_modules, not a
120
+ // copy — the cheap option, and it escapes isolation. A child that runs an
121
+ // install, or `rm -rf node_modules/` (trailing slash follows the link),
122
+ // mutates the leader's real deps outside any branch. Ceiling accepted because
123
+ // copying/hardlinking node_modules per worktree costs GBs per task; the
124
+ // upgrade path is a per-worktree install on an explicit opt-in flag.
125
+ // Children are warned in their system prompt (see manager.ts childPrompt).
106
126
  const nm = join(root, "node_modules");
107
127
  if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
108
128
  try {
@@ -320,7 +340,7 @@ export function claimWorktree(wt: Worktree): void {
320
340
  * Guards against pid reuse across reboots (boot id) and other hosts (hostname);
321
341
  * EPERM means the pid exists under another user — alive, not reapable.
322
342
  */
323
- export function ownerAlive(path: string): boolean {
343
+ export function ownerAlive(path: string, ownedHere?: (path: string) => boolean): boolean {
324
344
  let marker: { pid?: number; host?: string; boot?: string };
325
345
  try {
326
346
  marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
@@ -331,7 +351,10 @@ export function ownerAlive(path: string): boolean {
331
351
  if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
332
352
  if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
333
353
  if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
334
- if (pid === process.pid) return true;
354
+ // Our OWN pid is not proof: a previous session in this same long-lived process
355
+ // (pi `/new`) leaves markers with this pid, and trusting them made those dirs
356
+ // unreapable for the process lifetime. Callers pass `isLive` for real knowledge.
357
+ if (pid === process.pid) return ownedHere?.(path) ?? true;
335
358
  try {
336
359
  process.kill(pid, 0);
337
360
  return true;