@sema-agent/client-core 0.72.12 → 0.72.14

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.
@@ -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 PlanReviewModeAfter } from './hitlBridge.js';
40
41
  import type { ReopenCardVerdict } from '../adapter/activeRunSelfHeal.js';
41
42
  /**
42
43
  * REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
@@ -46,6 +47,17 @@ import type { ReopenCardVerdict } from '../adapter/activeRunSelfHeal.js';
46
47
  */
47
48
  export declare const PLAN_REVIEW_APPROVE_LABEL = "Yes, approve and run the plan";
48
49
  export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
50
+ /**
51
+ * 0.72.13 CC-46(engine ≥7.86.0 S-433;契约 §4c):三选卡的两个 Yes —— 对齐 CC `ExitPlanMode` 卡
52
+ * 「Yes, and auto-accept edits」/「Yes, and manually approve edits」。只在**双闸**都过时才出三选卡(见 {@link planReviewCardOptions}
53
+ * 的调用点):宿主声明这条任务是它自己用 `permissionMode:"plan"` 提交的(适用面读不到 wire)∧ 引擎版本证据 ≥7.86.0。
54
+ */
55
+ export declare const PLAN_REVIEW_APPROVE_AUTO_LABEL = "Yes, and auto-accept edits";
56
+ export declare const PLAN_REVIEW_APPROVE_MANUAL_LABEL = "Yes, and manually approve edits";
57
+ /** `permissionModeAfter` 的引擎地板(server 7.86.0;更老的 server 静默丢这个键 = 承诺了却不兑现)。 */
58
+ export declare const PLAN_REVIEW_MODE_AFTER_MIN_ENGINE: readonly [number, number, number];
59
+ /** 引擎版本串够不够新:三态 —— 读不出(缺席 / 非 x.y.z)⇒ `unknown`,**不折 supported**(没证据不许出三选卡)。 */
60
+ export declare function versionSupportsPlanReviewModeAfter(v: string | undefined): 'supported' | 'unsupported' | 'unknown';
49
61
  /**
50
62
  * REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
51
63
  * 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
@@ -53,7 +65,28 @@ export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
53
65
  * (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
54
66
  * 负责如实分类,不替调用方做选择。
55
67
  */
56
- export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined): 'approve' | 'reject' | 'dismissed';
68
+ export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined,
69
+ /** 本次实际展示的那张卡的标签;不给 = 老两选(见 {@link planReviewChoiceFromAnswer})。 */
70
+ shownLabels?: readonly string[]): 'approve' | 'reject' | 'dismissed';
71
+ /** 0.72.13 CC-46:带批准后模式的判读(三选卡的两个 Yes 各带一档;老两选的 Yes 不带键)。标签显式等值比对,第三种输入 ⇒ dismissed。 */
72
+ export type PlanReviewChoice = {
73
+ decision: 'approve';
74
+ permissionModeAfter?: PlanReviewModeAfter;
75
+ } | {
76
+ decision: 'reject';
77
+ };
78
+ export declare function planReviewChoiceFromAnswer(answer: QuestionAnswer | null | undefined,
79
+ /**
80
+ * 本次**实际展示**的那张卡的标签(`planReviewCardOptions(offer).map(o => o.label)`);只认其中的标签。
81
+ * 🔴 **不给 = 老两选**:三选卡的两个 Yes 只有调用方显式说「我展示的是三选卡」才读得成批准 —— 缺省不许替老调用方
82
+ * 悄悄扩大批准的输入域(异源对抗复审:重开腿恒两选,缺省放行时一个没展示过的 auto 标签会被投递成 approve)。
83
+ */
84
+ shownLabels?: readonly string[]): PlanReviewChoice | 'dismissed';
85
+ /** 卡选项的**单源**:`offerModeAfter=false` ⇒ 老两选(逐字同旧);`true` ⇒ 三选(两个 Yes + No)。 */
86
+ export declare function planReviewCardOptions(offerModeAfter: boolean): Array<{
87
+ label: string;
88
+ description: string;
89
+ }>;
57
90
  /**
58
91
  * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
59
92
  * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
@@ -85,13 +118,27 @@ export declare function _resetArmedPlanReviewsForTest(): void;
85
118
  * REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
86
119
  * never break the stream drain.
87
120
  */
88
- export declare function armPlanReviewApproval(result: unknown, sessionKey?: string): boolean;
121
+ export declare function armPlanReviewApproval(result: unknown, sessionKey?: string,
122
+ /**
123
+ * 0.72.13 CC-46:`submittedInPlanMode: true` = 宿主声明「这条任务是我用 `permissionMode:"plan"` 提交的」(只读起步)。
124
+ * 🔴 这件事**读不到 wire 上**(契约 §4c:两种任务产出同一张卡),所以只能由提交方声明;不声明 / false ⇒ 老两选卡、决断体不带键。
125
+ * 声明了还要过第二道闸:本进程 caps 缓存里的引擎版本 ≥7.86.0(更老的 server 静默丢这个键 = 卡上承诺了却不兑现)。
126
+ */
127
+ opts?: {
128
+ submittedInPlanMode?: boolean;
129
+ }): boolean;
89
130
  /** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.
90
131
  * SDK 宪法迁移批:SDK 0.0.52 assistant.planReview verb(同 wire `POST /v1/assistant/tasks/:id/
91
132
  * plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
92
133
  * server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
93
134
  * reach the engine」臂。 */
94
- export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject'): Promise<void>;
135
+ export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject',
136
+ /**
137
+ * 0.72.13 CC-46(engine ≥7.86.0;契约 §4c):批准之后用哪一档。缺席 ⇒ 体逐字节同旧 `{decision}`。只配 approve、闭集两词 ——
138
+ * 坏形本地拒(零 POST,outcome 如实说没送出去,不回显值)。引擎答 400 `request.field_conflict`(这条任务不是只读起步):
139
+ * `default` 与缺席同义 ⇒ 去键重发恰一次;`acceptEdits` **不**重发(不静默降成逐次征询),outcome 说批准没生效。
140
+ */
141
+ permissionModeAfter?: string): Promise<void>;
95
142
  /**
96
143
  * {@link reopenPlanReviewCard} 的宿主参数。
97
144
  */
@@ -41,6 +41,8 @@ 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 { isPlanReviewModeAfter } from './hitlBridge.js';
45
+ import { engineCapString } from '../engineCapsCache.js';
44
46
  import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
45
47
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
46
48
  import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
@@ -58,8 +60,42 @@ const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
58
60
  */
59
61
  export const PLAN_REVIEW_APPROVE_LABEL = 'Yes, approve and run the plan';
60
62
  export const PLAN_REVIEW_REJECT_LABEL = 'No, reject it (keep planning)';
63
+ /**
64
+ * 0.72.13 CC-46(engine ≥7.86.0 S-433;契约 §4c):三选卡的两个 Yes —— 对齐 CC `ExitPlanMode` 卡
65
+ * 「Yes, and auto-accept edits」/「Yes, and manually approve edits」。只在**双闸**都过时才出三选卡(见 {@link planReviewCardOptions}
66
+ * 的调用点):宿主声明这条任务是它自己用 `permissionMode:"plan"` 提交的(适用面读不到 wire)∧ 引擎版本证据 ≥7.86.0。
67
+ */
68
+ export const PLAN_REVIEW_APPROVE_AUTO_LABEL = 'Yes, and auto-accept edits';
69
+ export const PLAN_REVIEW_APPROVE_MANUAL_LABEL = 'Yes, and manually approve edits';
70
+ /** `permissionModeAfter` 的引擎地板(server 7.86.0;更老的 server 静默丢这个键 = 承诺了却不兑现)。 */
71
+ export const PLAN_REVIEW_MODE_AFTER_MIN_ENGINE = Object.freeze([7, 86, 0]);
72
+ /** 引擎版本串够不够新:三态 —— 读不出(缺席 / 非 x.y.z)⇒ `unknown`,**不折 supported**(没证据不许出三选卡)。 */
73
+ export function versionSupportsPlanReviewModeAfter(v) {
74
+ // 🔴 **整串**匹配正式版 x.y.z(无前导零):预发布(7.86.0-rc.1 可能还没实现这一位)/ 尾随垃圾 / v 前缀 / 空白 一律 unknown ——
75
+ // 它们不是支持证据(异源对抗复审:前缀匹配曾把这些都放行,宿主声明为 true 时三选卡就出了,而老引擎静默丢键)。
76
+ const mm = v ? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(v) : null;
77
+ if (!mm)
78
+ return 'unknown';
79
+ const got = [Number(mm[1]), Number(mm[2]), Number(mm[3])];
80
+ for (let i = 0; i < 3; i++) {
81
+ const g = got[i];
82
+ const need = PLAN_REVIEW_MODE_AFTER_MIN_ENGINE[i];
83
+ if (g !== need)
84
+ return g > need ? 'supported' : 'unsupported';
85
+ }
86
+ return 'supported';
87
+ }
61
88
  const APPROVE_LABEL = PLAN_REVIEW_APPROVE_LABEL;
62
89
  const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
90
+ /** 老两选卡的标签集 = 判读口不给展示标签时的缺省读域。 */
91
+ const LEGACY_TWO_CHOICE_LABELS = Object.freeze([PLAN_REVIEW_APPROVE_LABEL, PLAN_REVIEW_REJECT_LABEL]);
92
+ /** 四个已知标签(只供**记账**口判「是不是一次决断性作答」,不驱动任何决断)。 */
93
+ const ALL_KNOWN_LABELS = Object.freeze([
94
+ PLAN_REVIEW_APPROVE_AUTO_LABEL,
95
+ PLAN_REVIEW_APPROVE_MANUAL_LABEL,
96
+ PLAN_REVIEW_APPROVE_LABEL,
97
+ PLAN_REVIEW_REJECT_LABEL,
98
+ ]);
63
99
  /**
64
100
  * REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
65
101
  * 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
@@ -67,14 +103,55 @@ const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
67
103
  * (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
68
104
  * 负责如实分类,不替调用方做选择。
69
105
  */
70
- export function planReviewDecisionFromAnswer(answer) {
71
- const selected = answer?.answers?.[0]?.selected?.[0];
106
+ export function planReviewDecisionFromAnswer(answer,
107
+ /** 本次实际展示的那张卡的标签;不给 = 老两选(见 {@link planReviewChoiceFromAnswer})。 */
108
+ shownLabels) {
109
+ const choice = planReviewChoiceFromAnswer(answer, shownLabels);
110
+ if (choice === 'dismissed')
111
+ return 'dismissed';
112
+ return choice.decision;
113
+ }
114
+ export function planReviewChoiceFromAnswer(answer,
115
+ /**
116
+ * 本次**实际展示**的那张卡的标签(`planReviewCardOptions(offer).map(o => o.label)`);只认其中的标签。
117
+ * 🔴 **不给 = 老两选**:三选卡的两个 Yes 只有调用方显式说「我展示的是三选卡」才读得成批准 —— 缺省不许替老调用方
118
+ * 悄悄扩大批准的输入域(异源对抗复审:重开腿恒两选,缺省放行时一个没展示过的 auto 标签会被投递成 approve)。
119
+ */
120
+ shownLabels = LEGACY_TWO_CHOICE_LABELS) {
121
+ // 🔴 这是一张**单选**卡,而其中一项在安全轴上(批准后放宽写征询):恰一题、恰一项才算作答。矛盾多选(`[auto, reject]`)/
122
+ // 多题 / 空 selected 一律 dismissed —— 此前只取 `selected[0]`,拒绝意图会被丢弃、放宽被执行(异源对抗复审 [high])。
123
+ const answers = answer?.answers;
124
+ if (!Array.isArray(answers) || answers.length !== 1)
125
+ return 'dismissed';
126
+ const picked = answers[0]?.selected;
127
+ if (!Array.isArray(picked) || picked.length !== 1)
128
+ return 'dismissed';
129
+ const selected = picked[0];
130
+ // 标签必须属于这次展示的卡:两选卡上不存在「auto-accept edits」,收到它 = 答的不是这张卡。
131
+ if (typeof selected !== 'string' || !shownLabels.includes(selected))
132
+ return 'dismissed';
133
+ if (selected === PLAN_REVIEW_APPROVE_AUTO_LABEL)
134
+ return { decision: 'approve', permissionModeAfter: 'acceptEdits' };
135
+ if (selected === PLAN_REVIEW_APPROVE_MANUAL_LABEL)
136
+ return { decision: 'approve', permissionModeAfter: 'default' };
72
137
  if (selected === APPROVE_LABEL)
73
- return 'approve';
138
+ return { decision: 'approve' };
74
139
  if (selected === REJECT_LABEL)
75
- return 'reject';
140
+ return { decision: 'reject' };
76
141
  return 'dismissed';
77
142
  }
143
+ /** 卡选项的**单源**:`offerModeAfter=false` ⇒ 老两选(逐字同旧);`true` ⇒ 三选(两个 Yes + No)。 */
144
+ export function planReviewCardOptions(offerModeAfter) {
145
+ const reject = { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' };
146
+ if (!offerModeAfter) {
147
+ return [{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' }, reject];
148
+ }
149
+ return [
150
+ { label: PLAN_REVIEW_APPROVE_AUTO_LABEL, description: 'Resume and execute the plan; edits inside the working directory are not asked about one by one' },
151
+ { label: PLAN_REVIEW_APPROVE_MANUAL_LABEL, description: 'Resume and execute the plan; every edit still asks for approval' },
152
+ reject,
153
+ ];
154
+ }
78
155
  /**
79
156
  * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
80
157
  * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
@@ -90,7 +167,8 @@ export function notePlanReviewAnsweredIfDecisive(questionId, answer) {
90
167
  }
91
168
  /** W1 带 key 变体。 */
92
169
  function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
93
- if (planReviewDecisionFromAnswer(answer ?? { answers: [] }) === 'dismissed')
170
+ // 记账口:两种卡的任一已知标签都算决断性作答(这里不投递决断;投递侧各自按**自己展示的卡**校验标签)。
171
+ if (planReviewDecisionFromAnswer(answer ?? { answers: [] }, ALL_KNOWN_LABELS) === 'dismissed')
94
172
  return;
95
173
  notePlanReviewAnsweredFor(sessionKey, questionId);
96
174
  }
@@ -121,9 +199,15 @@ export function isPlanReviewPark(result) {
121
199
  * `registerLocalQuestionResponder` 调用。
122
200
  */
123
201
  const armedPlanReviewTaskIds = new Set();
202
+ /**
203
+ * 0.72.13 CC-46:这张已武装的卡**出的是不是三选卡**(两个 Yes 各带一档批准后模式)。与 {@link armedPlanReviewTaskIds}
204
+ * 同生命周期(arm 写、决断性作答 / arm 失败清);重放重呈读它,保证同一张卡前后两次呈现选项一致。缺席 = 老两选。
205
+ */
206
+ const offerModeAfterByTask = new Map();
124
207
  /** 测试钩:清掉武装态(不动 liveQuestionStore 本身,那边有自己的 reset)。 */
125
208
  export function _resetArmedPlanReviewsForTest() {
126
209
  armedPlanReviewTaskIds.clear();
210
+ offerModeAfterByTask.clear();
127
211
  }
128
212
  /* 合成 questionId 的唯一铸口:A-028.3 起收编进 `gateIdentity.planReviewQuestionId`(公面导出,
129
213
  * 壳侧重开腿从此 import 同一铸口,不再手抄字面)。本文件只 import,不再自铸。 */
@@ -132,15 +216,13 @@ const ARM_QUESTION = 'The plan is ready for review. Approve it and start the imp
132
216
  /** 重开腿的题面正句(点明「这张卡就是占住会话的那件事」;标签/选项与首呈同源)。 */
133
217
  const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
134
218
  /** 题面唯一构造点 —— 首次 arm、重放重呈、重开三处共用,标签/选项描述不许各写一份。 */
135
- function planReviewQuestions(question) {
219
+ function planReviewQuestions(question, offerModeAfter = false) {
136
220
  return [
137
221
  {
138
222
  header: 'Plan review',
139
223
  question,
140
- options: [
141
- { label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
142
- { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
143
- ],
224
+ // 选项单源 `planReviewCardOptions`:老两选逐字同旧;三选只在 arm 的双闸都过时出(重开腿恒老两选 = 批准不带键 ⇒ default)。
225
+ options: planReviewCardOptions(offerModeAfter),
144
226
  multiSelect: false,
145
227
  },
146
228
  ];
@@ -150,7 +232,7 @@ function publishPlanReviewCard(taskId) {
150
232
  publishQuestionFrame({
151
233
  type: 'question',
152
234
  questionId: planReviewQuestionId(taskId),
153
- questions: planReviewQuestions(ARM_QUESTION),
235
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
154
236
  });
155
237
  }
156
238
  /**
@@ -159,7 +241,13 @@ function publishPlanReviewCard(taskId) {
159
241
  * REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
160
242
  * never break the stream drain.
161
243
  */
162
- export function armPlanReviewApproval(result, sessionKey) {
244
+ export function armPlanReviewApproval(result, sessionKey,
245
+ /**
246
+ * 0.72.13 CC-46:`submittedInPlanMode: true` = 宿主声明「这条任务是我用 `permissionMode:"plan"` 提交的」(只读起步)。
247
+ * 🔴 这件事**读不到 wire 上**(契约 §4c:两种任务产出同一张卡),所以只能由提交方声明;不声明 / false ⇒ 老两选卡、决断体不带键。
248
+ * 声明了还要过第二道闸:本进程 caps 缓存里的引擎版本 ≥7.86.0(更老的 server 静默丢这个键 = 卡上承诺了却不兑现)。
249
+ */
250
+ opts) {
163
251
  let armedTaskId;
164
252
  try {
165
253
  if (!isPlanReviewPark(result))
@@ -227,6 +315,13 @@ export function armPlanReviewApproval(result, sessionKey) {
227
315
  }
228
316
  armedPlanReviewTaskIds.add(taskId);
229
317
  armedTaskId = taskId;
318
+ // CC-46 双闸:宿主声明 ∧ 引擎版本证据(读不出 ⇒ unknown ⇒ 不出三选卡)。
319
+ const offerModeAfter = opts?.submittedInPlanMode === true &&
320
+ versionSupportsPlanReviewModeAfter(engineCapString(engineWireTarget()?.baseUrl, 'version')) === 'supported';
321
+ if (offerModeAfter)
322
+ offerModeAfterByTask.set(taskId, true);
323
+ else
324
+ offerModeAfterByTask.delete(taskId);
230
325
  const questionId = planReviewQuestionId(taskId);
231
326
  const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
232
327
  // REF-CC-027:一次性 —— 首句就注销 + 清武装态(照 askGateWire 的 cleanup() 形)。
@@ -234,19 +329,24 @@ export function armPlanReviewApproval(result, sessionKey) {
234
329
  // 第二道钉:武装态一清,同 taskId 才能重新正常 arm(证明 responder 真的落定了)。
235
330
  unregister();
236
331
  armedPlanReviewTaskIds.delete(taskId);
237
- const decided = planReviewDecisionFromAnswer(answer);
238
- if (decided === 'dismissed') {
332
+ const shown = planReviewCardOptions(offerModeAfterByTask.get(taskId) === true).map(o => o.label);
333
+ const choice = planReviewChoiceFromAnswer(answer, shown);
334
+ if (choice === 'dismissed') {
239
335
  // dismiss/无法判读:诚实缺席优先于编造默认值(REF-CC-026)——不静默驱动 decidePlanReview,
240
336
  // 卡的重开路径把决定权还给用户。呈现台账的键**不清**:同一张卡再被重开就是真「reopened」。
241
337
  return { ok: true };
242
338
  }
339
+ const decided = choice.decision;
340
+ // CC-46:只有三选卡的两个 Yes 带批准后模式;老两选的 Yes / No 不带 ⇒ 决断体不落键。
341
+ const modeAfter = choice.decision === 'approve' ? choice.permissionModeAfter : undefined;
342
+ offerModeAfterByTask.delete(taskId);
243
343
  // A-024.4(#244 F1 起走分代):决断一经递交,这个门的呈现史就消费掉(当代键清 + 推代);
244
344
  // 同 run 的**下一个** plan gate(approve 推进后引擎可再 park 一个新 plan)必须读回首见,
245
345
  // 否则文案对一张从未呈现过的新卡说「reopened」。递交后决定未生效的形(RB-471 族)读回
246
346
  // 首见只损失「reopened」一词 —— 首见文案零历史断言,诚实方向安全。
247
347
  notePlanReviewAnswered(questionId);
248
348
  // fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
249
- void decidePlanReview(taskId, decided);
349
+ void decidePlanReview(taskId, decided, modeAfter);
250
350
  return { ok: true };
251
351
  });
252
352
  publishPlanReviewCard(taskId);
@@ -256,8 +356,10 @@ export function armPlanReviewApproval(result, sessionKey) {
256
356
  catch (e) {
257
357
  // REF-CC-027:arm 本身失败(register/publish 抛)绝不能把 taskId 永久锁在武装态里
258
358
  // ——那样这张卡再也无法被正常 arm 一次。
259
- if (armedTaskId !== undefined)
359
+ if (armedTaskId !== undefined) {
260
360
  armedPlanReviewTaskIds.delete(armedTaskId);
361
+ offerModeAfterByTask.delete(armedTaskId);
362
+ }
261
363
  hostLog('debug', `planReviewWire: arm failed (fail-soft): ${String(e)}`);
262
364
  return false;
263
365
  }
@@ -267,7 +369,13 @@ export function armPlanReviewApproval(result, sessionKey) {
267
369
  * plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
268
370
  * server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
269
371
  * reach the engine」臂。 */
270
- export async function decidePlanReview(taskId, decision) {
372
+ export async function decidePlanReview(taskId, decision,
373
+ /**
374
+ * 0.72.13 CC-46(engine ≥7.86.0;契约 §4c):批准之后用哪一档。缺席 ⇒ 体逐字节同旧 `{decision}`。只配 approve、闭集两词 ——
375
+ * 坏形本地拒(零 POST,outcome 如实说没送出去,不回显值)。引擎答 400 `request.field_conflict`(这条任务不是只读起步):
376
+ * `default` 与缺席同义 ⇒ 去键重发恰一次;`acceptEdits` **不**重发(不静默降成逐次征询),outcome 说批准没生效。
377
+ */
378
+ permissionModeAfter) {
271
379
  // REF-CC-025:此前 `cfg`/`client` 缺席各自 `return` 静默——卡已经对用户回了「收到」
272
380
  // (armPlanReviewApproval 的 responder 早就 `return {ok:true}` 过了),但决断本身连一次网络
273
381
  // 请求都没发出去,而模型/用户没有任何回程信号。两条早退现在都汇进同一条 outcome 管道(下方
@@ -276,7 +384,12 @@ export async function decidePlanReview(taskId, decision) {
276
384
  // 🔴 design/285 批 3 显式裁定:本处与 `armPlanReviewApproval` 同一笔豁免(理由逐字见那边)——
277
385
  // 决断是从 arm 立的那张卡的 responder 回调进来的,槽键必须与立卡时同源,单换这一跳即两头不一致。
278
386
  const cfg = engineWireTarget();
279
- if (!cfg) {
387
+ const badMode = permissionModeAfter !== undefined && (decision !== 'approve' || !isPlanReviewModeAfter(permissionModeAfter));
388
+ if (badMode) {
389
+ hostLog('error', `planReviewWire: ${decision} NOT sent — permissionModeAfter is only valid with approve and must be "default" or "acceptEdits"`);
390
+ outcome = `The plan_review ${decision} could NOT be sent: the post-approval permission mode given with it is not valid for this decision (it only applies to an approval, and must be "default" or "acceptEdits"). Tell the user plainly that the decision did not go through; the plan is still waiting.`;
391
+ }
392
+ else if (!cfg) {
280
393
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
281
394
  outcome = `The plan_review ${decision} could NOT be sent: no engine connection is configured on this host. Tell the user plainly that the decision did not go through.`;
282
395
  }
@@ -293,7 +406,27 @@ export async function decidePlanReview(taskId, decision) {
293
406
  }
294
407
  else {
295
408
  try {
296
- const body = await client.assistant.planReview(taskId, { decision });
409
+ // sdk 9.6.0 的 PlanReviewRequest 未声明 permissionModeAfter ⇒ 交集型加一可选位;缺席 = 键不落(体逐字节同旧)。
410
+ const mode = isPlanReviewModeAfter(permissionModeAfter) ? permissionModeAfter : undefined;
411
+ const reqBody = { decision };
412
+ if (mode !== undefined)
413
+ reqBody.permissionModeAfter = mode;
414
+ let body;
415
+ try {
416
+ body = await client.assistant.planReview(taskId, reqBody);
417
+ }
418
+ catch (e1) {
419
+ // 400 request.field_conflict + 带了 default:default ≡ 缺席(契约:缺席 = 这一档)⇒ 去键重发恰一次,零语义损失。
420
+ // 被这道门拒掉的请求一个字节的状态都不动(契约),同一次批准可原样重试。acceptEdits 不走这里(见下方 catch)。
421
+ const conflict = e1;
422
+ if (mode === 'default' && conflict?.status === 400 && conflict?.errorCode === 'request.field_conflict') {
423
+ hostLog('debug', 'planReviewWire: permissionModeAfter "default" refused as not applicable — re-sending the approval without the key (same meaning)');
424
+ body = await client.assistant.planReview(taskId, { decision });
425
+ }
426
+ else {
427
+ throw e1;
428
+ }
429
+ }
297
430
  hostLog('debug', `planReviewWire: ${decision} → ok ${JSON.stringify(body).slice(0, 200)}`);
298
431
  // [2315]/[2316](#109,2026-08-02):decide 的 **2xx 不当终态** —— test 黑盒实测 reject 9/9
299
432
  // 返回 200 而会话仍锁在同一 gate(core RB-471:重开兜底对 reject 腿恒真误触发)。这里回拉
@@ -341,7 +474,16 @@ export async function decidePlanReview(taskId, decision) {
341
474
  catch (e) {
342
475
  // APIError 判型走 status duck-check(sseIdleTriage 同款纪律:双包时 instanceof 会分叉)。
343
476
  const status = e?.status;
344
- if (typeof status === 'number') {
477
+ const refusedCode = e?.errorCode;
478
+ if (status === 400 && refusedCode === 'request.field_conflict' && permissionModeAfter === 'acceptEdits') {
479
+ // CC-46:这条任务不是只读起步的(模型自选 plan 模式那种形)⇒ 引擎拒收「批准后自动接受编辑」。**不**静默降档重发:
480
+ // 用户选的是「不再逐次征询」,悄悄换成「逐次征询」= 替他改了决定。引擎一个字节的状态都没动,计划仍在等。
481
+ hostLog('debug', 'planReviewWire: approve + acceptEdits refused (request.field_conflict) — NOT re-sent with a different mode');
482
+ outcome =
483
+ 'The plan approval was NOT applied: the engine refused "auto-accept edits" for this task (it was not started read-only in plan mode, so that option does not apply). ' +
484
+ 'Nothing changed engine-side — the plan is still waiting for review. Tell the user plainly, and that approving with "manually approve edits" will go through.';
485
+ }
486
+ else if (typeof status === 'number') {
345
487
  const msg = e instanceof Error ? e.message : String(e);
346
488
  hostLog('debug', `planReviewWire: ${decision} → ${status} ${msg.slice(0, 200)}`);
347
489
  outcome = `The plan_review decision failed: HTTP ${status} ${msg}`.trim();
@@ -450,14 +592,15 @@ export function reopenPlanReviewCard(taskId, opts) {
450
592
  publishQuestionFrameFor(sessionKey, {
451
593
  type: 'question',
452
594
  questionId: canonicalId,
453
- questions: planReviewQuestions(ARM_QUESTION),
595
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
454
596
  });
455
597
  return settleOnReceipt(receipt, firstSight);
456
598
  }
457
599
  publishQuestionFrameFor(sessionKey, {
458
600
  type: 'question',
459
601
  questionId: canonicalId,
460
- questions: planReviewQuestions(ARM_QUESTION),
602
+ // 🔴 沿用 arm responder ⇒ 题面必须是**那张卡**的选项(三选 arm 后重绘成两选 = 屏上的 Yes 不在 responder 的读域里,点了不投递)。
603
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
461
604
  });
462
605
  registerArmedGateFor(sessionKey, armedKey);
463
606
  return { reopened: true, firstSight };
@@ -489,7 +632,8 @@ export function reopenPlanReviewCard(taskId, opts) {
489
632
  if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
490
633
  activeReopenResponders.delete(activeKey);
491
634
  }
492
- const decided = planReviewDecisionFromAnswer(answer);
635
+ // 本腿展示的恒是老两选 ⇒ 显式按两选标签校验(未展示的 auto / manual 标签 = 答的不是这张卡 ⇒ dismissed)。
636
+ const decided = planReviewDecisionFromAnswer(answer, LEGACY_TWO_CHOICE_LABELS);
493
637
  if (decided === 'dismissed') {
494
638
  // 三态(REF-CC-026):dismiss/读不出不驱动 decidePlanReview;呈现台账不清 ——
495
639
  // 这张卡再被重开就是真「reopened」。
@@ -486,6 +486,11 @@ export interface ApprovalCardRequest {
486
486
  /** wire 层降级说明(卡 content 上方 dim 行,ToolUseConfirm.wireNote 超集位)——argsOmitted 等
487
487
  * 不可注入 args 的提示走这里([1543]③ 记账的 note 位,2026-07-23 落位)。 */
488
488
  wireNote?: string;
489
+ /**
490
+ * 0.72.14:这只 ask 的工具入参在本读面上**不可得**(悬挂的流内 ask)。为真 ⇒ 卡上渲的 `args` 不是工具的真实入参,
491
+ * 端**不要**给「编辑后批准」;包侧收到带改写的批准会拒发(见 `ToolApprovalFrameOutcome.editRefused`)。缺席 ≠ false。
492
+ */
493
+ argsUnavailable?: true;
489
494
  /**
490
495
  * 治理强制位(server ≥7.5.0,2026-08-08 补透传)。**缺席 ≠ false**:只在为真时在场,缺席 = 无治理
491
496
  * 来源的证据。壳应据此把门呈成**表态掀不掉**(而不是引导用户去改 `permissionMode`);呈现形是壳
@@ -1284,6 +1289,11 @@ export interface ToolApprovalFrameOutcome {
1284
1289
  * (传输层失败)都是真实形。
1285
1290
  */
1286
1291
  respondRefusal?: ToolApprovalRespondRefusal;
1292
+ /**
1293
+ * 0.72.14:入参不可得的卡(悬挂的流内 ask)上收到了「编辑后批准」⇒ 包侧**一个字节都没发**,`decision` 为 `'unresolved'`,
1294
+ * 这只 ask 在引擎上仍然挂着。端应重新出卡(跟踪器 `requeue`)。缺席 = 没发生这件事。
1295
+ */
1296
+ editRefused?: true;
1287
1297
  }
1288
1298
  /**
1289
1299
  * #225 件5:respond 抛错的**结构化原文**(不是新的决断态,见
@@ -1484,5 +1494,11 @@ export interface ToolApprovalFrameLaneOpts {
1484
1494
  * 老宿主不传本位时行为 = 0.38.1,零回归)。live 腿的宿主接线随各端提货批补 `true`。
1485
1495
  */
1486
1496
  windowIsCurrent?: boolean;
1497
+ /**
1498
+ * 0.72.14:这只 ask 的**工具入参在本读面上不可得**(悬挂的流内 ask 从 `GET /v1/approvals` 的 `livePending` 段读出,
1499
+ * 该段刻意不带入参)。为真 ⇒ 卡上明说「看不到入参」,且「编辑后批准」的改写不转发(没有原文可编,转发一个空对象
1500
+ * 会把工具的真实入参换掉)—— 收到带改写的批准时**整次不发**,结局标 `editRefused`。只由 `surfaceSuspendedAskAndRespond` 置;流内帧腿不传。
1501
+ */
1502
+ argsUnavailable?: true;
1487
1503
  }
1488
1504
  export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal, lane?: ToolApprovalFrameLaneOpts): Promise<ToolApprovalFrameOutcome>;
@@ -87,7 +87,7 @@ import { askParkRowArm } from './askParkRowRouting.js';
87
87
  import { hostLog } from '../host.js';
88
88
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
89
89
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
90
- import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded, surfaceRuleArmNotSent, surfaceRuleArmRejected } from './hitlHostSurface.js';
90
+ import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded, surfaceEditRefusedOnBlindAsk, surfaceRuleArmNotSent, surfaceRuleArmRejected } from './hitlHostSurface.js';
91
91
  import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
92
92
  // 0.67.0(core 7.14.0 #688 C3):`ruleStoreUnreadable` 的**闭二词判据**。词表属主 = core
93
93
  // (`RULE_STORE_UNREADABLE_KINDS`),镜像与措辞都在 `gateVocabulary.ts` 那个唯一铸点上 ——
@@ -1353,6 +1353,12 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1353
1353
  wireNote = 'tool arguments exceeded the wire cap and were omitted — the diff below is reconstructed from the gate message, not the full payload';
1354
1354
  hostLog('debug', `liveToolApprovalWire: frame ${frame.approvalId} args omitted (>16KiB wire cap) — card falls back to message-derived path`);
1355
1355
  }
1356
+ if (lane?.argsUnavailable === true) {
1357
+ wireNote =
1358
+ "this request's tool arguments are not available on this surface — it was raised while no client was connected, " +
1359
+ 'so you are deciding without seeing them; deny it if you are not sure what it will do. ' +
1360
+ 'Edits are not accepted on this card: there is no original input to edit';
1361
+ }
1356
1362
  const p = pathFromGateMessage(frame.message);
1357
1363
  args = p !== undefined ? { file_path: p } : {};
1358
1364
  }
@@ -1382,6 +1388,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1382
1388
  ...(signal ? { signal } : {}),
1383
1389
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
1384
1390
  ...(wireNote !== undefined ? { wireNote } : {}),
1391
+ ...(lane?.argsUnavailable === true ? { argsUnavailable: true } : {}),
1385
1392
  ...(frame.governanceForced === true ? { governanceForced: true } : {}),
1386
1393
  // [4851]:窗三键透传,三闸合取(异源对抗复审 P1/P2 收编):
1387
1394
  // ① `lane.windowIsCurrent === true` —— durable 账本重放的历史帧带的是铸帧时刻的旧余量,
@@ -1468,6 +1475,14 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1468
1475
  // `"policy"`**:那是一个正面事实(出自部署 ToolPolicy),把「老引擎没报」折进去 = 替引擎编话。
1469
1476
  ...(typeof frame.origin === 'string' && frame.origin !== '' ? { origin: frame.origin } : {}),
1470
1477
  });
1478
+ // 0.72.14:入参不可得的卡上收到「编辑后批准」⇒ **什么都不发**。丢掉改写再发 allow = 批准了人没看到、也不是他改成的那份
1479
+ // 原始入参;把改写转发出去 = 拿卡上那个空对象替换工具的真实入参。两条都不是人按下的那个决定 ⇒ 这只 ask 保持悬挂,
1480
+ // 结局如实标 `editRefused`,由端重新出卡(异源对抗复审 [high])。
1481
+ if (lane?.argsUnavailable === true && card.kind === 'allow' && card.updatedInput !== undefined) {
1482
+ hostLog('error', `liveToolApprovalWire: ${frame.approvalId} card returned an edited approval on an args-unavailable ask — nothing sent (the ask stays pending)`);
1483
+ surfaceEditRefusedOnBlindAsk();
1484
+ return { decision: 'unresolved', editRefused: true };
1485
+ }
1471
1486
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
1472
1487
  if (card.kind === 'failed') {
1473
1488
  hostLog('debug', `liveToolApprovalWire: approval card unavailable (${card.reason}) — fail-closed deny for ${frame.approvalId}`);
package/dist/index.d.ts CHANGED
@@ -155,6 +155,7 @@ export * from './engineCapsCache.js';
155
155
  export * from './sqlEngineCapability.js';
156
156
  export * from './writeProtectionCapability.js';
157
157
  export * from './webSearchBackendCapability.js';
158
+ export * from './executionLaneCapability.js';
158
159
  export * from './mcpReconnect.js';
159
160
  export * from './leaderConflict.js';
160
161
  export * from './runTerminal.js';
@@ -266,6 +267,7 @@ export * from './hitl/resumeRunningCard.js';
266
267
  export * from './hitl/persistedRulesWire.js';
267
268
  export * from './hitl/localAllowRule.js';
268
269
  export * from './hitl/approvalsFeed.js';
270
+ export * from './hitl/livePendingAsk.js';
269
271
  export * from './hitl/crashConverged.js';
270
272
  export * from './interactiveHalt.js';
271
273
  export * from './compensations.js';
package/dist/index.js CHANGED
@@ -187,6 +187,9 @@ export * from './writeProtectionCapability.js';
187
187
  // S-382(0.72.5;server ≥7.82.1,sdk 尚未声明):`Capabilities.webSearch.backend` 的四态窄读器 ——
188
188
  // 部署默认 WebSearch 后端(`none` = 正面事实 / 键缺席 = 老引擎判不了,两者处置相反)。与上两只同构同纪律。
189
189
  export * from './webSearchBackendCapability.js';
190
+ // 0.72.13 CC-54:`capabilities.executionLane`(server ≥7.86.0 S-426)四态读面 —— 键缺席 = 老 server 判不了(≠ 不是本机车道);
191
+ // 「工具是否跑在本机」判据归包(读到位用位,读不到原样用调用方旁证)。
192
+ export * from './executionLaneCapability.js';
190
193
  // S-381 / core 7.22.0 #857(0.72.9 CC-30;server ≥7.85.0,sdk 尚未声明):会话内 MCP re-dial 的消费口 —— 200 三态按 outcome 分形读、
191
194
  // unsupported 恒五键、名册按 toolNames 在场性、失败按 errorCode 分诊、「重连 = 一次交易」措辞单源;能力位 mcpReconnect 三态读。
192
195
  export * from './mcpReconnect.js';
@@ -489,6 +492,8 @@ export * from './hitl/localAllowRule.js';
489
492
  // B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
490
493
  // 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
491
494
  export * from './hitl/approvalsFeed.js';
495
+ // 0.72.14:悬挂的流内 ask(`GET /v1/approvals` 的 `livePending` 段)的读面、视图、去重账与决断口。
496
+ export * from './hitl/livePendingAsk.js';
492
497
  // ── L-38:`/v1/approvals` additive 键 `crashConverged` 的读面 + 纯投影 ─────────────────────────
493
498
  // local 引擎崩在审批门上时,那些孤儿 ask 被 server 重启后收敛成 DENIED 同码;这一键把「上一条命
494
499
  // 留下了什么」交到端手上。收在库里的理由是**两处判定**三端各写一遍必然各错一遍:① **缺席 vs
@@ -902,26 +902,37 @@ export function enqueueBgChildNotification(n) {
902
902
  return;
903
903
  }
904
904
  bgNotifiedKeys.add(key);
905
- // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
906
- markRunNotified(n.taskId, cycle);
907
- cardEnqueuedRunIds.add(n.taskId);
905
+ // 🔴 完成三本账(完成去重 / 周期 / 完成卡)与面板 settle **只认通知自己的终态词**(completed / failed / killed / cancelled)。
906
+ // 非终态(running / queued / event / unknown / 未来词)只过上面那本**逐条**去重账:它若占了完成账,同一任务随后的
907
+ // 真终态会被跨通道去重吞掉 ⇒ 零 end、常驻标永不摘(异源对抗复审)。
908
+ const terminal = isTaskNotificationTerminalStatus(n.status);
909
+ if (terminal) {
910
+ // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
911
+ markRunNotified(n.taskId, cycle);
912
+ cardEnqueuedRunIds.add(n.taskId);
913
+ }
908
914
  // #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
909
915
  // 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
910
916
  // 消费端 settle 幂等(running 才动),推送帧先到时此发布为 no-op。
911
- try {
912
- clearEnginePanelTaskResident(n.taskId);
913
- publishEngineAgentPanelEvent({
914
- kind: 'end',
915
- taskId: n.taskId,
916
- // L-215②(0.65.0):读**单铸谓词**而不是内联两词 —— 修前这里只认 `failed`/`killed`,
917
- // 而 core [6908] 的 `blocked` 是 **agent 自报的终态**(不是等人)⇒ 一条自报走不下去的
918
- // 后台 run 在面板上被 settle 成**成功**。🔴 `suspended`/`needs_review` 仍不在表里
919
- // (那两词是「等一次人的决定」,判成终局会把一条正等着你的 run 在面板上判死)。
920
- isError: isTerminalNotSuccess(n.status),
921
- });
922
- }
923
- catch {
924
- /* fail-soft — 面板 settle 失败绝不挡模型通知注入 */
917
+ if (terminal) {
918
+ try {
919
+ // 0.72.13(test 0.72.12 验收 ①;DS-06 同形存量):只对**通知自己的终态词**(completed / failed / killed / cancelled)settle 面板行。
920
+ // 此前对任何 status 都铸 `end`,而 `isError: isTerminalNotSuccess(status)` 对 running / queued / event / 未来词答 false ⇒
921
+ // 面板把一条还在跑 / 认不出状态的行结成 **completed** —— 该谓词的 JSDoc 明禁拿它的 false 当成功。非终态 ⇒ 不 settle、常驻标不摘。
922
+ clearEnginePanelTaskResident(n.taskId);
923
+ publishEngineAgentPanelEvent({
924
+ kind: 'end',
925
+ taskId: n.taskId,
926
+ // L-215②(0.65.0):读**单铸谓词**而不是内联两词 —— 修前这里只认 `failed`/`killed`,
927
+ // 而 core [6908] 的 `blocked` 是 **agent 自报的终态**(不是等人)⇒ 一条自报走不下去的
928
+ // 后台 run 在面板上被 settle 成**成功**。🔴 `suspended`/`needs_review` 仍不在表里
929
+ // (那两词是「等一次人的决定」,判成终局会把一条正等着你的 run 在面板上判死)。
930
+ isError: isTerminalNotSuccess(n.status),
931
+ });
932
+ }
933
+ catch {
934
+ /* fail-soft — 面板 settle 失败绝不挡模型通知注入 */
935
+ }
925
936
  }
926
937
  const message = `<${TASK_NOTIFICATION_TAG}>
927
938
  <${TASK_ID_TAG}>${escapeXml(n.taskId)}</${TASK_ID_TAG}>