@sema-agent/client-core 0.28.0 → 0.29.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.
@@ -36,11 +36,14 @@
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, hasLocalQuestionResponder, } from '../liveQuestionStore.js';
39
+ import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionResponder, unregisterLocalQuestionResponder, hasLocalQuestionResponder, hasQuestionOverlayFor, } from '../liveQuestionStore.js';
40
40
  import { hostLog } from '../host.js';
41
41
  import { makeEngineWireClient } from '../engineWireSdk.js';
42
42
  import { engineWireTarget } from '../engineWireTarget.js';
43
43
  import { enqueuePlanReviewOutcome } from '../notifications.js';
44
+ import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
45
+ import { clearArmedGate, clearArmedGateFor, registerArmedGateFor, wasGateArmedFor } from './armedGateRegistry.js';
46
+ import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
44
47
  /** resume 是 SYNC 驱动到终态的(可能分钟级)——原 raw fetch 无超时;SDK Transport 恒有每请求
45
48
  * 超时,取 6h 宽上界(诚实预算:超过它基本是引擎僵死,超时文案与网络失败同走 outcome 通知)。 */
46
49
  const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
@@ -92,26 +95,32 @@ const armedPlanReviewTaskIds = new Set();
92
95
  export function _resetArmedPlanReviewsForTest() {
93
96
  armedPlanReviewTaskIds.clear();
94
97
  }
95
- /** 合成 questionId 的唯一铸口(短路臂与首次 arm 共用,别各铸各的)。 */
96
- function planReviewQuestionId(taskId) {
97
- return `plan-review:${taskId}`;
98
+ /* 合成 questionId 的唯一铸口:A-028.3 起收编进 `gateIdentity.planReviewQuestionId`(公面导出,
99
+ * 壳侧重开腿从此 import 同一铸口,不再手抄字面)。本文件只 import,不再自铸。 */
100
+ /** 首次 arm 的题面正句。 */
101
+ const ARM_QUESTION = 'The plan is ready for review. Approve it and start the implementation?';
102
+ /** 重开腿的题面正句(点明「这张卡就是占住会话的那件事」;标签/选项与首呈同源)。 */
103
+ const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
104
+ /** 题面唯一构造点 —— 首次 arm、重放重呈、重开三处共用,标签/选项描述不许各写一份。 */
105
+ function planReviewQuestions(question) {
106
+ return [
107
+ {
108
+ header: 'Plan review',
109
+ question,
110
+ options: [
111
+ { label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
112
+ { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
113
+ ],
114
+ multiSelect: false,
115
+ },
116
+ ];
98
117
  }
99
- /** 审批卡的题面 —— 首次 arm 与「重放 arm 重新弹卡」共用同一份,两处不许各写一份。 */
118
+ /** 审批卡的发布口 —— 首次 arm 与「重放 arm 重新弹卡」共用同一份(canonical 身份)。 */
100
119
  function publishPlanReviewCard(taskId) {
101
120
  publishQuestionFrame({
102
121
  type: 'question',
103
122
  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
- ],
123
+ questions: planReviewQuestions(ARM_QUESTION),
115
124
  });
116
125
  }
117
126
  /**
@@ -128,6 +137,11 @@ export function armPlanReviewApproval(result) {
128
137
  if (!engineWireTarget())
129
138
  return false;
130
139
  const { taskId } = result;
140
+ // 单活纪律(复审二轮,与 reopen 侧对偶):arm/重呈要发的 canonical 卡从此是这个 gate 唯一的
141
+ // 作答入口 —— 名下还在场的重开卡先退役(注销 responder + 撤帧)。不退役的话,durable 重放的
142
+ // arm 会在 fresh 重开卡还开着时再立一张 canonical 卡,双活双投递面从 arm 侧复发。
143
+ // (arm 臂是默认会话装配,退役也按默认键;多会话宿主的 arm 路径归后续工单。)
144
+ retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
131
145
  if (armedPlanReviewTaskIds.has(taskId)) {
132
146
  // REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,**不二次注册** responder。
133
147
  //
@@ -165,9 +179,15 @@ export function armPlanReviewApproval(result) {
165
179
  const decided = planReviewDecisionFromAnswer(answer);
166
180
  if (decided === 'dismissed') {
167
181
  // dismiss/无法判读:诚实缺席优先于编造默认值(REF-CC-026)——不静默驱动 decidePlanReview,
168
- // 卡的重开路径把决定权还给用户。
182
+ // 卡的重开路径把决定权还给用户。呈现台账的键**不清**:同一张卡再被重开就是真「reopened」。
169
183
  return { ok: true };
170
184
  }
185
+ // A-024.4(键粒度收口):plan 键 = `plan-review:<taskId>`,粒度是 taskId 不是 gate ——
186
+ // 决断一经递交,这个门的呈现史就消费掉;同 run 的**下一个** plan gate(approve 推进后
187
+ // 引擎可再 park 一个新 plan)必须读回首见,否则文案对一张从未呈现过的新卡说「reopened」。
188
+ // 递交后决定未生效的形(RB-471 族)读回首见只损失「reopened」一词 —— 首见文案零历史断言,
189
+ // 诚实方向安全。
190
+ clearArmedGate(planReviewQuestionId(taskId));
171
191
  // fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
172
192
  void decidePlanReview(taskId, decided);
173
193
  return { ok: true };
@@ -287,3 +307,138 @@ export async function decidePlanReview(taskId, decision) {
287
307
  hostLog('debug', `planReviewWire: outcome enqueue threw: ${String(e)}`);
288
308
  }
289
309
  }
310
+ /** 重开身份的进程内唯一尾缀(时基 + 随机段:两份包实例同时铸也不撞键 —— 撞键会被宿主 overlay
311
+ * 的去重集吃掉帧,重开静默变 no-op;所以刻意不用模块级计数器)。 */
312
+ function reopenIdSuffix() {
313
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
314
+ }
315
+ /**
316
+ * 每个 `(sessionKey, canonicalId)` **至多一个在场的重开 responder**(复审 [high]:此前每次重开
317
+ * 都新注册一个 responder 而不退役上一个 —— 重试/并发 409 会留下两张都能投递决断的活卡,
318
+ * 一张 approve 一张 reject 就是两次相互冲突的状态变更请求)。铸新卡前:退役上一张的 responder +
319
+ * 对它的帧 id 发 `question_complete` 撤卡;作答/退役后清账。
320
+ * 🔴 模块级单例(singleton-manifest 登记):两份实例 ⇒ 各退各的,跨份的旧卡退役不掉 ——
321
+ * 退化回修复前的重复活卡形。
322
+ */
323
+ const activeReopenResponders = new Map();
324
+ /** 退役 `(sessionKey, canonicalId)` 名下还在场的重开 responder(撤帧 + 注销 + 清账)。 */
325
+ function retireActiveReopen(sessionKey, canonicalId) {
326
+ const key = `${sessionKey}\u0000${canonicalId}`;
327
+ const prev = activeReopenResponders.get(key);
328
+ if (!prev)
329
+ return;
330
+ activeReopenResponders.delete(key);
331
+ try {
332
+ prev.unregister();
333
+ }
334
+ catch {
335
+ // 注销钩自身的意外 throw 不许挡住新卡铸造(退役面 fail-soft,新卡才是用户的把手)
336
+ }
337
+ publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: prev.questionId });
338
+ }
339
+ /** 测试钩:清空重开 responder 台账(跨用例状态)。 */
340
+ export function _resetActiveReopenRespondersForTest() {
341
+ activeReopenResponders.clear();
342
+ }
343
+ /**
344
+ * 重开某个 parked plan_review 的审批卡(409 自愈分诊树 `attemptActiveRunSelfHeal` 的
345
+ * `reopenPlanReview` 注入口的包内生产实现)。判决形/成文语义见 {@link ReopenCardVerdict}
346
+ * (`adapter/activeRunSelfHeal.ts`);本函数不产 `presented` 位 —— 包看不到像素,回执机制归
347
+ * 宿主端包装。绝不抛(turn 收尾路径)。
348
+ *
349
+ * 与 arm 臂的合成关系(A-028.4):首呈(done 帧)= `armPlanReviewApproval`;重开(409 撞锁)=
350
+ * 本函数。两臂共用同一份题面构造点/标签单源/决断三态判决与同一条 `decidePlanReview` 投递管道;
351
+ * 「铸新 questionId 还是复用同 id」由宿主去重语义作参数(见 {@link ReopenPlanReviewOpts})。
352
+ * firstSight 判据 = 呈现台账(`armedGateRegistry`,键 = canonical `plan-review:<taskId>`);
353
+ * 先查后记 —— 顺序决定判决正确性。
354
+ */
355
+ export function reopenPlanReviewCard(taskId, opts) {
356
+ try {
357
+ if (typeof taskId !== 'string' || taskId.length === 0)
358
+ return { reopened: false };
359
+ const sessionKey = opts?.sessionKey ?? DEFAULT_SESSION_KEY;
360
+ // 诚实边界:请求方会话没有 overlay 订阅者(headless / 尚未挂载)⇒ 返回 false,调用方如实说
361
+ // 「没能重开」。overlay 在场答的是「有没有一个 UI 会收到这一帧」;检查按 sessionKey 走 ——
362
+ // 别的会话挂着 overlay 不等于本会话的卡有地方渲(复审 [high]:默认键硬走会把帧投进别人的面)。
363
+ if (!hasQuestionOverlayFor(sessionKey))
364
+ return { reopened: false };
365
+ const canonicalId = planReviewQuestionId(taskId);
366
+ // 先查(firstSight)后记(铸卡即 arm)—— 顺序决定判决正确性;台账按 sessionKey 分账。
367
+ const firstSight = !wasGateArmedFor(sessionKey, canonicalId);
368
+ const mintFresh = opts?.mintFreshQuestionId !== false;
369
+ if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
370
+ // 重呈短路(与 arm 臂的重放短路同形):同一个 canonical 身份、同一份题面、同一个 responder。
371
+ // 卡还开着的宿主只是收到一帧同形重绘,卡已被 dismiss 的宿主拿回重开路径。
372
+ // 0.29.0 发包扫描门:本臂沿用 arm responder ⇒ opts.deliverDecision 不生效(JSDoc 成文例外),
373
+ // 传了就留痕 —— 静默吞注入口是契约不一致的形。
374
+ if (opts?.deliverDecision !== undefined) {
375
+ hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
376
+ }
377
+ // 先退役本会话名下还在场的**重开**卡(如此前走过 mintFresh 臂)—— 同一 gate 不留两张活卡。
378
+ retireActiveReopen(sessionKey, canonicalId);
379
+ publishQuestionFrameFor(sessionKey, {
380
+ type: 'question',
381
+ questionId: canonicalId,
382
+ questions: planReviewQuestions(ARM_QUESTION),
383
+ });
384
+ registerArmedGateFor(sessionKey, canonicalId);
385
+ return { reopened: true, firstSight };
386
+ }
387
+ // 复审 [high] 单活纪律(两轮收紧):铸新卡前退役**全部**旧作答入口 —— 同一个 gate 至多一张
388
+ // 能投递决断的卡,两张都活着时一张 approve 一张 reject = 两次相互冲突的状态变更请求。
389
+ // · 上一张重开卡:注销 responder + question_complete 撤帧(单活台账);
390
+ // · canonical(arm)卡:question_complete 撤帧**且按 id 注销 responder**(二轮 [high]:
391
+ // 撤帧只是 UI 动作,表里的 responder 不死,在途的迟到作答仍能经它投递)。canonical
392
+ // responder 死后,arm 重放走它自己的「陈旧武装态 ⇒ 整条重新 arm」自愈臂([2393] hitl-F3),
393
+ // 不会留下弹不出卡的死短路。
394
+ retireActiveReopen(sessionKey, canonicalId);
395
+ if (unregisterLocalQuestionResponder(canonicalId)) {
396
+ hostLog('debug', `planReviewWire: reopen retired the canonical responder for task ${taskId} (single-active discipline)`);
397
+ }
398
+ publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
399
+ const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
400
+ const deliver = opts?.deliverDecision ??
401
+ ((tid, decision) => {
402
+ // fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay(arm 臂同姿势);
403
+ // 失败处置/结果回植由 decidePlanReview 自带的 outcome 管道承担。
404
+ void decidePlanReview(tid, decision);
405
+ });
406
+ const activeKey = `${sessionKey}\u0000${canonicalId}`;
407
+ const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
408
+ unregister(); // 一次性(arm 臂同款):respond 侧 delete-before-invoke 之外的第二道钉
409
+ // 单活台账清账(只清**自己**的条目 —— 自己被更新的重开取代后,旧 responder 的迟到作答
410
+ // 不许把新卡的条目清掉)。
411
+ if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
412
+ activeReopenResponders.delete(activeKey);
413
+ }
414
+ const decided = planReviewDecisionFromAnswer(answer);
415
+ if (decided === 'dismissed') {
416
+ // 三态(REF-CC-026):dismiss/读不出不驱动 decidePlanReview;呈现台账不清 ——
417
+ // 这张卡再被重开就是真「reopened」。
418
+ return { ok: true };
419
+ }
420
+ // A-024.4:决断递交即消费呈现史(与 arm 臂 responder 同点,理由见那里)。
421
+ clearArmedGateFor(sessionKey, canonicalId);
422
+ try {
423
+ await deliver(taskId, decided);
424
+ }
425
+ catch (e) {
426
+ // 投递编排是注入方的职责面(缺省臂自带 outcome 管道不会走到这);这里只留痕,
427
+ // 绝不把宿主投递臂的 throw 冒进 overlay 的 respond 链。
428
+ hostLog('error', `planReviewWire: reopen decision delivery threw for task ${taskId}: ${String(e)}`);
429
+ }
430
+ return { ok: true };
431
+ });
432
+ activeReopenResponders.set(activeKey, { questionId, unregister });
433
+ registerArmedGateFor(sessionKey, canonicalId);
434
+ publishQuestionFrameFor(sessionKey, {
435
+ type: 'question',
436
+ questionId,
437
+ questions: planReviewQuestions(REOPEN_QUESTION),
438
+ });
439
+ return { reopened: true, firstSight };
440
+ }
441
+ catch {
442
+ return { reopened: false }; // 重开失败绝不炸 turn —— 调用方会如实说「没能重开」
443
+ }
444
+ }
@@ -69,10 +69,13 @@
69
69
  * 规则逐字节相同、更不是它的 id;下游「删除/撤销规则」面拿它当身份用 = 删错或删不掉。
70
70
  * ② **deny 归因通道**({@link ApprovalCardDenyDecision.reason}):两腿不同形 ——
71
71
  * durable `/decide` **有** reason 位 ⇒ 原文上 wire(包内按 server 4096 上限截断);
72
- * live 帧腿 `/v1/tool-approvals/:id/respond` 是三词闭集、**无** reason ⇒ 只 debug 留痕、不发、
73
- * 不自造侧信道。⏳ **记账:respond 端点的 reason 位候 server**(server 辖区,到货后本包侧
74
- * 只需在 opts 上加一位,窄化器 `denyReasonForWire` 已在)
75
- * 🔴 方向纪律:reason 只做归因,绝不参与裁决;缺席 现状字节不变。
72
+ * live 帧腿 `/v1/tool-approvals/:id/respond`:✅ **#229 已兑现**(0.29.0 respond-note 批,
73
+ * server ≥7.15.0)—— 端点收 **`note`**(不是 reason;与 durable `AskDecisionBody.note`
74
+ * 同词同源同一列 `decision_note`),deny reason 经 {@link RespondToolApprovalOpts.note} 上 wire
75
+ * 发送前置两道门:能力位 `approvalDecisionNote`(SDK 6.16 成文「位缺席就别发」)+ 2048 上限
76
+ * (server 超限响亮 400 且**连决断一起拒**,所以超限 ⇒ 整条不发 + 留痕,诚实缺席优先于
77
+ * 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
78
+ * 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
76
79
  */
77
80
  import { type HitlClientLike } from './hitlBridge.js';
78
81
  import type { RuleSuggestion, ToolApprovalRespondAck } from '@sema-agent/sdk';
@@ -350,6 +353,11 @@ export interface RespondToolApprovalOpts {
350
353
  updatedInput?: unknown;
351
354
  /** #225 件1:兑付键 —— 帧候选之一的**原文**(编排层已做表内核对与 deny 剥除)。 */
352
355
  persistRule?: string;
356
+ /** #229(server ≥7.15.0):回决备注 —— 与 durable 腿 `AskDecisionBody.note` **同词同源同一列**
357
+ * (`decision_note`,≤2048)。任何 decision 都可带(deny 的「为什么拒」正是审计面上最值钱的
358
+ * 一条);真落行与否看 ack 的 {@link surfaceToolApprovalFrameAndRespond} 消费的 `noteRecorded`。
359
+ * 🔴 编排层(本文件)只在能力位确认 + 上限内才填本位 —— 注入面原样透传,别自己再判。 */
360
+ note?: string;
353
361
  }
354
362
  export type RespondToolApprovalFn = (approvalId: string, decision: ToolApprovalRespondDecision, opts?: RespondToolApprovalOpts) => Promise<ToolApprovalRespondAck | void>;
355
363
  /**
@@ -380,13 +388,14 @@ export interface ToolApprovalFrameOutcome {
380
388
  * `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
381
389
  * 结构读(不依赖类型收窄),因为它同时服务 raw SSE 与 durable 回放两条入口。 */
382
390
  export declare function isToolApprovalFrame(ev: unknown): ev is ToolApprovalFrame;
383
- /**
384
- * 消费一张 `tool_approval` 帧:弹三选卡 → respond。引擎此刻同步阻塞在本帧上(fail-closed 全腿:
385
- * TTL 5min/abort/emit 失败⇒deny),所以本函数 await 卡决断是安全的(流上不会再来别的内容帧)。
386
- *
387
- * fail-closed:卡面不可用(print 模式无卡口/enqueue 失败)⇒ 立刻 respond deny,不让引擎
388
- * 干等 TTL。respond 自身失败(404=已过期/settled)⇒ 'unresolved'(诚实放行后续帧,引擎已自决)。
389
- *
390
- * @returns {@link ToolApprovalFrameOutcome} —— 决断 + server ack(ack 缺席 = 未知,不是 false)。
391
- */
392
- export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal): Promise<ToolApprovalFrameOutcome>;
391
+ /** 帧腿的宿主车道参数(#229 respond-note 批,0.29.0)。 */
392
+ export interface ToolApprovalFrameLaneOpts {
393
+ /**
394
+ * server 能力位 `capabilities.approvalDecisionNote` 的读数(宿主从自己的 caps 缓存供给)。
395
+ * 🔴 SDK 6.16 成文:**位缺席就别发 `note`** —— 老 server 对未知请求键静默忽略且照回 200,
396
+ * 「这台不认识 note」与「记上了」在响应上不可分,发了只造「已留档」的错觉。
397
+ * 缺席/false ⇒ 不发(fail-closed 到「不发」侧;决断本身照常送达,现状字节不变)。
398
+ */
399
+ approvalDecisionNoteCapable?: boolean;
400
+ }
401
+ export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal, lane?: ToolApprovalFrameLaneOpts): Promise<ToolApprovalFrameOutcome>;
@@ -69,16 +69,20 @@
69
69
  * 规则逐字节相同、更不是它的 id;下游「删除/撤销规则」面拿它当身份用 = 删错或删不掉。
70
70
  * ② **deny 归因通道**({@link ApprovalCardDenyDecision.reason}):两腿不同形 ——
71
71
  * durable `/decide` **有** reason 位 ⇒ 原文上 wire(包内按 server 4096 上限截断);
72
- * live 帧腿 `/v1/tool-approvals/:id/respond` 是三词闭集、**无** reason ⇒ 只 debug 留痕、不发、
73
- * 不自造侧信道。⏳ **记账:respond 端点的 reason 位候 server**(server 辖区,到货后本包侧
74
- * 只需在 opts 上加一位,窄化器 `denyReasonForWire` 已在)
75
- * 🔴 方向纪律:reason 只做归因,绝不参与裁决;缺席 现状字节不变。
72
+ * live 帧腿 `/v1/tool-approvals/:id/respond`:✅ **#229 已兑现**(0.29.0 respond-note 批,
73
+ * server ≥7.15.0)—— 端点收 **`note`**(不是 reason;与 durable `AskDecisionBody.note`
74
+ * 同词同源同一列 `decision_note`),deny reason 经 {@link RespondToolApprovalOpts.note} 上 wire
75
+ * 发送前置两道门:能力位 `approvalDecisionNote`(SDK 6.16 成文「位缺席就别发」)+ 2048 上限
76
+ * (server 超限响亮 400 且**连决断一起拒**,所以超限 ⇒ 整条不发 + 留痕,诚实缺席优先于
77
+ * 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
78
+ * 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
76
79
  */
77
80
  import { DEFAULT_DENY_REASON, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, } from './hitlBridge.js';
78
81
  import { hostLog } from '../host.js';
79
82
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
80
83
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
81
84
  import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded } from './hitlHostSurface.js';
85
+ import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
82
86
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
83
87
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
84
88
  export function isFsApprovalGate(gate) {
@@ -242,7 +246,8 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
242
246
  return { kind: 'failed', gatedCallId, reason: 'gate has no tool input payload' };
243
247
  }
244
248
  const bridge = new HitlBridge(deps.client, taskId);
245
- const callKey = gatedCallId ?? taskId;
249
+ // A-028.3:卡身份键经 gateIdentity 唯一铸口(呈现台账/壳侧重开腿同源,字面不再各铸各的)。
250
+ const callKey = approvalCallKey(gatedCallId, taskId);
246
251
  const card = await surfaceApprovalCard({
247
252
  toolName,
248
253
  args,
@@ -408,6 +413,9 @@ export function readToolApprovalRespondAck(v) {
408
413
  ...(typeof o.ruleRefusal === 'string' && o.ruleRefusal !== ''
409
414
  ? { ruleRefusal: o.ruleRefusal }
410
415
  : {}),
416
+ // #229(sdk 6.16.0):这次回决的 note 有没有**真落进持久行**。缺席 ≠ false(未带 note 的回决/
417
+ // 老 server 字段省略);非布尔降缺席 —— 它决定「审计理由记没记上」的诚实告知。
418
+ ...(typeof o.noteRecorded === 'boolean' ? { noteRecorded: o.noteRecorded } : {}),
411
419
  };
412
420
  }
413
421
  /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
@@ -497,7 +505,10 @@ function subagentBadgeFor(frame) {
497
505
  *
498
506
  * @returns {@link ToolApprovalFrameOutcome} —— 决断 + server ack(ack 缺席 = 未知,不是 false)。
499
507
  */
500
- export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal) {
508
+ /** respond `note` 位的 server 上限(server `MAX_DECISION_NOTE_CHARS` = 2048,与 durable
509
+ * `AskDecisionBody.note` 同一列;超限 server **响亮 400 且连决断一起拒**,所以本包发送前有界)。 */
510
+ const MAX_RESPOND_NOTE_CHARS = 2048;
511
+ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal, lane) {
501
512
  const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'Write';
502
513
  // 工具入参:流上 tool_start.args(完整一手源)> 帧 args(redacted+16KiB 帽)> message 兜 path。
503
514
  let args = streamArgs !== undefined && streamArgs !== null
@@ -527,7 +538,8 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
527
538
  const card = await surfaceApprovalCard({
528
539
  toolName,
529
540
  args: args,
530
- callKey: `hitl-frame:${frame.approvalId}`,
541
+ // A-028.3:live 帧腿的卡身份键经 gateIdentity 唯一铸口(帧上无 gatedCallId,以 approvalId 铸)。
542
+ callKey: liveFrameCallKey(frame.approvalId),
531
543
  ...(signal ? { signal } : {}),
532
544
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
533
545
  ...(wireNote !== undefined ? { wireNote } : {}),
@@ -554,24 +566,31 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
554
566
  if (card.kind === 'failed') {
555
567
  hostLog('debug', `liveToolApprovalWire: approval card unavailable (${card.reason}) — fail-closed deny for ${frame.approvalId}`);
556
568
  }
557
- // #144 半场记账(0.28.0):respond 端点(POST /v1/tool-approvals/:id/respond)的体是**三词闭集**
558
- // (`allow|allow_session|deny`,server `parseToolApprovalResponse`)—— wire 上**没有 reason 位**。
559
- // 所以这条腿上的拒因只留痕、**不发**:塞进 `updatedInput` 会把归因喂回工具入参面,另开一条消息
560
- // /header 是自造侧信道,两者都比「暂时没有这个位」更坏。durable `/decide` 腿有位、已透传(上面
561
- // 那条腿)。⏳ 记账:respond 端点的 reason 位候 server(本包侧零改动即可消费,窄化器已在)。
562
- // 🔴 留痕只写**元数据**(在场 + 长度),绝不把拒因正文拷进日志(对抗复审命中):
563
- // 正文是自由文本(人打的拒绝说明 / 规则原文),可能带路径、命令、被拒内容的片段,而 `hostLog`
564
- // 的落点是宿主任意注入的 sink(文件/远端都可能)—— 把它原样拷过去既是外溢面,也给了
565
- // 换行/控制字符注入日志行的口子。**而且正文本来就在宿主手里**:这条腿上的 reason 正是宿主
566
- // 自己的卡口返回的,回喂给它自己的 sink 零诊断增量。要看正文的场景是 durable 腿,那一路它
567
- // 真的上了 wire。
568
- // 0.28.0 发版扫描 F4:本腿留痕**不借** denyReasonForWire —— 那个窄化器的超限日志讲的是
569
- // 「413 会丢掉决断」,对 respond 端点是假话(它根本不收 reason,无 413 可言);且截断后 len
570
- // 恒 4096,掩盖真实丢弃量。本地判空白+报**原始**长度,只说这条腿真实发生的事:归因整条没发。
569
+ // #229 兑现(0.29.0 respond-note 批;此前 0.28.0 记账「候 server」,server 7.15.0 已到货):
570
+ // respond 端点收 **`note`**(不是 reason —— durable `AskDecisionBody.note` 同词同源同一列
571
+ // `decision_note`),任何 decision 都可带。deny reason 经它上 wire,发送前置两道门:
572
+ // · 能力位(`lane.approvalDecisionNoteCapable === true`)—— SDK 6.16 成文「位缺席就别发」:
573
+ // server 静默忽略未知键照回 200,「没听懂」与「记上了」不可分,发了只造「已留档」错觉;
574
+ // · 上限(≤{@link MAX_RESPOND_NOTE_CHARS}=2048)—— server 超限**响亮 400 且连决断一起拒**,
575
+ // 而备注是决断的补充面,绝不许它把决断本身打失败。超限 **整条不发** + 留痕
576
+ // (诚实缺席优先于静默截断 —— 「一条被悄悄砍半的审计理由比没有理由更坏」,server 同款立场)。
577
+ // 🔴 留痕纪律不变:只写**元数据**(在场 + 长度 + 没发的原因),绝不把正文拷进日志 ——
578
+ // 正文是宿主自己卡口返回的自由文本,回喂宿主 sink 零诊断增量,还是外溢/注入面。
579
+ let note;
571
580
  if (card.kind === 'deny' && typeof card.reason === 'string' && card.reason.trim() !== '') {
572
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} card supplied a deny reason (len=${card.reason.length}) but the respond ` +
573
- 'endpoint carries no reason slot (three-word closed set) attribution not sent on this lane, no side channel invented ' +
574
- '(content not logged: it is host-authored free text and the host already holds it)');
581
+ if (lane?.approvalDecisionNoteCapable !== true) {
582
+ hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} card supplied a deny reason (len=${card.reason.length}) but the ` +
583
+ 'approvalDecisionNote capability is not confirmed for this engine note not sent (an unknown key would be ' +
584
+ 'silently swallowed by an older server while still acking 200)');
585
+ }
586
+ else if (card.reason.length > MAX_RESPOND_NOTE_CHARS) {
587
+ hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} deny reason exceeds the decision_note cap ` +
588
+ `(len=${card.reason.length} > ${MAX_RESPOND_NOTE_CHARS}) — note not sent at all (the server rejects the WHOLE ` +
589
+ 'respond with 400 over an oversize note, and a silently halved audit reason is worse than none)');
590
+ }
591
+ else {
592
+ note = card.reason;
593
+ }
575
594
  }
576
595
  try {
577
596
  // abort 后的 deny 仍要送达(引擎侧同款 cancel-by-deny)——不带已 aborted 的 signal。
@@ -592,6 +611,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
592
611
  ...(signal && !signal.aborted ? { signal } : {}),
593
612
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
594
613
  ...(persistRule !== undefined ? { persistRule } : {}),
614
+ ...(note !== undefined ? { note } : {}),
595
615
  });
596
616
  // 🔴 相关性门(对抗复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
597
617
  // 对不上的 ack(注入面串了别人的响应 / 坏 mock / raw-fetch 包装器复用了连接)如果照样被
@@ -619,6 +639,13 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
619
639
  hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} edited args were NOT forwarded (ack.updatedInputForwarded=false) — the tool runs on the ORIGINAL input`);
620
640
  surfaceEditNotForwarded();
621
641
  }
642
+ // #229:note 发了而 ack 说没落行(noteRecorded=false:纯活卡无行可落 / 店抖但裁决照常生效 /
643
+ // 并发同决议先落行)或位缺席(异常形:能力位为真却没回执)—— 审计面事实,decision 本身不受
644
+ // 影响,debug 留痕即可(不惊动用户:决断没丢,丢的只是理由的持久档)。
645
+ if (note !== undefined && ack !== undefined && ack.noteRecorded !== true) {
646
+ hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} decision note was sent but not persisted ` +
647
+ `(ack.noteRecorded=${String(ack.noteRecorded)}) — decision stood; the audit note did not land on the ask row`);
648
+ }
622
649
  return ack !== undefined ? { decision, ack } : { decision };
623
650
  }
624
651
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -182,6 +182,7 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
182
182
  export * from './adapter/downstream/eventToSdkMessage.js';
183
183
  export * from './adapter/downstream/terminalToSdkResult.js';
184
184
  export * from './adapter/runStream.js';
185
+ export * from './adapter/activeRunSelfHeal.js';
185
186
  export * from './request/taskRequest.js';
186
187
  export * from './clientSlice.js';
187
188
  export * from './host.js';
@@ -220,6 +221,9 @@ export * from './hitl/hitlHostSurface.js';
220
221
  export * from './hitl/toolApprovalWire.js';
221
222
  export * from './hitl/askGateWire.js';
222
223
  export * from './hitl/planReviewWire.js';
224
+ export * from './hitl/gateIdentity.js';
225
+ export * from './hitl/armedGateRegistry.js';
226
+ export * from './hitl/parkOwnership.js';
223
227
  export * from './hitl/approvalsFeed.js';
224
228
  export * from './compensations.js';
225
229
  export * from './request/printNotification.js';
package/dist/index.js CHANGED
@@ -208,6 +208,14 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
208
208
  export * from './adapter/downstream/eventToSdkMessage.js';
209
209
  export * from './adapter/downstream/terminalToSdkResult.js';
210
210
  export * from './adapter/runStream.js';
211
+ // ── A-028.1(#244 族A,2026-08-12):409 active-run 自愈的分诊树 + 结局文案层 ————————————
212
+ // 判别半场(activeRunBusySignal)在 runStream.js 早已出面;本件是它的消费侧:四路分诊
213
+ // (pendingGate.kind 优先 / status 表回退)+ 八种结局的可覆盖默认文案。归通用半场的理由:
214
+ // 三端面对同一把会话锁 —— cli 交互/headless 两车道换包导入删本地抄件(提货批);desktop
215
+ // session-host 已自写同族「parked run 不能 cancel」分诊,收敛到此;web 读 activeTaskId 的
216
+ // busy 面同源。文案默认串 = cli 产品面逐字,端经 ActiveRunSelfHealCopy 覆盖出路句。
217
+ // 🔴 零值级 import 纯函数叶(类型 type-only 借 runStream),不进内核/A 层闭包。
218
+ export * from './adapter/activeRunSelfHeal.js';
211
219
  // ── B4 批:A 层帧分派收官 + 请求面合一 + 构造点收编(2026-07-27)────────────────────────────────
212
220
  // 请求面:三个构造器(seamQuery.toTaskRequest / seamQueryEngine / liveClient.toLiveRequest)
213
221
  // 的**字段集与合并语义**收成一份;车道差异做成 REQUEST_FIELD_MATRIX 数据表(gap:true = 判为漏)。
@@ -306,6 +314,18 @@ export * from './hitl/hitlHostSurface.js';
306
314
  export * from './hitl/toolApprovalWire.js';
307
315
  export * from './hitl/askGateWire.js';
308
316
  export * from './hitl/planReviewWire.js';
317
+ // ── A-028.2/.3(#244 族A,2026-08-12):决断卡链的键词汇/呈现台账/归属判据三件 ————————————
318
+ // · gateIdentity:三条身份键字面(hitl-ask:/callKey/plan-review:,外加 hitl-frame: 同族)的唯一
319
+ // 铸口 —— 包内三处铸口与呈现台账的键推导同源;壳侧 armedGateRegistry/askParkReopen 的手抄字面
320
+ // 换 import 删本地(提货批);desktop session-host 的 plan:/durable: 前缀族收敛候后续对齐。
321
+ // · armedGateRegistry:per-session 呈现台账(firstSight 文案分形的唯一判据);sessionSlot 键支持
322
+ // 多会话宿主(desktop session-host 多引擎会话),cli 走 DEFAULT_SESSION_KEY 零参兼容层。
323
+ // · parkOwnership:ask park 重开腿的纯判据三件(fail-closed 归属证明/questions 结构判定/
324
+ // HitlSafetyError duck 判型)—— cli 换包导入删抄件;desktop refreshPendingApprovals 的自写
325
+ // 归属判定收敛到同一判据。approvals.list 编排与 stderr 留各端。
326
+ export * from './hitl/gateIdentity.js';
327
+ export * from './hitl/armedGateRegistry.js';
328
+ export * from './hitl/parkOwnership.js';
309
329
  // B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
310
330
  // 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
311
331
  export * from './hitl/approvalsFeed.js';
@@ -68,6 +68,17 @@ 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
+ * Unregister a local responder **by id**(A-028.4 复审二轮收紧,2026-08-12)。
73
+ *
74
+ * 为什么需要按 id 注销:`registerLocalQuestionResponder` 的注销钩只回到注册方手里,而重开腿
75
+ * (`planReviewWire.reopenPlanReviewCard`)要退役的是**别的注册方**(arm 臂)登记的 canonical
76
+ * responder —— 只发 `question_complete` 撤帧撤不掉表里的 responder,一个在途的迟到作答仍能经
77
+ * 它投递决断,与新卡的投递构成同一 gate 的双决断面。返回是否真删了一条(调用方留痕用)。
78
+ * 🔴 只删表项,不通知原注册方 —— 原注册方的自愈臂(如 arm 的陈旧武装态分支)按
79
+ * `hasLocalQuestionResponder` 探询真相,不依赖回调。
80
+ */
81
+ export declare function unregisterLocalQuestionResponder(id: string): boolean;
71
82
  /**
72
83
  * Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
73
84
  *
@@ -20,6 +20,19 @@ export function registerLocalQuestionResponder(id, fn) {
20
20
  localResponders.delete(id);
21
21
  };
22
22
  }
23
+ /**
24
+ * Unregister a local responder **by id**(A-028.4 复审二轮收紧,2026-08-12)。
25
+ *
26
+ * 为什么需要按 id 注销:`registerLocalQuestionResponder` 的注销钩只回到注册方手里,而重开腿
27
+ * (`planReviewWire.reopenPlanReviewCard`)要退役的是**别的注册方**(arm 臂)登记的 canonical
28
+ * responder —— 只发 `question_complete` 撤帧撤不掉表里的 responder,一个在途的迟到作答仍能经
29
+ * 它投递决断,与新卡的投递构成同一 gate 的双决断面。返回是否真删了一条(调用方留痕用)。
30
+ * 🔴 只删表项,不通知原注册方 —— 原注册方的自愈臂(如 arm 的陈旧武装态分支)按
31
+ * `hasLocalQuestionResponder` 探询真相,不依赖回调。
32
+ */
33
+ export function unregisterLocalQuestionResponder(id) {
34
+ return localResponders.delete(id);
35
+ }
23
36
  /**
24
37
  * Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
25
38
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,12 +28,12 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@sema-agent/agent-types": ">=0.2.0",
31
- "@sema-agent/sdk": ">=6.14.0"
31
+ "@sema-agent/sdk": ">=6.16.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@sema-agent/agent-types": "^0.2.0",
35
35
  "@sema-agent/core": "^5.20.0",
36
- "@sema-agent/sdk": "^6.14.0",
36
+ "@sema-agent/sdk": "^6.16.0",
37
37
  "esbuild": "^0.27.4",
38
38
  "typescript": "^6.0.2"
39
39
  }