@sema-agent/client-core 0.12.2 → 0.14.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 (131) hide show
  1. package/README.md +15 -2
  2. package/dist/abortableSleep.d.ts +29 -0
  3. package/dist/abortableSleep.js +43 -0
  4. package/dist/adapt/arms.d.ts +67 -0
  5. package/dist/adapt/arms.js +623 -0
  6. package/dist/adapt/ids.d.ts +22 -0
  7. package/dist/adapt/ids.js +34 -0
  8. package/dist/adapt/instanceLedger.d.ts +36 -0
  9. package/dist/adapt/instanceLedger.js +50 -0
  10. package/dist/adapt/panelTasks.d.ts +59 -0
  11. package/dist/adapt/panelTasks.js +193 -0
  12. package/dist/adapt/textStream.d.ts +63 -0
  13. package/dist/adapt/textStream.js +141 -0
  14. package/dist/adapt/toolCards.d.ts +65 -0
  15. package/dist/adapt/toolCards.js +100 -0
  16. package/dist/adapt/turnFlags.d.ts +37 -0
  17. package/dist/adapt/turnFlags.js +55 -0
  18. package/dist/adapt/wireShapes.d.ts +96 -0
  19. package/dist/adapt/wireShapes.js +167 -0
  20. package/dist/adapt.d.ts +33 -60
  21. package/dist/adapt.js +84 -1207
  22. package/dist/adapter/downstream/eventToSdkMessage.d.ts +57 -13
  23. package/dist/adapter/downstream/eventToSdkMessage.js +174 -106
  24. package/dist/adapter/downstream/terminalToSdkResult.js +151 -160
  25. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
  26. package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
  27. package/dist/adapter/runStream.d.ts +36 -4
  28. package/dist/adapter/runStream.js +195 -13
  29. package/dist/adapter/types.d.ts +28 -2
  30. package/dist/agentSession/backgroundView.js +4 -15
  31. package/dist/agentsWireCaps.d.ts +10 -6
  32. package/dist/agentsWireCaps.js +21 -7
  33. package/dist/argvFlagValue.d.ts +41 -0
  34. package/dist/argvFlagValue.js +69 -0
  35. package/dist/attachmentsWireCaps.d.ts +3 -2
  36. package/dist/attachmentsWireCaps.js +5 -3
  37. package/dist/classifierVerdictWire.d.ts +8 -0
  38. package/dist/classifierVerdictWire.js +8 -0
  39. package/dist/cloudConfigWireCaps.d.ts +28 -1
  40. package/dist/cloudConfigWireCaps.js +51 -11
  41. package/dist/controlRouter.d.ts +14 -8
  42. package/dist/controlRouter.js +15 -21
  43. package/dist/detachWire.d.ts +15 -5
  44. package/dist/detachWire.js +17 -7
  45. package/dist/effortWire.d.ts +0 -21
  46. package/dist/effortWire.js +6 -20
  47. package/dist/engineInlineTaskStats.d.ts +12 -6
  48. package/dist/engineWireSdk.d.ts +12 -0
  49. package/dist/env/localeGeo.js +2 -1
  50. package/dist/envFlag.d.ts +39 -0
  51. package/dist/envFlag.js +51 -0
  52. package/dist/finalVerifyWire.d.ts +7 -5
  53. package/dist/finalVerifyWire.js +6 -5
  54. package/dist/fleet/fleetLedger.d.ts +32 -9
  55. package/dist/fleet/fleetLedger.js +119 -34
  56. package/dist/fleet/fleetProjection.d.ts +44 -6
  57. package/dist/fleet/fleetProjection.js +52 -10
  58. package/dist/fleetAgentPanelProjection.d.ts +18 -1
  59. package/dist/fleetAgentPanelProjection.js +61 -14
  60. package/dist/forkWireCaps.d.ts +2 -1
  61. package/dist/forkWireCaps.js +5 -11
  62. package/dist/goalStopHook.d.ts +142 -0
  63. package/dist/goalStopHook.js +258 -0
  64. package/dist/headlessPermissionModeWire.d.ts +10 -0
  65. package/dist/headlessPermissionModeWire.js +24 -21
  66. package/dist/headlessReconnectWire.d.ts +7 -1
  67. package/dist/headlessReconnectWire.js +20 -2
  68. package/dist/hitl/approvalsFeed.d.ts +22 -1
  69. package/dist/hitl/approvalsFeed.js +103 -9
  70. package/dist/hitl/askGateWire.d.ts +27 -96
  71. package/dist/hitl/askGateWire.js +69 -618
  72. package/dist/hitl/frameRouter.d.ts +86 -0
  73. package/dist/hitl/frameRouter.js +383 -0
  74. package/dist/hitl/gateLedger.d.ts +119 -0
  75. package/dist/hitl/gateLedger.js +113 -0
  76. package/dist/hitl/hitlBridge.d.ts +75 -15
  77. package/dist/hitl/hitlBridge.js +94 -22
  78. package/dist/hitl/hitlHostSurface.d.ts +49 -0
  79. package/dist/hitl/hitlHostSurface.js +155 -0
  80. package/dist/hitl/parkResolver.d.ts +74 -0
  81. package/dist/hitl/parkResolver.js +250 -0
  82. package/dist/hitl/planReviewWire.d.ts +60 -2
  83. package/dist/hitl/planReviewWire.js +197 -91
  84. package/dist/hitl/toolApprovalWire.d.ts +67 -4
  85. package/dist/hitl/toolApprovalWire.js +129 -31
  86. package/dist/hooksWireCaps.d.ts +1 -82
  87. package/dist/hooksWireCaps.js +44 -237
  88. package/dist/host.d.ts +16 -5
  89. package/dist/index.d.ts +2 -0
  90. package/dist/index.js +15 -1
  91. package/dist/interactiveToolsWire.d.ts +15 -4
  92. package/dist/interactiveToolsWire.js +24 -26
  93. package/dist/limitsWire.js +7 -40
  94. package/dist/liveInitToolFace.d.ts +51 -6
  95. package/dist/liveQuestionStore.d.ts +18 -6
  96. package/dist/liveQuestionStore.js +15 -0
  97. package/dist/model/providerPresets.js +11 -1
  98. package/dist/notifications.d.ts +62 -2
  99. package/dist/notifications.js +306 -49
  100. package/dist/printToolResultFrame.d.ts +12 -4
  101. package/dist/printToolResultFrame.js +11 -21
  102. package/dist/retainBackgroundWireCaps.d.ts +3 -2
  103. package/dist/retainBackgroundWireCaps.js +5 -9
  104. package/dist/sandboxWire.d.ts +9 -31
  105. package/dist/sandboxWire.js +51 -50
  106. package/dist/scenarioWire.d.ts +1 -1
  107. package/dist/scenarioWire.js +25 -36
  108. package/dist/seam.d.ts +23 -6
  109. package/dist/seam.js +40 -30
  110. package/dist/seatContract.d.ts +369 -83
  111. package/dist/seatContract.js +585 -198
  112. package/dist/selfOrchestrationWireCaps.d.ts +6 -5
  113. package/dist/selfOrchestrationWireCaps.js +8 -12
  114. package/dist/sessionSlot.d.ts +8 -0
  115. package/dist/sessionSlot.js +1 -0
  116. package/dist/steering.js +2 -2
  117. package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
  118. package/dist/subagent/engineTaskHandleWire.js +17 -2
  119. package/dist/subagentContentStore.d.ts +5 -5
  120. package/dist/toolResult.d.ts +89 -8
  121. package/dist/toolResult.js +99 -30
  122. package/dist/typePins.d.ts +17 -0
  123. package/dist/typePins.js +1 -0
  124. package/dist/ultracodeWireCaps.js +5 -6
  125. package/dist/unrefTimer.d.ts +30 -0
  126. package/dist/unrefTimer.js +5 -0
  127. package/dist/workflow.d.ts +3 -2
  128. package/dist/workflow.js +3 -2
  129. package/dist/workflowClient.d.ts +7 -0
  130. package/dist/workflowClient.js +47 -12
  131. package/package.json +3 -3
@@ -0,0 +1,113 @@
1
+ /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
2
+ export function createGateLedger() {
3
+ const startedCalls = new Set();
4
+ const endedCalls = new Set();
5
+ const heldAskEnds = new Map();
6
+ const gatedStartArgsByCall = new Map();
7
+ const deniedCalls = new Set();
8
+ const pendingFsCalls = [];
9
+ const resolvedAnswers = new Map();
10
+ const decidedGates = new Set();
11
+ let lastFsOrShellGatedCall;
12
+ let denyStampNextFsEnd = false;
13
+ let seq;
14
+ return {
15
+ noteSeq(ev) {
16
+ const rawSeq = ev.id ?? ev.seq;
17
+ if (typeof rawSeq === 'string' && rawSeq.length > 0)
18
+ seq = rawSeq;
19
+ else if (typeof rawSeq === 'number' && Number.isFinite(rawSeq))
20
+ seq = String(rawSeq);
21
+ },
22
+ lastSeq() {
23
+ return seq;
24
+ },
25
+ markStarted(callId) {
26
+ if (startedCalls.has(callId))
27
+ return false;
28
+ startedCalls.add(callId);
29
+ return true;
30
+ },
31
+ noteGatedStart(callId, ev) {
32
+ gatedStartArgsByCall.set(callId, ev.args);
33
+ },
34
+ gatedStartArgs() {
35
+ return gatedStartArgsByCall;
36
+ },
37
+ isEnded(callId) {
38
+ return endedCalls.has(callId);
39
+ },
40
+ markEnded(callId) {
41
+ endedCalls.add(callId);
42
+ },
43
+ hold(callId, ev) {
44
+ heldAskEnds.set(callId, ev);
45
+ },
46
+ dropHeld(callId) {
47
+ heldAskEnds.delete(callId);
48
+ },
49
+ heldCount() {
50
+ return heldAskEnds.size;
51
+ },
52
+ *flushHeld() {
53
+ for (const [callId, held] of heldAskEnds) {
54
+ endedCalls.add(callId);
55
+ yield held;
56
+ }
57
+ heldAskEnds.clear();
58
+ },
59
+ markDenied(callId) {
60
+ deniedCalls.add(callId);
61
+ },
62
+ isDenied(callId) {
63
+ return deniedCalls.has(callId);
64
+ },
65
+ takeDenied(callId) {
66
+ return deniedCalls.delete(callId);
67
+ },
68
+ armDenyStamp() {
69
+ denyStampNextFsEnd = true;
70
+ },
71
+ takeDenyStamp() {
72
+ if (!denyStampNextFsEnd)
73
+ return false;
74
+ denyStampNextFsEnd = false;
75
+ return true;
76
+ },
77
+ pushFsCall(callId) {
78
+ pendingFsCalls.push(callId);
79
+ },
80
+ dropFsCall(callId) {
81
+ const at = pendingFsCalls.indexOf(callId);
82
+ if (at !== -1)
83
+ pendingFsCalls.splice(at, 1);
84
+ },
85
+ lastPendingFsCall() {
86
+ return pendingFsCalls.length > 0 ? pendingFsCalls[pendingFsCalls.length - 1] : undefined;
87
+ },
88
+ noteFsOrShellGate(callId) {
89
+ lastFsOrShellGatedCall = callId;
90
+ },
91
+ lastFsOrShellGatedCallId() {
92
+ return lastFsOrShellGatedCall;
93
+ },
94
+ rememberAnswer(callId, answered) {
95
+ resolvedAnswers.set(callId, answered);
96
+ },
97
+ answerFor(callId) {
98
+ return resolvedAnswers.get(callId);
99
+ },
100
+ dropAnswer(callId) {
101
+ resolvedAnswers.delete(callId);
102
+ },
103
+ markDecided(callId) {
104
+ decidedGates.add(callId);
105
+ },
106
+ takeDecided(callId) {
107
+ return decidedGates.delete(callId);
108
+ },
109
+ decidedCount() {
110
+ return decidedGates.size;
111
+ },
112
+ };
113
+ }
@@ -129,18 +129,55 @@ export interface PlanReviewOutcome {
129
129
  * The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
130
130
  * (contract/04 §9.1 fail-closed law). */
131
131
  export declare class HitlSafetyError extends Error {
132
- /** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
133
- readonly code: string;
132
+ /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string
133
+ * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
134
+ readonly code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit';
134
135
  constructor(message: string,
135
- /** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
136
- code: string);
136
+ /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string
137
+ * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
138
+ code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit');
137
139
  }
138
140
  /** The active gate the downstream stream most recently suspended on (null when running). */
139
141
  export interface ActiveGate {
140
142
  gate: CheckpointGate;
141
143
  /** The durable seq of the `suspended` event, for rewind/dedup correlation (types.ts:48 eventSeq). */
142
- seq?: string;
144
+ seq?: string | undefined;
143
145
  }
146
+ /** Outcome of {@link findPendingForTask}. */
147
+ export type FindPendingOutcome = {
148
+ ok: true;
149
+ pending: PendingCheckpoint;
150
+ gatedCallId: string | undefined;
151
+ }
152
+ /** `code`:结构化判别位(REF-CC-033)。'no_pending'=良性缺席(park 重放常见);缺席=真失败(list 抛)。 */
153
+ | {
154
+ ok: false;
155
+ reason: string;
156
+ code?: 'no_pending';
157
+ };
158
+ /**
159
+ * The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
160
+ * decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
161
+ * BEFORE they know which specific call/question they're presenting — that is the FIRST fetch, the one
162
+ * that decides what gets rendered on the card/overlay.
163
+ *
164
+ * REF-CC-029(2026-08-02,消 hitl2-08/hitl2-01 双病根):this used to be three near-identical copies
165
+ * (this file's own `HitlBridge.findPending` plus one each in `toolApprovalWire.ts` / `askGateWire.ts`),
166
+ * and only `HitlBridge.findPending`'s copy had a `?? pending[0]` last-resort fallback (REF-CC-022) that
167
+ * could resolve a DIFFERENT session's or a background subagent lane's pending row once this taskId's own
168
+ * rows were gone — they all share the same `/v1/approvals` queue. Single-sourcing this lookup here means
169
+ * that divergence can't recur, and passing the resolved row straight into `decideTool`/`answerQuestion`'s
170
+ * `preResolvedPending` param closes the TOCTOU window between "the row the human saw" (this fetch) and
171
+ * "the row decide resolves" (previously a SECOND independent fetch inside the bridge).
172
+ *
173
+ * Match by `taskId` + a caller-supplied predicate over the row's `toolName` (the tool family this wire
174
+ * cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
175
+ * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
176
+ * — a typed failure when nothing for this taskId is queued.
177
+ */
178
+ export declare function findPendingForTask(client: HitlClientLike, taskId: string, matches: (toolName: string | undefined) => boolean, opts?: {
179
+ signal?: AbortSignal;
180
+ }): Promise<FindPendingOutcome>;
144
181
  /**
145
182
  * The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
146
183
  * outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
@@ -161,13 +198,22 @@ export declare class HitlBridge {
161
198
  * Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
162
199
  * `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
163
200
  * the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
201
+ *
202
+ * Used only as the fallback when a caller does NOT already have the pending row in hand (see
203
+ * `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
204
+ * overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
205
+ * pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
206
+ * decide resolves" (REF-CC-029/hitl2-08).
164
207
  */
165
208
  private findPending;
166
209
  /**
167
210
  * Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
168
- * (contract/04 §2.2). Returns `{}` against a pre-D-1 worker (no binding fields) so the server can fall
169
- * back to resolve-by-sessionId. `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
211
+ * (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
170
212
  * compliant clients send ONLY the two-tuple, approvals.ts:23-27).
213
+ *
214
+ * pre-D-1 的 `{}` 回落臂已删(2026-08-03,#117 提货 / [2356]#2):server 5.x 的 decide 三键必填、
215
+ * 缺键 400,「发一个注定 400 的请求」不如在本地响亮失败——pending 行缺绑定键只可能来自
216
+ * 不再支持的旧 worker 或坏数据,fail-loud 带坐标好过静默烧一轮 HTTP。
171
217
  */
172
218
  private bindingOf;
173
219
  /**
@@ -185,13 +231,20 @@ export declare class HitlBridge {
185
231
  decideTool(outcome: {
186
232
  decision: 'approve';
187
233
  updatedInput?: unknown;
188
- remember?: 'session';
234
+ remember?: 'session' | undefined;
189
235
  } | {
190
236
  decision: 'deny';
191
- reason?: string;
237
+ reason?: string | undefined;
192
238
  }, toolUseID?: string, opts?: {
193
239
  signal?: AbortSignal;
194
- }): Promise<unknown>;
240
+ },
241
+ /** REF-CC-029(2026-08-02):the pending row the caller ALREADY fetched (via `findPendingForTask`,
242
+ * below) to render the card/overlay the human just decided on. When supplied, this method skips
243
+ * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
244
+ * row decide resolves" (two independent fetches could otherwise land on different rows if the
245
+ * first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
246
+ * such row in hand (falls back to `findPending`). */
247
+ preResolvedPending?: PendingCheckpoint): Promise<unknown>;
195
248
  /**
196
249
  * Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
197
250
  * permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
@@ -200,12 +253,16 @@ export declare class HitlBridge {
200
253
  */
201
254
  answerQuestion(answers: AskAnswer[], toolUseID?: string, opts?: {
202
255
  signal?: AbortSignal;
203
- }): Promise<unknown>;
256
+ },
257
+ /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
258
+ * fetched via `findPendingForTask`. */
259
+ preResolvedPending?: PendingCheckpoint): Promise<unknown>;
204
260
  /**
205
261
  * Resolve a plan-mode gate (contract/04 §3; 08 IH-6). Routed to `assistant.planReview`, NOT `decide`/
206
- * `resume`. Guards the gate kind locally (a non-`plan_review` gate routed here would 409
207
- * `gate_not_plan_review`) and the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
208
- * otherwise → 400) so the bad call never leaves the shell.
262
+ * `resume`. Also guards the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
263
+ * otherwise → 400) so the bad call never leaves the shell — that check is unconditional (no
264
+ * `observe()` dependency). The gate-kind guard below is host-optional (see its own comment); the
265
+ * server's 409 `gate_not_plan_review` is the real backstop either way.
209
266
  */
210
267
  reviewPlan(outcome: PlanReviewOutcome, opts?: {
211
268
  signal?: AbortSignal;
@@ -213,7 +270,10 @@ export declare class HitlBridge {
213
270
  /**
214
271
  * Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
215
272
  * `decide`. A `human`/`irreversible_ask` gate routed here would 409 `gate_not_resumable` (a "continue"
216
- * must NEVER bypass a human approval); the bridge guards locally first.
273
+ * must NEVER bypass a human approval). REF-CC-028(2026-08-02,评注现势):the local guard below is
274
+ * host-optional — it only fires when the host drives `observe(ev)` (neither production call site does
275
+ * today, see `reviewPlan`'s comment); the server's 409 is the fail-closed backstop that actually holds
276
+ * this invariant.
217
277
  */
218
278
  resumeResourceLimit(opts?: {
219
279
  signal?: AbortSignal;
@@ -6,13 +6,48 @@ import { eventSeq } from '../adapter/types.js';
6
6
  export class HitlSafetyError extends Error {
7
7
  code;
8
8
  constructor(message,
9
- /** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
9
+ /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string
10
+ * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
10
11
  code) {
11
12
  super(message);
12
13
  this.code = code;
13
14
  this.name = 'HitlSafetyError';
14
15
  }
15
16
  }
17
+ /**
18
+ * The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
19
+ * decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
20
+ * BEFORE they know which specific call/question they're presenting — that is the FIRST fetch, the one
21
+ * that decides what gets rendered on the card/overlay.
22
+ *
23
+ * REF-CC-029(2026-08-02,消 hitl2-08/hitl2-01 双病根):this used to be three near-identical copies
24
+ * (this file's own `HitlBridge.findPending` plus one each in `toolApprovalWire.ts` / `askGateWire.ts`),
25
+ * and only `HitlBridge.findPending`'s copy had a `?? pending[0]` last-resort fallback (REF-CC-022) that
26
+ * could resolve a DIFFERENT session's or a background subagent lane's pending row once this taskId's own
27
+ * rows were gone — they all share the same `/v1/approvals` queue. Single-sourcing this lookup here means
28
+ * that divergence can't recur, and passing the resolved row straight into `decideTool`/`answerQuestion`'s
29
+ * `preResolvedPending` param closes the TOCTOU window between "the row the human saw" (this fetch) and
30
+ * "the row decide resolves" (previously a SECOND independent fetch inside the bridge).
31
+ *
32
+ * Match by `taskId` + a caller-supplied predicate over the row's `toolName` (the tool family this wire
33
+ * cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
34
+ * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
35
+ * — a typed failure when nothing for this taskId is queued.
36
+ */
37
+ export async function findPendingForTask(client, taskId, matches, opts) {
38
+ let rows;
39
+ try {
40
+ rows = (await client.approvals.list(opts)).pending;
41
+ }
42
+ catch (e) {
43
+ return { ok: false, reason: `approvals.list failed: ${String(e)}` };
44
+ }
45
+ const pending = rows.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
46
+ rows.find((r) => r.taskId === taskId);
47
+ if (!pending)
48
+ return { ok: false, reason: 'no pending checkpoint for this run (resolved/expired?)', code: 'no_pending' };
49
+ return { ok: true, pending, gatedCallId: pending.toolCallId ?? pending.boundCallId ?? undefined };
50
+ }
16
51
  /**
17
52
  * The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
18
53
  * outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
@@ -59,6 +94,12 @@ export class HitlBridge {
59
94
  * Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
60
95
  * `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
61
96
  * the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
97
+ *
98
+ * Used only as the fallback when a caller does NOT already have the pending row in hand (see
99
+ * `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
100
+ * overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
101
+ * pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
102
+ * decide resolves" (REF-CC-029/hitl2-08).
62
103
  */
63
104
  async findPending(toolUseID, opts) {
64
105
  const { pending } = await this.client.approvals.list(opts);
@@ -70,22 +111,29 @@ export class HitlBridge {
70
111
  return byCall;
71
112
  }
72
113
  // Fall back to this run's pending row (the suspended run holding the session claim).
73
- const byTask = pending.find((p) => p.taskId === this.taskId);
74
- return byTask ?? pending[0] ?? null;
114
+ // REF-CC-022(2026-08-02): NEVER `?? pending[0]` once this taskId's own rows are exhausted, the
115
+ // first queue row can belong to a DIFFERENT session or a background subagent lane (they share the
116
+ // same `/v1/approvals` queue); deciding on it would resolve someone else's gate. A miss here is a
117
+ // real `no_pending` — the caller's existing typed-fail path (and exactly the signal #110's
118
+ // cancel-by-deny-recovery arm expects), not "decide on whatever happens to be first".
119
+ return pending.find((p) => p.taskId === this.taskId) ?? null;
75
120
  }
76
121
  /**
77
122
  * Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
78
- * (contract/04 §2.2). Returns `{}` against a pre-D-1 worker (no binding fields) so the server can fall
79
- * back to resolve-by-sessionId. `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
123
+ * (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
80
124
  * compliant clients send ONLY the two-tuple, approvals.ts:23-27).
125
+ *
126
+ * pre-D-1 的 `{}` 回落臂已删(2026-08-03,#117 提货 / [2356]#2):server 5.x 的 decide 三键必填、
127
+ * 缺键 400,「发一个注定 400 的请求」不如在本地响亮失败——pending 行缺绑定键只可能来自
128
+ * 不再支持的旧 worker 或坏数据,fail-loud 带坐标好过静默烧一轮 HTTP。
81
129
  */
82
130
  bindingOf(p) {
83
- const binding = {};
84
- if (p.boundCallId !== undefined)
85
- binding.boundCallId = p.boundCallId;
86
- if (p.boundInputHash !== undefined)
87
- binding.boundInputHash = p.boundInputHash;
88
- return binding;
131
+ if (p.boundCallId === undefined || p.boundInputHash === undefined) {
132
+ throw new HitlSafetyError(`pending checkpoint ${p.id ?? '<no-id>'} is missing D-1 binding fields ` +
133
+ `(boundCallId=${String(p.boundCallId)}, boundInputHash=${String(p.boundInputHash)}) — ` +
134
+ 'pre-D-1 workers are no longer supported (server 5.x requires the verbatim two-tuple echo)', 'binding_mismatch');
135
+ }
136
+ return { boundCallId: p.boundCallId, boundInputHash: p.boundInputHash };
89
137
  }
90
138
  // ── §2. Permission gate: approve / deny a tool ──────────────────────────────
91
139
  /**
@@ -100,8 +148,15 @@ export class HitlBridge {
100
148
  * The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
101
149
  * `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
102
150
  */
103
- async decideTool(outcome, toolUseID, opts) {
104
- const pending = await this.findPending(toolUseID, opts);
151
+ async decideTool(outcome, toolUseID, opts,
152
+ /** REF-CC-029(2026-08-02):the pending row the caller ALREADY fetched (via `findPendingForTask`,
153
+ * below) to render the card/overlay the human just decided on. When supplied, this method skips
154
+ * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
155
+ * row decide resolves" (two independent fetches could otherwise land on different rows if the
156
+ * first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
157
+ * such row in hand (falls back to `findPending`). */
158
+ preResolvedPending) {
159
+ const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
105
160
  if (!pending) {
106
161
  throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
107
162
  }
@@ -131,8 +186,11 @@ export class HitlBridge {
131
186
  * `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
132
187
  * checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
133
188
  */
134
- async answerQuestion(answers, toolUseID, opts) {
135
- const pending = await this.findPending(toolUseID, opts);
189
+ async answerQuestion(answers, toolUseID, opts,
190
+ /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
191
+ * fetched via `findPendingForTask`. */
192
+ preResolvedPending) {
193
+ const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
136
194
  if (!pending) {
137
195
  throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
138
196
  }
@@ -146,13 +204,20 @@ export class HitlBridge {
146
204
  // ── §4. plan_review: the distinct wire ──────────────────────────────────────
147
205
  /**
148
206
  * Resolve a plan-mode gate (contract/04 §3; 08 IH-6). Routed to `assistant.planReview`, NOT `decide`/
149
- * `resume`. Guards the gate kind locally (a non-`plan_review` gate routed here would 409
150
- * `gate_not_plan_review`) and the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
151
- * otherwise → 400) so the bad call never leaves the shell.
207
+ * `resume`. Also guards the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
208
+ * otherwise → 400) so the bad call never leaves the shell — that check is unconditional (no
209
+ * `observe()` dependency). The gate-kind guard below is host-optional (see its own comment); the
210
+ * server's 409 `gate_not_plan_review` is the real backstop either way.
152
211
  */
153
212
  async reviewPlan(outcome, opts) {
154
213
  // Gate-kind guard (contract/04 §3.1): plan_review parks as needs_review / plan_review, distinct from the
155
- // approval family. If the active gate is a tool/resource gate, refuse locally rather than 409 the server.
214
+ // approval family. REF-CC-028(2026-08-02,评注现势):this.active only latches when the host calls
215
+ // `observe(ev)` on the downstream stream — BOTH production construction points
216
+ // (askGateWire.ts / toolApprovalWire.ts) build a fresh `HitlBridge` per decide and never call
217
+ // `observe`, so `this.active` is null and this guard is a no-op there today. It is a host-optional
218
+ // auxiliary: a host that DOES drive `observe()` gets a local refuse instead of a round-trip; a host
219
+ // that doesn't (today's shell) relies entirely on the server's 409 `gate_not_plan_review` fail-closed
220
+ // path — the safety property holds either way, this guard just saves the round-trip when wired.
156
221
  const kind = this.active?.gate.kind;
157
222
  if (kind && kind !== 'plan_review' && kind !== 'needs_review') {
158
223
  throw new HitlSafetyError(`cannot plan-review a "${kind}" gate — a plan decision must never resolve a tool approval`, 'wrong_gate');
@@ -167,7 +232,10 @@ export class HitlBridge {
167
232
  throw new HitlSafetyError(`plan-review "${outcome.decision}" must NOT carry editedPlan`, 'bad_plan_edit');
168
233
  }
169
234
  const req = { decision: outcome.decision };
170
- if (outcome.decision === 'edit')
235
+ // eopt:上面的 invariant 检查已经保证 decision==='edit' ⇒ editedPlan 非空,但那道保证隔着一个
236
+ // 已经关闭的 if 块,TS 不跨块记账——这里重新直接判一次 undefined,narrowing 与赋值同一表达式内完成
237
+ // (PlanReviewRequest 是 SDK 外部 wire 型,不能改;诚实缺席 = 键不落,不传 `editedPlan: undefined`)。
238
+ if (outcome.decision === 'edit' && outcome.editedPlan !== undefined)
171
239
  req.editedPlan = outcome.editedPlan;
172
240
  if (outcome.reason !== undefined)
173
241
  req.reason = outcome.reason;
@@ -177,7 +245,10 @@ export class HitlBridge {
177
245
  /**
178
246
  * Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
179
247
  * `decide`. A `human`/`irreversible_ask` gate routed here would 409 `gate_not_resumable` (a "continue"
180
- * must NEVER bypass a human approval); the bridge guards locally first.
248
+ * must NEVER bypass a human approval). REF-CC-028(2026-08-02,评注现势):the local guard below is
249
+ * host-optional — it only fires when the host drives `observe(ev)` (neither production call site does
250
+ * today, see `reviewPlan`'s comment); the server's 409 is the fail-closed backstop that actually holds
251
+ * this invariant.
181
252
  */
182
253
  async resumeResourceLimit(opts) {
183
254
  const kind = this.active?.gate.kind;
@@ -233,7 +304,8 @@ export function makeHitlCanUseTool(bridge, prompt) {
233
304
  };
234
305
  }
235
306
  // ── helpers ───────────────────────────────────────────────────────────────────
236
- /** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`. */
307
+ /** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`.
308
+ * REF-CC-038(2026-08-02):return 类型是 `AskAnswer` 本身(唯一源),不再是一份重写的内联匿名形。 */
237
309
  function marshalAnswer(a) {
238
310
  const entry = {
239
311
  header: a.header,
@@ -0,0 +1,49 @@
1
+ import type { ClassifierDenyVerdict } from '../classifierVerdictWire.js';
2
+ /** footer 通知(壳 `AppState.notifications.current` 的形)。 */
3
+ export interface HitlNotice {
4
+ key: string;
5
+ text: string;
6
+ color: 'warning';
7
+ priority: 'immediate';
8
+ timeoutMs: number;
9
+ }
10
+ /** HITL 的宿主副作用面 —— 通知上屏 + 分类器 deny 的端侧记账。 */
11
+ export interface HitlHostSurface {
12
+ /** 立即顶到 current(壳:`notifications.current = notice`,queue 不动)。 */
13
+ showNotice(notice: HitlNotice): void;
14
+ /** **仅当** current 仍是这个 key 时清掉(壳原文的 if-still-mine 语义 —— 否则会误清别人的通知)。 */
15
+ clearNoticeIfCurrent(key: string): void;
16
+ /** 分类器 deny 裁决的宿主副作用:footer 通知 + /permissions Recent Denials 记账(壳资产)。 */
17
+ surfaceClassifierDeny(toolName: string, verdict: ClassifierDenyVerdict): void;
18
+ }
19
+ /** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
20
+ export declare function installHitlHostSurface(surface: HitlHostSurface | null): () => void;
21
+ /** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
22
+ export declare function installHitlHostSurfaceFor(sessionKey: string, surface: HitlHostSurface | null): () => void;
23
+ /** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
24
+ export declare function hitlHostSurfaceMisses(): number;
25
+ /** W1 带 key 变体(miss 计数逐键)。 */
26
+ export declare function hitlHostSurfaceMissesFor(sessionKey: string): number;
27
+ /** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
28
+ export declare function hitlHostSurfaceFor(sessionKey: string): HitlHostSurface | null;
29
+ /** 测试钩:卸口 + 清计数。 */
30
+ export declare function _resetHitlHostSurfaceForTest(): void;
31
+ /** 内部读点(计 miss)。`askGateWire.ts` 的 `surfaceClassifierDeny` 路径与本文件的
32
+ * `surfaceCancelDenyWarn` 共用它。 */
33
+ export declare function surfaceForCurrentSession(): HitlHostSurface | null;
34
+ /** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
35
+ * 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
36
+ * 只是多一行良性 warn,比锁死无线索诚实)。 */
37
+ export declare const CANCEL_DENY_BUDGET_MS = 2000;
38
+ /** warn 行文案(测试锁字面)。 */
39
+ export declare const CANCEL_DENY_WARN_TEXT = "could not cancel the pending question \u2014 the session may stay locked; the run may need engine-side recovery";
40
+ /**
41
+ * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
42
+ * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
43
+ * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
44
+ * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
45
+ * 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
46
+ * - `code === 'no_pending'`(判据见上方 `isNoPendingError`)⇒ 良性静默(pending 已被别处消解/
47
+ * 过期 —— run 没锁;hitlBridge decideTool 的同款语义,那里的静默维持不动)。
48
+ */
49
+ export declare function observeCancelByDeny(settle: Promise<unknown>, taskId: string): void;