@junghanacs/entwurf 0.16.1 → 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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -245,6 +341,72 @@ interface BridgeSession {
245
341
  childEnd: ChildEndLatch;
246
342
  /** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
247
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;
248
410
  /**
249
411
  * This TURN owns reporting the child's end, so the next turn must not also
250
412
  * announce it. Raised at the top of a failure path — BEFORE the bounded
@@ -610,7 +772,7 @@ async function awaitAcpPromptTurn(
610
772
  session: BridgeSession,
611
773
  promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
612
774
  opts: { signal?: AbortSignal; graceMs: number },
613
- ): Promise<{ stopReason?: string }> {
775
+ ): Promise<AcpPromptResponse> {
614
776
  let rejectLifecycle: ((err: Error) => void) | undefined;
615
777
  const lifecycle = new Promise<never>((_, reject) => {
616
778
  rejectLifecycle = reject;
@@ -680,9 +842,12 @@ export type AcpStopVerdict = {
680
842
  /**
681
843
  * ACP prompt stopReason → pi verdict.
682
844
  *
683
- * The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.3.0
684
- * `schema/types.gen`): end_turn | max_tokens | max_turn_requests | refusal |
685
- * 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
686
851
  * implementation returned a bare StopReason with `default: "stop"`, which turned
687
852
  * `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
688
853
  * clean successful turn — pi then rendered a silently truncated answer as if the
@@ -957,6 +1122,14 @@ export function streamAcpTurn(
957
1122
  deps: AcpTurnDeps,
958
1123
  ): ReturnType<typeof createAssistantMessageEventStream> {
959
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();
960
1133
  const state: AcpPiStreamState = createAcpStreamState(stream, {
961
1134
  api: "entwurf",
962
1135
  provider: "entwurf",
@@ -983,13 +1156,258 @@ export function streamAcpTurn(
983
1156
  };
984
1157
  }
985
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
+
986
1403
  /**
987
1404
  * Seal the turn from the ACP prompt result. "Success" here means the RPC
988
1405
  * returned, not that the turn ended well — a returned `refusal` /
989
1406
  * `max_turn_requests` / unknown / absent reason is sealed as an error event,
990
1407
  * never a `done`. `rawStopReason` carries the wire value out either way.
991
1408
  */
992
- function finishSuccess(promptResult: { stopReason?: string }): void {
1409
+ function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
1410
+ sealTurnUsage(adapter, session, promptResult);
993
1411
  finalizeAcpStreamState(state);
994
1412
  const verdict = mapPromptStopReason(promptResult?.stopReason);
995
1413
  if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
@@ -1200,7 +1618,7 @@ export function streamAcpTurn(
1200
1618
  inFlightKeys.add(sessionKey);
1201
1619
  try {
1202
1620
  if (decision.path === "reuse" && existing) {
1203
- await runReuseTurn(existing, ctxSigs);
1621
+ await runReuseTurn(existing, ctxSigs, adapter);
1204
1622
  } else {
1205
1623
  await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
1206
1624
  }
@@ -1426,18 +1844,42 @@ export function streamAcpTurn(
1426
1844
 
1427
1845
  session.activePromptHandler = undefined;
1428
1846
  session.busy = false;
1429
- finishSuccess(promptResult);
1430
1847
 
1431
1848
  // Retain ONLY a long-lived process-scoped session that survived the turn
1432
1849
  // alive and un-aborted. A turn-scoped one-shot (and any aborted/dead
1433
1850
  // turn) tears down so its stdio handle cannot pin pi's exit (S2c hang).
1434
- 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) {
1435
1867
  bridgeSessions.set(sessionKey, session);
1436
1868
  retainedChildren.add(spawned);
1437
1869
  registerGlobalCleanup();
1438
1870
  // unref so the retained stdio cannot pin pi's exit at resident
1439
1871
  // shutdown — reuse is unaffected (unref ≠ destroy). GPT amber.
1440
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.
1441
1883
  persistRecord(session, deps);
1442
1884
  } else {
1443
1885
  session.retiring = true;
@@ -1478,7 +1920,7 @@ export function streamAcpTurn(
1478
1920
  }
1479
1921
 
1480
1922
  // --- reuse: send only the latest user delta to the live ACP session
1481
- async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
1923
+ async function runReuseTurn(session: BridgeSession, ctxSigs: string[], adapter: AcpBackendAdapter): Promise<void> {
1482
1924
  /** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
1483
1925
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1484
1926
  try {
@@ -1514,7 +1956,7 @@ export function streamAcpTurn(
1514
1956
  // prefix-compat check sees the full prior history (GPT ④: store the
1515
1957
  // ctxSigs from the START of this call, only after the turn succeeds).
1516
1958
  session.contextMessageSignatures = ctxSigs;
1517
- finishSuccess(promptResult);
1959
+ finishSuccess(adapter, session, promptResult);
1518
1960
  persistRecord(session, deps);
1519
1961
  } catch (err) {
1520
1962
  const aborted = Boolean(signal?.aborted);
@@ -49,6 +49,22 @@ export type AcpPiStreamState = {
49
49
  /** When false, tool/permission notices are suppressed (kept terse for smokes). */
50
50
  showToolNotifications?: boolean;
51
51
  observedTools?: Map<string, ObservedToolState>;
52
+ /**
53
+ * RAW, UNINTERPRETED observations from this turn's `usage_update` notifications
54
+ * — carried beside the pi message, never folded into it here.
55
+ *
56
+ * The mapper is COMMON layer and deliberately assigns no meaning to either
57
+ * number: what `cost.amount` is CUMULATIVE OVER, and whether that even holds
58
+ * for a non-claude backend, is a per-backend measurement. backend.ts seals them
59
+ * only for a backend whose adapter declares `sealsTurnAccounting` (#93).
60
+ *
61
+ * Last write wins, never a sum: both are latest session-level observations, and
62
+ * one turn can legitimately see several (claude emits one per `result` message,
63
+ * including a sub-agent's own — read at claude-agent-acp 0.73.0
64
+ * `dist/acp-agent.js:2918-2933`), each carrying that result's current values.
65
+ */
66
+ observedSessionCostUsd?: number;
67
+ observedContextOccupancyTokens?: number;
52
68
  };
53
69
 
54
70
  /** A zeroed pi Usage block. */
@@ -328,13 +344,34 @@ export function applyAcpSessionUpdate(
328
344
  break;
329
345
  }
330
346
  case "usage_update": {
331
- // S2c maps COARSE ACP usage only: `used` is occupancy-shaped and does
332
- // not split cleanly into pi's input/output/cache fields, so we fill
333
- // totalTokens + cost.total and leave the rest zero. Richer accounting
334
- // is a later lane (S2e/PR-polish).
335
- if (typeof update.used === "number") state.output.usage.totalTokens = update.used;
347
+ // `used` is OCCUPANCY-shaped the backend's post-turn context size, not
348
+ // the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
349
+ // as `used` (read at claude-agent-acp 0.73.0
350
+ // `dist/acp-agent.js:3290-3297`), after constructing that scalar from the
351
+ // latest assistant snapshot (`:3273-3289`). pi reads `usage.totalTokens` as
352
+ // exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
353
+ // || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
354
+ // `dist/core/compaction/compaction.js:86-88`). The assignment below is the
355
+ // raw observation; backend.ts seals the measured occupancy and relays the
356
+ // turn aggregate separately on `usage.acp`, never onto pi's four fields.
357
+ if (typeof update.used === "number") {
358
+ state.output.usage.totalTokens = update.used;
359
+ state.observedContextOccupancyTokens = update.used;
360
+ }
361
+ // `cost.amount` is a running SESSION total on the claude backend, so
362
+ // assigning it to a TURN field is wrong wherever an adapter has measured
363
+ // that (#93: pi sums per-message cost, which inflated a long session
364
+ // 10-18x). It stays here for a backend whose adapter has NOT measured its
365
+ // semantics — for those, this coarse assignment is the pre-#93 behaviour
366
+ // and changing it would be an unmeasured claim. backend.ts OVERWRITES
367
+ // this field authoritatively (from the baseline diff) for every turn of a
368
+ // backend that HAS sealsTurnAccounting, so the cumulative can never reach
369
+ // an operator's dashboard as a turn cost on that path.
336
370
  const cost = update.cost as { amount?: unknown } | undefined;
337
- if (typeof cost?.amount === "number") state.output.usage.cost.total = cost.amount;
371
+ if (typeof cost?.amount === "number") {
372
+ state.output.usage.cost.total = cost.amount;
373
+ state.observedSessionCostUsd = cost.amount;
374
+ }
338
375
  break;
339
376
  }
340
377
  default:
package/run.sh CHANGED
@@ -2700,6 +2700,24 @@ check_acp_event_mapper() {
2700
2700
  run_ts scripts/check-acp-event-mapper.ts
2701
2701
  }
2702
2702
 
2703
+ check_acp_usage_accounting() {
2704
+ # Deterministic gate for the ACP USAGE ACCOUNTING contract (#93). A long-lived
2705
+ # Claude ACP session's dashboard read 10-18x high on three live ledgers,
2706
+ # because the per-turn token partition was dropped at the type boundary while
2707
+ # the backend's RUNNING SESSION TOTAL was assigned to a per-turn cost field pi
2708
+ # then summed. Drives streamAcpTurn against a fake ACP child + connection whose
2709
+ # turns are scripted (usage_update notification + PromptResponse.usage) for
2710
+ # five cells: the four-way token partition reaches the pi message; per-turn
2711
+ # costs are adjacent diffs of the running total and sum back to it across a
2712
+ # reused session; a turn with no cost notification attributes $0 and HOLDS the
2713
+ # baseline; a decreasing total rebaselines, attributes $0 and tells the
2714
+ # operator; totalTokens stays CONTEXT OCCUPANCY (asserted through pi's own
2715
+ # calculateContextTokens) and carries forward; and cortex — no measured
2716
+ # extractor — keeps its pre-#93 output untouched.
2717
+ section "ACP usage accounting (turn partition + adjacent-diff cost)"
2718
+ run_ts scripts/check-acp-usage-accounting.ts
2719
+ }
2720
+
2703
2721
  check_acp_stop_reason() {
2704
2722
  # Deterministic gate for the ACP stop-reason contract. Drives every member of
2705
2723
  # the closed ACP terminal set (end_turn / max_tokens / max_turn_requests /
@@ -6520,6 +6538,9 @@ case "$cmd" in
6520
6538
  check-acp-event-mapper)
6521
6539
  check_acp_event_mapper
6522
6540
  ;;
6541
+ check-acp-usage-accounting)
6542
+ check_acp_usage_accounting
6543
+ ;;
6523
6544
  check-acp-stop-reason)
6524
6545
  check_acp_stop_reason
6525
6546
  ;;