@junghanacs/entwurf 0.16.0 → 0.17.1

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 (48) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +337 -0
  3. package/README.md +8 -11
  4. package/VERIFY.md +8 -1
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +25 -14
  7. package/docs/setup-clean-host.md +24 -10
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
  10. package/package.json +10 -10
  11. package/pi-extensions/lib/acp/acp-client.ts +57 -4
  12. package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
  13. package/pi-extensions/lib/acp/backend.ts +578 -18
  14. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  15. package/pi-extensions/lib/acp/event-mapper.ts +43 -6
  16. package/run.sh +129 -32
  17. package/scripts/check-acp-launch-namespace.ts +127 -0
  18. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  19. package/scripts/check-acp-stop-reason.ts +8 -2
  20. package/scripts/check-acp-usage-accounting.ts +1074 -0
  21. package/scripts/check-copilot-birth-hook.ts +28 -1
  22. package/scripts/check-gate-qualification.ts +4 -2
  23. package/scripts/check-omp-fresh-preflight.ts +27 -0
  24. package/scripts/check-setup-qualification.sh +40 -2
  25. package/scripts/copilot-bridge-oracle.sh +14 -6
  26. package/scripts/fake-copilot-vendor.sh +4 -2
  27. package/scripts/lib/pi-record-discovery.ts +47 -0
  28. package/scripts/mutants/acp-launch-namespace.json +34 -0
  29. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  30. package/scripts/mutants/acp-stream-hooks.json +4 -2
  31. package/scripts/mutants/acp-usage-accounting.json +181 -0
  32. package/scripts/mutants/copilot-birth.json +3 -5
  33. package/scripts/mutants/pack-install.json +2 -2
  34. package/scripts/mutants/setup-verdict.json +35 -0
  35. package/scripts/omp-config-xdev.py +310 -0
  36. package/scripts/omp-config-xdev.sh +76 -0
  37. package/scripts/omp-tool-surface.py +61 -10
  38. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  39. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  40. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  41. package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
  42. package/scripts/smoke-acp-cortex-live.ts +2 -2
  43. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  44. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  45. package/scripts/smoke-acp-v2-send-live.ts +2 -2
  46. package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
  47. package/scripts/smoke-mux-lifecycle-live.ts +46 -2
  48. package/scripts/smoke-setup-verdict.sh +48 -3
@@ -46,7 +46,12 @@ import { Readable, Writable } from "node:stream";
46
46
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
47
47
  import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
48
48
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
49
- import { type AcpClientHandlers, type AcpConnectionLike, connectAcpClient } from "./acp-client.js";
49
+ import {
50
+ type AcpClientHandlers,
51
+ type AcpConnectionLike,
52
+ type AcpPromptResponse,
53
+ connectAcpClient,
54
+ } from "./acp-client.js";
50
55
  import { prependNewPromptAugment } from "./augment.js";
51
56
  import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
52
57
  import {
@@ -104,6 +109,97 @@ const SET_MODEL_TIMEOUT_MS = 30_000;
104
109
  // to process-group teardown, so an abort always returns promptly.
105
110
  const ABORT_CANCEL_GRACE_MS = 5_000;
106
111
 
112
+ /** Tokens below which a re-billed prefix is breakpoint granularity, not news.
113
+ * This matches pi's token threshold for a native cache-miss notice, which also
114
+ * considers a separate $0.10 cost threshold (read at pi-coding-agent
115
+ * `dist/modes/interactive/interactive-mode.js:3130`). */
116
+ const CACHE_MISS_NOTICE_FLOOR_TOKENS = 20_000;
117
+
118
+ /** ONE turn's ACCOUNTING totals: the SUM over that turn's API round trips, as
119
+ * ACP reports them. Carried on `usage.acp`, never on pi's four `Usage` fields —
120
+ * those mean one REQUEST's prompt shape to readers that would misread a sum
121
+ * (see `sealTurnUsage`). This is the vendor's own arithmetic, relayed. */
122
+ interface AcpTurnAccounting {
123
+ input: number;
124
+ output: number;
125
+ cacheRead: number;
126
+ cacheWrite: number;
127
+ }
128
+
129
+ function finiteOrZero(value: unknown): number {
130
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
131
+ }
132
+
133
+ /**
134
+ * ONE turn's ACCOUNTING totals, preferring the widest scope the backend offers.
135
+ *
136
+ * `_meta.quota.model_usage` comes from `result.modelUsage` and the vendor calls it
137
+ * "the accounting-grade figure per the SDK" — it also counts Task subagents,
138
+ * sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
139
+ * than `token_count`" and are "the fuller picture, not a decomposition of it"
140
+ * (read at claude-agent-acp 0.73.0 `dist/acp-agent.js:5728-5748`). The narrower
141
+ * `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
142
+ *
143
+ * The wider one is the right numerator because the denominator already has that
144
+ * scope: `usage.cost.total` is the diff of the backend's running total, which
145
+ * includes those internal calls. Pairing a main-loop token sum with an
146
+ * all-inclusive cost understates the cache-effect badge exactly when compaction
147
+ * ran — and compaction is a live path again (#94). Rows are summed because a
148
+ * session may in principle report more than one model; with GLG's single-model
149
+ * rule there is exactly one.
150
+ *
151
+ * Falls back to `usage` when the sidecar is absent: `_meta` is a standard ACP
152
+ * extension slot whose contents a client may not assume, and `quota` is not in
153
+ * claude-agent-acp's exported types (#96 carries the re-measure-on-bump duty).
154
+ */
155
+ function readTurnAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
156
+ const rows = promptResult?._meta?.quota?.model_usage;
157
+ if (Array.isArray(rows) && rows.length > 0) {
158
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
159
+ for (const row of rows) {
160
+ const t = row?.token_count;
161
+ if (!t) continue;
162
+ total.input += finiteOrZero(t.inputTokens);
163
+ total.output += finiteOrZero(t.outputTokens);
164
+ total.cacheRead += finiteOrZero(t.cachedInputTokens);
165
+ total.cacheWrite += finiteOrZero(t.cachedWriteTokens);
166
+ }
167
+ return total;
168
+ }
169
+ const wire = promptResult?.usage;
170
+ if (!wire) return undefined;
171
+ return {
172
+ input: finiteOrZero(wire.inputTokens),
173
+ output: finiteOrZero(wire.outputTokens),
174
+ cacheRead: finiteOrZero(wire.cachedReadTokens),
175
+ cacheWrite: finiteOrZero(wire.cachedWriteTokens),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * The MAIN AGENT LOOP's token totals (`PromptResponse.usage` ==
181
+ * `quota.token_count`). Same four fields as `readTurnAccounting`, different
182
+ * scope: this excludes Task subagents, sidechains, and internal calls such
183
+ * as compaction. The cache-miss bound's recurrence is a MAIN-LOOP identity
184
+ * — occupancy is main-context occupancy — so its IO terms come from here,
185
+ * never from the wide rows. Reporting still uses `readTurnAccounting`.
186
+ */
187
+ function readMainLoopAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
188
+ const wire = promptResult?.usage;
189
+ if (!wire) return undefined;
190
+ return {
191
+ input: finiteOrZero(wire.inputTokens),
192
+ output: finiteOrZero(wire.outputTokens),
193
+ cacheRead: finiteOrZero(wire.cachedReadTokens),
194
+ cacheWrite: finiteOrZero(wire.cachedWriteTokens),
195
+ };
196
+ }
197
+
198
+ /** 191,971 → "192k". Keeps the notice inside the 80-char fragment cap. */
199
+ function formatTokenCount(tokens: number): string {
200
+ return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(Math.round(tokens));
201
+ }
202
+
107
203
  type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
108
204
 
109
205
  // ---------------------------------------------------------------------------
@@ -119,7 +215,13 @@ export interface AcpChildLike {
119
215
  signalCode: NodeJS.Signals | null;
120
216
  stdin: { destroy(): void; unref?(): void };
121
217
  stdout: { destroy(): void; unref?(): void };
122
- stderr: { on(event: "data", listener: (chunk: Buffer) => void): void; destroy(): void; unref?(): void };
218
+ stderr: {
219
+ on(event: "data", listener: (chunk: Buffer) => void): void;
220
+ /** Optional: absent on minimal fakes; the flush is best-effort, never load-bearing for liveness. */
221
+ once?(event: "close", listener: () => void): void;
222
+ destroy(): void;
223
+ unref?(): void;
224
+ };
123
225
  kill(signal?: NodeJS.Signals | number): boolean;
124
226
  unref(): void;
125
227
  once(event: "exit" | "error", listener: (...args: unknown[]) => void): void;
@@ -210,6 +312,11 @@ interface BridgeSession {
210
312
  * nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
211
313
  */
212
314
  stderrTail: string[];
315
+ /**
316
+ * SESSION-scoped like `stderrTail`, and for the same reason: the launcher's
317
+ * frame can arrive on a turn later than the one that spawned the child.
318
+ */
319
+ launchObservation: AcpLaunchObservation;
213
320
  /** How the child ended, once it has — folded into the prompt-phase error. */
214
321
  exit?: { code: number | null; signal: NodeJS.Signals | null };
215
322
  /**
@@ -234,6 +341,72 @@ interface BridgeSession {
234
341
  childEnd: ChildEndLatch;
235
342
  /** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
236
343
  notifyChildGone?: (err: Error) => void;
344
+ /**
345
+ * The SDK's last-observed SESSION-CUMULATIVE cost, in USD — the baseline the
346
+ * NEXT turn's cost is differenced against — authoritative for this rail in
347
+ * the sense that the backend's own estimate outranks any local recompute,
348
+ * not in the sense of a billing statement (#93).
349
+ *
350
+ * BRIDGE-SESSION SCOPED, and that scope is the invariant. It must track the
351
+ * live child, because the cumulative it measures is that child's own running
352
+ * total and restarts at zero with a new one: a rebuilt session (config-signature
353
+ * drift) gets a fresh object and therefore a fresh baseline, which is exactly
354
+ * right. Hoisting this to the pi session would keep a dead child's total as the
355
+ * baseline and make the next real turn's diff enormous or negative.
356
+ *
357
+ * `undefined` = no cumulative observed yet, which is NOT the same as 0: a turn
358
+ * that reports no cost must HOLD this value, not zero it, or the amount it
359
+ * carried would be double-counted by the next diff.
360
+ */
361
+ sdkCumulativeCostUsd?: number;
362
+ /**
363
+ * The last observed context OCCUPANCY (`usage_update.used`), carried forward
364
+ * across a turn whose notification never arrived (#93).
365
+ *
366
+ * Without this, such a turn carries accounting at `usage.acp` but has zero
367
+ * `totalTokens` and zero pi-native usage fields. pi's
368
+ * `calculateContextTokens` (read at pi-coding-agent
369
+ * `dist/core/compaction/compaction.js:86-88`) then returns 0, making
370
+ * auto-compaction replace the vendor observation with its local estimate
371
+ * (read at pi-coding-agent `dist/core/agent-session.js:1696-1699`). Carrying
372
+ * the last measurement preserves the last known occupancy. Assigned, never
373
+ * summed.
374
+ */
375
+ contextOccupancyTokens?: number;
376
+ /**
377
+ * The PREVIOUS turn's MAIN-LOOP `Σinput + Σoutput` (`PromptResponse.usage`,
378
+ * never the wide `model_usage` sum) and the wall-clock ms at which that
379
+ * turn SEALED. The idle gap is then measured from that seal to THIS turn's
380
+ * START (`turnStartedAtMs`), never to its seal, so a turn's own duration can
381
+ * never inflate the gap it reports. Together with `contextOccupancyTokens`,
382
+ * they are the whole input to the cache-miss bound in `sealTurnUsage` — no
383
+ * per-round-trip collection.
384
+ *
385
+ * Why these two and nothing else: within one turn Claude Code's cache
386
+ * breakpoints make `cacheRead_i = cacheRead_(i-1) + cacheWrite_(i-1)`
387
+ * (re-measured 2026-09-02 across the WHOLE ACP overlay corpus, not one ledger:
388
+ * 2,398 of 2,410 adjacent pairs hold — 99.50% — over 30+ session transcripts
389
+ * under `~/.pi/agent/claude-config-overlay/projects` spanning 2026-05 to
390
+ * 2026-09. On the incident ledger alone it is 101 of 102, and that single break
391
+ * IS the miss: cacheRead 0 against a predicted 195,177 at 2026-09-01T21:15:09Z.
392
+ * Of the 12 corpus-wide breaks, 10 fall BELOW prediction — each one a real miss
393
+ * or partial re-read — and the 2 that exceed it do so by 4,357 and 3,283 tokens,
394
+ * both far under the notice floor, so neither could lift a quiet turn over the
395
+ * threshold on its own). Telescoping that identity gives
396
+ *
397
+ * cacheRead_first = used_end − ΣcacheWrite − (input_last + output_last)
398
+ *
399
+ * and the same identity on the previous turn gives what THIS turn's first
400
+ * request would have read had the cache still been warm:
401
+ *
402
+ * expected = used_prev − (input_last' + output_last')
403
+ *
404
+ * Only the LAST request's input+output is unknown at turn scope, and it is
405
+ * bounded by the turn's own sums — so both quantities come out as PROVEN
406
+ * INTERVALS, never estimates. Assigned, never summed.
407
+ */
408
+ priorTurnInputOutputSum?: number;
409
+ priorTurnSealedAtMs?: number;
237
410
  /**
238
411
  * This TURN owns reporting the child's end, so the next turn must not also
239
412
  * announce it. Raised at the top of a failure path — BEFORE the bounded
@@ -367,6 +540,85 @@ const CHILD_END_SETTLE_MS = 500;
367
540
  */
368
541
  const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
369
542
 
543
+ /**
544
+ * The launcher's control frame — see `claude-acp-launch.js`.
545
+ *
546
+ * Matched as an EXACT FULL LINE and nothing else. The vendor writes prose that
547
+ * mentions signals; a substring test would let vendor text manufacture an
548
+ * entwurf observation, which is precisely the confusion #72 cost three
549
+ * diagnosis passes. Our own frame is a fixed enum, so exact-line matching is
550
+ * sufficient AND necessary.
551
+ */
552
+ const LAUNCH_SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
553
+ const LAUNCH_SIGNAL_FRAME_VALUES: ReadonlySet<string> = new Set(["SIGTERM", "SIGINT"]);
554
+
555
+ /** A mutable, session-scoped record of a terminating signal the LAUNCHER caught. */
556
+ export type AcpLaunchObservation = { signal?: "SIGTERM" | "SIGINT" };
557
+
558
+ /**
559
+ * Split a stderr chunk stream into lines, consuming our own frames and passing
560
+ * everything else through to the tail untouched.
561
+ *
562
+ * Line-buffered because a chunk boundary can fall inside a frame; the trailing
563
+ * partial line is held, not emitted, so a frame split across two reads is still
564
+ * recognised exactly.
565
+ */
566
+ function makeLaunchFrameFilter(observation: AcpLaunchObservation, onText: (text: string) => void) {
567
+ let held = "";
568
+ return {
569
+ write(chunk: string): void {
570
+ held += chunk;
571
+ let nl = held.indexOf("\n");
572
+ let passed = "";
573
+ while (nl !== -1) {
574
+ const line = held.slice(0, nl);
575
+ if (
576
+ line.startsWith(LAUNCH_SIGNAL_FRAME_PREFIX) &&
577
+ LAUNCH_SIGNAL_FRAME_VALUES.has(line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length))
578
+ ) {
579
+ // FIRST caught signal wins: a later one is our own teardown racing
580
+ // the external kill, and reporting that would bury the cause.
581
+ observation.signal ??= line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length) as "SIGTERM" | "SIGINT";
582
+ } else {
583
+ passed += `${line}\n`;
584
+ }
585
+ held = held.slice(nl + 1);
586
+ nl = held.indexOf("\n");
587
+ }
588
+ if (passed) onText(passed);
589
+ },
590
+ /**
591
+ * The child's LAST words may arrive without a trailing newline — a process
592
+ * dying mid-write is exactly when that happens, and it is exactly when the
593
+ * tail matters most. Line buffering would otherwise hold that fragment
594
+ * forever, so the stream's close flushes it VERBATIM.
595
+ *
596
+ * No frame check here, deliberately: the launcher writes its frame with a
597
+ * single `writeSync` including the newline, well under PIPE_BUF, so a
598
+ * complete frame can never be the un-terminated remainder. Anything left
599
+ * without a newline is vendor text by construction.
600
+ */
601
+ flush(): void {
602
+ if (!held) return;
603
+ onText(held);
604
+ held = "";
605
+ },
606
+ };
607
+ }
608
+
609
+ /**
610
+ * What the operator is told about a caught signal — and what they are NOT told.
611
+ *
612
+ * Absence is reported as "not observed", never as "no signal": the launcher can
613
+ * only see what reached it, and an override launch (`CLAUDE_AGENT_ACP_COMMAND`)
614
+ * has no launcher at all. Attribution of the SENDER is not claimed here either —
615
+ * that needs the host's journal, which this process cannot read.
616
+ */
617
+ function launchSignalLine(observation: AcpLaunchObservation | undefined): string | undefined {
618
+ if (!observation?.signal) return undefined;
619
+ return `[acp] launch observed ${observation.signal} before child exit (sender not attributed)`;
620
+ }
621
+
370
622
  function isAcpConnectionClosure(err: unknown): boolean {
371
623
  const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
372
624
  return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
@@ -520,7 +772,7 @@ async function awaitAcpPromptTurn(
520
772
  session: BridgeSession,
521
773
  promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
522
774
  opts: { signal?: AbortSignal; graceMs: number },
523
- ): Promise<{ stopReason?: string }> {
775
+ ): Promise<AcpPromptResponse> {
524
776
  let rejectLifecycle: ((err: Error) => void) | undefined;
525
777
  const lifecycle = new Promise<never>((_, reject) => {
526
778
  rejectLifecycle = reject;
@@ -590,9 +842,12 @@ export type AcpStopVerdict = {
590
842
  /**
591
843
  * ACP prompt stopReason → pi verdict.
592
844
  *
593
- * The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.3.0
594
- * `schema/types.gen`): end_turn | max_tokens | max_turn_requests | refusal |
595
- * cancelled. Only three of those are successful or benign ends. The previous
845
+ * The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.4.0
846
+ * `dist/schema/types.gen.d.ts:3001`): end_turn | max_tokens | max_turn_requests |
847
+ * refusal | cancelled no `| string` arm. 1.4.0 also ships an OPEN union at
848
+ * `dist/v2/schema/types.gen.d.ts:3607`, but that is behind the `./experimental/v2`
849
+ * export and entwurf imports the bare specifier, which resolves to the closed v1
850
+ * surface. Only three of those are successful or benign ends. The previous
596
851
  * implementation returned a bare StopReason with `default: "stop"`, which turned
597
852
  * `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
598
853
  * clean successful turn — pi then rendered a silently truncated answer as if the
@@ -867,6 +1122,14 @@ export function streamAcpTurn(
867
1122
  deps: AcpTurnDeps,
868
1123
  ): ReturnType<typeof createAssistantMessageEventStream> {
869
1124
  const stream = createAssistantMessageEventStream();
1125
+ // When THIS turn began. The idle gap that expires a prompt cache is the time
1126
+ // between turns, so it must be measured to a turn's START — measuring to its
1127
+ // seal would fold this turn's own duration into the gap and call a 58-minute
1128
+ // pause plus a 4-minute turn "62m idle", which crosses the 1h TTL in the
1129
+ // report while nothing crossed it in fact. pi's native detector uses message
1130
+ // timestamps for the same reason (read at pi-coding-agent
1131
+ // `dist/core/cache-stats.js:14-37`, `idleMs`).
1132
+ const turnStartedAtMs = Date.now();
870
1133
  const state: AcpPiStreamState = createAcpStreamState(stream, {
871
1134
  api: "entwurf",
872
1135
  provider: "entwurf",
@@ -893,13 +1156,258 @@ export function streamAcpTurn(
893
1156
  };
894
1157
  }
895
1158
 
1159
+ /**
1160
+ * Seal this turn's ACCOUNTING — the #93 authority boundary.
1161
+ *
1162
+ * Runs ONLY for a backend whose adapter declares `sealsTurnAccounting`. Flag
1163
+ * absence means that backend's usage semantics were never measured, so nothing
1164
+ * is sealed and its emitted usage is whatever the common mapper produced —
1165
+ * unchanged, not guessed at.
1166
+ *
1167
+ * Two INDEPENDENT axes, because they arrive on different wires:
1168
+ *
1169
+ * tokens the turn aggregate, relayed from PromptResponse on `usage.acp`.
1170
+ * cost an ADJACENT DIFF of the backend's own running session total, which
1171
+ * arrives on `usage_update` and is captured raw by the mapper.
1172
+ *
1173
+ * The diff is what makes the session sum reproduce the backend's own running
1174
+ * total exactly — with no residue, which is what the gate measures. Read that
1175
+ * exactness for what it is: agreement with the BACKEND'S CUMULATIVE ESTIMATE,
1176
+ * never with an Anthropic invoice. The vendor calls `total_cost_usd` a
1177
+ * "Cumulative estimated cost" and "An estimate, not a billing statement"
1178
+ * (claude-agent-sdk `sdk.d.ts:4884`), and entwurf has measured no live
1179
+ * comparison against a bill.
1180
+ *
1181
+ * We prefer that estimate anyway for a STRUCTURAL reason, not because it is
1182
+ * more precise in general: it is computed UPSTREAM of a lossy flattening we
1183
+ * cannot undo. The observed cache-writes are entirely 1h, ACP carries no 1h
1184
+ * field, and pi prices a write with no `cacheWrite1h` at the 5m rate (read at
1185
+ * pi-coding-agent `dist/bundle/chunks/bedrock-converse-stream.js`,
1186
+ * `calculateCost`) — a local recompute measured $1.66 low on one live
1187
+ * ledger. Nothing here calls calculateCost: any price table we could apply
1188
+ * runs DOWNSTREAM of that flattening, so it would be a second, wronger
1189
+ * source.
1190
+ */
1191
+ function sealTurnUsage(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
1192
+ if (!adapter.sealsTurnAccounting) return;
1193
+
1194
+ // --- the turn's ACCOUNTING aggregate, verbatim ------------------------
1195
+ // ACP reports one number set per turn and it is the SUM OVER THAT TURN'S API
1196
+ // ROUND TRIPS. It is carried on its OWN key, never on pi's four, because
1197
+ // those four mean ONE REQUEST's prompt shape to two readers that never go
1198
+ // through `calculateContextTokens` and so cannot be rescued by an honest
1199
+ // `totalTokens` (isContextOverflow, read at pi-ai
1200
+ // `dist/utils/overflow.js:132-145`; cache-stats.detectMiss, read at
1201
+ // pi-coding-agent `dist/core/cache-stats.js:14-37`). Projecting the
1202
+ // aggregate onto them compacted a live 223,516-token session on a 1,000,000
1203
+ // window (measured 2026-09-01).
1204
+ //
1205
+ // Nothing is computed here: these are the vendor's own four numbers, put
1206
+ // somewhere they cannot be mistaken for a per-request reading. pi stores an
1207
+ // assistant message as JSON and reads it back the same way (read at
1208
+ // pi-coding-agent `dist/core/session-manager.js:768-777` appendMessage,
1209
+ // `:98` parseSessionEntries), so a key pi does not know survives a resume.
1210
+ const aggregate = readTurnAccounting(promptResult);
1211
+ const mainLoop = readMainLoopAccounting(promptResult);
1212
+ if (aggregate) {
1213
+ (state.output.usage as unknown as { acp?: AcpTurnAccounting }).acp = aggregate;
1214
+ }
1215
+
1216
+ // --- token axis: DELIBERATELY UNWRITTEN ------------------------------
1217
+ // The four token fields stay at their zero initialisation. ACP's only token
1218
+ // carrier is `PromptResponse.usage`, and that is the SUM OVER THIS TURN'S
1219
+ // API ROUND TRIPS — measured 2026-09-02 on one 21-round-trip turn whose
1220
+ // overlay rows summed to exactly the four numbers ACP reported
1221
+ // (cacheRead 4,185,084) while the context it occupied was 223,516.
1222
+ //
1223
+ // pi reads these four as ONE REQUEST's prompt shape, in two places that do
1224
+ // NOT go through `calculateContextTokens` and so cannot be rescued by the
1225
+ // honest `totalTokens` written below:
1226
+ //
1227
+ // isContextOverflow `input + cacheRead > contextWindow` (read at pi-ai
1228
+ // `dist/utils/overflow.js:132-145`). The aggregate made
1229
+ // that true at 223k of a 1M window and compacted a live
1230
+ // session — the defect this seal now refuses to feed.
1231
+ // detectMiss `input + cacheRead + cacheWrite` vs the previous
1232
+ // request (read at pi-coding-agent
1233
+ // `dist/core/cache-stats.js:14-37`). Under the aggregate
1234
+ // it invented two phantom misses and SILENCED the real
1235
+ // 195,177-token one after a 401-minute idle gap.
1236
+ //
1237
+ // Writing zeros is not a placeholder for a better number we could compute:
1238
+ // the per-request partition is genuinely absent from the wire. The vendor
1239
+ // builds it in `lastAssistantUsage` and sends only its scalar sum (read at
1240
+ // claude-agent-acp 0.73.0 `dist/acp-agent.js:3273-3297`) — #96.
1241
+ //
1242
+ // But silence is NOT the resting state. A cache miss the operator never sees
1243
+ // is a false reading, not a modest one: a session can run for hours believing
1244
+ // its badge while a full prefix rewrite has already been paid for. So the
1245
+ // aggregate rides its own key above, and the bound below reports the rewrite.
1246
+
1247
+ // --- context occupancy (NOT a turn total — see the field's note) -----
1248
+ // Refresh from THIS turn's notification when there was one, then assign the
1249
+ // session's value UNCONDITIONALLY. The assignment is not conditional on the
1250
+ // notification having been missing: on this path the seal — not the mapper —
1251
+ // is the authority for what pi reads as occupancy, and when a fresh value did
1252
+ // arrive the two agree by construction.
1253
+ //
1254
+ // Why the field must be right rather than merely non-zero: pi's auto-compaction
1255
+ // takes `calculateContextTokens(message.usage)` at face value and falls back to
1256
+ // its own estimate ONLY when the message errored or that value is exactly 0
1257
+ // (read at pi-coding-agent `dist/core/agent-session.js:1696-1697`). A turn
1258
+ // aggregate written here would be non-zero and therefore never corrected — the
1259
+ // session would read as nearly empty all the way to an overflow.
1260
+ const priorOccupancy = session.contextOccupancyTokens;
1261
+ const priorIoSum = session.priorTurnInputOutputSum;
1262
+ const priorSealedAtMs = session.priorTurnSealedAtMs;
1263
+
1264
+ const occupancy = state.observedContextOccupancyTokens;
1265
+ if (typeof occupancy === "number") session.contextOccupancyTokens = occupancy;
1266
+ if (typeof session.contextOccupancyTokens === "number") {
1267
+ state.output.usage.totalTokens = session.contextOccupancyTokens;
1268
+ }
1269
+
1270
+ // --- cache-miss bound (the rewrite the operator must not miss) -------
1271
+ // Both quantities below are PROVEN INTERVALS derived from numbers already in
1272
+ // hand, not estimates. See `priorTurnInputOutputSum` for the identity and its
1273
+ // telescoping. Only the LAST request's input+output is unknown at turn scope,
1274
+ // and each turn's own sums bound it:
1275
+ //
1276
+ // cacheRead_first ≤ used_end − ΣcacheWrite (upper)
1277
+ // expected ≥ used_prev − (Σinput' + Σoutput') (lower)
1278
+ // miss ≥ expected_lower − cacheRead_first_upper
1279
+ //
1280
+ // The interval is proven only while the within-turn recurrence holds, and that
1281
+ // recurrence is an OBSERVED property of Claude Code's breakpoint placement
1282
+ // (measured 2026-09-02 corpus-wide: 2,398 of 2,410 adjacent pairs, 99.50%; see
1283
+ // `priorTurnInputOutputSum` for the full count and the break characterisation),
1284
+ // not a guarantee. If it breaks mid-turn — a 1h TTL expiring
1285
+ // between round trips, or the child compacting itself — `used_end − ΣcacheWrite`
1286
+ // goes negative and the `max(0, …)` below assumes cacheRead_first = 0. The
1287
+ // notice is still TRUE (a rewrite did happen) but its N is then a floor of a
1288
+ // weaker kind, not a telescoped bound. #96 carries that limit.
1289
+ //
1290
+ // A negative or small bound proves nothing was re-billed beyond breakpoint
1291
+ // granularity, so nothing is said. The floor is the TOKEN half of pi's own
1292
+ // native cache-miss display policy, which suppresses a notice only when the
1293
+ // miss is under 20,000 tokens AND under $0.10 (read at pi-coding-agent
1294
+ // `dist/modes/interactive/interactive-mode.js:3130`). The ACP rail borrows
1295
+ // that familiar token threshold alone and claims no part of the cost half:
1296
+ // this bound is a token interval, and pricing it locally is the very
1297
+ // downstream reprice the cost axis above refuses.
1298
+ // The re-billed size can never exceed what this turn's MAIN LOOP actually
1299
+ // WROTE: a re-billed prefix is paid for as cache creation. Clamping to
1300
+ // `mainLoop.cacheWrite` (not the wide aggregate) is what keeps the notice
1301
+ // a statement about money that changed hands on the prefix rather than
1302
+ // about the recurrence holding, or about an internal compaction write.
1303
+ //
1304
+ // Without it a context SHRINK reads as a giant miss. Worked counterexample
1305
+ // (gpt-5.6-sol, 2026-09-02): prior occupancy 200,000, prior IO 1,000, this
1306
+ // turn occupancy 20,000 with cacheWrite 1,000 — organic compaction, nothing
1307
+ // re-billed — yields 200,000 − 1,000 − 19,000 = 180,000 and would announce
1308
+ // "cache miss ≥180k" over a turn that wrote 1,000 tokens. The clamp answers
1309
+ // 1,000, which is under the floor, so nothing is said. On the real incident
1310
+ // the clamp does not bind: 191,971 ≤ cacheWrite 221,084.
1311
+ //
1312
+ // Both prior marks are read from ONE turn and written from ONE turn. Mixing
1313
+ // an older occupancy with a newer IO sum puts two turns in one equation and
1314
+ // can skew the bound HIGH, so the update below is all-or-nothing.
1315
+ //
1316
+ // The same all-or-nothing applies to SCOPE. Occupancy is main-context;
1317
+ // the recurrence is a main-loop identity. `aggregate.cacheWrite` is the
1318
+ // WIDE accounting figure (Task subagents, sidechains, internal compaction).
1319
+ // A large unrelated wide write shrinks `max(0, occupancy − cacheWrite)`
1320
+ // (less is subtracted) AND raises the `min(…, cacheWrite)` ceiling — both
1321
+ // paths push the bound UP, so a warm main prefix can announce a miss and
1322
+ // attach this turn's all-inclusive dollar figure to it. The IO terms
1323
+ // therefore come from `mainLoop`, never from `aggregate`.
1324
+ const derivable =
1325
+ mainLoop !== undefined &&
1326
+ typeof occupancy === "number" &&
1327
+ typeof priorOccupancy === "number" &&
1328
+ typeof priorIoSum === "number";
1329
+ const cacheWriteForBound = mainLoop !== undefined ? mainLoop.cacheWrite : 0;
1330
+ const rawBound = derivable
1331
+ ? (priorOccupancy as number) - (priorIoSum as number) - Math.max(0, (occupancy as number) - cacheWriteForBound)
1332
+ : undefined;
1333
+ const missLowerBound = rawBound === undefined ? undefined : Math.min(rawBound, cacheWriteForBound);
1334
+
1335
+ if (mainLoop && typeof occupancy === "number") {
1336
+ session.priorTurnInputOutputSum = mainLoop.input + mainLoop.output;
1337
+ session.priorTurnSealedAtMs = Date.now();
1338
+ }
1339
+
1340
+ // --- cost axis ------------------------------------------------------
1341
+ // ALWAYS assigned on this path, including the 0 cases: the mapper may have
1342
+ // written the running cumulative into this field, and leaving it would put a
1343
+ // session total on a dashboard that sums per-turn costs — the whole defect.
1344
+ const observed = state.observedSessionCostUsd;
1345
+ let turnCostUsd: number | undefined;
1346
+ if (typeof observed !== "number") {
1347
+ // No cumulative arrived this turn. HOLD the baseline: this turn's real
1348
+ // amount is still inside the backend's running total and the next
1349
+ // adjacent diff absorbs it. Attributed to the wrong turn, exact in the
1350
+ // session sum. Zeroing the baseline here would double-count it instead.
1351
+ state.output.usage.cost.total = 0;
1352
+ } else {
1353
+ const diff = observed - (session.sdkCumulativeCostUsd ?? 0);
1354
+ if (diff < 0) {
1355
+ // The backend's running total went BACKWARDS. TWO receipts, and the gap
1356
+ // between them is exactly why the notice below names a MECHANISM and
1357
+ // never a cause: claude-agent-acp's `conversation_reset` handler only
1358
+ // switches the SDK to a fresh conversation and touches no cost at all
1359
+ // (read at 0.73.0 `dist/acp-agent.js:3675-3682`), while claude-agent-sdk
1360
+ // separately documents that "a mid-session /clear resets the running
1361
+ // total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
1362
+ // explains a backwards total, but nothing here has MEASURED that it did,
1363
+ // and asserting the cause would be the same unmeasured claim this lane
1364
+ // exists to end.
1365
+ // Rebaseline and attribute 0 — but SAY SO. Silently absorbing it would
1366
+ // hide the one observation that can settle what a reset does to the
1367
+ // total, which is precisely the "no silent misaccounting" this lane owes.
1368
+ const detail = `${(session.sdkCumulativeCostUsd ?? 0).toFixed(6)} → ${observed.toFixed(6)} USD`;
1369
+ session.sdkCumulativeCostUsd = observed;
1370
+ state.output.usage.cost.total = 0;
1371
+ // Kept under the notice fragment cap (80) so the two numbers survive
1372
+ // verbatim — a truncated diagnostic is not evidence.
1373
+ pushAcpLifecycleNotice(state, `cost baseline reset (${detail}) — this turn attributed $0`);
1374
+ console.error(
1375
+ `entwurf: ACP backend reported a DECREASING session cost (${detail}). Rebaselined; this turn is ` +
1376
+ `attributed $0. The cause is NOT measured here. The one documented mechanism is a mid-session ` +
1377
+ `/clear, which claude-agent-sdk (sdk.d.ts:4884) says resets the running total; the adapter's ` +
1378
+ `conversation_reset event is NOT it — that handler switches conversation and touches no cost. ` +
1379
+ `The session total from here on is measured against the new baseline.`,
1380
+ );
1381
+ } else {
1382
+ session.sdkCumulativeCostUsd = observed;
1383
+ state.output.usage.cost.total = diff;
1384
+ turnCostUsd = diff;
1385
+ }
1386
+ }
1387
+
1388
+ // --- report the rewrite ----------------------------------------------
1389
+ // Said LAST so it can quote what this turn actually cost. The cost is the
1390
+ // SDK's own adjacent diff, never a local repricing.
1391
+ if (typeof missLowerBound === "number" && missLowerBound >= CACHE_MISS_NOTICE_FLOOR_TOKENS) {
1392
+ // Only when there is an idle gap worth naming. A sub-minute one explains
1393
+ // nothing and "after 0m idle" reads as noise, so the clause is dropped —
1394
+ // the re-billed size and the cost still stand on their own.
1395
+ const idleMinutes =
1396
+ typeof priorSealedAtMs === "number" ? Math.round((turnStartedAtMs - priorSealedAtMs) / 60_000) : 0;
1397
+ const idle = idleMinutes >= 1 ? ` after ${idleMinutes}m idle` : "";
1398
+ const paid = typeof turnCostUsd === "number" ? ` — this turn $${turnCostUsd.toFixed(2)}` : "";
1399
+ pushAcpLifecycleNotice(state, `cache miss ≥${formatTokenCount(missLowerBound)} re-billed${idle}${paid}`);
1400
+ }
1401
+ }
1402
+
896
1403
  /**
897
1404
  * Seal the turn from the ACP prompt result. "Success" here means the RPC
898
1405
  * returned, not that the turn ended well — a returned `refusal` /
899
1406
  * `max_turn_requests` / unknown / absent reason is sealed as an error event,
900
1407
  * never a `done`. `rawStopReason` carries the wire value out either way.
901
1408
  */
902
- function finishSuccess(promptResult: { stopReason?: string }): void {
1409
+ function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
1410
+ sealTurnUsage(adapter, session, promptResult);
903
1411
  finalizeAcpStreamState(state);
904
1412
  const verdict = mapPromptStopReason(promptResult?.stopReason);
905
1413
  if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
@@ -921,14 +1429,24 @@ export function streamAcpTurn(
921
1429
  stream.end();
922
1430
  }
923
1431
 
924
- function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
1432
+ function finishError(
1433
+ err: unknown,
1434
+ aborted: boolean,
1435
+ stderrTail?: string[],
1436
+ lifecycle?: string,
1437
+ launchObservation?: AcpLaunchObservation,
1438
+ ): void {
925
1439
  finalizeAcpStreamState(state);
926
1440
  state.output.stopReason = aborted ? "aborted" : "error";
927
1441
  const base = err instanceof Error ? err.message : String(err);
928
1442
  // The FIRST failure stays first and verbatim (it is what the backend
929
1443
  // actually said); the lifecycle line is added, never substituted, so a
930
1444
  // reader can still match the transport's own text.
931
- const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
1445
+ const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
1446
+ // Its OWN line, above the vendor tail: an entwurf-owned observation must not
1447
+ // have to be recovered by reading vendor prose.
1448
+ const observed = launchSignalLine(launchObservation);
1449
+ const diagnosed = observed ? `${withLifecycle}\n${observed}` : withLifecycle;
932
1450
  const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
933
1451
  const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
934
1452
  // A-c: a real failure (not an abort) that looks like a context-window
@@ -1100,7 +1618,7 @@ export function streamAcpTurn(
1100
1618
  inFlightKeys.add(sessionKey);
1101
1619
  try {
1102
1620
  if (decision.path === "reuse" && existing) {
1103
- await runReuseTurn(existing, ctxSigs);
1621
+ await runReuseTurn(existing, ctxSigs, adapter);
1104
1622
  } else {
1105
1623
  await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
1106
1624
  }
@@ -1124,6 +1642,8 @@ export function streamAcpTurn(
1124
1642
  /** Which phase a failure belongs to — flipped once the prompt is on the wire. */
1125
1643
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1126
1644
  const stderrTail: string[] = [];
1645
+ // Session-scoped alongside the tail — see the field's note on the session type.
1646
+ const launchObservation: AcpLaunchObservation = {};
1127
1647
  const sessionKey = resolveSessionKey(opts, cwd);
1128
1648
  try {
1129
1649
  if (signal?.aborted) throw new Error("aborted before launch");
@@ -1156,10 +1676,18 @@ export function streamAcpTurn(
1156
1676
  const spawned = child;
1157
1677
 
1158
1678
  // Drain stderr (an unconsumed pipe can backpressure-deadlock a long turn).
1159
- spawned.stderr.on("data", (c: Buffer) => {
1160
- stderrTail.push(c.toString());
1679
+ // The launcher's own control frames are consumed here and kept OUT of the
1680
+ // tail: the tail is vendor evidence, the observation is an entwurf fact,
1681
+ // and mixing the two is the overloading #72 was made of.
1682
+ const consumeStderr = makeLaunchFrameFilter(launchObservation, (text) => {
1683
+ stderrTail.push(text);
1161
1684
  if (stderrTail.length > 50) stderrTail.shift();
1162
1685
  });
1686
+ spawned.stderr.on("data", (c: Buffer) => consumeStderr.write(c.toString()));
1687
+ // `close` rather than `end`: it covers the destroy path our own teardown
1688
+ // takes, and on the EOF-first death it lands inside the post-mortem
1689
+ // settle window, so the flushed fragment is in the tail before we seal.
1690
+ spawned.stderr.once?.("close", () => consumeStderr.flush());
1163
1691
 
1164
1692
  // Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
1165
1693
  // there is no prompt turn for the agent to cancel yet, so the child is
@@ -1211,6 +1739,8 @@ export function streamAcpTurn(
1211
1739
  // this turn with the session, so a later reuse turn can still report
1212
1740
  // the child's dying words.
1213
1741
  stderrTail,
1742
+ // SAME object the frame filter writes into, for the same reason.
1743
+ launchObservation,
1214
1744
  // Armed at spawn, before ANY turn can fail on this child — the latch
1215
1745
  // must already exist when the `exit` listener below can fire.
1216
1746
  childEnd: makeChildEndLatch(),
@@ -1314,18 +1844,42 @@ export function streamAcpTurn(
1314
1844
 
1315
1845
  session.activePromptHandler = undefined;
1316
1846
  session.busy = false;
1317
- finishSuccess(promptResult);
1318
1847
 
1319
1848
  // Retain ONLY a long-lived process-scoped session that survived the turn
1320
1849
  // alive and un-aborted. A turn-scoped one-shot (and any aborted/dead
1321
1850
  // turn) tears down so its stdio handle cannot pin pi's exit (S2c hang).
1322
- if (params.lifecyclePolicy === "process-scoped" && !signal?.aborted && session.alive) {
1851
+ const retain = params.lifecyclePolicy === "process-scoped" && !signal?.aborted && session.alive;
1852
+
1853
+ // DISCOVERABILITY BEFORE THE SEAL. `finishSuccess` ends the stream, and
1854
+ // ending the stream is what releases the caller — which may start the next
1855
+ // turn on that event. A completed process-scoped session that is not yet in
1856
+ // the map would send that turn down the NEW path: a second child, and with
1857
+ // it a FRESH cost baseline, so a long session's accounting would silently
1858
+ // reset at a turn boundary (#93).
1859
+ //
1860
+ // Two things make that window empty today — nothing here awaits, and the
1861
+ // turn's in-flight claim is not released until after this returns — but both
1862
+ // are invariants of surrounding code rather than of this ordering, and
1863
+ // neither is visible from this line. Registering first makes the guarantee
1864
+ // local: the session is discoverable before anything can act on the seal,
1865
+ // whatever the code around it later does.
1866
+ if (retain) {
1323
1867
  bridgeSessions.set(sessionKey, session);
1324
1868
  retainedChildren.add(spawned);
1325
1869
  registerGlobalCleanup();
1326
1870
  // unref so the retained stdio cannot pin pi's exit at resident
1327
1871
  // shutdown — reuse is unaffected (unref ≠ destroy). GPT amber.
1328
1872
  unrefRetainedChild(spawned);
1873
+ }
1874
+
1875
+ finishSuccess(adapter, session, promptResult);
1876
+
1877
+ if (retain) {
1878
+ // AFTER the seal, deliberately: persisting is bookkeeping about a turn
1879
+ // that already answered, so a failed record write must not convert an
1880
+ // answered turn into an error turn. If it throws, the catch below still
1881
+ // un-registers this session and tears its child down — the same cleanup
1882
+ // a sealing exception gets.
1329
1883
  persistRecord(session, deps);
1330
1884
  } else {
1331
1885
  session.retiring = true;
@@ -1345,7 +1899,13 @@ export function streamAcpTurn(
1345
1899
  // own SIGTERM; the tail collected by then is sealed before our cleanup
1346
1900
  // touches the stderr pipe.
1347
1901
  const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
1348
- finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
1902
+ finishError(
1903
+ err,
1904
+ aborted,
1905
+ session?.stderrTail ?? stderrTail,
1906
+ lifecycle,
1907
+ session?.launchObservation ?? launchObservation,
1908
+ );
1349
1909
  // error/abort → drop the (uncertain) session and close its child; an
1350
1910
  // uncertain connection must never be reused (GPT ④).
1351
1911
  if (child) {
@@ -1360,7 +1920,7 @@ export function streamAcpTurn(
1360
1920
  }
1361
1921
 
1362
1922
  // --- reuse: send only the latest user delta to the live ACP session
1363
- async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
1923
+ async function runReuseTurn(session: BridgeSession, ctxSigs: string[], adapter: AcpBackendAdapter): Promise<void> {
1364
1924
  /** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
1365
1925
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1366
1926
  try {
@@ -1396,7 +1956,7 @@ export function streamAcpTurn(
1396
1956
  // prefix-compat check sees the full prior history (GPT ④: store the
1397
1957
  // ctxSigs from the START of this call, only after the turn succeeds).
1398
1958
  session.contextMessageSignatures = ctxSigs;
1399
- finishSuccess(promptResult);
1959
+ finishSuccess(adapter, session, promptResult);
1400
1960
  persistRecord(session, deps);
1401
1961
  } catch (err) {
1402
1962
  const aborted = Boolean(signal?.aborted);
@@ -1412,7 +1972,7 @@ export function streamAcpTurn(
1412
1972
  // Without the session-scoped tail a mid-turn child death on a resident
1413
1973
  // session surfaced as a bare "ACP connection closed" with nothing to read
1414
1974
  // it by.
1415
- finishError(err, aborted, session.stderrTail, lifecycle);
1975
+ finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
1416
1976
  // error/abort on a reused session → drop it and close the child (GPT ④).
1417
1977
  session.retiring = true;
1418
1978
  retainedChildren.delete(session.child);