@sema-agent/client-core 0.27.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * armedGateRegistry.ts — per-session 呈现台账:哪些决断卡在**这个宿主进程的哪个会话**里真的
3
+ * 呈现过(A-028.3,#244 族A 包半场,2026-08-12;源形 = cli `src/sema/armedGateRegistry.ts`,
4
+ * 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主)。
5
+ *
6
+ * ── 为什么需要它 ────────────────────────────────────────────────────────────────────────────
7
+ * 409 自愈重开臂(`adapter/activeRunSelfHeal.ts` 的分诊消费方)此前对每一张 pending 卡都说
8
+ * 「that card was closed without being answered, so sema reopened it」。但已定谳的机理链
9
+ * (plan approve → outcome enqueue 成新提交 → 引擎已推进到**新** gate → 该提交撞 409 →
10
+ * 重开腿铸出全新 pending 卡)里,那张卡是**首见**的 —— 从未被 arm 过,更谈不上 closed/
11
+ * reopened。文案对首见卡编造历史 = 对用户下一个证不出的断言。重开机制本身是设计内的
12
+ * surfacing 路径(承重,不动);本台账只供「说了什么」分形:firstSight 判据 = 这张卡的身份键
13
+ * 有没有在本进程本会话呈现过。
14
+ *
15
+ * ── 键的词汇(单源 = `gateIdentity.ts`,别自造字面)────────────────────────────────────────
16
+ * · tool 审批卡:durable 腿 = `approvalCallKey(gatedCallId, taskId)`;live 帧腿 =
17
+ * `liveFrameCallKey(approvalId)`(帧上无 gatedCallId,与 pending 行的 callId 天然对不上 ——
18
+ * 漏配的代价只是把「重开」说成「呈上」,首见文案零历史断言,诚实方向安全)。
19
+ * · AskUserQuestion overlay:`askGateQuestionId(callKey)` 铸帧 id,本台账经
20
+ * {@link registerArmedGateFromQuestionId} 剥前缀取键(= callKey,与卡键同域)。
21
+ * · plan_review:`planReviewQuestionId(taskId)` 整串作键(保留前缀,与 taskId 直接作 ask 键
22
+ * 永不撞域);重开腿铸 `…#reopen-*`,归一化剥尾 —— 首呈与重开落同一键。
23
+ *
24
+ * ── 登记点(「没呈现就不算 arm」)───────────────────────────────────────────────────────────
25
+ * ① 宿主呈现面:overlay 收到可渲染 question 帧时 / 卡口真把卡 enqueue 进渲染队列时,宿主调
26
+ * {@link registerArmedGateFromQuestionId} / {@link registerArmedGate}(auto-allow/deny/
27
+ * 失败臂不登记)。包的 publish 点**不**代登记 —— publish 无 overlay 时是 no-op,代登记 =
28
+ * 把「发过帧」谎报成「呈现过」。
29
+ * ② 重开腿:先读(firstSight 判决)后写(铸卡即 arm)—— 同一张卡第二次撞 409 才说 reopened。
30
+ * 包内 `planReviewWire.reopenPlanReviewCard` 与壳侧 ask 重开腿都按此序。
31
+ * ③ 消费点:plan_review 决断**递交后**清键({@link clearArmedGate};A-024.4 —— 同 run 的下一
32
+ * 个 plan gate 必须读回首见,键粒度是 taskId,不清就把新门谎成复见)。ask 族键粒度是
33
+ * callId(每 gate 唯一),无此问题,不清。
34
+ *
35
+ * per-session 语义:sessionSlot 注册表(desktop session-host 多引擎会话互不串账);零参 API =
36
+ * DEFAULT_SESSION_KEY 兼容层,单会话宿主(cli)装配一行不动。callId 全局唯一,跨 /clear 不撞键。
37
+ */
38
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
39
+ import { armedKeyFromQuestionId } from './gateIdentity.js';
40
+ // 🔴 模块级单例(singleton-manifest 登记):per-session 键 → 已呈现身份键集。
41
+ const armedGatesByKey = createSessionSlot();
42
+ function setFor(sessionKey) {
43
+ let s = armedGatesByKey.get(sessionKey);
44
+ if (!s) {
45
+ s = new Set();
46
+ armedGatesByKey.set(sessionKey, s);
47
+ }
48
+ return s;
49
+ }
50
+ /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。 */
51
+ export function registerArmedGate(key) {
52
+ registerArmedGateFor(DEFAULT_SESSION_KEY, key);
53
+ }
54
+ /** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
55
+ export function registerArmedGateFor(sessionKey, key) {
56
+ if (typeof key === 'string' && key.length > 0)
57
+ setFor(sessionKey).add(key);
58
+ }
59
+ /** 该身份键的卡在本进程本会话呈现过吗?(false = 首见;文案分形的唯一判据) */
60
+ export function wasGateArmed(key) {
61
+ return wasGateArmedFor(DEFAULT_SESSION_KEY, key);
62
+ }
63
+ /** W1 带 key 变体。 */
64
+ export function wasGateArmedFor(sessionKey, key) {
65
+ if (typeof key !== 'string' || key.length === 0)
66
+ return false;
67
+ return armedGatesByKey.get(sessionKey)?.has(key) === true;
68
+ }
69
+ /** 消费一个身份键(A-024.4:plan_review 决断递交后清键 —— 同 taskId 的下一个 plan gate 读回首见)。 */
70
+ export function clearArmedGate(key) {
71
+ clearArmedGateFor(DEFAULT_SESSION_KEY, key);
72
+ }
73
+ /** W1 带 key 变体。 */
74
+ export function clearArmedGateFor(sessionKey, key) {
75
+ if (typeof key === 'string' && key.length > 0)
76
+ armedGatesByKey.get(sessionKey)?.delete(key);
77
+ }
78
+ /** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
79
+ * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。 */
80
+ export function registerArmedGateFromQuestionId(questionId) {
81
+ registerArmedGateFromQuestionIdFor(DEFAULT_SESSION_KEY, questionId);
82
+ }
83
+ /** W1 带 key 变体。 */
84
+ export function registerArmedGateFromQuestionIdFor(sessionKey, questionId) {
85
+ if (typeof questionId !== 'string' || questionId.length === 0)
86
+ return;
87
+ registerArmedGateFor(sessionKey, armedKeyFromQuestionId(questionId));
88
+ }
89
+ /** 测试用:清全部会话的台账。 */
90
+ export function _resetArmedGateRegistryForTest() {
91
+ armedGatesByKey.clear();
92
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { AgentEvent } from '@sema-agent/sdk';
18
18
  import type { HitlClientLike } from './hitlBridge.js';
19
- import { type RespondToolApprovalFn } from './toolApprovalWire.js';
19
+ import { type RespondToolApprovalFn, type ToolApprovalFrameLaneOpts } from './toolApprovalWire.js';
20
20
  import type { GateLedger } from './gateLedger.js';
21
21
  /**
22
22
  * CC `utils/messages.ts` 的 `REJECT_MESSAGE` **逐字**(B7 搬迁差分 2)。deny 后重放 tool_end 的
@@ -50,6 +50,10 @@ export interface AskGateWireDeps {
50
50
  /** POST /v1/tool-approvals/:id/respond(server 1.191 同步帧腿,[830]①)。缺省=不消费
51
51
  * tool_approval 帧(帧被吞、引擎按自身 fail-closed TTL 自决)——mock/旧引擎路径零影响。 */
52
52
  respondToolApproval?: RespondToolApprovalFn;
53
+ /** #229 respond-note 供给链(0.29.0 发包扫描门修,2026-08-12):帧腿车道参数(能力位读数),
54
+ * 宿主从自己的 caps 缓存供给。缺席 = note 门 fail-closed 到「不发」侧(决断照常,现状字节
55
+ * 不变)—— 此前包内唯一生产调用点(routeFrame 的 tool_approval 臂)无此位,note 恒不发。 */
56
+ approvalLane?: ToolApprovalFrameLaneOpts;
53
57
  }
54
58
  /** 两族 gate:AskUserQuestion 走问答 overlay(原路);其余(fs 写三件 / Bash / 一等
55
59
  * `kind==='tool_approval'`)走 CC 三选卡。 */
@@ -302,7 +302,7 @@ async function routeToolApprovalFrame(ev, ctx) {
302
302
  const gatedCallId = !fromSubagent ? led.lastPendingFsCall() : undefined;
303
303
  // FIX①(2026-08-07):产物是 `{decision, ack?}` —— ack 上的 `rememberApplied`/`updatedInputForwarded`
304
304
  // 由 toolApprovalWire 就地收敛成宿主通知(本路由只认决断词,不重复解释 ack)。
305
- const { decision } = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, argsOfGatedStart(led, gatedCallId), ctx.signal);
305
+ const { decision } = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, argsOfGatedStart(led, gatedCallId), ctx.signal, deps.approvalLane);
306
306
  if (decision === 'deny' && !fromSubagent) {
307
307
  // 该 call 的报错收口帧 stamp REJECT 文案(vendored `User rejected <op> to <path>` 卡)。
308
308
  if (gatedCallId !== undefined)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * gateIdentity.ts — HITL 决断卡**身份键**的唯一铸口(A-028.3,#244 族A 包半场,2026-08-12)。
3
+ *
4
+ * 为什么要有它:三条身份键字面此前散在三处各铸各的 ——
5
+ * · `parkResolver.ts` 铸 `hitl-ask:${gatedCallId ?? taskId}`(overlay 问答帧 id);
6
+ * · `toolApprovalWire.ts` 铸 `gatedCallId ?? taskId`(durable 腿卡口 callKey)与
7
+ * `hitl-frame:${approvalId}`(live 帧腿 callKey);
8
+ * · `planReviewWire.ts` 铸 `plan-review:${taskId}`(module 私有,壳侧只能人肉抄字面对齐)。
9
+ * 消费方(呈现台账 `armedGateRegistry` 的键归一、壳侧重开腿)靠「与铸口逐字对齐」的注释纪律
10
+ * 活着 —— 漂一个字节,首见/复见判决就静默失真。本文件把全部字面收成单源:铸口 import 这里,
11
+ * 台账的键推导也 import 这里,两边在结构上不可能再漂。
12
+ *
13
+ * 🔴 零 import 纯函数叶(portability:不进任何闭包新边;index 闭包 +1 文件已登记)。
14
+ */
15
+ /** overlay 问答帧的 ask-gate 命名空间前缀(`parkResolver.surfaceGateAndDecide` 合成帧 id 用)。 */
16
+ export declare const HITL_ASK_QUESTION_ID_PREFIX = "hitl-ask:";
17
+ /** plan_review 审批卡的合成 questionId 前缀(`planReviewWire` 首次 arm 与重开腿共用)。 */
18
+ export declare const PLAN_REVIEW_QUESTION_ID_PREFIX = "plan-review:";
19
+ /** live `tool_approval` 帧腿的卡口 callKey 前缀(帧上无 gatedCallId 可对齐,以 approvalId 铸)。 */
20
+ export declare const HITL_FRAME_CALL_KEY_PREFIX = "hitl-frame:";
21
+ /** 重开腿铸新身份的尾分隔符(`…#reopen-<suffix>`);键归一时从这里剥尾,首呈与重开落同一键。 */
22
+ export declare const REOPEN_ID_TAIL = "#reopen-";
23
+ /**
24
+ * durable 审批腿的卡身份键(`toolApprovalWire` 卡口 / own-run 台账同域):
25
+ * gate 绑定的 callId 优先,行没带 callId 时退 taskId。
26
+ */
27
+ export declare function approvalCallKey(gatedCallId: string | undefined, taskId: string): string;
28
+ /** ask-gate overlay 帧 id(入参 = {@link approvalCallKey} 的产出)。 */
29
+ export declare function askGateQuestionId(callKey: string): string;
30
+ /** plan_review 审批卡的合成 questionId(首次 arm 的 canonical 身份;重开腿在其后接 {@link REOPEN_ID_TAIL})。
31
+ * A-028.3:此前是 `planReviewWire.ts` 的 module 私有函数,壳侧重开腿只能手抄字面 —— 现转公面单源。 */
32
+ export declare function planReviewQuestionId(taskId: string): string;
33
+ /** live `tool_approval` 帧腿的卡口 callKey(`toolApprovalWire.surfaceToolApprovalFrameAndRespond`)。 */
34
+ export declare function liveFrameCallKey(approvalId: string): string;
35
+ /**
36
+ * question 帧 id → 呈现台账键的归一(armedGateRegistry 的键推导):
37
+ * · `hitl-ask:X` → `X`(ask 帧的台账键 = 卡口 callKey,与 durable 腿卡的键同域);
38
+ * · `…#reopen-<suffix>` 尾剥掉(重开铸的新身份归一到原键,首呈与重开同键);
39
+ * · `plan-review:<taskId>` **保留前缀**(plan 键与「taskId 直接作 ask 键」永不撞域);
40
+ * · 不认识的形原样入册(未来新 id 形至多多占一个键,不误伤既有词汇)。
41
+ */
42
+ export declare function armedKeyFromQuestionId(questionId: string): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * gateIdentity.ts — HITL 决断卡**身份键**的唯一铸口(A-028.3,#244 族A 包半场,2026-08-12)。
3
+ *
4
+ * 为什么要有它:三条身份键字面此前散在三处各铸各的 ——
5
+ * · `parkResolver.ts` 铸 `hitl-ask:${gatedCallId ?? taskId}`(overlay 问答帧 id);
6
+ * · `toolApprovalWire.ts` 铸 `gatedCallId ?? taskId`(durable 腿卡口 callKey)与
7
+ * `hitl-frame:${approvalId}`(live 帧腿 callKey);
8
+ * · `planReviewWire.ts` 铸 `plan-review:${taskId}`(module 私有,壳侧只能人肉抄字面对齐)。
9
+ * 消费方(呈现台账 `armedGateRegistry` 的键归一、壳侧重开腿)靠「与铸口逐字对齐」的注释纪律
10
+ * 活着 —— 漂一个字节,首见/复见判决就静默失真。本文件把全部字面收成单源:铸口 import 这里,
11
+ * 台账的键推导也 import 这里,两边在结构上不可能再漂。
12
+ *
13
+ * 🔴 零 import 纯函数叶(portability:不进任何闭包新边;index 闭包 +1 文件已登记)。
14
+ */
15
+ /** overlay 问答帧的 ask-gate 命名空间前缀(`parkResolver.surfaceGateAndDecide` 合成帧 id 用)。 */
16
+ export const HITL_ASK_QUESTION_ID_PREFIX = 'hitl-ask:';
17
+ /** plan_review 审批卡的合成 questionId 前缀(`planReviewWire` 首次 arm 与重开腿共用)。 */
18
+ export const PLAN_REVIEW_QUESTION_ID_PREFIX = 'plan-review:';
19
+ /** live `tool_approval` 帧腿的卡口 callKey 前缀(帧上无 gatedCallId 可对齐,以 approvalId 铸)。 */
20
+ export const HITL_FRAME_CALL_KEY_PREFIX = 'hitl-frame:';
21
+ /** 重开腿铸新身份的尾分隔符(`…#reopen-<suffix>`);键归一时从这里剥尾,首呈与重开落同一键。 */
22
+ export const REOPEN_ID_TAIL = '#reopen-';
23
+ /**
24
+ * durable 审批腿的卡身份键(`toolApprovalWire` 卡口 / own-run 台账同域):
25
+ * gate 绑定的 callId 优先,行没带 callId 时退 taskId。
26
+ */
27
+ export function approvalCallKey(gatedCallId, taskId) {
28
+ return gatedCallId ?? taskId;
29
+ }
30
+ /** ask-gate overlay 帧 id(入参 = {@link approvalCallKey} 的产出)。 */
31
+ export function askGateQuestionId(callKey) {
32
+ return `${HITL_ASK_QUESTION_ID_PREFIX}${callKey}`;
33
+ }
34
+ /** plan_review 审批卡的合成 questionId(首次 arm 的 canonical 身份;重开腿在其后接 {@link REOPEN_ID_TAIL})。
35
+ * A-028.3:此前是 `planReviewWire.ts` 的 module 私有函数,壳侧重开腿只能手抄字面 —— 现转公面单源。 */
36
+ export function planReviewQuestionId(taskId) {
37
+ return `${PLAN_REVIEW_QUESTION_ID_PREFIX}${taskId}`;
38
+ }
39
+ /** live `tool_approval` 帧腿的卡口 callKey(`toolApprovalWire.surfaceToolApprovalFrameAndRespond`)。 */
40
+ export function liveFrameCallKey(approvalId) {
41
+ return `${HITL_FRAME_CALL_KEY_PREFIX}${approvalId}`;
42
+ }
43
+ /**
44
+ * question 帧 id → 呈现台账键的归一(armedGateRegistry 的键推导):
45
+ * · `hitl-ask:X` → `X`(ask 帧的台账键 = 卡口 callKey,与 durable 腿卡的键同域);
46
+ * · `…#reopen-<suffix>` 尾剥掉(重开铸的新身份归一到原键,首呈与重开同键);
47
+ * · `plan-review:<taskId>` **保留前缀**(plan 键与「taskId 直接作 ask 键」永不撞域);
48
+ * · 不认识的形原样入册(未来新 id 形至多多占一个键,不误伤既有词汇)。
49
+ */
50
+ export function armedKeyFromQuestionId(questionId) {
51
+ const base = questionId.startsWith(HITL_ASK_QUESTION_ID_PREFIX)
52
+ ? questionId.slice(HITL_ASK_QUESTION_ID_PREFIX.length)
53
+ : questionId;
54
+ const cut = base.indexOf(REOPEN_ID_TAIL);
55
+ return cut >= 0 ? base.slice(0, cut) : base;
56
+ }
@@ -66,6 +66,18 @@
66
66
  * backend supplies the SIGNAL; the shell owns the chrome.
67
67
  */
68
68
  import type { AgentEvent, ApprovalDecision, PendingCheckpoint, CheckpointGate, PlanReviewRequest, AssistantTaskStatus } from '@sema-agent/sdk';
69
+ /** durable `/decide` 腿的既有缺省拒因(不带归因时逐字不变 —— 0.27.0 及之前的 wire 字节)。 */
70
+ export declare const DEFAULT_DENY_REASON = "The user rejected this tool use";
71
+ /** server 两条腿共用的 reason 字符上限(超限 413,决断被打回)。 */
72
+ export declare const MAX_DENY_REASON_CHARS = 4096;
73
+ /**
74
+ * 归因原文 → 可上 wire 的形。缺席/非串/纯空白 ⇒ `undefined`(调用方自定回落:deny 腿落
75
+ * {@link DEFAULT_DENY_REASON},plan-review 腿键不落);超上限 ⇒ 截到上限并 debug 留痕。
76
+ * 🔴 截断**边界安全**:切点落在代理对中间会产出孤高代理 —— server 长度检查放行,但 UTF-8
77
+ * 落库/入签时末尾退化成 U+FFFD,两侧字节不一致还会毒化签名对账。末码元是高代理就再退一位。
78
+ * 🔴 只做归因,不参与裁决:任何返回值都不改变这次决断本身。
79
+ */
80
+ export declare function denyReasonForWire(reason: string | undefined, tag: string): string | undefined;
69
81
  /** CC `PermissionDecision` 的结构切片 —— 本桥运行时真正读的三个键。 */
70
82
  export interface HitlPermissionDecisionLike {
71
83
  behavior: 'allow' | 'ask' | 'deny';
@@ -1,4 +1,38 @@
1
1
  import { eventSeq } from '../adapter/types.js';
2
+ import { hostLog } from '../host.js';
3
+ // ── deny/plan-review 归因的 wire 窄化(0.28.0 发版扫描 F1/F2/F3 收编;单源,三条腿共用)──────
4
+ //
5
+ // server 对 `/decide` 与 plan-review 两条腿的 `reason` 同限 `MAX_APPROVAL_REASON_CHARS`(4096 字符,
6
+ // 超限 **413 `reason_too_large`**;server 侧 reason 入签后无法替发送方截 —— 超长原样发出去,丢掉的
7
+ // 不是归因而是**整次决断**:413 ⇒ 决断没送达 ⇒ run 留 suspended)。所以三条 reason 出口(卡 deny /
8
+ // canUseTool deny / plan-review)全部经本窄化器,绝不裸发。
9
+ /** durable `/decide` 腿的既有缺省拒因(不带归因时逐字不变 —— 0.27.0 及之前的 wire 字节)。 */
10
+ export const DEFAULT_DENY_REASON = 'The user rejected this tool use';
11
+ /** server 两条腿共用的 reason 字符上限(超限 413,决断被打回)。 */
12
+ export const MAX_DENY_REASON_CHARS = 4096;
13
+ /**
14
+ * 归因原文 → 可上 wire 的形。缺席/非串/纯空白 ⇒ `undefined`(调用方自定回落:deny 腿落
15
+ * {@link DEFAULT_DENY_REASON},plan-review 腿键不落);超上限 ⇒ 截到上限并 debug 留痕。
16
+ * 🔴 截断**边界安全**:切点落在代理对中间会产出孤高代理 —— server 长度检查放行,但 UTF-8
17
+ * 落库/入签时末尾退化成 U+FFFD,两侧字节不一致还会毒化签名对账。末码元是高代理就再退一位。
18
+ * 🔴 只做归因,不参与裁决:任何返回值都不改变这次决断本身。
19
+ */
20
+ export function denyReasonForWire(reason, tag) {
21
+ if (typeof reason !== 'string')
22
+ return undefined;
23
+ const trimmed = reason.trim();
24
+ if (trimmed === '')
25
+ return undefined;
26
+ if (trimmed.length <= MAX_DENY_REASON_CHARS)
27
+ return trimmed;
28
+ let cut = trimmed.slice(0, MAX_DENY_REASON_CHARS);
29
+ const last = cut.charCodeAt(cut.length - 1);
30
+ if (last >= 0xd800 && last <= 0xdbff)
31
+ cut = cut.slice(0, -1); // 孤高代理:退一位,绝不发半个字符
32
+ hostLog('debug', `hitlBridge: reason for ${tag} truncated ${trimmed.length}→${cut.length} chars ` +
33
+ `(server caps approval reason at ${MAX_DENY_REASON_CHARS}; over-cap is a 413 reason_too_large that would drop the DECISION, not just the reason)`);
34
+ return cut;
35
+ }
2
36
  // ── Errors the bridge surfaces (fail-closed; never auto-retried) ───────────────
3
37
  /** A safety stop: a contract law was about to be violated (e.g. a binding mismatch, a wrong-gate route).
4
38
  * The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
@@ -254,8 +288,12 @@ export class HitlBridge {
254
288
  // (PlanReviewRequest 是 SDK 外部 wire 型,不能改;诚实缺席 = 键不落,不传 `editedPlan: undefined`)。
255
289
  if (outcome.decision === 'edit' && outcome.editedPlan !== undefined)
256
290
  req.editedPlan = outcome.editedPlan;
257
- if (outcome.reason !== undefined)
258
- req.reason = outcome.reason;
291
+ // 0.28.0 发版扫描 F2:reason 经窄化器(超 4096 截断防 413 丢整次计划决断);空白 ⇒ 键不落。
292
+ if (outcome.reason !== undefined) {
293
+ const r = denyReasonForWire(outcome.reason, `plan-review ${this.taskId}`);
294
+ if (r !== undefined)
295
+ req.reason = r;
296
+ }
259
297
  return this.client.assistant.planReview(this.taskId, req, opts);
260
298
  }
261
299
  // ── §5. resource_limit: continue ────────────────────────────────────────────
@@ -305,7 +343,9 @@ export function makeHitlCanUseTool(bridge, prompt) {
305
343
  (await prompt({ toolName: tool.name, input, toolUseID, gate }));
306
344
  if (decision.behavior === 'deny') {
307
345
  // Deny → cancel-by-deny (contract/04 §2.4). The deny message rides `reason`.
308
- await bridge.decideTool({ decision: 'deny', reason: decision.message }, toolUseID);
346
+ // 0.28.0 发版扫描 F1(P2):message 逐字嵌原始命令(壳侧 bashPermissions 无上限)——必须与
347
+ // 卡腿同门经窄化器截到 4096,否则 server 413 丢的是整次 deny(run 留 suspended)。
348
+ await bridge.decideTool({ decision: 'deny', reason: denyReasonForWire(decision.message, `canUseTool ${toolUseID}`) ?? DEFAULT_DENY_REASON }, toolUseID);
309
349
  return decision;
310
350
  }
311
351
  if (decision.behavior === 'allow') {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * parkOwnership.ts — ask park 重开腿的**纯判据**三件(A-028.2,#244 族A 包半场,2026-08-12;
3
+ * 源形 = cli `src/sema/askParkReopen.ts` 的发现面判据半场;`approvals.list` 编排与 stderr
4
+ * 留宿主 —— 本文件零网络、零渲染,只回答判决)。
5
+ *
6
+ * ── A-024.1:「队列恰一行」不是归属证明 ────────────────────────────────────────────────────
7
+ * `/v1/approvals` 是 **scope 级**单队列:恰一行只说明「此刻没有别的行在排队」,而孤儿 park、
8
+ * 行可见性延迟、另一会话独占队列三种形都能让**别人的**行落单。把这一行直接展示并代为决断,
9
+ * 是 REF-CC-029(`?? pending[0]`)那个病的换形复发 —— 后果不是渲染错,是**把别人的审批递到
10
+ * 这个用户手上并替他按了钮**。
11
+ *
12
+ * 宿主能拿出的归属硬证据只有两条(缺一不可地互补,不是二选一):
13
+ * ① 行的 `taskId` ∈ own-run 台账(`isOwnEngineRun`,只记本进程亲手驱动的 run + fleet 行帧
14
+ * 闭包进来的自家子代)⇒ 本宿主起的 run/子代。child 委派行走这条 —— 子代 park 行的
15
+ * `sessionId` 是子代自己的 session,天生 ≠ 本会话,只按 ② 判会把发现面拆掉。
16
+ * ② 行自带 `sessionId` === 本宿主当前引擎会话 ⇒ 本会话自己的 park。
17
+ *
18
+ * 🔴 判据是 **fail-CLOSED 的正向证明**:两条腿任一成立才采信,**都不成立就不采信** —— 包括
19
+ * 「本会话 id 还没有」「行没带 sessionId」这两种**判不出**的形。把「证明不了」当「是我的」,
20
+ * 在一个会替用户按钮的面上正好反了。判不出的代价只是一句诚实的 reopen-failed(用户重发消息
21
+ * 即重走本链),而放行的代价是替别人决断,不对等。
22
+ */
23
+ import type { PendingCheckpoint } from '@sema-agent/sdk';
24
+ import type { AskQuestion } from '../liveQuestionStore.js';
25
+ /** 归属判据的两条腿的注入口(缺席走包内既有真源:SessionPort + own-run 台账)。 */
26
+ export interface ParkOwnershipDeps {
27
+ /**
28
+ * 多会话宿主的会话键(缺省 = DEFAULT_SESSION_KEY,单会话宿主零参装配)。
29
+ * 🔴 传了**非默认键**而没有注入 `isOwnRun` 时,own-run 缺省腿**整条跳过**(复审 [high] 收紧):
30
+ * 包内 own-run 台账是**进程级**的(记「本进程驱动过的 run」),在一个跑着 A/B 两个会话的宿主
31
+ * 进程里,它答不了「这行是 A 的还是 B 的」—— 拿进程级证据放行会把 A 的审批递到 B 手上。
32
+ * 多会话宿主要么注入自己会话粒度的 `isOwnRun`,要么只靠会话腿(fail-closed:少一条腿的代价
33
+ * 只是多一次诚实 reopen-failed)。
34
+ */
35
+ sessionKey?: string;
36
+ /** 会话腿真源(缺省 = `hostSessionFor(sessionKey).currentSessionId()`;空串按缺席归一,
37
+ * 判不出 ≠ 命中)。 */
38
+ currentSessionId?: () => string | undefined;
39
+ /** own-run 台账腿(缺省 = 包 `isOwnEngineRun`,**仅默认会话键**下启用,理由见 sessionKey 注)。
40
+ * 🔴 键域 = `rowIdTail(行 taskId)`(fleet 台账登记时就过 rowIdTail,本判据同域取键后才调本口)。
41
+ * 🔴 成文局限(复审二轮裁定,不改行为):默认键下的缺省腿是**进程级**证据,不区分同一宿主
42
+ * 进程内的会话代际 —— `/clear` 前登记的 run 在新会话语境下仍判 owned。这是单会话宿主的
43
+ * 出货语义(owner = 本进程的这一位用户;child 委派行的 sessionId 天生 ≠ 本会话,发现面恰恰
44
+ * 依赖跨会话 id 的进程级证据),最坏形 = 用户看到**自己**旧会话的审批卡(卡面标注行归属)。
45
+ * 要会话代际粒度就注入自己的 `isOwnRun` —— 台账本体改代际级属 own-run 域独立议题。 */
46
+ isOwnRun?: (taskIdTail: string) => boolean;
47
+ }
48
+ /**
49
+ * 这行 pending 能不能**正向证明**属于本宿主会话(fail-closed;两腿语义见文件头)。
50
+ * 腿序:own-run 先问(child 委派行不依赖会话 id),会话腿后问。
51
+ */
52
+ export declare function pendingRowIsOwnedByThisSession(row: PendingCheckpoint, deps?: ParkOwnershipDeps): boolean;
53
+ /**
54
+ * questions payload 从 pending.input 读 —— **结构判定**收窄到 AskQuestion 必需键(question
55
+ * 非 string / questions 非数组 / 空数组 ⇒ null,不 cast 硬塞;缺 header 的行照 CC 形容忍,
56
+ * overlay 自兜)。铸空卡 = 假 affordance,拿不到题就诚实返 null。
57
+ */
58
+ export declare function askQuestionsFromPending(pending: PendingCheckpoint): AskQuestion[] | null;
59
+ /**
60
+ * HitlSafetyError 判型的 duck-check(按 `name`,不按 instanceof —— 宿主 bundle 里存在第二份包
61
+ * 实例时 instanceof 会分叉,sseIdleTriage 同款纪律)。安全信号(binding_mismatch 族)契约禁
62
+ * 盲重试,重开腿据此把「可重试的瞬时故障」与「必须交还人重决的安全停」分开。
63
+ * 入参是任意抛出值,本函数就是窄化动作本身(unknown 出境登记见 typeshape 门 RATCHET 注)。
64
+ */
65
+ export declare function isHitlSafetyErrorLike(e: unknown): boolean;
@@ -0,0 +1,49 @@
1
+ import { isOwnEngineRun } from '../subagentContentStore.js';
2
+ import { rowIdTail } from '../workflow.js';
3
+ import { hostSessionFor } from '../host.js';
4
+ import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
5
+ /**
6
+ * 这行 pending 能不能**正向证明**属于本宿主会话(fail-closed;两腿语义见文件头)。
7
+ * 腿序:own-run 先问(child 委派行不依赖会话 id),会话腿后问。
8
+ */
9
+ export function pendingRowIsOwnedByThisSession(row, deps) {
10
+ const sessionKey = deps?.sessionKey ?? DEFAULT_SESSION_KEY;
11
+ // ① 本宿主亲手驱动的 run / 由它闭包进来的自家子代 —— 不依赖会话 id。
12
+ // 进程级缺省腿只在默认会话键下启用(多会话宿主必须注入会话粒度的口,见 deps.sessionKey 注)。
13
+ const rowTaskId = typeof row.taskId === 'string' ? row.taskId : '';
14
+ const ownRun = deps?.isOwnRun ?? (sessionKey === DEFAULT_SESSION_KEY ? isOwnEngineRun : undefined);
15
+ if (ownRun !== undefined && rowTaskId.length > 0 && ownRun(rowIdTail(rowTaskId)))
16
+ return true;
17
+ // ② 行自带 sessionId === 本会话当前引擎会话。任一侧缺席/空串 = 判不出,不是命中。
18
+ const currentSessionId = deps?.currentSessionId ?? (() => hostSessionFor(sessionKey)?.currentSessionId());
19
+ const sessionId = currentSessionId();
20
+ const rowSessionId = typeof row.sessionId === 'string' ? row.sessionId : '';
21
+ return (typeof sessionId === 'string' &&
22
+ sessionId.length > 0 &&
23
+ rowSessionId.length > 0 &&
24
+ rowSessionId === sessionId);
25
+ }
26
+ /**
27
+ * questions payload 从 pending.input 读 —— **结构判定**收窄到 AskQuestion 必需键(question
28
+ * 非 string / questions 非数组 / 空数组 ⇒ null,不 cast 硬塞;缺 header 的行照 CC 形容忍,
29
+ * overlay 自兜)。铸空卡 = 假 affordance,拿不到题就诚实返 null。
30
+ */
31
+ export function askQuestionsFromPending(pending) {
32
+ const input = pending.input;
33
+ if (typeof input !== 'object' || input === null)
34
+ return null;
35
+ const qs = input.questions;
36
+ if (!Array.isArray(qs) || qs.length === 0)
37
+ return null;
38
+ const ok = qs.every(q => typeof q === 'object' && q !== null && typeof q.question === 'string');
39
+ return ok ? qs : null;
40
+ }
41
+ /**
42
+ * HitlSafetyError 判型的 duck-check(按 `name`,不按 instanceof —— 宿主 bundle 里存在第二份包
43
+ * 实例时 instanceof 会分叉,sseIdleTriage 同款纪律)。安全信号(binding_mismatch 族)契约禁
44
+ * 盲重试,重开腿据此把「可重试的瞬时故障」与「必须交还人重决的安全停」分开。
45
+ * 入参是任意抛出值,本函数就是窄化动作本身(unknown 出境登记见 typeshape 门 RATCHET 注)。
46
+ */
47
+ export function isHitlSafetyErrorLike(e) {
48
+ return typeof e === 'object' && e !== null && e.name === 'HitlSafetyError';
49
+ }
@@ -4,6 +4,7 @@ import { hostLog } from '../host.js';
4
4
  import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
5
  import { observeCancelByDeny } from './hitlHostSurface.js';
6
6
  import { isAskTool } from './frameRouter.js';
7
+ import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
7
8
  /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
8
9
  const MAX_GATE_HOPS = 24;
9
10
  /**
@@ -121,7 +122,8 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
121
122
  };
122
123
  }
123
124
  // overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
124
- const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
125
+ // A-028.3:id gateIdentity 唯一铸口(呈现台账/壳侧重开腿的键推导同源,字面不再各铸各的)。
126
+ const questionId = askGateQuestionId(approvalCallKey(gatedCallId, taskId));
125
127
  const answer = await new Promise(resolve => {
126
128
  const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
127
129
  cleanup();
@@ -37,6 +37,7 @@
37
37
  * app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
38
38
  */
39
39
  import { type QuestionAnswer } from '../liveQuestionStore.js';
40
+ import type { ReopenCardVerdict } from '../adapter/activeRunSelfHeal.js';
40
41
  /**
41
42
  * REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
42
43
  * 人眼手抄同步(头注写着「与 client-core planReviewWire 的原卡逐字一致」)——漂一个字节,判决就
@@ -73,3 +74,53 @@ export declare function armPlanReviewApproval(result: unknown): boolean;
73
74
  * server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
74
75
  * reach the engine」臂。 */
75
76
  export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject'): Promise<void>;
77
+ /**
78
+ * {@link reopenPlanReviewCard} 的宿主参数。
79
+ */
80
+ export interface ReopenPlanReviewOpts {
81
+ /**
82
+ * 宿主 overlay 是否按 questionId 做会话级去重(cli REPL 的 overlay 钩子 = true:它有一个
83
+ * `seenQuestionIds` 集,为的是 at-least-once wire 的重投不把 hook 跑第二遍 —— 拿 canonical id
84
+ * 再发一次,帧会被静默吃掉,「重开了」就成了假话)。
85
+ * · `true`(缺省,两类宿主都安全):重开铸**每次都不同**的身份 `plan-review:<taskId>#reopen-*`;
86
+ * · `false`:responder 仍绑着时按 canonical id 原样重呈(同一张卡、同一个 responder ——
87
+ * 与 arm 臂的重放重呈短路同形),不产生第二次 responder 注册。
88
+ */
89
+ mintFreshQuestionId?: boolean;
90
+ /**
91
+ * 决断投递口(缺省 = 包内 `decidePlanReview` fire-and-forget,自带 outcome 通知管道)。
92
+ * 宿主要包一层重试/上屏编排(如 cli 的短退避恰一次重试腿)就从这里注入 —— 卡链/三态判决
93
+ * 单源在包,投递编排留端。
94
+ * 🔴 成文例外(0.29.0 发包扫描门):`mintFreshQuestionId:false` 且 canonical(arm)responder
95
+ * 仍绑着时走「同 id 重呈短路臂」——作答经 **arm 的既有 responder** 投递(缺省 decidePlanReview),
96
+ * 本注入口**不生效**(短路臂命中时 debug 留痕)。要保证注入口恒生效就走缺省 mintFresh 臂。
97
+ */
98
+ deliverDecision?: (taskId: string, decision: 'approve' | 'reject') => void | Promise<void>;
99
+ /**
100
+ * 多会话宿主的会话键(复审 [high] 补口;缺省 = DEFAULT_SESSION_KEY,cli 单会话装配零参不动)。
101
+ * overlay 在场检查 / 帧发布 / 呈现台账读写全部按此键走 `*For` 变体 —— 不传就落默认键,
102
+ * 多会话宿主不传 = 帧和台账都路由到别的会话的默认面,所以它们**必须**传自己的键。
103
+ * (`registerLocalQuestionResponder` 的 responder 表是进程级单表、id 全局唯一,不分键。)
104
+ * 🔴 已知局限(0.29.0 发包扫描门,候跟进票):`armPlanReviewApproval` 的 arm 臂是**默认会话**
105
+ * 装配(无 sessionKey 形参),其 responder 递交决断时清账走默认键 —— 非默认键会话对同一
106
+ * taskId 混用 arm 臂 + `mintFreshQuestionId:false` 重呈臂时,该键会话槽的呈现史不被消费
107
+ * (影响=后续首见/复见**文案**判定,动作两形一致)。多会话宿主避开该组合(用缺省 mintFresh)
108
+ * 或候 armPlanReviewApproval 补 sessionKey 位。
109
+ */
110
+ sessionKey?: string;
111
+ }
112
+ /** 测试钩:清空重开 responder 台账(跨用例状态)。 */
113
+ export declare function _resetActiveReopenRespondersForTest(): void;
114
+ /**
115
+ * 重开某个 parked plan_review 的审批卡(409 自愈分诊树 `attemptActiveRunSelfHeal` 的
116
+ * `reopenPlanReview` 注入口的包内生产实现)。判决形/成文语义见 {@link ReopenCardVerdict}
117
+ * (`adapter/activeRunSelfHeal.ts`);本函数不产 `presented` 位 —— 包看不到像素,回执机制归
118
+ * 宿主端包装。绝不抛(turn 收尾路径)。
119
+ *
120
+ * 与 arm 臂的合成关系(A-028.4):首呈(done 帧)= `armPlanReviewApproval`;重开(409 撞锁)=
121
+ * 本函数。两臂共用同一份题面构造点/标签单源/决断三态判决与同一条 `decidePlanReview` 投递管道;
122
+ * 「铸新 questionId 还是复用同 id」由宿主去重语义作参数(见 {@link ReopenPlanReviewOpts})。
123
+ * firstSight 判据 = 呈现台账(`armedGateRegistry`,键 = canonical `plan-review:<taskId>`);
124
+ * 先查后记 —— 顺序决定判决正确性。
125
+ */
126
+ export declare function reopenPlanReviewCard(taskId: string, opts?: ReopenPlanReviewOpts): ReopenCardVerdict;