@sema-agent/client-core 0.66.0 → 0.67.0

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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,84 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.67.0(2026-09-12)
53
+
54
+ **core 7.14.0 → 7.16.0 提货批**(逐键处置表见 `docs/INTEGRATION-CLIENTS.md` §32z;每件的键名 / 形 /
55
+ 缺席语义 / 三端待办 / 黑盒判据 G32-01 ~ G32-27 见 §32b–§32h)。
56
+
57
+ ### BREAKING
58
+
59
+ - **删公面导出 `classifierUnavailableOf`** —— core 7.14.0 的 Retired keys 表把 `classifierUnavailable`
60
+ 从六个载体上整族删掉(ask 那几处自 #661 起引擎就没写过);继任者是 `classifierDenyCauseOf(门记录)`。
61
+ clean-cut,**不留别名**。
62
+ - **删公面导出 `classifierUnavailableDetail`** —— 同上;继任者是 `classifierDenyCauseDetail(cause)`。
63
+ - **删公面类型 `ClassifierUnavailableView`** —— 同上(它是那只读器的读数形,也是卡位与帧位的型)。
64
+ - **删卡面键 `ApprovalCardRequest.classifierUnavailable` 与帧面键
65
+ `ToolApprovalFrame.classifierUnavailable`** —— 同一个装配位换成 `ruleStoreUnreadable`;旧耐久行带旧键时
66
+ 投影**忽略**(不读、不渲、不崩)。
67
+ - **`ASK_ORIGIN_WORDS` 换词:`unresolvable` → `ancestor_marked`(无 alias)** —— core 7.14.0 的
68
+ `ASK_ORIGINS` BREAKING 换词。旧值在每一道成员筛上都是**非成员** ⇒ 投影省略该 origin、措辞走开集兜底句。
69
+ - **`ENGINE_NOTICE_AUDIENCE` 值域变更:`memory.consolidation_withheld` `user` → `operator`** ——
70
+ core 7.14.0 value-domain BREAKING(这条通告没有 `sessionId`,一次固化跑在任何会话之外)。
71
+ - **`classifierStatusOf` 的第二参语义变更**(签名与三态词表**未改**,不是编译期 BREAKING):
72
+ 从「本轮那只 ask」变成「本轮那一次**观测**」(ask / 耐久行 / 带 `gate` 的 `tool_end` 帧 / 门记录本体);
73
+ 喂旧形的端那一态会恒不出现。
74
+
75
+ ### Added
76
+
77
+ - `CLASSIFIER_DENY_CAUSES` / `isClassifierDenyCause` / `classifierDenyCauseOf` /
78
+ `classifierDenyCauseDetail` —— core 7.14.0 `GateDisposition.denied.cause`(闭二词 `unavailable` /
79
+ `parse_error`)的**拒绝面**读器与唯一措辞铸点;`GateOutcomeView.disposition` 的 denied 臂同批多一格
80
+ `cause?`(**开集透传**,判成员留给公面读器,两层分工与退役前逐字同形)。
81
+ - `RULE_STORE_UNREADABLE_KINDS` / `isRuleStoreUnreadableKind` / `ruleStoreUnreadableDetail` ——
82
+ core 7.14.0 #688 C3:`origin: "rule_store_unavailable"` 底下的**机制位**(`store` = 规则店整体读不出来 /
83
+ `call` = 这条调用对不上规则行),两句人话的下一步**相反**;卡面两腿(活卡帧 + durable park 行)
84
+ 经**同一把**窄读器投到 `ApprovalCardRequest.ruleStoreUnreadable`。
85
+ - `WiringManifestAutoMode.deniedSource?: string`(core 7.14.0 `AutoModeArmFact`)—— 哪一层设置面的棘轮
86
+ 关掉了 auto(`org` / `local` / `settings`,**开集读**);**只在 `reason === "denied"` 旁收**(段内自洽,
87
+ 与本段既有的 `armed === (reason === 'armed')` 互证同族),缺席不铸。
88
+ - 终帧 `_sema_usage_lower_bound: true` 与 `RunCostReconcile.usageLowerBound`(core 7.14.0
89
+ `TaskResult.stats.usageMissing`)—— 「这一面的数字是**下界**不是一次测量」的判别位;
90
+ 🔴 CC 同名键 `usage` / `modelUsage` / `total_cost_usd` **语义零改**,never false。
91
+ - **per-subagent usage 分表**(L-228):新 chrome 臂 `subagent_turn_usage`(`required: false`,`laneProof`
92
+ 恒是子流那条)+ 终帧 `_sema_nested_usage_by_task` 与诚实位 `_sema_nested_usage_by_task_partial`。
93
+ 🔴 §E2 的两处主臂断闸**一个字节不动**;`partial` 的判据锚在引擎自己的权威合计 `stats.nested` 上
94
+ (turns 之和 + 行数都对得上才算可证完整),失效方向只会**多**铸 partial。
95
+ - 码册加员 `task.interrupt_unconsumed`(audience `user`)—— **合同外加员**,由
96
+ `run-engine-notice-catalog-test.mjs` 的码数双向对账当天红抓出,如实登记在 §32z ⑤。
97
+
98
+ ### Changed
99
+
100
+ - devDep `@sema-agent/core` `~7.12.0` → `~7.16.0`。
101
+ - `run-gate-vocabulary-test.mjs` 的 `AskOrigin` **对账基准从 sdk 换成 core**(词表属主是 core,
102
+ 而 sdk 8.8.0 的联合滞后一代);sdk 的滞后改成一条**带退出条件的登记**(追平那天自红逼删)。
103
+ - 同批**退役**一条恒真的型面钉 `_askOriginWordsPin`(sdk 的 `AskOrigin` 带 `(string & {})` 逃生口
104
+ ⇒ 任何字符串都满足它,换词当天一声不响);判据整只移交那道门的 B 段。
105
+ - `run-retired-vocabulary-census-test.mjs` 的剥注释器修一处**词法跑偏**:单/双引号串遇裸换行即收口
106
+ (JS 词法本来如此)—— 修前一次误判会让其后的每一段注释都被当成串体保留,而本门的全部意义正是
107
+ 「注释里写了不算数,码才算数」。
108
+
109
+ ### 已知局限(本版新增)
110
+
111
+ - 🔴 **F-6 的供给面未端到端实证**(异源对抗复审 [high] 提出,本批如实登记而不是销格):core 里子代
112
+ 事件有两条外送腿 —— 工具 ctx 的 `forwardEvent` **白名单**(`prepare-run-refs.js`:无条件过
113
+ `task_progress`,开了 `forwardSubagentEvents` 再加五类转录事件,**`turn_end` 不在其中**;`tool-spec.d.ts`
114
+ 顶注逐字「other event types never cross it」)与**委派车道自己的 tap**(同一段顶注逐字「forwards the
115
+ child's FULL event stream」)。本包消费的是后者,而本批**没有**跑通 core→server→父流的真机供给实证
116
+ (本树无 server fixture)。⇒ 只走白名单腿的部署上分表**恒零行**,两个终帧键按设计**整键不铸**
117
+ (端读到的是「说不出来」,不是一个假的零)。本件因此按「包边界承诺」交付:子流 `turn_end` 到得了这条流
118
+ 就有行,到不了就两个键都不在场;端**不许**把键缺席渲成「这条 run 没委派子代」。真机供给与集成门另立。
119
+
120
+ - `WorkflowRun.errorCode`(core 7.14.0)**本包今天到不了它**:本包投影的两条腿(core `formatWorkflowRun`
121
+ 的 TaskOutput JSON / `summarizeWorkflowRun` 的列表行)都不发 run 级 `errorCode`,第三条腿
122
+ `GET /v1/workflows/:id` 的 sdk 型面无此声明且本树未装 server fixture ⇒ 证不出 service 有没有转投。
123
+ 按「core 定型 ≠ 壳可消费」办:**不猜载体**,`pending`(§32z ①-4)。
124
+ - 首请求 `tools[]` 形(core 7.15.0)对自报面有一处影响未修:`ENGINE_RUNNER_FACE` 把 `ToolSearch` 写成
125
+ **无条件**成员,而 7.15.0 之后它**有条件**了(延迟集为空 ⇒ 无 `ToolSearch`)。阈值只有引擎算得出,
126
+ 正解是让自报面改读引擎的 roster 面 ⇒ `pending`(§32z ③-2)。
127
+
128
+ ---
129
+
52
130
  ## 0.66.0(2026-09-11)
53
131
 
54
132
  > **终局真值三件**(cli 台账 L-192①② / B-068 · DEBTS L-198):三件都是同一条病形 ——
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.66.0
38
+ **Version:** 0.67.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -230,7 +230,7 @@ public-surface guard checks that last one).
230
230
  | `scripts/run-client-core-portability-test.mjs` | Kernel / A-layer / index import closures, the runtime-dependency equality gate, barrel reachability, and a real esbuild `--platform=browser` bundle |
231
231
  | `scripts/run-client-core-diff-test.mjs` | Differential equivalence against the CLI reference bridge + replay-id invariant + ledger round-trip |
232
232
  | `scripts/run-seat-contract-keys-test.mjs` | The seat IPC contract: verb list ↔ SPEC ↔ types, element-wise |
233
- | `scripts/run-approval-frame-keys-test.mjs` | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal — the register is occupied again by the classifier-unavailable key the server already emits, carrying both the release that minted it and the byte coordinates that prove it, so the lead is a dated record rather than an exemption) |
233
+ | `scripts/run-approval-frame-keys-test.mjs` | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal — the register is occupied again — this time by the rule-store-unreadable key the engine now defines, carrying both the release that minted it and the byte coordinates that prove it, so the lead is a dated record rather than an exemption; its predecessor left the register the other way, by being retired upstream rather than by the anchor catching up) |
234
234
  | `scripts/run-print-bash-iserror-test.mjs` | The print lane's Bash `is_error` authority (structured over regex) |
235
235
  | `scripts/run-bash-benign-exit-interpretation-test.mjs` | Benign non-zero Bash exits (`returnCodeInterpretation`) stay non-errors across all three derivation arms, and the annotation transits to the card |
236
236
  | `scripts/run-sdk-floor-test.mjs` | The SDK version floor — and, more to the point, that the *installed* type declarations still carry the keys this package reads |
@@ -250,6 +250,7 @@ public-surface guard checks that last one).
250
250
  | `scripts/run-wire-auth-source-test.mjs` | **When** the outbound credential is read. A literal string is consumed at construction — the transport captures it in a closure and every later request reuses that one copy — so once the engine is replaced by another session and the credential rotates, a long-lived client keeps presenting the old one and the only way out is to rebuild the client along with everything hanging off it. The credential position now also accepts a getter that is called **once per outbound request**. The guard anchors on the deciding quantity, which is not "was the getter called" — reading once at construction and reusing the result would satisfy that too, and is exactly the shape being removed — but *which read produced the value on the wire*: it changes the getter's answer between two requests through the same client and requires the second request to carry the new one, and it requires construction to read the getter **zero** times. The three-state credential semantics are replayed per request rather than assumed: on loopback an unavailable credential sends **no** authorization header at all rather than a fabricated one, off loopback it sends the fail-closed anonymous identity so the deployment answers with an honest 401, and the guard shows a single client moving between those states across successive requests. A getter that throws is fail-soft — the request still goes out under the no-credential branch, because a broken credential port should not take the whole wire down, and the exception may itself carry credential material. The same-origin relay form is checked to stay out of the getter path entirely, and every request is checked to keep the credential in the authorization header only — never in the URL, never in another header |
251
251
  | `scripts/run-subagent-durable-divert-test.mjs` | The side-channel that keeps a **sub-agent's** content out of the leader's transcript, on the replay leg. A content frame stamped with a parent tool-call id belongs to a child, and rendering a child's tokens as the leader's own text is the pollution this divert exists to prevent — but the predicate only listed the four **live** frame shapes, while the durable leg replays the same segment in its **aggregated** form. Those frames fell straight through onto the main projection path, which is how a reconnect or a resumed session ended up with the child's answer printed as the leader's. The anchor is unchanged and shared: the parent tool-call id is what says whose frame this is, and whether the frame is an increment or a whole segment has nothing to do with whose it is — judging the two shapes separately is exactly how one of them got missed. Folding the aggregate into a synthetic increment would have been the smaller diff and the wrong one: an increment means *append*, so a segment that already streamed live and then replays whole would be counted **twice**. The two are kept distinct and the aggregate absorbs instead — a whole segment whose prefix is what the buffer already holds replaces it, which also makes a redelivery of the same frame idempotent, and a prefix that does not match falls back to appending both rather than deciding on the engine's behalf which version counts. Segment boundaries stay with the tool frames rather than moving into the aggregate arm, since closing there would turn a second replay of one segment into a second entry, and the increment arm is pinned to keep appending so a token run that happens to be a prefix of the next does not silently lose characters |
252
252
  | `scripts/run-subagent-content-budget-test.mjs` | The **byte** budget on the sub-agent transcript ledger. It used to be bounded only by *counts* — so many entries per child, so many children — and a count is not a budget when a single entry has no ceiling of its own: one tool result carrying an inlined attachment, or one long model answer, and a single slot sits on tens of megabytes. The guard anchors on how many bytes are **still held** after over-filling, not on whether truncation fired, because an implementation that flags the overflow without actually dropping anything satisfies the second and not the first. Dropping is required to leave a record — how much went and where the retained content now starts — and that record has to reach the render plan, because content that vanishes with no marker gives the reader a transcript shorter than what happened with nothing to say so; the record is one per child, updated in place, pinned to the front, and excluded from the budget it describes. Order matters and is checked: oldest entries go first and the live tail is trimmed only as a last resort, since taking the text the user is watching stream while older history survives is the wrong end. The total budget evicts a whole least-recently-used child rather than shaving every child, and the configuration surface is fail-loud on zero, negatives, non-finite and non-integer values — a silently ignored budget is the exact failure this exists to remove — with the rejection proven atomic so a bad second field cannot leave half a configuration behind. The defaults are checked to be a magnitude that can really be reached, since a number too large to hit is a field rather than a budget |
253
+ | `scripts/run-subagent-usage-projection-test.mjs` | Per-subagent usage, split by task. The engine's final accounting carries the delegated spend as **one total** — tokens, turns, task count — and no per-task breakdown, while every sub-flow turn on the stream carries its own usage. This package used to fold that away at the leader/sub-flow divide (a child's output tokens must never reconcile the leader's response length), so a client showing a subagent's detail pane had nothing to print. The split table can therefore only be accumulated from the stream, and this guard pins what that costs. The two existing leader-only arms stay **byte-for-byte unchanged** — the new arm is additive and always carries the sub-flow's own lane proof, so a host cannot mistake a child's numbers for the session window. Attribution is by the engine's own originating-task id — deliberately not a second `taskId`, which the event identity does not carry and whose absence would silently collapse every child under one parent call — falling back to the parent call id; a turn that answers neither is dropped rather than filed under an invented row, because merging two children's ledgers is worse than missing one. Cache-read tokens are read from the **engine's own shape** rather than the mirrored one, since the mirror fills that member with zero when the wire omits it and reading it there would erase the difference between *not reported* and *no cache hit*. A turn that reported no usage at all still counts as a turn and still adds its zeros — the numbers are a lower bound, and dropping the round would make the bound less true, so the honesty bit rides on the row instead and is never spelled `false`; such a round still emits its live arm, because the frame that says "this round has no account" is the one a real-time consumer most needs and the easiest one to drop. The same honesty bit also survives a terminal that carries no statistics at all: what the stream observed is unioned with what the final record says, so a run that already reported an unmeasured round cannot come out the other end looking like an exact zero. Finally the table says whether it is **partial**, and that verdict is anchored on the quantity that actually decides it: the engine's own totals. Turn count and row count must both reconcile before the table claims to cover the whole run; anything else — including totals that cannot be read — marks it partial, so the failure direction is always the safe one (a complete table called partial, never the reverse). The two accounts are kept separate and are never added together or used to correct each other |
253
254
  | `scripts/run-result-text-backfill-test.mjs` | What happens when the terminal frame's answer text and the text already on screen do not match. A turn's answer normally streams in and the terminal frame carries the same words again, so the two agree — but when the connection drops mid-answer and the reconnect brings the finished version, "this turn already produced assistant text" is true, the terminal fallback is skipped entirely, and the screen stays permanently short of whatever arrived while the stream was down, with nothing to say so. Four cases are pinned. Nothing on screen yet: render the terminal text whole, byte for byte the previous behaviour. On-screen text is a **prefix** of the terminal text: emit only the missing tail, and the guard measures the deciding quantity — the total bytes that reached the screen must equal the terminal text, which fails both for a missing tail and for a re-render that would print the first half twice; when the two are already equal, nothing is emitted at all. Terminal text is a prefix of what is on screen (an engine-side trim): touch nothing, since there is nothing missing and overwriting with the shorter version would erase what the reader already saw. Neither is a prefix of the other: emit **nothing** and raise a fact instead — which version counts is the engine's to say, and appending the terminal version after the streamed one composes a passage nobody ever wrote. That fact carries lengths rather than text, so a renderer is not handed a third version to choose from, and its declared duty is to *reword* the transcript line, never to render more. A cross-segment case proves the comparison reads the whole committed answer rather than the last segment, and the whole thing is driven through the real two-stage path rather than hand-built messages |
254
255
  | `scripts/run-engine-vocab-floor-test.mjs` | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the *installed* `@sema-agent/core` |
255
256
  | `scripts/run-limits-env-failloud-test.mjs` | `SEMA_HEADLESS_*` env-lane limits reject invalid values as loudly as the flag lane (no silent "no budget" runs) |
@@ -723,8 +723,9 @@ async function staleParkArm(taskId, busy, runs, deps) {
723
723
  // —— 「不知道」和「行又回来了」都不构成销毁一条 run 的授权。
724
724
  // 🔴 **在册边界(P-44,异源对抗复审 R2 [high] 如实登记)**:这是「查了再做」,**不是原子条件取消**。
725
725
  // 复证与那一枪之间仍有毫秒级窗口,行恰在此间恢复的话那一枪照样落下去。客户端关不死它 ——
726
- // 真正的关法是引擎侧的**条件取消**(带审批快照版本 / checkpoint 标识,条件变了回 409 且不取消),
727
- // 那是 wire 能力不是壳能自造的语义。这里能做的是把窗口从「人看卡的任意长时间」压到最小,
726
+ // 真正的关法是 **server 读写面**的条件取消(core [7006] 定谳:core 无席——`TaskSpec.signal` 无条件中止、
727
+ // `claimTerminal` 是 ask 行 CAS 非 run lease;server [7007] 认领:cancel 已是两臂状态 CAS,7.73.0 契约明写 + 可选
728
+ // 版本前置,S-122 车),那是 wire 能力不是壳能自造的语义。这里能做的是把窗口从「人看卡的任意长时间」压到最小,
728
729
  // 并把剩余风险登记在册(docs/INTEGRATION-CLIENTS.md §7b P-44),不假装它不存在。
729
730
  const stillGone = await readOwnedPendingCount(recheckOwnedPending, deps);
730
731
  if (stillGone !== 0)
@@ -169,6 +169,20 @@ export interface WiringManifestAutoMode {
169
169
  armed: boolean;
170
170
  /** core **六词逐字透传**。🔴 不映射 `/v1/capabilities.permissionModeAuto.reason` —— 见投影函数头注。 */
171
171
  reason: string;
172
+ /**
173
+ * 0.67.0(core 7.14.0 `AutoModeArmFact.deniedSource`,`wiring-manifest.d.ts:87`)——
174
+ * **哪一层设置面**的棘轮把 auto 模式关掉了(core `AUTO_MODE_DENY_SOURCES`:`org` / `local` /
175
+ * `settings`)。解析器**自己的词,原样带过来,从不推断**(合同顶注 `:79-82` 逐字)。
176
+ *
177
+ * 🔴 **只在 `reason === "denied"` 旁在场**;`denied` 却没有它 = 那个解析器**不记来源**
178
+ * (一句正面事实,不是「不知道」的同义词);`resolver_fault` / `armed` / `no_intent` /
179
+ * `no_face` 上**恒不在场**(屏掉一个它拒绝了的来源是 core 自己做的)。
180
+ * 🔴 **开集读**(与同段 `reason` 逐字同规):词表属主在 core,包在边界抄一份闭集只会在 core
181
+ * 加词那天把一个合法值判没。端的 `switch` 必须带 `default`。
182
+ * 🔴 **缺席不铸**:绝不折成空串,更不折成 `"settings"` 这种看起来最像的默认值 —— 那是替引擎
183
+ * 指认一个它没点名的设置面,而用户会照着去改错的那一层。
184
+ */
185
+ deniedSource?: string;
172
186
  }
173
187
  /**
174
188
  * {@link wiringManifestSupersetBody} 的 `mcp[]` 一条目(S-124 / core 7.5.0,server ≥7.60.0 的形)。
@@ -1036,7 +1036,19 @@ function projectAutoModeSection(raw) {
1036
1036
  return undefined;
1037
1037
  if (armed !== (reason === 'armed'))
1038
1038
  return undefined;
1039
- return { armed, reason };
1039
+ // ── 0.67.0(core 7.14.0):`deniedSource` 补位 ───────────────────────────────────────────────
1040
+ // 见 {@link WiringManifestAutoMode.deniedSource}。本层**只在 `reason === "denied"` 上收** ——
1041
+ // core 的段内规矩是它只站在 `denied` 旁边,而这一条与上面那条 `armed === (reason === 'armed')`
1042
+ // 互证判据**同族**:非投影口(宿主自建管线 / 重放存量转录)喂进来的帧不过 server,一个
1043
+ // `{reason:'armed', deniedSource:'org'}` 会让消费端同时读到「武装了」和「被 org 关掉了」。
1044
+ // ⚠️ 与 `errorCode`/`origin` 的「不校配对」不同裁,理由也是上游自己给的:那些是**跨系统的
1045
+ // 不变量**(server 已按它铸),而本条是**同一条帧上的段内自洽**(gateOutcome.ts 顶注点名的
1046
+ // 那条反向先例,逐字就是本函数)。
1047
+ // 🔴 开集读 + 缺席不铸键(绝不折成空串/默认来源)。
1048
+ const deniedSource = reason === 'denied' && typeof a.deniedSource === 'string' && a.deniedSource.length > 0
1049
+ ? a.deniedSource
1050
+ : undefined;
1051
+ return { armed, reason, ...(deniedSource !== undefined ? { deniedSource } : {}) };
1040
1052
  }
1041
1053
  /**
1042
1054
  * `wiring_manifest` 帧 → 两个超集键的**纯投影**(公面导出;三端共用,壳侧绝不自抄一份形校验)。
@@ -210,6 +210,18 @@ export interface RunCostReconcile {
210
210
  readonly costAbsent?: true;
211
211
  /** `true` ⇒ 委派过,但那本账**没定价**(不是 0)。绝不铸 `false`。 */
212
212
  readonly nestedCostAbsent?: true;
213
+ /**
214
+ * 0.67.0(core 7.14.0 `TaskResult.stats.usageMissing`)—— `true` ⇒ 这条 run 上**至少有一轮**
215
+ * 没报 usage,所以本对账里的每一个数字(以及终帧 `usage` / `modelUsage` / `_sema_nested_usage`
216
+ * 的每一个 token 数)都是**下界**,不是一笔已知的账。
217
+ *
218
+ * 🔴 **`tokens: 0` 在这一位在场时读作「不知道」,不是「免费」**(core 合同逐字)。
219
+ * 🔴 **never false**:core 只在真缺 usage 时铸 `true`,缺席 ⇔ 每一轮都报了 usage ⇒ 本包同律
220
+ * **缺席不铸**(铸一个 `false` 就是替引擎说「我全都数到了」)。
221
+ * ⚠️ 它**与成本位正交**:`costAbsent` 说的是「没定价」,本位说的是「数得不全」——
222
+ * 一笔定了价、但少数了几轮的账,两位可以同时在场,渲染面要分别说。
223
+ */
224
+ readonly usageLowerBound?: true;
213
225
  }
214
226
  /** {@link readRunCostFacts} 的产物:两面(终帧超集键 / chrome 对账臂)各取所需。 */
215
227
  export interface RunCostFacts {
@@ -227,6 +239,41 @@ export interface RunCostFacts {
227
239
  * **这条帧没有账**,调用方据此「不说话」(不发臂、不铸键),而不是发一条全缺席的空账。
228
240
  */
229
241
  export declare function readRunCostFacts(stats: TaskStats | undefined): RunCostFacts | undefined;
242
+ /**
243
+ * L-228(0.67.0)—— 一只子任务在**这条流上被看见的**那本账(`_sema_nested_usage_by_task` 的行形)。
244
+ *
245
+ * 🔴 **它是流内累加的产物,不是引擎报的一个字段**:core 终局只有合计 `stats.nested`
246
+ * (`{tokens, turns, tasks, costMicroUsd}`),**没有 per-task 分项**(`task-result.d.ts` #594-660 亲验)。
247
+ * ⇒ 这几个数的唯一来源是子流 `turn_end` 的逐轮累加,而「这条流看见了多少」与「这条 run 一共有多少」
248
+ * 可以不相等 —— 那正是 {@link SemaNestedUsageByTask.partial} 存在的理由。
249
+ * 🔴 **没有 `costMicroUsd`**:`turn_end.usage` 上没有钱这一格(定价在终局做),编一个出来就是造账。
250
+ */
251
+ export interface SemaSubagentUsageRow {
252
+ /** 这条流上看见的该子任务 `turn_end` 条数(**不是** core 的 `nested.turns`,见顶注)。 */
253
+ readonly turns: number;
254
+ /** 逐轮 `inputTokens` 求和。`usageMissing` 在场时它是**下界**。 */
255
+ readonly inputTokens: number;
256
+ /** 逐轮 `outputTokens` 求和。同上。 */
257
+ readonly outputTokens: number;
258
+ /** 逐轮 `cacheReadTokens` 求和;一轮都没报过 ⇒ **键不在**(绝不铸 0 冒充「零命中」)。 */
259
+ readonly cacheReadTokens?: number;
260
+ /** `true` ⇒ 这只子任务**至少有一轮**没报 usage,本行三个数是**下界**。never false。 */
261
+ readonly usageMissing?: true;
262
+ }
263
+ /** {@link SemaSubagentUsageRow} 的累加中间态(runStream 持有;`readonly` 在收口那一拍才加)。 */
264
+ export interface MutableSubagentUsageRow {
265
+ turns: number;
266
+ inputTokens: number;
267
+ outputTokens: number;
268
+ cacheReadTokens?: number;
269
+ usageMissing?: true;
270
+ }
271
+ /** 终帧两个超集键的产物形(见 {@link nestedUsageByTaskParts})。 */
272
+ export interface SemaNestedUsageByTask {
273
+ readonly rows: Readonly<Record<string, SemaSubagentUsageRow>>;
274
+ /** 见 `_sema_nested_usage_by_task_partial`。 */
275
+ readonly partial: boolean;
276
+ }
230
277
  /**
231
278
  * D-2 族扫(0.66.0;异源对抗复审 [medium])—— **终局** per-model 行的 sema 超集位。
232
279
  *
@@ -212,8 +212,13 @@ export function readRunCostFacts(stats) {
212
212
  // 求和自身也必须是有限数:两个合法但极端的值相加可以溢出成 Infinity,而 Infinity 会被下游
213
213
  // 当成真数字摊进总计(与 `microUsdToUsd` 同一条规约)⇒ 宁可不铸。
214
214
  const sum = ownMicroUsd !== undefined && !nestedUnknown ? ownMicroUsd + (nestedMicroUsd ?? 0) : undefined;
215
+ // 0.67.0(core 7.14.0):usage 下界位。**严格 true 才铸**(core 契约:`true` 或缺席,恒不写
216
+ // `false`/`null`;认宽了就会把一个 falsy 值渲成「数得不全」)。它与成本三段正交,所以读在这里、
217
+ // 与三段同一只读器出 —— 两面(终帧超集键 / chrome 对账臂)因此永远不会各算各的。
218
+ const usageLowerBound = stats.usageMissing === true;
215
219
  const reconcile = {
216
220
  ...(ownMicroUsd !== undefined ? { ownMicroUsd } : { costAbsent: true }),
221
+ ...(usageLowerBound ? { usageLowerBound: true } : {}),
217
222
  ...(nestedMicroUsd !== undefined ? { nestedMicroUsd } : {}),
218
223
  ...(nestedUnknown ? { nestedCostAbsent: true } : {}),
219
224
  ...(breakdown?.compactionMicroUsd !== undefined ? { compactionMicroUsd: breakdown.compactionMicroUsd } : {}),
@@ -233,13 +238,80 @@ export function readRunCostFacts(stats) {
233
238
  * (超集键纪律:CC 形上已有的位不许塞我们自己的含义)。「fully-reconciled spend」由消费方按
234
239
  * 这两个超集键自己加 —— 包给的是**可对账的事实**,不是一个改了口径的数。
235
240
  */
236
- function costFactParts(stats) {
241
+ function costFactParts(stats, ctx) {
237
242
  const facts = readRunCostFacts(stats);
243
+ // 🔴 **下界位与成本三段分开算**(异源对抗复审 [medium] 实抓):`stats` 读不出(`failed` 事件帧 /
244
+ // 409 拒绝信封 / park 体)时**成本**那三段确实没有账、一条都不该说;但「这条流观测到过一轮
245
+ // 没有 usage」这件事**照旧成立**,而那种终帧的 `usage` 恰恰是 `flattenUsage(undefined)` 的
246
+ // 全零 —— 早退回空对象等于把「不知道」渲成精确零(正是本位要修的病的另一条路径)。
247
+ // ⇒ 两者**取并**:stats 说了算一半,流内观测算另一半。
248
+ const lowerBound = facts?.reconcile.usageLowerBound === true || ctx.usageMissingObserved === true;
249
+ const lowerBoundPart = lowerBound ? { _sema_usage_lower_bound: true } : {};
238
250
  if (facts === undefined)
239
- return {};
251
+ return lowerBoundPart;
240
252
  return {
253
+ ...lowerBoundPart,
241
254
  ...(facts.breakdown !== undefined ? { _sema_cost_breakdown: facts.breakdown } : {}),
242
255
  ...(facts.nested !== undefined ? { _sema_nested_usage: facts.nested } : {}),
256
+ // ── 0.67.0(core 7.14.0 `TaskResult.stats.usageMissing`)────────────────────────────────
257
+ // 🔴 CC 同名键**语义零改**:`usage` / `modelUsage` / `total_cost_usd` 的字节一个不动,本位是
258
+ // 一个 `_sema_` 超集**判别位**,说的是「上面那些数字是**下界**」。把它折进数字里(比如
259
+ // 把 tokens 抹成 null)会让每一个既有消费者当场坏掉,而它们今天读到的是一笔**看起来
260
+ // 已知**的账 —— 那正是本位要修的病。
261
+ // 🔴 **缺席不铸**(never false):缺席 ⇔ 每一轮都报了 usage。
262
+ // 🔴 与 §31d 的 `usage._sema_total_input_tokens` **同帧不同问**:那一位答「这一行报了多少总
263
+ // 输入」,本位答「这一整条 run 的账数全了没有」。
264
+ // (本位在上面与流内观测取并后已铸;这里不再重复。)
265
+ };
266
+ }
267
+ /**
268
+ * L-228 —— 流内子代分表 → 终帧两个超集键的**唯一 mint 点**(成功臂与错误信封共用)。
269
+ *
270
+ * **不导出**:它的入参是一个只有 `runStream` 攒得出来的累加表,推上公面等于邀请宿主自己攒一份
271
+ * 「看见了多少」的账(而那份账的 `partial` 判据在宿主手上不成立)。包侧门走**端到端**素材:
272
+ * 喂子流 `turn_end` + `done` 给 `runStream`,断言终帧上那两个键 —— 那也是三端真正拿到它的路。
273
+ *
274
+ * ── 🔴 `partial` 怎么判(设计定谳,与派车单的「恒铸于重连车道」不同,理由写在这里)────────────
275
+ * 派车单给的形是「本条流不是从 run 起点观测 ⇒ 恒铸 partial」。而本层**没有**「从不从起点观测」
276
+ * 这个读数:`runStream` 手上只有**同一条流内**的事件序号去重表(`seen`),它答的是「这一帧重放过
277
+ * 没有」,答不了「这条流之前还有没有别的帧」;`--resume` / 重连开出来的流与首开的流在本层
278
+ * **逐位不可分**。按一个读不出来的量铸判别位 = 编一件事实。
279
+ * ⇒ 判据改锚在**真正决定结果的量**上([anchor-on-the-deciding-quantity]):拿引擎自己的**权威
280
+ * 合计** `stats.nested` 对账 —— 分表的 `turns` 之和等于 `nested.turns` **且**行数等于 `nested.tasks`
281
+ * 时,这张表**可证**覆盖了整条 run ⇒ 不铸 partial;任何一边对不上、或 `stats.nested` 根本读不出来
282
+ * ⇒ **铸 partial**。
283
+ * 🔴 失效方向是安全的那一侧:上游哪天让某类嵌套轮不上子流 `turn_end`(比如更深一层的编排),
284
+ * 本判据只会**多**铸 partial(把一张其实完整的表说成不完整),**永远不会**把一张残表说成完整。
285
+ * 🔴 **两个键不互证、也不相加**:`_sema_nested_usage`(合计,引擎报的)与本表(流内看见的)是
286
+ * 两份独立的账;`partial` 在场时两者**本来就该不等**,消费方不许拿其中一份去「修正」另一份。
287
+ */
288
+ function nestedUsageByTaskParts(stats, rollup) {
289
+ // 🔴 一行都没有 ⇒ **什么都不说**:空表会被读成「这条 run 一个子代都没委派」,而真相可能是
290
+ // 「委派了,但这条流没看见任何一轮」(重连车道)。两句话不许折成一句。
291
+ if (rollup === undefined || rollup.size === 0)
292
+ return {};
293
+ const rows = {};
294
+ let turnsSeen = 0;
295
+ for (const [taskId, r] of rollup) {
296
+ turnsSeen += r.turns;
297
+ rows[taskId] = {
298
+ turns: r.turns,
299
+ inputTokens: r.inputTokens,
300
+ outputTokens: r.outputTokens,
301
+ ...(r.cacheReadTokens !== undefined ? { cacheReadTokens: r.cacheReadTokens } : {}),
302
+ ...(r.usageMissing === true ? { usageMissing: true } : {}),
303
+ };
304
+ }
305
+ const nested = stats !== null && typeof stats === 'object' && !Array.isArray(stats)
306
+ ? stats.nested
307
+ : undefined;
308
+ const authTurns = finiteOrAbsent(nested?.turns);
309
+ const authTasks = finiteOrAbsent(nested?.tasks);
310
+ const complete = authTurns !== undefined && authTasks !== undefined &&
311
+ authTurns === turnsSeen && authTasks === Object.keys(rows).length;
312
+ return {
313
+ _sema_nested_usage_by_task: rows,
314
+ ...(complete ? {} : { _sema_nested_usage_by_task_partial: true }),
243
315
  };
244
316
  }
245
317
  /** P1-5 — real elapsed ms since the stream opened (runStream stamps startedAtMs); 0 only pre-stamp. */
@@ -493,7 +565,10 @@ function errorResult(ctx, parts) {
493
565
  // D-1 / L-192①:两句话不再折成一句 —— 清单 + 「有没有这本账」的判别位,见 permissionDenialParts。
494
566
  ...permissionDenialParts(parts.stats),
495
567
  // D-3 / B-068:失败/到限/park 的 run 一样花过钱,账不因结局不好就不报。
496
- ...costFactParts(parts.stats),
568
+ ...costFactParts(parts.stats, ctx),
569
+ // L-228(0.67.0):流内 per-subagent 分表的收口快照(判据本体在 `nestedUsageByTaskParts`)。
570
+ // 🔴 与成本三段同理 —— 失败的 run 一样委派过,账不因结局不好就不报。
571
+ ...nestedUsageByTaskParts(parts.stats, ctx.nestedUsageByTask),
497
572
  errors: [...parts.errors],
498
573
  ...(parts.errorCode !== undefined && parts.errorCode.length > 0 ? { errorCode: parts.errorCode } : {}),
499
574
  ...(parts.degraded !== undefined ? { degraded: parts.degraded } : {}),
@@ -686,7 +761,10 @@ export function doneToSdkResult(ev, ctx) {
686
761
  // D-1 / L-192①:同形第二处 —— 与错误信封共用**同一个** mint 点(修前两处各一个字面量 [])。
687
762
  ...permissionDenialParts(stats),
688
763
  // D-3 / B-068:成本明细与子代那本账(micro-USD 原值);`total_cost_usd` 语义一字不动。
689
- ...costFactParts(stats),
764
+ ...costFactParts(stats, ctx),
765
+ // L-228(0.67.0):流内 per-subagent 分表的收口快照;与 `_sema_nested_usage`(引擎报的合计)
766
+ // 是**两份独立的账**,不相加、不互证(见 `nestedUsageByTaskParts` 顶注)。
767
+ ...nestedUsageByTaskParts(stats, ctx.nestedUsageByTask),
690
768
  // MF-25 — the effective served model id (`done.result.model`, e.g. "deepseek-v4-pro"). The CC
691
769
  // SDKResultSuccess schema has no `model` field, so this rides as an additive seam field a cost/overview
692
770
  // consumer reads (it is ALSO surfaced as the `modelUsage` key). Omitted when the wire didn't carry it.
@@ -310,6 +310,15 @@ async function* runStreamInner(events, ctx, handle = {}) {
310
310
  // projector can emit a REAL `duration_ms` (the wire carries no duration; the old hardcoded 0 was fake).
311
311
  if (ctx.startedAtMs === undefined)
312
312
  ctx.startedAtMs = Date.now();
313
+ // ── L-228(0.67.0):per-subagent turn usage 的**流内**累加表 ─────────────────────────────────
314
+ // 🔴 **每条流一张**(局部量,不是模块级):两条并发的流各自攒各自看见的账;做成模块级单例会让
315
+ // A 流的子代用量落进 B 流的终帧(本仓在册的「共享 store 跨流污染」病形)。
316
+ // 🔴 它**只在终帧那一拍**挂到 `ctx` 上(见下面 `done`/`failed` 分支),不是开流时就挂 ——
317
+ // 终帧的铸点在 `terminalToSdkResult`(单一 mint 点,见该文件顶注:给信封加一个位要改六处
318
+ // 正是它收编掉的病),而 `ctx` 是**调用方**的对象:宿主若把同一个 ctx 复用给两条并发的流,
319
+ // 开流时挂等于让后开的那条把先开的那条的表顶掉,先开的终帧于是报出别人的账。
320
+ // 挂在终帧那一拍 + 与 `terminalToSdkResult(...)` 在**同一个同步步**里,那个窗按构造不存在。
321
+ const nestedUsageByTask = new Map();
313
322
  for await (const ev of events) {
314
323
  // event-id idempotency — drop a re-seen durable seq (contract 02 §1.1).
315
324
  const seq = eventSeq(ev);
@@ -379,6 +388,14 @@ async function* runStreamInner(events, ctx, handle = {}) {
379
388
  // `usage` **可以同帧**)。把它剥掉,本批新开的这条 usage 通道就会把「不知道」渲成一笔
380
389
  // 全零的已知账 —— 与本批要根治的病(B-073 的成本 0/缺席)逐字同形,只是换了个量。
381
390
  const usageMissing = ev.usageMissing === true;
391
+ // 🔴 L-215③ 的**终局对偶**(异源对抗复审 [medium] 实抓):这条流上**只要有一轮**报过
392
+ // 「这一轮没有 usage」,终帧上那些数字就是**下界**。`stats.usageMissing` 只在带得出
393
+ // `TaskResult` 的终帧上有,而 `failed` 事件帧 / 409 拒绝信封 / park 体**根本没有 stats**
394
+ // ⇒ 只读 stats 的话,一条已经观测到缺口的 run 会在终帧上被读成「每一轮都报了 usage」
395
+ // (而 `usage` 那几格恰好是 `flattenUsage(undefined)` 的全零)——「不知道」渲成了精确零。
396
+ // ⇒ 流内观测到就记下来,终帧那一拍与 stats 的读数**取并**(见 costFactParts)。
397
+ if (usageMissing)
398
+ ctx.usageMissingObserved = true;
382
399
  const stopReasonRaw = ev.stopReason;
383
400
  const stopWord = typeof stopReasonRaw === 'string' && stopReasonRaw.length > 0 ? stopReasonRaw : undefined;
384
401
  const usage = turnEndUsage(ev);
@@ -419,6 +436,65 @@ async function* runStreamInner(events, ctx, handle = {}) {
419
436
  }
420
437
  }
421
438
  }
439
+ // ── L-228(0.67.0):子流那条腿 —— **additive 第三条腿**,主臂两处 `!isSubFlow` 断闸不动 ────
440
+ // 病(车 E 件⑥ 实抓):core 终局只有合计 `stats.nested`(**无 per-task 分项**),而流里每条
441
+ // 子流 turn_end 都带着它自己那一轮的 usage —— 本包此前在 §E2 断闸处**折而未读**,于是壳的
442
+ // 子代详情面只渲得出 `_sema_usage_absent`。⇒ 分表只能由流内累加得出,这就是那条通道。
443
+ // 🔴 **归属钥匙**:`sourceTaskId` 优先,wire 缺席时回落 `parentToolCallId`(同一只子代的每一轮
444
+ // 至少归得到同一行)。两个都读不出 ⇒ **整条不入表**(编一个 `"unknown"` 行就是把几只子代的
445
+ // 账混成一只)。身份位为什么**不是** `taskId`,见下面那段 🔴。
446
+ // 🔴 **`usageMissing` 与数字同帧并存**:core 明说那一轮的 usage 是 UNKNOWN 不是 0 ⇒ 数字照
447
+ // 累加(它是**下界**),判别位在行上立起来;把那一轮整个丢掉会让下界更假。
448
+ if (isSubFlow) {
449
+ const parentToolCallId = ev.parentToolCallId;
450
+ // 🔴 **身份键读 `sourceTaskId`,不是 `taskId`**(异源对抗复审 [high] 实抓,core 真字节直证):
451
+ // core `TaskEventIdentity` 顶注逐字「the `WorkflowRun.sourceTaskId` family, **NOT a second
452
+ // `taskId`** — that field already exists on `task_progress` and a duplicate would bite
453
+ // consumers」⇒ 子代内容事件上**根本没有** `taskId` 这一位,读它恒缺席、恒回落到父调用 id,
454
+ // 于是**同一个父调用下的多只子任务会并成一行**(sdk 的 `turn_end` 臂也没有声明任何身份位,
455
+ // 两条腿都读 cast —— 这正是「按 d.ts 抄,不按印象猜」那条纪律要防的形)。
456
+ const rawSourceTaskId = ev.sourceTaskId;
457
+ const parent = typeof parentToolCallId === 'string' && parentToolCallId.length > 0 ? parentToolCallId : undefined;
458
+ const sourceTaskId = typeof rawSourceTaskId === 'string' && rawSourceTaskId.length > 0 ? rawSourceTaskId : undefined;
459
+ // 行键:`sourceTaskId` 优先,缺席回落父调用 id(同一只子代的每一轮至少归得到同一行)。
460
+ // ⚠️ 回落**有损**:同父调用多子任务会并成一行 —— 那时终帧那张表的 `partial` 判别位会因
461
+ // 行数对不上 `nested.tasks` 而立起来(诚实缺席优先于假装分得开)。
462
+ const taskId = sourceTaskId ?? parent;
463
+ if (taskId !== undefined && parent !== undefined) {
464
+ // 🔴 发臂条件与主臂 0.65.1 / B-088 **逐字同族**:core 真会发**裸**
465
+ // `{type:'turn_end', usageMissing:true}`(无 usage、无 stopReason),旧条件「有 usage 才发」
466
+ // 会让**最诚实的那一帧**整条静默 —— 那是本仓已定谳的病形,子代这条腿不许再犯一次。
467
+ // 三者任一在场即发;三者皆缺席仍不发。
468
+ if ((usage !== undefined || usageMissing || stopWord !== undefined) && ctx.emitChrome) {
469
+ emitChromeFireAndForget(ctx, {
470
+ kind: 'subagent_turn_usage',
471
+ laneProof: { lane: 'subagent', parentToolCallId: parent },
472
+ taskId,
473
+ ...(sourceTaskId !== undefined ? { sourceTaskId } : {}),
474
+ parentToolCallId: parent,
475
+ ...(usage !== undefined ? { usage } : {}),
476
+ ...(ev.usage !== undefined ? { engineUsage: ev.usage } : {}),
477
+ ...(usageMissing ? { usageMissing: true } : {}),
478
+ ...(stopWord !== undefined ? { stopReason: stopWord } : {}),
479
+ });
480
+ }
481
+ const row = nestedUsageByTask.get(taskId) ?? { turns: 0, inputTokens: 0, outputTokens: 0 };
482
+ row.turns += 1;
483
+ row.inputTokens += usage?.inputTokens ?? 0;
484
+ row.outputTokens += usage?.outputTokens ?? 0;
485
+ // 🔴 `cacheReadTokens` 读的是**引擎原形** `ev.usage`,不是 CC 镜像:镜像的
486
+ // `cacheReadInputTokens` 是**必填** number,缺席在那儿已经被折成 0
487
+ // (`toCcModelUsage` 的 `finiteOrZero`)⇒ 从镜像读就再也分不出「没报」与「零命中」。
488
+ // ⇒ 一轮都没报过 ⇒ 键**不铸**;报过之后再加 0 的那些轮是真的零命中。
489
+ const cacheRead = ev.usage?.cacheReadTokens;
490
+ if (typeof cacheRead === 'number' && Number.isFinite(cacheRead)) {
491
+ row.cacheReadTokens = (row.cacheReadTokens ?? 0) + cacheRead;
492
+ }
493
+ if (usageMissing)
494
+ row.usageMissing = true;
495
+ nestedUsageByTask.set(taskId, row);
496
+ }
497
+ }
422
498
  const outputTokens = ev.usage?.outputTokens;
423
499
  // 🔴 异源对抗复审 [medium]③:发臂条件从「有 outputTokens」放宽到「**有话可说**」——
424
500
  // core 会发 `{type:'turn_end', usageMissing:true, stopReason:'error'}` 这种合法帧,而
@@ -588,6 +664,10 @@ async function* runStreamInner(events, ctx, handle = {}) {
588
664
  emitChromeFireAndForget(ctx, { kind: 'run_cost_reconciled', laneProof: MAIN, ...costFacts.reconcile });
589
665
  }
590
666
  }
667
+ // L-228:见本表声明处的头注 —— 挂表与铸终帧在**同一个同步步**里,复用 ctx 的并发流不会串账。
668
+ // (`usageMissingObserved` 是 per-ctx 的**单调布尔**,没有「顶掉别人」这一形:复用 ctx 的两条流
669
+ // 里只要有一条观测到缺口,两条的数字就都该按下界读 —— 取并是安全的那一侧。)
670
+ ctx.nestedUsageByTask = nestedUsageByTask;
591
671
  yield terminalToSdkResult(ev, ctx);
592
672
  return;
593
673
  }
@@ -36,6 +36,7 @@
36
36
  import type { AgentEvent } from '@sema-agent/sdk';
37
37
  import type { ModelUsage, SDKMessage } from '@sema-agent/agent-types';
38
38
  import type { ChromeEvent } from '../seam.js';
39
+ import type { MutableSubagentUsageRow } from './downstream/terminalToSdkResult.js';
39
40
  export type { ModelUsage, SDKMessage };
40
41
  export type StampedAgentEvent = AgentEvent & {
41
42
  id?: string;
@@ -100,6 +101,32 @@ export interface EmitContext {
100
101
  * · sink 抛错**绝不影响流**,并且痕迹落回 console —— 让位的前提是它真接住了。
101
102
  */
102
103
  onDroppedFrame?(info: DroppedFrameInfo): void;
104
+ /**
105
+ * L-228(0.67.0)—— **这条流上看见的 per-subagent turn 用量分表**,键 = 子任务 id
106
+ * (wire 缺席时回落 `parentToolCallId`)。终帧的两个超集键
107
+ * `_sema_nested_usage_by_task` / `_sema_nested_usage_by_task_partial` 由它铸出
108
+ * (唯一 mint 点 = `terminalToSdkResult.ts` 的 `nestedUsageByTaskParts`)。
109
+ *
110
+ * 🔴 **由 `runStream` 在流内写,宿主不要自己填** —— 与同接口的 `startedAtMs` 同一类
111
+ * (「请求是宿主构造的,这一格是驱动自己攒的」)。宿主塞一份进来 = 把一份**不是这条流看见的**
112
+ * 账当成这条流的,而下游那个 `partial` 判别位恰恰是靠「这条流看见了多少」才成立的。
113
+ * 🔴 **缺席 / 空表 ⇒ 终帧两个键都不铸**:空表会被读成「一个子代都没委派」,而真相可能是
114
+ * 「委派了但这条流没看见任何一轮」。
115
+ */
116
+ nestedUsageByTask?: ReadonlyMap<string, MutableSubagentUsageRow>;
117
+ /**
118
+ * 0.67.0 —— **这条流上观测到过「某一轮没有 usage」**(core 的 `turn_end.usageMissing`)。
119
+ * 终帧的 `_sema_usage_lower_bound` 与 chrome 对账臂的 `usageLowerBound` 与 `stats.usageMissing`
120
+ * **取并**读它。
121
+ *
122
+ * 🔴 **为什么非有它不可**:`stats.usageMissing` 只在带得出 `TaskResult` 的终帧上有,而
123
+ * `failed` 事件帧 / 409 拒绝信封 / park 体**根本没有 stats** ⇒ 只读 stats 的话,一条**已经
124
+ * 观测到缺口**的 run 会在终帧上落成「判别位缺席」,而按新合同那读作「每一轮都报了 usage」——
125
+ * 偏偏那种终帧的 `usage` 是 `flattenUsage(undefined)` 的**全零**:「不知道」被渲成了精确零。
126
+ * 🔴 **由 `runStream` 在流内写,宿主不要自己填**(同 `nestedUsageByTask` / `startedAtMs`)。
127
+ * 🔴 **只置 `true`,从不置回 false**:一轮不知道,整条流的数字就是下界,后面的轮补不回来。
128
+ */
129
+ usageMissingObserved?: true;
103
130
  }
104
131
  /** Stamp `uuid` + `session_id` onto a freshly-built arm body. */
105
132
  export declare function stamp<T extends {