@sema-agent/client-core 0.64.1 → 0.65.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 (51) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/README.md +15 -7
  3. package/dist/adapt/arms.js +125 -7
  4. package/dist/adapt/ids.d.ts +22 -0
  5. package/dist/adapt/ids.js +29 -0
  6. package/dist/adapt/panelTasks.d.ts +22 -0
  7. package/dist/adapt/panelTasks.js +45 -0
  8. package/dist/adapt/textStream.js +6 -3
  9. package/dist/adapt.d.ts +1 -1
  10. package/dist/adapt.js +3 -0
  11. package/dist/adapter/downstream/eventToSdkMessage.d.ts +0 -10
  12. package/dist/adapter/downstream/eventToSdkMessage.js +207 -65
  13. package/dist/adapter/downstream/terminalToSdkResult.d.ts +4 -4
  14. package/dist/adapter/downstream/terminalToSdkResult.js +43 -12
  15. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +23 -2
  16. package/dist/adapter/downstream/turnUsageToModelUsage.js +7 -1
  17. package/dist/adapter/runStream.js +39 -2
  18. package/dist/adapter/types.d.ts +12 -0
  19. package/dist/autoModeUnavailable.d.ts +48 -79
  20. package/dist/autoModeUnavailable.js +70 -99
  21. package/dist/classifierStatus.d.ts +25 -71
  22. package/dist/classifierStatus.js +110 -105
  23. package/dist/cloudConfigWireCaps.js +16 -0
  24. package/dist/decideReceipt.d.ts +117 -0
  25. package/dist/decideReceipt.js +142 -0
  26. package/dist/engineErrorCodes.d.ts +32 -0
  27. package/dist/engineErrorCodes.js +42 -0
  28. package/dist/fleet/fleetProjection.d.ts +24 -1
  29. package/dist/fleet/fleetProjection.js +26 -1
  30. package/dist/fleetAgentPanelProjection.js +6 -1
  31. package/dist/gateVocabulary.d.ts +9 -1
  32. package/dist/gateVocabulary.js +46 -4
  33. package/dist/hitl/askGateWire.js +22 -1
  34. package/dist/hitl/gateLedger.d.ts +24 -0
  35. package/dist/hitl/gateLedger.js +8 -0
  36. package/dist/hitl/hitlBridge.js +14 -2
  37. package/dist/hitl/parkResolver.d.ts +23 -2
  38. package/dist/hitl/parkResolver.js +34 -6
  39. package/dist/hitl/toolApprovalWire.d.ts +94 -6
  40. package/dist/hitl/toolApprovalWire.js +127 -7
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +16 -6
  43. package/dist/mcpWireCaps.d.ts +31 -0
  44. package/dist/mcpWireCaps.js +12 -0
  45. package/dist/notifications.js +11 -2
  46. package/dist/runTerminal.d.ts +48 -0
  47. package/dist/runTerminal.js +59 -0
  48. package/dist/seam.d.ts +131 -1
  49. package/dist/seam.js +22 -0
  50. package/docs/INTEGRATION-CLIENTS.md +934 -61
  51. package/package.json +2 -2
@@ -338,6 +338,48 @@ export const RESUME_RETRY_LATER_CODES = Object.freeze([
338
338
  RESUME_USAGE_WINDOW_EXHAUSTED,
339
339
  RESUME_PREFLIGHT_REJECTED,
340
340
  ]);
341
+ // ── `/decide` workflow 车道拒绝族(B-070 / L-200;sdk README §9.0.0,server ≥7.69.0)──────────
342
+ //
343
+ // 背景一句话:`/decide` 7.69.0 起长出**第三条车道** —— 停在耐久审批门上的那一只 checkpoint 属于一个
344
+ // **workflow 子代**时,决断要投给它的**宿主会话**去 wake。这条车道的 200 与另外两条**不是同一句话**
345
+ // (见 `src/decideReceipt.ts` 顶注),而它的三个拒绝码也各有各的出路,合并判就会把「重发没用」
346
+ // 与「等一会儿重发」说成同一件事。
347
+ /**
348
+ * `decide.workflow_host_unknown`(409;sdk README §9.0.0 逐字:「run 上没有发起会话锚 ⇒ 无处投递,
349
+ * **无重试价值**」)。
350
+ * 🔴 出路**不是等**:这条 run 上根本没有可投递的宿主锚,重发一百次也一样。人能做的是据拒体的
351
+ * `runId` 自己去看那条 run(或换一条路 resume 它)。
352
+ */
353
+ export const DECIDE_WORKFLOW_HOST_UNKNOWN = 'decide.workflow_host_unknown';
354
+ /**
355
+ * `decide.workflow_host_not_parked`(409;同上:「宿主此刻没停在可唤醒的 park 上 ⇒ **等它再 park
356
+ * 后重发**,或据 `runId` 自行 resume」)。
357
+ * 🔴 与上一码**刻意分成两个词**:这一个**可以**重发(宿主是时序问题),上一个不能。
358
+ * ⚠️ 但它**不在** {@link RESUME_RETRY_LATER_CODES} 里,也不该在 —— 那张表闭的是「server 在哪些码上
359
+ * 铸 `retryAfterSec`」,而本码**恒无窗**(server 给不出「等多久」)。可等 ≠ 有窗。
360
+ */
361
+ export const DECIDE_WORKFLOW_HOST_NOT_PARKED = 'decide.workflow_host_not_parked';
362
+ /**
363
+ * `decide.workflow_remember_unsupported`(400;同上:「本车道 fail-closed 拒 `remember:"session"`」)。
364
+ * 🔴 出路是**去掉 `remember` 重发**,而且 sdk 顶注逐字保证「零副作用」——这一格与另外两个 409
365
+ * 的分法不是严重程度,是**改什么才能过**(改参数 / 等时序 / 没救)。
366
+ */
367
+ export const DECIDE_WORKFLOW_REMEMBER_UNSUPPORTED = 'decide.workflow_remember_unsupported';
368
+ /**
369
+ * `/decide` workflow 车道的拒绝码**闭集**(三员)。
370
+ *
371
+ * 🔴 闭的不是「`decide.*` 一共有几个码」(那仍是开集:`approval_binding_mismatch` /
372
+ * `approval_stale` / `gate_not_tool_approval` 等都不在本表,它们各有既有处置),闭的是
373
+ * 「**这三个码是 workflow 那条新车道独有的、而且各自有一句人话**」。
374
+ * 🔴 **形制**:`Object.freeze` 的数组,不是 `ReadonlySet`(同 {@link RESUME_RETRY_LATER_CODES} 的
375
+ * 已定谳病形 —— `ReadonlySet` 只在类型面只读,而判定查的就是公面上这同一个实例)。
376
+ * 🔴 加成员必须**同批**补那一句人话 + 判据,不许靠 `decide.` 前缀放宽(前缀下住着三种处置)。
377
+ */
378
+ export const DECIDE_WORKFLOW_LANE_CODES = Object.freeze([
379
+ DECIDE_WORKFLOW_HOST_UNKNOWN,
380
+ DECIDE_WORKFLOW_HOST_NOT_PARKED,
381
+ DECIDE_WORKFLOW_REMEMBER_UNSUPPORTED,
382
+ ]);
341
383
  // ── drain / 场景执法族(A-028.11/.13 单源化,#244 族E,2026-08-15)────────────────────────────
342
384
  /**
343
385
  * server 温切 drain 门的 pre-stream 拒收码(503 + `errorCode:"draining"`;server 侧
@@ -170,7 +170,30 @@ export declare const TERMINAL_FLEET_TASK_STATUSES: ReadonlySet<string>;
170
170
  * `projectTasks` 传的是 SDK 必填闭集 `FleetTaskRow['status']`),继续留着是死分支。
171
171
  */
172
172
  export declare function coerceTaskStatus(s: string): FleetTaskStatus;
173
- /** 中性 workflow run 状态(running|completed|failed)→ 渲染词汇。 */
173
+ /**
174
+ * 中性 workflow 状态(run 席 `running|completed|failed`;item 席多一个 `parked`)→ 渲染词汇。
175
+ *
176
+ * 🔴 **0.65.0 加 `parked`(L-215① / B-079②)**:sdk 9.0.0 把 item 席具名成 `WorkflowItemStatus`
177
+ * 并加了 `parked`(「这一序数停在耐久审批门上等一次决定」),core 7.10.0 #642 是它的出处。
178
+ * 修前这里**没有这个 case**,于是它落进 `default` 被折成 `running` —— fleet 车道上一条**停着等人**
179
+ * 的工作流被渲成「在跑」,而那正是「等你」与「不需要你动」的分界。折成 `running` 之后用户没有任何
180
+ * 线索去按 ctrl+t 找那张卡,run 就一直停在那里。
181
+ * ⇒ 与 {@link coerceTaskStatus} 的 `parked` 臂**同一个落点** `awaiting approval`:同一件事在两条
182
+ * 车道上必须渲同一个词(两处各渲各的正是同名不同义的来源)。
183
+ *
184
+ * 🔴 **为什么直接加词、而不是铸一个 `_sema_parked: true` 超集键**(任务书要求写明选形理由):
185
+ * 判据是 sdk 那一位**在型面上开不开**,不是「它今天有几个已知值」——
186
+ * `FleetWorkflowRow.status` 在 sdk 8.8.0 与 9.0.0 上都声明成裸 `string`(`dist/resources/fleet.d.ts`
187
+ * 的 `status: string`,头注逐字「the NEUTRAL workflow run status (`running`|`completed`|`failed`),
188
+ * **open on read**」)。**它不是闭集** ⇒ 一个新词在 wire 上、在型面上都合法,消费端要做的就是
189
+ * 认它;此时再加一个 `_sema_` 前缀的孪生布尔,等于替一件上游已经能说清楚的事**另铸第二个真源**,
190
+ * 而两个真源迟早分叉(本包一贯要根治的形)。超集键留给「上游那一位真是闭集、加词会让严格消费端
191
+ * 判违约」的那一类,这里不是。
192
+ *
193
+ * ⚠️ **本函数仍收 run 席与 item 席两处**(调用点见 `projectWorkflows`):core 的两张词表里
194
+ * `parked` 只在 item 席,所以 run 席读到它属于上游异常 —— 但**渲染面的处置相同**(都是「等人」),
195
+ * 在这里为「哪一席」分叉只会长出一条没有用户面差别的分支。
196
+ */
174
197
  export declare function coerceWorkflowStatus(s: string | undefined): FleetTaskStatus;
175
198
  /**
176
199
  * 187 给 fleet 行命名用的是 agent 的**身份**,从不是它的 objective:
@@ -63,7 +63,30 @@ export function coerceTaskStatus(s) {
63
63
  return 'idle';
64
64
  }
65
65
  }
66
- /** 中性 workflow run 状态(running|completed|failed)→ 渲染词汇。 */
66
+ /**
67
+ * 中性 workflow 状态(run 席 `running|completed|failed`;item 席多一个 `parked`)→ 渲染词汇。
68
+ *
69
+ * 🔴 **0.65.0 加 `parked`(L-215① / B-079②)**:sdk 9.0.0 把 item 席具名成 `WorkflowItemStatus`
70
+ * 并加了 `parked`(「这一序数停在耐久审批门上等一次决定」),core 7.10.0 #642 是它的出处。
71
+ * 修前这里**没有这个 case**,于是它落进 `default` 被折成 `running` —— fleet 车道上一条**停着等人**
72
+ * 的工作流被渲成「在跑」,而那正是「等你」与「不需要你动」的分界。折成 `running` 之后用户没有任何
73
+ * 线索去按 ctrl+t 找那张卡,run 就一直停在那里。
74
+ * ⇒ 与 {@link coerceTaskStatus} 的 `parked` 臂**同一个落点** `awaiting approval`:同一件事在两条
75
+ * 车道上必须渲同一个词(两处各渲各的正是同名不同义的来源)。
76
+ *
77
+ * 🔴 **为什么直接加词、而不是铸一个 `_sema_parked: true` 超集键**(任务书要求写明选形理由):
78
+ * 判据是 sdk 那一位**在型面上开不开**,不是「它今天有几个已知值」——
79
+ * `FleetWorkflowRow.status` 在 sdk 8.8.0 与 9.0.0 上都声明成裸 `string`(`dist/resources/fleet.d.ts`
80
+ * 的 `status: string`,头注逐字「the NEUTRAL workflow run status (`running`|`completed`|`failed`),
81
+ * **open on read**」)。**它不是闭集** ⇒ 一个新词在 wire 上、在型面上都合法,消费端要做的就是
82
+ * 认它;此时再加一个 `_sema_` 前缀的孪生布尔,等于替一件上游已经能说清楚的事**另铸第二个真源**,
83
+ * 而两个真源迟早分叉(本包一贯要根治的形)。超集键留给「上游那一位真是闭集、加词会让严格消费端
84
+ * 判违约」的那一类,这里不是。
85
+ *
86
+ * ⚠️ **本函数仍收 run 席与 item 席两处**(调用点见 `projectWorkflows`):core 的两张词表里
87
+ * `parked` 只在 item 席,所以 run 席读到它属于上游异常 —— 但**渲染面的处置相同**(都是「等人」),
88
+ * 在这里为「哪一席」分叉只会长出一条没有用户面差别的分支。
89
+ */
67
90
  export function coerceWorkflowStatus(s) {
68
91
  switch (s) {
69
92
  case 'completed':
@@ -72,6 +95,8 @@ export function coerceWorkflowStatus(s) {
72
95
  return 'failed';
73
96
  case 'killed':
74
97
  return 'killed';
98
+ case 'parked':
99
+ return 'awaiting approval';
75
100
  default:
76
101
  return 'running';
77
102
  }
@@ -47,6 +47,8 @@ import { publishEngineAgentPanelEvent } from './engineAgentPanelStore.js';
47
47
  import { rowIdTail } from './workflow.js';
48
48
  import { TERMINAL_FLEET_TASK_STATUSES } from './fleet/fleetProjection.js';
49
49
  import { DEFAULT_SESSION_KEY } from './sessionSlot.js';
50
+ // L-215+(0.65.0;core [6908]):「非成功终局」的单铸谓词(与 notifications 的同名位同源)。
51
+ import { isTerminalNotSuccess } from './runTerminal.js';
50
52
  /** 行从投影集消失多久之后按 completed 兜底 settle(重连空窗 / snapshot 清表远小于此)。 */
51
53
  export const ABSENT_SETTLE_MS = 30_000;
52
54
  /** REF-CC-045(fleet2-07):settled 条目的回收期 —— settle 后这么久仍未再被投影 ⇒ 台账整条
@@ -158,7 +160,10 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
158
160
  publishEngineAgentPanelEvent({
159
161
  kind: 'end',
160
162
  taskId,
161
- isError: status === 'failed' || status === 'killed',
163
+ // L-215② 族扫(0.65.0):与 `notifications.enqueueBgChildNotification` `isError`
164
+ // **同一个病形**(内联两词 ⇒ core [6908] 的 `blocked` 漏成「成功」)。两处一次改齐,
165
+ // 读同一个单铸谓词;只修当格 = 下一次加词又漏一处。
166
+ isError: isTerminalNotSuccess(status),
162
167
  });
163
168
  }
164
169
  seenMap.set(taskId, {
@@ -58,10 +58,18 @@ export declare const ASK_ORIGIN_WORDS: readonly string[];
58
58
  /**
59
59
  * 一个 `origin` 词 → 一句人话。**唯一铸点**(三端共用;端零自拼)。
60
60
  *
61
+ * @param origin 帧/行上的 `origin` 词(开集;非串/空串走兜底)
62
+ * @param message 🆕 **0.65.0 additive 第二参**(B-080③):引擎在这只调用上给的散文
63
+ * (`PermissionResult.message` / 卡上的同名位)。**只在 {@link ORIGIN_PREFERS_ENGINE_MESSAGE}
64
+ * 的词上优先**,其余词一字不读 —— 理由写在那张表的头注。缺席 / 非串 / 空白串 ⇒ 行为与本参
65
+ * 出现之前**逐字节相同**(表里那一句中性定义句)。
66
+ *
61
67
  * 🔴 表外词的那一句说的是「**这个词比这一端新**」,不是「坏记录」——理由见模块顶注(server 只判
62
68
  * 非空串,core 加词当天合法的帧就带着它到达)。原样带上那个词,并明说这次仍然是在问人:
63
69
  * 读不懂出身**不改变**这只 ask 要人回答这件事。
64
70
  * 🔴 非串 / 空串:同走兜底但词位渲 `(none)` —— 「没报出身」与「报了一个读不懂的出身」在这一句里
65
71
  * 不必分家(两者对用户的下一步相同:照常回答这只 ask),但都**不许**被折成十一词里的任何一个。
72
+ * 🔴 `message` 是引擎/工具产文,**只渲不回喂模型**(与卡上其余散文位同一条纪律);本铸点
73
+ * **不截断不改写**它 —— 显示封顶归端(它才知道自己的行宽)。
66
74
  */
67
- export declare function askOriginDetail(origin: unknown): string;
75
+ export declare function askOriginDetail(origin: unknown, message?: unknown): string;
@@ -90,34 +90,76 @@ export const ASK_ORIGIN_WORDS = Object.freeze([
90
90
  * · `shell_gate_tighten` / `safety_tighten`:两条 tighten 分成两个词,正是为了说出**哪一层**
91
91
  * 引擎逻辑提的问(粗粒度 shellGate 教条 vs 调用的显式事实:egress 标 / 不可逆标 / 写保护);
92
92
  * · `org_rule` / `ask_rule`:组织的规则 vs 这个人自己的常驻 ask 行。
93
+ *
94
+ * ── 🔴 0.65.0 三句订正(B-080③;core `dist/core/ask-origin.d.ts` 真字节)───────────────────────
95
+ * 三句修前各自漂开了上游的定义,而漂的方向都是「把引擎真正说的事换成一件别的事」:
96
+ * · **`unresolvable`** 修前渲「门拿不定主意」—— core:36-38 逐字是「the call is MARKED by an
97
+ * ancestor (an inherited approver-unavailable float, or a durable mandate floated down because
98
+ * this task can park it): it must reach the park with no synchronous decision-maker in between」。
99
+ * 那不是犹豫,是**祖先盖的标记**+一条硬路径约束;渲成「拿不定主意」会让人去找一个不存在的
100
+ * 「让门自己拿主意」的配置。
101
+ * · **`shell_gate_tighten`** 修前渲「**每一条** shell 命令都问」—— 过度陈述。core:58-59 逐字是
102
+ * 「raised the ask over a tier the COARSE `shellGate` doctrine installed」:问的是**这一档以上**的,
103
+ * 不是全部。
104
+ * · **`rule_store_unavailable`** 修前渲固定的「规则店读不出来」—— 而这一个词 core 7.9.0 起盖的是
105
+ * **两种机制**(store 读失败 **或** tightening lexer 的 `unreadable`:展开里有读不懂的词 / 引号未
106
+ * 闭合 / 语法错,core:44-51),core CHANGELOG 7.9.1 逐字交代处置:「a card that hard-codes store
107
+ * wording for the origin should read the message」。⇒ 本铸点改成**引擎的 `message` 优先**(见
108
+ * {@link askOriginDetail} 的 additive 第二参),表里留的那一句改成两种机制都成立的中性句。
93
109
  */
94
110
  const ASK_ORIGIN_SENTENCES = Object.freeze({
95
111
  content_question: 'the tool itself asked you a question',
96
- unresolvable: 'the gate could not decide on its own, so it asks',
112
+ unresolvable: 'an ancestor marked this call (an inherited approver-unavailable float, or a durable mandate): it must reach the park with no synchronous decision-maker in between',
97
113
  org_unavailable: 'the organization policy was unavailable, so this call asks',
98
114
  org_rule: 'an organization rule asks about this call',
99
- rule_store_unavailable: 'the rule store was unavailable, so this call asks',
115
+ rule_store_unavailable: 'this call could not be checked against your standing deny/ask rules, so it asks',
100
116
  hook: 'a PreToolUse hook asked about this call',
101
117
  ask_rule: 'a persisted ask rule matches this call',
102
118
  denial_limit_fallback: 'the auto-mode classifier hit its denial limit and handed this call back to you',
103
- shell_gate_tighten: 'this deployment asks about every shell command at this gate setting',
119
+ shell_gate_tighten: 'this deployment asks about shell commands the coarse shell gate rates above this tier',
104
120
  safety_tighten: 'the gate tightened on this call’s own facts (network egress, irreversibility, or a protected write)',
105
121
  policy: 'this deployment’s permission policy asks about this call',
106
122
  });
123
+ /**
124
+ * 哪些 `origin` 词的一句人话应当**让位给引擎自己的 `message`**(闭集;0.65.0 新铸)。
125
+ *
126
+ * 🔴 **不是「有 message 就用 message」**:绝大多数出身词的那一句是本包写的、对每一只同出身的 ask
127
+ * 都成立的**定义句**,而引擎的 `message` 是**这一只**调用的拒因/疑因散文 —— 两者不同类,
128
+ * 无差别地拿 message 顶掉定义句会把「谁问的」换成「引擎这一次写了什么」。
129
+ * 🔴 本集只收**一个词盖了多种机制**的那一类:`rule_store_unavailable` 的两条腿(店读不出来 /
130
+ * 这条命令对不上你的规则行)对人的下一步不同,而只有引擎的 message 分得出来。
131
+ * 加员 = 上游又把一个词做成了多机制合并词,必须同批带坐标。
132
+ */
133
+ const ORIGIN_PREFERS_ENGINE_MESSAGE = new Set(['rule_store_unavailable']);
107
134
  /**
108
135
  * 一个 `origin` 词 → 一句人话。**唯一铸点**(三端共用;端零自拼)。
109
136
  *
137
+ * @param origin 帧/行上的 `origin` 词(开集;非串/空串走兜底)
138
+ * @param message 🆕 **0.65.0 additive 第二参**(B-080③):引擎在这只调用上给的散文
139
+ * (`PermissionResult.message` / 卡上的同名位)。**只在 {@link ORIGIN_PREFERS_ENGINE_MESSAGE}
140
+ * 的词上优先**,其余词一字不读 —— 理由写在那张表的头注。缺席 / 非串 / 空白串 ⇒ 行为与本参
141
+ * 出现之前**逐字节相同**(表里那一句中性定义句)。
142
+ *
110
143
  * 🔴 表外词的那一句说的是「**这个词比这一端新**」,不是「坏记录」——理由见模块顶注(server 只判
111
144
  * 非空串,core 加词当天合法的帧就带着它到达)。原样带上那个词,并明说这次仍然是在问人:
112
145
  * 读不懂出身**不改变**这只 ask 要人回答这件事。
113
146
  * 🔴 非串 / 空串:同走兜底但词位渲 `(none)` —— 「没报出身」与「报了一个读不懂的出身」在这一句里
114
147
  * 不必分家(两者对用户的下一步相同:照常回答这只 ask),但都**不许**被折成十一词里的任何一个。
148
+ * 🔴 `message` 是引擎/工具产文,**只渲不回喂模型**(与卡上其余散文位同一条纪律);本铸点
149
+ * **不截断不改写**它 —— 显示封顶归端(它才知道自己的行宽)。
115
150
  */
116
- export function askOriginDetail(origin) {
151
+ export function askOriginDetail(origin, message) {
117
152
  // 🔴 自有属性判据,理由与 `gateDeniedByDetail` 逐字相同(冻结不移除原型)。
118
153
  const known = typeof origin === 'string' && Object.hasOwn(ASK_ORIGIN_SENTENCES, origin)
119
154
  ? ASK_ORIGIN_SENTENCES[origin]
120
155
  : undefined;
156
+ // 🔴 让位只在**多机制合并词**上发生,且只在 message 真是一句非空散文时:引擎没给 / 给的是坏值
157
+ // ⇒ 回落表里那一句中性定义句(绝不渲半句、绝不渲空)。
158
+ if (typeof origin === 'string' && ORIGIN_PREFERS_ENGINE_MESSAGE.has(origin)) {
159
+ const spoken = typeof message === 'string' ? message.trim() : '';
160
+ if (spoken.length > 0)
161
+ return spoken;
162
+ }
121
163
  if (known !== undefined)
122
164
  return known;
123
165
  const word = typeof origin === 'string' && origin.length > 0 ? origin : '(none)';
@@ -186,7 +186,28 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
186
186
  lastRoundDecided = resolution.progress === true && !sameCall;
187
187
  lastRoundPresentedCard = resolution.presented; // 每次调用的真回执:规则直决 / 取件失败 = false ⇒ 触顶时收场卡照呈
188
188
  progressedSinceReattach = false;
189
- const seq = led.lastSeq();
189
+ // ── B-070 / L-200(0.65.0):**换挂** —— 这一轮的 decide 200 说了「受理了,续跑在另一条 run 上」──
190
+ // sdk README §9.0.0 逐字:workflow 车道的受理形回执带的是**宿主新铸**的 run id(「poll 它,
191
+ // 不要盯那张卡」)。修前这里恒用原 `taskId.current` 重挂 ⇒ 旧 run 上再也不会有推进帧,
192
+ // 预算一路烧到 hop 上限、终帧是一句与真因无关的话。判据在 `readDecideReceipt`(两件合取)
193
+ // 与 `resolvePark`(还要与当前 taskId 不同)里,这里只执行。
194
+ // 🔴 **换挂必须同时丢掉 `lastEventId`**:durable seq 是 **per-run** 的序号,拿旧 run 的序号去
195
+ // 续读新 run 是跨命名空间取数([same-name-different-meaning-crosses-layers])——
196
+ // 要么被 server 拒,要么静默跳过新 run 的开头(那正是本 bug 的另一种死法)。
197
+ // 🔴 台账里**跟着 run 走**的那几格同批清掉(`forgetRunScopedState`,逐格理由见该动词头注):
198
+ // ① seq 游标 —— 不清的话,下一轮若没有换挂,`led.lastSeq()` 会把**上一条 run** 的序号带回
199
+ // 新 run 的续读上,同一个跨命名空间取数换个时点再犯一次;
200
+ // ② 🔴 **已渲 call 集 + 两张 args 快照表** —— 异源复审 [high] 实撞的安全格:`toolCallId`
201
+ // 不保证跨 run 唯一,而 `routeToolStart` 在 `markStarted` 判重后直接早退,新 run 的
202
+ // `noteGatedStart` 于是不执行 ⇒ 卡面渲**旧 run 的入参**,而 decide 回显**新 pending 行**
203
+ // 的 D-1 绑定:人看见 A、批准的是 B。
204
+ // 「这一只 ask 的历史」(已决身份 / 扣留帧 / 同因计数)**刻意保留** —— 它们正是换挂的理由。
205
+ const handoff = resolution.handoffTaskId;
206
+ if (handoff !== undefined) {
207
+ taskId.current = handoff;
208
+ led.forgetRunScopedState();
209
+ }
210
+ const seq = handoff !== undefined ? undefined : led.lastSeq();
190
211
  stream = deps.runsEvents(taskId.current, {
191
212
  ...(seq !== undefined ? { lastEventId: seq } : {}),
192
213
  ...(opts?.signal ? { signal: opts.signal } : {}),
@@ -54,6 +54,30 @@ export interface GateLedger {
54
54
  * re-attach 从一个陈旧位置重放,把已经决断的 park 又送一遍(实测:消费到 16 却 attach from 8)。 */
55
55
  noteSeq(ev: AgentEvent): void;
56
56
  lastSeq(): string | undefined;
57
+ /**
58
+ * 🔴 **换挂到另一条 run 时,把「跟着 run 走」的那几格忘掉**(B-070 / L-200,0.65.x)。
59
+ *
60
+ * workflow 车道的 decide 受理形把续跑交给**宿主新铸**的 run(sdk README §9.0.0),于是同一本
61
+ * turn 级台账第一次要跨**两条 run** 活着。台账里有两类记账,它们的射程不同:
62
+ *
63
+ * **① 跟着 run 走 ⇒ 本动词清掉**:
64
+ * · `lastSeq` —— durable seq 是 **per-run** 的序号,带着旧 run 的号去续读新 run 是跨命名空间
65
+ * 取数([same-name-different-meaning-crosses-layers]);
66
+ * · **`markStarted` 的已渲 call 集**与**两张 args 快照表**(gated 的与在飞的)——
67
+ * 🔴 **这一条是安全格**(异源复审 [high] 实撞):`toolCallId` **不保证跨 run 唯一**,而
68
+ * `routeToolStart` 在 `markStarted` 判重后**直接早退**(`skip`),`noteGatedStart` 不再执行。
69
+ * ⇒ 新 run 复用同一个 callId 时,卡面渲的是**上一条 run 的入参快照**,而 decide 回显的是
70
+ * **新 pending 行**的 D-1 绑定 —— 人看见 A、批准的是 B。这正是「两处各持一半事实」那一族
71
+ * 里代价最高的一格,所以换挂那一拍必须把这三张表一起清掉,让新 run 的 `tool_start`
72
+ * 重新走一遍完整的渲染 + 记账路径。
73
+ *
74
+ * **② 属于「这一只 ask 的历史」⇒ 刻意保留**(它们正是换挂的**理由**,清掉就自相矛盾):
75
+ * · `decidedGates` / `lastFsOrShellGatedCall` —— 「这张 park 已经被决断过」的身份留痕,
76
+ * #110 那条「重放的 park 不是失败」的判据靠它;
77
+ * · 扣留帧表(`heldAskEnds`)—— 本 turn 尚未吐出的帧,「一帧不丢」靠它;
78
+ * · 同因连续计数与批级判据 —— 它们数的是「这一只门重放了几次」,与流挂在哪条 run 上无关。
79
+ */
80
+ forgetRunScopedState(): void;
57
81
  /** 首见 ⇒ true(并记账);durable 重放的已渲 call ⇒ false。 */
58
82
  markStarted(callId: string): boolean;
59
83
  /** 🔴 存 **tool_start 当拍的 `args` 快照**而非帧引用(与拆分前 `askArgsByCall` 逐字同语义;
@@ -161,6 +161,14 @@ export function createGateLedger() {
161
161
  lastSeq() {
162
162
  return seq;
163
163
  },
164
+ forgetRunScopedState() {
165
+ seq = undefined;
166
+ // 🔴 三张 per-call 表一起清(理由见接口头注的安全格):`toolCallId` 不保证跨 run 唯一,
167
+ // 留着任何一张都会让新 run 的同名 call 走进「渲旧入参、决新绑定」那条路。
168
+ startedCalls.clear();
169
+ gatedStartArgsByCall.clear();
170
+ startArgsByCall.clear();
171
+ },
164
172
  markStarted(callId) {
165
173
  if (startedCalls.has(callId))
166
174
  return false;
@@ -649,8 +649,20 @@ export class HitlBridge {
649
649
  attempts++;
650
650
  try {
651
651
  const r = await this.client.approvals.decide(sessionId, decision, opts);
652
- // A successful decide resumes the SAME durable stream; the gate clears on the next running arm.
653
- this.active = null;
652
+ // 🔴 **B-070(0.65.0):200 是投递受理,不是「门已解决」——所以这里不再清 `active`。**
653
+ // sdk README §9.0.0 逐字:「workflow 车道的 200 只是**投递受理** —— 子代的 checkpoint 仍
654
+ // pending,`/v1/approvals` 上那张卡**可能还在**。据 200 立刻把卡从 UI 抹掉,用户会看到
655
+ // 一张『批过了却还在』的幽灵卡」;core `runner/contracts.d.ts:1492` 同向(这一步
656
+ // **launches the run that re-invokes**)。修前这一行正上方的注释自己写着「the gate clears
657
+ // on the next running arm」—— 而代码在同一拍就把它清了,注释与码互相矛盾,矛盾的那一半
658
+ // 是码。
659
+ // ⇒ 判据锚换到**真正决定结果的量**上:`active` 由 {@link HitlBridge.observe} 在**流上的下一条
660
+ // running 臂 / 终态臂**上清([anchor-on-the-deciding-quantity])。那条腿修前就在
661
+ // (`observe` 的 `text`/`tool_start`/`turn_end`/`done`/`failed` 五臂),本批只是把抢跑的
662
+ // 那一行拿掉,没有新增任何清除路径。
663
+ // ⚠️ 影响面如实说:`active` 今天只在宿主真的驱动 `observe()` 时才 latch(两处生产调用点都
664
+ // 不驱动,见 `reviewPlan` 顶注),所以这一行的现实效果 = 对已驱动 observe 的宿主
665
+ // (desktop/web 的座位层)不再谎报「门没了」;不驱动的宿主逐字节不变。
654
666
  return r;
655
667
  }
656
668
  catch (e) {
@@ -17,6 +17,7 @@ import { type GateCurrentPending, type HitlFailureStage } from './hitlBridge.js'
17
17
  import { type QuestionAnswer } from '../liveQuestionStore.js';
18
18
  import type { AskAnsweredOutput, GateLedger } from './gateLedger.js';
19
19
  import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
20
+ import { type DecideReceiptView } from '../decideReceipt.js';
20
21
  /**
21
22
  * L-80(2026-09-03,[6215];#357 复发):**连续非进展轮**上限 —— 数的是「壳再附着之后那段流里一个
22
23
  * host 推进帧都没有」的轮次,**不是** park 次数。修前这里是 `24` 且按每次 park 递增
@@ -60,10 +61,15 @@ export declare const MAX_TOTAL_PARKS = 64;
60
61
  */
61
62
  export declare const GATE_FAILURE_CODES: readonly ["no_pending", "binding_mismatch", "wrong_gate", "bad_plan_edit", "empty_answer"];
62
63
  export type GateFailureCode = (typeof GATE_FAILURE_CODES)[number];
63
- export type GateOutcome = {
64
+ export type GateOutcome =
65
+ /** `receipt`(B-070 / L-200,0.65.0):这次 decide 的 **200 回执读数** —— 与 fs 腿的
66
+ * `FsApprovalOutcome.receipt` **同形同源**(同形存量清剿:两条决断腿一次改齐)。语义与缺席纪律
67
+ * 逐字见 `decideReceipt.ts` 顶注;🔴 它**不是**「门已解决」的证据(200 只是投递受理)。 */
68
+ {
64
69
  kind: 'decided';
65
70
  gatedCallId?: string | undefined;
66
71
  answered?: AskAnsweredOutput | undefined;
72
+ receipt?: DecideReceiptView | undefined;
67
73
  } | {
68
74
  kind: 'aborted';
69
75
  gatedCallId?: string | undefined;
@@ -99,17 +105,32 @@ export declare function toAnsweredOutput(questions: unknown[], answer: QuestionA
99
105
  /** 外环决断的两种出路(见文件头注)。 */
100
106
  /** resolvePark 的续流判决:`progress` 位 = 这一轮决断真落地了一次(驱动侧不据此复位计数 —— 复位只看
101
107
  * 下一段流有没有 host 推进帧);非进展轮携 `reason`(收场文案用)。 */
102
- export type ParkResolution = {
108
+ export type ParkResolution =
109
+ /**
110
+ * `handoffTaskId`(B-070 / L-200,0.65.0):🔴 **这一轮之后该盯哪条 run**。
111
+ *
112
+ * sdk README §9.0.0 的 workflow 车道逐字:受理形 200 上的 `taskId` 是「**宿主新铸**的 run id
113
+ * (poll 它,**不要盯那张卡**)」。修前重挂腿恒用**原** taskId 重挂 `runs.events` —— 而那条 run
114
+ * 已经把续跑交给了另一条,于是旧流上再也不会有推进帧,预算一路烧到 hop 上限,终帧还是一句
115
+ * 与真因无关的话。
116
+ * 🔴 **缺席 = 没有换挂**(终局形 / 幂等回放形 / 老 server / 这一轮压根没 decide 成功):调用方
117
+ * **照旧用原 taskId**;绝不把缺席折成任何别的意思。
118
+ * 🔴 **换挂时 `lastEventId` 必须丢掉**:durable seq 是 **per-run** 的,拿旧 run 的序号去续读新
119
+ * run 是跨命名空间取数([same-name-different-meaning-crosses-layers])。调用点有断言。
120
+ */
121
+ {
103
122
  kind: 'reattach';
104
123
  progress: true;
105
124
  presented: boolean;
106
125
  gatedCallId?: string | undefined;
126
+ handoffTaskId?: string | undefined;
107
127
  } | {
108
128
  kind: 'reattach';
109
129
  progress: false;
110
130
  reason: string;
111
131
  presented: boolean;
112
132
  gatedCallId?: string | undefined;
133
+ handoffTaskId?: string | undefined;
113
134
  } | {
114
135
  kind: 'failsoft';
115
136
  events: readonly AgentEvent[];
@@ -5,6 +5,8 @@ import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
5
  import { observeCancelByDeny } from './hitlHostSurface.js';
6
6
  import { flushHeldWithInterruptRewrite, isAskTool } from './frameRouter.js';
7
7
  import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
8
+ // B-070 / L-200(0.65.0):`/decide` 200 回执的单一读面(换挂句柄 + executionOutcome)。
9
+ import { readDecideReceipt } from '../decideReceipt.js';
8
10
  /**
9
11
  * L-80(2026-09-03,[6215];#357 复发):**连续非进展轮**上限 —— 数的是「壳再附着之后那段流里一个
10
12
  * host 推进帧都没有」的轮次,**不是** park 次数。修前这里是 `24` 且按每次 park 递增
@@ -246,6 +248,7 @@ parkGatedCallId, onPresented) {
246
248
  observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
247
249
  return { kind: 'aborted', gatedCallId };
248
250
  }
251
+ let declinedReceipt;
249
252
  try {
250
253
  // REF-CC-038(2026-08-02):`answer.answers` 已经是 `AskAnswer[]`(liveQuestionStore 的
251
254
  // `QuestionAnswer` 直用 hitlBridge 的 `AskAnswer` 为唯一源)—— 不再需要靠 cast 把两个
@@ -253,14 +256,22 @@ parkGatedCallId, onPresented) {
253
256
  const entries = answer.answers ?? [];
254
257
  const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
255
258
  if (hasContent) {
256
- await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined, pending);
257
- return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
259
+ const raw = await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined, pending);
260
+ // B-070:回执原样读一次(缺席即缺席);两条腿同形 —— fs 腿在 `toolApprovalWire` 的三处。
261
+ const receipt = readDecideReceipt(raw);
262
+ return {
263
+ kind: 'decided',
264
+ gatedCallId,
265
+ answered: toAnsweredOutput(questions, answer),
266
+ ...(receipt !== undefined ? { receipt } : {}),
267
+ };
258
268
  }
259
269
  else {
260
270
  // 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
261
- await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined, pending);
271
+ const raw = await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined, pending);
272
+ declinedReceipt = readDecideReceipt(raw);
262
273
  }
263
- return { kind: 'decided', gatedCallId };
274
+ return { kind: 'decided', gatedCallId, ...(declinedReceipt !== undefined ? { receipt: declinedReceipt } : {}) };
264
275
  }
265
276
  catch (e) {
266
277
  // REF-CC-033:HitlSafetyError.code 是 hitlBridge.ts 自己的闭集契约(见其类型头注),
@@ -558,9 +569,26 @@ export async function resolvePark(park, ctx) {
558
569
  if ('answered' in outcome && outcome.answered)
559
570
  led.rememberAnswer(outcome.gatedCallId, outcome.answered);
560
571
  }
572
+ // ── B-070:200 = 投递受理,续跑可能挂在**另一条 run** 上 ────────────────────────────────────
573
+ // workflow 车道的受理形回执带的是**宿主新铸**的 run id(sdk README §9.0.0:「poll 它,不要盯那张
574
+ // 卡」)。修前这里恒用原 `taskId` 重挂 `runs.events` ⇒ 旧流上再无推进帧,一路烧到 hop 上限。
575
+ // 🔴 判据是回执上的**两件合取**(`status:"resuming"` ∧ 非空 taskId,判在 `readDecideReceipt` 里),
576
+ // 而且**必须与当前 taskId 不同**才算换挂 —— 任务级车道的受理形回执带的就是同一条 run,
577
+ // 把它当「换挂」会白白丢掉 `lastEventId`(等于从头重放一遍这条流)。
578
+ const handoffTaskId = 'receipt' in outcome && outcome.receipt?.handoffTaskId !== undefined && outcome.receipt.handoffTaskId !== taskId
579
+ ? outcome.receipt.handoffTaskId
580
+ : undefined;
561
581
  const seq = led.lastSeq();
562
- hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
563
- return { kind: 'reattach', progress: true, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
582
+ hostLog('debug', handoffTaskId !== undefined
583
+ ? `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) the decide was ACCEPTED for delivery (status "resuming") and the engine handed the continuation to run ${handoffTaskId}; attaching runs.events(${handoffTaskId}) from the start (durable seq is per-run, so ${seq ?? 'the previous seq'} does not apply there)`
584
+ : `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
585
+ return {
586
+ kind: 'reattach',
587
+ progress: true,
588
+ presented: presentedThisRound,
589
+ gatedCallId: outcome.gatedCallId,
590
+ ...(handoffTaskId !== undefined ? { handoffTaskId } : {}),
591
+ };
564
592
  }
565
593
  /**
566
594
  * fail-soft 收场的事件序列:扣留帧一律走中断感知出口(件 B:五个排水出口同一形,零-park 硬门必挡,语义等价 no-op;