@sema-agent/client-core 0.8.0 → 0.9.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 +18 -6
- package/dist/adapt.js +12 -48
- package/dist/compensations.d.ts +2 -1
- package/dist/compensations.js +71 -1
- package/dist/hitl/approvalsFeed.d.ts +102 -0
- package/dist/hitl/approvalsFeed.js +185 -0
- package/dist/hitl/askGateWire.d.ts +155 -0
- package/dist/hitl/askGateWire.js +530 -0
- package/dist/hitl/hitlBridge.d.ts +245 -0
- package/dist/hitl/hitlBridge.js +267 -0
- package/dist/hitl/planReviewWire.d.ts +17 -0
- package/dist/hitl/planReviewWire.js +142 -0
- package/dist/hitl/toolApprovalWire.d.ts +126 -0
- package/dist/hitl/toolApprovalWire.js +263 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +36 -0
- package/dist/notifications.d.ts +67 -0
- package/dist/notifications.js +94 -0
- package/dist/subagent/engineTaskHandleWire.d.ts +0 -1
- package/dist/subagent/engineTaskHandleWire.js +4 -3
- package/package.json +2 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⇄ B7 批搬迁(2026-07-27,设计稿 §3 B7):cli `src/seam/adapter/upstream/hitlBridge.ts`(491 行)
|
|
3
|
+
* **整条进包**。这是 HITL 的心脏 —— suspended ↔ canUseTool 的编组层。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **D-1 两元组是安全不变量,原样搬,一个字节不改**(设计稿 §3 B7 原文):
|
|
6
|
+
* `bindingOf()`(下方 §1 末)把人**看见的那一行** `PendingCheckpoint` 上的
|
|
7
|
+
* `boundCallId` + `boundInputHash` **逐字回显**进 `approvals.decide(...)` —— 绝不本地重算
|
|
8
|
+
* (重算出来的假不匹配会 fail-close 掉一个合法审批);409 `approval_binding_mismatch`
|
|
9
|
+
* 是**安全信号**,抛 `HitlSafetyError('binding_mismatch')`,**绝不自动重试**。
|
|
10
|
+
* 这两段代码在 `scripts/run-client-core-pure-test.mjs` 的 B7 段有**字节级**断言
|
|
11
|
+
* (常量字面量比对 + 壳树在场时的双向比对),改一个字符就红。
|
|
12
|
+
*
|
|
13
|
+
* ── 搬迁差分(只有三处,全在类型/依赖面,零运行时语义变化)────────────────────────────────
|
|
14
|
+
* 1. `eventSeq` 从 `../types.js` → `../adapter/types.js`(B3 已搬入本包,同一份实现)。
|
|
15
|
+
* 2. CC 的 `PermissionDecision` / `PermissionDenyDecision` / `CanUseToolFn` 三个类型住在壳的
|
|
16
|
+
* `src/types/permissions.ts` + `src/hooks/useCanUseTool.tsx`(它们拖 `ToolType` /
|
|
17
|
+
* `ToolUseContext` / `AssistantMessage` 整张 CC 内部类型图,不属于 wire 词汇表)。
|
|
18
|
+
* 本包改用**结构切片 + 泛型**:`HitlPermissionDecisionLike`(本桥真正读的三个键)与
|
|
19
|
+
* `HitlCanUseToolFn<D>`(CC 回调的形状,tool/context/message 三个位对本桥是不透明的)。
|
|
20
|
+
* 壳侧 shim 用自己的 `PermissionDecision` 实例化泛型 ⇒ 壳侧调用点类型一字节不变。
|
|
21
|
+
* 🔴 这是**类型面**的收窄,不是行为面:运行时只读 `.behavior` / `.message` / `.updatedInput`
|
|
22
|
+
* 三个键(原文就只读这三个)。
|
|
23
|
+
* 3. `backendDeny` 的返回形状(`decisionReason:{type:'asyncAgent',reason}`)是 CC 的
|
|
24
|
+
* `PermissionDenyDecision` 逐字形,本包按字面量声明(壳侧 shim 断言可赋值)。
|
|
25
|
+
*
|
|
26
|
+
* ── 以下为原文件的领域说明(逐字保留)────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* src/seam/adapter/upstream/hitlBridge.ts — the UPSTREAM control bridge (tasks.json T23; NEW file, no CC-logic edit).
|
|
29
|
+
*
|
|
30
|
+
* This is the human-in-the-loop (HITL) heart of the seam: the place where CC's synchronous `canUseTool`
|
|
31
|
+
* callback (useCanUseTool.tsx:27 — the spine the contract must reproduce) meets the `@sema-ai/sdk`
|
|
32
|
+
* suspended → decide → resume loop (contract/04 §2/§4/§6, contract/08 IH-4/IH-5/IH-10). CC's loop calls
|
|
33
|
+
* `canUseTool(tool, input, …)` before any guarded tool runs and BLOCKS on the returned `PermissionDecision`;
|
|
34
|
+
* the contract turns that blocking callback into a DURABLE pause (a `suspended` event carrying a
|
|
35
|
+
* `CheckpointGate`) plus a `decide` verb. This module is the marshalling layer between the two worlds.
|
|
36
|
+
*
|
|
37
|
+
* What it does (the three contract laws this file pins):
|
|
38
|
+
*
|
|
39
|
+
* 1. **suspended ↔ canUseTool with the D-1 TOCTOU guard** (contract/04 §2.2, §D-1; 08 IH-4).
|
|
40
|
+
* When CC asks "may I run THIS tool?", the bridge finds the matching `PendingCheckpoint` the human SAW
|
|
41
|
+
* (joined by `toolCallId` → `boundCallId`), reads its `boundCallId` + `boundInputHash` two-tuple, and
|
|
42
|
+
* echoes them back into `approvals.decide(...)` VERBATIM. The hash is a server-minted opaque sha256 over
|
|
43
|
+
* the pending args at mint time — the bridge NEVER recomputes it (a false mismatch would fail-close a
|
|
44
|
+
* legitimate approval, approvals.ts:33). `updatedInput` (approve-with-edit, design/37 last-wins) is
|
|
45
|
+
* applied AFTER the binding check and does NOT change the binding — the hash still binds the ORIGINAL
|
|
46
|
+
* input the human reviewed. A 409 `approval_binding_mismatch` is a SAFETY signal: re-present to the
|
|
47
|
+
* human, NEVER auto-retry (contract/04 §9.1 fail-closed law).
|
|
48
|
+
*
|
|
49
|
+
* 2. **AskUserQuestion answer marshalling** (contract/04 §4.1; 08 CS-16/IH-4). The same suspended → decide
|
|
50
|
+
* loop, but the gate's `toolName` is `"AskUserQuestion"` and the answer rides `ApprovalDecision.answer`
|
|
51
|
+
* as the worker-validated wire shape `{ answers: [{ header, selected, note? }] }` — `header` MATCHES the
|
|
52
|
+
* question's header, `selected` is an ARRAY (multi-select → multiple entries), `note` is optional free
|
|
53
|
+
* text. The SDK types `answer` as `unknown`, so this is hand-marshalled (catalog mock-fill); the
|
|
54
|
+
* transport is sdk-covered. The D-1 binding still binds (AskUserQuestion is still a checkpoint).
|
|
55
|
+
*
|
|
56
|
+
* 3. **plan_review** (contract/04 §3; 08 CS-17/IH-6). CC's plan mode ends a planning turn with a markdown
|
|
57
|
+
* plan; the human approves/edits/rejects the PLAN itself (no bound tool action). This resolves on a
|
|
58
|
+
* DISTINCT wire — `assistant.planReview(taskId, { decision, editedPlan?, reason? })` — NOT `decide` and
|
|
59
|
+
* NOT `resume`. `editedPlan` is REQUIRED iff `decision === "edit"` and FORBIDDEN otherwise (→ 400). A
|
|
60
|
+
* non-`plan_review` gate routed here would 409 `gate_not_plan_review`; the bridge guards the gate kind
|
|
61
|
+
* before it calls.
|
|
62
|
+
*
|
|
63
|
+
* Provider- and presentation-agnostic (contract/04 laws 2-3; 08 invariants): every verb stays at
|
|
64
|
+
* `ApprovalDecision` / `PlanReviewRequest` / `CheckpointGate` altitude. No Anthropic `effort`/`fast_mode`
|
|
65
|
+
* vocabulary, no claude.ai permission-rule destinations, no widget/glyph/keybinding crosses this seam. The
|
|
66
|
+
* backend supplies the SIGNAL; the shell owns the chrome.
|
|
67
|
+
*/
|
|
68
|
+
import type { AgentEvent, ApprovalDecision, PendingCheckpoint, CheckpointGate, PlanReviewRequest, AssistantTaskStatus } from '@sema-agent/sdk';
|
|
69
|
+
/** CC `PermissionDecision` 的结构切片 —— 本桥运行时真正读的三个键。 */
|
|
70
|
+
export interface HitlPermissionDecisionLike {
|
|
71
|
+
behavior: 'allow' | 'ask' | 'deny';
|
|
72
|
+
/** deny 臂的人话理由(CC `PermissionDenyDecision.message`),approve 臂缺席。 */
|
|
73
|
+
message?: string;
|
|
74
|
+
/** approve-with-edit(design/37 last-wins);在**绑定校验之后**生效,绑定仍绑原始 input。 */
|
|
75
|
+
updatedInput?: unknown;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* CC `CanUseToolFn` 的结构形(`useCanUseTool.tsx:28`)。tool / toolUseContext / assistantMessage
|
|
79
|
+
* 三个位对本桥是**不透明**的(只读 `tool.name`),所以按最小形声明 —— 壳侧那个更窄的真类型
|
|
80
|
+
* 可赋值到这里(函数参数双变),`makeHitlCanUseTool` 的返回值也就能直接当 `CanUseToolFn` 用。
|
|
81
|
+
*/
|
|
82
|
+
export type HitlCanUseToolFn<D extends HitlPermissionDecisionLike = HitlPermissionDecisionLike> = (tool: {
|
|
83
|
+
name: string;
|
|
84
|
+
}, input: Record<string, unknown>, toolUseContext: never, assistantMessage: never, toolUseID: string, forceDecision?: D) => Promise<D>;
|
|
85
|
+
export interface ApprovalsResourceLike {
|
|
86
|
+
/** GET /v1/approvals — the rich decide-ready queue (approvals.ts:45). NEVER carries a capability token. */
|
|
87
|
+
list(opts?: {
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
}): Promise<PendingCheckpoint[]>;
|
|
90
|
+
/** POST /v1/approvals/:sessionId/decide — resolve THAT checkpoint; the resumed run continues its stream
|
|
91
|
+
* (approvals.ts:52). NOT a submit → no retry (a decide must never double-act). */
|
|
92
|
+
decide(sessionId: string, decision: ApprovalDecision, opts?: {
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}): Promise<unknown>;
|
|
95
|
+
}
|
|
96
|
+
export interface AssistantResourceLike {
|
|
97
|
+
/** POST /v1/assistant/tasks/:id/plan_review — the 3-state plan gate (assistant.ts:87). */
|
|
98
|
+
planReview(taskId: string, req: PlanReviewRequest, opts?: {
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
}): Promise<AssistantTaskStatus>;
|
|
101
|
+
/** POST /v1/assistant/tasks/:id/resume — continue a resource_limit slice, NO body (assistant.ts:75). */
|
|
102
|
+
resume(taskId: string, opts?: {
|
|
103
|
+
signal?: AbortSignal;
|
|
104
|
+
}): Promise<AssistantTaskStatus>;
|
|
105
|
+
}
|
|
106
|
+
export interface HitlClientLike {
|
|
107
|
+
approvals: ApprovalsResourceLike;
|
|
108
|
+
assistant: AssistantResourceLike;
|
|
109
|
+
}
|
|
110
|
+
/** The marshalled AskUserQuestion answer body — the worker-validated wire shape (contract/04 §4.1). */
|
|
111
|
+
export interface AskAnswer {
|
|
112
|
+
/** MATCHES the question's `header`. */
|
|
113
|
+
header: string;
|
|
114
|
+
/** ARRAY — multi-select yields multiple entries; single-select is a one-element array. */
|
|
115
|
+
selected: string[];
|
|
116
|
+
/** Optional operator free-text note. */
|
|
117
|
+
note?: string;
|
|
118
|
+
}
|
|
119
|
+
/** A plan-review outcome the shell collected (contract/04 §3.1). `editedPlan` REQUIRED iff decision==="edit". */
|
|
120
|
+
export interface PlanReviewOutcome {
|
|
121
|
+
decision: 'approve' | 'edit' | 'reject';
|
|
122
|
+
editedPlan?: string;
|
|
123
|
+
reason?: string;
|
|
124
|
+
}
|
|
125
|
+
/** A safety stop: a contract law was about to be violated (e.g. a binding mismatch, a wrong-gate route).
|
|
126
|
+
* The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
|
|
127
|
+
* (contract/04 §9.1 fail-closed law). */
|
|
128
|
+
export declare class HitlSafetyError extends Error {
|
|
129
|
+
/** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
|
|
130
|
+
readonly code: string;
|
|
131
|
+
constructor(message: string,
|
|
132
|
+
/** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
|
|
133
|
+
code: string);
|
|
134
|
+
}
|
|
135
|
+
/** The active gate the downstream stream most recently suspended on (null when running). */
|
|
136
|
+
export interface ActiveGate {
|
|
137
|
+
gate: CheckpointGate;
|
|
138
|
+
/** The durable seq of the `suspended` event, for rewind/dedup correlation (types.ts:48 eventSeq). */
|
|
139
|
+
seq?: string;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
|
|
143
|
+
* outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
|
|
144
|
+
*/
|
|
145
|
+
export declare class HitlBridge {
|
|
146
|
+
private readonly client;
|
|
147
|
+
/** The suspended run's taskId — the handle plan_review/resume address (RunReceipt.taskId). */
|
|
148
|
+
private readonly taskId;
|
|
149
|
+
private active;
|
|
150
|
+
constructor(client: HitlClientLike,
|
|
151
|
+
/** The suspended run's taskId — the handle plan_review/resume address (RunReceipt.taskId). */
|
|
152
|
+
taskId: string);
|
|
153
|
+
/** Observe a downstream event; latch the gate on `suspended`, clear it on a resuming/terminal arm. */
|
|
154
|
+
observe(ev: AgentEvent): void;
|
|
155
|
+
/** The gate currently awaiting a human, if any. The shell branches on `gate.kind` to pick the chrome. */
|
|
156
|
+
currentGate(): CheckpointGate | null;
|
|
157
|
+
/**
|
|
158
|
+
* Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
|
|
159
|
+
* `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
|
|
160
|
+
* the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
|
|
161
|
+
*/
|
|
162
|
+
private findPending;
|
|
163
|
+
/**
|
|
164
|
+
* Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
|
|
165
|
+
* (contract/04 §2.2). Returns `{}` against a pre-D-1 worker (no binding fields) so the server can fall
|
|
166
|
+
* back to resolve-by-sessionId. `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
167
|
+
* compliant clients send ONLY the two-tuple, approvals.ts:23-27).
|
|
168
|
+
*/
|
|
169
|
+
private bindingOf;
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a `human` / `irreversible_ask` permission gate (contract/04 §2, §6.2; 08 IH-4/IH-10).
|
|
172
|
+
*
|
|
173
|
+
* - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
|
|
174
|
+
* approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
|
|
175
|
+
* - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
|
|
176
|
+
* (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
|
|
177
|
+
* "the run ends after the deny", carried by the backend, not a wire flag.
|
|
178
|
+
*
|
|
179
|
+
* The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
|
|
180
|
+
* `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
|
|
181
|
+
*/
|
|
182
|
+
decideTool(outcome: {
|
|
183
|
+
decision: 'approve';
|
|
184
|
+
updatedInput?: unknown;
|
|
185
|
+
remember?: 'session';
|
|
186
|
+
} | {
|
|
187
|
+
decision: 'deny';
|
|
188
|
+
reason?: string;
|
|
189
|
+
}, toolUseID?: string, opts?: {
|
|
190
|
+
signal?: AbortSignal;
|
|
191
|
+
}): Promise<unknown>;
|
|
192
|
+
/**
|
|
193
|
+
* Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
|
|
194
|
+
* permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
|
|
195
|
+
* `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
|
|
196
|
+
* checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
|
|
197
|
+
*/
|
|
198
|
+
answerQuestion(answers: AskAnswer[], toolUseID?: string, opts?: {
|
|
199
|
+
signal?: AbortSignal;
|
|
200
|
+
}): Promise<unknown>;
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a plan-mode gate (contract/04 §3; 08 IH-6). Routed to `assistant.planReview`, NOT `decide`/
|
|
203
|
+
* `resume`. Guards the gate kind locally (a non-`plan_review` gate routed here would 409
|
|
204
|
+
* `gate_not_plan_review`) and the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
|
|
205
|
+
* otherwise → 400) so the bad call never leaves the shell.
|
|
206
|
+
*/
|
|
207
|
+
reviewPlan(outcome: PlanReviewOutcome, opts?: {
|
|
208
|
+
signal?: AbortSignal;
|
|
209
|
+
}): Promise<AssistantTaskStatus>;
|
|
210
|
+
/**
|
|
211
|
+
* Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
|
|
212
|
+
* `decide`. A `human`/`irreversible_ask` gate routed here would 409 `gate_not_resumable` (a "continue"
|
|
213
|
+
* must NEVER bypass a human approval); the bridge guards locally first.
|
|
214
|
+
*/
|
|
215
|
+
resumeResourceLimit(opts?: {
|
|
216
|
+
signal?: AbortSignal;
|
|
217
|
+
}): Promise<AssistantTaskStatus>;
|
|
218
|
+
private decideRaw;
|
|
219
|
+
}
|
|
220
|
+
/** The shell's presentation hook: surface a gate, resolve to a CC `PermissionDecision`. */
|
|
221
|
+
export type GatePrompt<D extends HitlPermissionDecisionLike = HitlPermissionDecisionLike> = (ctx: {
|
|
222
|
+
toolName: string;
|
|
223
|
+
input: Record<string, unknown>;
|
|
224
|
+
toolUseID: string;
|
|
225
|
+
gate: CheckpointGate | null;
|
|
226
|
+
}) => Promise<D>;
|
|
227
|
+
/**
|
|
228
|
+
* Build a `CanUseToolFn` backed by the bridge. CC's loop calls it before any guarded tool; it surfaces the
|
|
229
|
+
* gate via `prompt`, routes the human's decision to `approvals.decide` (binding echoed verbatim), and
|
|
230
|
+
* returns the same `PermissionDecision` to unblock the loop. A `forceDecision` (CC's pre-resolved path)
|
|
231
|
+
* short-circuits the prompt but STILL drives the decide verb so the backend resolves the checkpoint.
|
|
232
|
+
*/
|
|
233
|
+
export declare function makeHitlCanUseTool<D extends HitlPermissionDecisionLike = HitlPermissionDecisionLike>(bridge: HitlBridge, prompt: GatePrompt<D>): HitlCanUseToolFn<D>;
|
|
234
|
+
/** CC `PermissionDenyDecision` 的逐字形(`decisionReason` 的 `asyncAgent` 臂)。 */
|
|
235
|
+
export interface HitlBackendDenyDecision {
|
|
236
|
+
behavior: 'deny';
|
|
237
|
+
message: string;
|
|
238
|
+
decisionReason: {
|
|
239
|
+
type: 'asyncAgent';
|
|
240
|
+
reason: string;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Build a CC deny `PermissionDecision` from a backend-driven denial (e.g. an SLA / policy auto-deny).
|
|
244
|
+
* The decisionReason is `asyncAgent` — the denial originated upstream, not from a local rule/mode. */
|
|
245
|
+
export declare function backendDeny(reason: string): HitlBackendDenyDecision;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { eventSeq } from '../adapter/types.js';
|
|
2
|
+
// ── Errors the bridge surfaces (fail-closed; never auto-retried) ───────────────
|
|
3
|
+
/** A safety stop: a contract law was about to be violated (e.g. a binding mismatch, a wrong-gate route).
|
|
4
|
+
* The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
|
|
5
|
+
* (contract/04 §9.1 fail-closed law). */
|
|
6
|
+
export class HitlSafetyError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(message,
|
|
9
|
+
/** A stable code the shell can branch on (`binding_mismatch` | `no_pending` | `wrong_gate` | `bad_plan_edit`). */
|
|
10
|
+
code) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'HitlSafetyError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
|
|
18
|
+
* outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
|
|
19
|
+
*/
|
|
20
|
+
export class HitlBridge {
|
|
21
|
+
client;
|
|
22
|
+
taskId;
|
|
23
|
+
active = null;
|
|
24
|
+
constructor(client,
|
|
25
|
+
/** The suspended run's taskId — the handle plan_review/resume address (RunReceipt.taskId). */
|
|
26
|
+
taskId) {
|
|
27
|
+
this.client = client;
|
|
28
|
+
this.taskId = taskId;
|
|
29
|
+
}
|
|
30
|
+
/** Observe a downstream event; latch the gate on `suspended`, clear it on a resuming/terminal arm. */
|
|
31
|
+
observe(ev) {
|
|
32
|
+
switch (ev.type) {
|
|
33
|
+
case 'suspended':
|
|
34
|
+
// gate may be null (Event_suspended 'null' arm) → a generic "needs you" pause (contract/08 CS-9).
|
|
35
|
+
if (ev.gate) {
|
|
36
|
+
this.active = { gate: ev.gate, seq: eventSeq(ev) };
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.active = { gate: { kind: 'human' }, seq: eventSeq(ev) };
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case 'done':
|
|
43
|
+
case 'failed':
|
|
44
|
+
this.active = null;
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
// running again (text / tool_start / turn_end / …) → the gate cleared when decide resumed the stream.
|
|
48
|
+
if (this.active && (ev.type === 'text' || ev.type === 'tool_start' || ev.type === 'turn_end')) {
|
|
49
|
+
this.active = null;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The gate currently awaiting a human, if any. The shell branches on `gate.kind` to pick the chrome. */
|
|
55
|
+
currentGate() {
|
|
56
|
+
return this.active?.gate ?? null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
|
|
60
|
+
* `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
|
|
61
|
+
* the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
|
|
62
|
+
*/
|
|
63
|
+
async findPending(toolUseID, opts) {
|
|
64
|
+
const pending = await this.client.approvals.list(opts);
|
|
65
|
+
if (pending.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
if (toolUseID) {
|
|
68
|
+
const byCall = pending.find((p) => p.boundCallId === toolUseID || p.toolCallId === toolUseID);
|
|
69
|
+
if (byCall)
|
|
70
|
+
return byCall;
|
|
71
|
+
}
|
|
72
|
+
// 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;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 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;
|
|
80
|
+
* compliant clients send ONLY the two-tuple, approvals.ts:23-27).
|
|
81
|
+
*/
|
|
82
|
+
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;
|
|
89
|
+
}
|
|
90
|
+
// ── §2. Permission gate: approve / deny a tool ──────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a `human` / `irreversible_ask` permission gate (contract/04 §2, §6.2; 08 IH-4/IH-10).
|
|
93
|
+
*
|
|
94
|
+
* - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
|
|
95
|
+
* approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
|
|
96
|
+
* - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
|
|
97
|
+
* (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
|
|
98
|
+
* "the run ends after the deny", carried by the backend, not a wire flag.
|
|
99
|
+
*
|
|
100
|
+
* The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
|
|
101
|
+
* `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
|
|
102
|
+
*/
|
|
103
|
+
async decideTool(outcome, toolUseID, opts) {
|
|
104
|
+
const pending = await this.findPending(toolUseID, opts);
|
|
105
|
+
if (!pending) {
|
|
106
|
+
throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
|
|
107
|
+
}
|
|
108
|
+
const decision = outcome.decision === 'approve'
|
|
109
|
+
? {
|
|
110
|
+
decision: 'approve',
|
|
111
|
+
...this.bindingOf(pending),
|
|
112
|
+
// updatedInput is applied AFTER the binding check; binding still binds the ORIGINAL input.
|
|
113
|
+
...(outcome.updatedInput !== undefined ? { updatedInput: outcome.updatedInput } : {}),
|
|
114
|
+
// accept-session 的 durable-park 半场(server 1.191 decide 契约 `remember:"session"` →
|
|
115
|
+
// approvalExemptionStore.grant(sessionId, toolName)= 同 session 该工具后续 ask 免 park。
|
|
116
|
+
// 需要 binding(上面已 echo)。调用方只在三选第 2 项才带;老 server 收到未知键按契约 400,
|
|
117
|
+
// 故带 remember 的 decide 失败时调用方要能回退纯 approve(见 liveToolApprovalWire)。
|
|
118
|
+
...(outcome.remember !== undefined ? { remember: outcome.remember } : {}),
|
|
119
|
+
}
|
|
120
|
+
: {
|
|
121
|
+
decision: 'deny',
|
|
122
|
+
...this.bindingOf(pending),
|
|
123
|
+
...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
|
|
124
|
+
};
|
|
125
|
+
return this.decideRaw(pending.sessionId, decision, opts);
|
|
126
|
+
}
|
|
127
|
+
// ── §3. AskUserQuestion: answer through the same gate ───────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
|
|
130
|
+
* permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
|
|
131
|
+
* `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
|
|
132
|
+
* checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
|
|
133
|
+
*/
|
|
134
|
+
async answerQuestion(answers, toolUseID, opts) {
|
|
135
|
+
const pending = await this.findPending(toolUseID, opts);
|
|
136
|
+
if (!pending) {
|
|
137
|
+
throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
|
|
138
|
+
}
|
|
139
|
+
const decision = {
|
|
140
|
+
decision: 'approve',
|
|
141
|
+
answer: { answers: answers.map((a) => marshalAnswer(a)) },
|
|
142
|
+
...this.bindingOf(pending),
|
|
143
|
+
};
|
|
144
|
+
return this.decideRaw(pending.sessionId, decision, opts);
|
|
145
|
+
}
|
|
146
|
+
// ── §4. plan_review: the distinct wire ──────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* 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.
|
|
152
|
+
*/
|
|
153
|
+
async reviewPlan(outcome, opts) {
|
|
154
|
+
// 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.
|
|
156
|
+
const kind = this.active?.gate.kind;
|
|
157
|
+
if (kind && kind !== 'plan_review' && kind !== 'needs_review') {
|
|
158
|
+
throw new HitlSafetyError(`cannot plan-review a "${kind}" gate — a plan decision must never resolve a tool approval`, 'wrong_gate');
|
|
159
|
+
}
|
|
160
|
+
// editedPlan invariant (contract/04 §3.1): REQUIRED iff edit, FORBIDDEN otherwise.
|
|
161
|
+
if (outcome.decision === 'edit') {
|
|
162
|
+
if (!outcome.editedPlan || outcome.editedPlan.length === 0) {
|
|
163
|
+
throw new HitlSafetyError('plan-review "edit" requires a non-empty editedPlan', 'bad_plan_edit');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (outcome.editedPlan !== undefined) {
|
|
167
|
+
throw new HitlSafetyError(`plan-review "${outcome.decision}" must NOT carry editedPlan`, 'bad_plan_edit');
|
|
168
|
+
}
|
|
169
|
+
const req = { decision: outcome.decision };
|
|
170
|
+
if (outcome.decision === 'edit')
|
|
171
|
+
req.editedPlan = outcome.editedPlan;
|
|
172
|
+
if (outcome.reason !== undefined)
|
|
173
|
+
req.reason = outcome.reason;
|
|
174
|
+
return this.client.assistant.planReview(this.taskId, req, opts);
|
|
175
|
+
}
|
|
176
|
+
// ── §5. resource_limit: continue ────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
|
|
179
|
+
* `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.
|
|
181
|
+
*/
|
|
182
|
+
async resumeResourceLimit(opts) {
|
|
183
|
+
const kind = this.active?.gate.kind;
|
|
184
|
+
if (kind && kind !== 'resource_limit') {
|
|
185
|
+
throw new HitlSafetyError(`cannot resume a "${kind}" gate — "continue" must never bypass a human approval; use decide`, 'wrong_gate');
|
|
186
|
+
}
|
|
187
|
+
return this.client.assistant.resume(this.taskId, opts);
|
|
188
|
+
}
|
|
189
|
+
// ── decide transport (the single choke point; maps 409 binding mismatch → safety stop) ──
|
|
190
|
+
async decideRaw(sessionId, decision, opts) {
|
|
191
|
+
try {
|
|
192
|
+
const r = await this.client.approvals.decide(sessionId, decision, opts);
|
|
193
|
+
// A successful decide resumes the SAME durable stream; the gate clears on the next running arm.
|
|
194
|
+
this.active = null;
|
|
195
|
+
return r;
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
if (isBindingMismatch(e)) {
|
|
199
|
+
// contract/04 §2.2 / §9.1: a binding mismatch means the pending action changed under the human.
|
|
200
|
+
// SAFETY signal — re-present to the human, NEVER auto-retry / auto-re-decide.
|
|
201
|
+
throw new HitlSafetyError('approval_binding_mismatch — the pending action changed under the human; refetch + re-present', 'binding_mismatch');
|
|
202
|
+
}
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build a `CanUseToolFn` backed by the bridge. CC's loop calls it before any guarded tool; it surfaces the
|
|
209
|
+
* gate via `prompt`, routes the human's decision to `approvals.decide` (binding echoed verbatim), and
|
|
210
|
+
* returns the same `PermissionDecision` to unblock the loop. A `forceDecision` (CC's pre-resolved path)
|
|
211
|
+
* short-circuits the prompt but STILL drives the decide verb so the backend resolves the checkpoint.
|
|
212
|
+
*/
|
|
213
|
+
export function makeHitlCanUseTool(bridge, prompt) {
|
|
214
|
+
return async (tool, input, _toolUseContext, _assistantMessage, toolUseID, forceDecision) => {
|
|
215
|
+
const gate = bridge.currentGate();
|
|
216
|
+
const decision = forceDecision ??
|
|
217
|
+
(await prompt({ toolName: tool.name, input, toolUseID, gate }));
|
|
218
|
+
if (decision.behavior === 'deny') {
|
|
219
|
+
// Deny → cancel-by-deny (contract/04 §2.4). The deny message rides `reason`.
|
|
220
|
+
await bridge.decideTool({ decision: 'deny', reason: decision.message }, toolUseID);
|
|
221
|
+
return decision;
|
|
222
|
+
}
|
|
223
|
+
if (decision.behavior === 'allow') {
|
|
224
|
+
// Approve (+ approve-with-edit via updatedInput, applied after the binding check).
|
|
225
|
+
await bridge.decideTool(decision.updatedInput !== undefined
|
|
226
|
+
? { decision: 'approve', updatedInput: decision.updatedInput }
|
|
227
|
+
: { decision: 'approve' }, toolUseID);
|
|
228
|
+
return decision;
|
|
229
|
+
}
|
|
230
|
+
// behavior === 'ask': not a wire decision — re-surface (the shell loops the prompt). Returned as-is so
|
|
231
|
+
// CC's loop re-asks; the bridge does NOT decide on an `ask`.
|
|
232
|
+
return decision;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
236
|
+
/** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`. */
|
|
237
|
+
function marshalAnswer(a) {
|
|
238
|
+
const entry = {
|
|
239
|
+
header: a.header,
|
|
240
|
+
selected: a.selected,
|
|
241
|
+
};
|
|
242
|
+
if (a.note !== undefined)
|
|
243
|
+
entry.note = a.note;
|
|
244
|
+
return entry;
|
|
245
|
+
}
|
|
246
|
+
/** Detect a 409 `approval_binding_mismatch` across the SDK error shape variants (errorCode / code / status). */
|
|
247
|
+
function isBindingMismatch(e) {
|
|
248
|
+
if (!e || typeof e !== 'object')
|
|
249
|
+
return false;
|
|
250
|
+
const o = e;
|
|
251
|
+
const code = o.errorCode ?? o.code;
|
|
252
|
+
if (code === 'approval_binding_mismatch')
|
|
253
|
+
return true;
|
|
254
|
+
// SDK throws a named ApprovalBindingMismatchError (index.ts:19).
|
|
255
|
+
if (typeof o.name === 'string' && o.name === 'ApprovalBindingMismatchError')
|
|
256
|
+
return true;
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/** Build a CC deny `PermissionDecision` from a backend-driven denial (e.g. an SLA / policy auto-deny).
|
|
260
|
+
* The decisionReason is `asyncAgent` — the denial originated upstream, not from a local rule/mode. */
|
|
261
|
+
export function backendDeny(reason) {
|
|
262
|
+
return {
|
|
263
|
+
behavior: 'deny',
|
|
264
|
+
message: reason,
|
|
265
|
+
decisionReason: { type: 'asyncAgent', reason },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
|
|
2
|
+
export declare function isPlanReviewPark(result: unknown): result is {
|
|
3
|
+
taskId: string;
|
|
4
|
+
sessionId?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
|
|
8
|
+
* armed (live path + shape matched); false = caller renders the terminal as usual. Fail-soft: any
|
|
9
|
+
* error inside must never break the stream drain.
|
|
10
|
+
*/
|
|
11
|
+
export declare function armPlanReviewApproval(result: unknown): boolean;
|
|
12
|
+
/** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.
|
|
13
|
+
* SDK 宪法迁移批:SDK 0.0.52 assistant.planReview verb(同 wire `POST /v1/assistant/tasks/:id/
|
|
14
|
+
* plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
|
|
15
|
+
* server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
|
|
16
|
+
* reach the engine」臂。 */
|
|
17
|
+
export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject'): Promise<void>;
|