pi-crew 0.11.22 → 0.11.24

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/agents/explorer.md +3 -3
  3. package/agents/librarian.md +3 -3
  4. package/dist/index.mjs +523 -520
  5. package/docs/dynamic-workflows.md +14 -2
  6. package/package.json +4 -4
  7. package/src/agents/discover-agents.ts +8 -2
  8. package/src/config/config-validation.ts +3 -1
  9. package/src/config/env-allowlist-registry.ts +5 -0
  10. package/src/config/env-vars.ts +9 -6
  11. package/src/config/role-tools.ts +7 -6
  12. package/src/extension/management.ts +7 -0
  13. package/src/extension/register.ts +15 -0
  14. package/src/extension/run-index.ts +24 -1
  15. package/src/extension/team-tool/destructive-gate.ts +208 -0
  16. package/src/extension/team-tool/goal-wrap.ts +11 -1
  17. package/src/extension/team-tool/goal.ts +13 -1
  18. package/src/extension/team-tool/respond.ts +8 -6
  19. package/src/extension/team-tool/run.ts +4 -4
  20. package/src/extension/team-tool.ts +10 -0
  21. package/src/observability/exporters/otlp-exporter.ts +11 -1
  22. package/src/prompt/prompt-runtime.ts +102 -18
  23. package/src/prompt/surface-worker.ts +13 -1
  24. package/src/runtime/broker/crew-broker-client.ts +7 -0
  25. package/src/runtime/broker/crew-broker.ts +113 -152
  26. package/src/runtime/broker/protocol/connection-state.ts +159 -1
  27. package/src/runtime/broker/protocol/events-replay.ts +50 -10
  28. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  29. package/src/runtime/child-pi/child-pi-constants.ts +9 -0
  30. package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
  31. package/src/runtime/child-pi/child-pi-streams.ts +19 -3
  32. package/src/runtime/child-pi/postmortem-registry.ts +8 -2
  33. package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
  34. package/src/runtime/compaction/tool-output-pruner.ts +20 -2
  35. package/src/runtime/crew-agent-records.ts +92 -17
  36. package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
  37. package/src/runtime/deadletter.ts +5 -1
  38. package/src/runtime/dispatch-batch.ts +60 -16
  39. package/src/runtime/effectiveness.ts +24 -1
  40. package/src/runtime/finalize-run.ts +12 -1
  41. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
  42. package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
  43. package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
  44. package/src/runtime/live-session/live-agent-control.ts +34 -14
  45. package/src/runtime/live-session/live-agent-manager.ts +20 -5
  46. package/src/runtime/live-session/live-session-runtime.ts +66 -81
  47. package/src/runtime/live-session/usage-bridge.ts +66 -2
  48. package/src/runtime/mcp-proxy.ts +10 -0
  49. package/src/runtime/model/model-fallback.ts +68 -0
  50. package/src/runtime/model/runtime-policy.ts +20 -6
  51. package/src/runtime/model/runtime-resolver.ts +5 -3
  52. package/src/runtime/orphan-worker-registry.ts +64 -24
  53. package/src/runtime/pi-spawn.ts +4 -1
  54. package/src/runtime/process/zombie-scanner.ts +6 -2
  55. package/src/runtime/recovery/crash-recovery.ts +35 -2
  56. package/src/runtime/scheduling/batch-barrier.ts +42 -5
  57. package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
  58. package/src/runtime/scheduling/scheduler.ts +74 -29
  59. package/src/runtime/stale-reconciler.ts +45 -6
  60. package/src/runtime/surface/herdr-provider.ts +54 -1
  61. package/src/runtime/surface/launch-script.ts +7 -1
  62. package/src/runtime/surface/resolve-surface.ts +4 -1
  63. package/src/runtime/surface/tmux-provider.ts +32 -2
  64. package/src/runtime/task-runner/child-executor.ts +3 -2
  65. package/src/runtime/workspace-lock.ts +52 -4
  66. package/src/state/contracts.ts +2 -0
  67. package/src/state/coordination/process-lock.ts +50 -0
  68. package/src/state/event-log/cursor.ts +24 -3
  69. package/src/state/event-log/event-log-rotation.ts +26 -2
  70. package/src/state/event-log/worker-atomic-writer.ts +5 -0
  71. package/src/state/stores/active-run-registry.ts +56 -5
  72. package/src/state/stores/ownership-map.ts +26 -7
  73. package/src/state/stores/plan-store.ts +30 -1
  74. package/src/state/stores/sqlite-run-state.ts +91 -1
  75. package/src/state/types.ts +5 -0
  76. package/src/ui/dashboard-panes/agents-pane.ts +4 -3
  77. package/src/ui/dashboard-panes/plan-pane.ts +5 -3
  78. package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
  79. package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
  80. package/src/ui/run-dashboard.ts +6 -4
  81. package/src/ui/run-snapshot-cache.ts +3 -2
  82. package/src/ui/settings-overlay.ts +68 -10
  83. package/src/ui/theme-discovery.ts +26 -7
  84. package/src/ui/transcript-viewer.ts +5 -3
  85. package/src/utils/visual.ts +84 -0
@@ -9,7 +9,8 @@
9
9
  */
10
10
 
11
11
  import type * as net from "node:net";
12
- import type { NdjsonDecoder } from "../../../utils/ndjson.ts";
12
+ import { logInternalError } from "../../../utils/internal-error.ts";
13
+ import { encodeBrokerFrame, type NdjsonDecoder } from "../../../utils/ndjson.ts";
13
14
  import type { GrandchildSpawnInput, GrandchildSpawnResult } from "../../delegate-spawn.ts";
14
15
  import type { WaitStatusCache } from "../wait-status-cache.ts";
15
16
 
@@ -22,6 +23,10 @@ export interface CrewBrokerOptions {
22
23
  maxFrameBytes?: number;
23
24
  /** Per-connection outbound queue cap. Default 256. */
24
25
  outboundQueueCap?: number;
26
+ /** R8-M1: per-connection queued-BYTES cap (frames can be up to
27
+ * MAX_BROKER_FRAME_BYTES each, so the frame cap alone bounds the queue
28
+ * at cap × 256 KiB ≈ 64 MiB in-app). Default 4 MiB. */
29
+ outboundQueueBytesCap?: number;
25
30
  /** Required: when false, start() is a no-op and the server never binds.
26
31
  * Lets the lifecycle controller install the broker unconditionally and
27
32
  * have a single kill switch. */
@@ -102,6 +107,9 @@ export interface ServerConnection {
102
107
  authedSecretHash?: string;
103
108
  /** Outbound queue of encoded frames awaiting drain. */
104
109
  outbound: Buffer[];
110
+ /** R8-M1: sum of `outbound[i].length` — maintained by writeOrQueue/
111
+ * drainOutbound so the queued-BYTES cap does not rescan the queue. */
112
+ queuedBytes: number;
105
113
  /** Set when the queue has hit the cap and a frame was dropped. */
106
114
  needsResync: boolean;
107
115
  /** Set when the connection is closing (idempotent). */
@@ -111,3 +119,153 @@ export interface ServerConnection {
111
119
  /** Monotonic seq counter for outbound events (diagnostic). */
112
120
  outboundSeq: number;
113
121
  }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Outbound queue management (moved from crew-broker.ts — M4 wc-gate ≤2000;
125
+ // pure move, no behavior change). The broker keeps thin delegating methods.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Default per-connection outbound queue cap (events). */
129
+ export const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
130
+
131
+ /** R8-M1 (2026-10-17): default per-connection queued-BYTES cap. The frame cap
132
+ * alone lets a subscribed-but-never-reading client buffer 256 × 256 KiB ≈
133
+ * 64 MiB in-app before the R6-5 typed close engages (kernel backpressure
134
+ * shifts frames off the queue, so the count rarely grows — the BYTES the
135
+ * process holds do). ~4 MiB bounds a stuck connection's memory while a
136
+ * healthy LAN client drains comfortably inside it. */
137
+ export const DEFAULT_OUTBOUND_QUEUE_BYTES = 4 * 1024 * 1024;
138
+
139
+ /** R6-5 (2026-10-16): typed protocol close-reason sent as a best-effort
140
+ * `protocol.close` event frame (direct write, jumps the saturated queue)
141
+ * before the connection is closed after a fanout/event frame was dropped
142
+ * at the queue cap. Clients observe the reason, reconnect, and replay via
143
+ * `events.since` (Phase 1.5). */
144
+ export const OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON = "outbound-overflow-resync";
145
+
146
+ /** R6-5: admission class for an outbound frame.
147
+ * - `false` — fanout/event class: drop-newest at cap → resync close.
148
+ * - `true` — close-reason class: bypass the cap, attempt an immediate
149
+ * direct write (existing `sendErrorAndClose` behavior).
150
+ * - `"response"` — request-response class (sendResult/sendError acks):
151
+ * bypass the drop-newest cap but stay IN the queue (ordering preserved,
152
+ * backpressure honored). Bounded by the client's in-flight request count
153
+ * — one response frame per request — so it can never storm the way a
154
+ * fanout burst can. Kept a `boolean | "response"` union (not a fresh
155
+ * enum) so the external FanoutWriters interface (mailbox-fanout.ts) keeps
156
+ * its `(conn, buf, force: boolean)` shape. */
157
+ export type OutboundAdmission = boolean | "response";
158
+
159
+ export interface OutboundQueueOpts {
160
+ outboundQueueCap?: number;
161
+ outboundQueueBytesCap?: number;
162
+ sessionId: string;
163
+ }
164
+ export interface OutboundQueueHooks {
165
+ onOverflow(conn: ServerConnection): void;
166
+ onClose(conn: ServerConnection): void;
167
+ }
168
+
169
+ export function writeOrQueueFrame(
170
+ conn: ServerConnection,
171
+ buf: Buffer,
172
+ force: OutboundAdmission,
173
+ opts: OutboundQueueOpts,
174
+ hooks: OutboundQueueHooks,
175
+ ): void {
176
+ if (conn.closed) return;
177
+ const cap = opts.outboundQueueCap ?? DEFAULT_OUTBOUND_QUEUE_CAP;
178
+ // R8-M1: queued-BYTES cap alongside the frame cap. Few-but-large frames
179
+ // (a slow reader behind a 256 KiB fanout) never trip a 256-frame cap;
180
+ // without this the process buffers up to ~64 MiB per stuck connection.
181
+ // Overflow takes the same typed-close path as the frame cap (resync +
182
+ // reconnect + replay) — applied to ALL admission classes because the
183
+ // connection is about to close anyway and the alternative is unbounded
184
+ // memory, not a delivered response.
185
+ const bytesCap = opts.outboundQueueBytesCap ?? DEFAULT_OUTBOUND_QUEUE_BYTES;
186
+ if (conn.queuedBytes + buf.length > bytesCap) {
187
+ hooks.onOverflow(conn);
188
+ return;
189
+ }
190
+ if (conn.outbound.length >= cap) {
191
+ if (force === "response") {
192
+ // R6-5(a): response frames queue PAST the cap instead of being
193
+ // drop-newest'd. Bounded: at most one response per in-flight
194
+ // request, and the client's request pipeline is serial — this can
195
+ // never grow unbounded the way a fanout burst can. Ordering is
196
+ // preserved (pushed at the tail; drainOutbound honors backpressure).
197
+ conn.outbound.push(buf);
198
+ conn.queuedBytes += buf.length;
199
+ drainOutboundFrames(conn, hooks);
200
+ return;
201
+ }
202
+ if (force) {
203
+ // Forced sends (e.g. close-reason) bypass the cap and attempt
204
+ // to flush directly; if the socket is busy they may still drop.
205
+ try {
206
+ conn.socket.write(buf);
207
+ } catch {
208
+ /* socket may have closed; the close handler will sweep. */
209
+ }
210
+ return;
211
+ }
212
+ // R6-5(b): fanout/event frame dropped at cap (drop-newest). The old
213
+ // `needsResync` marker was write-only (set, never read) — the claimed
214
+ // "pause + client notices" recovery did not exist, so live frames
215
+ // were silently dropped for the connection's remaining lifetime.
216
+ // Now the drop CLOSES the connection with a typed close-reason so
217
+ // the client reconnects and replays (events.since / msg.inbox).
218
+ hooks.onOverflow(conn);
219
+ return;
220
+ }
221
+ conn.outbound.push(buf);
222
+ conn.queuedBytes += buf.length;
223
+ drainOutboundFrames(conn, hooks);
224
+ }
225
+
226
+ export function drainOutboundFrames(conn: ServerConnection, hooks: OutboundQueueHooks): void {
227
+ while (conn.outbound.length > 0) {
228
+ const buf = conn.outbound[0];
229
+ if (buf === undefined) break;
230
+ // Try the write; if it returns false, wait for drain before pushing more.
231
+ try {
232
+ const ok = conn.socket.write(buf);
233
+ if (!ok) {
234
+ // Backpressure — re-arm on drain event.
235
+ conn.socket.once("drain", () => {
236
+ if (!conn.closed) drainOutboundFrames(conn, hooks);
237
+ });
238
+ return;
239
+ }
240
+ } catch {
241
+ // Write failed — close the connection (we already had it open).
242
+ hooks.onClose(conn);
243
+ return;
244
+ }
245
+ conn.outbound.shift();
246
+ conn.queuedBytes -= buf.length;
247
+ conn.outboundSeq += 1;
248
+ }
249
+ }
250
+
251
+ export function handleOutboundOverflowFrame(conn: ServerConnection, opts: OutboundQueueOpts, hooks: OutboundQueueHooks): void {
252
+ // Single-flight: fanoutMailboxMessage iterates a snapshot of the run's
253
+ // connections, so one burst can re-enter here for an already-overflowing
254
+ // conn before `closeConnection`'s `closed` guard becomes visible. The
255
+ // flag is the "close already initiated" bit (its only read).
256
+ if (conn.needsResync) return;
257
+ conn.needsResync = true;
258
+ logInternalError(
259
+ "crew-broker.outbound.overflow",
260
+ new Error("outbound queue overflow — closing connection for resync"),
261
+ `sessionId=${opts.sessionId}
262
+ runId=${conn.runId ?? "?"} queued=${conn.outbound.length} seq=${conn.outboundSeq}`,
263
+ );
264
+ try {
265
+ const buf = encodeBrokerFrame({ event: "protocol.close", data: { reason: OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON } });
266
+ conn.socket.write(buf);
267
+ } catch {
268
+ /* socket may be dead; closeConnection below sweeps regardless */
269
+ }
270
+ hooks.onClose(conn);
271
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { readEventsCursor } from "../../../state/event-log/event-log.ts";
13
13
  import { loadRunManifestById } from "../../../state/stores/state-store.ts";
14
+ import { encodeBrokerFrame } from "../../../utils/ndjson.ts";
14
15
  import type { ServerConnection } from "./connection-state.ts";
15
16
 
16
17
  export interface EventWriterHelpers {
@@ -50,18 +51,57 @@ export async function handleEventsSince(
50
51
  const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
51
52
  const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
52
53
  const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
54
+ // R9-N2 (2026-10-17): optional byte-offset anchor + generation echo. The
55
+ // default (non-offset) read only sees the last TAIL_BYTES (4 MB) of the
56
+ // log, so the PREFIX of a larger log is unreachable by seq alone; a client
57
+ // that passes fromByteOffset pages it (the cursor resumes scanning from
58
+ // that byte position). Omitted → exact previous behavior.
59
+ const fromByteOffset =
60
+ typeof v.fromByteOffset === "number" && Number.isFinite(v.fromByteOffset) && v.fromByteOffset >= 0
61
+ ? Math.floor(v.fromByteOffset)
62
+ : undefined;
63
+ const generation = typeof v.generation === "number" && Number.isFinite(v.generation) ? Math.floor(v.generation) : undefined;
53
64
  try {
54
- const result = readEventsCursor(eventsPath, { sinceSeq, limit });
55
- // hasMore is true iff the total filtered count exceeds the page we
56
- // returned. When `total === events.length` we are at the exact end
57
- // of the stream (caller will discover this on the next call when
58
- // `nextSeq` is unchanged from `sinceSeq`).
59
- const hasMore = result.total > result.events.length;
60
- helpers.sendResult(conn, id, {
61
- events: result.events,
62
- nextSeq: result.nextSeq,
63
- hasMore,
65
+ const result = readEventsCursor(eventsPath, { sinceSeq, limit, fromByteOffset, generation });
66
+ // R9-N1 (2026-10-17): a page whose encoded frame exceeds the broker
67
+ // frame limit used to make sendResult → enqueueFrame → encodeBrokerFrame
68
+ // throw into a debug-only log — the client hung on its id with no
69
+ // response (runtime-proven on >256 KiB replays). Pre-check the EXACT
70
+ // response frame here and binary-halve the batch until it encodes;
71
+ // `truncated` + `nextSinceSeq` tell the client to page the rest. Never
72
+ // silence — if even ONE event cannot encode, answer with a typed error.
73
+ let events = result.events;
74
+ let frameTruncated = false;
75
+ let response: Record<string, unknown>;
76
+ const buildResponse = (): Record<string, unknown> => ({
77
+ events,
78
+ nextSeq: events.length ? events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq) : result.nextSeq,
79
+ hasMore: result.total > events.length,
80
+ // Continuation contract: nextSinceSeq is the seq to pass as sinceSeq on
81
+ // the next call (safe resume anchor — never past an undelivered event).
82
+ nextSinceSeq: events.length ? events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq) : result.nextSeq,
83
+ truncated: frameTruncated || result.truncated === true,
84
+ ...(result.nextByteOffset !== undefined ? { nextByteOffset: result.nextByteOffset } : {}),
85
+ ...(result.generation !== undefined ? { generation: result.generation } : {}),
64
86
  });
87
+ while (true) {
88
+ response = buildResponse();
89
+ try {
90
+ encodeBrokerFrame(response);
91
+ break;
92
+ } catch {
93
+ if (events.length <= 1) {
94
+ // A single event over the frame limit can never page — typed error
95
+ // (enqueueFrame's R9-N1 fallback would also catch this, but failing
96
+ // here keeps the client's paging loop coherent).
97
+ helpers.sendError(conn, id, "response-oversize", "events.since: single event exceeds the broker frame limit");
98
+ return;
99
+ }
100
+ events = events.slice(0, Math.ceil(events.length / 2));
101
+ frameTruncated = true;
102
+ }
103
+ }
104
+ helpers.sendResult(conn, id, response);
65
105
  } catch (err) {
66
106
  helpers.sendError(conn, id, "replay-failed", (err as Error).message);
67
107
  }
@@ -176,6 +176,74 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
176
176
  };
177
177
  }
178
178
 
179
+ // ============================================================================
180
+ // R7-H1 fix (deep-review round 8): delegate.request parameter parser.
181
+ // Mirrors parseWaitRequestParams discipline — the handler previously spread
182
+ // `typeof x === "number"` values straight into `requested`, so non-integer,
183
+ // non-finite, negative and over-ceiling maxTurns/budgetTokens reached
184
+ // admission (e.g. raw-wire -1e9 budget → durable negative tokensSpent;
185
+ // 1e999 → Infinity maxTurns → dead child-pi turn caps).
186
+ // ============================================================================
187
+
188
+ /** Server-side ceiling for grandchild turn caps (client tool schema: 1..200). */
189
+ export const DELEGATE_MAX_TURNS_CEILING = 200;
190
+ /** Server-side ceiling for grandchild token budgets (client schema: <=100M). */
191
+ export const DELEGATE_BUDGET_TOKENS_CEILING = 100_000_000;
192
+ /** Bounded delegate prompt (defense-in-depth under the frame cap). */
193
+ export const DELEGATE_PROMPT_MAX_CHARS = 100_000;
194
+
195
+ export interface DelegateRequestParams {
196
+ prompt: string;
197
+ description?: string;
198
+ role?: string;
199
+ model?: string;
200
+ maxTurns?: number;
201
+ budgetTokens?: number;
202
+ timeoutSec?: number;
203
+ }
204
+
205
+ export function parseDelegateRequestParams(value: unknown): DelegateRequestParams | undefined {
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
207
+ const v = value as Record<string, unknown>;
208
+ if (typeof v.prompt !== "string" || v.prompt.trim().length === 0 || v.prompt.length > DELEGATE_PROMPT_MAX_CHARS) {
209
+ return undefined;
210
+ }
211
+ if (v.description !== undefined && typeof v.description !== "string") return undefined;
212
+ if (v.role !== undefined && !(typeof v.role === "string" && ["explorer", "analyst", "executor"].includes(v.role))) {
213
+ return undefined;
214
+ }
215
+ if (v.model !== undefined && typeof v.model !== "string") return undefined;
216
+ if (v.maxTurns !== undefined) {
217
+ if (typeof v.maxTurns !== "number" || !Number.isInteger(v.maxTurns) || v.maxTurns < 1 || v.maxTurns > DELEGATE_MAX_TURNS_CEILING) {
218
+ return undefined;
219
+ }
220
+ }
221
+ if (v.budgetTokens !== undefined) {
222
+ if (
223
+ typeof v.budgetTokens !== "number" ||
224
+ !Number.isInteger(v.budgetTokens) || // R8 lane-B correction: client Type.Integer parity (0.5/3.5 pass isFinite)
225
+ v.budgetTokens < 0 ||
226
+ v.budgetTokens > DELEGATE_BUDGET_TOKENS_CEILING
227
+ ) {
228
+ return undefined;
229
+ }
230
+ }
231
+ // timeoutSec stays finite-only here; the 1..MAX range enforcement remains
232
+ // in spawn-policy §7 (evaluateDelegateAdmission).
233
+ if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
234
+ return undefined;
235
+ }
236
+ return {
237
+ prompt: v.prompt,
238
+ ...(typeof v.description === "string" ? { description: v.description } : {}),
239
+ ...(typeof v.role === "string" ? { role: v.role } : {}),
240
+ ...(typeof v.model === "string" ? { model: v.model } : {}),
241
+ ...(v.maxTurns !== undefined ? { maxTurns: v.maxTurns as number } : {}),
242
+ ...(v.budgetTokens !== undefined ? { budgetTokens: v.budgetTokens as number } : {}),
243
+ ...(v.timeoutSec !== undefined ? { timeoutSec: v.timeoutSec as number } : {}),
244
+ };
245
+ }
246
+
179
247
  export interface WaitResolveParams {
180
248
  to: string;
181
249
  questionId: string;
@@ -31,6 +31,15 @@ export const RESPONSE_TIMEOUT_MS = DEFAULT_CHILD_PI.responseTimeoutMs;
31
31
  */
32
32
  export const MAX_LINE_BUFFER_BYTES = 1024 * 1024; // 1 MB
33
33
 
34
+ /**
35
+ * R3A-M3 (wave-2b): byte cap for a single persisted transcript line. The
36
+ * buffer force-flush can hand the pipeline a raw "line" of up to 1 MB, and
37
+ * non-JSON lines are persisted VERBATIM (no compact stage) — a worker that
38
+ * base64-dumps a binary into its stdout would write it whole into the
39
+ * transcript. Lines beyond this cap are truncated with an explicit marker.
40
+ */
41
+ export const MAX_PERSISTED_LINE_BYTES = 64 * 1024; // 64 KiB
42
+
34
43
  /** Maximum characters for assistant text fragments in compacted events. */
35
44
  export const MAX_ASSISTANT_TEXT_CHARS = DEFAULT_CHILD_PI.maxAssistantTextChars;
36
45
 
@@ -169,12 +169,29 @@ interface RpcPoolEntry {
169
169
  proc: ChildProcess;
170
170
  /** True while the pooled process is idle between runs (reusable). */
171
171
  idle: boolean;
172
+ /** R2-M-A5 (Wave-3): the run that pinned this process. Reuse re-validates
173
+ * the requesting run matches — a pooled worker (its env, session dir,
174
+ * transcript paths) is run-scoped and must NEVER cross run boundaries. */
175
+ pinnedRunId: string;
172
176
  }
173
177
 
174
178
  const rpcPool = new Map<string, RpcPoolEntry>();
175
179
 
176
- function poolKeyFor(input: ChildPiRunInput): string {
177
- return input.agentId ?? input.agent.name;
180
+ /** @internal — exported for the R2-M-A5 run-identity pinning test. */
181
+ export function poolKeyFor(input: ChildPiRunInput): string {
182
+ // R2-M-A5 (Wave-3): the pool key is RUN-SCOPED. The old key (agentId only)
183
+ // let two DIFFERENT runs with the same agent id reuse one process — a
184
+ // worker's env/session/transcript state leaking across runs (and stale
185
+ // run-scoped registrations pointing at the wrong run).
186
+ const runId = input.runId ?? "untracked";
187
+ const agentId = input.agentId ?? "-";
188
+ return `${runId}:${agentId}:${input.agent.name}`;
189
+ }
190
+
191
+ /** R2-M-A5 (Wave-3): the run id a pool lookup must match for reuse. */
192
+ /** @internal — exported for the R2-M-A5 run-identity pinning test. */
193
+ export function requestingRunIdFor(input: ChildPiRunInput): string {
194
+ return input.runId ?? "untracked";
178
195
  }
179
196
 
180
197
  /**
@@ -187,9 +204,19 @@ function poolKeyFor(input: ChildPiRunInput): string {
187
204
  * evicted (the watchdog owns their failure); busy entries are left in place
188
205
  * and the caller falls back to a fresh spawn.
189
206
  */
190
- function acquirePooledRpcClient(key: string): RpcPoolEntry | undefined {
207
+ function acquirePooledRpcClient(key: string, requestingRunId?: string): RpcPoolEntry | undefined {
191
208
  const entry = rpcPool.get(key);
192
209
  if (!entry) return undefined;
210
+ // R2-M-A5 (Wave-3): belt-and-braces identity re-validation — even if the
211
+ // key were ever reused across runs, a pinned-runId mismatch evicts the
212
+ // entry (stop best-effort) and forces a fresh spawn.
213
+ if (requestingRunId !== undefined && entry.pinnedRunId !== requestingRunId) {
214
+ rpcPool.delete(key);
215
+ void entry.client.stop().catch(() => {
216
+ /* identity eviction best-effort */
217
+ });
218
+ return undefined;
219
+ }
193
220
  const stdin = entry.proc.stdin;
194
221
  const procAlive = entry.proc.exitCode === null && entry.proc.signalCode === null && !!stdin && !stdin.destroyed;
195
222
  if (!procAlive) {
@@ -319,7 +346,10 @@ export async function tryChildPiRpcRun(plan: ChildPiRpcRunPlan): Promise<ChildPi
319
346
  // resolves in ~0ms; only a cold standalone test pays module load.
320
347
  // LAZY: registered with scripts/check-lazy-imports.mjs (runtime dynamic import)
321
348
  const { RpcClient } = await import("@earendil-works/pi-coding-agent");
322
- const pooled = resolveChildPiRpcPoolEnabled() ? acquirePooledRpcClient(poolKeyFor(plan.input)) : undefined;
349
+ // R2-M-A5 (Wave-3): pass the requesting run id so reuse re-validates the pinned run.
350
+ const pooled = resolveChildPiRpcPoolEnabled()
351
+ ? acquirePooledRpcClient(poolKeyFor(plan.input), requestingRunIdFor(plan.input))
352
+ : undefined;
323
353
  const Ctor = RpcClient as unknown as new (options: unknown) => RpcClientInstance;
324
354
  return runRpcSession(Ctor, plan, cliPath, pooled);
325
355
  }
@@ -357,6 +387,20 @@ async function runRpcSession(
357
387
  }
358
388
 
359
389
  // ── start (fresh spawns only) ─────────────────────────────────────────
390
+ // R2-L-A5 rider (wave-2b): every early-failure return below (spawn-error,
391
+ // no-process, canary-kill) bypasses the settle/finally tempDir cleanup at
392
+ // the bottom of this function — a leaked per-run scratch dir each time.
393
+ // finally-equivalent cleanup right before each return (fresh spawns only:
394
+ // a pooled reuse must not delete another task's scratch dir).
395
+ const cleanupFreshTempDir = (): void => {
396
+ if (!pooled && ctx.tempDir) {
397
+ try {
398
+ cleanupTempDir(ctx.tempDir);
399
+ } catch {
400
+ /* cleanup never masks the start failure */
401
+ }
402
+ }
403
+ };
360
404
  if (!pooled) {
361
405
  // Env-splice: RpcClient spawns with `{...process.env, ...options.env}`
362
406
  // (rpc-client.js start()) — WITHOUT this splice the child would inherit
@@ -397,6 +441,7 @@ async function runRpcSession(
397
441
  } catch {
398
442
  /* listener errors never mask the start failure */
399
443
  }
444
+ cleanupFreshTempDir();
400
445
  return {
401
446
  exitCode: null,
402
447
  stdout: "",
@@ -425,6 +470,7 @@ async function runRpcSession(
425
470
  // runtime (rpc-client.js field) — hence the structural cast.
426
471
  const child = (client as unknown as { process: ChildProcess | null }).process;
427
472
  if (!child) {
473
+ cleanupFreshTempDir();
428
474
  return {
429
475
  exitCode: null,
430
476
  stdout: "",
@@ -458,6 +504,7 @@ async function runRpcSession(
458
504
  } catch {
459
505
  /* listener errors never mask the leak failure */
460
506
  }
507
+ cleanupFreshTempDir();
461
508
  return {
462
509
  exitCode: null,
463
510
  stdout: "",
@@ -764,9 +811,27 @@ async function runRpcSession(
764
811
  child.stdin &&
765
812
  !child.stdin.destroyed
766
813
  ) {
767
- poolEntry = { client, proc: child, idle: false };
768
- rpcPool.set(poolKey, poolEntry);
769
- attachPoolExitHook(poolKey, child);
814
+ // R2-M-A5 (Wave-3): pin the registering run — reuse re-validates.
815
+ poolEntry = { client, proc: child, idle: false, pinnedRunId: requestingRunIdFor(input) };
816
+ // R2-M-A4 (2026-10-17): NEVER overwrite a live same-key entry — the
817
+ // previous unconditional rpcPool.set replaced (and orphaned) a live
818
+ // process: stdin open, unreachable, dead only at host shutdown. Skip
819
+ // registration instead (the run settles through the normal close path).
820
+ const existing = rpcPool.get(poolKey);
821
+ if (existing && existing !== poolEntry && existing.proc.exitCode === null) {
822
+ logInternalError(
823
+ "child-pi.rpc-pool-overwrite-guard",
824
+ new Error(
825
+ `rpcPool registration skipped: live entry already holds pool key ${poolKey} (pinnedRunId=${existing.pinnedRunId ?? "unknown"})`,
826
+ ),
827
+ `pid=${existing.proc.pid}`,
828
+ "warn",
829
+ );
830
+ poolEntry = undefined;
831
+ } else {
832
+ rpcPool.set(poolKey, poolEntry);
833
+ attachPoolExitHook(poolKey, child);
834
+ }
770
835
  }
771
836
  if (poolEntry) {
772
837
  // Warm path (acquired OR just registered): the process STAYS alive
@@ -14,6 +14,7 @@ import type { ChildPiRunInput } from "./child-pi.ts";
14
14
  import {
15
15
  MAX_ASSISTANT_TEXT_CHARS,
16
16
  MAX_LINE_BUFFER_BYTES,
17
+ MAX_PERSISTED_LINE_BYTES,
17
18
  MAX_THINKING_CHARS,
18
19
  MAX_TOOL_INPUT_CHARS,
19
20
  MAX_TOOL_RESULT_CHARS,
@@ -85,7 +86,11 @@ export function compactChildPiEvent(event: unknown): unknown | undefined {
85
86
  return {
86
87
  type: record.type,
87
88
  toolName: record.toolName,
88
- args: record.args,
89
+ // R3A-M3 (wave-2b): tool args were passed through RAW — a single
90
+ // tool_execution carrying a 1 MB arg (e.g. an edit diff or base64
91
+ // blob) landed whole in the agent event log. Route through the same
92
+ // compaction as message content parts.
93
+ args: compactValue(record.args),
89
94
  };
90
95
  }
91
96
  if (record.type === "tool_result_end" || record.type === "message_end" || record.type === "message") {
@@ -124,7 +129,10 @@ export function compactChildPiEvent(event: unknown): unknown | undefined {
124
129
  taskId: record.taskId,
125
130
  reason: record.reason,
126
131
  message: record.message,
127
- data: record.data,
132
+ // R3A-M3 (wave-2b): the "full payload" pass-through kept `data` raw —
133
+ // compact it too (the P2-25 intent was feature fidelity, not unbounded
134
+ // payload retention).
135
+ data: compactValue(record.data),
128
136
  };
129
137
  }
130
138
  return record.type ? { type: record.type } : undefined;
@@ -178,7 +186,15 @@ function nonJsonLineResult(line: string): {
178
186
  displayLine?: string;
179
187
  json: boolean;
180
188
  } {
181
- return { json: false, persistedLine: line, displayLine: line };
189
+ // R3A-M3 (wave-2b): non-JSON lines persist VERBATIM — cap them so a
190
+ // force-flushed mega-line (up to MAX_LINE_BUFFER_BYTES) or a binary dump
191
+ // cannot balloon the transcript. JSON lines go through compactChildPiEvent
192
+ // (bounded per-field); this is the unbounded escape hatch.
193
+ const capped =
194
+ line.length > MAX_PERSISTED_LINE_BYTES
195
+ ? `${line.slice(0, MAX_PERSISTED_LINE_BYTES)}[pi-crew truncated ${line.length - MAX_PERSISTED_LINE_BYTES} chars]`
196
+ : line;
197
+ return { json: false, persistedLine: capped, displayLine: capped };
182
198
  }
183
199
 
184
200
  function compactChildPiLine(
@@ -31,8 +31,14 @@
31
31
  * event-log.ts) so sync flush handlers run first.
32
32
  * - uncaughtException/unhandledRejection → setImmediate kick, never swallow:
33
33
  * we add no output and no exit; existing pi-crew
34
- * fatal handlers already own crash reporting (and
35
- * already suppress Node's default, see event-log).
34
+ * fatal handlers own crash reporting. NOTE
35
+ * (R3A-M1, 2026-10-17): they do NOT suppress —
36
+ * event-log's unhandledRejection handler re-throw
37
+ * keeps the process-fatal default, so a stray
38
+ * rejection still kills the host. Call sites must
39
+ * not rely on suppression existing; guard their
40
+ * own fire-and-forget promises (see
41
+ * live-agent-control.ts / live-session-runtime.ts).
36
42
  */
37
43
 
38
44
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -34,6 +34,7 @@
34
34
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
35
35
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
36
36
  import { logInternalError } from "../../utils/internal-error.ts";
37
+ import { redactSecrets } from "../../utils/redaction.ts";
37
38
  import { classifyBool } from "./classifier-service.ts";
38
39
 
39
40
  export interface AmbientNoiseGateArgs {
@@ -88,7 +89,10 @@ export async function classifyAmbientNoiseGate(args: AmbientNoiseGateArgs): Prom
88
89
  state: {
89
90
  // Cap the summary: classifiers reason over small JSON state — the raw
90
91
  // error can carry multi-KB stderr tails (same cap as retry-triage).
91
- failureSummary: args.failureSummary.slice(0, 2000),
92
+ // R2-L-B8 (wave-2b): redact BEFORE the summary (and its slice) reaches
93
+ // classifier state — provider/API-key echoes in an auth failure would
94
+ // otherwise ride into the model prompt and the observed telemetry.
95
+ failureSummary: String(redactSecrets(args.failureSummary)).slice(0, 2000),
92
96
  attempt: args.attempt,
93
97
  maxAttempts: args.maxAttempts,
94
98
  },
@@ -168,9 +168,13 @@ export function resultDigest(toolName: string, content: string, isError?: boolea
168
168
  return undefined;
169
169
  }
170
170
 
171
- function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
171
+ export function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
172
172
  const generic = `[Output pruned — ${tokens} tokens]`;
173
- const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode);
173
+ // R2-L-B7 (SEV-12): tools without a known digest format (edit/write/read…)
174
+ // previously fell back to the content-free generic notice — dependency-context
175
+ // consumers lost ALL evidence the tool did anything. Generic head-2/tail-2
176
+ // digest keeps a bounded content signal for every tool.
177
+ const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode) ?? genericLineDigest(entry.content);
174
178
  if (!digest) return generic;
175
179
  const genericTokens = countTokens(generic);
176
180
  const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
@@ -180,6 +184,20 @@ function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
180
184
  return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
181
185
  }
182
186
 
187
+ /** R2-L-B7: generic digest for tools without a dedicated format — first two
188
+ * and last two non-empty lines, deduped when the content is short. Never
189
+ * empty (falls back to a line count) so the pruned notice always carries a
190
+ * content signal. */
191
+ function genericLineDigest(content: string): string {
192
+ const lines = (content ?? "")
193
+ .split(/\r?\n/)
194
+ .map((line) => line.trim())
195
+ .filter(Boolean);
196
+ if (lines.length === 0) return "empty output";
197
+ if (lines.length <= 4) return `${lines.length} line(s): ${lines.join(" ⏎ ")}`;
198
+ return `head=${lines.slice(0, 2).join(" ⏎ ")}; tail=${lines.slice(-2).join(" ⏎ ")}`;
199
+ }
200
+
183
201
  // ---------------------------------------------------------------------------
184
202
  // Target key resolution
185
203
  // ---------------------------------------------------------------------------