@sema-agent/client-core 0.40.0 → 0.42.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1 -1
  3. package/dist/adapter/downstream/eventToSdkMessage.js +24 -0
  4. package/dist/attachmentsWireCaps.d.ts +43 -8
  5. package/dist/attachmentsWireCaps.js +64 -14
  6. package/dist/classifierVerdictWire.d.ts +0 -25
  7. package/dist/classifierVerdictWire.js +27 -7
  8. package/dist/engineAgentPanelStore.js +102 -13
  9. package/dist/hitl/approvalsFeed.d.ts +2 -2
  10. package/dist/hitl/approvalsFeed.js +57 -2
  11. package/dist/hitl/askGateWire.d.ts +4 -2
  12. package/dist/hitl/askGateWire.js +2 -0
  13. package/dist/hitl/editedRuleTextPrecheck.d.ts +102 -0
  14. package/dist/hitl/editedRuleTextPrecheck.js +91 -0
  15. package/dist/hitl/frameRouter.d.ts +21 -2
  16. package/dist/hitl/frameRouter.js +114 -12
  17. package/dist/hitl/gateLedger.d.ts +27 -0
  18. package/dist/hitl/gateLedger.js +64 -9
  19. package/dist/hitl/hitlBridge.d.ts +70 -14
  20. package/dist/hitl/hitlBridge.js +117 -34
  21. package/dist/hitl/hitlHostSurface.d.ts +33 -3
  22. package/dist/hitl/hitlHostSurface.js +33 -3
  23. package/dist/hitl/parkResolver.js +39 -13
  24. package/dist/hitl/toolApprovalWire.d.ts +133 -3
  25. package/dist/hitl/toolApprovalWire.js +143 -6
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +7 -0
  28. package/dist/model/catalogLoader.js +151 -45
  29. package/dist/printToolResultFrame.d.ts +19 -0
  30. package/dist/seatContract.d.ts +25 -1
  31. package/dist/seatContract.js +29 -2
  32. package/dist/subagent/engineDelegatedPrompt.js +10 -3
  33. package/dist/systemReminderTag.d.ts +52 -0
  34. package/dist/systemReminderTag.js +73 -0
  35. package/docs/INTEGRATION-CLIENTS.md +123 -7
  36. package/package.json +2 -2
@@ -29,7 +29,7 @@
29
29
  * false」—— 对这一族,发 `false` 是违约(核心根本没有「显式关」这个语义位),所以本模块对
30
30
  * 它们**永不产出 false**:OFF ⇒ 键不出现。
31
31
  *
32
- * ② **explicit-false 族**(2 员:agentListing / skillsListing):
32
+ * ② **explicit-false 族**(3 员:agentListing / skillsListing / **backgroundTasks**):
33
33
  * 它们在 core 里是 **DEFAULT-ON**,SDK 头注写死「`agentListing`/`skillsListing` 是 core
34
34
  * DEFAULT-ON —— **explicit false 才关**(1.254 起 false 真透传;更老 server 静默丢)」。
35
35
  * 于是「删键」在这一族上的含义是**保持开着**,而不是关掉。旧的 `AttachmentsSpec` 把全部键
@@ -37,6 +37,29 @@
37
37
  * 路径可以关闭**(不是「难关」,是类型层封死)。本批把这两键的类型放宽成 `boolean` 并给出
38
38
  * 产出 `false` 的路径。
39
39
  *
40
+ * ══ 🔴 [4982] 候选①(P0-KPI,0.42.0)—— **第三员漏族:`backgroundTasks`** ════════════════════
41
+ *
42
+ * core **5.12.0 (BREAKING)** 把 `attachments.backgroundTasks` 与 `agentListing`/`skillsListing`
43
+ * **同批**翻成 DEFAULT-ON(core `types.d.ts` 逐字:「Post-compact background-task restatement —
44
+ * DEFAULT ON since 5.12.0 (**boolean, not `true`**: explicit `false` is the opt-out; **same contract
45
+ * as the listing family below**)」)。三兄弟同批翻转,本模块**跟修了后两个、漏了第一个** ——
46
+ * `backgroundTasks` 一直留在 ① 族(off = 删键,永不发 false)。
47
+ * 后果是这个旋钮上最坏的一种失效:`SEMA_ATTACHMENTS=off`(文件自称「真·全关」)对这一位与
48
+ * **什么都不配**逐字节同效(两条路都不发这个键 = 都是「开」),**且零报错** —— 用户以为关掉了,
49
+ * 附件照进上下文,没有任何一处会说出来。与 ② 族当初那个洞是同一个病形的第三例。
50
+ *
51
+ * ── 修法:把「能不能显式打开」与「能不能显式关掉」拆成**两条正交的轴** ────────────────────────
52
+ * `backgroundTasks` 同时住两族,这不是分类含糊,这**就是**它的契约:
53
+ * · **轴 A(可显式开)** = `full` 会全开的那 7 员 —— `backgroundTasks` 在内,**membership 不动**;
54
+ * · **轴 B(可显式关)** = core DEFAULT-ON 的 3 员 —— `backgroundTasks` 新入。
55
+ * 只把它整个搬进 ② 族(= 从轴 A 里删掉)是**错的修法**,两条理由都是硬的:
56
+ * ① `full` / `default` / `+backgroundTasks` 三条既有路会当场少发一个 `backgroundTasks: true`
57
+ * —— 对 core ≥5.12.0 行为等价(默认就是开),但对**更老引擎**那是真的从「开」变成「关」,
58
+ * 而本模块手里没有 server 版本这个量(见下面 §能力边界),没资格替用户做那个降级;
59
+ * ② 空 env 的默认产出 `{backgroundTasks: true, toolsDelta: true}` 是 [487]② CC-parity 那一对的
60
+ * 字面承诺,删掉它就是把「默认逐字节不变」这条本模块的立身纪律自己破了。
61
+ * ⇒ 本批**只加一条出路**(`off` 与 `-backgroundTasks` 产出 `false`),其余取值逐字节不变。
62
+ *
40
63
  * ── env 面(`SEMA_ATTACHMENTS`)的取值语法,以及为什么选这一种 ────────────────────────────────
41
64
  * 目标是「**默认行为逐字节不变** + 有路可关 DEFAULT-ON 两键」,所以选了**在既有值上加逗号 token
42
65
  * 列表**,而不是新开一个 env:
@@ -45,8 +68,9 @@
45
68
  * · 逗号 token 列表对**存量取值零影响**:`unset` / `full` / `all` / off 拼法四种老写法解析结果
46
69
  * 逐字节不变(FIX7 段 ⑤ 有正面钉),新语法只在用户真写了逗号 token 时才被触发。
47
70
  * 语法(逗号分隔,大小写不敏感,允许空白):
48
- * · `off|0|false|no|none`(整值) ⇒ **真·全关**:literal-true 族一个不发(那一族的 off 就是
49
- * 删键)+ DEFAULT-ON 两键发 `false`。
71
+ * · `off|0|false|no|none`(整值) ⇒ **真·全关**:只可显式开的那一族一个不发(它们的 off 就是
72
+ * 删键)+ DEFAULT-ON **三键**(agentListing / skillsListing /
73
+ * backgroundTasks)发 `false`。
50
74
  * 🔴 对抗复审 [medium](2026-08-07)判的真病:这一支此前直接 `return undefined`(不发字段),
51
75
  * 而「不发字段」对 DEFAULT-ON 两键的含义**恰恰是保持开着** —— 一个叫 off 的总开关关不掉十件里
52
76
  * 的两件,且用户没有任何办法察觉。旧行为在 <1.254 的 server 上与新行为**逐字节等价**(那些
@@ -60,8 +84,9 @@
60
84
  * · `full` / `all` ⇒ literal-true 族 7 员全开(**不含** DEFAULT-ON 两键 ——
61
85
  * 不发 = 保持 core 默认开,壳不替 core 做那个决定)
62
86
  * · `default` ⇒ CC-parity 那一对(显式写出「默认」这个意思)
63
- * · `<key>` / `+<key>` ⇒ 单点打开一个 literal-true 族成员
64
- * · `-agentListing` / `-skillsListing` ⇒ 产出 **`false`**(explicit-false 族唯一的关法)
87
+ * · `<key>` / `+<key>` ⇒ 单点打开一个「可显式开」族成员(轴 A 的 7 员)
88
+ * · `-agentListing` / `-skillsListing` / `-backgroundTasks` ⇒ 产出 **`false`**(DEFAULT-ON 三键
89
+ * 唯一的关法;`-backgroundTasks` 是 [4982] 候选① 补的那条)
65
90
  * · `todoReminderMode=baseline|off` ⇒ 取值型键
66
91
  * 🔴 未登记 token / 对 literal-true 族用 `-` / todoReminderMode 取闭集外的值 ⇒ **fail-loud 抛错**。
67
92
  * 静默忽略是这条链上最坏的失效形:用户以为自己关掉了 agentListing,而请求照发、附件照进上下文,
@@ -88,10 +113,20 @@ export const ATTACHMENTS_SPEC_KEYS = [
88
113
  'agentListing',
89
114
  'skillsListing',
90
115
  ];
91
- /** ② 族(core DEFAULT-ON,explicit false 才关)。 */
92
- export const ATTACHMENTS_DEFAULT_ON_KEYS = ['agentListing', 'skillsListing'];
93
- /** 族里 `full` 会全开的那 7 员(`todoReminderMode` 是取值型,不搭 full 的车)。 */
94
- const LITERAL_TRUE_KEYS = [
116
+ /**
117
+ * **轴 B —— 可显式关**(core DEFAULT-ON,`false` 才是关;`-<key>` 与总开关 `off` 的作用面)。
118
+ * 🔴 [4982] 候选①(0.42.0):`backgroundTasks` 补入 —— core 5.12.0 与另两员**同批**翻 DEFAULT-ON,
119
+ * 本模块当时只跟修了两员。这张表**不是** 族的补集(`backgroundTasks` 两族兼属,见 AttachmentsSpec
120
+ * 上的键注与文件头注的「两条正交的轴」段)。
121
+ */
122
+ export const ATTACHMENTS_DEFAULT_ON_KEYS = ['agentListing', 'skillsListing', 'backgroundTasks'];
123
+ /**
124
+ * **轴 A —— 可显式开**:`full` 会全开的那 7 员(`todoReminderMode` 是取值型,不搭 full 的车)。
125
+ * 🔴 `backgroundTasks` **留在本表**([4982] 候选① 的取舍,理由见文件头注):把它从这里删掉会让
126
+ * `full`/`default`/`+backgroundTasks` 三条既有路少发一个 `true`,对 core <5.12.0 是真的从
127
+ * 「开」变成「关」,而本模块手里没有 server 版本这个量。
128
+ */
129
+ const OPT_IN_KEYS = [
95
130
  'todoReminder',
96
131
  'changedFiles',
97
132
  'planModeReminder',
@@ -100,9 +135,15 @@ const LITERAL_TRUE_KEYS = [
100
135
  'toolsDelta',
101
136
  'mcpInstructions',
102
137
  ];
138
+ /**
139
+ * **只可显式关、不可显式开**的那两员(轴 B ∖ 轴 A)。`+agentListing` 这类写法的 fail-loud 判据
140
+ * 锚在**本表**而不是整个轴 B —— 锚轴 B 会把 `+backgroundTasks` / `full` 一起误拒([4982] 候选①
141
+ * 修复批的判别力所在:两条轴各判各的,不许拿一条去代另一条)。
142
+ */
143
+ const DEFAULT_ON_ONLY_KEYS = ['agentListing', 'skillsListing'];
103
144
  /** CC-parity 默认对(TOC live 路径,[487]②)。 */
104
145
  const DEFAULT_PAIR = ['backgroundTasks', 'toolsDelta'];
105
- const _attachmentsKeyPins = [true, true, true, true];
146
+ const _attachmentsKeyPins = [true, true, true, true, true, true];
106
147
  void _attachmentsKeyPins;
107
148
  const TODO_REMINDER_MODES = ['baseline', 'off'];
108
149
  /** fail-loud 的统一措辞:说清**哪个 token 坏了**、以及合法形是什么(判词要指得出下一步)。 */
@@ -156,7 +197,7 @@ export function attachmentsForRequest(env = hostEnv()) {
156
197
  if (!isDefaultOnKey(key)) {
157
198
  refuse(token, `"${key}" belongs to the literal-true family whose OFF is "delete the key", not "send false" ` +
158
199
  '(core wire contract, board [479] ask-2) — simply do not turn it on. ' +
159
- 'Only the core DEFAULT-ON keys (agentListing / skillsListing) accept an explicit false');
200
+ `Only the core DEFAULT-ON keys (${ATTACHMENTS_DEFAULT_ON_KEYS.join(' / ')}) accept an explicit false`);
160
201
  }
161
202
  spec[key] = false;
162
203
  continue;
@@ -174,7 +215,10 @@ export function attachmentsForRequest(env = hostEnv()) {
174
215
  const key = canonicalKey(lhs.startsWith('+') ? lhs.slice(1) : lhs);
175
216
  if (key === undefined)
176
217
  refuse(token, 'unknown attachments key');
177
- if (isDefaultOnKey(key)) {
218
+ // 🔴 判据锚 `DEFAULT_ON_ONLY_KEYS`(轴 B ∖ 轴 A),**不是**整个轴 B:0.42.0 起
219
+ // `backgroundTasks` 两族兼属,拿轴 B 判会把 `+backgroundTasks` 一起误拒(而它在
220
+ // `full` 里本来就开得出来,拒它等于同一件事有两个答案)。
221
+ if (isDefaultOnOnlyKey(key)) {
178
222
  refuse(token, `"${key}" is core DEFAULT-ON — turning it "on" is a no-op that would send a redundant true; ` +
179
223
  `use "-${key}" to turn it OFF`);
180
224
  }
@@ -184,7 +228,8 @@ export function attachmentsForRequest(env = hostEnv()) {
184
228
  }
185
229
  return spec;
186
230
  }
187
- /** 总开关 off 的产出:literal-true 族一个不发(删键即关)+ DEFAULT-ON 两键显式 `false`。 */
231
+ /** 总开关 off 的产出:只可显式开的那族一个不发(删键即关)+ DEFAULT-ON **三键**显式 `false`
232
+ * ([4982] 候选①:`backgroundTasks` 从 0.42.0 起真的在这条路上被关掉)。 */
188
233
  function allDefaultOnOff() {
189
234
  const out = {};
190
235
  for (const k of ATTACHMENTS_DEFAULT_ON_KEYS)
@@ -193,7 +238,7 @@ function allDefaultOnOff() {
193
238
  }
194
239
  function allLiteralTrue() {
195
240
  const out = {};
196
- for (const k of LITERAL_TRUE_KEYS)
241
+ for (const k of OPT_IN_KEYS)
197
242
  out[k] = true;
198
243
  return out;
199
244
  }
@@ -206,9 +251,14 @@ function splitAssign(token) {
206
251
  function canonicalKey(lower) {
207
252
  return ATTACHMENTS_SPEC_KEYS.find(k => k.toLowerCase() === lower);
208
253
  }
254
+ /** 轴 B:可显式关(`-<key>` 的合法作用面)。 */
209
255
  function isDefaultOnKey(k) {
210
256
  return ATTACHMENTS_DEFAULT_ON_KEYS.includes(k);
211
257
  }
258
+ /** 轴 B ∖ 轴 A:只可关不可开(`+<key>` 的 fail-loud 作用面)。 */
259
+ function isDefaultOnOnlyKey(k) {
260
+ return DEFAULT_ON_ONLY_KEYS.includes(k);
261
+ }
212
262
  /** 单 token 形的历史回落判据(见 `attachmentsForRequest` 里那段注释)。 */
213
263
  function isKnownToken(token) {
214
264
  if (token === 'full' || token === 'all' || token === 'default')
@@ -7,31 +7,6 @@
7
7
  * 执行半场、判定走本包,是安全的拆缝(B6 T35/T36/T44 同款「判定半场搬入」姿势)。
8
8
  * 逐字纪律:CLASSIFIER_DENY_SIGNATURE / NOTICE_REASON_MAX(80)/ 通知行三段格式一字节不改。
9
9
  */
10
- /**
11
- * src/sema/classifierVerdictWire.ts — auto-mode 分类器裁决帧的壳侧消费(分类器批3 壳半场,[907])。
12
- *
13
- * WIRE 形状(gap-check 2026-07-16 实勘,core 1.279 dist 亲核):
14
- * 引擎侧分类器 block(core hooks.js runToolGate)→ decision = deny + decisionReason:"classifier",
15
- * permissionDenied 钩子收 source:"classifier"(server 1.209 只作 metrics 计量,不出独立 deny 帧)——
16
- * 壳能看见的唯一 wire 事实 = `tool_end` 帧:
17
- * { type:'tool_end', isError:true, output: [{type:'text', text:'<system-reminder>\n
18
- * auto-mode classifier blocked this call[: <reason>|: [<category>]]\n</system-reminder>'}] }
19
- * (core hooks.js L138 deny message 逐字 + formatHookFeedback 包 system-reminder;
20
- * agent-loop createErrorToolResult 落 content 数组;runtask toolOutputFrom 原样上 wire。)
21
- * ⇒ 消费面按此签名结构性识别,等价于「deny 帧带 source:'classifier'」的判定(签名字符串是
22
- * core 的机器产文面,非模型作文——模型 echo 不会以该签名做 tool_end 错误体开头)。
23
- *
24
- * 🔴 fail-soft 铁律:识别不中一律静默走原路,绝不伤害事件流。
25
- * 🔴 UNTRUSTED:reason 为分类器(模型)产文,只渲染绝不回喂;通知行截断 ≤80(207 同款)。
26
- */
27
- /**
28
- * 🟢 B8 ALLOW 清单登记(REF-CC-域词表-05,属主=client-core 规范重构轮,到期复议=core 改这句
29
- * deny message 文案时或 2026-Q4 复审以先到者为准):文本匹配 + 非文本结构性合取前置
30
- * (`ev.type==='tool_end' && ev.isError===true`,见 `classifierDenyFromToolEnd`)。同批同表的
31
- * 另两处:`detachWire.ts` `DETACH_DURABLE_OFF_400_ANCHOR`(server 契约话)、
32
- * `subagent/engineTaskHandleWire.ts` `STOP_CONFLICT_CODES`(SDK typed 缺席回落)——三处互指,
33
- * 理由逐条写在各自站点。
34
- */
35
10
  /** core hooks.js 分类器 deny message 的机器签名(逐字锚;lockstep:core 改文案这里必须跟)。 */
36
11
  export declare const CLASSIFIER_DENY_SIGNATURE = "auto-mode classifier blocked this call";
37
12
  export interface ClassifierDenyVerdict {
@@ -32,6 +32,7 @@
32
32
  * `subagent/engineTaskHandleWire.ts` `STOP_CONFLICT_CODES`(SDK typed 缺席回落)——三处互指,
33
33
  * 理由逐条写在各自站点。
34
34
  */
35
+ import { unwrapSystemReminder } from './systemReminderTag.js';
35
36
  /** core hooks.js 分类器 deny message 的机器签名(逐字锚;lockstep:core 改文案这里必须跟)。 */
36
37
  export const CLASSIFIER_DENY_SIGNATURE = 'auto-mode classifier blocked this call';
37
38
  /** 通知 reason 段截断(CC 207 L696763 同款 80 → 79+…)。 */
@@ -52,11 +53,22 @@ function textFromWireOutput(output) {
52
53
  .filter((t) => t !== undefined);
53
54
  return texts.length > 0 ? texts.join('\n') : undefined;
54
55
  }
55
- /** 去 <system-reminder> 包裹(formatHookFeedback 逆变换;不包裹则原样)。 */
56
- function unwrapSystemReminder(text) {
57
- const m = /^\s*<system-reminder>\s*\n?([\s\S]*?)\n?\s*<\/system-reminder>\s*$/.exec(text);
58
- return m ? m[1] : text;
59
- }
56
+ /**
57
+ * core 分类器 deny 的**三个**铸点(core 5.52.0 dist 逐字枚举,候选③ [4974]):
58
+ * ① `core/hooks.js:606` —— 基础形;
59
+ * ② `core/runner/prepare-task.js:3103/3192` —— 继承自祖先层形;
60
+ * ③ `core/runner/prepare-task.js:3023` —— 「批准过的编辑」在祖先层被拦形。
61
+ * 三者尾巴同形:`: ${reason}` 或 `: [${category}]`(都没有时整条到此为止)。
62
+ *
63
+ * 🔴 **顺序即语义**:① 是 ② 的**前缀**,短的排在后面 —— 反过来的话 ② 会被 ① 蒙对,而 `tail` 变成
64
+ * ` at an inherited ancestor layer: …`,不以 `': '` 开头 ⇒ **reason 整条丢失**(修前实况)。
65
+ * 🔴 逐字锚,不做模糊匹配(B8 ALLOW 登记的既有纪律):core 改文案这里必须跟车。
66
+ */
67
+ const CLASSIFIER_DENY_FORMS = [
68
+ 'auto-mode classifier blocked the approved edit at an inherited ancestor layer',
69
+ 'auto-mode classifier blocked this call at an inherited ancestor layer',
70
+ CLASSIFIER_DENY_SIGNATURE,
71
+ ];
60
72
  /**
61
73
  * 结构性识别一张 tool_end 帧是否为分类器 deny 裁决帧。
62
74
  * 判定 = isError:true ∧ 错误体(去包裹后)以 core 机器签名开头。识别不中 ⇒ undefined(原路)。
@@ -67,10 +79,18 @@ export function classifierDenyFromToolEnd(ev) {
67
79
  const raw = textFromWireOutput(ev.output);
68
80
  if (raw === undefined)
69
81
  return undefined;
82
+ // 件⑥([4974]):剥壳走**单一闸口** —— core ≥5.46 的开标签恒带 `mark="…"`,而这里原来锚的是
83
+ // 裸开标签 ⇒ 恒剥不掉 ⇒ 下面的 startsWith 恒不成立 ⇒ 整条 auto-deny 呈现面静默失效。
84
+ // 口径与另一座位(`subagent/engineDelegatedPrompt`)共用一份,见该模块头注。
70
85
  const message = unwrapSystemReminder(raw).trim();
71
- if (!message.startsWith(CLASSIFIER_DENY_SIGNATURE))
86
+ // 🔴 前缀**不是**语法(七审 finding③):命中之后还要看**边界**。core 的铸点尾巴只有两种形 ——
87
+ // 到此为止,或紧跟 `': '`(reason / `[category]` 都走这一个分隔)。少了这条,
88
+ // `…ancestor layerX` 这类近似串也会被判成分类器裁决,而那条路径会 `markEnded` + 摘 HOLD +
89
+ // 写 Recent Denials + 用裁决原文覆盖渲染 —— 任何能产出工具错误正文的工具都能伪造。
90
+ const form = CLASSIFIER_DENY_FORMS.find((f) => message.startsWith(f) && (message.length === f.length || message.startsWith(': ', f.length)));
91
+ if (form === undefined)
72
92
  return undefined;
73
- const tail = message.slice(CLASSIFIER_DENY_SIGNATURE.length);
93
+ const tail = message.slice(form.length);
74
94
  const reason = tail.startsWith(': ') ? tail.slice(2).trim() : undefined;
75
95
  return { message, ...(reason !== undefined && reason.length > 0 ? { reason } : {}) };
76
96
  }
@@ -62,26 +62,115 @@ export function publishEngineAgentPanelEvent(ev) {
62
62
  }
63
63
  return;
64
64
  }
65
- // Buffered (panel not mounted yet / temporarily unmounted). Ticks COALESCE per taskId (only the
66
- // latest rollup matters — usage is cumulative), so lifecycle events ('end'/'sweep') are never the
67
- // ones squeezed out by a long unmount (adversarial review w0zwpa251 #4: dropping a buffered 'end'
68
- // left a row running forever).
69
- if (ev.kind === 'tick') {
70
- const i = buffer.findIndex(b => b.kind === 'tick' && b.taskId === ev.taskId);
65
+ // Buffered (panel not mounted yet / temporarily unmounted).
66
+ //
67
+ // Two event CLASSES, and every buffering rule below keys off the class (not off one kind):
68
+ // · PROGRESS ('tick' / 'fleet-row') cumulative rollups off the two live lanes. Only the latest
69
+ // one per taskId carries information, so they COALESCE per (kind, taskId) and they are the ones
70
+ // that yield when the buffer is full.
71
+ // · LIFECYCLE ('end' / 'sweep') — each one is a distinct fact that settles a row. They are never
72
+ // the ones squeezed out by a long unmount (adversarial review w0zwpa251 #4: dropping a buffered
73
+ // 'end' left a row running forever).
74
+ //
75
+ // 🔴 [F4957-2] (0.41.0) the full-buffer arm used to look ONLY for a 'tick' to evict and then pushed
76
+ // UNCONDITIONALLY even when it found none — so a stream carrying no ticks at all (the 'fleet-row'
77
+ // lane is exactly that: background subagents never reach the panel through 'task_progress', see the
78
+ // fleet-row arm's own header) grew the buffer without bound. MAX_BUFFER is a memory bound on
79
+ // UNTRUSTED wire volume; it has to hold for every kind mix, not just the one the first version had
80
+ // in mind.
81
+ // Adjacent 'sweep' dedup. A sweep settles every row the consumer still has running and its own doc
82
+ // calls it idempotent, so a sweep whose immediate predecessor is a sweep is a strict no-op — nothing
83
+ // happened in between that it could settle differently. Dropping it is not a micro-optimisation: a
84
+ // long unmount publishes one sweep per turn, and 200 of them fill the buffer with pure lifecycle
85
+ // events, after which every post-barrier progress event has to yield (that is the other half of the
86
+ // bounded policy) — a subagent that starts at that point would replay as "still settled", forever.
87
+ if (ev.kind === 'sweep' && buffer.length > 0 && buffer[buffer.length - 1]?.kind === 'sweep')
88
+ return;
89
+ const progress = ev.kind === 'tick' || ev.kind === 'fleet-row';
90
+ if (progress) {
91
+ const kind = ev.kind;
92
+ const taskId = ev.taskId;
93
+ // Search BACKWARDS for a merge target and STOP at the first barrier. The bar for the whole buffer
94
+ // is "replaying it leaves the consumer in the same state as if the panel had been mounted all
95
+ // along", and each barrier below is a place where the consumer's state machine changes what a
96
+ // later event means — so an event on the far side of one cannot be folded into an event on this
97
+ // side (0.41.0 adversarial review #5 and #6):
98
+ // · 'sweep' (global) and 'end' for this taskId — they SETTLE the row, and the consumer ignores
99
+ // progress for a settled row. Live: `tick(v1) → sweep → tick(v2)` builds the row, settles it,
100
+ // then drops v2 → the row ends settled. Folded into `sweep → tick(v2)` the replay sweeps an
101
+ // empty table and then v2 BUILDS a running row — a ghost that live delivery never produces.
102
+ // · the OTHER progress lane for this taskId — the fleet-row arm's own header states that a
103
+ // 'tick' makes the fleet lane hand the row over PERMANENTLY, so what the fleet lane said
104
+ // BEFORE the handover (transcriptId / startedAt, often carried by exactly one frame) would
105
+ // land after it and never be applied.
106
+ // Barriers cost buffer slots, and that is the right trade: the bound is still enforced by the
107
+ // eviction policy below, while a wrong fold is silently wrong forever.
108
+ let i = -1;
109
+ for (let at = buffer.length - 1; at >= 0; at--) {
110
+ const b = buffer[at];
111
+ if (b === undefined)
112
+ continue;
113
+ if (b.kind === 'sweep')
114
+ break;
115
+ if (b.kind === 'end') {
116
+ if (b.taskId === taskId)
117
+ break;
118
+ continue;
119
+ }
120
+ if (b.taskId !== taskId)
121
+ continue; // another task's progress — irrelevant to this row
122
+ if (b.kind === kind)
123
+ i = at;
124
+ break; // same lane ⇒ merge target; other lane ⇒ barrier
125
+ }
71
126
  if (i >= 0) {
72
- buffer[i] = ev;
127
+ // 🔴 Coalescing must not DROP sticky keys either (0.41.0 adversarial review #4). Both progress
128
+ // lanes are published as PARTIAL frames — the projection emits `...(toolUses !== undefined ?
129
+ // { toolUses } : {})` and friends, and an absent key means "this frame didn't say, keep what the
130
+ // row already has" (the three-state rule on the fleet-row arm above). A mounted panel therefore
131
+ // still shows the `toolUses` that arrived on v1 after a v2 that only moved `totalTokens`; a
132
+ // replay that kept only v2 would lose it for good. So the surviving copy is a per-key merge
133
+ // (later frame wins per key, keys it omits are inherited), not a wholesale replacement.
134
+ const merged = { ...buffer[i], ...ev };
135
+ // 🔴 Coalescing must not REORDER across a lifecycle barrier (0.41.0 adversarial review #2).
136
+ // Overwriting in place rewrites `progress(A,v1) → end(A) → progress(A,v2)` into
137
+ // `progress(A,v2) → end(A)` — the post-settle rollup is moved BEFORE the settle, so a row that
138
+ // was revived (the fleet projection explicitly allows a settled row to run again) gets settled
139
+ // by the replay again, with the revived progress applied to the dead cycle. The bar for a
140
+ // buffered replay is "same result as if the panel had been mounted the whole time", so the
141
+ // surviving copy has to keep the LATEST event's arrival position: drop the old one, append.
142
+ buffer.splice(i, 1);
143
+ buffer.push(merged);
73
144
  return;
74
145
  }
75
146
  }
76
- if (buffer.length < MAX_BUFFER)
147
+ if (buffer.length < MAX_BUFFER) {
77
148
  buffer.push(ev);
78
- else if (ev.kind !== 'tick') {
79
- // Lifecycle events must survive a full buffer: evict the oldest tick to make room.
80
- const i = buffer.findIndex(b => b.kind === 'tick');
81
- if (i >= 0)
82
- buffer.splice(i, 1);
149
+ return;
150
+ }
151
+ // Full: the oldest PROGRESS event yields (its taskId's next rollup supersedes it anyway).
152
+ const evictAt = buffer.findIndex(b => b.kind === 'tick' || b.kind === 'fleet-row');
153
+ if (evictAt >= 0) {
154
+ buffer.splice(evictAt, 1);
83
155
  buffer.push(ev);
156
+ return;
84
157
  }
158
+ // Nothing but lifecycle events in a full buffer (MAX_BUFFER rows settled while the panel stayed
159
+ // unmounted — 200 distinct 'end's is the realistic way there, the adjacent-sweep dedup above having
160
+ // removed the cheap one). At a finite bound something must give, and the rule is NEWEST-WINS for
161
+ // every kind: the oldest event yields, whatever it is.
162
+ //
163
+ // 🔴 Why not "progress yields here too" (which is what this arm used to do — adversarial review
164
+ // round 7, finding #4): a progress event arriving at this point is the ONLY word the panel will
165
+ // ever get about a row that was created or revived after the barrier — `projectFleetAgentRows`
166
+ // emits on CHANGE, so a row that then goes quiet never speaks again. Dropping it makes that row
167
+ // permanently invisible after a remount, which is a strictly worse loss than dropping the OLDEST
168
+ // lifecycle event: that one settles a row that finished ~MAX_BUFFER events ago, and the sweep that
169
+ // every turn publishes (newest ⇒ always survives) settles whatever is still running anyway.
170
+ // The residual — an old 'end' can be dropped, taking its `report`/`isError` with it — is the honest
171
+ // price of a bounded buffer and is registered in CHANGELOG 0.41.0 "已知局限".
172
+ buffer.shift();
173
+ buffer.push(ev);
85
174
  }
86
175
  /** Single consumer (the panel hook). Replays any buffered events, returns an unsubscribe. */
87
176
  export function subscribeEngineAgentPanel(fn) {
@@ -6,7 +6,7 @@
6
6
  * ## 先纠一个事实(census G20 的措辞会误导下一棒)
7
7
  *
8
8
  * G20 原话是「`hitlBridge.ts:197` 等三处今天靠 `approvals.list` 轮询」。**实测不是轮询** ——
9
- * 那三处(`hitlBridge.findPending` / `askGateWire.surfaceGateAndDecide` /
9
+ * 那三处(`hitlBridge.pendingForDecide` / `askGateWire.surfaceGateAndDecide` /
10
10
  * `toolApprovalWire.surfaceFsApprovalAndDecide`)都是**按需单次取件**:流上已经来了 park 信号,
11
11
  * 才去取那一行。真正缺的不是「把轮询换成推送」,而是**根本没有推送通道**:
12
12
  * · 别的客户端(web/桌面/另一个 TUI)解掉了一条 pending —— 本端不知道;
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * ## 🔴 它**不替换** D-1 的取件(这条是安全边界,别"顺手优化"掉)
19
19
  *
20
- * D-1 两元组必须取自**人看见的那一行**。让 `findPending` 去读一个可能陈旧的快照 =
20
+ * D-1 两元组必须取自**人看见的那一行**。让取件去读一个可能陈旧的快照 =
21
21
  * 用一个过期的 `boundInputHash` 去 decide ⇒ server fail-closed 409 ⇒ 一个合法审批被拒
22
22
  * (或者更坏:快照比服务端新旧关系不确定时,人看到的和绑定的不是同一件事)。
23
23
  * 所以那三处**继续走权威 `list()`**,本 feed 只做「有变化了」的通知面与快照面。
@@ -40,6 +40,16 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
40
40
  let retryTimer;
41
41
  /** [2393] hitl-F5:连续「回调体抛出」的次数(一次跑完就归零)——见 `schedulePoll` 的 catch 臂。 */
42
42
  let consecutivePollEscapes = 0;
43
+ /** [F4957-1] 轮询腿的**代际**:push 腿每重连一次 +1。在途的旧拍完成时凭它认出自己已经过期 ——
44
+ * 它的成败属于上一条腿,不该改现在这条腿的连续计数,也不该由它来续排(见 `schedulePoll`)。 */
45
+ let pollLeg = 0;
46
+ /**
47
+ * [F4957-1 三审] 取件的**单调发号**与「已观察到的最新一号」。乱序回滚是**取件面的通病**,不是
48
+ * 轮询腿的私事:`refresh()`(端上的显式刷新)与 push 腿的重取同样可能后发先至。所以提交面的闸
49
+ * 从「代际」升成请求序号 —— 一次响应只有在「没有更晚发起的请求先落过地」时才允许改视图。
50
+ */
51
+ let takeSeq = 0;
52
+ let lastObservedSeq = 0;
43
53
  const stats = {
44
54
  pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
45
55
  };
@@ -73,9 +83,12 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
73
83
  }
74
84
  return verdict;
75
85
  }
76
- async function take(via) {
86
+ async function take(via,
87
+ /** [F4957-1 二审] 发起这次取件时的轮询代际(只有轮询腿传;push/refresh 不受代际管)。 */
88
+ leg) {
77
89
  if (stopped)
78
90
  return;
91
+ const seq = ++takeSeq;
79
92
  let rows;
80
93
  try {
81
94
  rows = (await client.approvals.list({ signal: ac.signal })).pending;
@@ -88,6 +101,23 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
88
101
  if (stopped)
89
102
  return;
90
103
  const d = digestOf(rows);
104
+ // 🔴 [F4957-1 二审 finding②] 代际闸必须排在**提交之前**:下面四行(lastDigest / revision /
105
+ // last / onSnapshot)一落,视图就已经被改了 —— 守卫排在 take() 之外只挡得住计数与续排,挡不住
106
+ // 回滚。乱序是真的会发生的:旧 poll 在途 → push 重连并发布新快照 → 旧 poll 才带着**更老**的
107
+ // 结果返回 ⇒ 端拿到一张 revision 更高、内容更旧的 `mode:'poll'` 快照(刚出现的待审批被抹掉,
108
+ // 或刚解决的又冒回来)。过期的结论一个字节都不提交。
109
+ if (leg !== undefined && leg !== pollLeg) {
110
+ hostLog('debug', `approvalsFeed: dropping stale poll snapshot from leg ${leg} (current ${pollLeg})`);
111
+ return;
112
+ }
113
+ // 🔴 [F4957-1 三审 finding②] 乱序闸(三条取件路径共用):比「已观察到的最新一号」更早发起的
114
+ // 响应,其结论已经被后发者的结论取代 —— 一个字节都不提交。`refresh()` 不带代际,单靠上面那道
115
+ // 闸盖不住它(端上「用户点了刷新」正是最容易与 push 重取撞车的那一发)。
116
+ if (seq < lastObservedSeq) {
117
+ hostLog('debug', `approvalsFeed: dropping out-of-order take #${seq} (newest observed #${lastObservedSeq})`);
118
+ return;
119
+ }
120
+ lastObservedSeq = seq;
91
121
  if (d === lastDigest)
92
122
  return; // 内容没变 ⇒ 不打扰订阅者
93
123
  lastDigest = d;
@@ -118,6 +148,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
118
148
  return;
119
149
  pollTimer = arm(setTimeout(() => {
120
150
  pollTimer = undefined;
151
+ // 🔴 [F4957-1 二审] 这一拍属于**哪一代**轮询腿。`take('poll')` 是可以挂很久的(慢 list /
152
+ // 挂死读面),期间 push 腿完全可能重连成功并把连续计数清零 —— 那一拍的结论此时已经是
153
+ // **上一条腿**的历史,回写到新计数里就等于把「被健康 push 窗隔开的失败」又算回连续
154
+ // (回落之后只需 4 拍就转 idle),而这正是本批要修掉的那条语义。代际在重连处 +1。
155
+ const leg = pollLeg;
121
156
  // [C77]② 跳拍:消费方说这一拍不用查 ⇒ 不取件、不计 `stats.polls`(没取件却计数 = 谎报流量)、
122
157
  // **不动** `consecutivePollEscapes`(跳拍不是失败,连着跳一万拍也不该被 F5 判成腿挂了),
123
158
  // 但下一拍照排 —— 这是「腿活着但闲着」,不是停摆。
@@ -128,7 +163,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
128
163
  }
129
164
  void (async () => {
130
165
  stats.polls++;
131
- await take('poll');
166
+ await take('poll', leg); // 代际随取件一路带进去(提交面的闸在 take 内,见其头注)
167
+ // 代际已翻篇 ⇒ 这一拍的成败都不属于现在这条腿:既不清零(会掩盖新腿真正的连续失败),
168
+ // 也不续排(当代那条链由 degradeToPoll 自己排,这里再排一次纯属两条腿并行的种子)。
169
+ if (leg !== pollLeg)
170
+ return;
132
171
  consecutivePollEscapes = 0; // 跑完一整拍 ⇒ 之前的逃逸不再连续
133
172
  if (mode === 'poll')
134
173
  schedulePoll();
@@ -149,6 +188,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
149
188
  // ② **连续失败上限转 idle** —— 一直抛就是真的挂了,那时 `mode` 必须说真话,让端的自检
150
189
  // 看得见([honest-absence-not-fabricated-zero]:诚实缺席优先于一个还在报活的假象)。
151
190
  hostLog('debug', `approvalsFeed: poll leg threw unexpectedly (take() should have self-caught): ${String(e)}`);
191
+ // 🔴 [F4957-1 二审] 同上:上一代的逃逸不喂现在这条腿的连续计数,也不由它来续排。
192
+ if (leg !== pollLeg) {
193
+ hostLog('debug', `approvalsFeed: stale poll completion from leg ${leg} (current ${pollLeg}) — not counted as a consecutive escape`);
194
+ return;
195
+ }
152
196
  consecutivePollEscapes++;
153
197
  if (consecutivePollEscapes >= MAX_CONSECUTIVE_POLL_ESCAPES) {
154
198
  mode = 'idle';
@@ -216,6 +260,17 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
216
260
  clearTimeout(pollTimer);
217
261
  pollTimer = undefined;
218
262
  }
263
+ // 🔴 [F4957-1](0.41.0):**同批清零 F5 的连续逃逸计数**。这一拍与 `schedulePoll` 里
264
+ // 「跑完一整拍 ⇒ 之前的逃逸不再连续」是同等强度的健康证据 —— 端真的从服务端收到了一个
265
+ // 事件。不清零的后果不是多算几次:被一整段健康 push 窗**隔开**的两批逃逸会被当成连续
266
+ // 累加(3 次 → 重连 → 再 2 次 = 5),于是刚从 push 回落的轮询腿才逃逸两拍就撞上限,
267
+ // `mode()` 谎报 idle —— 而文件头 38-42 卖的正是「mode 不是 idle」这条端自检,
268
+ // 它会指着一条刚活过来的腿说它停摆了([honest-absence-not-fabricated-zero] 的反面:
269
+ // 把「活着」渲成「死了」同样是撒谎)。计数的语义是**连续**,那就只能被真正连续的失败喂大。
270
+ consecutivePollEscapes = 0;
271
+ // 与清零**同一拍**翻代际:清零只管已经落地的计数,代际管的是那些还挂在 `take()` 里、
272
+ // 稍后才回写的在途拍(二审 finding③ —— 少了这一半,清零会被一个旧拍当场推回 1)。
273
+ pollLeg++;
219
274
  hostLog('debug', 'approvalsFeed: push leg re-established — polling leg stopped');
220
275
  }
221
276
  if (ev?.type === 'heartbeat') {
@@ -52,8 +52,10 @@
52
52
  * 从 `GET /v1/approvals` 拿 pending 行(input=questions + boundCallId/boundInputHash 绑定)、
53
53
  * 合成 QuestionFrame 借既有 AskUserQuestion overlay(planReviewWire 同款 local-responder 姿势,
54
54
  * CC 原生对话框,零新 UI)、等用户作答。
55
- * 3. 决断走 T23 HitlBridge(答案 ride `ApprovalDecision.answer`,D-1 绑定 verbatim 回显;拒答=
56
- * cancel-by-deny,contract/04 §2.4)。decide SYNC 驱动的:引擎跑到下一个 park 或终态才返
55
+ * 3. 决断走 T23 HitlBridge(答案 ride `ApprovalDecision.answer`,D-1 绑定 verbatim 回显;拒答 =
56
+ * 一次 **TOOL 级 deny** 应答,把这只 ask 结算掉 —— server `ASSISTANT-WIRE-CONTRACT.md` §4a。
57
+ * 🔴 **0.42.0 撤稿**:原文写的是「拒答=cancel-by-deny,contract/04 §2.4」;§4a 逐字反对把
58
+ * deny 读成 run kill,逐条撤稿见 `hitlBridge.decideTool` 头注)。decide 是 SYNC 驱动的:引擎跑到下一个 park 或终态才返
57
59
  * (实测 4-5s+),返回体 `{status}` 即下一状态。
58
60
  * 4. 续流:attach `GET /v1/runs/:taskId/events`(durable leg;实证 durable log 从 park 点才开始,
59
61
  * 无挂起前重放)。重放的 tool_start/tool_end 按 toolCallId 去重;被 gate 的 call 在重放里带来
@@ -76,6 +76,8 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
76
76
  park = {
77
77
  gate: action.gate,
78
78
  ...(action.pendingDone !== undefined ? { pendingDone: action.pendingDone } : {}),
79
+ // [4914] 请托①到货件:park 的待批 call 身份(server ≥7.41.0)——缺席是常态,见 GatePark 头注。
80
+ ...(action.gatedCallId !== undefined ? { gatedCallId: action.gatedCallId } : {}),
79
81
  };
80
82
  break;
81
83
  }