@sema-agent/client-core 0.72.13 → 0.73.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,47 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.73.0(2026-09-18)
53
+
54
+ > 主题:**fleet 视图的数值键不再把「不知道」编成 0**(CC-52;审计 #62 A-18,「缺席被折成正向值」同族的数值形)。**minor,型面 BREAKING**:`FleetTaskView` 的 `elapsedMs` / `tokens` / `queuedCount` 与 `FleetWorkflowView` 的 `doneCount` / `totalCount` / `elapsedMs` / `tokens` / `failedCount`、以及 `WorkflowRunState.totalTokens` 由必填改**可选** —— 消费方 `tsc` 会红一次(`number | undefined`),那正是通知。公面导出名零增减(1019);零 wire 新键;peer 不动。接入面 §62。
55
+
56
+ ### Changed(BREAKING)
57
+
58
+ - **八个数值键:有键 ⇒ 真值(含真 0);缺键 ⇒ 不知道**。此前 `projectTasks` / `projectWorkflows` 对 wire 缺席一律 `?? 0`,于是屏上的「0s」「0 tokens」「0/0 agents done」里有一部分是本包替引擎编的。现在 wire 缺席 ⇒ 视图键缺席;坏形(负数 / NaN / Infinity / 非数;计数位另加非整数)同样缺席,不折 0 也不原样过境。sdk 的行型上这些键本来就是可选,本版只是不再在投影层把可选抹掉。
59
+ - **逐键缺席语义**(端按语义分渲,不是一律「—」):
60
+ - `FleetTaskView.elapsedMs` 缺席 = **这一行没有时长基数**:既没有 launch 锚 `startedAt`、也没有引擎快照 `elapsedMs`(很老的引擎 / 极早期首帧)。渲「—」。有 `startedAt`(且不晚于本端时钟)的行台账自算 `now - startedAt`;`startedAt` 晚于本端时钟的行退到引擎快照 + 空窗,连快照也没有就缺席。
61
+ - `FleetTaskView.tokens` 缺席 = **引擎还没为这一行报过用量**(排队中 / 刚起 / 该车道不报)。渲「—」或不渲这一段;**不是** 0 tokens。
62
+ - `FleetTaskView.queuedCount` 缺席 = **引擎没报这一位**(本包不保证任何引擎版本上报它;sdk 行型上它是可选整数)。缺席 ⇒ 不渲排队徽标、不渲 `0 queued`;有键即真值。
63
+ - `FleetWorkflowView.doneCount` / `totalCount` / `failedCount` / `tokens` / `elapsedMs` 缺席 = **本拍没报**:现役引擎的 workflow 行铸点五键恒发,缺席只出现在更老的引擎或乱序的部分增量上。渲「—」;🔴 **不互相推导**(`failedCount` 缺席不读作 0;`totalCount` 缺席不拿 `doneCount` 顶;`startedCount` 缺席时 `doneCount + failedCount` 这个下界只在两键都在场时成立)。
64
+ - **台账的帧间时钟补偿只给有基数的行推进**(`fleetLedger` 两处):基数 = 引擎快照 `elapsedMs`,且**先验再算**(非负有限数;负基数加上空窗会被洗成一个看着合法的正数)。基数缺席时此前 `(elapsedMs ?? 0) + 本端空窗` 把「本端收到这一行多久了」渲成「它跑了多久」—— 那是本端的钟,不是这一行的时长 ⇒ 现在保持缺席。有 `startedAt` 的行(现役引擎恒带)走 `now - startedAt`,不受影响。
65
+
66
+ - **同轴两条读面一并改**(发车前异源复审查漏抓出;它们不经过上面两只投影函数):
67
+ - **agent 面板投影** `projectFleetAgentRows`:行上没有 `tokens` 时此前发布 `fleet-row{ totalTokens: 0 }`,已知用量之后来一帧缺席又发布 0 把它清零。现在缺席 / 坏形 ⇒ `totalTokens` 键不落;已知值在**同一周期内**粘住不被缺席帧覆盖(行已 settle 后同 id 复活、或 launch 锚换了 = 新周期,不继承上一周期的数);终态帧只有用量变了也会在 `end` 之前先发一条带最终用量的 `fleet-row`(消费端通常不再更新非 running 行);同周期内每一条 `fleet-row` 都带上本周期已知的 `startedAt`(不只是它变化的那一帧),未订阅期间的事件缓冲据此把 launch 锚变化当作合并屏障(跨周期不逐键合并,回放与在线投递同果);🔴 `fleet-row` 是部分帧(省略的键 = 沿用行上旧值),`startedAt` 变了时清掉行上旧用量是消费端的义务;引擎真报的 0 逐字发布。(事件型上该键本来就是可选,类型面零变化。)
68
+ - **workflow monitor** `projectWorkflowRun`:`WorkflowRunState.totalTokens` 由必填改**可选**(BREAKING)。有键 ⇒ 一个**完整**的总数。引擎聚合 `stats.tokens` 合法 ⇒ 用它(有 nested 载体时其 `tokens` 也得合法才相加);聚合残缺(own 缺席而 nested 在场、或 nested 载体在场但读不出数)⇒ 键缺席,不按 0 补齐;两者都没有时只在**每条腿都报过合法用量**时才求和;有腿未报 / 非法数(负 / NaN / 小数)/ 零条腿 ⇒ 键缺席。此前未报的腿按 0 加进去,monitor 显示一个确定的「0」或偏小的总数。
69
+
70
+ ### 取舍
71
+
72
+ - 这是一次**故意的编译期打断**:additive 的做法(另加一组 `*Known` 布尔)会让老代码继续把编出来的 0 渲上屏,而这一类病的全部问题就是「没人知道那个 0 是编的」。三端在预告期内均未要求延后。
73
+ - 🔴 **不改消费代码只换包不是安全的**:类型检查会红,但绕过类型(纯 JS 调用 / 只转译不查型的构建)时,运行期的后果不止是屏上出现 `undefined` / `NaN` 字样 —— **凡是拿这些键做算术或阈值比较的地方会静默改变行为**。典型形:规模告警一类的判定里 `totalCount` / `tokens` 缺席 ⇒ 比较恒 false、预测量为 NaN ⇒ **告警不再触发**(此前编出来的 0 反而会让它触发)。迁移要求见 §62y:未知的规模不能被当作规模正常。
74
+
75
+ ## 0.72.14(2026-09-18)
76
+
77
+ > 主题:**悬挂的流内 ask 的拉半场**(CC-55;cli L-397 P1)。流内 ask 在铸造那一刻若没有活着的宿主流可投,引擎把它转成悬挂(窗约一小时,不补发),此后只在 `GET /v1/approvals` 回体的 `livePending` 段可发现 —— 后台 workflow 的子代恒落这一形(宿主 turn 先结束),用户面零帧零卡、代理静默卡到窗尽。**patch**:型面纯 additive(+1 常量 +7 函数,公面 1011 → 1019;`ApprovalsFeedSnapshot` / `ApprovalsFeedStats` / `ApprovalsFeedOptions` / `ToolApprovalFrameLaneOpts` / `ApprovalCardRequest` / `ToolApprovalFrameOutcome` 各加一可选或新增位;零 wire 新键;peer 不动)。接入面 §61。
78
+
79
+ ### Added
80
+
81
+ - **`ApprovalsFeedSnapshot.livePending?`**:`startApprovalsFeed` 的快照此前**只含 `pending`**,变化摘要也只算 `pending` ⇒ 悬挂 ask 出现 / 结算都不触发快照,端即使挂了 feed 也看不见。现在 `list()` 回体的 `livePending` 段按结构窄读进快照(sdk barrel 不导出该行型),并进变化摘要。🔴 **键缺席 = 引擎没报这一段**(老引擎),不是「没有」—— 快照上键缺席,绝不铸 `[]`;引擎报了且为空才是 `[]`;键在场却读不懂 ⇒ **整次取件不提交**(保留上一张,不把读不懂渲成没有,计入 `listErrors`)。
82
+ - **`src/hitl/livePendingAsk.ts`**:`readLivePendingRows(body)` 三态(`not_reported` / `malformed` / `present{rows, dropped}`;四必填位坏形丢行计数、同 `approvalId` 留第一条、可选旗标 only-if-true、多余成员不过境);`suspendedSubagentAsks(snapshot, {sessionId?})`(只留子代行;给了会话就只留该会话的,行上没有 `sessionId` = 归不了属 ⇒ 不出卡);`countApprovalsAwaitingDecision(snapshot, filter?)`(`durable` / `live` / `suspendedSubagent`,**不知道 = `null` 不是 0** —— 端的「在等你决定」计数读它,不再只认 durable 停驻一条来源);`createSuspendedAskTracker(filter?)`(快照 → `appeared` / `gone` 的去重账:同 `approvalId` 只出一次;`noteSurfacedByStream` = **双向认领口**,返回 `true` 帧腿可出卡且快照不再出第二张、返回 `false` = 已从快照出过卡 ⇒ 帧腿跳过;`requeue` = 让一只 ask 重新出卡;两本账都**不按条数淘汰活跃条目**(出过卡的随「从列表消失」离账;认领在列表里见过之后消失才离账;只有「认领了但从未在快照里见过」的那一小类受 512 上界管);`noteDecided` = 已决断的在引擎结算前不重新出卡;条目消失恰一次 `gone` = 撤卡;`livePending` 键缺席 ⇒ 已出的卡全部 `gone`)。
83
+ - **决断口 `surfaceSuspendedAskAndRespond(row, respond, signal?, lane?)`**:悬挂行经 `livePendingRowToApprovalFrame` 适配后走**流内帧腿同一条链**(同一个卡口、同一个身份键铸口 `liveFrameCallKey(approvalId)`、同一套回执 / 拒绝读法)。🔴 **数组名即路由判据**:`livePending` 行的决议口是 `POST /v1/tool-approvals/{approvalId}/respond`,不是 durable 行的 decide 口;幂等键 = `approvalId`。
84
+ - **`ToolApprovalFrameLaneOpts.argsUnavailable?: true`**:`livePending` 段刻意不带工具入参 ⇒ 这张卡上 `wireNote` 明说「看不到入参、你是在没看到它们的情况下做决定、这张卡不接受编辑」,卡请求带结构化位 `ApprovalCardRequest.argsUnavailable: true`(端据此不给「编辑后批准」)。🔴 真收到带改写的批准 ⇒ **整次不发**:丢掉改写再发 allow = 批准了人没看到的原始入参,把改写转发 = 拿卡上的空对象替换真实入参,两条都不是人按下的那个决定 ⇒ 结局 `decision:'unresolved'` + `ToolApprovalFrameOutcome.editRefused: true`,屏上一条告知(`EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT` / `surfaceEditRefusedOnBlindAsk`),ask 仍挂着由端 `requeue` 重出。只由上面那只决断口置位,流内帧腿行为零变化。
85
+ - **对账节拍 `ApprovalsFeedOptions.reconcile?`**(`{ shouldReconcile, intervalMs?, maxIntervalMs? }`)+ `ApprovalsFeedStats.reconciles`:较老的引擎在悬挂 ask 出现 / 结算时不在 `approvals.stream` 上发任何事件 ⇒ push 腿连着也看不见;而「仅活跃 run 时轮询」的 `shouldPoll` 恰好在宿主 turn 结束后关着。本旋钮是**独立**的宿主闸:为真期间(典型:会话有活跃后台代理 ∧ 没有活着的宿主 run)无论哪条腿都定时重取;闸关零额外取件;快照没变则间隔翻倍到上限,变了 / 闸重开回基础间隔;不计 `polls`、不动轮询腿的连续失败计数;谓词抛错 / 非布尔按「查」处理。不传 = 连定时器都不起,行为一字不差。
86
+ - 新门 `scripts/run-live-pending-ask-test.mjs`(52 格;变异反证逐格见红)。
87
+
88
+ ### Known limits(本版新增)
89
+
90
+ - 悬挂 ask 的卡**看不到工具入参**(引擎读面今天不带;已请引擎在该段补入参摘要)。卡上如实明说;到货后本包把摘要过到卡上,届时另版。
91
+ - 窗的倒计时不上这张卡:行上只有绝对死线、没有出帧时刻,凑不出可校偏的窗三键;`expiresAtMs` 原样在视图上,端要渲自己按本地钟渲并自担偏差。
92
+
52
93
  ## 0.72.13(2026-09-18)
53
94
 
54
95
  > 主题:**server 7.86.0 提货**(发车帖 [7549] @client-core 两点,表态 [7553] ⓪;CC-46 `plan_review` 决断体 `permissionModeAfter` 发口 —— cli L-385 / S-433;CC-54 `capabilities.executionLane` 四态读面 —— S-426)+ **CC-53** `engineSupportsDetach` 三态化(审计 #60 A-25,「异常吞成健康值」)。**patch**:型面纯 additive(+4 常量 +14 函数,公面 993 → 1011;测试钩 45 → 47;`HitlSafetyError.code` 闭集 +1 `bad_plan_mode`;`PlanReviewOutcome` / `armPlanReviewApproval` / `decidePlanReview` 各加一可选位;零 wire 新键 —— 键由 server 7.86.0 铸;peer 不动)。接入面 §60。
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.72.13
38
+ **Version:** 0.73.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
@@ -313,6 +313,7 @@ public-surface guard checks that last one).
313
313
  | `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") |
314
314
  | `scripts/run-absence-fold-census-test.mjs` | A package-wide census of the "absence folded into a positive outcome" defect shape, so that fixing the six sites this release does not merely move the shape somewhere else. The defect is defined by position, not syntax: a fallback position (the unconditional tail return, the `default:` arm, the literal minted when there is nothing to pass on, the value returned from an error path) may only say `unknown` or stay absent, never a positive word. Detection walks the syntax tree of every source file, so comments, strings and multi-line spellings cannot hide or fake a hit, and covers five forms: the right arm of `??` / `||`, the else arm of a ternary, the first return of an explicit `default:`, a `catch` block or `.catch(() => …)` arrow returning a healthy value, and a function whose last statement returns a positive word after other returns. Every remaining hit must be registered with a written reason, an unregistered hit fails the gate naming the file and line, the registered count must equal the real count so a cleared site cannot leave a spare allowance behind, and the gate proves its own teeth behind a fence (a failed self-proof refuses to report any count): each form injected into an in-memory copy must add exactly one hit, two correct spellings are pinned as non-hits, and samples inside comments or strings do not count. It also pins the headline site: the fleet panel projection no longer mints an `end` with `isError: false` on absence |
315
315
  | `scripts/run-execution-lane-capability-test.mjs` | The deployment execution-lane self-description (`capabilities.executionLane`, engine ≥7.86.0), read the same four-state way as its three sibling capability readers. An absent key is reported as "not reported" and never as "tools do not run on this host": on an older engine the client keeps inferring the lane the way it did before, because reading absence as `false` would silently stop every host deployment from sending skill `baseDir`. The implication is one-way (`toolsOnThisHost: false` means `baseDir` is never sent; `true` is only a necessary condition), `provider` is read as an open non-empty string rather than a hand-copied closed set and is sanitised and bounded before display, `toolsOnThisHost` must be a strict boolean, extra members never cross, the tee never throws, stale generations are dropped whole, and the single "do tools run here" predicate uses the bit when it is present and the caller's own inference, unchanged, when it is not |
316
+ | `scripts/run-live-pending-ask-test.mjs` | Suspended in-stream asks (the `livePending` section of `GET /v1/approvals`): an absent key reads as not reported (never an empty list or a zero count), an unreadable section leaves the previous snapshot in place, rows are narrow-read (malformed rows dropped and counted, optional flags only-if-true, extra members never cross), the feed digest covers the section so an ask appearing or settling produces a snapshot, the host-gated reconcile cadence re-lists while the push leg is connected and backs off when nothing changes, the tracker surfaces each `approvalId` once and reports it gone once, and the decision runs through the same respond chain as the in-stream frame leg — with the card stating that the tool arguments are not visible and edits on that card never forwarded. |
316
317
  | `scripts/run-leader-conflict-test.mjs` | The leader-run terminal `needs_human` + `result.conflict` (engine ≥7.83.0), read once for all three shells. A `result` without a `conflict` key is reported as `none` and worded as "no conflict details" rather than "no conflict": on the wire it is indistinguishable from an older engine that never reports one, and the reader does not pick a side. A `conflict` that is present but unreadable is a third word, never folded into `none`, because a tree that carries conflict markers must not be rendered as clean. `filesTruncated` is honoured only as `true` (absent means the list is complete; any other value makes the section unreadable), `workers[].applied` is passed through as the boolean fact it is, `salvaged[].patch` is handed to the save path byte-for-byte (an artifact, not screen text; a malformed row is dropped alone, and a salvage list that is present but cannot be fully read is flagged rather than rendered as "no patches"), and `rejHead` — the one on-screen diagnostic — is escaped and bounded before display. The record never throws on hostile input, extra members do not cross, arrays are fresh copies, and the single sentence minted here names the base, the files, which branches landed and which did not, and how many patches can be saved, without ever suggesting a retry: `needs_human` is a run waiting for a person, and the no-details sentence says plainly that it is not proof of a merge |
317
318
  | `scripts/run-mcp-reconnect-test.mjs` | The in-session MCP re-dial verb (`POST /v1/sessions/:id/mcp/reconnect`, engine ≥7.85.0), consumed. The single discriminant is `outcome` and all three answers are HTTP 200, so the reader branches on the word and never on the status; the `unsupported` answer carries exactly five keys and the reader refuses to invent a zero or an empty list for the four fields the engine did not produce, while `accepted` / `refused` treat those four as required and go malformed when one is missing. The tool roster follows the **presence** of `toolNames` (absent = untouched, empty = withdrawn), the connection record passes `errorCode` through as an open set, and every remote-authored string is sanitised and bounded before display. Failures are classified by `errorCode` alone, a missing code is reported as unknown rather than guessed, the verb never throws, and the request-side guard (non-empty name, ≤190 chars) stops a call that the contract would reject anyway. The capability bit reads absent as "cannot tell" rather than "unavailable", and the one sentence the contract insists every UI carries — that re-dialing is a transaction, not a refresh — is minted here once |
318
319
  | `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 |
@@ -52,6 +52,12 @@ export function isEnginePanelTaskResident(taskId) {
52
52
  const MAX_BUFFER = 200;
53
53
  let listener = null;
54
54
  const buffer = [];
55
+ /** 两条 fleet-row 都带 launch 锚且不相等 ⇒ 新周期(任一条没带锚 = 这一帧没说,不算变化)。 */
56
+ function launchAnchorChanged(prev, next) {
57
+ if (prev.kind !== 'fleet-row' || next.kind !== 'fleet-row')
58
+ return false;
59
+ return prev.startedAt !== undefined && next.startedAt !== undefined && prev.startedAt !== next.startedAt;
60
+ }
55
61
  export function publishEngineAgentPanelEvent(ev) {
56
62
  // 同 taskId 的任何主通道事实(tick / fleet-row = 行在场;end = 真终态)都让**缓冲里**那条缺席过期 —— 迟订阅的消费者
57
63
  // 不许先恢复了行、再被一条旧缺席标成 unknown(已递达订阅者的缺席由订阅者按契约在这些事件上撤标)。
@@ -123,9 +129,11 @@ export function publishEngineAgentPanelEvent(ev) {
123
129
  }
124
130
  if (b.taskId !== taskId)
125
131
  continue; // another task's progress — irrelevant to this row
126
- if (b.kind === kind)
132
+ // 0.73.0:launch 锚变了 = 同一个 taskId 的**新周期**,也是一道屏障。逐键合并的「省略的键沿用旧值」只在同一周期内
133
+ // 成立;跨周期合并会把上一周期的 totalTokens / toolUses 继承进新周期(投影层刻意省略的键被缓冲层补了回来)。
134
+ if (b.kind === kind && !launchAnchorChanged(b, ev))
127
135
  i = at;
128
- break; // same lane ⇒ merge target; other lane ⇒ barrier
136
+ break; // same lane & same cycle ⇒ merge target; other lane / new cycle ⇒ barrier
129
137
  }
130
138
  if (i >= 0) {
131
139
  // 🔴 Coalescing must not DROP sticky keys either (0.41.0 adversarial review #4). Both progress
@@ -385,10 +385,6 @@ export interface FleetLedger {
385
385
  /** 测试直取:当前 task 行原样(顺序 = 插入序)。 */
386
386
  __rowsForTest(): FleetTaskRow[];
387
387
  }
388
- /**
389
- * 建一个 fleet 帧台账。**不开流、不构造 client** —— 宿主的 consume 循环把解码后的帧喂进来。
390
- * (这条切缝就是设计稿 §2.5 给 fleetClient 划的那一刀:帧体归库、连接归端。)
391
- */
392
388
  export declare function createFleetLedger(hooks?: FleetLedgerHooks, opts?: CreateFleetLedgerOptions): FleetLedger;
393
389
  /**
394
390
  * `bg_notification` 帧的通知体 wire 形(SDK `FleetFrame` 联合的具名臂投影)。
@@ -109,6 +109,10 @@ export function fleetSnapshotOptions(opts) {
109
109
  * 建一个 fleet 帧台账。**不开流、不构造 client** —— 宿主的 consume 循环把解码后的帧喂进来。
110
110
  * (这条切缝就是设计稿 §2.5 给 fleetClient 划的那一刀:帧体归库、连接归端。)
111
111
  */
112
+ /** 补偿链的基数判据:非负有限数才算(先验再算 —— 负基数加上空窗会被洗成一个看着合法的正数;异源对抗复审)。 */
113
+ function isElapsedBase(v) {
114
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
115
+ }
112
116
  export function createFleetLedger(hooks = {}, opts = {}) {
113
117
  const sessionKey = opts.sessionKey ?? DEFAULT_SESSION_KEY;
114
118
  const nowFn = opts.nowFn ?? Date.now;
@@ -289,7 +293,7 @@ export function createFleetLedger(hooks = {}, opts = {}) {
289
293
  rowMeta.set(row.id, { receivedAtMs });
290
294
  debug(`[fleet-row] id=${row.id} parent=${row.parentId ?? '-'} agentType=${row.agentType ?? '-'}` +
291
295
  ` agentName=${row.agentName ?? '-'} name=${row.name ?? '-'} status=${row.status ?? '-'}` +
292
- ` tokens=${row.tokens ?? 0} parentToolCallId=${row.parentToolCallId ?? '-'}`);
296
+ ` tokens=${row.tokens ?? '-'} parentToolCallId=${row.parentToolCallId ?? '-'}`);
293
297
  // [1491] subagentOutput 寻址:子代行出生帧带 parentId(spawn run)——记映射供查看态终报读面。
294
298
  // 归属判别与通知臂同锚(R1/F1,2026-07-22):
295
299
  // · parent ∈ own-run 台账 → own:登映射 + 把本行 taskId 也登进台账(F1 闭包 —— 嵌套孙代的
@@ -586,8 +590,8 @@ export function createFleetLedger(hooks = {}, opts = {}) {
586
590
  const meta = rowMeta.get(id);
587
591
  const frozenElapsed = typeof started === 'number' && started > 0 && started <= nowMs
588
592
  ? nowMs - started
589
- : meta !== undefined
590
- ? (known.elapsedMs ?? 0) + Math.max(0, nowMs - meta.receivedAtMs)
593
+ : meta !== undefined && isElapsedBase(known.elapsedMs)
594
+ ? known.elapsedMs + Math.max(0, nowMs - meta.receivedAtMs) // 0.73.0:无基数不铸(同 project() 的补偿链)
591
595
  : known.elapsedMs;
592
596
  taskMap.delete(id);
593
597
  rowMeta.delete(id);
@@ -707,7 +711,11 @@ export function createFleetLedger(hooks = {}, opts = {}) {
707
711
  if (!meta)
708
712
  return r;
709
713
  const idle = Math.max(0, nowMs - meta.receivedAtMs);
710
- return idle > 0 ? { ...r, elapsedMs: (r.elapsedMs ?? 0) + idle } : r;
714
+ // 0.73.0:补偿链只给**真有基数**的行推进(基数 = 引擎快照 elapsedMs)。基数缺席时 `?? 0` 会把「本端收到这一行多久了」
715
+ // 渲成「它跑了多久」—— 那是本端的钟,不是这一行的时长 ⇒ 保持缺席。
716
+ if (!isElapsedBase(r.elapsedMs))
717
+ return r;
718
+ return idle > 0 ? { ...r, elapsedMs: r.elapsedMs + idle } : r;
711
719
  });
712
720
  // ② 终态留存池:live 集没有的行在宽限窗内继续投影;被查看中的行不过期。
713
721
  const retainedRows = [];
@@ -46,10 +46,17 @@ export interface FleetTaskView {
46
46
  depth: number;
47
47
  parentId?: string;
48
48
  status: FleetTaskStatus;
49
- elapsedMs: number;
50
- tokens: number;
49
+ /**
50
+ * 🔴 0.73.0(BREAKING):三个数值键由必填改**可选** —— 有键 ⇒ 真值(含真 0);缺键 ⇒ 「不知道」。此前 `?? 0` 把 wire
51
+ * 缺席编成 0(「0 秒」「0 tokens」「0 个排队」都是本包替引擎编的话)。坏形(负数 / NaN / 非数)同样缺席。
52
+ * · `elapsedMs` 缺席 = 这一行既没有 launch 锚也没有引擎快照(极早期的首帧 / 很老的引擎);渲「—」,不渲 0s。
53
+ * · `tokens` 缺席 = 引擎还没为这一行报过用量(排队中 / 刚起 / 该车道不报);渲「—」,不渲 0。
54
+ * · `queuedCount` 缺席 = 引擎没报这一位(本包不保证任何引擎版本上报它);不渲排队徽标,不渲「0 queued」。
55
+ */
56
+ elapsedMs?: number;
57
+ tokens?: number;
51
58
  tokenDir: 'down' | 'up';
52
- queuedCount: number;
59
+ queuedCount?: number;
53
60
  /** REF-CC-050(fleet2-12):投影器无条件赋值(`?? false`),门 `ALWAYS_EMITTED_TASK_VIEW_KEYS`
54
61
  * 已把它钉成「恒发」——类型必填而不是留一条测不到的缺席分支。 */
55
62
  awaitingPlanApproval: boolean;
@@ -121,11 +128,16 @@ export interface FleetWorkflowView {
121
128
  name: string;
122
129
  description: string;
123
130
  status: FleetTaskStatus;
124
- doneCount: number;
125
- totalCount: number;
126
- elapsedMs: number;
127
- tokens: number;
128
- failedCount: number;
131
+ /**
132
+ * 🔴 0.73.0(BREAKING):五个数值键由必填改**可选**(读法同 {@link FleetTaskView.elapsedMs})。现役引擎的 workflow 行
133
+ * 铸点五键**恒发**,缺席只出现在更老的引擎 / 乱序的部分增量上 —— 那时渲「—」,**不互相推导**
134
+ * (`failedCount` 缺席不读作 0;`totalCount` 缺席不拿 `doneCount` 顶)。
135
+ */
136
+ doneCount?: number;
137
+ totalCount?: number;
138
+ elapsedMs?: number;
139
+ tokens?: number;
140
+ failedCount?: number;
129
141
  /** 🔴 缺席 ≠ 0:0 = 引擎说「一个都没起」,undefined = 老引擎「不知道」(退 done+failed 下界)。 */
130
142
  startedCount?: number;
131
143
  }
@@ -327,11 +327,11 @@ export function projectTasks(allRows, opts) {
327
327
  depth: depthOf(r),
328
328
  ...(parentId !== undefined ? { parentId } : {}),
329
329
  status: coerceTaskStatus(r.status),
330
- elapsedMs: r.elapsedMs ?? 0,
331
- tokens: r.tokens ?? 0,
330
+ ...wireNumberKey('elapsedMs', wireDuration(r.elapsedMs)),
331
+ ...wireNumberKey('tokens', wireCount(r.tokens)),
332
332
  // 🔴 wire 从无 tokenDir(#50 门禁盘出的真类型错:读点恒 undefined 曾靠 ?? 掩住)——恒壳侧缺省。
333
333
  tokenDir: 'down',
334
- queuedCount: r.queuedCount ?? 0,
334
+ ...wireNumberKey('queuedCount', wireCount(r.queuedCount)),
335
335
  awaitingPlanApproval: r.awaitingPlanApproval ?? false,
336
336
  descendantCount: childCount.get(r.id) ?? 0,
337
337
  // 198 isViewed:这一行就是被查看的那个 agent(行 id 尾段 = 引擎 taskId)。
@@ -357,6 +357,18 @@ export function projectTasks(allRows, opts) {
357
357
  return task;
358
358
  });
359
359
  }
360
+ /** 计数位的窄读:非负**整数**才算数(0 是真 0);其余(缺席 / 负数 / 小数 / NaN / Infinity / 非数)⇒ `undefined`。 */
361
+ function wireCount(v) {
362
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
363
+ }
364
+ /** 时长位的窄读:非负有限数才算数;其余 ⇒ `undefined`。 */
365
+ function wireDuration(v) {
366
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
367
+ }
368
+ /** 有值才落键(缺席 ⇒ 空对象 ⇒ 视图上键缺席,不铸 0)。 */
369
+ function wireNumberKey(key, v) {
370
+ return (v !== undefined ? { [key]: v } : {});
371
+ }
360
372
  /** wire workflow 行 → 渲染行。 */
361
373
  export function projectWorkflows(rows) {
362
374
  return rows.map(r => {
@@ -367,11 +379,11 @@ export function projectWorkflows(rows) {
367
379
  name: collapseLabel(r.name ?? '') || 'Dynamic workflow',
368
380
  description: projectDescription(r.description),
369
381
  status: coerceWorkflowStatus(r.status),
370
- doneCount: r.doneCount ?? 0,
371
- totalCount: r.totalCount ?? 0,
372
- elapsedMs: r.elapsedMs ?? 0,
373
- tokens: r.tokens ?? 0,
374
- failedCount: r.failedCount ?? 0,
382
+ ...wireNumberKey('doneCount', wireCount(r.doneCount)),
383
+ ...wireNumberKey('totalCount', wireCount(r.totalCount)),
384
+ ...wireNumberKey('elapsedMs', wireDuration(r.elapsedMs)),
385
+ ...wireNumberKey('tokens', wireCount(r.tokens)),
386
+ ...wireNumberKey('failedCount', wireCount(r.failedCount)),
375
387
  ...(startedCount !== undefined ? { startedCount } : {}),
376
388
  };
377
389
  });
@@ -134,7 +134,10 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
134
134
  continue;
135
135
  present.add(taskId);
136
136
  const status = row.status ?? 'running';
137
- const tokens = row.tokens ?? 0;
137
+ // 0.73.0:三态 —— 帧上真给了非负整数才算数;缺席 / 坏形 ⇒ 沿用已知值(粘性),从没报过就是 undefined。
138
+ // 此前 `?? 0`:首帧发布 `totalTokens:0`(面板渲「0 tokens」),已知之后来一帧缺席又发布 0 把用量清零。
139
+ const wireTokens = typeof row.tokens === 'number' && Number.isInteger(row.tokens) && row.tokens >= 0 ? row.tokens : undefined;
140
+ const tokens = wireTokens; // 粘性在下面(拿到 prev 与周期锚之后)补
138
141
  // 🔴 三态直取:`row.toolUses` 缺席就是 undefined,这里**不许**兜 0(见 FleetAgentRowLike 注)。
139
142
  const toolUses = typeof row.toolUses === 'number' ? row.toolUses : undefined;
140
143
  const transcriptId = row.transcriptId || undefined;
@@ -142,12 +145,24 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
142
145
  const currentTool = currentToolKeyOf(row.currentTool) !== undefined ? row.currentTool : undefined;
143
146
  const currentToolKey = currentToolKeyOf(row.currentTool);
144
147
  const prev = seenMap.get(taskId);
148
+ // 🔴 粘性只在**同一周期**内成立:上一条已 settle(同 id 复活 = 新周期)或 launch 锚换了 ⇒ 不继承上一周期的用量
149
+ // (否则新周期首帧没报 tokens 时会把上一周期的数当成当前读数发布;异源对抗复审)。
150
+ const sameCycle = prev !== undefined &&
151
+ !prev.settled &&
152
+ (startedAt === undefined || prev.startedAt === undefined || prev.startedAt === startedAt);
153
+ const knownTokens = tokens !== undefined ? tokens : sameCycle ? prev?.tokens : undefined;
154
+ // 本周期已知的 launch 锚:帧上给了用帧上的,没给则同周期沿用。🔴 它随**每一条** fleet-row 发布(不只是变化那一帧)——
155
+ // 事件缓冲靠它判「这两条部分帧是不是同一周期」;缺锚的部分帧会让跨周期合并绕过屏障(异源对抗复审 [high])。
156
+ const knownStartedAt = startedAt !== undefined ? startedAt : sameCycle ? prev?.startedAt : undefined;
145
157
  if (TERMINAL_FLEET_TASK_STATUSES.has(status)) {
146
158
  // ② 终态一次性 settle(幂等台账;消费端 settle 也幂等)。
147
159
  if (!prev?.settled) {
148
160
  // 终态行也把最后一拍的累计工具数/转录锚带上:详情页在行翻终态后仍要显示真计数与
149
161
  // 委派 prompt,而终态帧常常是这一行**唯一**带全 usage 的一帧(bg lane 只在活动边界发行帧)。
150
- if ((toolUses !== undefined && prev?.toolUses !== toolUses) ||
162
+ if (
163
+ // 最终用量也算(终态帧常是唯一带全 usage 的一帧;消费端不更新非 running 行 ⇒ 必须赶在 end 之前发)
164
+ (tokens !== undefined && prev?.tokens !== tokens) ||
165
+ (toolUses !== undefined && prev?.toolUses !== toolUses) ||
151
166
  (transcriptId !== undefined && prev?.transcriptId !== transcriptId) ||
152
167
  (startedAt !== undefined && prev?.startedAt !== startedAt)) {
153
168
  publishEngineAgentPanelEvent({
@@ -155,10 +170,10 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
155
170
  taskId,
156
171
  ...(row.name ? { name: row.name } : {}),
157
172
  ...(row.description ? { description: row.description } : {}),
158
- totalTokens: tokens,
173
+ ...(knownTokens !== undefined ? { totalTokens: knownTokens } : {}),
159
174
  ...(toolUses !== undefined ? { toolUses } : {}),
160
175
  ...(transcriptId !== undefined ? { transcriptId } : {}),
161
- ...(startedAt !== undefined ? { startedAt } : {}),
176
+ ...(knownStartedAt !== undefined ? { startedAt: knownStartedAt } : {}),
162
177
  });
163
178
  }
164
179
  publishEngineAgentPanelEvent({
@@ -172,10 +187,10 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
172
187
  }
173
188
  seenMap.set(taskId, {
174
189
  status,
175
- tokens,
190
+ tokens: knownTokens,
176
191
  toolUses: toolUses !== undefined ? toolUses : prev?.toolUses,
177
192
  transcriptId: transcriptId !== undefined ? transcriptId : prev?.transcriptId,
178
- startedAt: startedAt !== undefined ? startedAt : prev?.startedAt,
193
+ startedAt: knownStartedAt,
179
194
  currentToolKey: currentToolKey !== undefined ? currentToolKey : prev?.currentToolKey,
180
195
  lastSeenAt: nowMs,
181
196
  settled: true,
@@ -193,7 +208,7 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
193
208
  prev.settled ||
194
209
  prev.absentReportedAtMs !== undefined || // 行回来了:即使值未变也发一条,消费端据此撤掉 absent 标
195
210
  prev.status !== status ||
196
- prev.tokens !== tokens ||
211
+ prev.tokens !== knownTokens ||
197
212
  (toolUses !== undefined && prev.toolUses !== toolUses) ||
198
213
  (transcriptId !== undefined && prev.transcriptId !== transcriptId) ||
199
214
  (startedAt !== undefined && prev.startedAt !== startedAt) ||
@@ -203,21 +218,21 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
203
218
  taskId,
204
219
  ...(row.name ? { name: row.name } : {}),
205
220
  ...(row.description ? { description: row.description } : {}),
206
- totalTokens: tokens,
221
+ ...(knownTokens !== undefined ? { totalTokens: knownTokens } : {}),
207
222
  // 🔴 缺席即不落键(消费端据「键在不在」判三态)。
208
223
  ...(toolUses !== undefined ? { toolUses } : {}),
209
224
  ...(transcriptId !== undefined ? { transcriptId } : {}),
210
- ...(startedAt !== undefined ? { startedAt } : {}),
225
+ ...(knownStartedAt !== undefined ? { startedAt: knownStartedAt } : {}),
211
226
  ...(currentTool !== undefined ? { currentTool } : {}),
212
227
  });
213
228
  }
214
229
  // 台账对这几位是**粘性**的:缺席帧不擦掉已知值(同上的单向语义)。
215
230
  seenMap.set(taskId, {
216
231
  status,
217
- tokens,
232
+ tokens: knownTokens,
218
233
  toolUses: toolUses !== undefined ? toolUses : prev?.toolUses,
219
234
  transcriptId: transcriptId !== undefined ? transcriptId : prev?.transcriptId,
220
- startedAt: startedAt !== undefined ? startedAt : prev?.startedAt,
235
+ startedAt: knownStartedAt,
221
236
  currentToolKey: currentToolKey !== undefined ? currentToolKey : prev?.currentToolKey,
222
237
  lastSeenAt: nowMs,
223
238
  settled: false,
@@ -44,6 +44,7 @@
44
44
  * 端的自检可以断言「跑了 N 秒之后 mode 不是 idle 且 polls+pushEvents > 0」。
45
45
  */
46
46
  import type { PendingCheckpoint } from '@sema-agent/sdk';
47
+ import { type LivePendingAskView } from './livePendingAsk.js';
47
48
  /** feed 消费的 wire 切片。`stream` 可选 —— 老 SDK / mock 没有它就直接走轮询腿。 */
48
49
  export interface ApprovalsFeedClientLike {
49
50
  approvals: {
@@ -51,6 +52,7 @@ export interface ApprovalsFeedClientLike {
51
52
  signal?: AbortSignal;
52
53
  }): Promise<{
53
54
  pending: PendingCheckpoint[];
55
+ livePending?: unknown[];
54
56
  }>;
55
57
  stream?(opts?: {
56
58
  signal?: AbortSignal;
@@ -64,7 +66,12 @@ export type ApprovalsFeedMode = 'push' | 'poll' | 'idle';
64
66
  export interface ApprovalsFeedSnapshot {
65
67
  /** 权威 pending 列表(**恒来自 `list()`**,不是 delta 拼出来的)。 */
66
68
  pending: PendingCheckpoint[];
67
- /** 这次快照是被哪条腿触发的。 */
69
+ /**
70
+ * 0.72.14:**流内 ask**(含悬挂的子代 ask)的窄读行,同样恒来自 `list()`。
71
+ * 🔴 **键缺席 = 引擎没报这一段**(老引擎),不是「没有」;引擎报了且为空 ⇒ `[]`。读法见 `livePendingAsk.ts`。
72
+ */
73
+ livePending?: LivePendingAskView[];
74
+ /** 这次快照是被哪条腿触发的(对账节拍触发的取件记 `'poll'`:它就是一次定时 `list()`)。 */
68
75
  mode: 'push' | 'poll';
69
76
  /** 单调递增的修订号(端可用它判「我看到的是不是最新的」)。 */
70
77
  revision: number;
@@ -82,6 +89,24 @@ export interface ApprovalsFeedStats {
82
89
  streamFailures: number;
83
90
  /** 真正发给订阅者的快照数(内容无变化时不发 ⇒ 这个数会小于 polls+pushEvents)。 */
84
91
  snapshots: number;
92
+ /** 0.72.14:对账节拍真取件的次数(与 `polls` 分开记 —— 它不属于回落轮询腿)。 */
93
+ reconciles: number;
94
+ }
95
+ /**
96
+ * 0.72.14 **对账节拍**:悬挂的流内 ask 出现 / 结算时,较老的引擎不在 `approvals.stream` 上发任何事件 ⇒ push 腿连着也
97
+ * 看不见它;而「仅活跃 run 时轮询」的 `shouldPoll` 恰好在宿主 turn 结束后关着。本旋钮给宿主一个**独立**的闸:
98
+ * `shouldReconcile()` 为真(典型:会话有活跃后台代理 ∧ 没有活着的宿主 run)期间,无论当前是哪条腿都定时重取 `list()`。
99
+ * · 闸关 ⇒ 零额外取件;不传本旋钮 ⇒ 连定时器都不起,行为与此前一字不差。
100
+ * · 取件后快照没变 ⇒ 间隔翻倍直到 `maxIntervalMs`;变了 / 闸由关转开 ⇒ 回到 `intervalMs`。
101
+ * · 谓词抛错 / 返回非布尔 ⇒ 按「查」处理(与 `shouldPoll` 同一个 fail-safe 方向:少查一次 = 一只等人的 ask 迟迟不出现)。
102
+ * · 引擎开始在 stream 上为这一段发事件之后,push 腿自己就会重取,本节拍只剩兜底意义,宿主可以不再传。
103
+ */
104
+ export interface ApprovalsFeedReconcileOptions {
105
+ shouldReconcile: () => boolean;
106
+ /** 基础间隔;缺省 5000ms。 */
107
+ intervalMs?: number;
108
+ /** 退避上限;缺省 30_000ms。 */
109
+ maxIntervalMs?: number;
85
110
  }
86
111
  export interface ApprovalsFeedOptions {
87
112
  /** 回落轮询间隔;缺省 5000ms。 */
@@ -107,6 +132,8 @@ export interface ApprovalsFeedOptions {
107
132
  * · **`refresh()`** 不受管 —— 那是端自己按下的显式动作。
108
133
  */
109
134
  shouldPoll?: () => boolean;
135
+ /** 0.72.14:对账节拍(见 {@link ApprovalsFeedReconcileOptions})。缺省不起。 */
136
+ reconcile?: ApprovalsFeedReconcileOptions;
110
137
  signal?: AbortSignal;
111
138
  }
112
139
  export interface ApprovalsFeedHandle {
@@ -1,5 +1,6 @@
1
1
  import { hostLog } from '../host.js';
2
2
  import { unrefTimer } from '../unrefTimer.js';
3
+ import { readLivePendingRows } from './livePendingAsk.js';
3
4
  /** [2393] hitl-F5:poll 回调体**连续**逃逸多少次之后判定「这条腿真挂了」并把 `mode` 打成 idle。
4
5
  * 取 5 是为了让「偶发一拍畸形 payload」不误判(单次逃逸下一拍就归零),同时不让一条永远抛的腿
5
6
  * 无限期地对着端的自检假装自己还活着。 */
@@ -9,13 +10,29 @@ const MAX_CONSECUTIVE_POLL_ESCAPES = 5;
9
10
  * 键里带 `boundInputHash`:同一条 pending 的绑定被服务端换掉(TOCTOU 场景)也算变化,
10
11
  * 否则端会拿着一个「id 没变但绑定变了」的行继续渲染。
11
12
  */
12
- function digestOf(rows) {
13
+ function durableDigestOf(rows) {
13
14
  return rows
14
15
  .map(r => [r.sessionId ?? '', r.taskId ?? '', r.toolCallId ?? '', r.boundCallId ?? '', r.boundInputHash ?? '', r.toolName ?? '']
15
16
  .join(''))
16
17
  .sort()
17
18
  .join('');
18
19
  }
20
+ const LIVE_FIELD_SEP = String.fromCharCode(1);
21
+ const LIVE_ROW_SEP = String.fromCharCode(2);
22
+ const LIVE_SECTION_SEP = String.fromCharCode(3);
23
+ /**
24
+ * 0.72.14:流内 ask 段进摘要 —— 此前只算 `pending`,悬挂 ask 出现 / 消失都不触发快照。
25
+ * 「未报」与「报了且为空」是两个不同的事实(前者读不出计数),摘要上也分开。
26
+ */
27
+ function digestOf(rows, live) {
28
+ const liveDigest = live === undefined
29
+ ? 'absent'
30
+ : live
31
+ .map(r => [r.approvalId, r.toolName, String(r.expiresAtMs), r.sessionId ?? '', r.originTaskId ?? ''].join(LIVE_FIELD_SEP))
32
+ .sort()
33
+ .join(LIVE_ROW_SEP);
34
+ return `${durableDigestOf(rows)}${LIVE_SECTION_SEP}${liveDigest}`;
35
+ }
19
36
  /**
20
37
  * 起一条 pending-approvals feed。**幂等性归调用方**:一个 client 起一条就够了,起两条 =
21
38
  * 两倍取件(不是错误,但没意义)。
@@ -51,7 +68,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
51
68
  let takeSeq = 0;
52
69
  let lastObservedSeq = 0;
53
70
  const stats = {
54
- pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
71
+ pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0, reconciles: 0,
55
72
  };
56
73
  /** 域词表-14 收编:「怎么调 unref」的单一实现见 unrefTimer.ts,本函数只是就地起个短名。 */
57
74
  function arm(t) {
@@ -90,8 +107,23 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
90
107
  return;
91
108
  const seq = ++takeSeq;
92
109
  let rows;
110
+ let live;
93
111
  try {
94
- rows = (await client.approvals.list({ signal: ac.signal })).pending;
112
+ const body = await client.approvals.list({ signal: ac.signal });
113
+ rows = body.pending;
114
+ // 0.72.14:`livePending` 段按结构窄读。键在场却读不懂 ⇒ **整次取件不提交**(保留上一张快照):
115
+ // 把「读不懂」提交成「未报 / 为空」会让端撤掉一张还在等人的卡。
116
+ const reading = readLivePendingRows(body);
117
+ if (reading.kind === 'malformed') {
118
+ stats.listErrors++;
119
+ hostLog('debug', `approvalsFeed: list() body has an unreadable livePending section (${via} leg) — snapshot not committed`);
120
+ return;
121
+ }
122
+ if (reading.kind === 'present') {
123
+ live = reading.rows;
124
+ if (reading.dropped > 0)
125
+ hostLog('debug', `approvalsFeed: dropped ${reading.dropped} malformed livePending row(s)`);
126
+ }
95
127
  }
96
128
  catch (e) {
97
129
  stats.listErrors++;
@@ -100,7 +132,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
100
132
  }
101
133
  if (stopped)
102
134
  return;
103
- const d = digestOf(rows);
135
+ const d = digestOf(rows, live);
104
136
  // 🔴 [F4957-1 二审 finding②] 代际闸必须排在**提交之前**:下面四行(lastDigest / revision /
105
137
  // last / onSnapshot)一落,视图就已经被改了 —— 守卫排在 take() 之外只挡得住计数与续排,挡不住
106
138
  // 回滚。乱序是真的会发生的:旧 poll 在途 → push 重连并发布新快照 → 旧 poll 才带着**更老**的
@@ -122,7 +154,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
122
154
  return; // 内容没变 ⇒ 不打扰订阅者
123
155
  lastDigest = d;
124
156
  revision++;
125
- const snap = { pending: rows, mode: via, revision };
157
+ const snap = { pending: rows, ...(live !== undefined ? { livePending: live } : {}), mode: via, revision };
126
158
  last = snap;
127
159
  stats.snapshots++;
128
160
  try {
@@ -205,6 +237,54 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
205
237
  });
206
238
  }, pollIntervalMs));
207
239
  }
240
+ /** 0.72.14 对账节拍(见 {@link ApprovalsFeedReconcileOptions})。独立于两条腿:不读 `mode`、不动 F5 计数、不计 `polls`。 */
241
+ const reconcileBaseMs = Math.max(1, opts?.reconcile?.intervalMs ?? 5000);
242
+ const reconcileMaxMs = Math.max(reconcileBaseMs, opts?.reconcile?.maxIntervalMs ?? 30_000);
243
+ let reconcileTimer;
244
+ let reconcileDelayMs = reconcileBaseMs;
245
+ function reconcileGateOpen() {
246
+ let verdict;
247
+ try {
248
+ verdict = opts?.reconcile?.shouldReconcile();
249
+ }
250
+ catch (e) {
251
+ hostLog('debug', `approvalsFeed: shouldReconcile() threw — treating this tick as "reconcile" (fail-safe): ${String(e)}`);
252
+ return true;
253
+ }
254
+ if (typeof verdict !== 'boolean') {
255
+ hostLog('debug', `approvalsFeed: shouldReconcile() returned ${typeof verdict} (not boolean) — treating this tick as "reconcile"`);
256
+ return true;
257
+ }
258
+ return verdict;
259
+ }
260
+ function scheduleReconcile() {
261
+ if (opts?.reconcile === undefined)
262
+ return;
263
+ if (stopped || ac.signal.aborted)
264
+ return;
265
+ if (reconcileTimer !== undefined)
266
+ return;
267
+ reconcileTimer = arm(setTimeout(() => {
268
+ reconcileTimer = undefined;
269
+ if (stopped || ac.signal.aborted)
270
+ return;
271
+ if (!reconcileGateOpen()) {
272
+ reconcileDelayMs = reconcileBaseMs; // 闸关 ⇒ 下次转开时从基础间隔起
273
+ scheduleReconcile();
274
+ return;
275
+ }
276
+ void (async () => {
277
+ const before = revision;
278
+ stats.reconciles++;
279
+ await take('poll');
280
+ reconcileDelayMs = revision !== before ? reconcileBaseMs : Math.min(reconcileDelayMs * 2, reconcileMaxMs);
281
+ })()
282
+ .catch(e => {
283
+ hostLog('debug', `approvalsFeed: reconcile tick threw unexpectedly (take() should have self-caught): ${String(e)}`);
284
+ })
285
+ .then(() => scheduleReconcile());
286
+ }, reconcileDelayMs));
287
+ }
208
288
  /**
209
289
  * 与 schedulePoll 对称(域词表-18):**幂等卫兵**(retryTimer 已在飞则不再排一个)+ **回调首行
210
290
  * 自清**(否则 retryTimer 触发后仍持有已死句柄,degradeToPoll 若被再次调用会直接覆盖它而不清掉
@@ -312,6 +392,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
312
392
  void runStream();
313
393
  else
314
394
  degradeToPoll('SDK has no approvals.stream (old SDK / mock)', false);
395
+ scheduleReconcile();
315
396
  });
316
397
  return {
317
398
  stop() {
@@ -323,6 +404,8 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
323
404
  clearTimeout(pollTimer);
324
405
  if (retryTimer !== undefined)
325
406
  clearTimeout(retryTimer);
407
+ if (reconcileTimer !== undefined)
408
+ clearTimeout(reconcileTimer);
326
409
  opts?.signal?.removeEventListener('abort', onOuterAbort);
327
410
  ac.abort();
328
411
  },
@@ -88,7 +88,8 @@ export interface CrashConvergedRow {
88
88
  * `GET /v1/approvals` 的**信封**形。`pending` 是老形的全部;后两键 additive
89
89
  * (server 按 deps 在场才发)⇒ **老形 `{ pending }` 仍然可赋值到本型**,现有消费点一个字节不用改。
90
90
  *
91
- * 🔴 `livePending` 在本包是**不透明载体**,留 `unknown[]` 有两条**互相独立**的理由:
91
+ * 🔴 `livePending` 在**本型上**留 `unknown[]`(0.72.14 起它有了包内读器 `readLivePendingRows`,按结构窄读;
92
+ * 下面 ① 说的「零消费点」自那版起不再成立,② 仍然成立,也是读器按结构读而不引类型的原因):
92
93
  * ① 本包对它零消费点,在这里手抄一份结构 = 手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause`
93
94
  * 同一条理由);
94
95
  * ② **SDK barrel 根本没把 `LivePendingRow` 导出**(实证:`@sema-agent/sdk` 7.4.0 的 `dist/index.d.ts`
@@ -114,6 +114,10 @@ export declare function surfaceRememberNotApplied(): void;
114
114
  * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
115
115
  */
116
116
  export declare function surfaceEditNotForwarded(): void;
117
+ /** 0.72.14:入参不可得的卡上收到了「编辑后批准」——什么都没发,ask 仍挂着(文案测试锁字面)。 */
118
+ export declare const EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT: string;
119
+ /** 见 {@link EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT};无宿主口 ⇒ 静默(与同族通知同语义)。 */
120
+ export declare function surfaceEditRefusedOnBlindAsk(): void;
117
121
  /**
118
122
  * 文案(测试锁字面)。措辞刻意描述**后果**,并且**只说证得出的话**(异源对抗复审四轮 [medium] 修):
119
123
  * · 不写「这台引擎不支持」——能力位缺席有三种同形成因(宿主没接这个字段 / 探测还没回来 / 探测失败),