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
@@ -26,7 +26,7 @@
26
26
  * informational only — the scheduler never derives liveness from them.
27
27
  */
28
28
 
29
- import { appendFileSync, readFileSync } from "node:fs";
29
+ import { appendFileSync, closeSync, fstatSync, openSync, readSync } from "node:fs";
30
30
 
31
31
  export const WORKER_EVENT_TYPE_PATTERN = /^worker\.[a-z0-9_.-]{1,63}$/;
32
32
 
@@ -46,6 +46,8 @@ export interface WorkerEventsChannelOptions {
46
46
  export interface WorkerEventsChannel {
47
47
  /** Emit a worker.* event. false = dropped (schema/rate/buffer reasons). */
48
48
  emit(type: string, data: Record<string, unknown>): boolean;
49
+ /** Emit a terminal worker.* event (bypasses rate-limit; schema/buffer still apply). */
50
+ emitTerminal(type: string, data: Record<string, unknown>): boolean;
49
51
  /** Retry queued appends (called on the next emit internally). */
50
52
  flush(): void;
51
53
  /** Test introspection. */
@@ -67,8 +69,21 @@ export function createWorkerEventsChannel(options: WorkerEventsChannelOptions =
67
69
  // which the orchestrator reader skips).
68
70
  let prefix = "";
69
71
  try {
70
- const buf = readFileSync(eventsPath);
71
- if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) prefix = "\n";
72
+ // PERF (2026-08-24): 1-byte tail read (the comment above always
73
+ // described this; the implementation used to readFileSync the
74
+ // WHOLE file — O(file) reads up to 300x/min per worker for a
75
+ // single byte of information).
76
+ const fd = openSync(eventsPath, "r");
77
+ try {
78
+ const size = fstatSync(fd).size;
79
+ if (size > 0) {
80
+ const tail = Buffer.alloc(1);
81
+ readSync(fd, tail, 0, 1, size - 1);
82
+ if (tail[0] !== 0x0a) prefix = "\n";
83
+ }
84
+ } finally {
85
+ closeSync(fd);
86
+ }
72
87
  } catch {
73
88
  /* absent file — nothing to separate */
74
89
  }
@@ -163,6 +178,37 @@ export function createWorkerEventsChannel(options: WorkerEventsChannelOptions =
163
178
  suppressedSinceLast = 0;
164
179
  return true;
165
180
  },
181
+ emitTerminal(type: string, data: Record<string, unknown>): boolean {
182
+ if (!eventsPath || !runId) return false; // non-team context — no-op
183
+ if (!WORKER_EVENT_TYPE_PATTERN.test(type)) {
184
+ counters.droppedSchema++;
185
+ return false;
186
+ }
187
+ // NO sliding-window rate limit — terminal events bypass.
188
+ // Drain any queued items first (FIFO — oldest first), then the new one.
189
+ if (pending.length > 0) {
190
+ const carry = flushInternal(0);
191
+ if (carry > 0) {
192
+ // Writer failing: queue the new item under the FIFO cap.
193
+ if (pending.length >= bufferCap) {
194
+ pending.shift();
195
+ counters.droppedBuffer++;
196
+ }
197
+ pending.push({ type, data, droppedSinceLast: 0 });
198
+ return true; // accepted (queued)
199
+ }
200
+ }
201
+ if (write({ type, data, droppedSinceLast: 0 })) {
202
+ counters.accepted++;
203
+ return true;
204
+ }
205
+ if (pending.length >= bufferCap) {
206
+ pending.shift();
207
+ counters.droppedBuffer++;
208
+ }
209
+ pending.push({ type, data, droppedSinceLast: 0 });
210
+ return true;
211
+ },
166
212
  flush(): void {
167
213
  flushInternal(0);
168
214
  },
@@ -226,8 +226,36 @@ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
226
226
  // Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
227
227
  "PI_CREW_BG_REPORT_ON_FATAL",
228
228
  "PI_TEAMS_BG_REPORT_ON_FATAL",
229
+ // MuxSurface: multiplexer env the detached runner needs to DETECT and
230
+ // REACH the host's tmux/herdr — surface follows env + config for async runs
231
+ // too (2026-08-30 Finding 2: without these, every async run gates to no-mux
232
+ // and stays headless). None of these are secret-named (sanitizeEnvSecrets
233
+ // would reject them from the allowlist otherwise).
234
+ "TMUX",
235
+ "TMUX_PANE",
236
+ "TMUX_TMPDIR",
237
+ "HERDR_ENV",
238
+ "HERDR_SESSION",
239
+ "HERDR_PANE_ID",
240
+ "HERDR_SOCKET_PATH",
241
+ "HERDR_WORKSPACE_ID",
242
+ "HERDR_PING_TIMEOUT_MS",
229
243
  ];
230
244
 
245
+ /**
246
+ * Stamp the async telemetry marker onto the background runner's env.
247
+ * (MuxSurface async policy, 2026-08-30): `PI_CREW_ASYNC_RUN` is NOT a surface
248
+ * gate — surface follows env + `runtime.surface.*` config, same as sync runs.
249
+ * The marker stays for telemetry (`SurfaceGateEnvSnapshot.asyncRun`). Pane
250
+ * attach from a detached process is safe: parent-guard (PI_CREW_PARENT_PID)
251
+ * still reaps panes when the host dies.
252
+ *
253
+ * Exported for the unit test asserting the invariant.
254
+ */
255
+ export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
256
+ return { ...env, PI_CREW_ASYNC_RUN: "1" };
257
+ }
258
+
231
259
  export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
232
260
  // FIX (2026-07-02, perf review F-critical): use packageRoot() instead of
233
261
  // import.meta.url-relative path. The previous path.resolve walks
@@ -257,7 +285,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
257
285
  // `npm root -g` probe. No-op when pi-crew and pi are co-located. See
258
286
  // src/runtime/peer-dep.ts.
259
287
  const peerDepDir = resolvePeerDepDir();
260
- const childEnv = peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv;
288
+ const childEnv = buildBackgroundRunnerEnv(peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv);
261
289
 
262
290
  const loader = resolveTypeScriptLoader();
263
291
  if (!loader) {
@@ -5,7 +5,7 @@ import { loadConfig } from "../config/config.ts";
5
5
  import { getCrewEnv } from "../config/env-vars.ts";
6
6
  import { atomicWriteFile } from "../state/atomic-write.ts";
7
7
  import { withRunLockSync } from "../state/coordination/locks.ts";
8
- import { appendEvent, appendEventFireAndForget } from "../state/event-log/event-log.ts";
8
+ import { appendEvent, appendEventBuffered, appendEventFireAndForget } from "../state/event-log/event-log.ts";
9
9
  import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/stores/state-store.ts";
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
11
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
@@ -18,8 +18,6 @@ import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows
18
18
  import { primePeerDep } from "./peer-dep.ts";
19
19
  import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
20
20
 
21
- let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
22
-
23
21
  /** Maximum runtime for a single background run before the watchdog force-aborts
24
22
  * it. Prevents zombie background-runner processes when a team run hangs forever
25
23
  * (e.g. a hung child Pi process, a stuck lock, or a test that spawns a run
@@ -33,16 +31,13 @@ const MAX_BACKGROUND_RUN_MS = (() => {
33
31
  return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000;
34
32
  })();
35
33
  async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
36
- if (!_cachedExecuteTeamRun) {
37
- // FIX (split-scope install): prime the ESM peer dep BEFORE team-runner is
38
- // imported, so its transitive skill-instructions.ts can read getAgentDir()
39
- // from the primed cache instead of crashing on `Cannot find module`.
40
- await primePeerDep().catch(() => undefined);
41
- // LAZY: avoid pulling team-runner into background-runner at module load time.
42
- const mod = await import("./team-runner.ts");
43
- _cachedExecuteTeamRun = mod.executeTeamRun;
44
- }
45
- return _cachedExecuteTeamRun(...args);
34
+ // FIX (split-scope install): prime the ESM peer dep BEFORE team-runner is
35
+ // imported, so its transitive skill-instructions.ts can read getAgentDir()
36
+ // from the primed cache instead of crashing on `Cannot find module`.
37
+ await primePeerDep().catch(() => undefined);
38
+ // LAZY: avoid pulling team-runner into background-runner at module load time.
39
+ const mod = await import("./team-runner.ts");
40
+ return mod.executeTeamRun(...args);
46
41
  }
47
42
 
48
43
  import { logInternalError } from "../utils/internal-error.ts";
@@ -214,13 +209,19 @@ export function startInterruptGuard(
214
209
  ): () => void {
215
210
  const controlPath = path.join(manifest.stateRoot, "foreground-control.json");
216
211
  // FIX: Made configurable via PI_CREW_INTERRUPT_GUARD_INTERVAL_MS env var.
217
- // Default 250ms balances fast SIGINT response against filesystem overhead.
218
- const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 250;
212
+ // PERF (2026-08-24): default 1000ms. Each tick does existsSync + readFileSync
213
+ // + JSON.parse for the whole background run, and the guard is best-effort
214
+ // interrupt LATENCY, not a correctness deadline — verified by reading the
215
+ // trigger body below: a late trigger merely delays the SIGTERM (children are
216
+ // SIGKILLed after HARD_KILL_MS=3s regardless) and no state machine aborts on
217
+ // a deadline when the guard fires late. Nothing downstream requires the old
218
+ // 250ms cadence, so trade up to 4×/s of fs+parse churn for 1×/s.
219
+ const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 1000;
219
220
  // RT-4 FIX: Module-local gate so the interrupt body runs only once per
220
221
  // interrupt request. Without this, the guard re-fires every
221
- // interruptGuardInterval (250ms) — each tick does a full
222
- // terminateActiveChildPiProcesses sweep + sync appendEvent = ~4×/s steady
223
- // state. The ack write stops the re-fire; this gate is defense-in-depth if
222
+ // interruptGuardInterval — each tick does a full
223
+ // terminateActiveChildPiProcesses sweep + sync appendEvent. The ack write
224
+ // stops the re-fire; this gate is defense-in-depth if
224
225
  // the ack write fails (e.g. transient fs error).
225
226
  let interruptHandled = false;
226
227
  const interval = setInterval(() => {
@@ -238,8 +239,8 @@ export function startInterruptGuard(
238
239
 
239
240
  // RT-4 FIX: Write acknowledged:true back to foreground-control.json
240
241
  // SYNCHRONOUSLY. This stops the guard from re-firing on the next tick
241
- // (250ms). Must be sync because this is a setInterval polling callback
242
- // — we cannot await in a polling callback.
242
+ // (interruptGuardInterval). Must be sync because this is a setInterval
243
+ // polling callback — we cannot await in a polling callback.
243
244
  try {
244
245
  const reqs = parsed.requests ?? [];
245
246
  if (reqs.length > 0) {
@@ -250,11 +251,11 @@ export function startInterruptGuard(
250
251
  /* best-effort ack — interruptHandled gate prevents re-fire */
251
252
  }
252
253
 
253
- appendEvent(manifest.eventsPath, {
254
+ appendEventBuffered(manifest.eventsPath, {
254
255
  type: "async.interrupt_detected",
255
256
  runId: manifest.runId,
256
257
  message: "Background runner detected foreground interrupt — killing child processes and exiting.",
257
- });
258
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.interrupt_detected"));
258
259
  // FIX: Terminate ALL child-pi processes IMMEDIATELY before exiting.
259
260
  // Previously this was missing, causing orphaned child processes to run forever
260
261
  // after the background-runner exited. terminateActiveChildPiProcesses sends
@@ -305,7 +306,7 @@ function setupUnhandledRejectionGuard(
305
306
  console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
306
307
  try {
307
308
  if (state.eventsPath && state.runId) {
308
- appendEvent(state.eventsPath, {
309
+ appendEventBuffered(state.eventsPath, {
309
310
  type: "async.failed",
310
311
  runId: state.runId,
311
312
  message: `Unhandled rejection: ${message}`,
@@ -314,7 +315,7 @@ function setupUnhandledRejectionGuard(
314
315
  stack: reason instanceof Error ? reason.stack : undefined,
315
316
  handled: false,
316
317
  },
317
- });
318
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=unhandledRejection"));
318
319
  }
319
320
  } catch (appendErr) {
320
321
  console.error("[background-runner] Failed to write async.failed event:", appendErr);
@@ -371,12 +372,12 @@ function runCleanup(
371
372
  console.log(`[background-runner] runCleanup: unregisterWorker error: ${errorMessage(error)}`);
372
373
  if (eventsPath) {
373
374
  try {
374
- appendEvent(eventsPath, {
375
+ appendEventBuffered(eventsPath, {
375
376
  type: "background.unregister_worker_failed",
376
377
  runId: argValue("--run-id") ?? "unknown",
377
378
  message: `unregisterWorker failed: ${errorMessage(error)}`,
378
379
  data: { pid: process.pid },
379
- });
380
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=background.unregister_worker_failed"));
380
381
  } catch {
381
382
  /* best-effort */
382
383
  }
@@ -532,12 +533,12 @@ async function main(): Promise<void> {
532
533
  const runId = argValue("--run-id");
533
534
  if (runId && manifest.eventsPath) {
534
535
  try {
535
- appendEvent(manifest.eventsPath, {
536
+ appendEventBuffered(manifest.eventsPath, {
536
537
  type: "async.sigterm_received_graceful_shutdown",
537
538
  runId,
538
539
  message: `SIGTERM received, graceful shutdown via abort pid=${process.pid}`,
539
540
  data: { pid: process.pid, ppid: process.ppid },
540
- });
541
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.sigterm_received_graceful_shutdown"));
541
542
  } catch {
542
543
  /* best-effort */
543
544
  }
@@ -590,12 +591,12 @@ async function main(): Promise<void> {
590
591
  const codeStr = code === undefined ? "<none>" : String(code);
591
592
  if (runId2 && manifest.eventsPath) {
592
593
  try {
593
- appendEvent(manifest.eventsPath, {
594
+ appendEventBuffered(manifest.eventsPath, {
594
595
  type: "async.exit",
595
596
  runId: runId2,
596
597
  message: `Background runner exit(${codeStr}) pid=${process.pid}`,
597
598
  data: { code, pid: process.pid },
598
- });
599
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.exit"));
599
600
  } catch {
600
601
  /* best-effort */
601
602
  }
@@ -629,11 +630,11 @@ async function main(): Promise<void> {
629
630
  // NOTE: intentionally no unref() — the guard keeps the event loop alive
630
631
  // to prevent premature worker exit. See parent-guard.ts:86 for rationale.
631
632
 
632
- appendEvent(manifest.eventsPath, {
633
+ appendEventBuffered(manifest.eventsPath, {
633
634
  type: "async.started",
634
635
  runId: manifest.runId,
635
636
  data: { pid: process.pid },
636
- });
637
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.started"));
637
638
  debugLog(`[background-runner] async.started written, pid=${process.pid}`);
638
639
  writeAsyncStartMarker(manifest, {
639
640
  pid: process.pid,
@@ -657,12 +658,12 @@ async function main(): Promise<void> {
657
658
  const watchdogTimer = setTimeout(() => {
658
659
  console.error(`[background-runner] WATCHDOG: run ${runId} exceeded ${MAX_BACKGROUND_RUN_MS}ms — aborting (zombie prevention)`);
659
660
  try {
660
- appendEvent(manifest.eventsPath, {
661
+ appendEventBuffered(manifest.eventsPath, {
661
662
  type: "async.watchdog_fired",
662
663
  runId,
663
664
  message: `Run exceeded ${MAX_BACKGROUND_RUN_MS}ms and was force-aborted to prevent a zombie background-runner process.`,
664
665
  data: { maxRunMs: MAX_BACKGROUND_RUN_MS },
665
- });
666
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.watchdog_fired"));
666
667
  } catch {
667
668
  /* best-effort event log */
668
669
  }
@@ -819,12 +820,12 @@ async function main(): Promise<void> {
819
820
  updatedAt: new Date().toISOString(),
820
821
  };
821
822
  await saveRunManifestAsync(manifest);
822
- appendEvent(manifest.eventsPath, {
823
+ appendEventBuffered(manifest.eventsPath, {
823
824
  type: "runtime.resolved",
824
825
  runId: manifest.runId,
825
826
  message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
826
827
  data: { runtimeResolution, async: true },
827
- });
828
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=runtime.resolved"));
828
829
  if (runtime.safety === "blocked")
829
830
  throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
830
831
  const executeWorkers = runtime.kind !== "scaffold";
@@ -884,11 +885,11 @@ async function main(): Promise<void> {
884
885
  } // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
885
886
  manifest = result!.manifest;
886
887
  tasks = result!.tasks;
887
- appendEvent(manifest.eventsPath, {
888
+ appendEventBuffered(manifest.eventsPath, {
888
889
  type: "async.completed",
889
890
  runId: manifest.runId,
890
891
  data: { status: manifest.status, tasks: tasks.length },
891
- });
892
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.completed"));
892
893
  console.log(`[background-runner] async.completed written, status=${manifest.status}`);
893
894
  if (manifest.status === "failed" || manifest.status === "cancelled" || manifest.status === "blocked") process.exitCode = 1;
894
895
  } catch (error) {
@@ -909,11 +910,11 @@ async function main(): Promise<void> {
909
910
  const fresh = loaded?.manifest ?? manifest;
910
911
  if (fresh) {
911
912
  manifest = updateRunStatus(fresh, "failed", message);
912
- appendEvent(manifest.eventsPath, {
913
+ appendEventBuffered(manifest.eventsPath, {
913
914
  type: "async.failed",
914
915
  runId: manifest.runId,
915
916
  message,
916
- });
917
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=updateFailed"));
917
918
  }
918
919
  return fresh;
919
920
  },
@@ -982,12 +983,12 @@ try {
982
983
  const mRunId = argValue("--run-id");
983
984
  if (mCwd && mRunId) {
984
985
  const mEventsPath = createRunPaths(mCwd, mRunId).eventsPath;
985
- appendEvent(mEventsPath, {
986
+ appendEventBuffered(mEventsPath, {
986
987
  type: "async.failed",
987
988
  runId: mRunId,
988
989
  message: errorMessage(err),
989
990
  data: { stack: err instanceof Error ? err.stack : undefined },
990
- });
991
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=catch"));
991
992
  }
992
993
  } catch {
993
994
  /* best-effort — don't let event-write failure mask the original error */
@@ -28,8 +28,9 @@ export interface BrokerSpawnCredentials {
28
28
  * ADR-5 §4 (governed nesting): `childDepth` carries the DEPTH-2+ grandchild's
29
29
  * depth when the root-side delegate handler spawns it. The issuer mints only
30
30
  * for children that may themselves delegate (childDepth < resolved
31
- * PI_CREW_MAX_DEPTH) — at the default maxDepth=2 a depth-2 grandchild gets NO
32
- * credentials (env containment AC: no PI_CREW_BROKER_SOCKET/TOKEN at depth 2).
31
+ * PI_CREW_MAX_DEPTH) — at the default maxDepth=4 a depth-4 grandchild gets NO
32
+ * credentials (env containment AC: no PI_CREW_BROKER_SOCKET/TOKEN at the cap
33
+ * depth).
33
34
  * Undefined childDepth = legacy worker spawn (depth 1) — unchanged behavior. */
34
35
  export type BrokerIssuer = (runId: string, taskId?: string, childDepth?: number) => Promise<BrokerSpawnCredentials | undefined>;
35
36
 
@@ -44,3 +45,27 @@ export function setActiveBrokerIssuer(issuer: BrokerIssuer | undefined): void {
44
45
  export function getActiveBrokerIssuer(): BrokerIssuer | undefined {
45
46
  return activeIssuer;
46
47
  }
48
+
49
+ /**
50
+ * MuxSurface A1 (spec §7 D3 step 2): process-local revoker for per-task tokens.
51
+ * The team-runner's surface-degrade controller calls it when a pane is lost so
52
+ * the zombie worker left in that pane can no longer authenticate to the broker;
53
+ * the headless respawn mints a FRESH token (T10 re-issue).
54
+ *
55
+ * Same registration model as the issuer: the lifecycle controller publishes a
56
+ * closure on start and clears it on stop, so the degrade path needs no wiring
57
+ * through every runner layer. A function reference only — never a token.
58
+ */
59
+ export type BrokerRevoker = (taskId: string) => void;
60
+
61
+ let activeRevoker: BrokerRevoker | undefined;
62
+
63
+ /** Register the active revoker (called by the lifecycle controller on start). */
64
+ export function setActiveBrokerRevoker(revoker: BrokerRevoker | undefined): void {
65
+ activeRevoker = revoker;
66
+ }
67
+
68
+ /** Read the active revoker, if any. Returns undefined when no broker is wired. */
69
+ export function getActiveBrokerRevoker(): BrokerRevoker | undefined {
70
+ return activeRevoker;
71
+ }
@@ -10,7 +10,7 @@
10
10
  * independently testable.
11
11
  */
12
12
 
13
- import { randomUUID, timingSafeEqual } from "node:crypto";
13
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
14
14
 
15
15
  /** Length guard: tokens are 128-bit-class (UUID v4). */
16
16
  export type BrokerToken = string;
@@ -37,6 +37,21 @@ export function newBrokerToken(): BrokerToken {
37
37
  */
38
38
  export class BrokerTokenRegistry {
39
39
  private readonly map = new Map<string, BrokerToken>();
40
+ /** Task 10 (mux-surface A1 §5.2): sha256 hashes of explicitly revoked
41
+ * tokens. Heap-only like the map; hashing keeps the registry from holding
42
+ * a second plaintext copy of a secret. Revocation is per-SECRET, not
43
+ * per-key: `issue()` mints a fresh token when the registered one is in
44
+ * this set (fix round 1 BUG #1), so the A2 re-issue remedy always
45
+ * produces a live token while the old secret stays dead. */
46
+ private readonly revokedTokenHashes = new Set<string>();
47
+
48
+ /** Public so the broker can hash the hello secret ONCE at auth time and
49
+ * keep only the digest on the connection (fix round 2 BUG #3: the frame
50
+ * revocation check is secret-based — a connection authenticated with a
51
+ * revoked secret stays dead even after the key is re-issued). */
52
+ static hashToken(token: BrokerToken): string {
53
+ return createHash("sha256").update(token, "utf8").digest("hex");
54
+ }
40
55
 
41
56
  /** Compute the registry key. Compound when taskId is present, bare
42
57
  * runId otherwise (backward-compat with the original per-run model). */
@@ -72,8 +87,12 @@ export class BrokerTokenRegistry {
72
87
  /** Issue a token for `runId` (+optional `taskId`). Idempotent per key:
73
88
  * if a token already exists for the computed key, the existing token is
74
89
  * returned unchanged so that concurrent sibling tasks sharing a key all
75
- * authenticate with the same token. Pass an explicit `token` only in
76
- * tests that need a deterministic value. */
90
+ * authenticate with the same token — UNLESS that token was revoked (fix
91
+ * round 1, BUG #1): a revoked secret is never resurrected, a fresh one
92
+ * is minted and overwrites the entry (the degrade respawn flow re-issues
93
+ * after revokeTaskToken and the new worker must get a live token).
94
+ * Pass an explicit `token` only in tests that need a deterministic
95
+ * value. */
77
96
  issue(runId: string, taskId?: string, token?: BrokerToken): BrokerToken {
78
97
  if (typeof runId !== "string" || runId.length === 0) {
79
98
  throw new Error("BrokerTokenRegistry.issue: runId must be a non-empty string");
@@ -81,7 +100,9 @@ export class BrokerTokenRegistry {
81
100
  const k = this.key(runId, taskId);
82
101
  if (token === undefined) {
83
102
  const existing = this.map.get(k);
84
- if (existing !== undefined) return existing;
103
+ if (existing !== undefined && !this.revokedTokenHashes.has(BrokerTokenRegistry.hashToken(existing))) {
104
+ return existing;
105
+ }
85
106
  const fresh = newBrokerToken();
86
107
  this.map.set(k, fresh);
87
108
  return fresh;
@@ -184,9 +205,40 @@ export class BrokerTokenRegistry {
184
205
  if (!taskId) this.map.delete(this.orchestratorKey(runId));
185
206
  }
186
207
 
208
+ /** Task 10 (mux-surface A1 §5.2): mark `token` revoked. The next hello
209
+ * presenting it — and every subsequent frame on a connection already
210
+ * authenticated with it — is rejected with code "revoked". No-op on a
211
+ * non-string/empty input. */
212
+ revokeToken(token: BrokerToken): void {
213
+ if (typeof token !== "string" || token.length === 0) return;
214
+ this.revokedTokenHashes.add(BrokerTokenRegistry.hashToken(token));
215
+ }
216
+
217
+ /** Task 10: whether the token currently registered for the compound
218
+ * (runId, taskId) key has been revoked. False when no such key exists
219
+ * (undefined ids, orchestrator connections, legacy bare-runId fallbacks)
220
+ * and short-circuits while nothing was ever revoked. This is the HELLO
221
+ * path check — at hello time the candidate must still match the map, so
222
+ * key resolution is equivalent to secret resolution. */
223
+ isTaskTokenRevoked(runId: string | undefined, taskId: string | undefined): boolean {
224
+ if (this.revokedTokenHashes.size === 0 || !runId || !taskId) return false;
225
+ const token = this.map.get(this.key(runId, taskId));
226
+ return token !== undefined && this.revokedTokenHashes.has(BrokerTokenRegistry.hashToken(token));
227
+ }
228
+
229
+ /** Fix round 2 (BUG #3): whether a specific SECRET digest has been
230
+ * revoked. The post-hello frame check uses this — it must evaluate the
231
+ * secret the connection ACTUALLY authenticated with, not whatever token
232
+ * the key currently holds (a revoke → re-issue window must not let an
233
+ * old connection ride a freshly issued token). */
234
+ isSecretRevoked(hash: string): boolean {
235
+ return this.revokedTokenHashes.has(hash);
236
+ }
237
+
187
238
  /** Wipe every token. Called from CrewBroker.stop(). */
188
239
  clear(): void {
189
240
  this.map.clear();
241
+ this.revokedTokenHashes.clear();
190
242
  }
191
243
 
192
244
  /** Diagnostic — count of registered tokens. Never returns the tokens. */