@sema-agent/client-core 0.70.1 → 0.71.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,43 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.71.0(2026-09-16)
53
+
54
+ > 主题:**core 7.20.0 提货**(闭集 +5 = 六十四码 / `requestedBudgetBytes` 删 ⇒ `laneClamped` 整只退役)+ CC-08 提货批(sdk 9.6.0)+ CC-16 + `MAX_AGENT_SKILLS` 常量删。**minor**:型面 BREAKING = `SkillsListingTruncatedFactsView` 删两键 + `MAX_AGENT_SKILLS` 删。接入面 §42。
55
+
56
+ ### Changed
57
+
58
+ - 🔴 **`SkillsListingTruncatedFactsView` 删 `requestedBudgetBytes?` 与派生 `laneClamped?`**(core 7.20.0 #818:8 KiB 结构天花板退役,声明比例即实际预算,detail 键删)。本包是该键唯一真消费方;派生位没有输入就永不为真,**整只退役**而不是留着恒 false。老引擎 7.19.0 仍发该键时视图**不铸不派生**(在场不报错)。运维面只读 `budgetBytes`;§39 S-2 / 39y ② 文档句同批改口。码册门 G2 三格改口。
59
+ - 🔴 **peer `@sema-agent/sdk` 地板 `>=9.4.0` → `>=9.6.0`**(型面 BREAKING 之一;运行期零 BREAKING:老 server 不发新帧);**peer `@sema-agent/agent-types` `>=0.2.0` → `>=0.3.0`**(devDep 同步抬;type-superset 门方向 ④ 的 CC 撞名预警此前对着盘上 0.2.0 求值,0.3.0 新加的位不会触发红 —— 接入方七轴审查查得;抬后跑一次零撞名)。`tool_disclosure` / `tool_progress` 两帧型与 `ToolApprovalFrame.readRootCandidate` 自 9.6.0 起;sdk-floor 门 FLOOR / public-surface C-CC-3 / README / §0a 同批。
60
+ - **CC-08 ① `tool_disclosure` 帧 → 中性内部臂 + chrome 臂 `tool_disclosure`**(core design/393 S3 第二缝):`policy` 开集三词 / `thresholdPercent?`(🔴 缺席 = 两档下没查过阈值,不是默认百分比;坏值只丢键)/ `deferred[]` / `activated[]`(全量快照整只替换;数组内非串空串逐个滤 = server 律)。三只必需位缺一 ⇒ `malformed`(半张普查表会被读成「只有这些被延迟」)。三键子流断闸同族。臂表 `required: false`。
61
+ - **CC-08 ⑤ `tool_progress` 帧 → 中性内部臂 + chrome 臂 `tool_progress`**(DV-741,clay C-R50 裁「一帧两拍」入 divergence 册,本包按裁定形投,不再 `dropped(unsupported_arm)`):`toolCallId` / `toolName` 必在(缺 ⇒ `malformed`:接不回自己 `tool_start` 的 tick 挂不上卡);`elapsedSeconds?`(🔴 缺席不是 0,不等于 `tool_end` 的 duration);`output?` 在场 = 命令尾巴窗口(🔴 UNTRUSTED RAW,与 `tool_end.output` 同类,宿主可再封顶展示、**绝不回喂**模型)+ `totalLines?` / `totalBytes?` 与它同进同退。三键子流断闸同族(子代工具进度不上 leader 面)。臂表 `required: false`。新门 `run-tool-disclosure-progress-projection-test` 30 格;变异「progress 去断闸 ⇒ D1 红」。
62
+ - **`ToolApprovalFrame.readRootCandidate?: { dir; clearsThisAsk: true }`**(core 7.19.0 #803 根外读卡附读根候选;sdk 9.6.0 `TOOL_APPROVAL_FRAME_KEYS` 第 30 员):镜像 +1(approval-frame-keys 门双向等值);🔴 只按在场渲、`dir` 原样回传显完整 dir;缺席 = 今日行为。`APPROVAL_CARD_KEYS` 两版相同,卡腿零动作。
63
+ - **CC-08 ① `wiring_manifest` 第四/五/六段进切片 + `tool_roster_delta` 接上**(L-315 收口):chrome `wiring_manifest` 新带 `tools?: ToolRosterView`(读器归 `toolRoster.ts`,0.63.0 起在)/ `hooks?: WiringManifestHookEntryView[]`(family / owner / observational never-false;坏行逐条丢,全坏键缺席)/ `lsp?: WiringManifestLspSeamView`(`mounted` 布尔必在否则段缺席;`lane` 缺席 ≠ 没挂,两键禁合读);`tool_roster_delta` 从有痕 `dropped(unsupported_arm)` 改为中性内部臂 + chrome 臂(`delta: ToolRosterDeltaView` = 已窄读的 fromDigest / roster / summary?;`fromDigest` 缺或 roster 读不出 ⇒ `malformed`;包不持名册,宿主拿它喂 `applyToolRosterDelta`)。新导出 `projectToolRosterDelta` + `ToolRosterDeltaView`。
64
+ - **CC-08 ③ `context_usage` 进切片**(此前 not_in_slice):中性内部臂 + chrome 臂,三标量走 finite 门(非有限即缺席,永不 null)+ `sections?: ContextUsageSectionView[]`(core 7.18.0 #790;坏行逐条丢、全丢键缺席 = 没装配出段 IR);🔴 义务:每个压缩边界一条按**最后一条**取值、`usedTokens > compactAtTokens` 是引擎的谓词别重算、`windowTokens` 是 autocompact 窗、`sections[].tokens` 与 `usedTokens` **禁相减**。
65
+ - 🔴 **CC-20 `decide.workflow_host_not_parked` 退役**(型面 BREAKING;接入方七轴审查查得):server 7.72.0 已退役该码且无别名(sdk `errors.d.ts` ⚰️:≥7.72.0 永不再发,那条车道改铸新 run 不再拒),本包却仍留常量 `DECIDE_WORKFLOW_HOST_NOT_PARKED`、三码闭集成员、出路句与 `resendable: true` 四处读点 —— 一起 clean-cut:闭集两员、`decideRefusalFromError` 对它返 `null`;退役词进 census 门登记(源码零命中 + 公面无该常量,上游若回来先红)。公面 987 → 986。
66
+ - **注释坐标核记**(接入方七轴审查):`settings-schema` devDep `^2.0.0` 零 import、只在三处源注释引 1.9.0 / 1.10.0 引入的形(`McpServerSpec.toolFaces` / `ModelEntry.compat` / mcp 段透明键),对着实装 2.0.0 `dist/types.d.ts` 逐一核过同形并标注;devDep 保留(portability / typeshape / model-capability-probe / additive-key-passthrough 四门读它)。
67
+ - **CC-19 `mcpEngineLegPresence(view)`**(接入方 L-335 归包;归层复审 cli 1.0.116 查得):引擎侧 MCP 在场三值 `present` / `absent` / `unknown`,只从 `projectMcpPanel` 视图求(`servers` 非空 / `lastLegMcp` 在场 / `lastLegMcpUnreadable` / `degraded` 任一 ⇒ present;视图读得出且全不成立 ⇒ absent 正面事实;视图读不出 ⇒ unknown 本包说不出)。🔴 `unknown` 不许渲成「一台 MCP 都没配」;有引擎接线的端在 unknown 上开面板让「未观测」句说话。壳里那份算式改调本口。mcp-panel 门 K 段七格。公面 +1。
68
+ - 🔴 **CC-08 ④ `WiringManifestMcpEntry` → `WiringManifestMcpEntryView`**(型面 BREAKING,[7296] 已提前点名):与 sdk 9.4.0 起的同名型「同名不同形」影子收敛,改名 View + 编译期钉(sdk 行形的每一键本视图都有,sdk 加键当天红)。两边都 import 的端改名即可。
69
+ - 🔴 **`MAX_AGENT_SKILLS` 常量删**(0.70.2 退役保留一版;server 7.78.1 起 `agents[].skills` 无条数帽)。公面 986 → 986(−1 +1 `projectToolRosterDelta`)。wiring 门 +F/G/H/I 四段。
70
+ - 🔴 **CC-16 `text_segment_end` 交出本窗口已提交文本行 `committedUuids[]`**(与思考面 CC-11 同律;接入方归层复审查得的缺口)。修前只带 `segmentId`,持有转录的端要自己跟踪「本窗口过境了哪几条盖着该 id 的行」,窗口内 ≥2 条(两个工具卡)时端整发放弃 = 未脱敏字节留盘。修后 `emitAnswerSegment` 铸行处登记、`replaceAnswerSegment` 一次交全(按序;段边界**之前**提交的行;`[]` = 零行在场但空;整键缺席只留给零经手形 f)+ `committedUuid` 末条兼容位;窗口轮换清账 ⇒ 常态段定稿行天然属下一窗口不在列。**端义务改口**:`committedPrefixDiverged` ⇒ 按 `committedRows[]` 逐行写回(见下一条);删跟踪与歧义闸。这也把 0.70.1 CC-17 收窄留下的多行跨界形交给端按同一义务处理。TSA Z 段十格;变异「铸行不登账 ⇒ Z1a 红」。
71
+ - 🔴 **CC-16 补:`text_segment_end.committedRows[]`(端要写回的最终正文,按行)**(发车前异源对抗复审第 1 轮查得):提交行可能是「上一段已封存的定稿 + 本段那截」**一条**(封存只换字节不切消息),端按「首行整行换成 `content`」义务替换会把上一段的权威正文一起删。修后只在 `committedPrefixDiverged` ∧ 本窗口有已提交行时在场:首行 = 前段定稿前缀 + `content`、其余行 `''`,端逐行写回零算术;b1 形(前缀对得上)与零行形不给。新导出型 `TextSegmentCommittedRow`。第 2 轮同族再抓一条:多行跨界(两卡 + 段末前有尾巴)交出 `committedRows` 后包仍把原始尾巴提交成第三行 ⇒ 端写回后重复正文 + 未脱敏残留;修后交出归属即清本段未提交尾巴、两本补差账按端写回后的屏同步,终帧补差落「同一段话」不再补吐;第 3 轮再收一条:补差账按**消息**重建(首行所在消息里窗口之外已独立提交的定稿前缀不丢,否则终帧误报分岔)。🔴 端不写回 `committedRows` 时尾巴缺席(明文不落盘)而非照旧上屏 —— 与 0.70.1 单行形同一前提,不做能力协商双轨(Z9e 对照格)。第 4 轮再收一条:`committedRows` 在场时**一律**按消息分组重建账本(行归属确定,零启发式),`endsWith` 快捷分支只在零行时回退 —— 重复正文会让它误命中、跳过重建 ⇒ 终帧再补一次。TSA 门 Z8 / Z9 各四格 + D8 / D9 各四格(按消息重建 / 重复正文)(混合行 / 多行写回 / b1 不给 / 首段不给;尾巴不再提交 / 模拟写回零残留 / 零补吐 / 活体零泄),D7 总不变量与 Y7 / D29 / D30 按新前提改口。
72
+ - 🔴 **`wiring_manifest` 三键子流断闸 + 身份透传**(同一轮查得):修前投影口只留 `eventId`、臂只按 `parentToolCallId` 断闸 ⇒ 带 `sourceTaskId` / `bgAgentId` 的子代接线快照上了 leader 面(0.71.0 起含 `tools` 名册,按 §42 义务消费的端会拿子代名册当重同步基线)。修后投影口走三键读器(在场非串 ⇒ `malformed`)、臂按 `isSubFlowSegmentEnd` 断闸(与 `tool_roster_delta` 同律)。wiring 门 J 段七格。
73
+ - 🔴 **三键身份空串透传**(同一轮查得):`tool_disclosure` / `tool_progress` / `tool_roster_delta` / `context_usage` / `wiring_manifest` 共用的三键读器修前只抄非空串,`sourceTaskId: ''` 既不 `malformed` 也不保留 ⇒ 子代帧被洗成主流。修后三键在场即抄(含空串,与 `text_end` 投影和「键在场 = 子代帧」断闸律一致);`eventId` 仍非空才带。tool-disclosure 门 B8 + D2 四格。
74
+ - **门体系两处假绿收口**(接入方七轴审查 R1 / R2):负控普查总账从 `16 + 7 + 51 === 74` 常量恒真式改**读盘** —— 三分类各登记名单(自动化 / 盲门 / 行为套),并集逐名 = 盘上门集 = 名单文件,两两不交,加门必答「要不要负控」(此前 8 只门从未分类:3 只读上游 dist 的登盲门附复演步骤,5 只行为套);新登记物 `scripts/skip-baseline.json` + runner skip 棘轮(名单外 SKIP / 超上限 / 名单陈腐名 ⇒ `SKIP-RATCHET` 计入 FAILED),判别器先在合成名单上自证。
75
+ - **`ENGINE_NOTICE_CODES` +5 = 六十四码**(顺序同源):`config.task_root_not_canonical` / `config.execution_env_capability_invalid`(紧跟 `artifact_host_invalid`)/ `config.forward_sink_failed`(紧跟 `tool_face_invalid`)/ `memory.restore_withheld`(紧跟 `consolidation_withheld`)/ `agents.listing_truncated`(紧跟 `skills.listing_truncated`),受众均 operator;devDep core `~7.19.0` → `~7.20.0`。`agents.listing_truncated` 的 detail 读器本版不出(候消费场景)。
76
+
77
+ ## 0.70.2(2026-09-16)
78
+
79
+ > 主题:**CC-18 `agents[].skills` 两条孪生帽退役** —— 0.70.0 那批(`SKILL_CAPS` 三键)的**同形存量**,同轴的这两条当时漏扫。接入面 §41。
80
+
81
+ ### Fixed
82
+
83
+ - 🔴 **CC-18 `agents[].skills` 不再被条数帽与名字长度帽静默削掉**(接入方实撞)。`src/agentsWireCaps.ts` 两处:
84
+ - **条数帽**:`joined.slice(0, MAX_AGENT_SKILLS)` 在第 10 条处截 + 一行运维 warn ⇒ 声明 11 个以上技能的 agent **静默丢技能**,用户看到的只是「我声明的第 11 个技能没生效」。server 7.78.1 的 `agents[].skills` 臂已删 `v.length <= MAX_AGENT_SKILLS`(7.78.0 及以前仍有),改按字节预算渲染清单。
85
+ - **名字长度帽**:join 时要求 `spec.name.length <= MAX_AGENT_NAME_CHARS`,名字超 64 的技能被滤掉后落进 `missing`,再被措辞说成「not found in the loaded skill set」—— 它明明加载了,那句话是假的。server `skillOk` 里 name 只判非空。
86
+ 🔴 **射程止于这两条**:`MAX_AGENT_NAME_CHARS` 管的是 **agent 自己的名字**(server 仍逐字判非空且 ≤64,超了整请求 400),`MAX_AGENT_SKILL_CONTENT_CHARS`(1MB)server `skillOk` 至今仍判 —— 两条都**不退**,退了就是把响亮的 400 换成静默通过。
87
+ `MAX_AGENT_SKILLS` 常量本身**已退役但保留导出**(不再被任何投影读),只为本版不破型面,随下一个 minor 删。判据:pure 门 CC-18a–e 五格(含两条反向自证:真没加载的仍 warn 并剪、content 超 1MB 仍剪);变异三发(条数帽回来 / 名字滤回来 / content 帽也退)各红。
88
+
52
89
  ## 0.70.1(2026-09-16)
53
90
 
54
91
  > 主题:**CC-17 推理/文本面安全修复** —— `handOffOnly` 形(段跨包侧边界 ∧ 已提交前缀自身过期)同步两本补差账 + 清未提交尾段。接入面 §40。
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.70.1
38
+ **Version:** 0.71.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
@@ -67,7 +67,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
67
67
  against — the tables live upstream precisely so this package does not keep a second copy that can
68
68
  fall behind. The browser bundle really bundles the SDK through (the portability guard would
69
69
  exit 3 rather than quietly mark it external).
70
- - The declared floor is `>=9.4.0` (raised from `>=8.8.0` in 0.69.0: the `reasoning_end` frame and `McpStatusPanel.lastLegMcp` are typed from 9.4.0 on), and it is *witnessed*: the guard checks that an actually
70
+ - The declared floor is `>=9.6.0` (raised from `>=9.4.0` in 0.71.0: the `tool_disclosure` / `tool_progress` frames and `ToolApprovalFrame.readRootCandidate` are typed there; earlier: raised from `>=8.8.0` in 0.69.0: the `reasoning_end` frame and `McpStatusPanel.lastLegMcp` are typed from 9.4.0 on), and it is *witnessed*: the guard checks that an actually
71
71
  installed SDK at that line still exports every value-level symbol this package imports and still
72
72
  declares `TaskStats.costMicroUsd` (the key `costOrNull` reads). A floor nobody ever ran is a
73
73
  promise, not a contract.
@@ -306,7 +306,8 @@ public-surface guard checks that last one).
306
306
  | `scripts/run-gate-vocabulary-test.mjs` | The two gate vocabularies — who denied a call (`DeniedBy`, nine words) and who asked about it (`AskOrigin`, eleven) — together with the one place their sentences are minted, so the same denial does not read three different ways across three clients. The tables are copies, not opinions: the gate parses the members straight out of the installed SDK's declarations and reconciles them against the package's tables in both directions, so a word added upstream (nobody renders it, the user sees a bare code) and a word only the package believes in (a branch that can never fire) both fail. Every word must carry its own literal sentence and no two may collide, including the sibling pairs the upstream deliberately split apart — an organization store and a personal rule store being unreadable send you to different people, and the two tighten origins exist precisely to name which layer of engine logic asked. The two fallbacks are pinned distinct because the sets differ in kind: one is genuinely closed on the wire (an out-of-set record is withheld by the engine, so reading one means the record is damaged) while the other is genuinely open (the server only checks for a non-empty string, so an unknown word just means the client is older than the engine) Alongside them sits an **uplift anchor** rather than a third table: the reason a call was decided the way it was is a distinct semantic face from who denied it and who asked, one upstream has not mirrored into the SDK at all, and one whose newest member — a shell command allowed because it only reads — has no sentence anywhere yet. Minting the union here would create the second drifting source the day upstream publishes it, so the guard instead asserts the **absence** from both ends: the SDK declarations carry no such union near that word, and the installed engine’s own list does not carry the word either. The engine end fires first, on the batch that raises the dependency, which is exactly when the ownership question should be answered; the SDK end fires when the mirror lands. Either red is the work order to mint the sentence, never a reason to delete the anchor |
307
307
  | `scripts/run-engine-identity-test.mjs` | The engine generation anchors on `/health` (`pid`, `instanceId`, `startedAt`; engine >=7.67.0). `/health` is the one unauthenticated door and its heartbeat is always green, so "another host restarted the shared engine" used to be discoverable only by having some authenticated request hit a 401 first — a path that misreads a restart as a network fault. The reader narrows each anchor independently (one malformed field never hides the other two) and always hands back a reading object rather than an absence, because the caller is asking which anchors answered, not whether there was a response. The comparison is a three-word verdict, not a boolean: `unknown` when the two readings share no comparable anchor at all — an empty intersection means nothing could be compared, never that nothing changed — and the boolean convenience is pinned so that only `true` is an assertion. Any comparable anchor differing decides `changed`, so a reading whose `startedAt` matches while its `instanceId` does not cannot be waved through as the same life; precedence only decides which anchor gets named in the diagnosis |
308
308
  | `scripts/run-posture-knob-projection-test.mjs` | The three deployment knobs on the operator face (`serverGates.durableApproval` / `streamAskWindowMs` / `sessionAutoTitle`, engine >=7.67.0), each read as a value **plus who set it plus one operator-facing pointer** rather than a bare value — a bare boolean cannot answer why this particular machine is on this setting or how to pin it back, and a default that flips with the deployment shape is invisible without that. A worker too old to report readings still sends a bare boolean; the reader folds it into the same shell so consumers keep one branch, but raises a `legacy` bit, answers `undefined` from the machine-readable source accessor, and mints a sentence that contains no source word at all — claiming a source nobody reported is worse than admitting the worker cannot say. The other two knobs are honestly absent on such a worker rather than defaulted, a malformed side knob drops only itself while the anchor knob drops the whole reading, and the four sentences are pinned literally distinct so an operator can tell "not observed" from "not reported" from a real value. The last leg reads the installed SDK's `openapi.yaml` and `types.d.ts` directly, including a pin that exactly one knob on this face is numeric — the premise the millisecond-to-prose rendering rests on |
309
- | `scripts/run-mcp-panel-projection-test.mjs` | The `GET /v1/sessions/:id/mcp` panel reader (`projectMcpPanel`; server >=7.77.0 adds the optional `lastLegMcp` key) and the single wording mint for its "last leg" line. Absence of `lastLegMcp` is one literal sentence that never blames the engine version (a new session, a leg outside the retention window, a leg without a manifest and an older engine all look the same on the wire); a key that is present but unreadable is a different sentence plus a `lastLegMcpUnreadable: true` mark, never folded into absence. The `mcp[]` roster goes through the same reader as the live `wiring_manifest` third section, so a replayed roster and a live one have one shape. The two faces of the panel (`servers[]` and the last-leg roster) may legitimately differ, so the view carries no agreement flag and none of the five sentences mentions `servers`. Required keys are pinned to the SDK `openapi.yaml` component bytes **0.69.0:** `fetchMcpPanel` fetches the panel through the SDK client's own `sessions.mcp` call (same transport and auth as every other read) and projects it; transport failure, an unreadable body and an empty session id all come back as `undefined`, never as a fabricated empty panel |
309
+ | `scripts/run-tool-disclosure-progress-projection-test.mjs` | The two wire arms sdk 9.6.0 adds — `tool_disclosure` (name-only tool census: open-set `policy`, `thresholdPercent` absent ≠ default, `deferred`/`activated` full snapshots) and `tool_progress` (one frame, two beats: Bash ticks carry an output tail with `totalLines`/`totalBytes` that come and go together; other tools carry only `elapsedSeconds`) — project to neutral internal arms plus chrome arms. Required keys missing ⇒ `malformed`; bad optional keys drop only themselves; the sub-flow three-key gate keeps child frames off the leader lane; both arms are `required: false` in the arm table with duties stated (the output tail is untrusted raw and must never be fed back to the model). |
310
+ | `scripts/run-mcp-panel-projection-test.mjs` | The `GET /v1/sessions/:id/mcp` panel reader (`projectMcpPanel`; server >=7.77.0 adds the optional `lastLegMcp` key) and the single wording mint for its "last leg" line. Absence of `lastLegMcp` is one literal sentence that never blames the engine version (a new session, a leg outside the retention window, a leg without a manifest and an older engine all look the same on the wire); a key that is present but unreadable is a different sentence plus a `lastLegMcpUnreadable: true` mark, never folded into absence. The `mcp[]` roster goes through the same reader as the live `wiring_manifest` third section, so a replayed roster and a live one have one shape. The two faces of the panel (`servers[]` and the last-leg roster) may legitimately differ, so the view carries no agreement flag and none of the five sentences mentions `servers`. Required keys are pinned to the SDK `openapi.yaml` component bytes **0.69.0:** `fetchMcpPanel` fetches the panel through the SDK client's own `sessions.mcp` call (same transport and auth as every other read) and projects it; transport failure, an unreadable body and an empty session id all come back as `undefined`, never as a fabricated empty panel 0.71.0 adds section K: `mcpEngineLegPresence(view)` — the engine-side MCP presence tri-state read only off the panel view (`unknown` when the view could not be read, never rendered as "no MCP configured") |
310
311
  | `scripts/run-core-value-ports-test.mjs` | The port-injection seam for ten **engine value-level** facilities (autonomous-loop prompt assembly, permission-rule loosening, tool-policy composition, protocol/retired-name/grammar lookups, rule compilation, the discussion workflow name). This package cannot re-export them (the engine barrel drags Node built-ins into the browser bundle), so it declares the ports and honest-absence readers; a Node host installs the engine's own functions verbatim. The guard pins: every reader returns `undefined` when nothing is installed (never a fabricated empty array or default policy), arguments and results pass through by reference, engine errors propagate unchanged, partial installs read partially, restore functions unwind to the previous bag, and the module source has zero engine imports |
311
312
  | `scripts/run-read-face-posture-projection-test.mjs` | The operator-face `readFace: ReadFacePosture` reader (server >=7.65.0). Three ways of "can't say" are pinned to three different, literal sentences, and none of them may read as "nothing is pinned" — that statement belongs to exactly one case, `face: null`, which is a positive fact reported by the engine, not an absence: not having read an operator response yet, having read one from an engine too old to report the key, and the engine actually saying nothing is pinned are three different next steps for an operator and must not collapse into each other. `source` is read as an open set (the server's closed four words plus an escape hatch) rather than narrowed to an enum, so a new word added upstream is not silently turned into a bad reading. The free-text `note` is sanitized and length-capped before it is ever rendered. A companion pure function flags disagreement between this face and the tenant-facing `capabilities.readFace` — silent only when the two actually agree, honest-absent when either side cannot be read at all, never asserting agreement as a fact. The gate's last leg reads the installed SDK's own `openapi.yaml` directly rather than restating the schema in prose, so the package's leniency cannot quietly drift from the real contract |
312
313
  | `scripts/run-display-cap-order-test.mjs` | The order in which untrusted text is sanitised and length-capped, across every mint point that puts an engine- or database-supplied string on a screen. The sanitiser rewrites each invisible character as a six-character escape, so capping the **raw** string first and escaping afterwards hands the screen six times the width that was budgeted — a forty-character allowance becomes two hundred and forty. The guard does not hardcode that allowance, because each mint point wraps its field in different fixed prose and the prose moves: it anchors on the deciding quantity instead, feeding one benign and one control-character input of the same length through the same mint and requiring the second not to come out longer. That criterion is immune to wording changes and stays sensitive to the expansion, and it is `<=` rather than `==` on purpose — a correct escape-then-cap backs the cut off a partially-consumed escape token, so the control-character line is legitimately the shorter of the two, and demanding equality would score that avoidance as a regression. Each mint is bracketed by two positive controls (the input really reaches the screen; the cap really engages) and the expansion predicate is shown to turn red against a deliberately cap-then-escape reference, so an all-green run cannot mean the guard simply measured nothing. The shared mint point is checked directly for the two avoidances it owes — never splitting an escape token in half, which would leave something on screen that looks like the beginning of a complete answer, and never splitting a legal surrogate pair, which would manufacture the very lone surrogate the sanitiser exists to catch |
@@ -348,7 +349,7 @@ public-surface guard checks that last one).
348
349
  | `scripts/run-additive-key-passthrough-test.mjs` | The one disease shape behind two legs: a **closed whitelist / flattening arm** dropping a fact that is already on the wire, while both sides of the seam look correct. (1) The `task_progress` projection carries a registered **key ledger** — a frame populated with every key the service really projects is pushed through the shipped `eventToSdkMessage`, and the set of wire keys that survive must equal the registered pass-through list **name for name in both directions**, so quietly forwarding one more key is as red as quietly dropping one. `model` (the child run's model id, minted by core as `prepared.model.id` and projected by the server since 7.52.1) is the key this batch adds, with the same conditional the server itself applies: a non-empty string or no key at all — an empty string is neither a model id nor "unknown". The ledger is also checked against the fenced list in `docs/INTEGRATION-CLIENTS.md` §3d, so a doc that still says seven keys while the code forwards eight is red rather than merely stale. (2) The decide-failure arms carry the server's S-02 `currentPending` pointer key from a 409 `approval_stale` refusal onto the outcome the host reads. The reader is structural rather than `instanceof`, because the client is host-injected and the class identity is not this package's to assume; a half triple never mints (half a pointer cannot relocate anything), an empty string is not presence, and `checkpointToken` never transits. Both the allow and the deny leg are driven end to end through the real durable approval path — as is the accept-session leg, where a refusal carrying the pointer key must now re-raise instead of silently re-sending the human's answer for the **old** card as a plain approve (one decide call, pointer preserved), while a legacy 400 still falls back exactly as before — and all three flattening points must call the one shared reader — the same-shape residue check that makes "fixed one arm and left the twin" red instead of invisible. (3) The same disease growing on the REQUEST side: the `.mcp.json` → server-spec projection rebuilds each server key by key, and the settings schema deliberately leaves some keys parse-transparent — whatever JSON the file carries reaches the engine untouched, because validating them where the whole domain parses all-or-nothing would let one bad declaration take every server down silently. The whitelist had no row for the newest of them, so an operator's per-tool declarations — the ones the write fence reads — were stripped at the package boundary while both sides looked correct. The criterion is not "is that key handled" but the transparent-key table read out of the INSTALLED schema at runtime, reconciled name-for-name against this leg's ledger, so the day upstream adds a third one this turns red and forces an explicit decision. Behaviour is pinned on both transports, by object identity rather than deep equality (a rebuild would be a second judge), and malformed values must transit UNCHANGED rather than be refused here — the engine refuses them loudly and names the server, whereas a package-side judge can only swallow a declared protection quietly. Absence still mints no key, unknown keys still never reach the wire (the fix is the dropped key, not the gate), and the one transparent key this leg deliberately does not forward is a ledger entry with its own exit condition: it belongs to the deployment plane, and the day the request-plane type declares it the entry's premise is gone and the gate says so |
349
350
  | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy. A third escalating case was added once tearing the stream stopped reaping the run: with detach armed, a shot that never lands leaves the run going all the way to the end of the turn, so the Esc the user pressed has no effect at all and nothing on screen says so — the old behaviour had a silent backstop (tearing the stream ended the run) and that backstop is gone. The new fact is held to the same three disciplines as `parked`: it is read only where the engine gave no verdict, it is judged **after** `parked` so an existing host's reason word does not change under it, and only strict `true` counts. Absence is proven to be a no-op rather than asserted — the guard carries its own reference implementation of the previous version's table, runs the full grid through both, requires zero divergence when the new field is omitted, and first shows the comparison really does report a difference on the one cell where the two versions are meant to differ |
350
351
  | `scripts/run-peer-frame-projection-test.mjs` | The three engine-injected lanes design/385 puts on the **one** `task_notification` carrier, which are not the same kind of thing at all: a delegated child's uplink (`agentMessage`), another session's message drained from this session's own box (`crossSessionMessage`), and a receipt about one of *this* session's own outbound messages (`crossSessionNotice`). The engine renders none of them inside a `<task-notification>` shell, so a client that projects them as the generic completion card shows "background task finished" while the model read a colleague's sentence — two faces describing different events. The discriminator is pinned to the **typed carrier being present**, never to the `summary` text: those carriers can only be minted by the engine's injection legs (the external `notify()` input is a strict subset of the payload and can wear none of them), while `summary` is filled by every notification there is — so anchoring on text would let any background task impersonate a colleague's message by writing `<agent-message from="…">` into its own summary, and a positive control asserts exactly that payload still projects as the generic card. Fail-closed has two tiers rather than one: a broken **required** field (empty `from`, a non-string `body`, a notice `kind` outside the closed set) returns absence so the caller falls back to the generic card — an honest downgrade where the user still sees the notification — while a broken **optional** field drops only itself, because losing an attribution note and losing a colleague's whole message are not the same magnitude. The provenance side record is **required and must agree on four points** (`kind` matches the lane; `from`/`taskId`/`seq` are present and equal the carrier/payload — each equality is anchored on a core mint site and pinned by the cli wire-anchor A-K24), so a carrier signed with a trusted name but a disagreeing provenance falls back to the generic card; peer bodies pass the same authority-envelope neutralization core applies (`<task-notification>` etc. are defused) so a colleague's text can never seed the resume dedup ledger. Lane precedence copies the engine renderer's own order, because the model already read the frame in that order and a client ordering of its own would put a card on screen that disagrees with the frame the model saw. Rendering and parsing of the transcript line live in the same module and are round-tripped in both directions, including a body carrying a forged closing tag (a parser fooled there hands half a message to the next row) and a quote inside the sender label (which must not forge a second attribute); the notice lane is deliberately kept **out** of the parser, since recognising it would mean anchoring the `[Cross-session …]` prefix and a user typing that same line would be rendered as engine speech. Hostile carriers are read as own **data** descriptors only and accessors are never invoked at all — `catch` catches throwing, not never returning — proven by a counting getter that must stay at zero calls, alongside a revoked proxy and a prototype-only carrier; and four legacy payload shapes assert the no-carrier path is byte-identical to before, which is the executable form of "zero difference for an older host" |
351
- | `scripts/run-wiring-manifest-projection-test.mjs` | The two end-user facts carried on the engine's `wiring_manifest` frame (`modelGate`: which tools this run's model gate removed and the verbatim restore hint; `autoMode`: whether auto mode is actually armed and the engine's own reason word). Projection: both sections ride as `_sema_`-prefixed superset keys, verbatim, and no SDK-named key is minted; a frame where neither section is well-formed projects to `none/not_in_slice` (no empty arm); `modelGate` needs all three keys and treats `removed: []` as a bad value rather than a reading; `autoMode` needs a boolean plus a non-empty reason that agrees with it, and the reason word is never mapped onto the capabilities vocabulary; the frame is flat (a nested `manifest:{}` wrapper is not a supply); `eventId` rides like every other arm. Adapter: exactly one chrome event on the main lane, a sub-flow frame (any `parentToolCallId`, `null` included) yields nothing, and an absent `eventId` leaves the key absent. Added at receiving time because the shell-side gate could not see this package's behaviour: two mutations (empty `removed` accepted, sub-flow gate removed) had passed the package suite untouched |
352
+ | `scripts/run-wiring-manifest-projection-test.mjs` | The two end-user facts carried on the engine's `wiring_manifest` frame (`modelGate`: which tools this run's model gate removed and the verbatim restore hint; `autoMode`: whether auto mode is actually armed and the engine's own reason word). Projection: both sections ride as `_sema_`-prefixed superset keys, verbatim, and no SDK-named key is minted; a frame where neither section is well-formed projects to `none/not_in_slice` (no empty arm); `modelGate` needs all three keys and treats `removed: []` as a bad value rather than a reading; `autoMode` needs a boolean plus a non-empty reason that agrees with it, and the reason word is never mapped onto the capabilities vocabulary; the frame is flat (a nested `manifest:{}` wrapper is not a supply); `eventId` rides like every other arm. Adapter: exactly one chrome event on the main lane, a sub-flow frame (any `parentToolCallId`, `null` included) yields nothing, and an absent `eventId` leaves the key absent. Added at receiving time because the shell-side gate could not see this package's behaviour: two mutations (empty `removed` accepted, sub-flow gate removed) had passed the package suite untouched 0.71.0 adds sections F–I: the fourth/fifth/sixth manifest sections (`tools` via the roster reader, `hooks[]` rows dropped one by one when malformed, `lsp` absent unless `mounted` is a boolean), the `tool_roster_delta` arm (narrowed `delta`, `malformed` when `fromDigest`/`roster` cannot be read, host applies it against its own digest), the `context_usage` arm (finite-gated scalars plus `sections[]` rows dropped one by one), and the `WiringManifestMcpEntryView` rename with `MAX_AGENT_SKILLS` gone from the surface |
352
353
  | `scripts/run-rule-offers-reader-test.mjs` | The narrowing reader behind the "don't ask again" options, now a public entry point rather than a card-port-only one. Hosts that render the frame themselves (a browser has no three-way terminal card) previously had to rebuild this reader on their side, and what it carries is a **redemption-safety** judgement, not a convenience: the batch arm is redeemed by **index**, so a reader that compacts the array after dropping a malformed entry makes the k-th option a person clicked and the k-th rule the server writes two different rules. So: a bad entry is dropped **on its own** (one bad option must not make a real one disappear) while every surviving entry keeps its **original wire index** — pinned from both ends, with the bad entries leading and trailing. A batch's *members* are the opposite: any malformed member drops the whole batch, because a conjunctive batch is one "yes" to all of them and a batch missing a member is a different grant; its honest-remainder count is a reading, not decoration, so a non-integer or negative value drops the batch rather than rendering a fabricated zero. An empty array, a non-array, an over-cap array and an all-bad array all read as **absence** rather than an empty list, because an empty list renders as "there is an option lane with nothing in it". The two wire generations are ordered by a rule, not a preference: the newer key wins outright, a newer key that is **present but unreadable** does not fall back to the retired key (borrowing the older material would pass someone else's options off as this request's), and a `null` newer key reads as absence so a relaying layer that serialises "missing" as null cannot delete the whole lane on older engines. The public entry is finally reconciled against **both** card-port legs on the same material, byte for byte, so the exported reader and the one the card sees can never become two. Two upstream vocabularies used to be **hand-copied** here, and both had fallen behind: a match word outside the copied pair dropped an otherwise valid option outright, and a batch carrying a directory-read member — a member kind the copy did not know — dropped the whole batch. Both tables now come from one place upstream and are re-exported verbatim, pinned in both directions: every word in the table must be accepted (a narrower copy reds on the words it never learned) and a word constructed to be outside it must still be refused (a reader widened to "any string" reds too), with the retired-key normalising leg sharing the same narrowing so the fix cannot land on one leg only. A member whose kind is genuinely unknown still drops **the whole batch and only that batch** — never one member, because a conjunctive batch one member short renders "yes to N" as "yes to N−1", and never the card, because the honest single beside it is intact — while a member from before the discriminant existed normalises to the historical kind rather than being refused. The additive per-segment reasons ride through verbatim, drop only the row that is malformed, and stay **absent rather than empty** when nothing survives, since an empty list would read as "confirmed nothing uncovered" while the count remains the only source of truth |
353
354
  | `scripts/run-resume-refusal-copy-test.mjs` | The **words** a client says when a resume is refused, minted once here instead of three times. The facts behind them already lived in this package; the sentences did not, so each client wrote its own — and those sentences answer a safety question (was my decision consumed, can this token still be redeemed), which is exactly the kind of answer that must not vary by client. Two closed sets meet here and the guard pins their relationship in both directions, because it is a premise rather than a coincidence: one set answers *can waiting help* (the codes the server mints a wait on), the other answers *what should a person be told*, they **intersect in exactly one code**, and each keeps a member the other must not have — a placement mismatch is never waitable no matter what arrives on the response, since its remedy is a changed argument rather than elapsed time, and a full governance window needs no prose because "you can wait" is the whole message. The overlapping code delegates its wait and its disposition to the existing reading rather than judging again: nine shapes of input drive both entry points and the two readings must agree byte for byte, the absent case included, because two judges always diverge somewhere. The wait is narrowed to the domain the server mints it in, which is **stricter than the shell's own copy was** — a zero now reads as no window rather than as "retry now", and the wake-up it would retry is an at-most-once action with real side effects. The third sentence is chosen by the disposition, never by the engine's prose: rewriting the message to either upstream branch's exact wording, with the window untouched, must leave all three sentences unchanged, while adding a window must change the third one and only the third one |
354
355
  | `scripts/run-resume-retry-later-test.mjs` | The two resume refusals that carry a **wait quantity** — the only members of that refusal family that do, which is the whole reason they form a closed set. Carrying a wait is not the same as being the only ones worth waiting on: a sibling refusal in the same family clears on its own and the engine says so in words, it just cannot put a number on it, so *not recognised here* must never be read as *waiting will not help*. One of the two also has a *terminal* upstream branch that arrives under the same code with the distinguishing detail only in prose, so recognition alone is not permission to say "try again": the disposition is decided by **positive evidence** and pinned from both directions — the quota-window code is evidence in itself, the preflight code counts only when the server really supplied a wait (an upstream fact, not a convention: the terminal branch throws with no detail at all, so a wait value cannot reach the client on that path), and a preflight refusal with no wait reads as *undecidable* (say what is true of both branches — nothing was consumed — and leave redeemability to the engine's own line) rather than being rendered as either a retry or an ending. Every other member means waiting will not help (change a setting, relaunch, the retained session is gone), so the recognition is a **closed set of two codes**: widening it to a family prefix would tell half the users to wait and the other half to keep waiting for something that will never arrive, and the negative controls drive exactly those codes through it, plus a same-named code on a different door (the submission-side quota refusal), the two underscore-form siblings, and a code merely quoted inside a message body. The wait value is narrowed to the same domain the server mints it in (a whole number of seconds, at least one): zero, a negative, a fraction and a non-number all read as **no window given** rather than as zero, because a zero tells the caller to retry immediately and the wake-up it would retry is an at-most-once action with real side effects. Reading is structural rather than `instanceof`, since the client is host-injected and the same class name across two bundles is two classes, and a null-prototype plain object must still be recognised. The failure classifier gains this one disposition without any existing one moving, an unknown code still falls to the honest open-set arm and its wait value is **not** believed, and an end-to-end call proves the disposition and the window reach the host while the call itself is still attempted exactly once. The recognised code set is a **frozen array**, not a type-level readonly set: the latter is a plain mutable collection at runtime and the decision reads the same instance, so one `.add` from any consumer would turn a refusal that waiting cannot fix into one that claims it can — the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift |
@@ -361,17 +361,25 @@ const engineNoticeArm = function* (m) {
361
361
  * 发生在渲染面,理由见臂注义务②③。
362
362
  */
363
363
  const wiringManifestArm = function* (m) {
364
- if (m.parentToolCallId !== undefined)
364
+ // 三键断闸(修前只判 parentToolCallId:sourceTaskId / bgAgentId 两形的子代快照漏上 leader 面;与 tool_roster_delta 同律)
365
+ if (isSubFlowSegmentEnd(m))
365
366
  return;
366
367
  const modelGate = m._sema_modelGate;
367
368
  const autoMode = m._sema_autoMode;
368
369
  const mcp = m._sema_mcp;
370
+ // CC-08 ①(0.71.0):第四/五/六段
371
+ const tools = m._sema_tools;
372
+ const hooks = m._sema_hooks;
373
+ const lsp = m._sema_lsp;
374
+ const hasTools = typeof tools === 'object' && tools !== null;
375
+ const hasHooks = Array.isArray(hooks);
376
+ const hasLsp = typeof lsp === 'object' && lsp !== null;
369
377
  const hasGate = typeof modelGate === 'object' && modelGate !== null;
370
378
  const hasAuto = typeof autoMode === 'object' && autoMode !== null;
371
379
  // S-124(0.59.0):🔴 `Array.isArray` 判在场,**不判 `.length`** —— `mcp: []` 是「这条腿一台都
372
380
  // 没申报」这句正面事实,按长度判会把它和「老 mint 没报」折成同一件事(core 顶注点名的读法)。
373
381
  const hasMcp = Array.isArray(mcp);
374
- if (!hasGate && !hasAuto && !hasMcp)
382
+ if (!hasGate && !hasAuto && !hasMcp && !hasTools && !hasHooks && !hasLsp)
375
383
  return;
376
384
  yield chrome({
377
385
  kind: 'wiring_manifest',
@@ -379,6 +387,9 @@ const wiringManifestArm = function* (m) {
379
387
  ...(hasGate ? { modelGate: modelGate } : {}),
380
388
  ...(hasAuto ? { autoMode: autoMode } : {}),
381
389
  ...(hasMcp ? { mcp: mcp } : {}),
390
+ ...(hasTools ? { tools: tools } : {}),
391
+ ...(hasHooks ? { hooks: hooks } : {}),
392
+ ...(hasLsp ? { lsp: lsp } : {}),
382
393
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
383
394
  });
384
395
  };
@@ -448,6 +459,94 @@ const approvalFrameArm = (kind) => function* (m) {
448
459
  * 防的是**非投影口喂进来的帧**(宿主自建管线 / 重放存量转录),不是重复判据。
449
460
  */
450
461
  // CC-10 子流身份三键判定 `isSubFlowSegmentEnd`:CC-13(0.69.2)起住在 `adapter/types.ts` 出公面(端改调,不再复刻)。
462
+ /**
463
+ * CC-08(0.71.0):`tool_disclosure` 内部臂 → chrome `tool_disclosure`(名字占位普查;全量快照整只替换)。
464
+ * 🔴 子流断闸与 `text_segment_end` 同族(三键任一在场 = 子代帧,不上 leader 面)。
465
+ */
466
+ const toolDisclosureArm = function* (m) {
467
+ if (isSubFlowSegmentEnd(m))
468
+ return;
469
+ const policy = typeof m.policy === 'string' ? m.policy : '';
470
+ const deferred = Array.isArray(m.deferred) ? m.deferred.filter((x) => typeof x === 'string') : null;
471
+ const activated = Array.isArray(m.activated) ? m.activated.filter((x) => typeof x === 'string') : null;
472
+ if (policy.length === 0 || deferred === null || activated === null)
473
+ return; // 二道判:非投影口喂进来的帧
474
+ yield chrome({
475
+ kind: 'tool_disclosure',
476
+ laneProof: MAIN,
477
+ policy,
478
+ ...(typeof m.thresholdPercent === 'number' ? { thresholdPercent: m.thresholdPercent } : {}),
479
+ deferred,
480
+ activated,
481
+ ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
482
+ });
483
+ };
484
+ /**
485
+ * CC-08(0.71.0):`tool_progress` 内部臂 → chrome `tool_progress`(C-R50 一帧两拍:Bash 带尾巴三键 / 其他只 elapsed)。
486
+ * 🔴 `output` 是 UNTRUSTED RAW(与 `tool_end.output` 同类,server 已脱敏一次),宿主可再封顶展示,**绝不**回喂模型。
487
+ * 🔴 子流断闸同族:子代工具的进度属子代那条流,不上 leader 面。
488
+ */
489
+ const toolProgressArm = function* (m) {
490
+ if (isSubFlowSegmentEnd(m))
491
+ return;
492
+ const toolCallId = typeof m.toolCallId === 'string' ? m.toolCallId : '';
493
+ const toolName = typeof m.toolName === 'string' ? m.toolName : '';
494
+ if (toolCallId.length === 0 || toolName.length === 0)
495
+ return;
496
+ const output = typeof m.output === 'string' ? m.output : undefined;
497
+ yield chrome({
498
+ kind: 'tool_progress',
499
+ laneProof: MAIN,
500
+ toolCallId,
501
+ toolName,
502
+ ...(typeof m.elapsedTimeSeconds === 'number' ? { elapsedSeconds: m.elapsedTimeSeconds } : {}),
503
+ ...(output !== undefined ? { output } : {}),
504
+ ...(output !== undefined && typeof m.totalLines === 'number' ? { totalLines: m.totalLines } : {}),
505
+ ...(output !== undefined && typeof m.totalBytes === 'number' ? { totalBytes: m.totalBytes } : {}),
506
+ ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
507
+ });
508
+ };
509
+ /**
510
+ * CC-08 ①(0.71.0):`tool_roster_delta` 内部臂 → chrome `tool_roster_delta`(名册增量;LIVE only,重连按 wiring_manifest.tools 快照重同步)。
511
+ * 🔴 包不持名册:`delta` 原样(已窄读)交宿主,宿主用 `applyToolRosterDelta` 与手上那份比 digest。三键子流断闸同族。
512
+ */
513
+ const toolRosterDeltaArm = function* (m) {
514
+ if (isSubFlowSegmentEnd(m))
515
+ return;
516
+ const delta = m.delta;
517
+ if (typeof delta !== 'object' || delta === null)
518
+ return;
519
+ yield chrome({
520
+ kind: 'tool_roster_delta',
521
+ laneProof: MAIN,
522
+ delta: delta,
523
+ ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
524
+ });
525
+ };
526
+ /**
527
+ * CC-08 ③(0.71.0):`context_usage` 内部臂 → chrome `context_usage`。每个压缩边界都报 ⇒ 一次任务多条,宿主按**最后一条**取当前值;
528
+ * 🔴 `sections[].tokens` 与 `usedTokens` 不是同一坐标系,禁相减、禁拿一段比 `compactAtTokens`;`windowTokens` 是 autocompact 窗不是模型上下文大小。
529
+ */
530
+ const contextUsageArm = function* (m) {
531
+ if (isSubFlowSegmentEnd(m))
532
+ return;
533
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
534
+ const usedTokens = num(m.usedTokens);
535
+ const windowTokens = num(m.windowTokens);
536
+ const compactAtTokens = num(m.compactAtTokens);
537
+ const sections = Array.isArray(m.sections) ? m.sections : undefined;
538
+ if (usedTokens === undefined && windowTokens === undefined && compactAtTokens === undefined && sections === undefined)
539
+ return;
540
+ yield chrome({
541
+ kind: 'context_usage',
542
+ laneProof: MAIN,
543
+ ...(usedTokens !== undefined ? { usedTokens } : {}),
544
+ ...(windowTokens !== undefined ? { windowTokens } : {}),
545
+ ...(compactAtTokens !== undefined ? { compactAtTokens } : {}),
546
+ ...(sections !== undefined ? { sections } : {}),
547
+ ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
548
+ });
549
+ };
451
550
  const textSegmentEndArm = function* (m, { text }) {
452
551
  // 🔴 断闸按「**键在不在**」判,不按「是不是串」判(异源对抗复审第三轮 [medium] 采纳)。
453
552
  // 投影层已对坏 lane 位整帧 fail-closed;这一道是给**非投影口**喂进来的帧(宿主自建管线 /
@@ -473,6 +572,10 @@ const textSegmentEndArm = function* (m, { text }) {
473
572
  ...(r.diverged ? { diverged: true } : {}),
474
573
  ...(r.committedPrefixDiverged ? { committedPrefixDiverged: true } : {}),
475
574
  ...(r.committedPrefixLen > 0 ? { committedPrefixLen: r.committedPrefixLen } : {}),
575
+ // CC-16(0.71.0):本窗口已提交行归属交全;`[]` 也透传(在场但空 = 零行),只有形 (f) 才整键缺席。
576
+ ...(r.committedUuids !== undefined ? { committedUuids: r.committedUuids } : {}),
577
+ ...(r.committedUuid !== undefined ? { committedUuid: r.committedUuid } : {}),
578
+ ...(r.committedRows !== undefined ? { committedRows: r.committedRows } : {}),
476
579
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
477
580
  });
478
581
  // CC-01:段界到此为止 —— 事件已交到宿主手上之后才轮换(宿主按帧上的身份认行时,行上盖的还是同一个)。
@@ -1142,6 +1245,11 @@ export const ARMS = new Map([
1142
1245
  ['engine_notice', engineNoticeArm],
1143
1246
  ['text_end', textSegmentEndArm],
1144
1247
  ['reasoning_end', thinkingSegmentEndArm],
1248
+ // CC-08(0.71.0):两条新帧的 chrome 臂(additive;不接 = 看不见,不属行为丢失)。
1249
+ ['tool_disclosure', toolDisclosureArm],
1250
+ ['tool_progress', toolProgressArm],
1251
+ ['tool_roster_delta', toolRosterDeltaArm],
1252
+ ['context_usage', contextUsageArm],
1145
1253
  ['wiring_manifest', wiringManifestArm],
1146
1254
  // B-078 / L-208(0.65.0):design/172 流内审批两帧 —— 修前它们在投影层就 `dropped`,
1147
1255
  // 壳自己另接一份 ⇒ desktop/web 拿不到卡集与撤卡(归层违例)。
@@ -26,7 +26,7 @@
26
26
  * 载荷是在 yield 之前**按值捕获**的,所以逻辑正确。**机械地把清空前移会读到已清空的串** ——
27
27
  * 谁想「统一成 reset-before-yield」,先读这一条。
28
28
  */
29
- import type { AdapterContext, AdapterOutput } from '../seam.js';
29
+ import type { AdapterContext, AdapterOutput, TextSegmentCommittedRow } from '../seam.js';
30
30
  import { type Frame, type IdOf } from './ids.js';
31
31
  /**
32
32
  * **段身份键**(CC-01,0.68.2)—— 打在 committed assistant **文本**转录行**顶层**的 `_sema_` 超集键。
@@ -66,6 +66,20 @@ export declare const SEMA_SEGMENT_ID_KEY = "_sema_segment_id";
66
66
  export interface TextSegmentReplacement {
67
67
  /** 替换前按 delta 拼出来的本段全文 ≠ 权威全文。假 = 逐字节相同(旧引擎 / 不含凭据形的文本)。 */
68
68
  diverged: boolean;
69
+ /**
70
+ * CC-16(0.71.0):本段身份窗口里、段边界**之前**已提交的文本行 uuid(按序;与 `committedThinking` 同律)。
71
+ * `[]` = 本包判定本窗口零已提交行(在场但空);整键缺席只留给形 (f)(本包零经手,说不出)。
72
+ * 端义务:`committedPrefixDiverged` ⇒ 按 `committedRows` 逐行写回(首行 = 前段定稿前缀 + content、其余行空);不再自己数窗口里过境的行。
73
+ */
74
+ committedUuids?: readonly string[];
75
+ /** 末条兼容位(与思考面 `committedUuid` 同律);零行时缺席。 */
76
+ committedUuid?: string;
77
+ /**
78
+ * 端要写回的最终正文(按行,与 `committedUuids` 同序);只在 `committedPrefixDiverged` ∧ 有已提交行时在场。
79
+ * 首行 = 前段定稿前缀(该行里属上一段、已封存的那截)+ `content`,其余行 `''`。混合行(A 定稿 + B 原始同一条)
80
+ * 的 A 不被换掉 —— 端整行换首行会删掉上一段权威正文(异源对抗复审第 1 轮 finding①)。
81
+ */
82
+ committedRows?: readonly TextSegmentCommittedRow[];
69
83
  /**
70
84
  * 本段在替换发生前**已经 committed 上屏**的长度(idle-flush 的那些刀 + 包侧边界带走的那些截)。
71
85
  *
@@ -78,6 +78,16 @@ export function createTextStream(ctx, idOf) {
78
78
  let committedText = '';
79
79
  /** 上一条**真有正文**的 assistant 消息在屏上的那段(见 lastCommittedAnswerText 头注)。 */
80
80
  let previousCommittedText = '';
81
+ /**
82
+ * CC-16(0.71.0):**本段身份窗口里已提交的文本行**(正文 + 本包铸的 uuid,按序)—— 与思考面的
83
+ * `committedThinking`(CC-11)同律。写于 `emitAnswerSegment` 铸行那一拍;`replaceAnswerSegment` 用过即清;
84
+ * 窗口轮换(`rotateSegmentIdentity`)整只清。修前 `text_segment_end` 只带 `segmentId`,持有转录的端要自己跟踪
85
+ * 「本窗口过境了哪几条盖着该 id 的行」,窗口内 ≥2 条(两个工具卡)时端整发放弃 = 未脱敏字节留盘;包在铸行处
86
+ * 确知每一条,交全就不用端猜。常态段的定稿行在轮换**之后**才提交 ⇒ 天然属下一窗口、不在本列。
87
+ */
88
+ let committedTextRows = [];
89
+ /** 消息序号(`beginAssistantMessage` 递增):行账按消息分组重建两本补差账用。 */
90
+ let messageSerial = 0;
81
91
  let textPending = '';
82
92
  let thinkingPending = '';
83
93
  let textBlockOpen = false;
@@ -205,10 +215,18 @@ export function createTextStream(ctx, idOf) {
205
215
  session_id: ctx.sessionId,
206
216
  parent_tool_use_id: null,
207
217
  };
218
+ const committedTextBeforeRow = committedText;
208
219
  committedText += body;
209
220
  // L-310:🔴 **只有当前段那一截**算进「本段已提交前缀」—— 已封存的定稿段属于**上一个**引擎段,
210
221
  // 把它算进来会让下一帧 `text_end` 拿一个跨段的前缀去比 `content`,当场误报前缀分歧。
211
222
  segmentCommitted += answerSegment;
223
+ // CC-16:本窗口行账 —— 与上一行的前缀账**同律**:只有携带本窗口字节(`answerSegment` 那截)的行才登记;
224
+ // 正文全是已封存定稿段(`segmentSealed`)的行属**上一个**引擎段,登进来会让下一帧 `text_end` 把它当
225
+ // 本窗口的已提交行去换 —— 那是思考面 CC-14 的同形病(定稿迟交行误登下一段账)。
226
+ // `sealedPrefix` = 该行里属上一段的定稿那截(封存只换字节不切消息 ⇒ 混合行);端写回时必须保住它。
227
+ // `msgTextBefore` = 该行提交前所属消息已有的正文(窗口之外独立提交的定稿前缀也在里面),写回后按消息重建账本用。
228
+ if (answerSegment.length > 0)
229
+ committedTextRows.push({ text: body, uuid: msg.uuid, sealedPrefix: segmentSealed, msgSerial: messageSerial, msgTextBefore: committedTextBeforeRow });
212
230
  segmentSealed = '';
213
231
  answerSegment = '';
214
232
  segmentAnchor = null;
@@ -354,6 +372,20 @@ export function createTextStream(ctx, idOf) {
354
372
  const diverged = verdict.diverged;
355
373
  const committedPrefixLen = verdict.committedPrefixLen;
356
374
  const committedPrefixDiverged = verdict.committedPrefixDiverged;
375
+ // CC-16:本窗口已提交行的归属一次交全(用过即清;轮换也清)。`[]` 是正面事实,不是缺席。
376
+ const rows = committedTextRows;
377
+ committedTextRows = [];
378
+ const rowUuids = rows.map((r) => r.uuid);
379
+ // 只有「已提交前缀自己也过期」这一形端才有东西要写回:首行 = 前段定稿前缀 + content(混合行的上一段正文保住),
380
+ // 其余行清空(它们的字节全在 content 里)。b1 形已提交行本来就对,不给;形 (a) 零行,不给。
381
+ const committedRowsOut = committedPrefixDiverged && rows.length > 0
382
+ ? rows.map((r, i) => ({ uuid: r.uuid, text: i === 0 ? r.sealedPrefix + content : r.sealedPrefix }))
383
+ : undefined;
384
+ const attribution = {
385
+ committedUuids: rowUuids,
386
+ ...(rowUuids.length > 0 ? { committedUuid: rowUuids[rowUuids.length - 1] } : {}),
387
+ ...(committedRowsOut !== undefined ? { committedRows: committedRowsOut } : {}),
388
+ };
357
389
  /**
358
390
  * 🔴 **段跨过包侧边界、而且已提交那截自己也过期** ⇒ 本模块**一个字节都不动**(只发信号)。
359
391
  *
@@ -387,20 +419,57 @@ export function createTextStream(ctx, idOf) {
387
419
  // (只报分岔)掉进第④臂(补吐一整段)—— 那正是轮五 finding② 抓到的**新增重复转录路径**,
388
420
  // 实测确认过(D35/D36 两格钉的就是它)。⇒ 对不上就退回 0.68.0 行为:只发信号,一个字节不动。
389
421
  // 多行跨界那一形的正解是把整段归属交全(`committedUuids[]`,CC-16),不是包单方面清。
390
- const syncedBook = committedText.length > 0 && committedText.endsWith(livePrefix) ? 'current'
391
- : previousCommittedText.endsWith(livePrefix) ? 'previous'
392
- : null;
393
- if (syncedBook === 'current') {
394
- committedText = committedText.slice(0, committedText.length - livePrefix.length) + content;
422
+ // 🔴 CC-16 `committedRows` 起(异源对抗复审第 2 轮 finding①):多行跨界的归属**已经交全**、端按行写回
423
+ // (首行 = 前缀 + content、其余行空)⇒ 上面那句「对不上就一个字节不动」的前提消失 —— 再把本段
424
+ // 原始尾巴提交成又一条,就是重复正文 + 未脱敏残留。所以 `syncedBook === null` 而 `committedRows`
425
+ // 已交出时,两本账按**端写回后的屏**同步(最后一行 = 当前消息那本,首行 = 上一条真有正文的那本),
426
+ // 尾巴照清;`result` 臂拿到的 `lastCommittedAnswerText` 就是权威全文,落第③格不补吐(Z9 钉)。
427
+ // 🔴 第 4 轮 finding①:`committedRows` 在场时**一律**按消息分组重建(行归属确定,零启发式);`endsWith` 快捷分支只在
428
+ // 零行(没有行归属可用)时回退 —— 重复正文(两条消息都是「Intro. 」)会让 endsWith 误命中、跳过重建,消息二那本账没清 ⇒ 终帧再补一次。
429
+ let booksSynced = false;
430
+ if (committedRowsOut !== undefined) {
431
+ // 按**消息**重建(第 3 轮 finding②):一条消息的正文 = 窗口之外已独立提交的那截(`msgTextBefore`)+ 该消息里
432
+ // 各行写回后的正文;只按首行写回正文重建会丢掉独立提交的定稿前缀,终帧再落第⑤臂误报分岔。
433
+ const groups = new Map();
434
+ rows.forEach((r, i) => {
435
+ const g = groups.get(r.msgSerial) ?? { before: r.msgTextBefore, parts: [] };
436
+ g.parts.push(committedRowsOut[i]?.text ?? '');
437
+ groups.set(r.msgSerial, g);
438
+ });
439
+ let lastNonEmpty = '';
440
+ let currentTouched = false;
441
+ for (const [serial, g] of groups) {
442
+ const txt = g.before + g.parts.join('');
443
+ if (serial === messageSerial) {
444
+ committedText = txt;
445
+ currentTouched = true;
446
+ }
447
+ if (txt.length > 0)
448
+ lastNonEmpty = txt;
449
+ }
450
+ // 当前消息不在窗口里却有已提交字节 ⇒ 那是本段之外的东西,不动(`currentTouched` 为假时只同步「上一条」那本)。
451
+ if (!currentTouched && committedText.length > 0) { /* 保持 */ }
452
+ if (committedText.length === 0 && lastNonEmpty.length > 0)
453
+ previousCommittedText = lastNonEmpty;
454
+ booksSynced = true;
395
455
  }
396
- else if (syncedBook === 'previous') {
397
- previousCommittedText =
398
- previousCommittedText.slice(0, previousCommittedText.length - livePrefix.length) + content;
456
+ else {
457
+ const syncedBook = committedText.length > 0 && committedText.endsWith(livePrefix) ? 'current'
458
+ : previousCommittedText.endsWith(livePrefix) ? 'previous'
459
+ : null;
460
+ if (syncedBook === 'current') {
461
+ committedText = committedText.slice(0, committedText.length - livePrefix.length) + content;
462
+ }
463
+ else if (syncedBook === 'previous') {
464
+ previousCommittedText =
465
+ previousCommittedText.slice(0, previousCommittedText.length - livePrefix.length) + content;
466
+ }
467
+ booksSynced = syncedBook !== null;
399
468
  }
400
- if (syncedBook !== null && answer.endsWith(liveSegment)) {
469
+ if (booksSynced && answer.endsWith(liveSegment)) {
401
470
  answer = answer.slice(0, answer.length - liveSegment.length) + content;
402
471
  }
403
- if (syncedBook !== null) {
472
+ if (booksSynced) {
404
473
  // 活体面:本段还押着没吐的那一截也作废(`textPending` 是段缓冲的后缀,前面那截 `carry`
405
474
  // 属于上一段,与本次替换无关 ⇒ 原样留)。
406
475
  const staleTailLen = Math.min(textPending.length, prevSegment.length);
@@ -410,7 +479,7 @@ export function createTextStream(ctx, idOf) {
410
479
  segmentCommitted = '';
411
480
  previousSegmentCommitted = '';
412
481
  idleFlushUsedInSegment = false;
413
- return { diverged, committedPrefixLen, committedPrefixDiverged };
482
+ return { diverged, committedPrefixLen, committedPrefixDiverged, ...attribution };
414
483
  }
415
484
  // ── ① 活体面:已泄出去的那一截撤不回 ⇒ 尾巴按替换后的段重算(判据见接口注「活体面」段)──
416
485
  // `textPending` 是段缓冲的**后缀**;工具卡边界只提交不 drain,所以它也可能还押着**上一段**
@@ -470,7 +539,7 @@ export function createTextStream(ctx, idOf) {
470
539
  segmentCommitted = '';
471
540
  previousSegmentCommitted = '';
472
541
  idleFlushUsedInSegment = false;
473
- return { diverged, committedPrefixLen, committedPrefixDiverged };
542
+ return { diverged, committedPrefixLen, committedPrefixDiverged, ...attribution };
474
543
  },
475
544
  hasPendingContent: () => thinking.length > 0 ||
476
545
  // L-310:已封存的定稿段同样是「有已生成未提交的内容」(不算进来会让它一直押到 turn 收口)。
@@ -539,6 +608,7 @@ export function createTextStream(ctx, idOf) {
539
608
  },
540
609
  segmentId: segmentIdNow,
541
610
  rotateSegmentIdentity: (anchor) => {
611
+ committedTextRows = []; // CC-16:窗口结束,本窗口的行账整只清(常态段定稿行随后提交 ⇒ 属下一窗口)
542
612
  segmentWindowSerial += 1;
543
613
  segmentWindowAnchor = anchor;
544
614
  segmentIdCache = null;
@@ -561,6 +631,7 @@ export function createTextStream(ctx, idOf) {
561
631
  return out;
562
632
  },
563
633
  beginAssistantMessage: () => {
634
+ messageSerial += 1;
564
635
  if (committedText.length > 0)
565
636
  previousCommittedText = committedText;
566
637
  committedText = '';
@@ -194,7 +194,7 @@ export interface WiringManifestAutoMode {
194
194
  * 渲不出任何有用的话,而**坏一条只丢那一条**,绝不丢整张表(丢整表 = 用户以为「一台都没申报」)。
195
195
  * 🔴 **`errorCode` 不是纯闭集**(core 7.6.0 换代:`MCP_FAILURE_CODES` 十词表退役,继任者是
196
196
  * `McpFailureKind` 十词;**`network` 与 `http_<status>` 两种拼法一并作废** —— HTTP 状态从此走
197
- * 结构位 {@link WiringManifestMcpEntry.httpStatus},不再编进码里)⇒ 本包**照旧既不枚举那十个词、
197
+ * 结构位 {@link WiringManifestMcpEntryView.httpStatus},不再编进码里)⇒ 本包**照旧既不枚举那十个词、
198
198
  * 也不套形正则**(词的单一属主在 core;抄一份会把 core 加的新词吞成缺席),只判非空串。
199
199
  * 消费端按具名词写的 `switch` **必须带 default 臂**。
200
200
  * 🔴 **`delivered` 与 `errorCode` 合读才有判别力**(core 7.6.0 S6-B):同一个 `connection_closed`,
@@ -204,7 +204,7 @@ export interface WiringManifestAutoMode {
204
204
  * 侧的裁定逐字是「进来会同时破本段『无自由文本 ⇒ 不脱敏』的段内不变量,和 core 把远端 MCP 错误
205
205
  * 文本脱敏收敛成**一个**铸点的单铸律」。可操作的因由在 `errorCode`。
206
206
  */
207
- export interface WiringManifestMcpEntry {
207
+ export interface WiringManifestMcpEntryView {
208
208
  /** spec 声明名(引擎产的标识,非用户内容)。 */
209
209
  name: string;
210
210
  /** 三词闭集(core 属主;**按开集读**,认不得的词照渲不丢行)。 */
@@ -231,25 +231,29 @@ export interface WiringManifestMcpEntry {
231
231
  */
232
232
  httpStatus?: number;
233
233
  }
234
+ export declare function projectMcpSection(raw: unknown): WiringManifestMcpEntryView[] | undefined;
234
235
  /**
235
- * `wiring_manifest.mcp` 的形校验(S-124 / core 7.5.0,server ≥7.60.0)。
236
- *
237
- * 🔴 **空数组 ≠ 缺席,两者都要能被消费端分辨**(core 顶注逐字:"an empty array is *this leg
238
- * declared no servers*, absence is an older mint or an external derivation")⇒ **真的**空数组
239
- * 原样铸成 `[]`。把它折成缺席 = 把「我一台都没申报」这句**正面事实**说成「不知道」。
240
- * 🔴 **但「过滤后为空」不是「原本就是空」**(异源对抗复审 r1 [medium] 采纳,真病):一份
241
- * `[{name:'github', errorCode:'http_503'}]`(缺 `status`)进来时,唯一那一行被下面的逐行判据
242
- * 丢掉 —— 若照样铸出 `[]`,消费端按本臂的义务④读到的是**「这条腿一台都没申报」这句肯定话**,
243
- * 一份读不懂的回体就此被伪装成一个确定的零申报。⇒ **非空输入而零行幸存 ⇒ 整段缺席**
244
- * (「我读不出来」不是「我知道是零」)。这与下面「逐条独立」并不矛盾:只要**还有幸存者**,
245
- * 半张表仍是真读数,坏行照旧只丢自己。
246
- * 🔴 **逐条独立**:一条坏行只丢自己(与 server 侧 `continue` 同判据),绝不因为一条坏行丢整表。
247
- * 🔴 **逐键挑,禁 spread**:core 往条目上加新键时必须由人显式处置 —— 而它下一个加的很可能又是
248
- * 一个像 `error` 那样的自由文本面。(0.60.0 按此显式处置了 S6-B 的 `delivered` / `httpStatus`。)
249
- * ⚠️ `toolCount` 按**有限数**判(`Number.isFinite`),不按真值判:`0` 是合法读数(连上了、零工具),
250
- * 折成缺席会让「连上了但没工具」与「没报」在消费端同形。
236
+ * CC-08(0.71.0):`wiring_manifest.hooks[]` 一行(sdk 9.6.0 `WiringManifestHookEntry`)—— 这条腿挂了哪些 hook 族、
237
+ * 由 spec 还是 deps 拥有、是否只是观察性的。开集透传 `family` / `owner`;`observational` never-false。
251
238
  */
252
- export declare function projectMcpSection(raw: unknown): WiringManifestMcpEntry[] | undefined;
239
+ export interface WiringManifestHookEntryView {
240
+ family: string;
241
+ owner: string;
242
+ observational?: true;
243
+ }
244
+ /** CC-08(0.71.0):`wiring_manifest.lsp`(sdk 9.6.0 `WiringManifestLspSeam`,core 7.18.0 #789):在场 iff 解析出了 manager 席;
245
+ * 🔴 `mounted` 与 `lane` 互相独立、禁合读(`lane` 缺席 ≠ 没挂;`mounted: false` 时 `lane` 仍可在场)。 */
246
+ export interface WiringManifestLspSeamView {
247
+ mounted: boolean;
248
+ lane?: string;
249
+ }
250
+ /** CC-08 ③(0.71.0):`context_usage.sections[]` 一行(sdk `ContextUsageSection`;core 7.18.0 #790)。`id` 与 `prompt_assembled` 同一张词表可 join;
251
+ * `tokens` 只在本段自己的坐标系里有意义(🔴 与 `usedTokens` 禁相减、禁拿一段比 `compactAtTokens`);`kind` 缺席 = 条目没声明槽,不是没语义。 */
252
+ export interface ContextUsageSectionView {
253
+ id: string;
254
+ tokens: number;
255
+ kind?: string;
256
+ }
253
257
  /**
254
258
  * CS-7 §2.7 — turn_end usage → CC `ModelUsage` (pinned name mapping;
255
259
  * costMicroUsd/1e6 → costUSD). Surfaced separately because the slice has no