@sema-agent/client-core 0.30.0 → 0.30.1

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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,61 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.30.1
20
+
21
+ - **#244 F1 决断卡链换装批 · 包半场(hitl/armedGateRegistry + hitl/planReviewWire;A-028.3/.4 二段,
22
+ +12 公面导出 +1 测试钩)**(2026-08-14;红先绿后:`run-selfheal-reopen-test.mjs` G9 修前红
23
+ (exit 1,分代/事件源/回执三位皆缺)→ 补位后 278 checks 绿):
24
+ - **arm 事件源上收**(cli `src/sema/armedGateRegistry.ts` 的呈现回执半场):`registerArmedGate(For)`
25
+ 每次调用发事件(不是集合成员测试 —— 复见重开的第二次呈现也要有回执);新导出
26
+ `waitForGateArmed(For)`(带窗一次性回执,timer 刻意不 unref)· `onGateArmed(For)`(#269 无窗
27
+ 事实订阅)· `gateArmedWaitMs`(缺省 5000ms 看门狗,`_setGateArmedWaitMsForTest` 覆盖)。
28
+ `registerArmedGateFromQuestionId(For)` 对**原始帧 id**(带 `#reopen-*` 尾)在归一键之外事件级
29
+ 加发一枪(尝试级回执;不入 Set)。
30
+ - **plan_review 呈现分代上收**(A-024.4;语义照 cli 现实现):wire 上没有 gate 实例位,恒定键会让
31
+ 同 run 第二只 plan 门 firstSight 恒 false(对从未呈现过的新卡谎报「被关过」)。新导出
32
+ `planReviewArmedKey(For)`(0 代与 canonical 字节同形,≥1 代接 `#g<N>` 尾)·
33
+ `notePlanReviewAnswered(For)`(决断记账:当代键清 + 推代,按完整 questionId 去重 —— overlay
34
+ answerAndRelease 与 responder 两条通路对同一张卡各报一次恰推一代)·
35
+ `notePlanReviewAnsweredIfDecisive(For)`(决断性门控:Esc/空答是 dismissal,不推代)。
36
+ 包内两个 responder(arm 臂 + 重开臂)的呈现史消费由 `clearArmedGate` 改走分代记账
37
+ (`wasGateArmed(canonical)` 在 0 代的决断后读面逐字兼容;`clearArmedGate(For)` 导出保留,语义不变)。
38
+ - **`reopenPlanReviewCard` 呈现回执模式**(新可选位 `ReopenPlanReviewOpts.presentationReceiptMs`;
39
+ 在场 ⇒ 返回 `Promise<ReopenCardVerdict>`,重载声明,存量同步调用零影响):publish 之后等宿主
40
+ 呈现面的登记事件(键=原始帧 id),回执到手才 `{reopened:true}`;窗尽如实 `{reopened:false}`
41
+ 且**零登记**(修前:发布即登记即报 true —— 帧被 overlay 去重集/hook 窗吃掉时,下一次重开会
42
+ 谎称复见);responder 窗尽后故意保留(迟到卡必须仍可答)。firstSight/登记键同批切到分代键。
43
+ - **#244 F1 · 409 自愈分诊树补假死锁复核读口(adapter/activeRunSelfHeal;A-028.1 二段,+1 导出)**
44
+ (2026-08-14):`ActiveRunSelfHealDeps` 新增 `listOwnedPendingApprovals?`([3892]-[3899] P0 防御,
45
+ 语义照 cli 现实现)—— `hasPendingDecision` 镜像说有卡时向引擎复核**属主** pending 行数,恰 0 =
46
+ 镜像脏值的正面证据 ⇒ 不 stand down 放行真分诊;>0 / 抛错 / 读口缺席 ⇒ 保守维持(存量装配零影响)。
47
+ 另导出 `INTERACTIVE_WAY_OUT`(默认出路串单源 —— 宿主 resume 对账腿等失败半场要说同一句真出路,
48
+ 各写一份必漂;覆盖语义照旧走 `ActiveRunSelfHealCopy.wayOut`)。
49
+ - **#244 F1 · 复审跟修批**(2026-08-14;同窗 codex 轮一三条 + cli 换装批 G-c 尾竞态,红先绿后
50
+ G9⑥/⑦ 四断言):① `waitForClaimRelease` 被**窗自己的截止**掐断的探测不再作废上一发完成读数
51
+ (404/传输错是「回答了不知道」才作废;不区分则 `confirmedHeld` 在窗尾随机翻 false,同输入两跑
52
+ 两种文案);② 假死锁复核读口加 **4s 有界窗**并合流 `deps.signal`(probe 恒不落地时整棵分诊树
53
+ 不再挂死 —— 窗尽=分不出真卡与幽灵,保守 stand down);③ plan 分代的去重记号随**新 canonical
54
+ 卡呈现**过期(arm 臂对同 run 每只 gate 复用 canonical id,记号不过期则第二只门的决断被第一只的
55
+ 记号吃掉 ⇒ 第三只门重开又谎报「被关过」);④ `reopenPlanReviewCard` 重载收紧:同步臂静态排除
56
+ `presentationReceiptMs`(宽类型带回执位的调用不再被谎报成同步),另给宽形联合兜底重载。
57
+ 轮二跟修(G9⑦c/⑦d 红先绿后):⑤ 复核竞速改**可回收**形 —— `listOwnedPendingApprovals` 契约
58
+ 加 `opts.signal`(合流 4s 窗 × `deps.signal`,装配方透传进真实读面;忽略该参的存量实现照常
59
+ 工作),finally 统一 abort(快成功不留 4s 悬挂计时器拖 `-p` 进程、窗尽真掐在飞请求);
60
+ ⑥ 已中止(进门前/竞速期间)一律不采信 `count===0` —— Esc 之后绝不接着放行重呈。
61
+ 轮二 [medium]「canonical 记号过期让旧卡迟到记账消费新卡」**驳回存档**:该窗(迟到重复记账
62
+ 跨越一次同 id 新卡呈现)的失真方向是首见话术 —— 零历史断言的诚实安全侧(反向才编造「被关
63
+ 过」);wire 无 gate 实例位([3664]),per-instance token 在答卡通路上结构性不可得,已在
64
+ `registerArmedGateFromQuestionIdFor` 注内成文。
65
+ - **行为增强(hitl/parkOwnership)**:`ParkOwnershipDeps.isOwnRun` 注入语义从**顶替**缺省台账腿改为
66
+ **并联**(默认会话键下,注入腿与包内 `isOwnEngineRun` 缺省腿任一命中即 owned)。修前「端为补一条
67
+ 自己的正向腿」会顺手关掉进程内台账腿,消费端必须记得自 OR(cli 1.0.76 扫码 P1 实翻,主板 [3925])。
68
+ fail-closed 方向不变(并联只多不少地要求正向证明);非默认 sessionKey 下缺省腿照旧整条跳过。
69
+ 已自 OR 的存量消费端不受影响(幂等)。门=park-decision-layer G6 四断言(修前 G6① 红留证)。
70
+ - **JSDoc 收敛(hitl/toolApprovalWire)**:`ruleSuggestionsReadOnly` 顶注红线从「绝不渲成可选中项」
71
+ 收敛到真不变量「决断字节绝不带规则位」——渲成可选中项允许,前提=兑付走客户端本地 settings 写
72
+ (cli 1.0.76 三态卡实形;`ef1be51`,随本版首次出包)。
73
+
19
74
  ## 0.30.0 — 2026-08-14
20
75
 
21
76
  > 发车门清账完成:0.30.0 发版扫描 36 条 confirmed 全处置(codex 八轮终轮零新),
@@ -153,6 +153,22 @@ export interface RunningChoiceRequest {
153
153
  export interface ActiveRunSelfHealDeps {
154
154
  /** 屏幕上是否已有一张待决卡(有 ⇒ 结局 = decision-pending,绝不重开第二张)。 */
155
155
  hasPendingDecision?: () => boolean;
156
+ /**
157
+ * [3892]-[3899] P0 假死锁防御(#244 F1 上收,语义照 cli 现实现):`hasPendingDecision` 的真值
158
+ * 常来自渲染队列长度的**本地镜像**,它在 `--resume` 后可能卡成脏值(队列里躺着一张从未渲上屏
159
+ * 的幽灵卡)—— 布尔真 + 屏上零真卡 ⇒ 每条消息被 decision-pending 臂闸死,而引擎侧那个 run 早已
160
+ * completed。本读口 = **引擎侧属主 pending 行数**(宿主装配点带归属过滤:引擎全局队列可躺着
161
+ * 别家会话的陈年行,裸计数恒非零 = 防御恒不触发,等于没修)。
162
+ * 判据:镜像说有卡时复核一次,**属主行数恰 0 = 镜像脏值的正面证据** ⇒ 不 stand down,继续真
163
+ * 分诊(放行重呈);>0 / 抛错 / 读口缺席 / 窗尽 / 调用方中止 ⇒ 保守维持 stand down(分不出
164
+ * 真卡与幽灵时,旧行为对真卡是对的;存量装配不注入时行为逐字不变)。
165
+ * 🔴 `opts.signal` = 本层合流的截止/中止口(4s 有界窗 × `deps.signal`),装配方**必须**把它
166
+ * 透传进真实读面(approvals.list)—— 只掐调用方的 await 不掐底层请求,「有界」就只是判决面
167
+ * 的说法(忽略该参的存量实现照常工作,只是失去被掐能力)。
168
+ */
169
+ listOwnedPendingApprovals?: (opts?: {
170
+ signal?: AbortSignal;
171
+ }) => Promise<number>;
156
172
  /** plan_review park 的重开口(生产 = 包 `hitl/planReviewWire.reopenPlanReviewCard`);
157
173
  * 判决形见 {@link ReopenCardVerdict}。同步/异步两式通吃(端的生产口可能要等呈现回执)。 */
158
174
  reopenPlanReview?: (taskId: string) => ReopenCardVerdict | Promise<ReopenCardVerdict>;
@@ -391,6 +407,11 @@ export interface ActiveRunSelfHealCopy {
391
407
  /** 整行覆盖(headless 面):返回 undefined = 落回默认串。 */
392
408
  headlessRowFor?: (signal: ActiveRunBusySignal) => string | undefined;
393
409
  }
410
+ /** cli 的唯一真出路(`/clear` = 新会话 id ⇒ 引擎侧全新 session ⇒ 不受旧 claim 影响)。
411
+ * 导出(#244 F1):宿主的 resume 冷启动对账腿等失败半场要说**同一句**真出路 —— 各写一份必漂
412
+ * (一句「按某个键」而那个键没注册,正是本文件头在骂的形)。覆盖语义照旧走
413
+ * {@link ActiveRunSelfHealCopy.wayOut},本常量只是默认串的单源。 */
414
+ export declare const INTERACTIVE_WAY_OUT = "run /clear to keep working in a fresh session";
394
415
  /**
395
416
  * 每种结局的上屏整行。**每一句出路都真的接了线**;没有真出路的分支就直说「这个会话暂时无法
396
417
  * 继续」。重开成功那条也要上屏 —— 卡是替用户重新打开的,他得知道去答哪张、答完做什么。
@@ -176,6 +176,9 @@ export function readSteerReceiptStatus(receipt) {
176
176
  }
177
177
  /** cancel 之后**有界**等那条 run 交出会话的缺省窗(`POST …/cancel` 是 202 异步 —— 收下 ≠ 已停)。 */
178
178
  export const CANCEL_RELEASE_WAIT_MS = 10_000;
179
+ /** 假死锁复核读口(`deps.listOwnedPendingApprovals`)的等待上界:一发 approvals 列表读,4s 是
180
+ * 「慢网也回得来」的诚实预算;窗尽=分不出真卡与幽灵 ⇒ 保守 stand down(见调用点注)。 */
181
+ const OWNED_PENDING_RECHECK_TIMEOUT_MS = 4_000;
179
182
  /** 轮询退避:200ms 起、×1.5、封顶 2s(短退避,别把一次 cancel 打成连发)。 */
180
183
  const CANCEL_POLL_START_MS = 200;
181
184
  const CANCEL_POLL_MAX_MS = 2_000;
@@ -267,11 +270,19 @@ export async function waitForClaimRelease(taskId, deps) {
267
270
  }));
268
271
  }
269
272
  catch {
270
- // 这一发读不出来 ⇒ 最新证据是「不知道」,陈旧的成功读数当场作废(见 lastStatus 头注)。
271
- lastStatus = null;
272
273
  lease.release();
273
274
  if (isAborted())
274
275
  return { released: false, waitedMs: waited(), aborted: true, lastStatus };
276
+ // 🔴 本发是被**窗自己的截止**掐掉的(lease 到点;caller 未中止)⇒ 没有读到任何回答 ——
277
+ // 既不是释放证据,也不是「读不出来」的新证据,上一发**完成了的**读数保持在座(#244 F1
278
+ // 换装批 G-c 尾竞态定谳:R2 [medium] 的「只记最近一次」指最近一次**有回答**的探测 ——
279
+ // 404/传输错是回答了「不知道」,截止掐断连回答都没有;不区分则 confirmedHeld 在窗尾随机
280
+ // 翻 false,同输入两跑两种文案)。回循环顶按墙钟收口。
281
+ if (lease.signal.aborted)
282
+ continue;
283
+ // 这一发**有回答但读不出**(404/传输错)⇒ 最新证据是「不知道」,陈旧的成功读数当场作废
284
+ // (见 lastStatus 头注)。
285
+ lastStatus = null;
275
286
  // 🔴 **404 也只是「读不到」,不是「释放了」**(二次评审 [high] 处置,2026-08-14):这个读口的
276
287
  // 404 在 SDK 契约上是 `not_found.run` —— 「这条 run 不存在」与「它不是你的」**共用同一个码,
277
288
  // 且是刻意设计的不可分辨**(跨租户不给存在性预言机)。凭它断言「那条 run 已经不占着会话」,
@@ -302,6 +313,41 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
302
313
  catch {
303
314
  pending = false; // 读不到就当没有 —— 这一项只用来**减少**破坏性动作,不该反过来卡住自愈
304
315
  }
316
+ // 🔴 经函数读(waitForClaimRelease 的 isAborted 同注):`aborted` 会在 await 两侧变,直接读两次
317
+ // 会被 tsc 的控制流分析把第二次窄成恒假比较(TS2367)。
318
+ const callerAborted = () => deps?.signal?.aborted === true;
319
+ if (pending && typeof deps?.listOwnedPendingApprovals === 'function' && !callerAborted()) {
320
+ // [3892]-[3899] 假死锁防御(deps.listOwnedPendingApprovals 头注全案):镜像说有卡 ⇒ 向引擎
321
+ // 复核一次属主 pending 行数。恰 0 = 镜像脏值的**正面证据**(引擎侧没有任何等这个用户的决定)
322
+ // ⇒ 放行真分诊;抛错/非 0 ⇒ 保守维持(真卡在屏时 stand down 是对的)。
323
+ // 🔴 复核必须**有界且可回收**(codex #244 F1 轮一 [high] + 轮二 [medium]×2):
324
+ // · probe 恒不落地(引擎挂死且读口无自带超时)不许把整棵分诊树挂死 —— 假死锁防御自己变成
325
+ // 真死锁;窗尽/调用方中止 = 分不出真卡与幽灵,与抛错同臂保守 stand down;
326
+ // · 合流 signal **真交到 probe 上**(超时/中止掐的是底层请求,不是只掐调用方的 await);
327
+ // · 竞速败方必须收口:快成功时 4s sleep 的普通计时器不清,-p 车道进程会被它多拖活一窗
328
+ // (finally 统一 abort ⇒ abortableSleep 清 timer、在飞 probe 被掐);
329
+ // · 已中止(进门前/竞速期间)一律不采信 count —— Esc 之后接着放行重呈 = 用户说停还弹卡。
330
+ const recheckCtl = new AbortController();
331
+ const onCallerAbort = () => {
332
+ recheckCtl.abort(deps.signal?.reason);
333
+ };
334
+ deps.signal?.addEventListener('abort', onCallerAbort, { once: true });
335
+ try {
336
+ const count = await Promise.race([
337
+ deps.listOwnedPendingApprovals({ signal: recheckCtl.signal }),
338
+ claimPollSleep(OWNED_PENDING_RECHECK_TIMEOUT_MS, recheckCtl.signal).then(() => null),
339
+ ]);
340
+ if (count === 0 && !callerAborted())
341
+ pending = false;
342
+ }
343
+ catch {
344
+ /* 复核失败分不出真卡与幽灵 —— 维持 stand down,下一条消息再走一遍本腿 */
345
+ }
346
+ finally {
347
+ deps.signal?.removeEventListener('abort', onCallerAbort);
348
+ recheckCtl.abort(new Error('owned-pending recheck settled'));
349
+ }
350
+ }
305
351
  if (pending)
306
352
  return { kind: 'decision-pending', taskId: signal.activeTaskId };
307
353
  const taskId = signal.activeTaskId;
@@ -513,8 +559,12 @@ async function runningChoiceArm(taskId, status, runs, deps) {
513
559
  // 用户选了 ③ / 卡没答上 / 选了一条本轮不可兑现的路 ⇒ 零动作,如实转述现状。
514
560
  return notParked;
515
561
  }
516
- /** cli 的唯一真出路(`/clear` = 新会话 id ⇒ 引擎侧全新 session ⇒ 不受旧 claim 影响)。 */
517
- const DEFAULT_WAY_OUT = 'run /clear to keep working in a fresh session';
562
+ /** cli 的唯一真出路(`/clear` = 新会话 id ⇒ 引擎侧全新 session ⇒ 不受旧 claim 影响)。
563
+ * 导出(#244 F1):宿主的 resume 冷启动对账腿等失败半场要说**同一句**真出路 —— 各写一份必漂
564
+ * (一句「按某个键」而那个键没注册,正是本文件头在骂的形)。覆盖语义照旧走
565
+ * {@link ActiveRunSelfHealCopy.wayOut},本常量只是默认串的单源。 */
566
+ export const INTERACTIVE_WAY_OUT = 'run /clear to keep working in a fresh session';
567
+ const DEFAULT_WAY_OUT = INTERACTIVE_WAY_OUT;
518
568
  const DEFAULT_FRESH_SESSION = 'start a new session (drop --resume/--continue)';
519
569
  /**
520
570
  * 「在等哪一种决定」的人话 —— **只由 wire 给的 `pendingGate.kind` 铸**。引擎对未知 gate 种类
@@ -1,4 +1,5 @@
1
- /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。 */
1
+ /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
2
+ * 每次调用都发事件(复见重开的第二次呈现也要有回执;Set 成员级当不了事件源)。 */
2
3
  export declare function registerArmedGate(key: string | null | undefined): void;
3
4
  /** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
4
5
  export declare function registerArmedGateFor(sessionKey: string, key: string | null | undefined): void;
@@ -6,14 +7,71 @@ export declare function registerArmedGateFor(sessionKey: string, key: string | n
6
7
  export declare function wasGateArmed(key: string | null | undefined): boolean;
7
8
  /** W1 带 key 变体。 */
8
9
  export declare function wasGateArmedFor(sessionKey: string, key: string | null | undefined): boolean;
9
- /** 消费一个身份键(A-024.4:plan_review 决断递交后清键 —— taskId 的下一个 plan gate 读回首见)。 */
10
+ /** 消费一个身份键(plan 族的决断消费请优先走 {@link notePlanReviewAnswered} —— 它同时推代;
11
+ * 本口保留给「只清账不推代」的宿主场景与 0.30.0 存量消费方,语义不变)。 */
10
12
  export declare function clearArmedGate(key: string | null | undefined): void;
11
13
  /** W1 带 key 变体。 */
12
14
  export declare function clearArmedGateFor(sessionKey: string, key: string | null | undefined): void;
15
+ /**
16
+ * 订阅 arm 事件(#269 上收):返回退订钩,调用方**必须**在自己的生命周期末调它(监听器挂在
17
+ * 模块级长存表上,漏退 = 闭包泄漏)。
18
+ *
19
+ * 🔴 为什么不复用 {@link waitForGateArmed}:那是**带窗**的一次性回执(超时即 resolve false),
20
+ * 它答的是「看门狗窗内呈现了没有」;#269 的 tool 门臂另外要问一个**无窗**的事实 ——「这条链的
21
+ * 整个寿命里,这张卡到底有没有呈现过」。用带窗的那只当事实源,会在「迟到卡」(窗后才入队)上
22
+ * 给出 false ⇒ 把一次真呈现误判成「行从未出生」⇒ 回环再铸一张卡,同一个待决项两张可按的卡。
23
+ */
24
+ export declare function onGateArmed(listener: (key: string) => void): () => void;
25
+ /** W1 带 key 变体。 */
26
+ export declare function onGateArmedFor(sessionKey: string, listener: (key: string) => void): () => void;
27
+ /** 测试用:缩短回执看门狗(null 复位)。 */
28
+ export declare function _setGateArmedWaitMsForTest(ms: number | null): void;
29
+ /** 回执看门狗现值(重开臂的缺省等待窗)。 */
30
+ export declare function gateArmedWaitMs(): number;
31
+ /**
32
+ * 等「这几个键里任意一个被 arm」的**事件**(#250 件1 呈现回执)。resolve true = 回执到手;
33
+ * false = 超时(呈现链没走到真入队 —— hook 自动应答/去重丢帧/enqueue 失败/处理器僵死)。
34
+ *
35
+ * 🔴 timer **不 unref**(包 `abortableSleep` 同判据):调用方(重开臂)正 await 本 promise 收
36
+ * verdict —— 回执窗里这只 timer 可能是事件循环里唯一的活,unref 会让 `-p` 车道进程在 await
37
+ * 中途直接退出,诚实的 reopen-failed 行一并蒸发。代价上限 = 一次 timeoutMs 的进程存活延长,
38
+ * 换判决必达。(浏览器宿主无 unref 概念,裸 setTimeout 两端行为一致 —— portability 零分支。)
39
+ */
40
+ export declare function waitForGateArmed(keys: readonly string[], timeoutMs: number): Promise<boolean>;
41
+ /** W1 带 key 变体。 */
42
+ export declare function waitForGateArmedFor(sessionKey: string, keys: readonly string[], timeoutMs: number): Promise<boolean>;
13
43
  /** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
14
- * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。 */
44
+ * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
45
+ *
46
+ * 尝试级回执(#250 codex 轮二 [medium]):原始帧 id 自带 `#reopen-*` 尝试序号,是现成的
47
+ * attempt token —— 归一键之外**事件级**再发一枪原始 id(不入 Set,台账词汇保持归一键)。
48
+ * 重开臂锚它,同 gate 两次在飞重开时一次真入队只唤对应那次尝试,不再同键互唤。 */
15
49
  export declare function registerArmedGateFromQuestionId(questionId: unknown): void;
16
50
  /** W1 带 key 变体。 */
17
51
  export declare function registerArmedGateFromQuestionIdFor(sessionKey: string, questionId: unknown): void;
18
- /** 测试用:清全部会话的台账。 */
52
+ /**
53
+ * plan_review 的台账键(当代)。0 代 = `planReviewQuestionId(taskId)`(canonical 原形 ——
54
+ * overlay 对**原卡**(done 帧首扎)的登记落进同一键);≥1 代 = 同形 + `#g<N>` 尾。
55
+ */
56
+ export declare function planReviewArmedKey(taskId: string): string;
57
+ /** W1 带 key 变体。 */
58
+ export declare function planReviewArmedKeyFor(sessionKey: string, taskId: string): string;
59
+ /**
60
+ * 记「一张 plan 问答卡被答掉了」⇒ 当代门已被消费,同 run 的**下一只** plan 门是新实例,落新键。
61
+ *
62
+ * ── 为什么锚在「答卡」而不是「决断送达」(局限成文,A-024.4)────────────────────────────────
63
+ * wire 上没有 gate 实例位,宿主能看见的最晚可靠时刻是作答(送达成败在 responder 返回之后才
64
+ * 揭晓)。所以按「答卡」推代。方向代价:决断若终败(重试后仍没送达),门 1 其实还 pending,
65
+ * 下一次重开会判成首见 ——「呈上了一张卡」零历史断言,诚实方向安全;反向(不推代)才会编造
66
+ * 「被关过」。当代键同时从台账清掉(`clearArmedGate` 的 0.30.0 语义在 0 代逐字保持:决断递交后
67
+ * `wasGateArmed(canonical)` 读回 false)。
68
+ *
69
+ * 🔴 决断性门控请走 {@link notePlanReviewAnsweredIfDecisive}(`planReviewWire`):Esc/空答是
70
+ * dismissal 不是决断 —— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc
71
+ * 关卡)上要保住的区分。本口只做记账,不做分类。
72
+ */
73
+ export declare function notePlanReviewAnswered(questionId: unknown): void;
74
+ /** W1 带 key 变体。 */
75
+ export declare function notePlanReviewAnsweredFor(sessionKey: string, questionId: unknown): void;
76
+ /** 测试用:清全部会话的台账(含监听器/代数/答卡记账 —— 半清会让跨用例判决互相污染)。 */
19
77
  export declare function _resetArmedGateRegistryForTest(): void;
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * armedGateRegistry.ts — per-session 呈现台账:哪些决断卡在**这个宿主进程的哪个会话**里真的
3
3
  * 呈现过(A-028.3,#244 族A 包半场,2026-08-12;源形 = cli `src/sema/armedGateRegistry.ts`,
4
- * 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主)。
4
+ * 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主。二段(#244 F1 换装批,
5
+ * 2026-08-14):壳侧剩余两位 —— **arm 事件源**(呈现回执)与 **plan_review 呈现分代** —— 一并
6
+ * 上收,壳侧台账本体退役)。
5
7
  *
6
8
  * ── 为什么需要它 ────────────────────────────────────────────────────────────────────────────
7
9
  * 409 自愈重开臂(`adapter/activeRunSelfHeal.ts` 的分诊消费方)此前对每一张 pending 卡都说
@@ -20,25 +22,49 @@
20
22
  * {@link registerArmedGateFromQuestionId} 剥前缀取键(= callKey,与卡键同域)。
21
23
  * · plan_review:`planReviewQuestionId(taskId)` 整串作键(保留前缀,与 taskId 直接作 ask 键
22
24
  * 永不撞域);重开腿铸 `…#reopen-*`,归一化剥尾 —— 首呈与重开落同一键。
25
+ * 🔴 A-024.4(#250,#244 F1 起分代在包):wire 上没有 gate 实例位([3664] pendingGate 只有
26
+ * kind/decidePath),同 run 推进到第二只 plan 门时,恒定键会让 firstSight 恒 false ⇒ 对一张
27
+ * 从未呈现过的新卡编造「被关过」历史。plan 键因此按**呈现代数**分代(见
28
+ * {@link planReviewArmedKeyFor} / {@link notePlanReviewAnsweredFor}):0 代键与 canonical
29
+ * 字节同形,≥1 代接 `#g<N>` 尾;决断性作答推代(消费当代门),下一只门读回首见。
23
30
  *
24
31
  * ── 登记点(「没呈现就不算 arm」)───────────────────────────────────────────────────────────
25
32
  * ① 宿主呈现面:overlay 收到可渲染 question 帧时 / 卡口真把卡 enqueue 进渲染队列时,宿主调
26
33
  * {@link registerArmedGateFromQuestionId} / {@link registerArmedGate}(auto-allow/deny/
27
34
  * 失败臂不登记)。包的 publish 点**不**代登记 —— publish 无 overlay 时是 no-op,代登记 =
28
35
  * 把「发过帧」谎报成「呈现过」。
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 唯一),无此问题,不清。
36
+ * ② 重开腿:先读(firstSight 判决)后写。🔴 回执模式(`planReviewWire`
37
+ * `presentationReceiptMs`,#250 件1 语义)下重开腿**零登记**:登记时点 = 宿主呈现面的真
38
+ * 入队点( 的调用),零回执还登记 = 下次谎称复见。
39
+ * 消费点:plan_review 的**决断性**作答(approve/reject)经 {@link notePlanReviewAnswered}
40
+ * 族推代(当代键同时清掉,`clearArmedGate` 的旧语义在 0 代保持逐字兼容);dismissal
41
+ * (Esc/空答)不推代 —— 门没被消费,下次重开要照实说「reopened」。ask 族键粒度是
42
+ * callId(每 gate 唯一),无此问题,不消费。
43
+ *
44
+ * ── arm 事件源(#250 件1「呈现回执」;#244 F1 上收)────────────────────────────────────────
45
+ * `registerArmedGate` 是「卡真呈现了」的唯一汇聚点,所以重开腿的回执 = 等它的**调用事件**
46
+ * ({@link waitForGateArmed})。事件按每次调用发(不是集合成员测试)—— 复见重开的第二次呈现
47
+ * 同样要有回执;集合本身 arm 一次恒真,当不了事件源。{@link onGateArmed} 是**无窗**订阅位
48
+ * (#269 tool 门臂问的是「这条链的整个寿命里这张卡到底呈现过没有」,带窗回执答不了它)。
49
+ * 尝试级回执:{@link registerArmedGateFromQuestionId} 对**原始帧 id**(带 `#reopen-*` 尾)在
50
+ * 归一键之外事件级加发一枪(不入 Set)—— 同 gate 两次在飞重开时,一次真入队只唤对应那次尝试。
51
+ * 🔴 承重前提(server [3664]③):local 部署形 attach 重连恒零卡帧补发 —— 回执必须宿主进程内闭环。
34
52
  *
35
53
  * per-session 语义:sessionSlot 注册表(desktop session-host 多引擎会话互不串账);零参 API =
36
54
  * DEFAULT_SESSION_KEY 兼容层,单会话宿主(cli)装配一行不动。callId 全局唯一,跨 /clear 不撞键。
37
55
  */
38
56
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
39
- import { armedKeyFromQuestionId } from './gateIdentity.js';
57
+ import { armedKeyFromQuestionId, PLAN_REVIEW_QUESTION_ID_PREFIX, planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
40
58
  // 🔴 模块级单例(singleton-manifest 登记):per-session 键 → 已呈现身份键集。
41
59
  const armedGatesByKey = createSessionSlot();
60
+ // 🔴 同为模块级单例:per-session 键 → 监听器集(两份实例 ⇒ 登记面与等待面各持一半,回执恒 miss)。
61
+ const armedListenersByKey = createSessionSlot();
62
+ // ── plan_review 呈现分代(A-024.4;#244 F1 上收,语义照壳现实现)──────────────────────────────
63
+ /** per-session:taskId → 已消费的 plan 门数(= 当前代数;0 代键保持与 canonical 字节同形)。 */
64
+ const planReviewGenByKey = createSessionSlot();
65
+ /** per-session:已记过账的 plan 问答卡 id(答卡两条通路 —— overlay answerAndRelease 与 responder
66
+ * —— 会对同一张卡各报一次,按完整 questionId 去重,恰推一代)。 */
67
+ const notedPlanReviewIdsByKey = createSessionSlot();
42
68
  function setFor(sessionKey) {
43
69
  let s = armedGatesByKey.get(sessionKey);
44
70
  if (!s) {
@@ -47,14 +73,52 @@ function setFor(sessionKey) {
47
73
  }
48
74
  return s;
49
75
  }
50
- /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链) */
76
+ function listenersFor(sessionKey) {
77
+ let s = armedListenersByKey.get(sessionKey);
78
+ if (!s) {
79
+ s = new Set();
80
+ armedListenersByKey.set(sessionKey, s);
81
+ }
82
+ return s;
83
+ }
84
+ function genMapFor(sessionKey) {
85
+ let m = planReviewGenByKey.get(sessionKey);
86
+ if (!m) {
87
+ m = new Map();
88
+ planReviewGenByKey.set(sessionKey, m);
89
+ }
90
+ return m;
91
+ }
92
+ function notedIdsFor(sessionKey) {
93
+ let s = notedPlanReviewIdsByKey.get(sessionKey);
94
+ if (!s) {
95
+ s = new Set();
96
+ notedPlanReviewIdsByKey.set(sessionKey, s);
97
+ }
98
+ return s;
99
+ }
100
+ /** 事件级发一枪(不动 Set)。监听器抛错不许炸登记面 —— 回执面是诊断/判决辅助面。 */
101
+ function emitGateArmedEventFor(sessionKey, key) {
102
+ for (const listener of [...listenersFor(sessionKey)]) {
103
+ try {
104
+ listener(key);
105
+ }
106
+ catch {
107
+ /* 绝不反噬呈现链 */
108
+ }
109
+ }
110
+ }
111
+ /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
112
+ * 每次调用都发事件(复见重开的第二次呈现也要有回执;Set 成员级当不了事件源)。 */
51
113
  export function registerArmedGate(key) {
52
114
  registerArmedGateFor(DEFAULT_SESSION_KEY, key);
53
115
  }
54
116
  /** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
55
117
  export function registerArmedGateFor(sessionKey, key) {
56
- if (typeof key === 'string' && key.length > 0)
57
- setFor(sessionKey).add(key);
118
+ if (typeof key !== 'string' || key.length === 0)
119
+ return;
120
+ setFor(sessionKey).add(key);
121
+ emitGateArmedEventFor(sessionKey, key);
58
122
  }
59
123
  /** 该身份键的卡在本进程本会话呈现过吗?(false = 首见;文案分形的唯一判据) */
60
124
  export function wasGateArmed(key) {
@@ -66,7 +130,8 @@ export function wasGateArmedFor(sessionKey, key) {
66
130
  return false;
67
131
  return armedGatesByKey.get(sessionKey)?.has(key) === true;
68
132
  }
69
- /** 消费一个身份键(A-024.4:plan_review 决断递交后清键 —— taskId 的下一个 plan gate 读回首见)。 */
133
+ /** 消费一个身份键(plan 族的决断消费请优先走 {@link notePlanReviewAnswered} —— 它同时推代;
134
+ * 本口保留给「只清账不推代」的宿主场景与 0.30.0 存量消费方,语义不变)。 */
70
135
  export function clearArmedGate(key) {
71
136
  clearArmedGateFor(DEFAULT_SESSION_KEY, key);
72
137
  }
@@ -75,8 +140,92 @@ export function clearArmedGateFor(sessionKey, key) {
75
140
  if (typeof key === 'string' && key.length > 0)
76
141
  armedGatesByKey.get(sessionKey)?.delete(key);
77
142
  }
143
+ /**
144
+ * 订阅 arm 事件(#269 上收):返回退订钩,调用方**必须**在自己的生命周期末调它(监听器挂在
145
+ * 模块级长存表上,漏退 = 闭包泄漏)。
146
+ *
147
+ * 🔴 为什么不复用 {@link waitForGateArmed}:那是**带窗**的一次性回执(超时即 resolve false),
148
+ * 它答的是「看门狗窗内呈现了没有」;#269 的 tool 门臂另外要问一个**无窗**的事实 ——「这条链的
149
+ * 整个寿命里,这张卡到底有没有呈现过」。用带窗的那只当事实源,会在「迟到卡」(窗后才入队)上
150
+ * 给出 false ⇒ 把一次真呈现误判成「行从未出生」⇒ 回环再铸一张卡,同一个待决项两张可按的卡。
151
+ */
152
+ export function onGateArmed(listener) {
153
+ return onGateArmedFor(DEFAULT_SESSION_KEY, listener);
154
+ }
155
+ /** W1 带 key 变体。 */
156
+ export function onGateArmedFor(sessionKey, listener) {
157
+ const listeners = listenersFor(sessionKey);
158
+ listeners.add(listener);
159
+ return () => {
160
+ listeners.delete(listener);
161
+ };
162
+ }
163
+ /**
164
+ * 呈现回执的缺省看门狗窗(#250 件1;重开臂共用单源,免得每臂各持一个会漂的数)。要盖住的最慢
165
+ * 真实成功路:tool 门臂整链重跑一次(attempt→2s 退避→attempt,各含一跳 approvals.list)与
166
+ * question 臂 overlay 的 PermissionRequest hooks 窗。
167
+ */
168
+ const GATE_ARMED_WAIT_MS = 5000;
169
+ let gateArmedWaitOverrideMs = null;
170
+ /** 测试用:缩短回执看门狗(null 复位)。 */
171
+ export function _setGateArmedWaitMsForTest(ms) {
172
+ gateArmedWaitOverrideMs = ms;
173
+ }
174
+ /** 回执看门狗现值(重开臂的缺省等待窗)。 */
175
+ export function gateArmedWaitMs() {
176
+ return gateArmedWaitOverrideMs ?? GATE_ARMED_WAIT_MS;
177
+ }
178
+ /**
179
+ * 等「这几个键里任意一个被 arm」的**事件**(#250 件1 呈现回执)。resolve true = 回执到手;
180
+ * false = 超时(呈现链没走到真入队 —— hook 自动应答/去重丢帧/enqueue 失败/处理器僵死)。
181
+ *
182
+ * 🔴 timer **不 unref**(包 `abortableSleep` 同判据):调用方(重开臂)正 await 本 promise 收
183
+ * verdict —— 回执窗里这只 timer 可能是事件循环里唯一的活,unref 会让 `-p` 车道进程在 await
184
+ * 中途直接退出,诚实的 reopen-failed 行一并蒸发。代价上限 = 一次 timeoutMs 的进程存活延长,
185
+ * 换判决必达。(浏览器宿主无 unref 概念,裸 setTimeout 两端行为一致 —— portability 零分支。)
186
+ */
187
+ export function waitForGateArmed(keys, timeoutMs) {
188
+ return waitForGateArmedFor(DEFAULT_SESSION_KEY, keys, timeoutMs);
189
+ }
190
+ /** W1 带 key 变体。 */
191
+ export function waitForGateArmedFor(sessionKey, keys, timeoutMs) {
192
+ const wanted = new Set(keys.filter((k) => typeof k === 'string' && k.length > 0));
193
+ if (wanted.size === 0)
194
+ return Promise.resolve(false);
195
+ const listeners = listenersFor(sessionKey);
196
+ return new Promise((resolve) => {
197
+ const listener = (key) => {
198
+ if (!wanted.has(key))
199
+ return;
200
+ listeners.delete(listener);
201
+ clearTimeout(timer);
202
+ resolve(true);
203
+ };
204
+ listeners.add(listener);
205
+ const timer = setTimeout(() => {
206
+ listeners.delete(listener);
207
+ resolve(false);
208
+ }, timeoutMs);
209
+ });
210
+ }
211
+ /**
212
+ * question 帧 id → 台账键(登记口用):基础归一(剥 ask 前缀/剥重开尾)= `gateIdentity.
213
+ * armedKeyFromQuestionId`;plan_review 形在其上叠**呈现代数**(A-024.4)—— 登记与查询都落在
214
+ * **当代**键上。不认识的形原样入册(未来新 id 形至多多占一个键,不误伤既有词汇)。
215
+ */
216
+ function armedKeyForQuestionIdFor(sessionKey, questionId) {
217
+ const base = armedKeyFromQuestionId(questionId);
218
+ if (base.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX)) {
219
+ return planReviewArmedKeyFor(sessionKey, base.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length));
220
+ }
221
+ return base;
222
+ }
78
223
  /** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
79
- * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。 */
224
+ * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
225
+ *
226
+ * 尝试级回执(#250 codex 轮二 [medium]):原始帧 id 自带 `#reopen-*` 尝试序号,是现成的
227
+ * attempt token —— 归一键之外**事件级**再发一枪原始 id(不入 Set,台账词汇保持归一键)。
228
+ * 重开臂锚它,同 gate 两次在飞重开时一次真入队只唤对应那次尝试,不再同键互唤。 */
80
229
  export function registerArmedGateFromQuestionId(questionId) {
81
230
  registerArmedGateFromQuestionIdFor(DEFAULT_SESSION_KEY, questionId);
82
231
  }
@@ -84,9 +233,72 @@ export function registerArmedGateFromQuestionId(questionId) {
84
233
  export function registerArmedGateFromQuestionIdFor(sessionKey, questionId) {
85
234
  if (typeof questionId !== 'string' || questionId.length === 0)
86
235
  return;
87
- registerArmedGateFor(sessionKey, armedKeyFromQuestionId(questionId));
236
+ // 🔴 canonical 复用的去重记号过期(codex #244 F1 轮一 [medium]):arm 臂对同 run 每只 plan gate
237
+ // 都复用 `plan-review:<taskId>` 这个 canonical id —— 决断记账按完整 questionId 去重,若记号不随
238
+ // 卡换代过期,第二只 canonical 卡的决断会撞上第一只留下的记号 ⇒ 不推代 ⇒ 第三只门的重开又谎报
239
+ // 「被关过」。**新 canonical 卡的呈现**就是「上一张同 id 卡已消解、这是新实例」的宿主侧可见时刻,
240
+ // 在此把旧记号过期;reopen 尾 id 进程内唯一,记号永不相撞,不用过期。方向:同一张卡「两路各报
241
+ // 一次」之间夹进一次同 id 重呈会双推代 —— 误差落首见话术(零历史断言),诚实安全侧。
242
+ if (questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX) && !questionId.includes(REOPEN_ID_TAIL)) {
243
+ notedPlanReviewIdsByKey.get(sessionKey)?.delete(questionId);
244
+ }
245
+ const normalized = armedKeyForQuestionIdFor(sessionKey, questionId);
246
+ registerArmedGateFor(sessionKey, normalized);
247
+ if (questionId !== normalized)
248
+ emitGateArmedEventFor(sessionKey, questionId);
249
+ }
250
+ /**
251
+ * plan_review 的台账键(当代)。0 代 = `planReviewQuestionId(taskId)`(canonical 原形 ——
252
+ * overlay 对**原卡**(done 帧首扎)的登记落进同一键);≥1 代 = 同形 + `#g<N>` 尾。
253
+ */
254
+ export function planReviewArmedKey(taskId) {
255
+ return planReviewArmedKeyFor(DEFAULT_SESSION_KEY, taskId);
256
+ }
257
+ /** W1 带 key 变体。 */
258
+ export function planReviewArmedKeyFor(sessionKey, taskId) {
259
+ const gen = planReviewGenByKey.get(sessionKey)?.get(taskId) ?? 0;
260
+ const canonical = planReviewQuestionId(taskId);
261
+ return gen > 0 ? `${canonical}#g${String(gen)}` : canonical;
262
+ }
263
+ /**
264
+ * 记「一张 plan 问答卡被答掉了」⇒ 当代门已被消费,同 run 的**下一只** plan 门是新实例,落新键。
265
+ *
266
+ * ── 为什么锚在「答卡」而不是「决断送达」(局限成文,A-024.4)────────────────────────────────
267
+ * wire 上没有 gate 实例位,宿主能看见的最晚可靠时刻是作答(送达成败在 responder 返回之后才
268
+ * 揭晓)。所以按「答卡」推代。方向代价:决断若终败(重试后仍没送达),门 1 其实还 pending,
269
+ * 下一次重开会判成首见 ——「呈上了一张卡」零历史断言,诚实方向安全;反向(不推代)才会编造
270
+ * 「被关过」。当代键同时从台账清掉(`clearArmedGate` 的 0.30.0 语义在 0 代逐字保持:决断递交后
271
+ * `wasGateArmed(canonical)` 读回 false)。
272
+ *
273
+ * 🔴 决断性门控请走 {@link notePlanReviewAnsweredIfDecisive}(`planReviewWire`):Esc/空答是
274
+ * dismissal 不是决断 —— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc
275
+ * 关卡)上要保住的区分。本口只做记账,不做分类。
276
+ */
277
+ export function notePlanReviewAnswered(questionId) {
278
+ notePlanReviewAnsweredFor(DEFAULT_SESSION_KEY, questionId);
279
+ }
280
+ /** W1 带 key 变体。 */
281
+ export function notePlanReviewAnsweredFor(sessionKey, questionId) {
282
+ if (typeof questionId !== 'string' || !questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX))
283
+ return;
284
+ const noted = notedIdsFor(sessionKey);
285
+ if (noted.has(questionId))
286
+ return;
287
+ noted.add(questionId);
288
+ const base = questionId.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length);
289
+ const cut = base.indexOf(REOPEN_ID_TAIL);
290
+ const taskId = cut >= 0 ? base.slice(0, cut) : base;
291
+ if (taskId.length === 0)
292
+ return;
293
+ // 当代键先清(0 代 = canonical,保持 0.30.0 clear-on-decide 的读面语义)再推代。
294
+ clearArmedGateFor(sessionKey, planReviewArmedKeyFor(sessionKey, taskId));
295
+ const gens = genMapFor(sessionKey);
296
+ gens.set(taskId, (gens.get(taskId) ?? 0) + 1);
88
297
  }
89
- /** 测试用:清全部会话的台账。 */
298
+ /** 测试用:清全部会话的台账(含监听器/代数/答卡记账 —— 半清会让跨用例判决互相污染)。 */
90
299
  export function _resetArmedGateRegistryForTest() {
91
300
  armedGatesByKey.clear();
301
+ armedListenersByKey.clear();
302
+ planReviewGenByKey.clear();
303
+ notedPlanReviewIdsByKey.clear();
92
304
  }
@@ -36,7 +36,8 @@ export interface ParkOwnershipDeps {
36
36
  /** 会话腿真源(缺省 = `hostSessionFor(sessionKey).currentSessionId()`;空串按缺席归一,
37
37
  * 判不出 ≠ 命中)。 */
38
38
  currentSessionId?: () => string | undefined;
39
- /** own-run 台账腿(缺省 = 包 `isOwnEngineRun`,**仅默认会话键**下启用,理由见 sessionKey 注)。
39
+ /** own-run 台账腿(**并联**:默认会话键下注入腿与包 `isOwnEngineRun` 缺省腿任一命中即 owned
40
+ * —— 注入是**补腿不是换腿**,cli 1.0.76 扫码 P1 跟修;非默认键下缺省腿整条跳过,见 sessionKey 注)。
40
41
  * 🔴 键域 = `rowIdTail(行 taskId)`(fleet 台账登记时就过 rowIdTail,本判据同域取键后才调本口)。
41
42
  * 🔴 成文局限(复审二轮裁定,不改行为):默认键下的缺省腿是**进程级**证据,不区分同一宿主
42
43
  * 进程内的会话代际 —— `/clear` 前登记的 run 在新会话语境下仍判 owned。这是单会话宿主的
@@ -11,9 +11,17 @@ export function pendingRowIsOwnedByThisSession(row, deps) {
11
11
  // ① 本宿主亲手驱动的 run / 由它闭包进来的自家子代 —— 不依赖会话 id。
12
12
  // 进程级缺省腿只在默认会话键下启用(多会话宿主必须注入会话粒度的口,见 deps.sessionKey 注)。
13
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;
14
+ // 🔴 注入腿与缺省台账腿是**并联**不是顶替(cli 1.0.76 扫码 P1 的包 API 半场,主板 [3925]):
15
+ // 此前 `deps?.isOwnRun ?? isOwnEngineRun` 让「端为补一条自己的正向腿」变成「顺手关掉进程内
16
+ // 台账腿」——补一条断一条,消费端得记得自 OR 才不踩(cli 复核读口实翻)。并联只多不少地
17
+ // 要求正向证明,fail-closed 方向不变;非默认键下缺省腿照旧整条跳过(sessionKey 注)。
18
+ if (rowTaskId.length > 0) {
19
+ const tail = rowIdTail(rowTaskId);
20
+ if (deps?.isOwnRun !== undefined && deps.isOwnRun(tail))
21
+ return true;
22
+ if (sessionKey === DEFAULT_SESSION_KEY && isOwnEngineRun(tail))
23
+ return true;
24
+ }
17
25
  // ② 行自带 sessionId === 本会话当前引擎会话。任一侧缺席/空串 = 判不出,不是命中。
18
26
  const currentSessionId = deps?.currentSessionId ?? (() => hostSessionFor(sessionKey)?.currentSessionId());
19
27
  const sessionId = currentSessionId();
@@ -54,6 +54,19 @@ export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
54
54
  * 负责如实分类,不替调用方做选择。
55
55
  */
56
56
  export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined): 'approve' | 'reject' | 'dismissed';
57
+ /**
58
+ * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
59
+ * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
60
+ * Esc/空答对原卡是 dismissal(arm 臂的 dismissed 分支不驱动 decidePlanReview,checkpoint 仍
61
+ * pending)—— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc 关卡)上要
62
+ * 保住的区分。消费方 = 宿主 overlay 的 answerAndRelease(respond 成功后调 —— 它是 responder
63
+ * 之外**第二条**决断可见点,原卡与重开卡两条通路对同一张卡各报一次,`notePlanReviewAnswered`
64
+ * 按完整 questionId 去重恰推一代);包内 responder 走 {@link notePlanReviewAnswered} 直报
65
+ * (它们没有 dismissed 臂会到达记账行)。
66
+ */
67
+ export declare function notePlanReviewAnsweredIfDecisive(questionId: unknown, answer: QuestionAnswer | null | undefined): void;
68
+ /** W1 带 key 变体。 */
69
+ export declare function notePlanReviewAnsweredIfDecisiveFor(sessionKey: string, questionId: unknown, answer: QuestionAnswer | null | undefined): void;
57
70
  /** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
58
71
  export declare function isPlanReviewPark(result: unknown): result is {
59
72
  taskId: string;
@@ -108,6 +121,17 @@ export interface ReopenPlanReviewOpts {
108
121
  * 或候 armPlanReviewApproval 补 sessionKey 位。
109
122
  */
110
123
  sessionKey?: string;
124
+ /**
125
+ * **呈现回执模式**(#250 件1 语义;#244 F1 换装位,cli 装配用):在场 ⇒ 本函数转异步 ——
126
+ * publish 之后等宿主呈现面的登记事件(`waitForGateArmed`,键 = 本次发布的**原始帧 id**,
127
+ * 尝试级回执)最多这么多毫秒;回执到手才 `{reopened:true}`,窗尽如实 `{reopened:false}`。
128
+ * 🔴 本模式下重开腿**零登记**:登记时点 = 宿主 overlay 真入队点(`registerArmedGate
129
+ * FromQuestionId`),零回执还登记 = 下一次重开谎称复见(毒化 firstSight)。responder 在
130
+ * 窗尽后**故意保留**:hook 窗超过看门狗才入队的迟到卡必须仍可答(答不了的死卡比迟到更坏)。
131
+ * 缺席 ⇒ 同步路径逐字不变(0.30.0 行为:发布即登记、即回 `{reopened:true}`,像素级回执归
132
+ * 宿主经 `presented` 位补充)。
133
+ */
134
+ presentationReceiptMs?: number;
111
135
  }
112
136
  /** 测试钩:清空重开 responder 台账(跨用例状态)。 */
113
137
  export declare function _resetActiveReopenRespondersForTest(): void;
@@ -123,4 +147,10 @@ export declare function _resetActiveReopenRespondersForTest(): void;
123
147
  * firstSight 判据 = 呈现台账(`armedGateRegistry`,键 = canonical `plan-review:<taskId>`);
124
148
  * 先查后记 —— 顺序决定判决正确性。
125
149
  */
126
- export declare function reopenPlanReviewCard(taskId: string, opts?: ReopenPlanReviewOpts): ReopenCardVerdict;
150
+ export declare function reopenPlanReviewCard(taskId: string, opts: ReopenPlanReviewOpts & {
151
+ presentationReceiptMs: number;
152
+ }): Promise<ReopenCardVerdict>;
153
+ export declare function reopenPlanReviewCard(taskId: string, opts?: ReopenPlanReviewOpts & {
154
+ presentationReceiptMs?: undefined;
155
+ }): ReopenCardVerdict;
156
+ export declare function reopenPlanReviewCard(taskId: string, opts?: ReopenPlanReviewOpts): ReopenCardVerdict | Promise<ReopenCardVerdict>;
@@ -42,7 +42,7 @@ import { makeEngineWireClient } from '../engineWireSdk.js';
42
42
  import { engineWireTarget } from '../engineWireTarget.js';
43
43
  import { enqueuePlanReviewOutcome } from '../notifications.js';
44
44
  import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
45
- import { clearArmedGate, clearArmedGateFor, registerArmedGateFor, wasGateArmedFor } from './armedGateRegistry.js';
45
+ import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
46
46
  import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
47
47
  /** resume 是 SYNC 驱动到终态的(可能分钟级)——原 raw fetch 无超时;SDK Transport 恒有每请求
48
48
  * 超时,取 6h 宽上界(诚实预算:超过它基本是引擎僵死,超时文案与网络失败同走 outcome 通知)。 */
@@ -72,6 +72,25 @@ export function planReviewDecisionFromAnswer(answer) {
72
72
  return 'reject';
73
73
  return 'dismissed';
74
74
  }
75
+ /**
76
+ * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
77
+ * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
78
+ * Esc/空答对原卡是 dismissal(arm 臂的 dismissed 分支不驱动 decidePlanReview,checkpoint 仍
79
+ * pending)—— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc 关卡)上要
80
+ * 保住的区分。消费方 = 宿主 overlay 的 answerAndRelease(respond 成功后调 —— 它是 responder
81
+ * 之外**第二条**决断可见点,原卡与重开卡两条通路对同一张卡各报一次,`notePlanReviewAnswered`
82
+ * 按完整 questionId 去重恰推一代);包内 responder 走 {@link notePlanReviewAnswered} 直报
83
+ * (它们没有 dismissed 臂会到达记账行)。
84
+ */
85
+ export function notePlanReviewAnsweredIfDecisive(questionId, answer) {
86
+ notePlanReviewAnsweredIfDecisiveFor(DEFAULT_SESSION_KEY, questionId, answer);
87
+ }
88
+ /** W1 带 key 变体。 */
89
+ export function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
90
+ if (planReviewDecisionFromAnswer(answer ?? { answers: [] }) === 'dismissed')
91
+ return;
92
+ notePlanReviewAnsweredFor(sessionKey, questionId);
93
+ }
75
94
  /** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
76
95
  export function isPlanReviewPark(result) {
77
96
  const r = result;
@@ -182,12 +201,11 @@ export function armPlanReviewApproval(result) {
182
201
  // 卡的重开路径把决定权还给用户。呈现台账的键**不清**:同一张卡再被重开就是真「reopened」。
183
202
  return { ok: true };
184
203
  }
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));
204
+ // A-024.4(#244 F1 起走分代):决断一经递交,这个门的呈现史就消费掉(当代键清 + 推代);
205
+ // run 的**下一个** plan gate(approve 推进后引擎可再 park 一个新 plan)必须读回首见,
206
+ // 否则文案对一张从未呈现过的新卡说「reopened」。递交后决定未生效的形(RB-471 )读回
207
+ // 首见只损失「reopened」一词 —— 首见文案零历史断言,诚实方向安全。
208
+ notePlanReviewAnswered(questionId);
191
209
  // fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
192
210
  void decidePlanReview(taskId, decided);
193
211
  return { ok: true };
@@ -340,31 +358,28 @@ function retireActiveReopen(sessionKey, canonicalId) {
340
358
  export function _resetActiveReopenRespondersForTest() {
341
359
  activeReopenResponders.clear();
342
360
  }
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
361
  export function reopenPlanReviewCard(taskId, opts) {
362
+ const receiptMs = opts?.presentationReceiptMs;
363
+ /** 回执模式下发布之后的收口(订阅必须**先于** publish:总线 handler 同步跑,快 overlay 的
364
+ * 登记可能发生在 publish 返回之前 —— 后订阅会错过已发生的回执)。 */
365
+ const settleOnReceipt = (receipt, firstSight) => receipt.then((presented) => (presented ? { reopened: true, firstSight } : { reopened: false }), () => ({ reopened: false }));
356
366
  try {
357
- if (typeof taskId !== 'string' || taskId.length === 0)
358
- return { reopened: false };
367
+ if (typeof taskId !== 'string' || taskId.length === 0) {
368
+ return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
369
+ }
359
370
  const sessionKey = opts?.sessionKey ?? DEFAULT_SESSION_KEY;
360
371
  // 诚实边界:请求方会话没有 overlay 订阅者(headless / 尚未挂载)⇒ 返回 false,调用方如实说
361
372
  // 「没能重开」。overlay 在场答的是「有没有一个 UI 会收到这一帧」;检查按 sessionKey 走 ——
362
373
  // 别的会话挂着 overlay 不等于本会话的卡有地方渲(复审 [high]:默认键硬走会把帧投进别人的面)。
363
- if (!hasQuestionOverlayFor(sessionKey))
364
- return { reopened: false };
374
+ if (!hasQuestionOverlayFor(sessionKey)) {
375
+ return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
376
+ }
365
377
  const canonicalId = planReviewQuestionId(taskId);
366
- // 先查(firstSight)后记(铸卡即 arm)—— 顺序决定判决正确性;台账按 sessionKey 分账。
367
- const firstSight = !wasGateArmedFor(sessionKey, canonicalId);
378
+ // 先查(firstSight)后记 —— 顺序决定判决正确性;台账按 sessionKey 分账,键 = **当代** plan 键
379
+ // (A-024.4 分代:0 代与 canonical 字节同形,决断消费后落新代 —— 恒定键会对同 run 的下一只
380
+ // plan 门谎报「被关过」)。
381
+ const armedKey = planReviewArmedKeyFor(sessionKey, taskId);
382
+ const firstSight = !wasGateArmedFor(sessionKey, armedKey);
368
383
  const mintFresh = opts?.mintFreshQuestionId !== false;
369
384
  if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
370
385
  // 重呈短路(与 arm 臂的重放短路同形):同一个 canonical 身份、同一份题面、同一个 responder。
@@ -376,12 +391,21 @@ export function reopenPlanReviewCard(taskId, opts) {
376
391
  }
377
392
  // 先退役本会话名下还在场的**重开**卡(如此前走过 mintFresh 臂)—— 同一 gate 不留两张活卡。
378
393
  retireActiveReopen(sessionKey, canonicalId);
394
+ if (receiptMs !== undefined) {
395
+ const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
396
+ publishQuestionFrameFor(sessionKey, {
397
+ type: 'question',
398
+ questionId: canonicalId,
399
+ questions: planReviewQuestions(ARM_QUESTION),
400
+ });
401
+ return settleOnReceipt(receipt, firstSight);
402
+ }
379
403
  publishQuestionFrameFor(sessionKey, {
380
404
  type: 'question',
381
405
  questionId: canonicalId,
382
406
  questions: planReviewQuestions(ARM_QUESTION),
383
407
  });
384
- registerArmedGateFor(sessionKey, canonicalId);
408
+ registerArmedGateFor(sessionKey, armedKey);
385
409
  return { reopened: true, firstSight };
386
410
  }
387
411
  // 复审 [high] 单活纪律(两轮收紧):铸新卡前退役**全部**旧作答入口 —— 同一个 gate 至多一张
@@ -417,8 +441,10 @@ export function reopenPlanReviewCard(taskId, opts) {
417
441
  // 这张卡再被重开就是真「reopened」。
418
442
  return { ok: true };
419
443
  }
420
- // A-024.4:决断递交即消费呈现史(与 arm 臂 responder 同点,理由见那里)。
421
- clearArmedGateFor(sessionKey, canonicalId);
444
+ // A-024.4(#244 F1 起走分代):决断递交即消费呈现史(与 arm 臂 responder 同点;overlay 的
445
+ // answerAndRelease 对同一张卡也会经 notePlanReviewAnsweredIfDecisive 报一次,按完整
446
+ // questionId 去重,不双推)。
447
+ notePlanReviewAnsweredFor(sessionKey, questionId);
422
448
  try {
423
449
  await deliver(taskId, decided);
424
450
  }
@@ -430,7 +456,19 @@ export function reopenPlanReviewCard(taskId, opts) {
430
456
  return { ok: true };
431
457
  });
432
458
  activeReopenResponders.set(activeKey, { questionId, unregister });
433
- registerArmedGateFor(sessionKey, canonicalId);
459
+ if (receiptMs !== undefined) {
460
+ // 回执模式(#250 件1):零登记(登记时点 = 宿主 overlay 真入队点)、订阅先于 publish、
461
+ // 回执键 = 原始帧 id(尝试级回执 —— 同 gate 并发重开不同键互唤);窗尽如实 false,
462
+ // responder 故意保留(迟到卡必须仍可答,决断腿照走)。
463
+ const receipt = waitForGateArmedFor(sessionKey, [questionId], receiptMs);
464
+ publishQuestionFrameFor(sessionKey, {
465
+ type: 'question',
466
+ questionId,
467
+ questions: planReviewQuestions(REOPEN_QUESTION),
468
+ });
469
+ return settleOnReceipt(receipt, firstSight);
470
+ }
471
+ registerArmedGateFor(sessionKey, armedKey);
434
472
  publishQuestionFrameFor(sessionKey, {
435
473
  type: 'question',
436
474
  questionId,
@@ -439,6 +477,7 @@ export function reopenPlanReviewCard(taskId, opts) {
439
477
  return { reopened: true, firstSight };
440
478
  }
441
479
  catch {
442
- return { reopened: false }; // 重开失败绝不炸 turn —— 调用方会如实说「没能重开」
480
+ // 重开失败绝不炸 turn —— 调用方会如实说「没能重开」(回执模式回同形的 resolved promise)。
481
+ return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
443
482
  }
444
483
  }
@@ -218,8 +218,11 @@ export interface ApprovalCardRequest {
218
218
  * → 决断带 {@link ApprovalCardAllowDecision.persistRule} 回兑」,兑付口=同副本活卡腿
219
219
  * `respond.persistRule`;而 durable 腿的 `/decide` 体**无规则位**(SDK 6.17.2 PendingCheckpoint
220
220
  * JSDoc 逐字:display/triage-only)——把行上候选落进那一位,就是一个按下去规则不落地的假
221
- * affordance。卡口对本键只许**只读渲染**(chips/说明行),绝不渲成可选中项、决断绝不因它带
222
- * `persistRule`。缺席 = 无候选/老行/坏形(三者同形,不猜);既有卡口不读本键 ⇒ 卡形字节不变。
221
+ * affordance。本键的不变量是**决断字节**:决断绝不因它带 `persistRule`(或任何规则位)——
222
+ * 「只读」限定的是 wire 回兑通道,不是屏面。渲成可选中项是**允许**的,前提=兑付走客户端
223
+ * **本地**落规则(cli 1.0.76 起的形:选中 → 客户端写自己的 settings `permissions.allow` 并把
224
+ * 成败如实上屏,决断仍与两态时代逐字节相同;这正是 CC 第三态的原形——本地文件写)。
225
+ * 缺席 = 无候选/老行/坏形(三者同形,不猜);既有卡口不读本键 ⇒ 卡形字节不变。
223
226
  */
224
227
  ruleSuggestionsReadOnly?: RuleSuggestion[];
225
228
  /**
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **673** 个运行期符号(+ 32 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **685** 个运行期符号(+ 33 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -99,7 +99,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
99
99
 
100
100
  ## §2 公共导出面地图(按域)
101
101
 
102
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**673** 项)。
102
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**685** 项)。
103
103
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
104
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
105
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,27 +109,27 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
109
109
 
110
110
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
111
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
- 实测:673 项 **100% 是运行期导出,零 type-only**。
112
+ 实测:685 项 **100% 是运行期导出,零 type-only**。
113
113
 
114
114
  **推论(端必须知道)**:
115
- - barrel 导出的**类型**面比 673 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
115
+ - barrel 导出的**类型**面比 685 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
116
116
  `ChromeEvent` / `HostPorts` / `ApprovalCardPort` / `HitlHostSurface` / `ClientSliceLike` /
117
117
  `LocalSessionEvent` / `SeatMethodName` / `ModelCatalog` 全在公面上、全**不在**基线里。
118
118
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
119
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
120
 
121
- 673 项的内部构成(帮助端估读表大小):**205** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
121
+ 685 项的内部构成(帮助端估读表大小):**206** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
122
  (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
123
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
- **33** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6)。
124
+ **38** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6)。
125
125
 
126
- ### 2b. 域图(16 域,逐域计数之和 = 673)
126
+ ### 2b. 域图(16 域,逐域计数之和 = 685)
127
127
 
128
128
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
129
  |---|---|---|---|---|---|
130
130
  | 1 | **适配内核(下行主链)** | 29 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
131
131
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
132
- | 3 | **HITL 决断卡链**(§4/§5 主战场) | 101 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层) |
132
+ | 3 | **HITL 决断卡链**(§4/§5 主战场) | 112 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层) |
133
133
  | 4 | **子代 wire + 面板侧信道台账** | 68 | `tailEngineSubagent` · `installSubagentActivitySink` · `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
134
134
  | 5 | **fleet 投影** | 43 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
135
135
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
@@ -141,7 +141,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
141
141
  | 12 | **workflow 与后台工作视图** | 15 | `projectWorkflowRun` · `createLiveWorkflowSource` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
142
142
  | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
143
143
  | 14 | **宿主端口与会话槽** | 18 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` | 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/localeGeo.ts` |
144
- | 15 | **控制面与传输** | 59 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
144
+ | 15 | **控制面与传输** | 60 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
145
  | 16 | **引擎词汇表与包自检** | 36 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(29 项)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
146
146
 
147
147
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
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",