@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.
@@ -1,5 +1,6 @@
1
1
  import { hostLog } from '../host.js';
2
2
  import { unrefTimer } from '../unrefTimer.js';
3
+ import { readLivePendingRows } from './livePendingAsk.js';
3
4
  /** [2393] hitl-F5:poll 回调体**连续**逃逸多少次之后判定「这条腿真挂了」并把 `mode` 打成 idle。
4
5
  * 取 5 是为了让「偶发一拍畸形 payload」不误判(单次逃逸下一拍就归零),同时不让一条永远抛的腿
5
6
  * 无限期地对着端的自检假装自己还活着。 */
@@ -9,13 +10,29 @@ const MAX_CONSECUTIVE_POLL_ESCAPES = 5;
9
10
  * 键里带 `boundInputHash`:同一条 pending 的绑定被服务端换掉(TOCTOU 场景)也算变化,
10
11
  * 否则端会拿着一个「id 没变但绑定变了」的行继续渲染。
11
12
  */
12
- function digestOf(rows) {
13
+ function durableDigestOf(rows) {
13
14
  return rows
14
15
  .map(r => [r.sessionId ?? '', r.taskId ?? '', r.toolCallId ?? '', r.boundCallId ?? '', r.boundInputHash ?? '', r.toolName ?? '']
15
16
  .join(''))
16
17
  .sort()
17
18
  .join('');
18
19
  }
20
+ const LIVE_FIELD_SEP = String.fromCharCode(1);
21
+ const LIVE_ROW_SEP = String.fromCharCode(2);
22
+ const LIVE_SECTION_SEP = String.fromCharCode(3);
23
+ /**
24
+ * 0.72.14:流内 ask 段进摘要 —— 此前只算 `pending`,悬挂 ask 出现 / 消失都不触发快照。
25
+ * 「未报」与「报了且为空」是两个不同的事实(前者读不出计数),摘要上也分开。
26
+ */
27
+ function digestOf(rows, live) {
28
+ const liveDigest = live === undefined
29
+ ? 'absent'
30
+ : live
31
+ .map(r => [r.approvalId, r.toolName, String(r.expiresAtMs), r.sessionId ?? '', r.originTaskId ?? ''].join(LIVE_FIELD_SEP))
32
+ .sort()
33
+ .join(LIVE_ROW_SEP);
34
+ return `${durableDigestOf(rows)}${LIVE_SECTION_SEP}${liveDigest}`;
35
+ }
19
36
  /**
20
37
  * 起一条 pending-approvals feed。**幂等性归调用方**:一个 client 起一条就够了,起两条 =
21
38
  * 两倍取件(不是错误,但没意义)。
@@ -51,7 +68,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
51
68
  let takeSeq = 0;
52
69
  let lastObservedSeq = 0;
53
70
  const stats = {
54
- pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
71
+ pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0, reconciles: 0,
55
72
  };
56
73
  /** 域词表-14 收编:「怎么调 unref」的单一实现见 unrefTimer.ts,本函数只是就地起个短名。 */
57
74
  function arm(t) {
@@ -90,8 +107,23 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
90
107
  return;
91
108
  const seq = ++takeSeq;
92
109
  let rows;
110
+ let live;
93
111
  try {
94
- rows = (await client.approvals.list({ signal: ac.signal })).pending;
112
+ const body = await client.approvals.list({ signal: ac.signal });
113
+ rows = body.pending;
114
+ // 0.72.14:`livePending` 段按结构窄读。键在场却读不懂 ⇒ **整次取件不提交**(保留上一张快照):
115
+ // 把「读不懂」提交成「未报 / 为空」会让端撤掉一张还在等人的卡。
116
+ const reading = readLivePendingRows(body);
117
+ if (reading.kind === 'malformed') {
118
+ stats.listErrors++;
119
+ hostLog('debug', `approvalsFeed: list() body has an unreadable livePending section (${via} leg) — snapshot not committed`);
120
+ return;
121
+ }
122
+ if (reading.kind === 'present') {
123
+ live = reading.rows;
124
+ if (reading.dropped > 0)
125
+ hostLog('debug', `approvalsFeed: dropped ${reading.dropped} malformed livePending row(s)`);
126
+ }
95
127
  }
96
128
  catch (e) {
97
129
  stats.listErrors++;
@@ -100,7 +132,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
100
132
  }
101
133
  if (stopped)
102
134
  return;
103
- const d = digestOf(rows);
135
+ const d = digestOf(rows, live);
104
136
  // 🔴 [F4957-1 二审 finding②] 代际闸必须排在**提交之前**:下面四行(lastDigest / revision /
105
137
  // last / onSnapshot)一落,视图就已经被改了 —— 守卫排在 take() 之外只挡得住计数与续排,挡不住
106
138
  // 回滚。乱序是真的会发生的:旧 poll 在途 → push 重连并发布新快照 → 旧 poll 才带着**更老**的
@@ -122,7 +154,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
122
154
  return; // 内容没变 ⇒ 不打扰订阅者
123
155
  lastDigest = d;
124
156
  revision++;
125
- const snap = { pending: rows, mode: via, revision };
157
+ const snap = { pending: rows, ...(live !== undefined ? { livePending: live } : {}), mode: via, revision };
126
158
  last = snap;
127
159
  stats.snapshots++;
128
160
  try {
@@ -205,6 +237,54 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
205
237
  });
206
238
  }, pollIntervalMs));
207
239
  }
240
+ /** 0.72.14 对账节拍(见 {@link ApprovalsFeedReconcileOptions})。独立于两条腿:不读 `mode`、不动 F5 计数、不计 `polls`。 */
241
+ const reconcileBaseMs = Math.max(1, opts?.reconcile?.intervalMs ?? 5000);
242
+ const reconcileMaxMs = Math.max(reconcileBaseMs, opts?.reconcile?.maxIntervalMs ?? 30_000);
243
+ let reconcileTimer;
244
+ let reconcileDelayMs = reconcileBaseMs;
245
+ function reconcileGateOpen() {
246
+ let verdict;
247
+ try {
248
+ verdict = opts?.reconcile?.shouldReconcile();
249
+ }
250
+ catch (e) {
251
+ hostLog('debug', `approvalsFeed: shouldReconcile() threw — treating this tick as "reconcile" (fail-safe): ${String(e)}`);
252
+ return true;
253
+ }
254
+ if (typeof verdict !== 'boolean') {
255
+ hostLog('debug', `approvalsFeed: shouldReconcile() returned ${typeof verdict} (not boolean) — treating this tick as "reconcile"`);
256
+ return true;
257
+ }
258
+ return verdict;
259
+ }
260
+ function scheduleReconcile() {
261
+ if (opts?.reconcile === undefined)
262
+ return;
263
+ if (stopped || ac.signal.aborted)
264
+ return;
265
+ if (reconcileTimer !== undefined)
266
+ return;
267
+ reconcileTimer = arm(setTimeout(() => {
268
+ reconcileTimer = undefined;
269
+ if (stopped || ac.signal.aborted)
270
+ return;
271
+ if (!reconcileGateOpen()) {
272
+ reconcileDelayMs = reconcileBaseMs; // 闸关 ⇒ 下次转开时从基础间隔起
273
+ scheduleReconcile();
274
+ return;
275
+ }
276
+ void (async () => {
277
+ const before = revision;
278
+ stats.reconciles++;
279
+ await take('poll');
280
+ reconcileDelayMs = revision !== before ? reconcileBaseMs : Math.min(reconcileDelayMs * 2, reconcileMaxMs);
281
+ })()
282
+ .catch(e => {
283
+ hostLog('debug', `approvalsFeed: reconcile tick threw unexpectedly (take() should have self-caught): ${String(e)}`);
284
+ })
285
+ .then(() => scheduleReconcile());
286
+ }, reconcileDelayMs));
287
+ }
208
288
  /**
209
289
  * 与 schedulePoll 对称(域词表-18):**幂等卫兵**(retryTimer 已在飞则不再排一个)+ **回调首行
210
290
  * 自清**(否则 retryTimer 触发后仍持有已死句柄,degradeToPoll 若被再次调用会直接覆盖它而不清掉
@@ -312,6 +392,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
312
392
  void runStream();
313
393
  else
314
394
  degradeToPoll('SDK has no approvals.stream (old SDK / mock)', false);
395
+ scheduleReconcile();
315
396
  });
316
397
  return {
317
398
  stop() {
@@ -323,6 +404,8 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
323
404
  clearTimeout(pollTimer);
324
405
  if (retryTimer !== undefined)
325
406
  clearTimeout(retryTimer);
407
+ if (reconcileTimer !== undefined)
408
+ clearTimeout(reconcileTimer);
326
409
  opts?.signal?.removeEventListener('abort', onOuterAbort);
327
410
  ac.abort();
328
411
  },
@@ -88,7 +88,8 @@ export interface CrashConvergedRow {
88
88
  * `GET /v1/approvals` 的**信封**形。`pending` 是老形的全部;后两键 additive
89
89
  * (server 按 deps 在场才发)⇒ **老形 `{ pending }` 仍然可赋值到本型**,现有消费点一个字节不用改。
90
90
  *
91
- * 🔴 `livePending` 在本包是**不透明载体**,留 `unknown[]` 有两条**互相独立**的理由:
91
+ * 🔴 `livePending` 在**本型上**留 `unknown[]`(0.72.14 起它有了包内读器 `readLivePendingRows`,按结构窄读;
92
+ * 下面 ① 说的「零消费点」自那版起不再成立,② 仍然成立,也是读器按结构读而不引类型的原因):
92
93
  * ① 本包对它零消费点,在这里手抄一份结构 = 手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause`
93
94
  * 同一条理由);
94
95
  * ② **SDK barrel 根本没把 `LivePendingRow` 导出**(实证:`@sema-agent/sdk` 7.4.0 的 `dist/index.d.ts`
@@ -138,7 +138,18 @@ export interface PlanReviewOutcome {
138
138
  decision: 'approve' | 'edit' | 'reject';
139
139
  editedPlan?: string;
140
140
  reason?: string;
141
+ /**
142
+ * 0.72.13 CC-46(engine ≥7.86.0;契约 §4c):批准之后这条任务继续用的 permission mode。**只配 `approve`**;
143
+ * 缺席 = 键不落(老壳姿势,引擎按 `default`)。🔴 适用面读不到 wire:只有**提交这条任务的那一方**知道它是不是
144
+ * `permissionMode:"plan"` 只读起步的 —— 不是只读起步的任务送这一位会吃 400 `request.field_conflict`,那次批准整个送不出去。
145
+ */
146
+ permissionModeAfter?: PlanReviewModeAfter;
141
147
  }
148
+ /** 契约 §4c `permissionModeAfter` 的闭集两词(`acceptEdits` = 工作目录内的写不再逐次征询;`default` = 每次写照常征询)。 */
149
+ export declare const PLAN_REVIEW_MODE_AFTER_WORDS: readonly ["default", "acceptEdits"];
150
+ export type PlanReviewModeAfter = (typeof PLAN_REVIEW_MODE_AFTER_WORDS)[number];
151
+ /** 这个值是不是闭集里的词(词表外 —— 含 `plan` / `bypassPermissions` / `auto` —— 引擎 400:一次批准不许顺带关掉征询面)。 */
152
+ export declare function isPlanReviewModeAfter(v: string | undefined): v is PlanReviewModeAfter;
142
153
  /**
143
154
  * {@link HitlSafetyError.code} 的闭集(0.60.0 抽名,**成员一个没动**)。
144
155
  *
@@ -147,7 +158,7 @@ export interface PlanReviewOutcome {
147
158
  * ——抄一份就是给自己立第二个判官(B-025 的病形)。加员仍必须**同批**改齐
148
159
  * `parkResolver.GATE_FAILURE_CODES` 白名单(见下方原注)。
149
160
  */
150
- export type HitlSafetyCode = 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'empty_answer';
161
+ export type HitlSafetyCode = 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'bad_plan_mode' | 'empty_answer';
151
162
  /**
152
163
  * 一次决断链失败**停在哪一段**(0.60.0;异源对抗复审 [medium] 采纳,B-037 候包件 ② 的完整兑现)。
153
164
  *
@@ -35,6 +35,12 @@ export function denyReasonForWire(reason, tag) {
35
35
  `(server caps approval reason at ${MAX_DENY_REASON_CHARS}; over-cap is a 413 reason_too_large that would drop the DECISION, not just the reason)`);
36
36
  return cut;
37
37
  }
38
+ /** 契约 §4c `permissionModeAfter` 的闭集两词(`acceptEdits` = 工作目录内的写不再逐次征询;`default` = 每次写照常征询)。 */
39
+ export const PLAN_REVIEW_MODE_AFTER_WORDS = Object.freeze(['default', 'acceptEdits']);
40
+ /** 这个值是不是闭集里的词(词表外 —— 含 `plan` / `bypassPermissions` / `auto` —— 引擎 400:一次批准不许顺带关掉征询面)。 */
41
+ export function isPlanReviewModeAfter(v) {
42
+ return v !== undefined && PLAN_REVIEW_MODE_AFTER_WORDS.includes(v);
43
+ }
38
44
  /** A safety stop: a contract law was about to be violated (e.g. a binding mismatch, a wrong-gate route).
39
45
  * The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
40
46
  * (contract/04 §9.1 fail-closed law). */
@@ -603,7 +609,21 @@ export class HitlBridge {
603
609
  else if (outcome.editedPlan !== undefined) {
604
610
  throw new HitlSafetyError(`plan-review "${outcome.decision}" must NOT carry editedPlan`, 'bad_plan_edit');
605
611
  }
612
+ // 0.72.13 CC-46:permissionModeAfter invariant(契约 §4c)—— 只配 approve、闭集两词;坏形本地拦,一个字节都不出门
613
+ // (server 会 400 field_conflict / field_invalid,而那次决断就整个丢了)。拒文不回显调用方给的值。
614
+ const modeAfter = outcome.permissionModeAfter;
615
+ if (modeAfter !== undefined) {
616
+ if (outcome.decision !== 'approve') {
617
+ throw new HitlSafetyError(`plan-review "${outcome.decision}" must NOT carry permissionModeAfter (it only applies to an approval)`, 'bad_plan_mode');
618
+ }
619
+ if (!isPlanReviewModeAfter(modeAfter)) {
620
+ throw new HitlSafetyError('plan-review permissionModeAfter must be "default" or "acceptEdits"', 'bad_plan_mode');
621
+ }
622
+ }
623
+ // sdk 9.6.0 的 PlanReviewRequest 尚未声明 permissionModeAfter ⇒ 交集型加一可选位(不断言、不 any);缺席 = 键不落。
606
624
  const req = { decision: outcome.decision };
625
+ if (modeAfter !== undefined && isPlanReviewModeAfter(modeAfter))
626
+ req.permissionModeAfter = modeAfter;
607
627
  // eopt:上面的 invariant 检查已经保证 decision==='edit' ⇒ editedPlan 非空,但那道保证隔着一个
608
628
  // 已经关闭的 if 块,TS 不跨块记账——这里重新直接判一次 undefined,narrowing 与赋值同一表达式内完成
609
629
  // (PlanReviewRequest 是 SDK 外部 wire 型,不能改;诚实缺席 = 键不落,不传 `editedPlan: undefined`)。
@@ -114,6 +114,10 @@ export declare function surfaceRememberNotApplied(): void;
114
114
  * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
115
115
  */
116
116
  export declare function surfaceEditNotForwarded(): void;
117
+ /** 0.72.14:入参不可得的卡上收到了「编辑后批准」——什么都没发,ask 仍挂着(文案测试锁字面)。 */
118
+ export declare const EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT: string;
119
+ /** 见 {@link EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT};无宿主口 ⇒ 静默(与同族通知同语义)。 */
120
+ export declare function surfaceEditRefusedOnBlindAsk(): void;
117
121
  /**
118
122
  * 文案(测试锁字面)。措辞刻意描述**后果**,并且**只说证得出的话**(异源对抗复审四轮 [medium] 修):
119
123
  * · 不写「这台引擎不支持」——能力位缺席有三种同形成因(宿主没接这个字段 / 探测还没回来 / 探测失败),
@@ -163,6 +163,14 @@ export function surfaceRememberNotApplied() {
163
163
  export function surfaceEditNotForwarded() {
164
164
  surfaceSelfClearingWarn(EDIT_NOT_FORWARDED_KEY, EDIT_NOT_FORWARDED_WARN_TEXT, EDIT_NOT_FORWARDED_TIMEOUT_MS);
165
165
  }
166
+ /** 0.72.14:入参不可得的卡上收到了「编辑后批准」——什么都没发,ask 仍挂着(文案测试锁字面)。 */
167
+ export const EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT = 'That approval was NOT sent: this request does not expose its tool arguments here, so an edited approval cannot be applied. ' +
168
+ 'The request is still waiting — approve or deny it as it is.';
169
+ const EDIT_REFUSED_ON_BLIND_ASK_KEY = 'hitl-edit-refused-blind-ask';
170
+ /** 见 {@link EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT};无宿主口 ⇒ 静默(与同族通知同语义)。 */
171
+ export function surfaceEditRefusedOnBlindAsk() {
172
+ surfaceSelfClearingWarn(EDIT_REFUSED_ON_BLIND_ASK_KEY, EDIT_REFUSED_ON_BLIND_ASK_WARN_TEXT, EDIT_NOT_FORWARDED_TIMEOUT_MS);
173
+ }
166
174
  /**
167
175
  * 文案(测试锁字面)。措辞刻意描述**后果**,并且**只说证得出的话**(异源对抗复审四轮 [medium] 修):
168
176
  * · 不写「这台引擎不支持」——能力位缺席有三种同形成因(宿主没接这个字段 / 探测还没回来 / 探测失败),
@@ -0,0 +1,107 @@
1
+ /**
2
+ * livePendingAsk — 「悬挂的流内 ask」的三端共用读面与决断口(0.72.14)。
3
+ *
4
+ * ── 这个模块补的是哪一个缺口 ────────────────────────────────────────────────────────────────
5
+ * 流内 ask 在铸造那一刻若没有活着的宿主流可投,引擎把它转成**悬挂**(窗约一小时,不补发、不重算),此后只在
6
+ * `GET /v1/approvals` 回体的第二顶层键 `livePending` 里可发现。后台 workflow 的子代恒落这一形:宿主 turn 先结束,
7
+ * 子代稍后才撞上审批门 ⇒ 用户面零帧零卡,代理静默卡到窗尽。本模块让端从 {@link startApprovalsFeed} 的快照里
8
+ * 读到这些行、出带子代身份的卡、并经**与流内帧腿同一条链**决断。
9
+ *
10
+ * ── 🔴 四条读法 ────────────────────────────────────────────────────────────────────────────
11
+ * ① **键缺席 ≠ 没有悬挂 ask**:老引擎不发这个键 ⇒ `not_reported`;快照上键缺席,计数读 `null`(不知道),绝不铸 `[]` / `0`。
12
+ * ② **数组名即路由判据**:`livePending` 行的决议口是 `POST /v1/tool-approvals/{approvalId}/respond`(流内审批那个口),
13
+ * **不是** durable 行的 decide 口;两数组不混编。幂等键 = `approvalId`(与流内帧同一个 id)。
14
+ * ③ **可选旗标 only-if-true**:`fromSubagent` / `governanceForced` / `requiresRealApproval` 缺席绝不编码成 `false`。
15
+ * ④ **行上没有工具入参**(引擎刻意不在这个读面开第二道脱敏面)⇒ 卡必须明说「看不到入参」,不许渲一个空命令冒充全貌。
16
+ *
17
+ * sdk 的 barrel 没有导出这一行的类型 ⇒ 按**结构**窄读(与 `capabilities.executionLane` 同一姿势),多余成员不过境。
18
+ */
19
+ import type { ApprovalsFeedSnapshot } from './approvalsFeed.js';
20
+ import { type RespondToolApprovalFn, type ToolApprovalFrame, type ToolApprovalFrameLaneOpts, type ToolApprovalFrameOutcome } from './toolApprovalWire.js';
21
+ /** 一条悬挂的流内 ask(窄读后的形;键集即全部)。 */
22
+ export interface LivePendingAskView {
23
+ /** 与 respond 口收的同一个 id。 */
24
+ approvalId: string;
25
+ toolName: string;
26
+ /** 登记时刻(epoch ms)。 */
27
+ ts: number;
28
+ /** 窗死线(绝对 epoch ms;引擎铸定,本包不重算)。 */
29
+ expiresAtMs: number;
30
+ sessionId?: string;
31
+ requiresRealApproval?: true;
32
+ governanceForced?: true;
33
+ /** 出自委派子代。 */
34
+ fromSubagent?: true;
35
+ /** 子代 taskId(与 `fromSubagent` 同生同缺)。 */
36
+ originTaskId?: string;
37
+ }
38
+ /** `livePending` 段的三态读数。`malformed` = 键在场但读不懂(**不**折成 `not_reported`,也不折成空)。 */
39
+ export type LivePendingReading = {
40
+ kind: 'not_reported';
41
+ } | {
42
+ kind: 'malformed';
43
+ } | {
44
+ kind: 'present';
45
+ rows: LivePendingAskView[];
46
+ dropped: number;
47
+ };
48
+ /** `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。 */
49
+ export declare function readLivePendingRows(body: unknown): LivePendingReading;
50
+ /** 视图过滤:给了 `sessionId` ⇒ 只留该会话的行;行上没有 `sessionId` = 归不了属 ⇒ 不留(不替别的会话出卡)。 */
51
+ export interface SuspendedAskFilter {
52
+ sessionId?: string;
53
+ }
54
+ /** 快照里**出自子代**的悬挂 ask(宿主自己的流内 ask 走流内帧腿,不在这里出卡)。未报 / 没有快照 ⇒ `[]`。 */
55
+ export declare function suspendedSubagentAsks(snapshot: ApprovalsFeedSnapshot | null | undefined, filter?: SuspendedAskFilter): LivePendingAskView[];
56
+ /** 「在等人决定」的计数。`null` = 不知道(没有快照 / 引擎没报这一段),**不是** 0。 */
57
+ export interface ApprovalsAwaitingDecisionCount {
58
+ /** durable 停驻行数。 */
59
+ durable: number | null;
60
+ /** 流内 ask 行数(含宿主自己的与子代的)。 */
61
+ live: number | null;
62
+ /** 其中出自子代的行数。 */
63
+ suspendedSubagent: number | null;
64
+ }
65
+ export declare function countApprovalsAwaitingDecision(snapshot: ApprovalsFeedSnapshot | null | undefined, filter?: SuspendedAskFilter): ApprovalsAwaitingDecisionCount;
66
+ /**
67
+ * 悬挂行 → 流内帧腿的输入。只带行上**真有**的位:窗三键不带(行上只有绝对死线,没有出帧时刻,凑不出可校偏的窗),
68
+ * `args` 不带(行上没有)。`sourceTaskId` = `originTaskId`(卡头子代身份徽章的来源)。
69
+ */
70
+ export declare function livePendingRowToApprovalFrame(row: LivePendingAskView): ToolApprovalFrame;
71
+ /**
72
+ * 出卡并决断一条悬挂 ask —— **与流内帧腿同一条链**({@link surfaceToolApprovalFrameAndRespond}):同一个卡口、
73
+ * 同一个身份键铸口(`approvalId`)、同一套回执 / 拒绝读法。差别只有一处:行上没有入参 ⇒ 卡上明说
74
+ * (`argsUnavailable`),且这张卡上收到「编辑后批准」时**整次不发**(结局 `editRefused`;没有原文可编,丢掉改写再批准 =
75
+ * 批准了人没看到的原始入参)。
76
+ */
77
+ export declare function surfaceSuspendedAskAndRespond(row: LivePendingAskView, respond: RespondToolApprovalFn, signal?: AbortSignal, lane?: ToolApprovalFrameLaneOpts): Promise<ToolApprovalFrameOutcome>;
78
+ /** {@link createSuspendedAskTracker} 每次吞一张快照的产出。 */
79
+ export interface SuspendedAskDelta {
80
+ /** 首次见到、该出卡的行。 */
81
+ appeared: LivePendingAskView[];
82
+ /** 此前 appeared 过、现在不在列表里的 approvalId(已被别处决断 / 过期 / 引擎换代)⇒ 撤卡。 */
83
+ gone: string[];
84
+ }
85
+ export interface SuspendedAskTracker {
86
+ ingest(snapshot: ApprovalsFeedSnapshot | null | undefined): SuspendedAskDelta;
87
+ /**
88
+ * 流内帧腿出卡**之前**调:认领这只 ask。返回 `true` = 帧腿可以出卡(此后快照不再为它出第二张);
89
+ * 返回 `false` = 它已经从快照出过卡 / 已被认领 / 已决断 ⇒ 帧腿**跳过出卡**(两条通道同一只 ask 只有一张卡、一次决断)。
90
+ */
91
+ noteSurfacedByStream(approvalId: string): boolean;
92
+ /** 让一只已出过卡的 ask 在下一张快照里重新 `appeared`(卡被关掉 / 改写被拒 `editRefused` 之后)。 */
93
+ requeue(approvalId: string): void;
94
+ /** 这只 ask 已在本端决断 ⇒ 引擎结算前它还会在列表里待一会儿,不重新出卡;之后消失也不再报 gone。 */
95
+ noteDecided(approvalId: string): void;
96
+ }
97
+ /**
98
+ * 快照 → 出卡 / 撤卡的去重账(每个宿主会话一只;不是模块级单例)。同一 `approvalId` 只 `appeared` 一次;
99
+ * `livePending` 键缺席按「现在一条都看不到」处理已出的卡(引擎换代后旧卡没有决断口可用)。
100
+ *
101
+ * 两本账,**都不按条数淘汰活跃条目**(异源对抗复审:无差别的「满了删最旧」会让仍挂着的卡重出、漏撤):
102
+ * · `shown` = 从快照出过卡、且仍在列表里的 ask。条目随「从列表消失」离账 ⇒ 大小恒 ≤ 列表长度,不设上界。
103
+ * · `claimed` = 被流内帧腿认领 / 已在本端决断的 ask,值 = 是否已在某张快照里见过。见过之后从列表消失 ⇒ 这只 ask
104
+ * 的一生结束,离账(同 id 日后再出现是新的一只)。**从未见过**的认领才受上界管(帧先到、快照一直没列到它的那一小段),
105
+ * 满了只淘汰这一类里最旧的。
106
+ */
107
+ export declare function createSuspendedAskTracker(filter?: SuspendedAskFilter): SuspendedAskTracker;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * livePendingAsk — 「悬挂的流内 ask」的三端共用读面与决断口(0.72.14)。
3
+ *
4
+ * ── 这个模块补的是哪一个缺口 ────────────────────────────────────────────────────────────────
5
+ * 流内 ask 在铸造那一刻若没有活着的宿主流可投,引擎把它转成**悬挂**(窗约一小时,不补发、不重算),此后只在
6
+ * `GET /v1/approvals` 回体的第二顶层键 `livePending` 里可发现。后台 workflow 的子代恒落这一形:宿主 turn 先结束,
7
+ * 子代稍后才撞上审批门 ⇒ 用户面零帧零卡,代理静默卡到窗尽。本模块让端从 {@link startApprovalsFeed} 的快照里
8
+ * 读到这些行、出带子代身份的卡、并经**与流内帧腿同一条链**决断。
9
+ *
10
+ * ── 🔴 四条读法 ────────────────────────────────────────────────────────────────────────────
11
+ * ① **键缺席 ≠ 没有悬挂 ask**:老引擎不发这个键 ⇒ `not_reported`;快照上键缺席,计数读 `null`(不知道),绝不铸 `[]` / `0`。
12
+ * ② **数组名即路由判据**:`livePending` 行的决议口是 `POST /v1/tool-approvals/{approvalId}/respond`(流内审批那个口),
13
+ * **不是** durable 行的 decide 口;两数组不混编。幂等键 = `approvalId`(与流内帧同一个 id)。
14
+ * ③ **可选旗标 only-if-true**:`fromSubagent` / `governanceForced` / `requiresRealApproval` 缺席绝不编码成 `false`。
15
+ * ④ **行上没有工具入参**(引擎刻意不在这个读面开第二道脱敏面)⇒ 卡必须明说「看不到入参」,不许渲一个空命令冒充全貌。
16
+ *
17
+ * sdk 的 barrel 没有导出这一行的类型 ⇒ 按**结构**窄读(与 `capabilities.executionLane` 同一姿势),多余成员不过境。
18
+ */
19
+ import { surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
20
+ function nonEmptyString(v) {
21
+ return typeof v === 'string' && v !== '';
22
+ }
23
+ function nonNegativeFinite(v) {
24
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
25
+ }
26
+ /** `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。 */
27
+ export function readLivePendingRows(body) {
28
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
29
+ return { kind: 'malformed' };
30
+ if (!('livePending' in body))
31
+ return { kind: 'not_reported' };
32
+ const raw = body.livePending;
33
+ if (raw === undefined)
34
+ return { kind: 'not_reported' };
35
+ if (!Array.isArray(raw))
36
+ return { kind: 'malformed' };
37
+ const rows = [];
38
+ const seen = new Set();
39
+ let dropped = 0;
40
+ for (const item of raw) {
41
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
42
+ dropped++;
43
+ continue;
44
+ }
45
+ const o = item;
46
+ if (!nonEmptyString(o.approvalId) || !nonEmptyString(o.toolName) || !nonNegativeFinite(o.ts) || !nonNegativeFinite(o.expiresAtMs)) {
47
+ dropped++;
48
+ continue;
49
+ }
50
+ if (seen.has(o.approvalId)) {
51
+ dropped++;
52
+ continue;
53
+ }
54
+ seen.add(o.approvalId);
55
+ rows.push({
56
+ approvalId: o.approvalId,
57
+ toolName: o.toolName,
58
+ ts: o.ts,
59
+ expiresAtMs: o.expiresAtMs,
60
+ ...(nonEmptyString(o.sessionId) ? { sessionId: o.sessionId } : {}),
61
+ ...(o.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
62
+ ...(o.governanceForced === true ? { governanceForced: true } : {}),
63
+ ...(o.fromSubagent === true ? { fromSubagent: true } : {}),
64
+ ...(nonEmptyString(o.originTaskId) ? { originTaskId: o.originTaskId } : {}),
65
+ });
66
+ }
67
+ return { kind: 'present', rows, dropped };
68
+ }
69
+ /** 快照里**出自子代**的悬挂 ask(宿主自己的流内 ask 走流内帧腿,不在这里出卡)。未报 / 没有快照 ⇒ `[]`。 */
70
+ export function suspendedSubagentAsks(snapshot, filter) {
71
+ const rows = snapshot?.livePending;
72
+ if (!Array.isArray(rows))
73
+ return [];
74
+ return rows.filter(r => r.fromSubagent === true && (filter?.sessionId === undefined || r.sessionId === filter.sessionId));
75
+ }
76
+ export function countApprovalsAwaitingDecision(snapshot, filter) {
77
+ if (snapshot === null || snapshot === undefined)
78
+ return { durable: null, live: null, suspendedSubagent: null };
79
+ const bySession = (rows) => filter?.sessionId === undefined ? [...rows] : rows.filter(r => r.sessionId === filter.sessionId);
80
+ const durable = Array.isArray(snapshot.pending) ? bySession(snapshot.pending).length : null;
81
+ if (!Array.isArray(snapshot.livePending))
82
+ return { durable, live: null, suspendedSubagent: null };
83
+ const live = bySession(snapshot.livePending);
84
+ return { durable, live: live.length, suspendedSubagent: live.filter(r => r.fromSubagent === true).length };
85
+ }
86
+ /**
87
+ * 悬挂行 → 流内帧腿的输入。只带行上**真有**的位:窗三键不带(行上只有绝对死线,没有出帧时刻,凑不出可校偏的窗),
88
+ * `args` 不带(行上没有)。`sourceTaskId` = `originTaskId`(卡头子代身份徽章的来源)。
89
+ */
90
+ export function livePendingRowToApprovalFrame(row) {
91
+ return {
92
+ type: 'tool_approval',
93
+ approvalId: row.approvalId,
94
+ toolName: row.toolName,
95
+ ...(row.fromSubagent === true ? { fromSubagent: true } : {}),
96
+ ...(row.originTaskId !== undefined ? { sourceTaskId: row.originTaskId } : {}),
97
+ ...(row.governanceForced === true ? { governanceForced: true } : {}),
98
+ ...(row.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
99
+ };
100
+ }
101
+ /**
102
+ * 出卡并决断一条悬挂 ask —— **与流内帧腿同一条链**({@link surfaceToolApprovalFrameAndRespond}):同一个卡口、
103
+ * 同一个身份键铸口(`approvalId`)、同一套回执 / 拒绝读法。差别只有一处:行上没有入参 ⇒ 卡上明说
104
+ * (`argsUnavailable`),且这张卡上收到「编辑后批准」时**整次不发**(结局 `editRefused`;没有原文可编,丢掉改写再批准 =
105
+ * 批准了人没看到的原始入参)。
106
+ */
107
+ export function surfaceSuspendedAskAndRespond(row, respond, signal, lane) {
108
+ return surfaceToolApprovalFrameAndRespond(livePendingRowToApprovalFrame(row), respond, undefined, signal, {
109
+ ...lane,
110
+ argsUnavailable: true,
111
+ });
112
+ }
113
+ /** 「认领了但还没在任何一张快照里见过」的条目上界(见 {@link createSuspendedAskTracker} 的账目说明)。 */
114
+ const TRACKER_UNSEEN_CLAIMS_MAX = 512;
115
+ /**
116
+ * 快照 → 出卡 / 撤卡的去重账(每个宿主会话一只;不是模块级单例)。同一 `approvalId` 只 `appeared` 一次;
117
+ * `livePending` 键缺席按「现在一条都看不到」处理已出的卡(引擎换代后旧卡没有决断口可用)。
118
+ *
119
+ * 两本账,**都不按条数淘汰活跃条目**(异源对抗复审:无差别的「满了删最旧」会让仍挂着的卡重出、漏撤):
120
+ * · `shown` = 从快照出过卡、且仍在列表里的 ask。条目随「从列表消失」离账 ⇒ 大小恒 ≤ 列表长度,不设上界。
121
+ * · `claimed` = 被流内帧腿认领 / 已在本端决断的 ask,值 = 是否已在某张快照里见过。见过之后从列表消失 ⇒ 这只 ask
122
+ * 的一生结束,离账(同 id 日后再出现是新的一只)。**从未见过**的认领才受上界管(帧先到、快照一直没列到它的那一小段),
123
+ * 满了只淘汰这一类里最旧的。
124
+ */
125
+ export function createSuspendedAskTracker(filter) {
126
+ const shown = new Set();
127
+ const claimed = new Map();
128
+ const claim = (id) => {
129
+ if (claimed.has(id))
130
+ return;
131
+ claimed.set(id, false);
132
+ let unseen = 0;
133
+ for (const seen of claimed.values())
134
+ if (!seen)
135
+ unseen++;
136
+ if (unseen <= TRACKER_UNSEEN_CLAIMS_MAX)
137
+ return;
138
+ for (const [oldId, seen] of claimed) {
139
+ if (seen)
140
+ continue;
141
+ claimed.delete(oldId);
142
+ break;
143
+ }
144
+ };
145
+ return {
146
+ ingest(snapshot) {
147
+ const rows = suspendedSubagentAsks(snapshot, filter);
148
+ const present = new Set(rows.map(r => r.approvalId));
149
+ const appeared = [];
150
+ for (const r of rows) {
151
+ if (claimed.has(r.approvalId)) {
152
+ claimed.set(r.approvalId, true);
153
+ continue;
154
+ }
155
+ if (shown.has(r.approvalId))
156
+ continue;
157
+ shown.add(r.approvalId);
158
+ appeared.push(r);
159
+ }
160
+ const gone = [];
161
+ for (const id of [...shown]) {
162
+ if (present.has(id))
163
+ continue;
164
+ shown.delete(id);
165
+ gone.push(id);
166
+ }
167
+ // 见过、现在不在列表里的认领 = 这只 ask 已结算 ⇒ 离账
168
+ for (const [id, seen] of [...claimed])
169
+ if (seen && !present.has(id))
170
+ claimed.delete(id);
171
+ return { appeared, gone };
172
+ },
173
+ noteSurfacedByStream(approvalId) {
174
+ if (typeof approvalId !== 'string' || approvalId === '')
175
+ return false;
176
+ if (shown.has(approvalId) || claimed.has(approvalId))
177
+ return false;
178
+ claim(approvalId);
179
+ return true;
180
+ },
181
+ noteDecided(approvalId) {
182
+ if (typeof approvalId !== 'string' || approvalId === '')
183
+ return;
184
+ // 从快照出过卡的 ask 已在列表里见过 ⇒ 认领直接记「见过」,结算后随列表消失离账
185
+ const wasShown = shown.delete(approvalId);
186
+ if (wasShown)
187
+ claimed.set(approvalId, true);
188
+ else
189
+ claim(approvalId);
190
+ },
191
+ requeue(approvalId) {
192
+ if (typeof approvalId !== 'string' || approvalId === '')
193
+ return;
194
+ shown.delete(approvalId);
195
+ claimed.delete(approvalId);
196
+ },
197
+ };
198
+ }
@@ -45,7 +45,7 @@ export declare const MAX_TOTAL_PARKS = 64;
45
45
  * 双向钉住,任何一边加员而另一边不跟 ⇒ 编译红。此前两边靠人肉同步,而不同步的可观测后果是
46
46
  * 「新码被 `isGateFailureCode` 降成 undefined 后静默走文案兜底臂」—— 门里长得跟正常一模一样。
47
47
  */
48
- export declare const GATE_FAILURE_CODES: readonly ["no_pending", "binding_mismatch", "wrong_gate", "bad_plan_edit", "empty_answer"];
48
+ export declare const GATE_FAILURE_CODES: readonly ["no_pending", "binding_mismatch", "wrong_gate", "bad_plan_edit", "bad_plan_mode", "empty_answer"];
49
49
  export type GateFailureCode = (typeof GATE_FAILURE_CODES)[number];
50
50
  export type GateOutcome =
51
51
  /** `receipt`(B-070 / L-200,0.65.0):这次 decide 的 **200 回执读数** —— 与 fs 腿的
@@ -98,6 +98,8 @@ export const GATE_FAILURE_CODES = [
98
98
  'binding_mismatch',
99
99
  'wrong_gate',
100
100
  'bad_plan_edit',
101
+ /** 0.72.13 CC-46:plan-review 的 permissionModeAfter 坏形(非 approve 带这一位 / 词表外)被本地拦下。 */
102
+ 'bad_plan_mode',
101
103
  /** FIX②:空作答被本地拦下(空 answers[] / 空 selected[] / 空 header)。 */
102
104
  'empty_answer',
103
105
  ];