@sema-agent/client-core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +400 -0
  2. package/README.md +19 -2
  3. package/dist/adapt/arms.js +24 -1
  4. package/dist/adapt/wireShapes.d.ts +7 -0
  5. package/dist/adapt/wireShapes.js +7 -0
  6. package/dist/adapter/activeRunSelfHeal.d.ts +407 -0
  7. package/dist/adapter/activeRunSelfHeal.js +771 -0
  8. package/dist/adapter/runStream.d.ts +23 -6
  9. package/dist/adapter/runStream.js +19 -5
  10. package/dist/engineWireSdk.d.ts +10 -2
  11. package/dist/engineWireSdk.js +7 -3
  12. package/dist/hitl/approvalDecisionNoteAudit.d.ts +58 -0
  13. package/dist/hitl/approvalDecisionNoteAudit.js +91 -0
  14. package/dist/hitl/armedGateRegistry.d.ts +19 -0
  15. package/dist/hitl/armedGateRegistry.js +92 -0
  16. package/dist/hitl/askParkRowRouting.d.ts +150 -0
  17. package/dist/hitl/askParkRowRouting.js +183 -0
  18. package/dist/hitl/frameRouter.d.ts +5 -1
  19. package/dist/hitl/frameRouter.js +1 -1
  20. package/dist/hitl/gateIdentity.d.ts +50 -0
  21. package/dist/hitl/gateIdentity.js +64 -0
  22. package/dist/hitl/hitlBridge.d.ts +7 -0
  23. package/dist/hitl/hitlBridge.js +11 -2
  24. package/dist/hitl/parkOwnership.d.ts +65 -0
  25. package/dist/hitl/parkOwnership.js +49 -0
  26. package/dist/hitl/parkResolver.js +3 -1
  27. package/dist/hitl/parkRowBirthWait.d.ts +63 -0
  28. package/dist/hitl/parkRowBirthWait.js +192 -0
  29. package/dist/hitl/planReviewWire.d.ts +51 -0
  30. package/dist/hitl/planReviewWire.js +172 -17
  31. package/dist/hitl/resumeRunningCard.d.ts +134 -0
  32. package/dist/hitl/resumeRunningCard.js +177 -0
  33. package/dist/hitl/toolApprovalWire.d.ts +69 -23
  34. package/dist/hitl/toolApprovalWire.js +60 -24
  35. package/dist/index.d.ts +9 -0
  36. package/dist/index.js +34 -0
  37. package/dist/liveQuestionStore.d.ts +11 -0
  38. package/dist/liveQuestionStore.js +13 -0
  39. package/dist/seatContract.d.ts +27 -0
  40. package/dist/seatContract.js +42 -0
  41. package/dist/subagent/engineSubagentTail.d.ts +0 -2
  42. package/dist/subagent/engineSubagentTail.js +7 -15
  43. package/dist/subagentContentStore.d.ts +58 -2
  44. package/dist/subagentContentStore.js +95 -6
  45. package/dist/toolResult.d.ts +26 -0
  46. package/dist/toolResult.js +38 -6
  47. package/dist/workflowClient.d.ts +6 -1
  48. package/docs/INTEGRATION-CLIENTS.md +844 -0
  49. package/docs/REFACTOR-LEDGER.md +392 -0
  50. package/package.json +7 -4
@@ -0,0 +1,192 @@
1
+ /**
2
+ * hitl/parkRowBirthWait.ts — 「park 的待决行**还没出生**」这个第三态的有界等待(#265 上收件,
3
+ * 2026-08-14;源形 = cli `src/sema/parkRowBirthWait.ts`,该文件自设计起就按候上收形写成零依赖)。
4
+ *
5
+ * ── 病(cli #269,plan 批准 → write 卡永不呈现)──────────────────────────────────────────────
6
+ * `/v1/approvals` 的 pending 表里**没有**某个 park 的行,过去被单义化成「这件事已经决了」。
7
+ * 真实世界有第三态:引擎刚被推进到下一只门,而门的行要等一次真实模型调用(实测 ~3s:plan
8
+ * 批准 → 模型算出 Write 的参数 → 才铸审批行)才落库。在那个窗里读到的空表既不是「已决」
9
+ * 也不是「没有这件事」,而是**还没出生**。
10
+ * 把「未出生」当「已决」的代价 = 客户端按成功收口 → 呈现回执永远等不到 → 上一句「没能重开
11
+ * 这张卡」的降级话,而那张卡三秒之后真的出生了却再没人去接 —— 用户看到的是会话死了。
12
+ *
13
+ * ── 这个模块是什么 ──────────────────────────────────────────────────────────────────────────
14
+ * 把「行不在表」拆成**三态**并给一个有界、可中断的重查环:
15
+ * · `row` —— 拿到一行可决断的行(唯一可以往下走的态);
16
+ * · `unborn` —— 此刻没有可决断的行,但**没有任何正面证据**说明这件事已经了结 ⇒ 值得再看一眼;
17
+ * · `settled` —— 有正面证据说明这里没有本臂该决的事(如行在但门种不属本臂)⇒ 等待零意义,立即收口。
18
+ * 判据纪律:`settled` 必须由**正面证据**得出;缺席本身永远只能得 `unborn`
19
+ * (「不知道」不许渲成「已完成」)。
20
+ *
21
+ * ── 边界(承重,别削)────────────────────────────────────────────────────────────────────────
22
+ * · **恒至少探一次**:`budgetMs <= 0` ⇒ 恰一次 probe,行为与「不等」逐字相同(存量判决不漂,
23
+ * 也让测试能把等待窗调成 0 拿回旧语义)。
24
+ * · **有界**:总窗 `budgetMs` 封顶,轮询间隔 `intervalMs`;窗尽如实回 `unborn`(不是回
25
+ * `settled` —— 等不到不等于已决,降级话在这一刻才是真话)。
26
+ * · **真可中断**:每一轮 probe 前、每一次 sleep 后都读 `signal.aborted`;缺省 sleep 自身也吃
27
+ * signal(包内共享叶 `abortableSleep`,不另铸第二份等待原语)。
28
+ * · **绝不裸固定拍当判据**:sleep 只是轮询节奏,判决恒来自下一次 probe 的真实读数。
29
+ * · probe 抛错按 `unborn` 处置(瞬时读面故障不该把有界重查提前判死),错误摘要进 reason ——
30
+ * 调用方只在 debug 档消费,绝不上屏(凭证外溢面纪律)。
31
+ *
32
+ * ── 归层 ────────────────────────────────────────────────────────────────────────────────────
33
+ * 「一条 park 的待决行到底出生了没有」是 wire 语义,三端(cli / desktop / web)都要问同一句;
34
+ * 各端只注入自己的读面(cli = `approvals.list` + 归属判据),环本身共用这一份。行形走结构泛型,
35
+ * 读面/时钟/sleep 全部注入 —— 本文件值级 import 只有包内 `abortableSleep`,零 SDK 零宿主。
36
+ */
37
+ import { abortableSleep } from '../abortableSleep.js';
38
+ /**
39
+ * 缺省轮询等待:signal 在场时直接借包内共享叶 {@link abortableSleep}(中止即刻 resolve 并清
40
+ * timer),缺席时退化成纯定时。
41
+ *
42
+ * 🔴 **不另铸第二份等待原语**:仓内「怎么安全地睡一觉」只此一份(`abortableSleep.ts` 的头注
43
+ * 写死了这条 —— D 族循环各自保留自己的节拍策略,但等待原语统一)。两份并存的代价不是重复代码,
44
+ * 是两条会各自漂的中止语义。
45
+ * 🔴 timer **不 unref**(与 abortableSleep 同判据):调用方正 await 本环拿判决,轮询窗里这只
46
+ * timer 可能是事件循环里唯一的活,unref 会让进程在等待中途直接退出、判决连同它要说的那句真话
47
+ * 一起蒸发。代价上限 = 一个 intervalMs 的进程存活延长。
48
+ */
49
+ function parkRowPollDelay(ms, signal) {
50
+ // 无 signal 的调用方(端没接中断口)也走同一个叶:给它一只**永不 abort** 的 signal,而不是就地
51
+ // 再写一个裸 setTimeout —— 那就是第二份等待原语的起点。
52
+ return abortableSleep(ms, signal ?? new AbortController().signal);
53
+ }
54
+ /**
55
+ * 有界、可中断地等一条 park 的待决行出生。语义与边界见文件头;绝不抛。
56
+ *
57
+ * 🔴 判决只有 `row` 一态可以往下走 —— `unborn`(窗尽)与 `aborted` 都必须由调用方翻成**如实的
58
+ * 失败**,绝不许翻成「已决/已成功」(那正是 cli #269 的病)。
59
+ */
60
+ export async function waitForParkRowBirth(deps) {
61
+ const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
62
+ const sleep = typeof deps.sleep === 'function' ? deps.sleep : parkRowPollDelay;
63
+ const budgetMs = Number.isFinite(deps.budgetMs) && deps.budgetMs > 0 ? deps.budgetMs : 0;
64
+ const intervalMs = Number.isFinite(deps.intervalMs) && deps.intervalMs > 0 ? deps.intervalMs : 1;
65
+ const startedAt = now();
66
+ let probes = 0;
67
+ let lastReason = 'no decidable pending row for this park';
68
+ // 🔴 经函数读,别直接读 `deps.signal?.aborted`:tsc 的控制流分析会拿环顶那一次判断把它窄成
69
+ // `false | undefined`,sleep 之后的第二次判断当场被判「不可能为 true」而报错 —— 而真实世界里
70
+ // 正是 sleep 期间它会翻。函数返回值走声明类型,不参与那条窄化链。
71
+ const aborted = () => deps.signal?.aborted === true;
72
+ /** 一拍探测(读面故障按 unborn,绝不外溢)。 */
73
+ const safeProbe = async (attempt, signal) => {
74
+ try {
75
+ return await deps.probe(attempt, signal);
76
+ }
77
+ catch (e) {
78
+ // 读面瞬时故障 ≠ 已决:按未出生继续轮询(有界,窗尽自然如实收口)。
79
+ return { kind: 'unborn', reason: `probe threw: ${String(e)}` };
80
+ }
81
+ };
82
+ /**
83
+ * 🔴 把**剩余预算**套在每一拍 probe 上:deadline 只在 `await probe` **之后**判,等于什么都没
84
+ * 界住 —— 一次挂死的 `approvals.list` 能把「15s 有界」拖到 SDK 请求超时那么长,而中断也要等
85
+ * 那个 promise 落地才被看见。这里给每一拍配一个内部 controller:窗尽即 abort(读面真被掐,
86
+ * 不只是被放弃)并当拍判窗尽;外部 signal 与它合流,Esc 同样当拍生效。
87
+ * 零窗形(budgetMs<=0)**不设**内部 deadline —— 那一拍就是「修前那一次 list」,不许被新机制改语义。
88
+ */
89
+ const probeWithinRemaining = async (attempt, remainingMs) => {
90
+ if (budgetMs <= 0)
91
+ return safeProbe(attempt, deps.signal);
92
+ const ctl = new AbortController();
93
+ let timer;
94
+ const deadline = new Promise(resolve => {
95
+ timer = setTimeout(() => {
96
+ ctl.abort();
97
+ resolve('deadline');
98
+ }, Math.max(1, remainingMs));
99
+ });
100
+ // 🔴 外部中断**自己也是竞速的一臂**,不能只靠「signal 传下去 ⇒ 读面自己会拒」:那是对 probe
101
+ // 实现的一个假设,而假设不成立时(probe 吞了 signal / 读面 abort 得慢)Esc 之后这一拍要一直
102
+ // 等到窗尽才被看见 —— 「UI 说停了、后台还在等」正是本模块承诺不会发生的那件事。
103
+ const caller = deps.signal;
104
+ /**
105
+ * 把中止**传导到真正交给 probe 的那只 signal**(清理动作:它自己的异常绝不挡住中止判决)。
106
+ * 🔴 承重理由:`AbortSignal.any` 缺席的宿主上 `merged` 就是 `ctl.signal`,而外部中断只赢竞速、
107
+ * 不会传导到它 —— finally 又把 deadline timer 清了,于是那一拍挂死的读面**永远**不会被 abort
108
+ * (判决说「停了」,后台请求活到进程结束)。两个中止入口(进拍即已中断 / 等待期间翻)共用这一份。
109
+ */
110
+ const abortProbeLane = () => {
111
+ try {
112
+ ctl.abort();
113
+ }
114
+ catch {
115
+ /* abort 面异常绝不挡住中止判决(唯一后果是那一拍的读面少收一次 abort) */
116
+ }
117
+ };
118
+ let onAbort;
119
+ const externalAbort = new Promise(resolve => {
120
+ if (caller === undefined)
121
+ return; // 永不 settle:race 另有两臂,无害
122
+ if (caller.aborted) {
123
+ // 进这一拍时就已中断 ⇒ probe 拿到的那只 signal 必须是**已中止**的(一个不查 `aborted`、
124
+ // 只挂 'abort' 事件的读面否则会一直挂着)。
125
+ abortProbeLane();
126
+ resolve('aborted');
127
+ return;
128
+ }
129
+ // 🔴 先传导中止**再** resolve(见 {@link abortProbeLane}):否则「可中断」只是判决面的说法。
130
+ onAbort = () => {
131
+ abortProbeLane();
132
+ resolve('aborted');
133
+ };
134
+ try {
135
+ caller.addEventListener('abort', onAbort, { once: true });
136
+ }
137
+ catch {
138
+ onAbort = undefined; // 没有事件面的 signal 形:退化成环顶那次 aborted() 读
139
+ }
140
+ });
141
+ // 合流(Node ≥20.3 / 现代浏览器原生);缺席时退化成只吃内部 deadline —— 中止仍由上面那一臂
142
+ // 当拍生效。`typeof` 探的是**运行期**在场,不是类型面缺口。
143
+ const merged = caller !== undefined && typeof AbortSignal.any === 'function' ? AbortSignal.any([caller, ctl.signal]) : ctl.signal;
144
+ try {
145
+ return await Promise.race([safeProbe(attempt, merged), deadline, externalAbort]);
146
+ }
147
+ finally {
148
+ // timer 必清:竞速由 probe 赢时它还挂着,不清会把进程按 remainingMs 拖活一拍。
149
+ if (timer !== undefined)
150
+ clearTimeout(timer);
151
+ // 监听器必摘:环可以跑几十拍,不摘就是几十个挂在调用方 signal 上的闭包。
152
+ if (onAbort !== undefined && caller !== undefined) {
153
+ try {
154
+ caller.removeEventListener('abort', onAbort);
155
+ }
156
+ catch {
157
+ /* 监听面异常绝不反噬等待环 */
158
+ }
159
+ }
160
+ }
161
+ };
162
+ for (;;) {
163
+ if (aborted())
164
+ return { kind: 'aborted', waitedMs: now() - startedAt, probes };
165
+ probes += 1;
166
+ const outcome = await probeWithinRemaining(probes, budgetMs - (now() - startedAt));
167
+ // 读面挂着的时候用户按了 Esc ⇒ 当拍停(判决优先于窗尽,两者都成立时中断更准确)。
168
+ if (outcome === 'aborted' || aborted())
169
+ return { kind: 'aborted', waitedMs: now() - startedAt, probes };
170
+ if (outcome === 'deadline') {
171
+ return {
172
+ kind: 'unborn',
173
+ reason: `${lastReason} (the last read face did not answer within the remaining window)`,
174
+ waitedMs: now() - startedAt,
175
+ probes,
176
+ };
177
+ }
178
+ const probed = outcome;
179
+ if (probed.kind === 'row')
180
+ return { kind: 'row', row: probed.row, waitedMs: now() - startedAt, probes };
181
+ if (probed.kind === 'settled') {
182
+ return { kind: 'settled', reason: probed.reason, waitedMs: now() - startedAt, probes };
183
+ }
184
+ lastReason = probed.reason;
185
+ const remainingMs = budgetMs - (now() - startedAt);
186
+ if (remainingMs <= 0)
187
+ return { kind: 'unborn', reason: lastReason, waitedMs: now() - startedAt, probes };
188
+ await sleep(Math.min(intervalMs, remainingMs), deps.signal);
189
+ if (aborted())
190
+ return { kind: 'aborted', waitedMs: now() - startedAt, probes };
191
+ }
192
+ }
@@ -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;
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * hitl/resumeRunningCard.ts — **resume 撞上「上一轮还在跑」**时那张三选卡的判定 / 选项集 / 文案
3
+ * (#265 上收件,2026-08-14;源形 = cli `src/sema/resumeRunningArm.ts` 的纯判定半场,编排腿留在端上)。
4
+ *
5
+ * ── 病与改形 ────────────────────────────────────────────────────────────────────────────────
6
+ * resume 时读到一条 `running` 的 run,旧形是**客户端自己**给它打一枪 cancel(动机 = 僵尸烧 token)。
7
+ * 矛盾:resume 时的 running run **不可判**是「用户要的在飞工作」还是「僵尸」—— 而实况里自动 cancel
8
+ * 恰好是错的方向(用户批准之后 run 该继续跑,resume 时 cancel = 把刚救活的 run 又杀了)。血统纪律:
9
+ * **破坏性动作恒不自动**。改形 = 把三条路交回用户:
10
+ * ① `Attach and watch` —— 接管为当前 turn 输出;
11
+ * ② `Cancel it` —— 那个动作降级为**用户按下**的选项;
12
+ * ③ `Leave it running in the background`(**默认焦点**)—— 零动作。
13
+ *
14
+ * ── §3.2a 活性证据的缺席纪律(不可削)──────────────────────────────────────────────────────
15
+ * 判活体的 `msSinceLastActivity`(相对毫秒)在场 ⇒ 卡上渲一行「last activity Ns ago」;
16
+ * **缺席 = 无法证明**(跨副本命中 / 副本重启后),不是「不活」—— 那一行**整行不渲**,绝不折成 0、
17
+ * 绝不写成「已死」。
18
+ *
19
+ * ── §3.2b 无 UI 臂:退役自动 cancel 之后不许留死墙 ──────────────────────────────────────────
20
+ * 「不自动 cancel + 没有卡可选」= 又一堵死墙。所以没有卡的形必须**显式定义臂**:恒不 cancel;
21
+ * 输出**机读三选指路**({@link resumeRunningNoUiGuidance},attach / cancel / 后台留着三条路各给
22
+ * 真实命令或 API 路径);headless 车道走**专码**退出({@link RESUME_RUNNING_NO_UI_EXIT_CODE});
23
+ * 绝不静默挂起、绝不谎报成功。
24
+ *
25
+ * ── 🔴 塌缩纪律 ─────────────────────────────────────────────────────────────────────────────
26
+ * Esc / 空答 / 认不出的自由文本 / 作答看门狗到点 / 呈卡失败 —— 一律收口成 ③(零动作)。
27
+ * 破坏性动作只认**逐字命中**的显式选择({@link resumeChoiceFromLabels})。
28
+ *
29
+ * ── 🔴 假 affordance 禁令(按供给渲)───────────────────────────────────────────────────────
30
+ * 一个按了没用的选项比没有这个选项更坏:durable `runs.events` 动词缺席**或**没有接管消费口 ⇒
31
+ * ① 整个不渲;`runs.cancel` 缺席 ⇒ ② 整个不渲;两条都缺 ⇒ 一张卡都不铸,走 §3.2b 臂。
32
+ *
33
+ * ── 归层 ────────────────────────────────────────────────────────────────────────────────────
34
+ * 本文件 = 纯判定 + 选项集 + 文案,**零 import**。呈卡口/动词切片/判活口/指路 sink/退出码应用口
35
+ * 全部由端在装配点注入;端只剩装配与呈现。
36
+ */
37
+ /** ① 接管观看。 */
38
+ export declare const RESUME_CHOICE_ATTACH_LABEL = "Attach and watch";
39
+ /** ② 用户显式取消(自动 cancel 那个动作的降级形)。 */
40
+ export declare const RESUME_CHOICE_CANCEL_LABEL = "Cancel it";
41
+ /** ③ 后台留着 —— 🔴 **默认焦点**,所以它必须排在 options[0](见 {@link resumeRunningOptions})。 */
42
+ export declare const RESUME_CHOICE_BACKGROUND_LABEL = "Leave it running in the background";
43
+ /** 卡的 header(答案按 header 归键;本卡只有一问)。与 409 三选卡的入口刻意不同词 —— 同一张卡的
44
+ * 两个入口,题面说的不是一件事。 */
45
+ export declare const RESUME_CHOICE_HEADER = "Earlier run still in flight";
46
+ /** 用户在 resume 变体卡上的决定。 */
47
+ export type ResumeRunningChoice = 'attach' | 'cancel' | 'background';
48
+ /**
49
+ * 呈卡口的回执。🔴 `'unavailable'` 与 `'background'` **绝不合并**:前者 =「问都没问成」
50
+ * (§3.2b 臂的入口:用户还欠一次告知),后者 =「问过了,用户选了什么都不做」(已经告知过)。
51
+ * 合并的代价 = 一次呈现失败被静默当成用户的决定 —— 那正是 §3.2b 要封的死墙。
52
+ */
53
+ export type ResumeChoiceOutcome = ResumeRunningChoice | 'unavailable';
54
+ /** 铸卡所需的全部材料(纯数据 —— 呈现层按它铸自己的卡/帧)。 */
55
+ export interface ResumeRunningCardInput {
56
+ /** 那条还在跑的 run。 */
57
+ taskId: string;
58
+ /** 引擎报的状态(如实转述,不加工)。 */
59
+ status: string;
60
+ /** ① 可渲吗(= durable events 动词在场 **且** 有接管消费口)。 */
61
+ canAttach: boolean;
62
+ /** ② 可渲吗(= `runs.cancel` 在场)。 */
63
+ canCancel: boolean;
64
+ /** §3.2a 活性证据(null = 引擎没给 ⇒ 无法证明 ⇒ 那一行整行不渲)。 */
65
+ msSinceLastActivity: number | null;
66
+ }
67
+ /** 一个选项(CC AskUserQuestionTool option 形)。 */
68
+ export interface ResumeRunningCardOption {
69
+ label: string;
70
+ description: string;
71
+ }
72
+ /**
73
+ * §3.2a 活性证据行。**缺席 ⇒ null(整行不渲)**;绝不渲 0、绝不渲「已死」。
74
+ * 负数 / 非有限值同样按缺席处理(wire 位 UNTRUSTED,类型说是 number 也要真判)。
75
+ */
76
+ export declare function resumeRunningLivenessRow(msSinceLastActivity: number | null | undefined): string | null;
77
+ /**
78
+ * 卡的题面。第二行 = 活性证据(§3.2a 缺席时**整行消失**,不留占位、不留「unknown」字样 ——
79
+ * 一行「last activity: unknown」和一行 0 一样是在替引擎回答一个它没回答的问题)。
80
+ */
81
+ export declare function resumeRunningQuestionText(i: ResumeRunningCardInput): string;
82
+ /**
83
+ * 选项集(同一张卡两个入口,选项集**按入口分形**)。
84
+ *
85
+ * 🔴 **顺序即默认焦点**:卡的渲染面 `focusedIndex` 初值恒 0,而 wire 的 `QuestionFrame` 里
86
+ * **没有**任何「默认焦点」位可传。所以「默认焦点 = ③ 后台留着」在结构上只有一个落法:把 ③ 排在
87
+ * options[0]。谁哪天为了「读起来顺」把 ①②③ 按序号排回去,默认焦点就漂到了 `Attach and watch`
88
+ * —— 一个手滑回车就把一条后台 run 接管到前台。常驻门逐位反钉这个顺序。
89
+ */
90
+ export declare function resumeRunningOptions(i: ResumeRunningCardInput): ResumeRunningCardOption[];
91
+ /**
92
+ * 答案 → 决定。🔴 只认**逐字命中**的标签;空答(overlay 的 Esc/dismiss 形)、多选、认不出的
93
+ * 自由文本一律回落 ③ —— 破坏性动作只认显式选择(与 409 三选卡同律)。
94
+ */
95
+ export declare function resumeChoiceFromLabels(selected: readonly string[] | null | undefined): ResumeRunningChoice;
96
+ /**
97
+ * headless 车道撞 resume-running 的**专码**退出码。
98
+ *
99
+ * 为什么非 0:这一趟 resume **没有**把会话交到一个可用状态 —— 那条 run 还占着会话,脚本接下来
100
+ * 提交什么都会撞 409。静默 0 = 谎报成功。
101
+ * 为什么是 75:sysexits.h 的 `EX_TEMPFAIL`(「暂时性失败,请稍后重试」)语义正对 —— run 跑完
102
+ * 或被取消之后同一条命令就能过;与 cli 既用的 0/1/2/129/143 零撞(脚本消费者据此可以把「会话被
103
+ * 在飞 run 占住」与「真失败(1)」「用法错(2)」分臂)。
104
+ */
105
+ export declare const RESUME_RUNNING_NO_UI_EXIT_CODE = 75;
106
+ /** 机读指路的行首标识 —— 脚本按它 grep;UI 改版不许动它。 */
107
+ export declare const RESUME_RUNNING_GUIDANCE_TAG = "sema: resume-running:";
108
+ /**
109
+ * §3.2b 的**机读三选指路**。三条路各给真实命令或 API 路径,一条都不许省 ——
110
+ * 少写一条 = 那条路在这个车道上被客户端单方面关掉了。
111
+ *
112
+ * 🔴 `attach=` 那一行只有在**真知道 session id** 时才给具体命令:不知道还硬拼一条
113
+ * `sema --resume <sessionId>` 字面量,就是印一条按了没用的命令(假 affordance 的文本形)。
114
+ */
115
+ export declare function resumeRunningNoUiGuidance(i: {
116
+ taskId: string;
117
+ sessionId?: string | null;
118
+ }): string;
119
+ /**
120
+ * ① 接管之前的**显式披露**。
121
+ *
122
+ * resume 入口没有 `lastEventId` 可用(端今天不持久化引擎事件锚),所以 durable 尾从这条 run 的
123
+ * 第一帧读起 —— 崩溃前已落盘、已被 resume 还原到屏上的那一段会再渲一遍。续听腿那道「无锚 × 已上屏
124
+ * ⇒ 不许从头续读」的门在这一格判不出来(它数的是**本条流**吐过几帧,resume 入口恒 0),所以不能
125
+ * 拿它当安全证明。处置 = 说在前面:静默复读会让用户以为模型把同一段活干了两遍。
126
+ *
127
+ * 🔴 **账目面同源**:同一次重放也会把这条 run 已经执行过的每一个 `turn_end` 再喂一次成本累计器。
128
+ * 客户端这一端证明不了哪一枚已经在恢复出来的总额里(那需要一本按 turn 落盘的账 —— 与缺席的
129
+ * 事件锚是同一件移交残件),所以接管腿声明**不计账**。既然不计,就必须在**同一行**说清楚:
130
+ * 只披露视觉复读、不披露账目缺口,用户拿成本面跟引擎账本对不上时只会怀疑客户端算错了。
131
+ */
132
+ export declare function resumeAttachReplayDisclosure(taskId: string): string;
133
+ /** ② 打完 cancel 但引擎没确认落地时的如实一行(绝不谎报「已停止」)。 */
134
+ export declare function resumeRunningCancelUnconfirmedRow(taskId: string): string;