pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. 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) {
@@ -214,13 +214,19 @@ export function startInterruptGuard(
214
214
  ): () => void {
215
215
  const controlPath = path.join(manifest.stateRoot, "foreground-control.json");
216
216
  // 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;
217
+ // PERF (2026-08-24): default 1000ms. Each tick does existsSync + readFileSync
218
+ // + JSON.parse for the whole background run, and the guard is best-effort
219
+ // interrupt LATENCY, not a correctness deadline — verified by reading the
220
+ // trigger body below: a late trigger merely delays the SIGTERM (children are
221
+ // SIGKILLed after HARD_KILL_MS=3s regardless) and no state machine aborts on
222
+ // a deadline when the guard fires late. Nothing downstream requires the old
223
+ // 250ms cadence, so trade up to 4×/s of fs+parse churn for 1×/s.
224
+ const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 1000;
219
225
  // RT-4 FIX: Module-local gate so the interrupt body runs only once per
220
226
  // 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
227
+ // interruptGuardInterval — each tick does a full
228
+ // terminateActiveChildPiProcesses sweep + sync appendEvent. The ack write
229
+ // stops the re-fire; this gate is defense-in-depth if
224
230
  // the ack write fails (e.g. transient fs error).
225
231
  let interruptHandled = false;
226
232
  const interval = setInterval(() => {
@@ -238,8 +244,8 @@ export function startInterruptGuard(
238
244
 
239
245
  // RT-4 FIX: Write acknowledged:true back to foreground-control.json
240
246
  // 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.
247
+ // (interruptGuardInterval). Must be sync because this is a setInterval
248
+ // polling callback — we cannot await in a polling callback.
243
249
  try {
244
250
  const reqs = parsed.requests ?? [];
245
251
  if (reqs.length > 0) {
@@ -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. */