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
@@ -0,0 +1,720 @@
1
+ /**
2
+ * surface-worker.ts — worker-side surface lifecycle (spec §5.2 D7 + §5.3).
3
+ *
4
+ * Runs INSIDE a surface worker pane (the worker is a full pi session that also
5
+ * loads this extension). Three independently-gated blocks, registered by
6
+ * `registerSurfaceWorkerLifecycle` from prompt-runtime:
7
+ *
8
+ * A. RECORDER (gate: PI_CREW_SURFACE + PI_CREW_AGENT_EVENTS_PATH) — a
9
+ * surface worker has NO stdout JSON stream (`stripHeadlessModeArgs`
10
+ * removes it), so nothing would reach `agents/<taskId>/events.jsonl` and
11
+ * the agent-view overlay would render an empty pane. The recorder
12
+ * subscribes the in-process session event stream instead and writes the
13
+ * SAME `{seq,time,event}` JSONL lines the host writes for headless
14
+ * workers (crew-agent-records.ts appendCrewAgentEvent), using the same
15
+ * compaction (compactChildPiEvent) so ONE parser — agent-transcript.ts —
16
+ * reads both shapes. seq continues after whatever is already in the log
17
+ * (tail scan = nextAgentEventSeq semantics): that reader filters
18
+ * `seq > sinceSeq`, so restarting at 1 over an older attempt's log would
19
+ * hide every new line forever.
20
+ *
21
+ * B. AUTO-EXIT (gate: PI_CREW_AUTO_EXIT=1, spec D7) — when the agent run has
22
+ * fully settled on a naturally-finished turn and NOTHING is pending
23
+ * (ask / delegate / steer), append the terminal run-level event
24
+ * `worker.completed` FIRST (D7 ordering: report before dying), abort any
25
+ * in-flight turn, then shut the session down (which closes the pane). A
26
+ * settled `stopReason:"error"` emits a capped `worker.error` instead and
27
+ * keeps the pane open for inspection.
28
+ *
29
+ * C. PARENT-GUARD (gate: PI_CREW_PARENT_PID [+ surface/auto-exit context]) —
30
+ * a 5s poll: if the parent disappears (pid gone OR starttime mismatch —
31
+ * the PID-reuse Critical-4 fix) emit `worker.parent-lost` and terminate
32
+ * like (B) so no orphaned pane keeps burning tokens.
33
+ *
34
+ * The stopReason contract differs from the spec's literal wording on purpose:
35
+ * pi normalizes provider stop reasons to StopReason = "pending"|"stop"|
36
+ * "length"|"toolUse"|"error"|"aborted"|"deferred" (@earendil-works/pi-ai).
37
+ * A finished task arrives as "stop"; there is no "done"/"end_turn" value
38
+ * today, but both aliases are accepted so a future pi surfacing them keeps
39
+ * working. error/aborted/length turns deliberately stay alive: the pane stays
40
+ * open for inspection and the host watchdog (taskTimeoutMs / degrade path)
41
+ * owns that lifecycle.
42
+ *
43
+ * Verified decisions (2026-08-26):
44
+ * - Shutdown uses ExtensionContext.shutdown() ("Gracefully shutdown pi and
45
+ * exit", pi extensions types.d.ts; interactive-mode.js binds it via
46
+ * shutdownHandler and process.exit(0)s after emitting session_shutdown) —
47
+ * the same seam pi-interactive-subagents/subagent-done.ts calls from
48
+ * inside handlers. process.exit(0) remains a logged last-resort fallback.
49
+ * - Event recording uses extension API hooks (message_end /
50
+ * tool_execution_start / tool_execution_end): they expose exactly what the
51
+ * host-side stdout bridge consumes. Tailing pi's own session file (the
52
+ * spec §11.2 fallback) would duplicate offsets handling for less data.
53
+ * - fsync is intentionally omitted: each record is one synchronous O_APPEND
54
+ * write of a single line and the consumer reads the same machine's page
55
+ * cache, so it sees the bytes immediately. fsync only matters for power
56
+ * loss, and a per-terminal-write fsync re-introduces the measured ~13ms
57
+ * stall flagged in perf round 3 for no reader-visible gain.
58
+ */
59
+
60
+ import * as fs from "node:fs";
61
+ import * as path from "node:path";
62
+
63
+ import { getCrewEnv } from "../config/env-vars.ts";
64
+ import type { ExtensionAPI } from "../extension/pi-api.ts";
65
+ import { compactChildPiEvent } from "../runtime/child-pi/child-pi-streams.ts";
66
+ import { procStartTimeTicks } from "../runtime/process/proc-stat.ts";
67
+ import { logInternalError } from "../utils/internal-error.ts";
68
+ import { redactSecrets } from "../utils/redaction.ts";
69
+ import { createWorkerEventsChannel } from "./worker-events-channel.ts";
70
+
71
+ /** Worker-owned per-agent events log (prepareSurfaceSpawn derives the path). */
72
+ export const PI_CREW_AGENT_EVENTS_PATH_ENV = "PI_CREW_AGENT_EVENTS_PATH";
73
+ /** "1" → auto-exit (D7): terminate the session after the final settled turn. */
74
+ export const PI_CREW_AUTO_EXIT_ENV = "PI_CREW_AUTO_EXIT";
75
+ /** Surface kind written by prepareSurfaceSpawn ("tmux" | "herdr"). */
76
+ export const PI_CREW_SURFACE_ENV = "PI_CREW_SURFACE";
77
+ /** Parent starttime ticks (field 22 of /proc/<pid>/stat) captured at spawn. */
78
+ export const PI_CREW_PARENT_START_TIME_ENV = "PI_CREW_PARENT_START_TIME";
79
+
80
+ /** Poll cadence of the parent-guard (spec §5.2: every 5s). */
81
+ export const PARENT_GUARD_INTERVAL_MS = 5000;
82
+ /**
83
+ * Gap between an idle-looking settled turn and the actual exit. It closes the
84
+ * race where a steer/inbox item was being handed to the queue while the turn
85
+ * wound down: 600ms spans one steering-file poll tick (500ms), so anything
86
+ * durable shows up as pending (or has already started its own run) BEFORE we
87
+ * decide to die.
88
+ */
89
+ export const AUTO_EXIT_SETTLE_CONFIRM_MS = 600;
90
+ /**
91
+ * `worker.error` rides the no-rate-limit terminal path, so an error loop
92
+ * (model keeps failing across steered retries) needs its own bound. After the
93
+ * cap the turn keeps failing silently — task/usage budgets and the host
94
+ * watchdog remain the real stoppers.
95
+ */
96
+ export const WORKER_ERROR_EVENT_CAP = 5;
97
+
98
+ // ── shouldAutoExit ────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Stop reasons that mean "the task finished": pi's normalized `"stop"` plus
102
+ * the two aliases the spec names (defensive forward-compat — pi never emits
103
+ * them today).
104
+ */
105
+ export const AUTO_EXIT_STOP_REASONS: ReadonlySet<string> = new Set(["stop", "end_turn", "done"]);
106
+
107
+ export interface AutoExitSignals {
108
+ /** pi StopReason of the LAST assistant message. */
109
+ stopReason?: string | null;
110
+ askPending?: boolean;
111
+ delegatesRunning?: boolean;
112
+ steersPending?: boolean;
113
+ }
114
+
115
+ /** Pure decision core of D7: exit ONLY on a natural finish with zero pending work. */
116
+ export function shouldAutoExit(signals: AutoExitSignals): boolean {
117
+ if (typeof signals.stopReason !== "string") return false;
118
+ if (!AUTO_EXIT_STOP_REASONS.has(signals.stopReason)) return false;
119
+ return !signals.askPending && !signals.delegatesRunning && !signals.steersPending;
120
+ }
121
+
122
+ // ── parent liveness ───────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Pure parent-liveness decision.
126
+ * - stat unreadable → parent is GONE (/proc ENOENT means dead on Linux).
127
+ * - readable + expected ticks present + mismatch → PID REUSE, treat as lost.
128
+ * - expected ticks absent (macOS, or spawn never recorded them) →
129
+ * alive-with-caveat: pid-only semantics. Never kills on SIGSTOP or an
130
+ * unreadable clock field — a false "alive" falls back to the host
131
+ * watchdog, while a false "dead" kills a healthy worker.
132
+ */
133
+ export function parentAlive(pid: number, expectedStartTime: string | undefined, readStat?: (pid: number) => string | undefined): boolean {
134
+ if (!Number.isFinite(pid) || pid <= 0) return false;
135
+ const reader =
136
+ readStat ??
137
+ ((p: number): string | undefined => {
138
+ try {
139
+ return fs.readFileSync(`/proc/${p}/stat`, "utf8");
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ });
144
+ let stat: string | undefined;
145
+ try {
146
+ stat = reader(pid);
147
+ } catch {
148
+ stat = undefined;
149
+ }
150
+ if (stat === undefined) return false;
151
+ if (!expectedStartTime) return true;
152
+ const observed = procStartTimeTicks(stat);
153
+ if (!observed) return true;
154
+ return observed === expectedStartTime;
155
+ }
156
+
157
+ // ── ask/delegate activity tracking ────────────────────────────────────────
158
+
159
+ export type WorkerActivityKind = "ask" | "delegate";
160
+
161
+ export interface WorkerActivityTracker {
162
+ begin(kind: WorkerActivityKind): void;
163
+ end(kind: WorkerActivityKind): void;
164
+ busy(): { askPending: boolean; delegatesRunning: boolean };
165
+ }
166
+
167
+ /** Counters backing the ask/delegate signals of shouldAutoExit. */
168
+ export function createWorkerActivityTracker(): WorkerActivityTracker {
169
+ const counts = { ask: 0, delegate: 0 };
170
+ return {
171
+ begin(kind) {
172
+ counts[kind] += 1;
173
+ },
174
+ end(kind) {
175
+ counts[kind] = Math.max(0, counts[kind] - 1);
176
+ },
177
+ busy() {
178
+ return { askPending: counts.ask > 0, delegatesRunning: counts.delegate > 0 };
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Wrap a tool definition so its in-flight state feeds the activity tracker.
185
+ * The ask/delegate tools stay untouched — wrapping replaces `execute` only,
186
+ * and a throwing execute still clears its flag via `finally`.
187
+ */
188
+ export function trackToolActivity<TTool extends { execute: (...args: never[]) => Promise<unknown> }>(
189
+ tool: TTool,
190
+ tracker: WorkerActivityTracker,
191
+ kind: WorkerActivityKind,
192
+ ): TTool {
193
+ async function trackedExecute(this: unknown, ...args: never[]): Promise<unknown> {
194
+ tracker.begin(kind);
195
+ try {
196
+ return await tool.execute.apply(this, args);
197
+ } finally {
198
+ tracker.end(kind);
199
+ }
200
+ }
201
+ return { ...tool, execute: trackedExecute };
202
+ }
203
+
204
+ // ── per-agent event recorder ──────────────────────────────────────────────
205
+
206
+ export interface AgentEventRecorderOptions {
207
+ eventsPath: string;
208
+ /** Default: one synchronous O_APPEND appendFileSync of the whole line. */
209
+ appendLine?: (path: string, line: string) => void;
210
+ now?: () => number;
211
+ /** Default: redactSecrets — the host applies the same filter to stdout records. */
212
+ redact?: (value: unknown) => unknown;
213
+ /**
214
+ * Override the automatic tail scan (tests / explicit continuation point).
215
+ * When absent, seq continues AFTER the highest `{seq}` already in the log.
216
+ */
217
+ seedSeq?: number;
218
+ /** Tail-reader override for the seed scan (tests). */
219
+ readTail?: (path: string, bytes: number) => string | undefined;
220
+ }
221
+
222
+ export interface RecorderStats {
223
+ written: number;
224
+ failed: number;
225
+ }
226
+
227
+ export interface TurnSnapshot {
228
+ resultText: string;
229
+ usage: Record<string, unknown>;
230
+ stopReason: string | undefined;
231
+ /** Provider error text of the latest failed assistant message (if any). */
232
+ errorMessage: string;
233
+ }
234
+
235
+ export interface AgentEventRecorder {
236
+ record(event: unknown): void;
237
+ /**
238
+ * Worker self-report through the SAME `{seq,time,event}` pipeline WITHOUT
239
+ * pi-event compaction (compaction would drop non-pi shapes like the §12.2
240
+ * `worker.started` run-level contract). Keeps seq monotonic so host tail
241
+ * consumers never see a duplicate id.
242
+ */
243
+ recordSelfReport(event: Record<string, unknown>): void;
244
+ turnSnapshot(): TurnSnapshot;
245
+ stats(): RecorderStats;
246
+ }
247
+
248
+ const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
249
+
250
+ function record(value: unknown): Record<string, unknown> {
251
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
252
+ }
253
+
254
+ function costTotal(usage: Record<string, unknown>): number {
255
+ const cost = record(usage.cost);
256
+ return num(cost.total ?? usage.cost);
257
+ }
258
+
259
+ function assistantText(message: Record<string, unknown>): string {
260
+ const content = Array.isArray(message.content) ? message.content : [];
261
+ return content
262
+ .flatMap((part) => {
263
+ const item = record(part);
264
+ return item.type === "text" && typeof item.text === "string" ? [item.text] : [];
265
+ })
266
+ .join("\n")
267
+ .trim();
268
+ }
269
+
270
+ function mergeUsage(into: Record<string, unknown>, incoming: Record<string, unknown>): void {
271
+ into.input = num(into.input) + num(incoming.input);
272
+ into.output = num(into.output) + num(incoming.output);
273
+ into.cacheRead = num(into.cacheRead) + num(incoming.cacheRead);
274
+ into.cacheWrite = num(into.cacheWrite) + num(incoming.cacheWrite);
275
+ into.cost = { total: costTotal(into) + costTotal(incoming) };
276
+ }
277
+
278
+ /** How much of an existing log's tail the seed scan reads (256KB ≈ hundreds of lines). */
279
+ export const SEQ_SEED_TAIL_BYTES = 262_144;
280
+
281
+ function defaultReadTail(target: string, bytes: number): string | undefined {
282
+ try {
283
+ const fd = fs.openSync(target, "r");
284
+ try {
285
+ const size = fs.fstatSync(fd).size;
286
+ if (size === 0) return "";
287
+ const start = Math.max(0, size - bytes);
288
+ const buffer = Buffer.alloc(size - start);
289
+ fs.readSync(fd, buffer, 0, buffer.length, start);
290
+ return buffer.toString("utf-8");
291
+ } finally {
292
+ fs.closeSync(fd);
293
+ }
294
+ } catch {
295
+ return undefined; // absent / unreadable log — nothing to continue from
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Highest `{seq}` in the last `tailBytes` of an existing per-agent log.
301
+ * Host writers (crew-agent-records) append sequentially, so the tail always
302
+ * holds the maximum; this is what lets a re-attempt CONTINUE the numbering —
303
+ * `readCrewAgentEventsCursor` filters `event.seq > sinceSeq`, so restarting at
304
+ * 1 over an older log would make every new line permanently invisible to the
305
+ * pane reader. Absent/corrupt data yields 0 (start fresh at 1).
306
+ */
307
+ export function lastSeqInLog(
308
+ eventsPath: string,
309
+ deps?: { readTail?: (path: string, bytes: number) => string | undefined; tailBytes?: number },
310
+ ): number {
311
+ const readTail = deps?.readTail ?? defaultReadTail;
312
+ const raw = readTail(eventsPath, deps?.tailBytes ?? SEQ_SEED_TAIL_BYTES);
313
+ if (!raw) return 0;
314
+ let max = 0;
315
+ for (const line of raw.split("\n")) {
316
+ if (!line.trim()) continue;
317
+ try {
318
+ const parsed = record(JSON.parse(line));
319
+ const candidate = typeof parsed.seq === "number" && Number.isInteger(parsed.seq) ? parsed.seq : NaN;
320
+ if (Number.isFinite(candidate) && candidate > max) max = candidate;
321
+ } catch {
322
+ /* partial or corrupt line — skipped */
323
+ }
324
+ }
325
+ return max;
326
+ }
327
+
328
+ /**
329
+ * Worker-side mirror of appendCrewAgentEvent: bounded `{seq,time,event}` JSONL
330
+ * lines into the per-agent events log. seq CONTINUES from whatever is already
331
+ * on disk (tail scan above), mirroring nextAgentEventSeq's monotonic contract;
332
+ * a fresh file simply starts at 1. Uses the exact host-side compaction, so
333
+ * agent-transcript.ts parses headless and surface logs identically. Write
334
+ * failures are counted, never thrown — telemetry must not take down a live
335
+ * worker.
336
+ */
337
+ export function createAgentEventRecorder(options: AgentEventRecorderOptions): AgentEventRecorder {
338
+ const { eventsPath } = options;
339
+ const now = options.now ?? Date.now;
340
+ const redact = options.redact ?? redactSecrets;
341
+ const appendLine =
342
+ options.appendLine ??
343
+ ((target: string, line: string) => {
344
+ try {
345
+ fs.appendFileSync(target, line, "utf-8");
346
+ } catch (error) {
347
+ if ((error as NodeJS.ErrnoException | null)?.code !== "ENOENT" || dirEnsured) throw error;
348
+ fs.mkdirSync(path.dirname(target), { recursive: true });
349
+ dirEnsured = true;
350
+ fs.appendFileSync(target, line, "utf-8");
351
+ }
352
+ });
353
+
354
+ // Same layout contract as the host writer: agents/<taskId>/ may not exist
355
+ // yet (the host normally creates it via status.json — never assume). The
356
+ // default writer creates it lazily on the first ENOENT, so injected sinks
357
+ // (tests) and already-existing dirs never trigger filesystem side effects.
358
+ let dirEnsured = false;
359
+
360
+ let seq =
361
+ options.seedSeq !== undefined
362
+ ? Math.max(0, options.seedSeq)
363
+ : lastSeqInLog(eventsPath, options.readTail ? { readTail: options.readTail } : undefined);
364
+ let lastAssistantText = "";
365
+ let lastStopReason: string | undefined;
366
+ let lastErrorMessage = "";
367
+ const usage: Record<string, unknown> = {};
368
+ const stats = { written: 0, failed: 0 };
369
+
370
+ // Fold compacted events into the running turn summary backing
371
+ // `worker.completed` / `worker.error` (final text, cumulative usage,
372
+ // final stopReason / provider error).
373
+ const observeTurnState = (compacted: Record<string, unknown>): void => {
374
+ // Event-level usage wins over message-level (a usage-only tail record
375
+ // carries the delta), and only one side contributes — merging both
376
+ // would double-count pi messages that repeat their own usage.
377
+ const eventUsage = record(compacted.usage);
378
+ const message = record(compacted.message);
379
+ const delta = Object.keys(eventUsage).length > 0 ? eventUsage : message.usage !== undefined ? record(message.usage) : null;
380
+ if (delta) mergeUsage(usage, delta);
381
+
382
+ if (message.role !== "assistant") return;
383
+ const text = assistantText(message);
384
+ if (text) lastAssistantText = text;
385
+ if (typeof message.errorMessage === "string" && message.errorMessage.trim()) lastErrorMessage = message.errorMessage.trim();
386
+ if (typeof message.stopReason === "string") lastStopReason = message.stopReason;
387
+ else if (typeof compacted.stopReason === "string") lastStopReason = compacted.stopReason;
388
+ };
389
+
390
+ return {
391
+ record(rawEvent: unknown): void {
392
+ const compactedValue = compactChildPiEvent(rawEvent);
393
+ if (compactedValue === undefined) return;
394
+ const compacted = record(compactedValue);
395
+ observeTurnState(compacted);
396
+
397
+ seq += 1;
398
+ let line: string;
399
+ try {
400
+ line = `${JSON.stringify(redact({ seq, time: new Date(now()).toISOString(), event: compacted }))}\n`;
401
+ } catch {
402
+ // Unserializable payload — drop the RECORD but keep advancing seq
403
+ // so consumers never see a duplicated id.
404
+ stats.failed += 1;
405
+ return;
406
+ }
407
+ try {
408
+ appendLine(eventsPath, line);
409
+ stats.written += 1;
410
+ } catch (error) {
411
+ stats.failed += 1;
412
+ logInternalError("prompt-runtime.surface-recorder-write", error as Error, `eventsPath=${eventsPath}`);
413
+ }
414
+ },
415
+ recordSelfReport(selfEvent: Record<string, unknown>): void {
416
+ seq += 1;
417
+ let line: string;
418
+ try {
419
+ line = `${JSON.stringify(redact({ seq, time: new Date(now()).toISOString(), event: selfEvent }))}\n`;
420
+ } catch {
421
+ stats.failed += 1;
422
+ return;
423
+ }
424
+ try {
425
+ appendLine(eventsPath, line);
426
+ stats.written += 1;
427
+ } catch (error) {
428
+ stats.failed += 1;
429
+ logInternalError("prompt-runtime.surface-recorder-write", error as Error, `eventsPath=${eventsPath}`);
430
+ }
431
+ },
432
+ turnSnapshot(): TurnSnapshot {
433
+ return { resultText: lastAssistantText, usage: { ...usage }, stopReason: lastStopReason, errorMessage: lastErrorMessage };
434
+ },
435
+ stats(): RecorderStats {
436
+ return { ...stats };
437
+ },
438
+ };
439
+ }
440
+
441
+ // ── wiring ────────────────────────────────────────────────────────────────
442
+
443
+ /** The slice of ExtensionContext the lifecycle relies on (kept structural so
444
+ * older pi runtimes degrade instead of throwing). */
445
+ interface LifecycleCtx {
446
+ isIdle?: () => boolean;
447
+ hasPendingMessages?: () => boolean;
448
+ abort?: () => void;
449
+ shutdown?: () => void;
450
+ }
451
+
452
+ export interface SurfaceWorkerDeps {
453
+ /** Env source override (tests). Production reads via getCrewEnv. */
454
+ env?: NodeJS.ProcessEnv;
455
+ now?: () => number;
456
+ /** Per-agent JSONL append sink (tests inject a collector). */
457
+ appendLine?: (path: string, line: string) => void;
458
+ /** Run-level terminal-event sink (tests inject a collector). Default:
459
+ * emitTerminal + immediate flush on the WP-9 worker-events channel —
460
+ * flush-before-shutdown is the D7 durability order. */
461
+ emitRunEvent?: (type: string, data: Record<string, unknown>) => void;
462
+ /** /proc reader override for parentAlive (tests). */
463
+ readStat?: (pid: number) => string | undefined;
464
+ setIntervalFn?: (fn: () => void, ms: number) => unknown;
465
+ clearIntervalFn?: (timer: unknown) => void;
466
+ setTimeoutFn?: (fn: () => void, ms?: number) => unknown;
467
+ clearTimeoutFn?: (timer: unknown) => void;
468
+ /** Last-resort exit when pi hands us no shutdown-capable ctx (tests spy). */
469
+ exit?: (code: number) => never;
470
+ /** Shared ask/delegate counters owned by prompt-runtime (so wrapping the
471
+ * tools feeds the signals even when this lifecycle is dormant). */
472
+ activity?: WorkerActivityTracker;
473
+ }
474
+
475
+ export interface SurfaceWorkerHandle {
476
+ recorder: AgentEventRecorder;
477
+ activity: WorkerActivityTracker;
478
+ dispose(): void;
479
+ }
480
+
481
+ /**
482
+ * Register all three blocks against a worker's ExtensionAPI. Returns
483
+ * undefined when no gate is present (main user sessions and other non-team
484
+ * contexts) so nothing is armed there.
485
+ */
486
+ export function registerSurfaceWorkerLifecycle(
487
+ pi: Pick<ExtensionAPI, "on">,
488
+ deps: SurfaceWorkerDeps = {},
489
+ ): SurfaceWorkerHandle | undefined {
490
+ const get = (name: string): string | undefined => (deps.env ? deps.env[name] : getCrewEnv(name));
491
+ const surfaceKind = get(PI_CREW_SURFACE_ENV);
492
+ const agentEventsPath = get(PI_CREW_AGENT_EVENTS_PATH_ENV);
493
+ const autoExitEnabled = get(PI_CREW_AUTO_EXIT_ENV) === "1";
494
+ const parentPid = Number.parseInt(get("PI_CREW_PARENT_PID") ?? "", 10);
495
+ const parentStartTime = get(PI_CREW_PARENT_START_TIME_ENV);
496
+
497
+ const recorderActive = Boolean(surfaceKind && agentEventsPath);
498
+ if (surfaceKind && !agentEventsPath) {
499
+ // Surface without a per-agent log = an empty agent-view pane later;
500
+ // say so now instead of debugging from the missing transcript.
501
+ logInternalError(
502
+ "prompt-runtime.surface-worker-config",
503
+ new Error("PI_CREW_SURFACE set without PI_CREW_AGENT_EVENTS_PATH — recording disabled"),
504
+ undefined,
505
+ "warn",
506
+ );
507
+ }
508
+ // The guard rides along ONLY with surface/auto-exit context: arming it for
509
+ // every crew worker would change headless + async-run lifecycles (their
510
+ // parents outlive the run by design), which A1 does not own.
511
+ const guardArmed = Number.isFinite(parentPid) && parentPid > 0 && (Boolean(surfaceKind) || autoExitEnabled);
512
+ if (!recorderActive && !autoExitEnabled && !guardArmed) return undefined;
513
+
514
+ const now = deps.now ?? Date.now;
515
+ const timers = {
516
+ setInterval: deps.setIntervalFn ?? ((fn: () => void, ms: number) => setInterval(fn, ms)),
517
+ clearInterval: deps.clearIntervalFn ?? ((timer: unknown) => clearInterval(timer as ReturnType<typeof setInterval>)),
518
+ setTimeout: deps.setTimeoutFn ?? ((fn: () => void, ms?: number) => setTimeout(fn, ms)),
519
+ clearTimeout: deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>)),
520
+ };
521
+
522
+ const channel = createWorkerEventsChannel({ env: deps.env });
523
+ const emitRunEvent =
524
+ deps.emitRunEvent ??
525
+ ((type: string, data: Record<string, unknown>) => {
526
+ // D7 ordering, spelled out: append the terminal event synchronously,
527
+ // drain any queued retry buffer, THEN hand control to shutdown.
528
+ channel.emitTerminal(type, data);
529
+ channel.flush();
530
+ });
531
+
532
+ const recorder = createAgentEventRecorder({
533
+ eventsPath: agentEventsPath ?? "",
534
+ appendLine: deps.appendLine ?? undefined,
535
+ now,
536
+ });
537
+ const activity = deps.activity ?? createWorkerActivityTracker();
538
+
539
+ if (recorderActive) {
540
+ // §12.2 worker.started (terminal-path, no rate limit): the ONLY host-visible
541
+ // signal of "this pane has a live worker" — doctor's zombie sweep and the
542
+ // T11 degrade manifest (`workerPids`/`sessionPaths`) read it. sessionPath
543
+ // stays optional (pi does not expose it to extensions in A1).
544
+ const startedData: Record<string, unknown> = {
545
+ pid: process.pid,
546
+ surface: surfaceKind,
547
+ surfacePaneId: get("PI_CREW_SURFACE_PANE"),
548
+ };
549
+ recorder.recordSelfReport({ type: "worker.started", ...startedData });
550
+ emitRunEvent("worker.started", startedData);
551
+ }
552
+
553
+ // pi hands every handler an ExtensionContext; keep the freshest one around,
554
+ // because the parent-guard fires OUTSIDE any handler yet still needs
555
+ // ctx.shutdown().
556
+ let latestCtx: LifecycleCtx | undefined;
557
+ let terminated = false;
558
+ let confirmTimer: unknown;
559
+ let guardTimer: unknown;
560
+ // worker.error rides the no-rate-limit terminal path; cap it so a task
561
+ // stuck in an error loop cannot flood the shared run log.
562
+ let errorReports = 0;
563
+
564
+ const stopParentGuard = (): void => {
565
+ if (guardTimer === undefined) return;
566
+ timers.clearInterval(guardTimer);
567
+ guardTimer = undefined;
568
+ };
569
+ const clearConfirmTimer = (): void => {
570
+ if (confirmTimer === undefined) return;
571
+ timers.clearTimeout(confirmTimer);
572
+ confirmTimer = undefined;
573
+ };
574
+
575
+ const requestShutdown = (): void => {
576
+ const shutdown = latestCtx?.shutdown;
577
+ if (typeof shutdown === "function") {
578
+ shutdown.call(latestCtx);
579
+ return;
580
+ }
581
+ logInternalError(
582
+ "prompt-runtime.surface-worker-shutdown",
583
+ new Error("no ctx.shutdown() available — falling back to process.exit"),
584
+ undefined,
585
+ "warn",
586
+ );
587
+ (deps.exit ?? ((code: number) => process.exit(code)))(0);
588
+ };
589
+
590
+ /**
591
+ * Terminal path shared by auto-exit and parent-lost: report FIRST (D7 —
592
+ * the append is synchronous), then abort any in-flight turn and shut down.
593
+ *
594
+ * abort() before shutdown() matters when the parent dies MID-TURN:
595
+ * pi defers a shutdown request until the current agent loop finishes
596
+ * (interactive-mode binds shutdownHandler as `if idle → shutdown`), which
597
+ * could leave the pane frozen for minutes while the recorder has already
598
+ * stopped. Aborting the operation makes that deferred shutdown immediate.
599
+ */
600
+ const terminate = (type: string, data: Record<string, unknown>): void => {
601
+ if (terminated) return;
602
+ terminated = true;
603
+ stopParentGuard();
604
+ clearConfirmTimer();
605
+ emitRunEvent(type, data);
606
+ if (typeof latestCtx?.abort === "function") {
607
+ try {
608
+ latestCtx.abort();
609
+ } catch (error) {
610
+ logInternalError("prompt-runtime.surface-worker-abort", error as Error, undefined, "warn");
611
+ }
612
+ }
613
+ requestShutdown();
614
+ };
615
+
616
+ /** Deferred confirmation armed by agent_settled (runs off the 600ms window). */
617
+ const confirmSettle = (): boolean => {
618
+ clearConfirmTimer();
619
+ if (terminated) return false;
620
+
621
+ // steersPending is really "the session still has work queued or
622
+ // running": pi keeps hasPendingMessages precise, and isIdle guards the
623
+ // case where a queued steer already STARTED its own agent run.
624
+ const ctx = latestCtx;
625
+ const steersPending = Boolean(ctx?.hasPendingMessages?.()) || (typeof ctx?.isIdle === "function" ? !ctx.isIdle() : false);
626
+
627
+ const snapshot = recorder.turnSnapshot();
628
+ // §12.2: surface failed turns so the run shows WHY it stalled — but do
629
+ // NOT exit on them (the pane stays open for inspection; the host
630
+ // watchdog owns that lifecycle).
631
+ if (snapshot.stopReason === "error") {
632
+ errorReports += 1;
633
+ if (errorReports <= WORKER_ERROR_EVENT_CAP) {
634
+ emitRunEvent("worker.error", {
635
+ errorMessage: snapshot.errorMessage || snapshot.resultText || "agent turn ended with stopReason=error",
636
+ usage: snapshot.usage,
637
+ stopReason: snapshot.stopReason,
638
+ });
639
+ }
640
+ return false;
641
+ }
642
+
643
+ if (
644
+ !shouldAutoExit({
645
+ stopReason: snapshot.stopReason,
646
+ ...activity.busy(),
647
+ steersPending,
648
+ })
649
+ ) {
650
+ return false;
651
+ }
652
+ terminate("worker.completed", {
653
+ result: snapshot.resultText,
654
+ usage: snapshot.usage,
655
+ stopReason: snapshot.stopReason ?? "",
656
+ });
657
+ return true;
658
+ };
659
+
660
+ if (autoExitEnabled) {
661
+ pi.on("agent_settled", (_event, ctx) => {
662
+ if (terminated) return;
663
+ latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
664
+ // Anything obviously still in flight → skip; the next settled event
665
+ // re-evaluates with fresh signals (no timer churn while asking).
666
+ const busy = activity.busy();
667
+ if (busy.askPending || busy.delegatesRunning || ctx.hasPendingMessages?.()) return;
668
+ clearConfirmTimer();
669
+ confirmTimer = timers.setTimeout(() => {
670
+ confirmSettle();
671
+ }, AUTO_EXIT_SETTLE_CONFIRM_MS);
672
+ });
673
+ }
674
+
675
+ if (guardArmed) {
676
+ guardTimer = timers.setInterval(() => {
677
+ if (terminated) return;
678
+ if (parentAlive(parentPid, parentStartTime, deps.readStat)) return;
679
+ terminate("worker.parent-lost", { parentPid, expectedStartTicks: parentStartTime ?? "" });
680
+ }, PARENT_GUARD_INTERVAL_MS);
681
+ (guardTimer as { unref?: () => void })?.unref?.();
682
+ }
683
+
684
+ // Block A: recorder (in-process session stream → per-agent JSONL).
685
+ if (recorderActive) {
686
+ // message_end carries assistant + toolResult messages (usage, stopReason);
687
+ // tool_execution_start/end carry the tool cards the pane folds them into.
688
+ const capture = (event: unknown): void => {
689
+ if (terminated) return;
690
+ recorder.record(event);
691
+ };
692
+ pi.on("tool_execution_start", capture);
693
+ pi.on("tool_execution_end", capture);
694
+ pi.on("message_end", capture);
695
+ }
696
+
697
+ // Teardown hygiene: never leak the timers across a quit/reload/resume.
698
+ pi.on("session_shutdown", (_event, ctx) => {
699
+ latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
700
+ stopParentGuard();
701
+ clearConfirmTimer();
702
+ });
703
+
704
+ // Capture a context EARLY: the parent-guard fires from its own timer, and
705
+ // grabbing ctx here means the shutdown seam exists even if the parent dies
706
+ // before the worker ever ran a turn.
707
+ pi.on("session_start", (_event, ctx) => {
708
+ latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
709
+ });
710
+
711
+ return {
712
+ recorder,
713
+ activity,
714
+ dispose() {
715
+ terminated = true;
716
+ stopParentGuard();
717
+ clearConfirmTimer();
718
+ },
719
+ };
720
+ }