@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.
- package/README.md +15 -2
- package/dist/abortableSleep.d.ts +29 -0
- package/dist/abortableSleep.js +43 -0
- package/dist/adapt/arms.d.ts +67 -0
- package/dist/adapt/arms.js +623 -0
- package/dist/adapt/ids.d.ts +22 -0
- package/dist/adapt/ids.js +34 -0
- package/dist/adapt/instanceLedger.d.ts +36 -0
- package/dist/adapt/instanceLedger.js +50 -0
- package/dist/adapt/panelTasks.d.ts +59 -0
- package/dist/adapt/panelTasks.js +193 -0
- package/dist/adapt/textStream.d.ts +63 -0
- package/dist/adapt/textStream.js +141 -0
- package/dist/adapt/toolCards.d.ts +65 -0
- package/dist/adapt/toolCards.js +100 -0
- package/dist/adapt/turnFlags.d.ts +37 -0
- package/dist/adapt/turnFlags.js +55 -0
- package/dist/adapt/wireShapes.d.ts +96 -0
- package/dist/adapt/wireShapes.js +167 -0
- package/dist/adapt.d.ts +33 -60
- package/dist/adapt.js +84 -1207
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +57 -13
- package/dist/adapter/downstream/eventToSdkMessage.js +174 -106
- package/dist/adapter/downstream/terminalToSdkResult.js +151 -160
- package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
- package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
- package/dist/adapter/runStream.d.ts +36 -4
- package/dist/adapter/runStream.js +195 -13
- package/dist/adapter/types.d.ts +28 -2
- package/dist/agentSession/backgroundView.js +4 -15
- package/dist/agentsWireCaps.d.ts +10 -6
- package/dist/agentsWireCaps.js +21 -7
- package/dist/argvFlagValue.d.ts +41 -0
- package/dist/argvFlagValue.js +69 -0
- package/dist/attachmentsWireCaps.d.ts +3 -2
- package/dist/attachmentsWireCaps.js +5 -3
- package/dist/classifierVerdictWire.d.ts +8 -0
- package/dist/classifierVerdictWire.js +8 -0
- package/dist/cloudConfigWireCaps.d.ts +28 -1
- package/dist/cloudConfigWireCaps.js +51 -11
- package/dist/controlRouter.d.ts +14 -8
- package/dist/controlRouter.js +15 -21
- package/dist/detachWire.d.ts +15 -5
- package/dist/detachWire.js +17 -7
- package/dist/effortWire.d.ts +0 -21
- package/dist/effortWire.js +6 -20
- package/dist/engineInlineTaskStats.d.ts +12 -6
- package/dist/engineWireSdk.d.ts +12 -0
- package/dist/env/localeGeo.js +2 -1
- package/dist/envFlag.d.ts +39 -0
- package/dist/envFlag.js +51 -0
- package/dist/finalVerifyWire.d.ts +7 -5
- package/dist/finalVerifyWire.js +6 -5
- package/dist/fleet/fleetLedger.d.ts +32 -9
- package/dist/fleet/fleetLedger.js +119 -34
- package/dist/fleet/fleetProjection.d.ts +44 -6
- package/dist/fleet/fleetProjection.js +52 -10
- package/dist/fleetAgentPanelProjection.d.ts +18 -1
- package/dist/fleetAgentPanelProjection.js +61 -14
- package/dist/forkWireCaps.d.ts +2 -1
- package/dist/forkWireCaps.js +5 -11
- package/dist/goalStopHook.d.ts +142 -0
- package/dist/goalStopHook.js +258 -0
- package/dist/headlessPermissionModeWire.d.ts +10 -0
- package/dist/headlessPermissionModeWire.js +24 -21
- package/dist/headlessReconnectWire.d.ts +7 -1
- package/dist/headlessReconnectWire.js +20 -2
- package/dist/hitl/approvalsFeed.d.ts +22 -1
- package/dist/hitl/approvalsFeed.js +103 -9
- package/dist/hitl/askGateWire.d.ts +27 -96
- package/dist/hitl/askGateWire.js +69 -618
- package/dist/hitl/frameRouter.d.ts +86 -0
- package/dist/hitl/frameRouter.js +383 -0
- package/dist/hitl/gateLedger.d.ts +119 -0
- package/dist/hitl/gateLedger.js +113 -0
- package/dist/hitl/hitlBridge.d.ts +75 -15
- package/dist/hitl/hitlBridge.js +94 -22
- package/dist/hitl/hitlHostSurface.d.ts +49 -0
- package/dist/hitl/hitlHostSurface.js +155 -0
- package/dist/hitl/parkResolver.d.ts +74 -0
- package/dist/hitl/parkResolver.js +250 -0
- package/dist/hitl/planReviewWire.d.ts +60 -2
- package/dist/hitl/planReviewWire.js +197 -91
- package/dist/hitl/toolApprovalWire.d.ts +67 -4
- package/dist/hitl/toolApprovalWire.js +129 -31
- package/dist/hooksWireCaps.d.ts +1 -82
- package/dist/hooksWireCaps.js +44 -237
- package/dist/host.d.ts +16 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -1
- package/dist/interactiveToolsWire.d.ts +15 -4
- package/dist/interactiveToolsWire.js +24 -26
- package/dist/limitsWire.js +7 -40
- package/dist/liveInitToolFace.d.ts +51 -6
- package/dist/liveQuestionStore.d.ts +18 -6
- package/dist/liveQuestionStore.js +15 -0
- package/dist/model/providerPresets.js +11 -1
- package/dist/notifications.d.ts +62 -2
- package/dist/notifications.js +306 -49
- package/dist/printToolResultFrame.d.ts +12 -4
- package/dist/printToolResultFrame.js +11 -21
- package/dist/retainBackgroundWireCaps.d.ts +3 -2
- package/dist/retainBackgroundWireCaps.js +5 -9
- package/dist/sandboxWire.d.ts +9 -31
- package/dist/sandboxWire.js +51 -50
- package/dist/scenarioWire.d.ts +1 -1
- package/dist/scenarioWire.js +25 -36
- package/dist/seam.d.ts +23 -6
- package/dist/seam.js +40 -30
- package/dist/seatContract.d.ts +369 -83
- package/dist/seatContract.js +585 -198
- package/dist/selfOrchestrationWireCaps.d.ts +6 -5
- package/dist/selfOrchestrationWireCaps.js +8 -12
- package/dist/sessionSlot.d.ts +8 -0
- package/dist/sessionSlot.js +1 -0
- package/dist/steering.js +2 -2
- package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
- package/dist/subagent/engineTaskHandleWire.js +17 -2
- package/dist/subagentContentStore.d.ts +5 -5
- package/dist/toolResult.d.ts +89 -8
- package/dist/toolResult.js +99 -30
- package/dist/typePins.d.ts +17 -0
- package/dist/typePins.js +1 -0
- package/dist/ultracodeWireCaps.js +5 -6
- package/dist/unrefTimer.d.ts +30 -0
- package/dist/unrefTimer.js +5 -0
- package/dist/workflow.d.ts +3 -2
- package/dist/workflow.js +3 -2
- package/dist/workflowClient.d.ts +7 -0
- package/dist/workflowClient.js +47 -12
- 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 (
|
|
133
|
-
|
|
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 (
|
|
136
|
-
|
|
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).
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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`.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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)
|
|
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;
|
package/dist/hitl/hitlBridge.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
74
|
-
|
|
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).
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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;
|