pi-crew 0.10.2 → 0.10.4

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.
Files changed (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import * as fs from "node:fs";
26
+ import { fieldsAfterComm, PROC_STAT_PPID_INDEX, PROC_STAT_STARTTIME_INDEX } from "./proc-stat.ts";
26
27
 
27
28
  export interface ZombieSubagent {
28
29
  pid: number;
@@ -32,6 +33,15 @@ export interface ZombieSubagent {
32
33
  /** Whether the recorded crew parent PID is still alive. */
33
34
  parentAlive: boolean;
34
35
  role: string | undefined;
36
+ /**
37
+ * Mux kind from PI_CREW_SURFACE ("tmux"|"herdr") when the worker booted in
38
+ * a surface pane (T12). Surface workers have NO heartbeat (T9 handoff) —
39
+ * env markers are the only identity signal, so the scanner never gates a
40
+ * surface entry on liveness telemetry.
41
+ */
42
+ surface?: "tmux" | "herdr";
43
+ /** Pane id from PI_CREW_SURFACE_PANE — doctor closes orphan panes by it. */
44
+ surfacePaneId?: string;
35
45
  rssKb: number;
36
46
  elapsedSec: number | undefined;
37
47
  cmd: string;
@@ -66,17 +76,12 @@ function readProcStat(pid: number): { ppid: number; elapsedSec: number | undefin
66
76
  try {
67
77
  const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
68
78
  // stat format: pid (comm) state ppid ... starttime ...
69
- // comm may contain spaces/parens, so parse from the LAST ')' backwards.
70
- const closeParen = stat.lastIndexOf(")");
71
- if (closeParen < 0) return undefined;
72
- const rest = stat
73
- .slice(closeParen + 2)
74
- .trim()
75
- .split(/\s+/);
76
- // rest[0] = state, rest[1] = ppid
77
- const ppid = Number.parseInt(rest[1] ?? "", 10);
78
- // starttime (clock ticks since boot) is field 22 in the full stat → index 19 in `rest`
79
- const starttimeTicksRaw = Number.parseInt(rest[19] ?? "", 10);
79
+ // Shared paren-aware cut (proc-stat.ts) — MUST index identically with
80
+ // surface-spawn's PI_CREW_PARENT_START_TIME capture.
81
+ const rest = fieldsAfterComm(stat);
82
+ if (!rest) return undefined;
83
+ const ppid = Number.parseInt(rest[PROC_STAT_PPID_INDEX] ?? "", 10);
84
+ const starttimeTicksRaw = Number.parseInt(rest[PROC_STAT_STARTTIME_INDEX] ?? "", 10);
80
85
  const starttimeTicks = Number.isFinite(starttimeTicksRaw) ? starttimeTicksRaw : undefined;
81
86
  const elapsedSec = computeElapsedSec(starttimeTicks);
82
87
  return { ppid: Number.isFinite(ppid) ? ppid : 0, elapsedSec };
@@ -184,12 +189,17 @@ export function scanZombieSubagents(): ZombieScanResult {
184
189
 
185
190
  const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
186
191
  const stat = readProcStat(pid);
192
+ // Surface identity (T12): strict allowlist — an unrelated PI_CREW_SURFACE
193
+ // value must not half-populate the fields doctor relies on for pane close.
194
+ const surface = environ.PI_CREW_SURFACE === "tmux" || environ.PI_CREW_SURFACE === "herdr" ? environ.PI_CREW_SURFACE : undefined;
187
195
  const entry: ZombieSubagent = {
188
196
  pid,
189
197
  ppid: stat?.ppid ?? 0,
190
198
  crewParentPid: Number.isFinite(crewParentPid) ? crewParentPid : 0,
191
199
  parentAlive: Number.isFinite(crewParentPid) && isPidAlive(crewParentPid),
192
200
  role: environ.PI_CREW_ROLE,
201
+ surface,
202
+ surfacePaneId: environ.PI_CREW_SURFACE_PANE || undefined,
193
203
  rssKb: readProcRssKb(pid),
194
204
  elapsedSec: stat?.elapsedSec,
195
205
  cmd: readProcCmdline(pid),
@@ -256,11 +266,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
256
266
  lines.push(`### Zombies — parent dead (${scan.zombies.length})`);
257
267
  lines.push("These sub-agents are orphaned. Safe to kill after review:");
258
268
  lines.push("");
259
- lines.push(" PID PARENT RSS ROLE CMD");
269
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
260
270
  for (const z of scan.zombies) {
261
- lines.push(
262
- ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${z.cmd.slice(0, 60)}`,
263
- );
271
+ lines.push(formatZombieRow(z));
264
272
  }
265
273
  lines.push("");
266
274
  }
@@ -269,11 +277,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
269
277
  lines.push(`### Live — parent still running (${scan.live.length})`);
270
278
  lines.push("NOT zombies. Do not kill (parent PID is alive and may still reap them).");
271
279
  lines.push("");
272
- lines.push(" PID PARENT RSS ROLE CMD");
280
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
273
281
  for (const l of scan.live) {
274
- lines.push(
275
- ` ${String(l.pid).padEnd(9)}${String(l.crewParentPid).padEnd(8)}${formatRss(l.rssKb).padEnd(10)}${(l.role ?? "?").padEnd(14)}${l.cmd.slice(0, 60)}`,
276
- );
282
+ lines.push(formatZombieRow(l));
277
283
  }
278
284
  lines.push("");
279
285
  }
@@ -288,6 +294,13 @@ export function formatZombieReport(scan: ZombieScanResult): string {
288
294
  return lines.join("\n");
289
295
  }
290
296
 
297
+ /** One table row — shared by the zombie + live sections so columns stay aligned. */
298
+ function formatZombieRow(z: ZombieSubagent): string {
299
+ // SURFACE = "kind:paneId" (T12) — the pane doctor closes when orphaned.
300
+ const surface = z.surface ? `${z.surface}:${z.surfacePaneId ?? "?"}` : "-";
301
+ return ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${surface.padEnd(16)}${z.cmd.slice(0, 44)}`;
302
+ }
303
+
291
304
  function formatRss(kb: number): string {
292
305
  if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
293
306
  if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
@@ -8,26 +8,26 @@
8
8
  *
9
9
  * Gate dimensions (each with a distinct machine-readable reason + fail-fast
10
10
  * message; evaluated in this order — cheapest/most-global first):
11
- * 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
12
- * 2. role-denied — the PARENT task's role is not executor-class
13
- * (read-only roles cannot raise spawn privileges),
14
- * or the requested grandchild role is not one of
15
- * the delegate-tool's allowed roles
16
- * 3. trust-denied — untrusted escalation context (manual-only trust
11
+ * 1. trust-denied — untrusted escalation context (manual-only trust
17
12
  * gate; the handler resolves trust, policy enforces)
18
- * 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
19
- * resolved maxDepth (default 2). Depth comes from
13
+ * 2. role-denied — the REQUESTED grandchild role is not one of the
14
+ * delegate-tool's allowed roles (the parent role is
15
+ * NO LONGER gated — D8: every role may delegate;
16
+ * the spawn-side checkCrewDepth cap is the boundary)
17
+ * 3. depth-exceeded — child depth (parent.depth + 1) exceeds the
18
+ * resolved maxDepth (default 4). Depth comes from
20
19
  * the parent task RECORD, never worker env
21
20
  * (design §7 rev-2 P0-2)
22
- * 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
21
+ * 4. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
23
22
  * fail-fast, never queue
24
- * 6. budget-insufficient— requested budgetTokens exceed the parent task's
23
+ * 5. budget-insufficient— requested budgetTokens exceed the parent task's
25
24
  * remaining allocation (tokensGranted - tokensSpent)
26
- * 7. model-invalid — requested model is not in the resolved model
25
+ * 6. model-invalid — requested model is not in the resolved model
27
26
  * catalog (the unvalidated provider/model
28
27
  * pass-through — model-fallback.ts:282, the
29
28
  * 429-cascade root — must not be reachable here)
30
- * 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
29
+ * 7. timeout-invalid — timeoutSec outside 1..MAX (default 900)
30
+ * 8. workspace-conflict — write-capable grandchild narrowing cwd overlap
31
31
  */
32
32
 
33
33
  import { modelRefToString } from "./model/model-fallback.ts";
@@ -48,7 +48,6 @@ export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
48
48
  export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
49
49
 
50
50
  export type SpawnPolicyDenyReason =
51
- | "nesting-disabled"
52
51
  | "role-denied"
53
52
  | "trust-denied"
54
53
  | "depth-exceeded"
@@ -59,14 +58,14 @@ export type SpawnPolicyDenyReason =
59
58
  | "workspace-conflict";
60
59
 
61
60
  export interface DelegateAdmissionInput {
62
- /** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
63
- nestingEnabled: boolean;
64
- /** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
61
+ /** Resolved max depth (nesting.maxDepth default 4; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
65
62
  maxDepth: number;
66
63
  /** Parent task record fields the policy is allowed to trust. */
67
64
  parentTask: {
68
65
  taskId: string;
69
- role: string;
66
+ /** Role for workspace-capability evaluation (policy no longer gates the
67
+ * parent role — D8 opens delegation to every role). */
68
+ role?: string;
70
69
  /** Absent on pre-v2 records — treated as depth 1 (a worker). */
71
70
  depth?: number;
72
71
  allocation?: { tokensGranted?: number; tokensSpent?: number };
@@ -122,26 +121,13 @@ function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecisi
122
121
  }
123
122
 
124
123
  export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
125
- const { nestingEnabled, maxDepth, parentTask, requested } = input;
124
+ const { maxDepth, parentTask, requested } = input;
126
125
  const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
127
126
  const childDepth = parentDepth + 1;
128
127
 
129
- // 1. Flag gate — fail-closed default (ADR-5 §10).
130
- if (!nestingEnabled) {
131
- return deny(
132
- "nesting-disabled",
133
- "delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
134
- );
135
- }
136
-
137
- // 2. Role gate — parent must be executor-class; requested role must be in
138
- // the tool's allowed set.
139
- if (!isExecutorClassRole(parentTask.role)) {
140
- return deny(
141
- "role-denied",
142
- `delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
143
- );
144
- }
128
+ // 1. Role gate — the REQUESTED grandchild role must be in the tool's allowed
129
+ // set (the PARENT role is no longer gated — D8: every role may delegate;
130
+ // the spawn-side checkCrewDepth cap + slot budget below are the boundary).
145
131
  const requestedRole = requested?.role;
146
132
  if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
147
133
  return deny(
@@ -150,12 +136,12 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
150
136
  );
151
137
  }
152
138
 
153
- // 3. Trust gate — untrusted escalation context never spawns.
139
+ // 2. Trust gate — untrusted escalation context never spawns.
154
140
  if (input.untrusted) {
155
141
  return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
156
142
  }
157
143
 
158
- // 4. Depth gate — computed from the parent RECORD (never worker env).
144
+ // 3. Depth gate — computed from the parent RECORD (never worker env).
159
145
  if (childDepth > maxDepth) {
160
146
  return deny(
161
147
  "depth-exceeded",
@@ -163,7 +149,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
163
149
  );
164
150
  }
165
151
 
166
- // 5. Nested-slot budget — fail-fast, never queue.
152
+ // 4. Nested-slot budget — fail-fast, never queue.
167
153
  if (input.slots.used >= input.slots.max) {
168
154
  return deny(
169
155
  "slots-exhausted",
@@ -171,7 +157,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
171
157
  );
172
158
  }
173
159
 
174
- // 6. Parent allocation sufficiency — reserve up-front or reject.
160
+ // 5. Parent allocation sufficiency — reserve up-front or reject.
175
161
  if (requested?.budgetTokens !== undefined) {
176
162
  const granted = parentTask.allocation?.tokensGranted ?? 0;
177
163
  const spent = parentTask.allocation?.tokensSpent ?? 0;
@@ -184,7 +170,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
184
170
  }
185
171
  }
186
172
 
187
- // 7. Model validation against the resolved catalog.
173
+ // 6. Model validation against the resolved catalog.
188
174
  const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
189
175
  if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
190
176
  if (!input.modelCatalog.includes(normalizedModel)) {
@@ -192,7 +178,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
192
178
  }
193
179
  }
194
180
 
195
- // 8. Timeout validation (mandatory default 900).
181
+ // 7. Timeout validation (mandatory default 900).
196
182
  const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
197
183
  if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
198
184
  return deny(
@@ -201,7 +187,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
201
187
  );
202
188
  }
203
189
 
204
- // 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
190
+ // 8. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
205
191
  // the parent's cwd with ANOTHER in-flight executor is only admitted when
206
192
  // serialization is established (limits.serializeOnPathOverlap) — otherwise
207
193
  // reject; read-only grandchild roles (explorer/analyst) never conflict.
@@ -270,10 +270,35 @@ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
270
270
  // Compute elapsed from both sources and use the fresher (minimum) one,
271
271
  // mirroring heartbeat-watcher logic: if either source has recent activity,
272
272
  // the task is not stale even if the other is stale.
273
- const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Infinity;
274
- const activityAge = Number.isFinite(activityAt) ? now - activityAt : Infinity;
273
+ const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Number.MAX_SAFE_INTEGER;
274
+ const activityAge = Number.isFinite(activityAt) ? now - activityAt : Number.MAX_SAFE_INTEGER;
275
275
  const elapsed = Math.min(heartbeatAge, activityAge);
276
- return elapsed > NO_PID_HEARTBEAT_STALE_MS;
276
+ if (elapsed <= NO_PID_HEARTBEAT_STALE_MS) return false;
277
+ // F1 part 2 (battery 2026-09-10, live evidence run team_20260910171307): a
278
+ // surface worker's recorder only flushes at turn boundaries — one slow LLM
279
+ // turn (observed: 6.7min) freezes lastSeenAt/lastActivityAt while the worker
280
+ // process is alive and mid-turn. The heartbeat-watcher already has a
281
+ // PID-liveness gate for exactly this ("alive → downgrade dead→stale"); the
282
+ // reconciler MUST apply the same gate before repairing, or it kills healthy
283
+ // runs at the 5-minute mark mid-turn. A truly dead pid still repairs as before.
284
+ const taskPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
285
+ const pidAlive = taskPid ? checkProcessLiveness(taskPid).alive : false;
286
+ if (taskPid && pidAlive) return false;
287
+ if (process.env.PI_CREW_DEBUG_STALE === "1") {
288
+ // F1 forensic (battery 2026-09-10): sidecar log of every STALE verdict so
289
+ // live repros can show exactly what the reconciler saw (pid present? alive?
290
+ // elapsed?) — the reconciler may run in ANY host process, hence a fixed
291
+ // sidecar path instead of console.
292
+ try {
293
+ fs.appendFileSync(
294
+ "/tmp/pi-crew-f1-debug.log",
295
+ `${JSON.stringify({ ts: new Date().toISOString(), taskId: task.id, status: task.status, taskPid, pidAlive, heartbeatAge, activityAge, hbLastSeen: task.heartbeat?.lastSeenAt })}\n`,
296
+ );
297
+ } catch {
298
+ /* best-effort forensic log */
299
+ }
300
+ }
301
+ return true;
277
302
  }
278
303
 
279
304
  /**
@@ -22,6 +22,9 @@ export function recordSupervisorContact(manifest: TeamRunManifest, payload: Omit
22
22
  timestamp: new Date().toISOString(),
23
23
  };
24
24
  try {
25
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
26
+ // supervisor.contact events are read back synchronously (tests +
27
+ // contact-history display); low-frequency by design.
25
28
  appendEvent(manifest.eventsPath, {
26
29
  type: "supervisor.contact",
27
30
  runId: manifest.runId,