@sema-agent/client-core 0.46.0 → 0.47.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
@@ -28,6 +28,89 @@
28
28
  > 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
29
29
  > `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
30
30
 
31
+ ## 0.47.0(2026-08-31)
32
+
33
+ 三件 additive 上收/透传批(#363;取证与定谳出自同批 CLI 提货)。**零 BREAKING**:
34
+ peer 地板不动(`@sema-agent/sdk >=7.2.0` / `@sema-agent/agent-types >=0.2.0`),既有键与文案
35
+ 逐字节不变,新增三个运行期导出(公开导出面 787 → **790**)。
36
+
37
+ - **件① `task_progress` 白名单补 `model`**(`src/adapter/downstream/eventToSdkMessage.ts`)。
38
+ server ≥**7.52.1** 的投影白名单(`trace/project.js` 的 `taskProgressEventData`)真发这一位 ——
39
+ 子 run 的**模型 id**,core 铸点 `prepared.model.id`;修前本层的闭形白名单把它整键剥掉,
40
+ 与 `workflowRunId`([1616]/[1617]②)是**同形第二例**:上游真发、白名单剥掉、两边代码看着都对。
41
+ 条件与 server 投影**同形**:`string` 且非空才带(空串既不是模型 id 也不是「不知道」)。
42
+ ⚠️ sdk **7.3.0** 的 `task_progress` 臂尚未声明这一位(与 `requiresRealApproval` / `ruleOffers`
43
+ 同形:server 已真发、SDK 锚未跟)⇒ 本层是**结构视图读**,SDK 补上当天那处 cast 可整条删。
44
+ 同批做了这条白名单的**族扫**并把账写进码与档:server 投影发 13 键、本层转 8 键,
45
+ **仍被剥掉的五键**(`seq` / `taskType` / `parentTaskId` / `status` / `eventId`)逐条登记了
46
+ 「谁没在读它」,见 `docs/INTEGRATION-CLIENTS.md` §3d。**刻意不静默多透**:透一位的前置条件是
47
+ 说得出谁读它、读来干什么。
48
+ - **件② decide 失败臂透传 `currentPending`**(`src/hitl/hitlBridge.ts` 新读口 +
49
+ `toolApprovalWire.ts` / `parkResolver.ts` 三个拍平点)。server S-02(≥7.52)在 409
50
+ `approval_stale` 拒体上铸的 **additive 指路键** —— 本会话**当前**那条 pending 的三件 D-1 坐标
51
+ (`toolName` / `boundCallId` / `boundInputHash?`),壳拿它**一跳重定位**再 decide,免整队重拉。
52
+ 新增公开读口 `readDecideCurrentPending(e)` + 类型 `GateCurrentPending`;`FsApprovalOutcome`
53
+ 与(包内型)`GateOutcome` 的 `failed` 臂各补一个**可选**位。
54
+ 🔴 **它不参与任何裁决**(server 自己的登记原话:纯指路/便利面,不进任何门/CAS/resume 判定)——
55
+ 缺席是常态且什么都不证明(老 server / 非工具门 / 行读失败 / 本次失败根本不是 stale 臂,四形同形)。
56
+ 🔴 读口是**结构视图读**不是 `instanceof`:decide 出站走的是宿主注入的 client,抛出物的类身份
57
+ 不归本包假设(跨 realm / 双实例 = `HitlSafetyError` 判型契约踩过的同一个坑);而且
58
+ `ApprovalStaleError.currentPending` 只在 sdk ≥7.3.0 上声明,本包地板是 7.2.0。
59
+ ⚠️ **可达性如实登记**:sdk 的 `ApprovalDecision` 自 1.0.0 起刻意无 `checkpointToken`,而 server
60
+ 的 stale 臂只在调用方**回显该 token** 时触发 ⇒ **经 SDK client 的 decide 今天拿不到这枚 409**。
61
+ 本位是给「注入自有传输层 / 读别人写的 wire」的宿主与将来上游放行准备的通路,**不是**今天就能
62
+ 在 cli 上观察到的行为改动。
63
+ - **件③ 交互 Esc 停止判定上收**(新模块 `src/interactiveHalt.ts`)。`planInteractiveHalt(...)` +
64
+ 升级码闭集 `RUN_LEVEL_STOP_ERROR_CODES`:「Esc ⇒ 先发 **turn 级** halt;升级成 **run 级** cancel
65
+ 恰有**两格** —— ① 引擎自己回了升级闭集里的 409(它在说「这里没有在飞 turn 可切,run 级停止请用
66
+ cancel」),或 ② 这一发**连判决都没拿到**且屏上确实挂着审批卡;其余一律不升级」。这条判定 TUI/desktop/web
67
+ 三端都要(都会 Esc、都会撞同一个 parked 格),此前整条住在 cli 壳里。**纯判定**:零 IO、零
68
+ import、零 module 级状态;发射(裸 fetch / SDK verb)、台账、留痕、UI 反馈仍归各端。
69
+ 不对称是刻意的:判**不**升级 = 用户退回「Session busy」卡再选一次(可恢复);判**错**升级 =
70
+ 拆掉一条其实还活着的 run 并连坐它身上的在飞工具(不可恢复)。
71
+ 🔴 **本批只出口子,壳侧换装不在本批** —— cli 那条「裸 fetch 直拨 interrupt」的网络面豁免,
72
+ 退役条件就是端接上这个口子(见 §7d)。
73
+
74
+ **异源对抗复审三条 finding 全数采纳并修**:
75
+ - [high] `RUN_LEVEL_STOP_ERROR_CODES` 原本导出成 `ReadonlySet` —— **只在类型面只读**,运行期
76
+ `.add('interrupt.not_held')` 就能把「不许升级」变成「升级」,把一条**还活着**的 run 不可恢复地
77
+ 拆掉,而模块头注那句「纯判定、零可变态」当场变成假话。改成 `Object.freeze` 的数组
78
+ (运行期真改不动,公开面与判定源可以安全同物),判据形随之 `.has()` → `.includes()`;
79
+ 门补 G4b 负控:**先真的去改公开面**,再确认判决零漂移。
80
+ - [high] accept-session(`allowSession`)腿的回退臂会**吞掉带 `currentPending` 的 stale 拒绝**,
81
+ 然后用人对**旧卡**给的答案自动重发一次纯 approve(§4d/§4e 铁律禁止的动作),并把首发的指路键
82
+ 顶掉。修:拒体带指路键 ⇒ 立即上抛(只发一次 decide,指路键原样进 outcome);老 server 的
83
+ 400 未知键**照旧回退**,既有兼容腿宽度一字未改。回退臂**更宽的那一半**(404/5xx/无 status 错误
84
+ 仍会回退)是**存量**问题,如实登记为 §7b **P-43**,不在本 additive 批射程内。
85
+ - [medium] §10 / CHANGELOG / README 的总契约句只写了「拿不到判决且 parked 才升级」,漏掉**引擎
86
+ 明确指路**那一格,与同节分支表自相矛盾;端照那句接线会漏接 409 升级分支、让 parked run 留锁。
87
+ 四处文案统一成「升级恰有两格」。
88
+
89
+ **新增常驻门两道**(`scripts/gates-manifest.json` 同批登记,35 套):
90
+ `run-additive-key-passthrough-test.mjs`(45 checks:白名单键账双向 + §3d 档账对账 + 指路键窄读表
91
+ + durable 两腿真行为 + accept-session 腿真路径 + 三拍平点同形自证)、`run-esc-halt-plan-test.mjs`
92
+ (39 checks:分支表逐格 + 全体负控 + 冻结闭集变异负控 + 纯度/导出形)。两道门各以**单点变异**自证
93
+ 判别力(去掉 model 透传 / 只修 allow 不修 deny / 拿掉 ask 腿透传 / 把 parked 闸改恒真 / 拿掉 409
94
+ 合取项 / 把闭集改回可变 Set / 拿掉 accept-session 再抛闸 —— 七发全被抓)。
95
+
96
+ **门侧盲区清账(件③ 换形逼出来的,不是行为改动)**:`RUN_LEVEL_STOP_ERROR_CODES` 改成
97
+ `Object.freeze` 的 `readonly string[]` 之后,singleton 门的枚举器 ⑤ 命中不了它 ——
98
+ `readonly T[]` 在 AST 上是 `TypeOperator(readonly) → ArrayType`,外面那层壳把整条判据挡住了。
99
+ 按那道门头注自己的规矩「**扩枚举器 + 同批补清单,别扩豁免**」办:枚举器补一层 readonly 解包
100
+ (自检语料同批加一条),随即浮现 **10 条此前从未被登记**的模块级容器(fleet 四张键清单 /
101
+ headless permissionMode 词表 / seat 四张词表 / `seam.CHROME_ARMS`)—— 全部补登,均 `low`
102
+ (九条是与**已登记** `_TUPLE` 同一个数组对象的只读公开投影,一条是已登记表的一次性 `.map()` 派生)。
103
+ 清单 265 → **275** 条,`high` 94 不动。
104
+
105
+
106
+ ### 已知局限(本版新增)
107
+
108
+ - 件② 的 `GateOutcome.currentPending`(ask 腿)今天在**包内无消费方**:`GateOutcome` 是包内型、
109
+ 决断结局不出包。它的存在理由是**同形存量清剿**(两条 decide 失败腿一次改齐,不许只修当格),
110
+ 消费条件登记在 `docs/INTEGRATION-CLIENTS.md` §7b。
111
+ - 件① 的 `model` 只**透传**,本包不据它做任何判定/渲染(面板 tick 的事件形没有这一位);
112
+ 端要用就从投影臂上读。
113
+
31
114
  ## 0.46.0(2026-08-30)
32
115
 
33
116
  两件修复批(#354/#355 双 P0 现网案的 client-core 半场):
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sema-agent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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.46.0
38
+ **Version:** 0.47.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
@@ -257,6 +257,8 @@ public-surface guard checks that last one).
257
257
  | `scripts/run-park-decision-layer-test.mjs` | The decision layer behind the "stuck behind a card" family, shared by every client. A pending row that is **not in the queue** is three states, not one: a bounded, interruptible re-probe loop distinguishes *a decidable row*, *not born yet* (no positive evidence that anything settled — an empty queue proves nothing) and *settled elsewhere*, always probes at least once so a zero budget keeps the pre-fix semantics verbatim, cuts a hung read face off at the window rather than only noticing afterwards, and reports the honest failure when the window is spent instead of inventing a decision. The decision-note reader is likewise three-state: an explicit `noteRecorded: false` outranks an echoed note body, absence renders **no line at all**, and untrusted note text is flattened and bounded before it ever reaches a renderer. Row routing anchors on the deciding quantity — a row carrying `gateKind: "human"` with `toolName: "Write"` is a tool gate, because `human` is the engine's *generic* "someone must decide", not a synonym for a question — and the queue scan refuses to surface a row it cannot positively prove belongs to this session. A chain that fails after the row vanished is split by whether a card was ever presented: decided-elsewhere, or not-its-turn-yet. A row-level single-flight makes "at most one card per pending item" structural rather than incidental. The resume three-way card pins the option **order** (the zero-effect choice sits at index 0, because the frame carries no default-focus field and a stray Enter must not attach or cancel), renders only options the wired verbs can honour, collapses every ambiguous answer to zero action, omits the liveness line entirely when the engine gave no evidence, and — when there is no card lane at all — prints three real routes and exits on a dedicated code rather than reporting success |
258
258
  | `scripts/run-selfheal-reopen-test.mjs` | The 409 active-run self-heal decision chain: `governanceForced` narrows on strict `true` only; triage prefers the wire's `pendingGate.kind` and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of `presented: false` demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (`hitl/gateIdentity.ts`, AST string-token scan); the armed-gate presentation ledger is per-session; and the `plan_review` reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the `running` three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent **exactly once** with its three delivery outcomes worded apart (a `queued` receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into *provably not delivered* (4xx) and *delivery unknown*, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is *unknown* rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
259
259
  | `scripts/run-terminal-identity-copy-test.mjs` | Terminal-state **identity**, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set `limits.*` family, `output.invalid`, and the `blocked` contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it `API Error:` sends the reader to check the network, the key and the quota when the handle is the `--max-turns` they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is *not* an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real `gateway HTTP 502`, a `conflict.session_active_run` and any unknown code all keep the `API Error:` prefix, and the row keeps its `isApiErrorMessage` class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: `selfHealSubmissionDisposition` is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
260
+ | `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 |
261
+ | `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 |
260
262
 
261
263
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
262
264
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -382,9 +382,29 @@ export function eventToSdkMessage(ev, ctx) {
382
382
  // NEUTRAL internal `task_progress` arm — NOT a renderable CC SDKMessage. The upstream bridge binds it
383
383
  // to the open Task/Agent tool_use card and synthesizes CC-native `progress` messages so CC's own
384
384
  // Task-card rollup (tool uses / tokens climbing) animates for an ENGINE-side subagent exactly like a
385
- // local AgentTool one. Fields are read DEFENSIVELY: the service wire whitelist (server.ts:1334-1342)
386
- // strips `status` + EventIdentity and may omit `name` — mirror what actually rides the wire, not the
387
- // richer core-side arm.
385
+ // local AgentTool one. Fields are read DEFENSIVELY: the service wire whitelist may omit `name` —
386
+ // mirror what actually rides the wire, not the richer core-side arm.
387
+ // 🔴 **就地订正**(#363,0.47.0,真字节实证):本句原文写的是「the service wire whitelist
388
+ // (server.ts:1334-1342) strips `status` + EventIdentity」—— 那是**错的**,而且方向正好相反:
389
+ // server 7.52.1 的 `taskProgressEventData` **发** `status`,`identityFields` 也**发**
390
+ // `eventId` + `parentToolCallId`(见下面的族扫账)。剥掉它们的是**本层**,不是 service。
391
+ // 这句错话正是「两边代码看着都对」的一部分:它把本层自己的白名单说成了上游的缺席。
392
+ //
393
+ // ── 🔴 本臂白名单的**族扫账**(#363,0.47.0;真源 = server 7.52.1 `trace/project.js` 的
394
+ // `taskProgressEventData` + `identityFields` 真字节,不是推断)────────────────────────────
395
+ // server 投影**发** 13 键:taskId · seq · taskType · parentTaskId · name · model · status ·
396
+ // usage · currentAction · workflowRunId · workflowAgentLabel · eventId · parentToolCallId。
397
+ // 本臂**转** 8 键:taskId · name · usage · model · currentAction · workflowRunId ·
398
+ // workflowAgentLabel · parentToolCallId。
399
+ // ⇒ **仍被本层剥掉的 5 键**(逐条登记,刻意不静默多透 —— 每一位都要有消费方与语义才配上臂):
400
+ // · `taskType` / `status` / `parentTaskId` —— 早已在册的 §7 缺口 **P-1**(lane 归属今天改用
401
+ // id 形状 / `workflowRunId` 启发式判,见 `src/adapt/arms.ts`);
402
+ // · `seq` —— core #258 的 stop-cycle 代际号(复活即 +1)。fleet 面已有同轴的
403
+ // `wireCycleSeq`(0.38.0),tick 这条腿今天**没有**消费方;SDK 7.3.0 的 `task_progress` 臂
404
+ // 连声明都没有(core/server 两侧都有)。
405
+ // · `eventId` —— EventIdentity 的另一半。`status` 臂 2026-08-08 已因跨 lane 状态破坏补齐两键,
406
+ // 本臂只补了 `parentToolCallId`(lane 判据要它),`eventId` 至今无消费方。
407
+ // 上面三档的共同纪律:要透哪一位,先说得出**谁读它、读来干什么**,并同批更新 §3d 的键级剥离账。
388
408
  case 'task_progress': {
389
409
  if (typeof ev.taskId !== 'string')
390
410
  return dropped('malformed', 'task_progress');
@@ -400,11 +420,27 @@ export function eventToSdkMessage(ev, ctx) {
400
420
  // 仍剥这两键(server a76bd77 才修,[1619])——真 wire 于 1.273+ 才带,此处先备好通路。
401
421
  const workflowRunId = ev.workflowRunId;
402
422
  const workflowAgentLabel = ev.workflowAgentLabel;
423
+ // #363(0.47.0,提货批取证定谳)— `model`:这条 tick 所属子 run 的**模型 id**。
424
+ // 🔴 上游真字节(不是推断):server 7.52.1 `dist/trace/project.js` 的
425
+ // `taskProgressEventData` 白名单里逐字有
426
+ // `...(typeof ev.model === "string" && ev.model.length > 0 ? { model: ev.model } : {})`,
427
+ // 三条腿(bg runs.ts / resume append / 同步 live SSE)共用它;core 铸点 = `prepared.model.id`。
428
+ // cli 侧 live 采帧同批实证过一条真帧带 `"model":"deepseek-v4-flash"`。
429
+ // 🔴 病形与 `workflowRunId` **同形第二例**([1616]/[1617]②):这层白名单是闭形的,于是一条
430
+ // **已经在 wire 上**的事实到不了宿主,而两边代码看着都对(「送到了但没人读」的精确坐标
431
+ // 其实是「没转」)。additive 透传,旧 server 缺席 ⇒ 键不 stamp,形状逐字节不变。
432
+ // 🔴 **结构视图读**:sdk 7.3.0(今天 npm 上的最新版)的 `task_progress` 臂**尚未声明**这一位
433
+ // —— 与 `requiresRealApproval`(#283)/ `ruleOffers`(#334)同形:**server 已真发、SDK 锚未跟**。
434
+ // 照那两例的先例先追 server 不等 SDK;SDK 补上当天这处 cast 可整条删掉(退役条件)。
435
+ // 🔴 条件 spread 与 server 投影**同形同条件**:`string` 且非空才带。空串既不是模型 id 也不是
436
+ // 「不知道」,透下去只会在面板上渲成一个空模型名([honest-absence-not-fabricated-zero])。
437
+ const model = ev.model;
403
438
  return projected(stamp(ctx, armBody({
404
439
  type: 'task_progress',
405
440
  taskId: ev.taskId,
406
441
  ...(ev.name !== undefined ? { name: ev.name } : {}),
407
442
  ...(ev.usage !== undefined ? { usage: ev.usage } : {}),
443
+ ...(typeof model === 'string' && model.length > 0 ? { model } : {}),
408
444
  ...(typeof currentAction === 'string' && currentAction.length > 0
409
445
  ? { currentAction }
410
446
  : {}),
@@ -211,10 +211,31 @@ export interface GateLedger {
211
211
  * 「已解决」,吃满 `MAX_GATE_HOPS` 才吐一句 `gate hop limit exceeded` —— 用户白等 24 轮往返,
212
212
  * 拿到的还是一句与真因无关的话(真因被预算话术顶掉了)。
213
213
  * 消费一次即失效之后:第一次(= 真的重放)照旧救回,第二次就是诚实的 fail-soft。
214
+ *
215
+ * ✅ **同形存量已销**(#357,0.47.0):上面这段描述的病形在**身份判据**这一腿由本动词的一次性
216
+ * 语义关掉,但它的**孪生腿**(`isAlreadyResolvedFailure` 的 code/文案判据)当时没有任何限次 ——
217
+ * 那一腿在「run 仍 parked 而取件读到空表」的坐标失配态下每轮都命中,照样烧满 24 个 hop。
218
+ * 闸坐标 = {@link noteAlreadyResolvedGate} + `parkResolver.MAX_ALREADY_RESOLVED_REATTACHES`
219
+ * (同因连续第 2 次即不再 reattach),主修 = `parkResolver` 的坐标重探臂。
214
220
  */
215
221
  takeDecided(callId: string): boolean;
216
222
  /** 只给 debug 串用的规模位(`[decided so far: N]`)。 */
217
223
  decidedCount(): number;
224
+ /**
225
+ * 记一次「这张 park 被判成**已解决**」的命中,返回**同一判据源**的连续命中次数(第一次 = 1)。
226
+ * 判据源换了(`key` 不等)⇒ 从 1 重新计。
227
+ *
228
+ * 🔴 为什么闸态放在台账而不是 `resolvePark` 里:`resolvePark` 是**无实例态的纯函数**,而这条闸
229
+ * 要跨多次 park 才成立 —— 与 `hops` 同源(那也是驱动侧跨 park 累加、经 `ParkResolveCtx` 传进去
230
+ * 的量)。台账是本 turn 里唯一活得比一次 park 长、且**只有一个写者**的载体。
231
+ *
232
+ * 🔴 `key` 必须是**归一化的判据源**(命中它的那个码 / 那个文案子串),不是原始 reason 串 ——
233
+ * reason 里带着每轮都不一样的尾巴(`decide failed: Error: …`),拿它当键会让「连续」永远不成立,
234
+ * 闸当场退化成空转([anchor-on-the-deciding-quantity])。
235
+ */
236
+ noteAlreadyResolvedGate(key: string): number;
237
+ /** 判据链上出现了真进展(park 真被决断 / 重探把新坐标的卡呈出去了)⇒ 连续计数归零。 */
238
+ resetAlreadyResolvedGate(): void;
218
239
  }
219
240
  /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
220
241
  export declare function createGateLedger(): GateLedger;
@@ -20,6 +20,9 @@ export function createGateLedger() {
20
20
  const pendingFsCalls = [];
21
21
  const resolvedAnswers = new Map();
22
22
  const decidedGates = new Set();
23
+ /** 「已解决」判据的**同因连续命中**闸(#357):判据源 + 连续次数,见 `noteAlreadyResolvedGate`。 */
24
+ let alreadyResolvedKey;
25
+ let alreadyResolvedStreak = 0;
23
26
  // ── 批级判据(四样同生共死,退休点见 `retireBatchState` 的两处调用)────────────────────────
24
27
  /** 本批 park 属 fs/shell 族的准入证(唯一性兜底的前置)。 */
25
28
  let fsOrShellFamilyGate = false;
@@ -266,5 +269,19 @@ export function createGateLedger() {
266
269
  decidedCount() {
267
270
  return decidedGates.size;
268
271
  },
272
+ noteAlreadyResolvedGate(key) {
273
+ if (key === alreadyResolvedKey) {
274
+ alreadyResolvedStreak += 1;
275
+ }
276
+ else {
277
+ alreadyResolvedKey = key;
278
+ alreadyResolvedStreak = 1;
279
+ }
280
+ return alreadyResolvedStreak;
281
+ },
282
+ resetAlreadyResolvedGate() {
283
+ alreadyResolvedKey = undefined;
284
+ alreadyResolvedStreak = 0;
285
+ },
269
286
  };
270
287
  }
@@ -155,6 +155,56 @@ export declare class HitlSafetyError extends Error {
155
155
  * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
156
156
  code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'empty_answer');
157
157
  }
158
+ /**
159
+ * decide 409 `approval_stale` 拒体上的 **additive 指路键** `currentPending` —— 本会话**当前**那条
160
+ * pending 的三件 D-1 坐标(#363,0.47.0;server S-02 ≥7.52)。
161
+ *
162
+ * ── 它是什么、不是什么 ────────────────────────────────────────────────────────────────────
163
+ * 是:「你手上那条已经不是当前的了,**当前**这条长这样」——壳拿它**一跳重定位**再 decide,
164
+ * 免去整队重拉(`GET /v1/approvals`)。
165
+ * 不是:放行凭据 / CAS 输入 / resume 凭证。server 自己的 fail-open 登记原话逐字:
166
+ * 「纯指路/便利面,**不参与任何门/CAS/resume 判定**」。⇒ 缺席的代价 = 退回它到货前的行为
167
+ * (重拉列表自行重定位),所以本包对它一律**宽读、诚实缺席**,绝不 fail-closed。
168
+ * 🔴 **恒不含 `checkpointToken`**(server 侧 resume 凭证不外发纪律)——本形也刻意没有那一位:
169
+ * 多一个位就是给一个「凭证能过境」的错觉。
170
+ *
171
+ * ── 为什么本包自铸这个形(记账,不是偷懒)────────────────────────────────────────────────
172
+ * `@sema-agent/sdk` **7.3.0** 已有逐字同形的 `ApprovalStaleCurrentPending`(`dist/types.d.ts`)+
173
+ * `ApprovalStaleError.currentPending` 第 5 构造参。但本包的 peer 地板是 **>=7.2.0**,那一版上这两个
174
+ * 名字都不存在 ⇒ `import type` 会让**装 7.2.0 的端当场编不过**,而抬地板不是 additive(它是对所有
175
+ * 消费方提要求)。⇒ 与 `RuleOffer`(#334)同款处置:**自铸形 + 记账**,名字刻意**不同名**
176
+ * (`GateCurrentPending` ≠ `ApprovalStaleCurrentPending`),免得地板线下出现一个与上游同名却不同源的
177
+ * 形([same-name-different-meaning-crosses-layers])。
178
+ * 🔴 **退役条件**:本包 peer 地板抬到 `>=7.3.0` 的那一批,把本形改成
179
+ * `import type { ApprovalStaleCurrentPending } from '@sema-agent/sdk'` 的别名并删掉这段记账。
180
+ */
181
+ export interface GateCurrentPending {
182
+ /** 当前 pending 被门住的工具名(与 pending 行 `toolName` 同源同值)。UNTRUSTED-for-display。 */
183
+ readonly toolName: string;
184
+ /** 当前 pending 的 `pendingAction.toolCallId` —— 壳重定位后 decide 要**逐字回显**的 D-1 锚。 */
185
+ readonly boundCallId: string;
186
+ /** server 铸的 `boundInputHash`(行上有才带)。🔴 逐字回显,**绝不本地重算**(§9.1 同一条铁律)。 */
187
+ readonly boundInputHash?: string;
188
+ }
189
+ /**
190
+ * 从一次 decide 失败(任意抛出物)里读出 {@link GateCurrentPending} —— 读不出即 `undefined`。
191
+ *
192
+ * 🔴 **结构视图读,不 `instanceof`**(两条理由,都是承重的):
193
+ * ① **供给面**:本包的 decide 出站走的是宿主注入的 `HitlClientLike`(见该型)。抛出来的到底是不是
194
+ * SDK 的 `ApprovalStaleError` 由**宿主**决定 —— web/desktop 完全可以注入自己的传输层。拿
195
+ * `instanceof` 当闸,等于让「宿主用的是哪一份 SDK 实例」决定一个纯读值的位在不在
196
+ * (跨 realm / 双实例 = `HitlSafetyError` 判型契约踩过的同一个坑,见 §4e)。
197
+ * ② **类型面**:`ApprovalStaleError.currentPending` 只在 sdk ≥7.3.0 上声明,而本包地板是 7.2.0
198
+ * (理由见 {@link GateCurrentPending} 的记账段)。
199
+ * 🔴 **判据锚在「值本身合不合形」,不锚它骑在哪个类/哪个 errorCode 上**
200
+ * ([anchor-on-the-deciding-quantity]):真正决定「壳能不能一跳重定位」的量,是**这个三元组的
201
+ * 两件必填位在不在且是非空串** —— 而不是错误的类名。宽读在这里是**方向安全**的:本位不参与
202
+ * 任何裁决(见 {@link GateCurrentPending} 顶注),多读到一次的代价 = 多一条指路;漏读的代价 =
203
+ * 退回整队重拉。
204
+ * 🔴 **两件必填位缺一即整只不铸**:半个三元组重定位不了(`decide` 要的是 boundCallId 本体),
205
+ * 铸一个残形只会让消费点以为「有指路」。`boundInputHash` 是**行上有才带**的可选位,缺席照铸。
206
+ */
207
+ export declare function readDecideCurrentPending(e: unknown): GateCurrentPending | undefined;
158
208
  /** 测试钩:把超时类重试总窗调小(传 undefined 还原缺省)。 */
159
209
  export declare function __setDecideTimeoutRetryBudgetForTests(ms?: number): void;
160
210
  /**
@@ -53,6 +53,41 @@ export class HitlSafetyError extends Error {
53
53
  this.name = 'HitlSafetyError';
54
54
  }
55
55
  }
56
+ /**
57
+ * 从一次 decide 失败(任意抛出物)里读出 {@link GateCurrentPending} —— 读不出即 `undefined`。
58
+ *
59
+ * 🔴 **结构视图读,不 `instanceof`**(两条理由,都是承重的):
60
+ * ① **供给面**:本包的 decide 出站走的是宿主注入的 `HitlClientLike`(见该型)。抛出来的到底是不是
61
+ * SDK 的 `ApprovalStaleError` 由**宿主**决定 —— web/desktop 完全可以注入自己的传输层。拿
62
+ * `instanceof` 当闸,等于让「宿主用的是哪一份 SDK 实例」决定一个纯读值的位在不在
63
+ * (跨 realm / 双实例 = `HitlSafetyError` 判型契约踩过的同一个坑,见 §4e)。
64
+ * ② **类型面**:`ApprovalStaleError.currentPending` 只在 sdk ≥7.3.0 上声明,而本包地板是 7.2.0
65
+ * (理由见 {@link GateCurrentPending} 的记账段)。
66
+ * 🔴 **判据锚在「值本身合不合形」,不锚它骑在哪个类/哪个 errorCode 上**
67
+ * ([anchor-on-the-deciding-quantity]):真正决定「壳能不能一跳重定位」的量,是**这个三元组的
68
+ * 两件必填位在不在且是非空串** —— 而不是错误的类名。宽读在这里是**方向安全**的:本位不参与
69
+ * 任何裁决(见 {@link GateCurrentPending} 顶注),多读到一次的代价 = 多一条指路;漏读的代价 =
70
+ * 退回整队重拉。
71
+ * 🔴 **两件必填位缺一即整只不铸**:半个三元组重定位不了(`decide` 要的是 boundCallId 本体),
72
+ * 铸一个残形只会让消费点以为「有指路」。`boundInputHash` 是**行上有才带**的可选位,缺席照铸。
73
+ */
74
+ export function readDecideCurrentPending(e) {
75
+ const raw = e?.currentPending;
76
+ if (typeof raw !== 'object' || raw === null)
77
+ return undefined;
78
+ const o = raw;
79
+ if (typeof o.toolName !== 'string' || o.toolName.length === 0)
80
+ return undefined;
81
+ if (typeof o.boundCallId !== 'string' || o.boundCallId.length === 0)
82
+ return undefined;
83
+ return {
84
+ toolName: o.toolName,
85
+ boundCallId: o.boundCallId,
86
+ ...(typeof o.boundInputHash === 'string' && o.boundInputHash.length > 0
87
+ ? { boundInputHash: o.boundInputHash }
88
+ : {}),
89
+ };
90
+ }
56
91
  // ── decide 出站的瞬断分诊与有界重试(Inkglow-1085 P0a,2026-08-19;[4664] server 定谳后对表)────
57
92
  //
58
93
  // 病形(案卷 INKGLOW-1085 车1 全链):`approvals.decide` 是**非 submit POST** —— SDK transport 对它
@@ -13,6 +13,7 @@
13
13
  * 在驱动的消费点上必然显形,而不是被本文件自己吞掉。
14
14
  */
15
15
  import type { AgentEvent } from '@sema-agent/sdk';
16
+ import { type GateCurrentPending } from './hitlBridge.js';
16
17
  import { type QuestionAnswer } from '../liveQuestionStore.js';
17
18
  import type { AskAnsweredOutput, GateLedger } from './gateLedger.js';
18
19
  import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
@@ -46,6 +47,7 @@ export type GateOutcome = {
46
47
  reason: string;
47
48
  code?: GateFailureCode | undefined;
48
49
  retryExhausted?: true;
50
+ currentPending?: GateCurrentPending | undefined;
49
51
  };
50
52
  /**
51
53
  * decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。