@sema-agent/client-core 0.13.0 → 0.14.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.
@@ -26,11 +26,17 @@ export function isAskTool(toolName) {
26
26
  return isAskToolLoose(toolName);
27
27
  }
28
28
  /**
29
- * 「这个工具名归 gate 管」的**统一**判据(#110 修,2026-08-02)。
29
+ * 「这个**工具名**归 gate 管」的判据(#110 修,2026-08-02)。
30
30
  *
31
- * 🔴 它必须与 park 判定(`isAskTool` / `isToolApprovalGate`)覆盖**同一个集合** ——
32
- * 两边不同集就是 [paired-mechanisms-must-share-premise] 那种「park 认得出、HOLD 认不出」的
33
- * 半场病:[2150] S1 给 park 判定加了 `toolNameIsShellExec`(Bash/shell 类进 gate ),但
31
+ * ⚠️ 它只是 park 判定的**名字腿**对位件,不是全部 —— park 判定 `isToolApprovalGate` 还有一条
32
+ * 一等 kind 腿(`gate.kind==='tool_approval'`,放行任意 toolName),名字面上没有对位物。
33
+ * tool_end 手柄用的是下面的 `isGatedToolEnd`(名字腿 abort 标记腿),两者的集合关系与理由
34
+ * 写在那个函数的头注里([2393] hitl-F1)。本函数还被 `routeToolStart` 用来决定「要不要记 args /
35
+ * 要不要跟 `lastFsOrShellGatedCallId`」,那两件事本来就只对名字识别得出的族成立。
36
+ *
37
+ * 🔴 它必须与 park 判定的**名字腿**(`isAskTool` / `toolNameIsFsWrite` / `toolNameIsShellExec`)
38
+ * 覆盖同一个集合 —— 两边不同集就是 [paired-mechanisms-must-share-premise] 那种「park 认得出、
39
+ * HOLD 认不出」的半场病:[2150] S1 给 park 判定加了 `toolNameIsShellExec`(Bash/shell 类进 gate 了),但
34
40
  * tool_end 手柄的 HOLD/REJECT 谓词还停在 `isAskTool || toolNameIsFsWrite` ——
35
41
  * 于是 Bash 的 durable gate 走到这里就两件事同时出错:
36
42
  * ① park 期的毒化帧(`tool_end{isError:true, output:"Operation aborted"}`)没被 HOLD,
@@ -47,6 +53,36 @@ function isGatedToolName(name) {
47
53
  return false;
48
54
  return toolNameIsFsWrite(name) || toolNameIsShellExec(name);
49
55
  }
56
+ /**
57
+ * 「这一帧 tool_end 归 gate 管」的判据 —— 上面那条**名字腿**加一条 kind 腿的替身([2393] hitl-F1,
58
+ * 2026-08-02)。
59
+ *
60
+ * 🔴 为什么名字腿一条不够(上面那句「必须覆盖同一个集合」在 kind 腿上的反例):park 判定
61
+ * `isToolApprovalGate` 有**两条**腿 —— 名字族(ask / fs 写三件 / Bash)与**一等 kind**
62
+ * (`gate.kind === 'tool_approval'`,`toolApprovalWire.isFsApprovalGate` 首行)。后者放行**任意**
63
+ * toolName:server 对名单外的工具出一等 kind 审批 park 时,park 侧认、tool_end 侧的名字腿认不出,
64
+ * 于是 #110 那三件事在这类 gate 上原样重演一遍(毒化帧上屏 / `markEnded` 吃掉重放的真结果 /
65
+ * deny 的 CC 文案进不去)。
66
+ *
67
+ * 🔴 为什么不能在名字腿里补 kind:`tool_end` 帧上**根本没有 kind 位** —— gate 的 kind 只在
68
+ * `done{suspended}.checkpointGate` / `suspended.gate` 上,而那两帧**晚于**毒化 tool_end 到达
69
+ * (park 帧序:tool_start → tool_end(毒化)→ done{suspended})。「park 时按 callId 打标、
70
+ * tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的**确切 abort
71
+ * 标记** `ENGINE_ABORT_TOOL_RESULT`(见下方 ④ 臂头注:core 对被 gate/连坐 abort 的 call 恒铸
72
+ * 这一串),所以本判据 = 名字腿 ∪ abort 标记腿。
73
+ *
74
+ * 🔴 集合关系(说清楚,别让下一棒再以为两边逐字相等):本判据是 park 判定的**超集**(它还会盖到
75
+ * 被同 turn 连坐 abort 的非 gate call)。方向是有意的,且不对称成立 —— 多盖一帧的代价是那一帧
76
+ * **晚**上屏(HOLD 的帧在模型推进 / 终帧 / fail-soft 三处必被 flush,一帧都不会丢),而少盖一帧
77
+ * 的代价是用户按了 Yes 却只看到 `Error: Operation aborted`。顺带把连坐 abort 的 call 从
78
+ * `markEnded` 里救出来:它们本来也会在 resume 后被模型重发,而旧码已把它们记成已收口。
79
+ * ⚠️ 普通工具错(非 abort 标记)照旧**不进** HOLD —— [2084]①-b 的收窄没有被放宽,负控 F1-c 钉着。
80
+ */
81
+ function isGatedToolEnd(ev) {
82
+ if (isGatedToolName(ev.toolName))
83
+ return true;
84
+ return ev.output === ENGINE_ABORT_TOOL_RESULT;
85
+ }
50
86
  /** done 帧的 AskUserQuestion park 形状(结构性读;别的终态一律 false)。 */
51
87
  function isAskGatePark(result) {
52
88
  const r = result;
@@ -128,9 +164,11 @@ const TOOL_END_ARMS = [
128
164
  {
129
165
  // ② 同步帧腿 deny 且 callId 没关联上:下一张 gated 报错帧即该 gate 的收口帧,stamp REJECT 文案。
130
166
  // #110:stamp 也要盖 shell —— 否则按 No 时用户看到的是引擎原文 `Operation aborted`。
167
+ // [2393] hitl-F1:改用 `isGatedToolEnd`(名字腿 ∪ abort 标记腿)—— 一等 kind gate 的
168
+ // 收口帧名字腿认不出,不盖它 = 用户按了 No 却看到引擎原文。
131
169
  id: 'deny-stamp-next',
132
170
  run(ev, callId, led) {
133
- if (ev.isError !== true || !isGatedToolName(ev.toolName) || led.isDenied(callId))
171
+ if (ev.isError !== true || !isGatedToolEnd(ev) || led.isDenied(callId))
134
172
  return undefined;
135
173
  if (!led.takeDenyStamp())
136
174
  return undefined;
@@ -143,7 +181,7 @@ const TOOL_END_ARMS = [
143
181
  // ③ 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
144
182
  id: 'denied-call',
145
183
  run(ev, callId, led) {
146
- if (ev.isError !== true || !isGatedToolName(ev.toolName))
184
+ if (ev.isError !== true || !isGatedToolEnd(ev))
147
185
  return undefined;
148
186
  if (!led.takeDenied(callId))
149
187
  return undefined;
@@ -160,7 +198,7 @@ const TOOL_END_ARMS = [
160
198
  // —— 收窄后它们零滞后直达转录(落到 ⑤ 通用收口),不再依赖「推进信号放行」。
161
199
  id: 'hold-poison',
162
200
  run(ev, callId, led) {
163
- if (ev.isError !== true || !isGatedToolName(ev.toolName))
201
+ if (ev.isError !== true || !isGatedToolEnd(ev))
164
202
  return undefined;
165
203
  if (ev.output !== ENGINE_ABORT_TOOL_RESULT)
166
204
  return undefined;
@@ -288,7 +326,10 @@ function routeSuspended(ev, ctx) {
288
326
  ctx.taskId.current) {
289
327
  return { kind: 'park', gate: 'fs' };
290
328
  }
291
- return { kind: 'yield', events: [ev] }; // 其余 gate:透传(现状,eventToSdkMessage 出 null)
329
+ // 其余 gate:透传。⚠️ ADAPTER-F8 注纠(2026-08-02):原文写「eventToSdkMessage 出 null」——
330
+ // 那个返回形已随 REF-CC-058 退役,今天它出的是 `EventProjection` 三态,永不是 null;本臂的
331
+ // 帧走到投影器时落 `none/hitl_out_of_slice`(HITL 登记是 run driver 的活,不在投影切片内)。
332
+ return { kind: 'yield', events: [ev] };
292
333
  }
293
334
  function routeDone(ev, ctx) {
294
335
  const { led } = ctx;
@@ -99,7 +99,19 @@ export interface GateLedger {
99
99
  * 「已解决」最多多读一轮、被 hop 预算兜底;误判成「真失败」烧会话)。
100
100
  */
101
101
  markDecided(callId: string): void;
102
- isDecided(callId: string): boolean;
102
+ /**
103
+ * 一次性消费([2393] hitl-F2,2026-08-02):记号被一次 park 失败消费掉就**失效**。
104
+ *
105
+ * 🔴 为什么必须一次性:这条判据的正当性完全来自「durable re-attach 会把**那一次**已决断的 park
106
+ * 重放一遍」——重放窗口只有一次。而它的两个输入都是单调的:`lastFsOrShellGatedCallId()` 不随
107
+ * tool_end 清空(见上方声明处注,那是**故意**的,park 重放时没有新的 tool_start 可跟),
108
+ * `decidedGates` 又只增不删 ⇒ 只要这个 turn 里成功决断过一次,**之后每一次** park 失败都会命中
109
+ * 身份匹配,不论真因是什么。实测后果:`approvals.list` 网络失败这类真失败被连续 24 次判成
110
+ * 「已解决」,吃满 `MAX_GATE_HOPS` 才吐一句 `gate hop limit exceeded` —— 用户白等 24 轮往返,
111
+ * 拿到的还是一句与真因无关的话(真因被预算话术顶掉了)。
112
+ * 消费一次即失效之后:第一次(= 真的重放)照旧救回,第二次就是诚实的 fail-soft。
113
+ */
114
+ takeDecided(callId: string): boolean;
103
115
  /** 只给 debug 串用的规模位(`[decided so far: N]`)。 */
104
116
  decidedCount(): number;
105
117
  }
@@ -103,8 +103,8 @@ export function createGateLedger() {
103
103
  markDecided(callId) {
104
104
  decidedGates.add(callId);
105
105
  },
106
- isDecided(callId) {
107
- return decidedGates.has(callId);
106
+ takeDecided(callId) {
107
+ return decidedGates.delete(callId);
108
108
  },
109
109
  decidedCount() {
110
110
  return decidedGates.size;
@@ -200,7 +200,16 @@ export async function resolvePark(park, ctx) {
200
200
  // `FsApprovalOutcome` 今天没有 `code`(toolApprovalWire.ts,C-bridge 域),失败原因若不是
201
201
  // 那三个文案子串(例如 `approvals.list` 自身网络失败)就会漏判。已决断身份匹配是独立于
202
202
  // 文案的第二判据 —— 两臂任一命中都按「已解决」处置,方向偏宽(见台账 `markDecided` 处注)。
203
- const alreadyDecidedById = candidateGatedCallId !== undefined && led.isDecided(candidateGatedCallId);
203
+ //
204
+ // [2393] hitl-F2(2026-08-02):这条身份判据**一次性消费**(`takeDecided` 而不是 `isDecided`)。
205
+ // 它的正当性只覆盖「durable re-attach 把**那一次**已决断的 park 重放一遍」这一个窗口,而它的两个
206
+ // 输入都是单调的(`lastFsOrShellGatedCallId()` 故意不随 tool_end 清空 + `decidedGates` 只增),
207
+ // 不消费就等于:本 turn 只要成功决断过一次,之后**每一次** park 失败都被判「已解决」——
208
+ // `approvals.list` 网络失败这类真失败会连吃 24 个 hop,最后吐一句与真因无关的 `gate hop limit`。
209
+ // 消费点写在 `outcome.kind === 'failed'` 之内:决断成功的那一轮压根不该动这个记号。
210
+ const alreadyDecidedById = outcome.kind === 'failed' &&
211
+ candidateGatedCallId !== undefined &&
212
+ led.takeDecided(candidateGatedCallId);
204
213
  if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
205
214
  const seq = led.lastSeq();
206
215
  hostLog('debug', `liveHitlAskWire: gate already resolved (${outcome.reason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
@@ -36,7 +36,7 @@
36
36
  * 挂账(诚实边界):approve 后壳本地的 plan-mode footer 徽章不自动退(CC 会退)——退 mode 的
37
37
  * app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
38
38
  */
39
- import { publishQuestionFrame, registerLocalQuestionResponder, } from '../liveQuestionStore.js';
39
+ import { publishQuestionFrame, registerLocalQuestionResponder, hasLocalQuestionResponder, } from '../liveQuestionStore.js';
40
40
  import { hostLog } from '../host.js';
41
41
  import { makeEngineWireClient } from '../engineWireSdk.js';
42
42
  import { engineWireTarget } from '../engineWireTarget.js';
@@ -92,6 +92,28 @@ const armedPlanReviewTaskIds = new Set();
92
92
  export function _resetArmedPlanReviewsForTest() {
93
93
  armedPlanReviewTaskIds.clear();
94
94
  }
95
+ /** 合成 questionId 的唯一铸口(短路臂与首次 arm 共用,别各铸各的)。 */
96
+ function planReviewQuestionId(taskId) {
97
+ return `plan-review:${taskId}`;
98
+ }
99
+ /** 审批卡的题面 —— 首次 arm 与「重放 arm 重新弹卡」共用同一份,两处不许各写一份。 */
100
+ function publishPlanReviewCard(taskId) {
101
+ publishQuestionFrame({
102
+ type: 'question',
103
+ questionId: planReviewQuestionId(taskId),
104
+ questions: [
105
+ {
106
+ header: 'Plan review',
107
+ question: 'The plan is ready for review. Approve it and start the implementation?',
108
+ options: [
109
+ { label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
110
+ { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
111
+ ],
112
+ multiSelect: false,
113
+ },
114
+ ],
115
+ });
116
+ }
95
117
  /**
96
118
  * Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
97
119
  * armed (live path + shape matched, INCLUDING the idempotent "already armed" short-circuit — see
@@ -107,13 +129,33 @@ export function armPlanReviewApproval(result) {
107
129
  return false;
108
130
  const { taskId } = result;
109
131
  if (armedPlanReviewTaskIds.has(taskId)) {
110
- // REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,不二次弹卡/二次注册。
111
- hostLog('debug', `planReviewWire: arm skipped — task ${taskId} is already armed (durable replay, idempotent)`);
112
- return true;
132
+ // REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,**不二次注册** responder。
133
+ //
134
+ // [2393] hitl-F3(2026-08-02):但「不二次注册」不等于「什么都不做」。旧码在这里只打一行
135
+ // debug 就 `return true`,于是这条短路成了一堵挡死重开路径的墙:
136
+ // · `liveQuestionStore.ts` 的契约明写「dialog dismissed without answering ⇒ caller cleans
137
+ // up」,而本模块**没有** dismiss/abort 腿 —— 用户 Esc 关掉卡(不作答)之后,武装态
138
+ // 一直是 true,responder 也一直挂着;
139
+ // · 此后每一次 arm(durable re-attach 重放 / 409 撞上再开)都走这条短路:**一张卡都不弹**,
140
+ // 调用方拿到 true 于是也不渲终帧 —— 用户面静默挂住,plan 卡在 park 里没有任何出口。
141
+ // 修法按「还在不在」分两支,两支都不产生第二次 `registerLocalQuestionResponder`:
142
+ if (hasLocalQuestionResponder(planReviewQuestionId(taskId))) {
143
+ // responder 还绑着 ⇒ 这张卡仍然可作答,只是可能已经不在屏上了。**重新弹一次**
144
+ // (同一个 questionId、同一份题面、同一个 responder):卡还开着的宿主只是收到一帧同形
145
+ // 的重绘,卡已经被 dismiss 的宿主则拿回了它的重开路径。
146
+ hostLog('debug', `planReviewWire: arm replayed — re-presenting the still-armed card for task ${taskId}`);
147
+ publishPlanReviewCard(taskId);
148
+ return true;
149
+ }
150
+ // responder 不在了(被别的注册顶掉后注销 / 被单方面清理)⇒ 武装态是**陈旧**的。
151
+ // 复用它 = 弹一张没有任何人能收答的卡(比不弹更坏:它看起来能点)。丢掉陈旧记号,
152
+ // 往下走整条重新 arm —— 这正是 REF-CC-027 头注里那个「悬空引用」形的收尾。
153
+ hostLog('debug', `planReviewWire: stale armed state for task ${taskId} (no local responder) — re-arming from scratch`);
154
+ armedPlanReviewTaskIds.delete(taskId);
113
155
  }
114
156
  armedPlanReviewTaskIds.add(taskId);
115
157
  armedTaskId = taskId;
116
- const questionId = `plan-review:${taskId}`;
158
+ const questionId = planReviewQuestionId(taskId);
117
159
  const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
118
160
  // REF-CC-027:一次性 —— 首句就注销 + 清武装态(照 askGateWire 的 cleanup() 形)。
119
161
  // liveQuestionStore.respondToQuestion 自身也是一次性取件(delete-before-invoke),这里是
@@ -130,21 +172,7 @@ export function armPlanReviewApproval(result) {
130
172
  void decidePlanReview(taskId, decided);
131
173
  return { ok: true };
132
174
  });
133
- publishQuestionFrame({
134
- type: 'question',
135
- questionId,
136
- questions: [
137
- {
138
- header: 'Plan review',
139
- question: 'The plan is ready for review. Approve it and start the implementation?',
140
- options: [
141
- { label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
142
- { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
143
- ],
144
- multiSelect: false,
145
- },
146
- ],
147
- });
175
+ publishPlanReviewCard(taskId);
148
176
  hostLog('debug', `planReviewWire: approval card armed for parked plan (task ${taskId})`);
149
177
  return true;
150
178
  }
@@ -61,7 +61,7 @@
61
61
  * 都返回 failed —— 调用方回退「flush + 原样终帧」的诚实红,绝不更糟。
62
62
  * 🔴 UNTRUSTED:gate args 为模型作文(service 已 redact),只渲染绝不回喂;决断只带 decision 枚举。
63
63
  */
64
- import { HitlBridge, findPendingForTask } from './hitlBridge.js';
64
+ import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
65
65
  import { hostLog } from '../host.js';
66
66
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
67
67
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
@@ -256,6 +256,16 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
256
256
  return { kind: 'decided', gatedCallId };
257
257
  }
258
258
  catch (e) {
259
+ // 🔴 [2393] hitl-F4(2026-08-02):回退臂**只**兜「老 server 不识别 remember ⇒ 400 未知键」
260
+ // 这一形。`HitlSafetyError` 是 hitlBridge 的**安全信号**闭集(hitlBridge.ts:9 / decideTool
261
+ // 头注末句逐字:「A binding mismatch (409) is re-raised as a `HitlSafetyError`
262
+ // ('binding_mismatch') — the caller re-presents, NEVER auto-retries」),旧的 catch-all
263
+ // 把它一并吞了,然后**立刻自动重发**一次纯 approve —— 正是那条铁律禁的动作:
264
+ // binding 不匹配意味着「人看见的那一行在他决断期间被换掉了」,自动重试等于替人对一件
265
+ // 他没看过的事按了 Yes。`no_pending` 同族(那一行已经没了,重试同样只会再失败一次)。
266
+ // 上抛给外层 catch ⇒ typed `failed` ⇒ 调用方走 fail-soft 诚实红,由人重新决断。
267
+ if (e instanceof HitlSafetyError)
268
+ throw e;
259
269
  hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
260
270
  }
261
271
  }
@@ -98,14 +98,22 @@ export function hooksForWire() {
98
98
  return undefined;
99
99
  // trust gate first (cheapest + broadest): untrusted interactive workspace ⇒ no hooks anywhere,
100
100
  // the engine leg included — same invariant the local executor enforces per-fire.
101
+ // [2393] F-2(fix-outright):此前这条 catch 是 **fail-OPEN**(fall through),理由句写「端侧本地
102
+ // 执行器仍 per-fire 把门」—— 那句在本包语境下是**假话**,本文件头第三段自己写明:壳里那条
103
+ // catch 的前提是单进程本地执行器,**库里没有那个兜底**。后果是「未受信工作区的 hooks 绝不上
104
+ // wire」这条被文件头称作安全门的不变量,在 SettingsPort 抛错时整个失效(用户/项目 settings 里的
105
+ // `command` 型 hook 照投给引擎真执行)。而同一函数下方对**同类失败**(port 抛)的 policy 读已选
106
+ // fail-closed 并写明「治理策略读不出来时更安全的默认应是当作有限制」—— 两道门相邻、同类输入,
107
+ // 结论必须一致,更敏感的那道不许留在宽的一侧。改成 fail-closed:信任查不出来 = 当作未受信。
101
108
  try {
102
109
  if (settings.shouldSkipHookDueToTrust()) {
103
110
  hostLog('debug', 'hooksWireCaps: workspace not trusted — no hooks projected to the engine');
104
111
  return undefined;
105
112
  }
106
113
  }
107
- catch {
108
- /* trust check threw (early boot) fall through; 端侧本地执行器仍 per-fire 把门 */
114
+ catch (err) {
115
+ hostLog('debug', `hooksWireCaps: workspace trust check threw fail-closed (no hooks projected to the engine): ${String(err)}`);
116
+ return undefined;
109
117
  }
110
118
  let policy = null;
111
119
  // REF-CC-155(midband-03,fix-outright,行为面已裁 GO — 安全敏感面 [2374] 背书):policy 是
@@ -68,6 +68,19 @@ type RespondFn = (id: string, answer: QuestionAnswer, opts?: {
68
68
  /** Register a one-shot local responder for a SYNTHETIC question frame (e.g. `plan-review:<taskId>`).
69
69
  * Returns an unregister fn (dialog dismissed without answering ⇒ caller cleans up). */
70
70
  export declare function registerLocalQuestionResponder(id: string, fn: RespondFn): () => void;
71
+ /**
72
+ * Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
73
+ *
74
+ * 🔴 为什么这是**只读探询**而不是「谁注册谁自己记着就行」:上面那条契约把「对话框被 dismiss 却没
75
+ * 作答」的清理责任交给了 caller,而 caller(planReviewWire)自己那份武装态与这张表是**两个**容器 ——
76
+ * 两个容器各自都可能被单方面改动(`respondToQuestion` 的一次性 delete、别的注册把同 id 顶掉后再
77
+ * 注销),于是「我以为还武装着」和「表里还真有人收答」会漂开。漂开的后果不是多弹一张卡,是
78
+ * **一张都不弹**:去重短路认为卡还在,而实际上没有任何 responder 能收答([paired-mechanisms-must-
79
+ * share-premise])。给出这条探询口,让去重臂能问真相而不是问自己的记忆。
80
+ *
81
+ * 🔴 只回答布尔:responder 函数本身绝不出境(它闭包着 caller 的一次性状态,交出去就有第二个调用者)。
82
+ */
83
+ export declare function hasLocalQuestionResponder(id: string): boolean;
71
84
  /**
72
85
  * Publish a demuxed live question frame (called from liveClient's demuxQuestionFrames on each
73
86
  * `question`/`question_complete`). Best-effort: a UI handler error is swallowed so a broken overlay can NEVER
@@ -20,6 +20,21 @@ export function registerLocalQuestionResponder(id, fn) {
20
20
  localResponders.delete(id);
21
21
  };
22
22
  }
23
+ /**
24
+ * Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
25
+ *
26
+ * 🔴 为什么这是**只读探询**而不是「谁注册谁自己记着就行」:上面那条契约把「对话框被 dismiss 却没
27
+ * 作答」的清理责任交给了 caller,而 caller(planReviewWire)自己那份武装态与这张表是**两个**容器 ——
28
+ * 两个容器各自都可能被单方面改动(`respondToQuestion` 的一次性 delete、别的注册把同 id 顶掉后再
29
+ * 注销),于是「我以为还武装着」和「表里还真有人收答」会漂开。漂开的后果不是多弹一张卡,是
30
+ * **一张都不弹**:去重短路认为卡还在,而实际上没有任何 responder 能收答([paired-mechanisms-must-
31
+ * share-premise])。给出这条探询口,让去重臂能问真相而不是问自己的记忆。
32
+ *
33
+ * 🔴 只回答布尔:responder 函数本身绝不出境(它闭包着 caller 的一次性状态,交出去就有第二个调用者)。
34
+ */
35
+ export function hasLocalQuestionResponder(id) {
36
+ return localResponders.has(id);
37
+ }
23
38
  /**
24
39
  * Publish a demuxed live question frame (called from liveClient's demuxQuestionFrames on each
25
40
  * `question`/`question_complete`). Best-effort: a UI handler error is swallowed so a broken overlay can NEVER
@@ -190,11 +190,21 @@ export declare function outstandingAbandonedCount(): number;
190
190
  * 回归,复活周期会伪装成首周期落进这一格 —— 这个数异常增长就是 seq 又丢了的信号);
191
191
  * · `stuckTickResets` = 在飞 tick 超过 STUCK_TICK_RESET_MS 被强制复位的次数,**恒应为 0**;
192
192
  * >0 = 有 probe 连 PROBE_TIMEOUT_MS 都没能截断它。
193
+ *
194
+ * 🔴 [2393] F-1 补两格 —— 上面三格全是 **enqueue 内部**的记账,而 watcher 收摊臂在到达 enqueue
195
+ * **之前**就把台账条目删掉了:「复活周期被秒删」这条路径对上面三格 100% 不可见(立案时被点名当
196
+ * 信号的 `bgCrossChannelDropped` 正是这样对它零判别力的)。删除动作要挣得,也要看得见:
197
+ * · `bgWatchRevivalPromoted` = 收摊臂靠 probe 实证「任务又在跑」把条目升到新周期的次数
198
+ * —— 正是修前被静默秒删的那一类;>0 说明 wire 的 seq 没到货,壳在自己补周期号;
199
+ * · `bgWatchCollectedUnprobed` = probe 未装(mock/离线)时按裸 id 黑名单**盲摘**的次数,
200
+ * 这是唯一保留的无证据删除;>0 = 有完成周期在这类宿主上恒不可观察。
193
201
  */
194
202
  export interface NotificationDropCounters {
195
203
  bgDedupDropped: number;
196
204
  bgCrossChannelDropped: number;
197
205
  stuckTickResets: number;
206
+ bgWatchRevivalPromoted: number;
207
+ bgWatchCollectedUnprobed: number;
198
208
  }
199
209
  export declare function notificationDropCounters(): NotificationDropCounters;
200
210
  /** liveClient 在构造会话 client 时注册(带正确 baseUrl/token/principal 的 workflows.get)。 */
@@ -215,7 +225,11 @@ export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void
215
225
  * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
216
226
  * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
217
227
  *
218
- * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
228
+ * 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
229
+ * 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
230
+ * 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
231
+ *
232
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
219
233
  */
220
234
  export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string, seq?: number): void;
221
235
  export declare function isOwnWorkflowRun(runId: string): boolean;
@@ -255,6 +255,22 @@ export function _resetNotificationQueuePortForTest() {
255
255
  // (the `<tool-use-id>` line is optional in CC's own template the same way).
256
256
  // ══════════════════════════════════════════════════════════════════════════════════════════════
257
257
  const notifiedRunIds = new Set();
258
+ const notifiedBgCycleHigh = new Map();
259
+ let ledgerOrder = 0;
260
+ const nextLedgerOrder = () => ++ledgerOrder;
261
+ /** 「已送达」两账的**唯一**写口(裸 id 维 + 周期维必须同时前进,否则收摊臂又回到没有周期证据)。 */
262
+ function markRunNotified(runId, cycle = BG_FIRST_SEQ) {
263
+ notifiedRunIds.add(runId);
264
+ const prev = notifiedBgCycleHigh.get(runId);
265
+ if (prev === undefined || cycle > prev.cycle) {
266
+ notifiedBgCycleHigh.set(runId, { cycle, order: nextLedgerOrder() });
267
+ }
268
+ }
269
+ /** 「**这一个周期**已经送达过」——收摊臂唯一可用的删除证据。 */
270
+ function notifiedBgCycleAtLeast(taskId, cycle) {
271
+ const high = notifiedBgCycleHigh.get(taskId);
272
+ return high !== undefined && high.cycle >= cycle ? high : undefined;
273
+ }
258
274
  /**
259
275
  * B1(frame-lane-matrix 三节定谳)— Path A 回声到达时丢弃同 run 的 pending 队列条目。
260
276
  * 双投机理:活跃 turn 内 Path B(workflow_complete/bg_notification/probe feeder →
@@ -314,7 +330,9 @@ export function dropQueuedNotificationsForRun(taskId) {
314
330
  * steer-inject 的 task_notification 帧)即预标记,Channel A 对同 runId 的补发直接丢弃。
315
331
  */
316
332
  export function markEngineWorkflowNotified(runId) {
317
- notifiedRunIds.add(runId);
333
+ // [2393] F-1:外部 mark 只证「首周期已送达」(它没有周期号可带),周期维按首周期记 —— 与本函数
334
+ // 既有的跨通道语义逐字一致(enqueueBgChildNotification 的跨通道臂同样只对首周期成立)。
335
+ markRunNotified(runId);
318
336
  if (outstandingRuns.delete(runId))
319
337
  notifyOutstanding();
320
338
  }
@@ -414,8 +432,10 @@ export function outstandingAbandonedCount() {
414
432
  let bgDedupDropped = 0;
415
433
  let bgCrossChannelDropped = 0;
416
434
  let stuckTickResets = 0;
435
+ let bgWatchRevivalPromoted = 0;
436
+ let bgWatchCollectedUnprobed = 0;
417
437
  export function notificationDropCounters() {
418
- return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets };
438
+ return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets, bgWatchRevivalPromoted, bgWatchCollectedUnprobed };
419
439
  }
420
440
  let statusProbe = null;
421
441
  let watchTimer = null;
@@ -462,7 +482,11 @@ export function installBgTaskStatusProbe(probe) {
462
482
  * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
463
483
  * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
464
484
  *
465
- * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
485
+ * 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
486
+ * 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
487
+ * 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
488
+ *
489
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
466
490
  */
467
491
  export function registerOutstandingBgTask(taskId, description, prompt, seq) {
468
492
  if (!taskId)
@@ -475,7 +499,13 @@ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
475
499
  const key = bgOutstandingKey(taskId, cycle);
476
500
  if (outstandingBgTasks.has(key))
477
501
  return;
478
- outstandingBgTasks.set(key, { taskId, seq: cycle, registeredAt: Date.now(), description });
502
+ outstandingBgTasks.set(key, {
503
+ taskId,
504
+ seq: cycle,
505
+ registeredAt: Date.now(),
506
+ registeredOrder: nextLedgerOrder(),
507
+ description,
508
+ });
479
509
  ensureWatchTimer();
480
510
  }
481
511
  /** 本壳亲手启动过的 workflow run(process-lifetime,只增不摘——outstandingRuns 会随完成摘除,
@@ -630,10 +660,55 @@ async function tickWatchInner() {
630
660
  // probe 未装=mock/离线,只等推送补发)
631
661
  const bgProbe = bgStatusProbe;
632
662
  for (const [key, meta] of [...outstandingBgTasks]) {
633
- // 收摊臂**只对首周期成立**:notifiedRunIds 是裸 taskId 键空间,它只证明「首周期已送达」,
634
- // seq≥2 的新完成周期毫无判别力( enqueueBgChildNotification 的跨通道臂同一前提)。
635
- if (meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)) {
636
- outstandingBgTasks.delete(key); // 推送/别的通道已送达 收摊
663
+ // 🔴 [2393] F-1:收摊臂的删除必须**挣得**。此前的判据是裸 taskId 黑名单
664
+ // (`meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)`),它只证明「这个 id 曾经
665
+ // 送达过某次完成」;而 `structured.seq` 缺席时复活周期的 cycle 塌回首周期,于是这条臂拿
666
+ // **首周期的送达记录**把**第二周期**的观察条目在下一拍秒删,probe 一次不跑 —— 与 notif-02
667
+ // 修前的静默丢通知逐字相同,且删在 enqueue 之前 ⇒ 三个既有计数器一格都摸不到。
668
+ // 新判据分三档,每一档都拿得出理由:
669
+ // ① 这一周期确已送达(周期键台账)⇒ 才谈得上收摊;
670
+ // ①a 条目**登记在那次送达之前** ⇒ 它就是那次送达自己的观察条目,无歧义,零成本收摊
671
+ // (= 旧行为的正当那一半,常态路径,不多花一次探测);
672
+ // ①b 条目**登记在送达之后** ⇒ 有歧义:复活周期?还是已终局任务的回执重放?靠 probe 定夺:
673
+ // ② probe 说它**真终局** ⇒ 就是那个已送达的周期,收摊(重放回执落这里,不会二次喂模型);
674
+ // ③ probe 说它**又在跑** ⇒ 这按构造是一个**新的完成周期**(裸 id 黑名单对它零判别力),
675
+ // 把条目升到下一个周期号继续观察,并留痕。probe 未装 ⇒ 没有可挣得的证据,沿用旧的盲摘
676
+ // 但记账;probe 失败/答不上来 ⇒ 不删也不升(TTL 兜底),绝不拿探测失败当终局证据。
677
+ const delivered = notifiedBgCycleAtLeast(meta.taskId, meta.seq);
678
+ if (delivered !== undefined) {
679
+ if (meta.registeredOrder < delivered.order) {
680
+ outstandingBgTasks.delete(key); // ①a 推送/别的通道已送达本条目观察的那个周期 — 收摊
681
+ continue;
682
+ }
683
+ if (!bgProbe) {
684
+ outstandingBgTasks.delete(key);
685
+ bgWatchCollectedUnprobed++;
686
+ continue;
687
+ }
688
+ let alive;
689
+ try {
690
+ const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
691
+ if (res === null || res === undefined)
692
+ continue; // 答不上来 ≠ 终局,留给下一拍/TTL
693
+ alive = !res.terminal;
694
+ }
695
+ catch {
696
+ continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
697
+ }
698
+ if (!alive) {
699
+ outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
700
+ continue;
701
+ }
702
+ const promoted = Math.max(meta.seq, delivered.cycle) + 1;
703
+ outstandingBgTasks.delete(key);
704
+ const promotedKey = bgOutstandingKey(meta.taskId, promoted);
705
+ if (!outstandingBgTasks.has(promotedKey)) {
706
+ outstandingBgTasks.set(promotedKey, { ...meta, seq: promoted, registeredOrder: nextLedgerOrder() });
707
+ }
708
+ bgWatchRevivalPromoted++;
709
+ traceNotif(`bg task ${meta.taskId} is running again after cycle ${meta.seq} was already delivered — ` +
710
+ `promoting the watch entry to cycle ${promoted} (wire carried no seq; without this the entry ` +
711
+ 'would be collected by the bare-taskId ledger and this completion would never reach the user)');
637
712
  continue;
638
713
  }
639
714
  if (!bgProbe)
@@ -709,7 +784,8 @@ export function enqueueBgChildNotification(n) {
709
784
  return;
710
785
  }
711
786
  bgNotifiedKeys.add(key);
712
- notifiedRunIds.add(n.taskId);
787
+ // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
788
+ markRunNotified(n.taskId, cycle);
713
789
  cardEnqueuedRunIds.add(n.taskId);
714
790
  // #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
715
791
  // 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
@@ -738,7 +814,8 @@ export function enqueueBgChildNotification(n) {
738
814
  export function enqueueEngineWorkflowNotification(c) {
739
815
  if (notifiedRunIds.has(c.runId))
740
816
  return;
741
- notifiedRunIds.add(c.runId);
817
+ // [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)
818
+ markRunNotified(c.runId);
742
819
  cardEnqueuedRunIds.add(c.runId);
743
820
  const message = `<${TASK_NOTIFICATION_TAG}>
744
821
  <${TASK_ID_TAG}>${escapeXml(c.runId)}</${TASK_ID_TAG}>
@@ -754,6 +831,7 @@ export function enqueueEngineWorkflowNotification(c) {
754
831
  * 🔴 生产绝不调用 —— 台账是 process-lifetime 去重的唯一凭据,清了就会双投。 */
755
832
  export function _resetEngineTaskNotificationForTest() {
756
833
  notifiedRunIds.clear();
834
+ notifiedBgCycleHigh.clear();
757
835
  cardEnqueuedRunIds.clear();
758
836
  outstandingRuns.clear();
759
837
  outstandingBgTasks.clear();
@@ -767,6 +845,8 @@ export function _resetEngineTaskNotificationForTest() {
767
845
  bgDedupDropped = 0;
768
846
  bgCrossChannelDropped = 0;
769
847
  stuckTickResets = 0;
848
+ bgWatchRevivalPromoted = 0;
849
+ bgWatchCollectedUnprobed = 0;
770
850
  probeTimeoutOverrideMs = null;
771
851
  stuckTickResetOverrideMs = null;
772
852
  if (watchTimer !== null) {
@@ -36,11 +36,19 @@
36
36
  import type { SDKMessage } from '@sema-agent/agent-types';
37
37
  /**
38
38
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
39
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
40
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
39
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
40
+ *
41
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
42
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
43
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
44
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
45
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
46
+ * public-export-baseline 里,改名会当场红)。
41
47
  */
42
- export declare function flattenToolOutput(output: unknown): string;
43
- /** 内部 tool_end_result arm(eventToSdkMessage.ts:213-231 铸造)的防御性读形。 */
48
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
49
+ /** 内部 tool_end_result arm(`eventToSdkMessage.ts` 的 `case 'tool_end'` 臂铸造)的防御性读形。
50
+ * ⚠️ ADAPTER-F8 注纠(2026-08-02):原文锚的是裸行号 `213-231`,A 族拆分后那段是 `case 'text'` /
51
+ * `case 'reasoning'`,真正的 tool_end 臂已挪位。锚换成符号名(REF-CC-063 同款,腐烂不了)。 */
44
52
  export interface ToolEndResultArmLike {
45
53
  type?: unknown;
46
54
  toolCallId?: unknown;
@@ -1,26 +1,16 @@
1
1
  /**
2
2
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
3
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
4
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
3
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
4
+ *
5
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
6
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
7
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
8
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
9
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
10
+ * public-export-baseline 里,改名会当场红)。
5
11
  */
6
- export function flattenToolOutput(output) {
7
- if (typeof output === 'string')
8
- return output;
9
- if (Array.isArray(output)) {
10
- const parts = [];
11
- for (const block of output) {
12
- if (block && typeof block === 'object') {
13
- const b = block;
14
- if (b.type === 'text' && typeof b.text === 'string')
15
- parts.push(b.text);
16
- else if (b.type === 'image')
17
- parts.push('[image]');
18
- }
19
- }
20
- return parts.join('');
21
- }
22
- return '';
23
- }
12
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
13
+ import { flattenWireOutput } from './adapt/wireShapes.js';
24
14
  /**
25
15
  * Bash 臂的 is_error 派生(真行为实证,2026-07-16):引擎 tool_end.isError 语义 = 「工具本身
26
16
  * 是否执行失败」——命令非零退出时工具照常返回模型面框架文本(core runShell `exit code: N\n---
@@ -50,7 +40,7 @@ function bashExitCodeFailed(toolName, text, structured) {
50
40
  export function toolEndResultToUserFrame(arm) {
51
41
  if (typeof arm.toolCallId !== 'string' || arm.toolCallId.length === 0)
52
42
  return null;
53
- const text = flattenToolOutput(arm.output);
43
+ const text = flattenWireOutput(arm.output);
54
44
  return {
55
45
  type: 'user',
56
46
  message: {