@workerdeck/core 0.6.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/LICENSE +21 -0
- package/README.md +140 -0
- package/build/index.d.mts +848 -0
- package/build/index.mjs +2324 -0
- package/build/index.mjs.map +1 -0
- package/package.json +63 -0
package/build/index.mjs
ADDED
|
@@ -0,0 +1,2324 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { getSessionMessages, query } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { createVfs, runScript } from "@workerdeck/sandbox";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { lookup } from "node:dns/promises";
|
|
10
|
+
//#region src/input-queue.ts
|
|
11
|
+
/**
|
|
12
|
+
* Push-based single-consumer AsyncIterable bridging imperative sendMessage() calls
|
|
13
|
+
* into the streaming `prompt` the Agent SDK consumes.
|
|
14
|
+
*/
|
|
15
|
+
var InputQueue = class {
|
|
16
|
+
#buffer = [];
|
|
17
|
+
#waiter = null;
|
|
18
|
+
#done = false;
|
|
19
|
+
push(message) {
|
|
20
|
+
if (this.#done) return;
|
|
21
|
+
if (this.#waiter) {
|
|
22
|
+
const resolve = this.#waiter;
|
|
23
|
+
this.#waiter = null;
|
|
24
|
+
resolve({
|
|
25
|
+
value: message,
|
|
26
|
+
done: false
|
|
27
|
+
});
|
|
28
|
+
} else this.#buffer.push(message);
|
|
29
|
+
}
|
|
30
|
+
end() {
|
|
31
|
+
if (this.#done) return;
|
|
32
|
+
this.#done = true;
|
|
33
|
+
if (this.#waiter) {
|
|
34
|
+
const resolve = this.#waiter;
|
|
35
|
+
this.#waiter = null;
|
|
36
|
+
resolve({
|
|
37
|
+
value: void 0,
|
|
38
|
+
done: true
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
[Symbol.asyncIterator]() {
|
|
43
|
+
return {
|
|
44
|
+
next: () => {
|
|
45
|
+
const buffered = this.#buffer.shift();
|
|
46
|
+
if (buffered !== void 0) return Promise.resolve({
|
|
47
|
+
value: buffered,
|
|
48
|
+
done: false
|
|
49
|
+
});
|
|
50
|
+
if (this.#done) return Promise.resolve({
|
|
51
|
+
value: void 0,
|
|
52
|
+
done: true
|
|
53
|
+
});
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
this.#waiter = resolve;
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
return: () => {
|
|
59
|
+
this.end();
|
|
60
|
+
return Promise.resolve({
|
|
61
|
+
value: void 0,
|
|
62
|
+
done: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/normalize.ts
|
|
70
|
+
function toApiMessage(message) {
|
|
71
|
+
const m = message;
|
|
72
|
+
return {
|
|
73
|
+
role: m.role ?? "assistant",
|
|
74
|
+
content: m.content,
|
|
75
|
+
model: m.model,
|
|
76
|
+
stop_reason: m.stop_reason,
|
|
77
|
+
usage: m.usage
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Map one SDKMessage to a wire-protocol event body, or null for messages the runner
|
|
82
|
+
* consumes itself (system_init and session-state changes carry runner state and are
|
|
83
|
+
* emitted by the runner with extra context).
|
|
84
|
+
*/
|
|
85
|
+
function normalizeSdkMessage(msg) {
|
|
86
|
+
switch (msg.type) {
|
|
87
|
+
case "assistant": return {
|
|
88
|
+
type: "assistant_message",
|
|
89
|
+
message: toApiMessage(msg.message),
|
|
90
|
+
parentToolUseId: msg.parent_tool_use_id,
|
|
91
|
+
uuid: msg.uuid
|
|
92
|
+
};
|
|
93
|
+
case "user": return {
|
|
94
|
+
type: "user_message",
|
|
95
|
+
message: toApiMessage(msg.message),
|
|
96
|
+
parentToolUseId: msg.parent_tool_use_id,
|
|
97
|
+
replay: "isReplay" in msg && msg.isReplay === true ? true : void 0,
|
|
98
|
+
synthetic: msg.isSynthetic === true ? true : void 0,
|
|
99
|
+
uuid: msg.uuid
|
|
100
|
+
};
|
|
101
|
+
case "stream_event": return {
|
|
102
|
+
type: "stream_delta",
|
|
103
|
+
event: msg.event,
|
|
104
|
+
parentToolUseId: msg.parent_tool_use_id,
|
|
105
|
+
uuid: msg.uuid
|
|
106
|
+
};
|
|
107
|
+
case "result": return {
|
|
108
|
+
type: "turn_result",
|
|
109
|
+
subtype: msg.subtype,
|
|
110
|
+
isError: msg.is_error,
|
|
111
|
+
durationMs: msg.duration_ms,
|
|
112
|
+
numTurns: msg.num_turns,
|
|
113
|
+
totalCostUsd: msg.total_cost_usd,
|
|
114
|
+
result: msg.subtype === "success" ? msg.result : void 0,
|
|
115
|
+
errors: msg.subtype === "success" ? void 0 : msg.errors,
|
|
116
|
+
usage: msg.usage
|
|
117
|
+
};
|
|
118
|
+
case "rate_limit_event": return {
|
|
119
|
+
type: "rate_limit",
|
|
120
|
+
info: {
|
|
121
|
+
status: msg.rate_limit_info.status,
|
|
122
|
+
rateLimitType: msg.rate_limit_info.rateLimitType,
|
|
123
|
+
utilization: msg.rate_limit_info.utilization,
|
|
124
|
+
resetsAt: msg.rate_limit_info.resetsAt,
|
|
125
|
+
isUsingOverage: msg.rate_limit_info.isUsingOverage
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
case "system":
|
|
129
|
+
if (msg.subtype === "init" || msg.subtype === "session_state_changed") return null;
|
|
130
|
+
return {
|
|
131
|
+
type: "sdk_event",
|
|
132
|
+
payload: msg
|
|
133
|
+
};
|
|
134
|
+
default: return {
|
|
135
|
+
type: "sdk_event",
|
|
136
|
+
payload: msg
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/runner.ts
|
|
142
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
|
|
143
|
+
/**
|
|
144
|
+
* One live Agent SDK session: owns the query() call, the streaming input queue, the
|
|
145
|
+
* pending-approval table, and a seq-numbered event log that subscribers can replay.
|
|
146
|
+
* No transport — the server (or any host) subscribes and bridges to the wire.
|
|
147
|
+
*/
|
|
148
|
+
var SessionRunner = class {
|
|
149
|
+
id;
|
|
150
|
+
createdAt;
|
|
151
|
+
#config;
|
|
152
|
+
#events = [];
|
|
153
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
154
|
+
#seq = 0;
|
|
155
|
+
#status = "starting";
|
|
156
|
+
#statusDetail;
|
|
157
|
+
#sdkSessionId;
|
|
158
|
+
#model;
|
|
159
|
+
#apiKeySource;
|
|
160
|
+
#permissionMode;
|
|
161
|
+
#pending = /* @__PURE__ */ new Map();
|
|
162
|
+
#totalCostUsd;
|
|
163
|
+
#numTurns;
|
|
164
|
+
#lastActivityAt;
|
|
165
|
+
#input = new InputQueue();
|
|
166
|
+
#query;
|
|
167
|
+
#capabilitiesEmitted = false;
|
|
168
|
+
#started = false;
|
|
169
|
+
#closed = false;
|
|
170
|
+
#runPromise;
|
|
171
|
+
constructor(config, id = randomUUID()) {
|
|
172
|
+
this.#config = config;
|
|
173
|
+
this.#permissionMode = config.permissionMode;
|
|
174
|
+
this.id = id;
|
|
175
|
+
this.createdAt = Date.now();
|
|
176
|
+
}
|
|
177
|
+
get status() {
|
|
178
|
+
return this.#status;
|
|
179
|
+
}
|
|
180
|
+
get sdkSessionId() {
|
|
181
|
+
return this.#sdkSessionId;
|
|
182
|
+
}
|
|
183
|
+
get lastSeq() {
|
|
184
|
+
return this.#seq;
|
|
185
|
+
}
|
|
186
|
+
/** 'oauth' = claude.ai subscription credentials; other values are API-key provenance. */
|
|
187
|
+
get apiKeySource() {
|
|
188
|
+
return this.#apiKeySource;
|
|
189
|
+
}
|
|
190
|
+
get pendingApprovals() {
|
|
191
|
+
return [...this.#pending.values()].map((p) => p.request);
|
|
192
|
+
}
|
|
193
|
+
info() {
|
|
194
|
+
return {
|
|
195
|
+
id: this.id,
|
|
196
|
+
sdkSessionId: this.#sdkSessionId,
|
|
197
|
+
status: this.#status,
|
|
198
|
+
cwd: this.#config.cwd,
|
|
199
|
+
profile: this.#config.profile,
|
|
200
|
+
engine: "claude",
|
|
201
|
+
model: this.#model ?? this.#config.model,
|
|
202
|
+
permissionMode: this.#permissionMode,
|
|
203
|
+
apiKeySource: this.#apiKeySource,
|
|
204
|
+
createdAt: this.createdAt,
|
|
205
|
+
lastSeq: this.#seq,
|
|
206
|
+
pendingPermissionCount: this.#pending.size,
|
|
207
|
+
meta: this.#config.meta,
|
|
208
|
+
title: this.#title(),
|
|
209
|
+
totalCostUsd: this.#totalCostUsd,
|
|
210
|
+
numTurns: this.#numTurns,
|
|
211
|
+
lastActivityAt: this.#lastActivityAt
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
#title() {
|
|
215
|
+
const metaTitle = this.#config.meta?.title;
|
|
216
|
+
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
217
|
+
const prompt = this.#config.prompt;
|
|
218
|
+
if (!prompt) return void 0;
|
|
219
|
+
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
220
|
+
}
|
|
221
|
+
/** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
|
|
222
|
+
start() {
|
|
223
|
+
if (this.#started) return this.#runPromise;
|
|
224
|
+
this.#started = true;
|
|
225
|
+
if (this.#config.prompt) this.sendMessage(this.#config.prompt);
|
|
226
|
+
this.#runPromise = this.#run();
|
|
227
|
+
return this.#runPromise;
|
|
228
|
+
}
|
|
229
|
+
/** Queue a user message for the session (starts the next turn when idle). */
|
|
230
|
+
sendMessage(text) {
|
|
231
|
+
if (this.#closed) throw new Error("session is closed");
|
|
232
|
+
this.#input.push({
|
|
233
|
+
type: "user",
|
|
234
|
+
message: {
|
|
235
|
+
role: "user",
|
|
236
|
+
content: text
|
|
237
|
+
},
|
|
238
|
+
parent_tool_use_id: null,
|
|
239
|
+
session_id: this.#sdkSessionId
|
|
240
|
+
});
|
|
241
|
+
this.#emit({
|
|
242
|
+
type: "user_message",
|
|
243
|
+
message: {
|
|
244
|
+
role: "user",
|
|
245
|
+
content: text
|
|
246
|
+
},
|
|
247
|
+
parentToolUseId: null,
|
|
248
|
+
uuid: randomUUID()
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
|
|
252
|
+
resolvePermission(requestId, decision) {
|
|
253
|
+
const pending = this.#pending.get(requestId);
|
|
254
|
+
if (!pending) return false;
|
|
255
|
+
this.#settleApproval(requestId, pending, decision, "client");
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
async interrupt() {
|
|
259
|
+
await this.#query?.interrupt();
|
|
260
|
+
}
|
|
261
|
+
async setPermissionMode(mode) {
|
|
262
|
+
await this.#query?.setPermissionMode(mode);
|
|
263
|
+
this.#permissionMode = mode;
|
|
264
|
+
this.#emit({
|
|
265
|
+
type: "permission_mode_changed",
|
|
266
|
+
mode
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Switch the model for subsequent responses; undefined = back to the default. */
|
|
270
|
+
async setModel(model) {
|
|
271
|
+
await this.#query?.setModel(model);
|
|
272
|
+
this.#model = model;
|
|
273
|
+
this.#emit({
|
|
274
|
+
type: "model_changed",
|
|
275
|
+
model
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** Emit a session_error and terminate. For host-enforced policy (e.g. requireApiKey). */
|
|
279
|
+
fail(message) {
|
|
280
|
+
if (this.#closed) return;
|
|
281
|
+
this.#emit({
|
|
282
|
+
type: "session_error",
|
|
283
|
+
message
|
|
284
|
+
});
|
|
285
|
+
this.#setStatus("failed");
|
|
286
|
+
this.close("error");
|
|
287
|
+
}
|
|
288
|
+
/** Terminate the session and the underlying CLI subprocess. */
|
|
289
|
+
close(reason = "client") {
|
|
290
|
+
if (this.#closed) return;
|
|
291
|
+
this.#closed = true;
|
|
292
|
+
for (const [id, pending] of this.#pending) this.#settleApproval(id, pending, {
|
|
293
|
+
behavior: "deny",
|
|
294
|
+
message: "Session closed"
|
|
295
|
+
}, "policy");
|
|
296
|
+
this.#input.end();
|
|
297
|
+
this.#query?.close();
|
|
298
|
+
this.#emit({
|
|
299
|
+
type: "session_closed",
|
|
300
|
+
reason
|
|
301
|
+
});
|
|
302
|
+
this.#setStatus("closed");
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Replay buffered events with seq > afterSeq, then deliver live events.
|
|
306
|
+
* Returns an unsubscribe function.
|
|
307
|
+
*/
|
|
308
|
+
subscribe(listener, afterSeq = 0) {
|
|
309
|
+
for (const event of this.#events) if (event.seq > afterSeq) listener(event);
|
|
310
|
+
this.#listeners.add(listener);
|
|
311
|
+
return () => this.#listeners.delete(listener);
|
|
312
|
+
}
|
|
313
|
+
async #run() {
|
|
314
|
+
const queryFn = this.#config.queryFn ?? query;
|
|
315
|
+
try {
|
|
316
|
+
await this.#backfillHistory();
|
|
317
|
+
if (this.#closed) return;
|
|
318
|
+
this.#query = queryFn({
|
|
319
|
+
prompt: this.#input,
|
|
320
|
+
options: this.#buildOptions()
|
|
321
|
+
});
|
|
322
|
+
if (!this.#config.prompt) {
|
|
323
|
+
this.#setStatus("idle");
|
|
324
|
+
this.#fetchCapabilities();
|
|
325
|
+
this.#fetchContextUsage();
|
|
326
|
+
}
|
|
327
|
+
for await (const message of this.#query) this.#handleMessage(message);
|
|
328
|
+
if (!this.#closed) {
|
|
329
|
+
this.#closed = true;
|
|
330
|
+
this.#input.end();
|
|
331
|
+
this.#emit({
|
|
332
|
+
type: "session_closed",
|
|
333
|
+
reason: "server"
|
|
334
|
+
});
|
|
335
|
+
this.#setStatus("closed");
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (!this.#closed) {
|
|
339
|
+
this.#emit({
|
|
340
|
+
type: "session_error",
|
|
341
|
+
message: error instanceof Error ? error.message : String(error)
|
|
342
|
+
});
|
|
343
|
+
this.#setStatus("failed");
|
|
344
|
+
this.close("error");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* On resume, emit the prior session's transcript as replay events (seq'd before any
|
|
350
|
+
* live event). The SDK only re-streams *user* messages on resume; assistant history
|
|
351
|
+
* would otherwise be lost to clients attaching after a server restart. Duplicated
|
|
352
|
+
* user messages are deduped client-side by uuid.
|
|
353
|
+
*/
|
|
354
|
+
async #backfillHistory() {
|
|
355
|
+
const c = this.#config;
|
|
356
|
+
if (!c.resume || c.backfillHistory === false) return;
|
|
357
|
+
const historyFn = c.historyFn ?? ((sessionId, options) => getSessionMessages(sessionId, options));
|
|
358
|
+
let messages;
|
|
359
|
+
try {
|
|
360
|
+
messages = await historyFn(c.resume, { dir: c.cwd });
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
for (const m of messages) {
|
|
365
|
+
if (this.#closed) return;
|
|
366
|
+
if (m.type === "user") this.#emit({
|
|
367
|
+
type: "user_message",
|
|
368
|
+
message: toApiMessage(m.message),
|
|
369
|
+
parentToolUseId: m.parent_tool_use_id,
|
|
370
|
+
replay: true,
|
|
371
|
+
uuid: m.uuid
|
|
372
|
+
});
|
|
373
|
+
else if (m.type === "assistant") this.#emit({
|
|
374
|
+
type: "assistant_message",
|
|
375
|
+
message: toApiMessage(m.message),
|
|
376
|
+
parentToolUseId: m.parent_tool_use_id,
|
|
377
|
+
replay: true,
|
|
378
|
+
uuid: m.uuid
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
#buildOptions() {
|
|
383
|
+
const c = this.#config;
|
|
384
|
+
return {
|
|
385
|
+
cwd: c.cwd,
|
|
386
|
+
permissionMode: c.permissionMode,
|
|
387
|
+
allowedTools: c.allowedTools,
|
|
388
|
+
disallowedTools: c.disallowedTools,
|
|
389
|
+
mcpServers: c.mcpServers,
|
|
390
|
+
settingSources: c.settingSources,
|
|
391
|
+
model: c.model,
|
|
392
|
+
maxTurns: c.maxTurns,
|
|
393
|
+
maxBudgetUsd: c.maxBudgetUsd,
|
|
394
|
+
resume: c.resume,
|
|
395
|
+
forkSession: c.forkSession,
|
|
396
|
+
includePartialMessages: c.includePartialMessages ?? true,
|
|
397
|
+
canUseTool: this.#canUseTool,
|
|
398
|
+
env: c.env,
|
|
399
|
+
pathToClaudeCodeExecutable: c.pathToClaudeCodeExecutable,
|
|
400
|
+
...c.permissionMode === "bypassPermissions" || c.allowDangerouslySkipPermissions ? { allowDangerouslySkipPermissions: true } : {},
|
|
401
|
+
...c.extraOptions
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
#handleMessage(msg) {
|
|
405
|
+
if (msg.type === "system" && msg.subtype === "init") {
|
|
406
|
+
this.#sdkSessionId = msg.session_id;
|
|
407
|
+
this.#model = msg.model;
|
|
408
|
+
this.#permissionMode = msg.permissionMode;
|
|
409
|
+
this.#apiKeySource = msg.apiKeySource;
|
|
410
|
+
this.#emit({
|
|
411
|
+
type: "system_init",
|
|
412
|
+
sdkSessionId: msg.session_id,
|
|
413
|
+
model: msg.model,
|
|
414
|
+
cwd: msg.cwd,
|
|
415
|
+
apiKeySource: msg.apiKeySource,
|
|
416
|
+
tools: msg.tools,
|
|
417
|
+
skills: msg.skills,
|
|
418
|
+
slashCommands: msg.slash_commands,
|
|
419
|
+
permissionMode: msg.permissionMode,
|
|
420
|
+
claudeCodeVersion: msg.claude_code_version,
|
|
421
|
+
mcpServers: msg.mcp_servers
|
|
422
|
+
});
|
|
423
|
+
this.#setStatus("running");
|
|
424
|
+
this.#fetchCapabilities();
|
|
425
|
+
this.#fetchContextUsage();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (msg.type === "system" && msg.subtype === "session_state_changed") {
|
|
429
|
+
if (this.#pending.size > 0) return;
|
|
430
|
+
if (msg.state === "idle") this.#setStatus("idle");
|
|
431
|
+
else if (msg.state === "running") this.#setStatus("running");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const body = normalizeSdkMessage(msg);
|
|
435
|
+
if (body) {
|
|
436
|
+
this.#emit(body);
|
|
437
|
+
if (body.type === "turn_result") {
|
|
438
|
+
this.#totalCostUsd = body.totalCostUsd;
|
|
439
|
+
this.#numTurns = body.numTurns;
|
|
440
|
+
if (this.#pending.size === 0) this.#setStatus("idle");
|
|
441
|
+
this.#fetchContextUsage();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Ask the CLI what models/commands it supports and surface them as an event
|
|
446
|
+
* (replayed to late attachers). Called eagerly for promptless sessions and again
|
|
447
|
+
* on init — the flag keeps it a single emit. Optional-chained: injected fake
|
|
448
|
+
* queries in tests may not implement these, and a failure must not affect the
|
|
449
|
+
* session. */
|
|
450
|
+
async #fetchCapabilities() {
|
|
451
|
+
if (this.#capabilitiesEmitted) return;
|
|
452
|
+
const query = this.#query;
|
|
453
|
+
if (typeof query?.supportedModels !== "function" || typeof query.supportedCommands !== "function") return;
|
|
454
|
+
try {
|
|
455
|
+
const [models, commands] = await Promise.all([query.supportedModels(), query.supportedCommands()]);
|
|
456
|
+
if (this.#closed || this.#capabilitiesEmitted) return;
|
|
457
|
+
this.#capabilitiesEmitted = true;
|
|
458
|
+
this.#emit({
|
|
459
|
+
type: "capabilities",
|
|
460
|
+
models: models.map((m) => ({
|
|
461
|
+
value: m.value,
|
|
462
|
+
displayName: m.displayName,
|
|
463
|
+
description: m.description
|
|
464
|
+
})),
|
|
465
|
+
commands: commands.map((c) => ({
|
|
466
|
+
name: c.name,
|
|
467
|
+
description: c.description,
|
|
468
|
+
argumentHint: c.argumentHint,
|
|
469
|
+
aliases: c.aliases
|
|
470
|
+
}))
|
|
471
|
+
});
|
|
472
|
+
} catch {}
|
|
473
|
+
}
|
|
474
|
+
/** Snapshot the context window after a turn and surface it as an event. Optional-chained
|
|
475
|
+
* and best-effort for the same reasons as #fetchCapabilities. */
|
|
476
|
+
async #fetchContextUsage() {
|
|
477
|
+
const query = this.#query;
|
|
478
|
+
if (typeof query?.getContextUsage !== "function") return;
|
|
479
|
+
try {
|
|
480
|
+
const usage = await query.getContextUsage();
|
|
481
|
+
if (this.#closed) return;
|
|
482
|
+
this.#emit({
|
|
483
|
+
type: "context_usage",
|
|
484
|
+
usage: {
|
|
485
|
+
categories: usage.categories.map((c) => ({
|
|
486
|
+
name: c.name,
|
|
487
|
+
tokens: c.tokens,
|
|
488
|
+
color: c.color
|
|
489
|
+
})),
|
|
490
|
+
totalTokens: usage.totalTokens,
|
|
491
|
+
maxTokens: usage.maxTokens,
|
|
492
|
+
percentage: usage.percentage,
|
|
493
|
+
model: usage.model
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
} catch {}
|
|
497
|
+
}
|
|
498
|
+
#canUseTool = (toolName, input, options) => {
|
|
499
|
+
const id = randomUUID();
|
|
500
|
+
const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
501
|
+
const request = {
|
|
502
|
+
id,
|
|
503
|
+
toolName,
|
|
504
|
+
input,
|
|
505
|
+
toolUseId: options.toolUseID,
|
|
506
|
+
title: options.title,
|
|
507
|
+
displayName: options.displayName,
|
|
508
|
+
description: options.description,
|
|
509
|
+
decisionReason: options.decisionReason,
|
|
510
|
+
agentId: options.agentID,
|
|
511
|
+
expiresAt: Date.now() + timeoutMs
|
|
512
|
+
};
|
|
513
|
+
const questionBehavior = this.#config.questionBehavior ?? "ask";
|
|
514
|
+
if (toolName === "AskUserQuestion" && questionBehavior !== "ask") {
|
|
515
|
+
delete request.expiresAt;
|
|
516
|
+
return Promise.resolve(this.#resolveQuestionByPolicy(request, questionBehavior));
|
|
517
|
+
}
|
|
518
|
+
return new Promise((resolve) => {
|
|
519
|
+
const timer = setTimeout(() => {
|
|
520
|
+
const pending = this.#pending.get(id);
|
|
521
|
+
if (pending) this.#settleApproval(id, pending, {
|
|
522
|
+
behavior: "deny",
|
|
523
|
+
message: "Approval timed out"
|
|
524
|
+
}, "timeout");
|
|
525
|
+
}, timeoutMs);
|
|
526
|
+
this.#pending.set(id, {
|
|
527
|
+
request,
|
|
528
|
+
resolve,
|
|
529
|
+
timer
|
|
530
|
+
});
|
|
531
|
+
options.signal.addEventListener("abort", () => {
|
|
532
|
+
const pending = this.#pending.get(id);
|
|
533
|
+
if (pending) this.#settleApproval(id, pending, {
|
|
534
|
+
behavior: "deny",
|
|
535
|
+
message: "Turn aborted"
|
|
536
|
+
}, "policy");
|
|
537
|
+
});
|
|
538
|
+
this.#emit({
|
|
539
|
+
type: "permission_requested",
|
|
540
|
+
request
|
|
541
|
+
});
|
|
542
|
+
this.#setStatus("awaiting_approval");
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
/** 'auto'/'deny' sessions settle AskUserQuestion synchronously instead of pending:
|
|
546
|
+
* 'auto' picks each question's first (recommended) option, 'deny' sends the model
|
|
547
|
+
* back to decide for itself. Request/resolved events still fire so transcripts and
|
|
548
|
+
* job webhooks show what was chosen. */
|
|
549
|
+
#resolveQuestionByPolicy(request, mode) {
|
|
550
|
+
this.#emit({
|
|
551
|
+
type: "permission_requested",
|
|
552
|
+
request
|
|
553
|
+
});
|
|
554
|
+
if (mode === "deny") {
|
|
555
|
+
const message = "Interactive questions are disabled for this session — choose the most reasonable option yourself and continue.";
|
|
556
|
+
this.#emit({
|
|
557
|
+
type: "permission_resolved",
|
|
558
|
+
requestId: request.id,
|
|
559
|
+
behavior: "deny",
|
|
560
|
+
resolvedBy: "policy",
|
|
561
|
+
message
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
behavior: "deny",
|
|
565
|
+
message,
|
|
566
|
+
toolUseID: request.toolUseId
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
this.#emit({
|
|
570
|
+
type: "permission_resolved",
|
|
571
|
+
requestId: request.id,
|
|
572
|
+
behavior: "allow",
|
|
573
|
+
resolvedBy: "policy"
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
behavior: "allow",
|
|
577
|
+
updatedInput: {
|
|
578
|
+
...request.input,
|
|
579
|
+
answers: recommendedAnswers(request.input)
|
|
580
|
+
},
|
|
581
|
+
toolUseID: request.toolUseId
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
#settleApproval(id, pending, decision, resolvedBy) {
|
|
585
|
+
clearTimeout(pending.timer);
|
|
586
|
+
this.#pending.delete(id);
|
|
587
|
+
if (decision.behavior === "allow") pending.resolve({
|
|
588
|
+
behavior: "allow",
|
|
589
|
+
updatedInput: decision.updatedInput ?? pending.request.input,
|
|
590
|
+
toolUseID: pending.request.toolUseId
|
|
591
|
+
});
|
|
592
|
+
else pending.resolve({
|
|
593
|
+
behavior: "deny",
|
|
594
|
+
message: decision.message ?? "Denied",
|
|
595
|
+
interrupt: decision.interrupt,
|
|
596
|
+
toolUseID: pending.request.toolUseId
|
|
597
|
+
});
|
|
598
|
+
this.#emit({
|
|
599
|
+
type: "permission_resolved",
|
|
600
|
+
requestId: id,
|
|
601
|
+
behavior: decision.behavior,
|
|
602
|
+
resolvedBy,
|
|
603
|
+
message: decision.behavior === "deny" ? decision.message ?? "Denied" : void 0
|
|
604
|
+
});
|
|
605
|
+
if (this.#pending.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
|
|
606
|
+
}
|
|
607
|
+
#setStatus(status, detail) {
|
|
608
|
+
if (this.#status === status && this.#statusDetail === detail) return;
|
|
609
|
+
if (this.#status === "closed" || this.#status === "failed") return;
|
|
610
|
+
this.#status = status;
|
|
611
|
+
this.#statusDetail = detail;
|
|
612
|
+
this.#emit({
|
|
613
|
+
type: "status_changed",
|
|
614
|
+
status,
|
|
615
|
+
detail
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
#emit(body) {
|
|
619
|
+
const event = {
|
|
620
|
+
...body,
|
|
621
|
+
seq: ++this.#seq,
|
|
622
|
+
ts: Date.now()
|
|
623
|
+
};
|
|
624
|
+
this.#lastActivityAt = event.ts;
|
|
625
|
+
this.#events.push(event);
|
|
626
|
+
for (const listener of this.#listeners) try {
|
|
627
|
+
listener(event);
|
|
628
|
+
} catch {}
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
/** Answer each AskUserQuestion question with its first option's label — the tool's
|
|
632
|
+
* convention puts the recommended choice first. Keyed by question text, the shape the
|
|
633
|
+
* CLI expects back in `updatedInput.answers`. */
|
|
634
|
+
function recommendedAnswers(input) {
|
|
635
|
+
const answers = {};
|
|
636
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
637
|
+
for (const entry of questions) {
|
|
638
|
+
const q = entry;
|
|
639
|
+
if (typeof q.question !== "string" || !Array.isArray(q.options)) continue;
|
|
640
|
+
const first = q.options[0];
|
|
641
|
+
if (typeof first?.label === "string") answers[q.question] = first.label;
|
|
642
|
+
}
|
|
643
|
+
return answers;
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/ai-sdk-runner.ts
|
|
647
|
+
/** Permission modes this engine can honor. The rest of the protocol vocabulary
|
|
648
|
+
* (acceptEdits/plan/auto) is Claude Code CLI semantics with no meaning here —
|
|
649
|
+
* setPermissionMode rejects them, which the server surfaces as protocol_error. */
|
|
650
|
+
const SUPPORTED_PERMISSION_MODES = [
|
|
651
|
+
"default",
|
|
652
|
+
"bypassPermissions",
|
|
653
|
+
"dontAsk"
|
|
654
|
+
];
|
|
655
|
+
/**
|
|
656
|
+
* Model-agnostic runner over the AI SDK v7 ToolLoopAgent. The session's durable
|
|
657
|
+
* state is its ModelMessage history: every turn — including continuation after an
|
|
658
|
+
* externally-executed tool call — is a fresh streamed call over that history
|
|
659
|
+
* (message-state replay; the loop cannot be suspended). Output is emitted as it
|
|
660
|
+
* happens: `stream_delta` per token (unless includePartialMessages is false) and
|
|
661
|
+
* assistant/tool messages per step. Emits the same seq-numbered SessionEvent log
|
|
662
|
+
* as SessionRunner; engine-specific CLI telemetry (system_init, capabilities,
|
|
663
|
+
* rate_limit, ...) is simply never emitted.
|
|
664
|
+
*/
|
|
665
|
+
var AiSdkRunner = class {
|
|
666
|
+
id;
|
|
667
|
+
createdAt;
|
|
668
|
+
#config;
|
|
669
|
+
#model;
|
|
670
|
+
#events = [];
|
|
671
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
672
|
+
#seq = 0;
|
|
673
|
+
#status = "starting";
|
|
674
|
+
#permissionMode;
|
|
675
|
+
#messages = [];
|
|
676
|
+
#pendingToolCalls = /* @__PURE__ */ new Map();
|
|
677
|
+
/** Calls already handed to the executor, so a re-park never double-dispatches. */
|
|
678
|
+
#dispatched = /* @__PURE__ */ new Set();
|
|
679
|
+
#turnChain = Promise.resolve();
|
|
680
|
+
#abort;
|
|
681
|
+
/** Accumulates across every leg of one turn. A turn that parks on external
|
|
682
|
+
* tool calls spans several generate() calls; usage and elapsed time must
|
|
683
|
+
* cover all of them, not just the leg that happens to finish. */
|
|
684
|
+
#turnAccum;
|
|
685
|
+
#numTurns = 0;
|
|
686
|
+
#totalUsage = {
|
|
687
|
+
input: 0,
|
|
688
|
+
output: 0,
|
|
689
|
+
cacheWrite: 0,
|
|
690
|
+
cacheRead: 0
|
|
691
|
+
};
|
|
692
|
+
#lastActivityAt;
|
|
693
|
+
#started = false;
|
|
694
|
+
#closed = false;
|
|
695
|
+
/** Parked: state has been snapshotted and this instance is inert. Not closed —
|
|
696
|
+
* the session lives on in the snapshot and resumes as a new instance. */
|
|
697
|
+
#parked = false;
|
|
698
|
+
/** Model alias as requested (not the resolved provider id) — what set_model was
|
|
699
|
+
* given, so a rehydrated session can re-resolve the same choice. */
|
|
700
|
+
#modelAlias;
|
|
701
|
+
constructor(config, id = randomUUID()) {
|
|
702
|
+
const mode = config.permissionMode ?? "default";
|
|
703
|
+
if (!SUPPORTED_PERMISSION_MODES.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the AI SDK engine`);
|
|
704
|
+
this.#config = config;
|
|
705
|
+
this.#model = config.languageModel;
|
|
706
|
+
this.#permissionMode = mode;
|
|
707
|
+
this.#modelAlias = config.model;
|
|
708
|
+
this.id = config.restore?.id ?? id;
|
|
709
|
+
this.createdAt = config.restore?.createdAt ?? Date.now();
|
|
710
|
+
if (config.restore) this.#restore(config.restore);
|
|
711
|
+
}
|
|
712
|
+
/** Adopt a parked session's state. The event log and seq counter come back
|
|
713
|
+
* verbatim: a client reattaching with `afterSeq` must see one unbroken stream
|
|
714
|
+
* across the teardown, not a second session that restarts at 1. */
|
|
715
|
+
#restore(snapshot) {
|
|
716
|
+
if (snapshot.engine !== "provider") throw new Error(`cannot restore a '${snapshot.engine}' snapshot into the AI SDK engine`);
|
|
717
|
+
const state = snapshot.state;
|
|
718
|
+
if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
|
|
719
|
+
this.#seq = snapshot.seq;
|
|
720
|
+
this.#events = [...snapshot.events];
|
|
721
|
+
this.#messages = [...state.messages];
|
|
722
|
+
for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
|
|
723
|
+
this.#dispatched = new Set(state.dispatched);
|
|
724
|
+
this.#numTurns = state.numTurns;
|
|
725
|
+
this.#totalUsage = { ...state.totalUsage };
|
|
726
|
+
this.#turnAccum = state.turnAccum ? { ...state.turnAccum } : void 0;
|
|
727
|
+
if (this.#turnAccum && state.parkedAt !== void 0) this.#turnAccum.startedAt += Date.now() - state.parkedAt;
|
|
728
|
+
this.#permissionMode = state.permissionMode;
|
|
729
|
+
this.#lastActivityAt = state.lastActivityAt;
|
|
730
|
+
this.#status = this.#pendingToolCalls.size > 0 ? "parked" : "idle";
|
|
731
|
+
if (state.model !== void 0 && state.model !== this.#modelAlias && this.#config.resolveModel) {
|
|
732
|
+
this.#modelAlias = state.model;
|
|
733
|
+
this.#model = this.#config.resolveModel(state.model);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
get status() {
|
|
737
|
+
return this.#status;
|
|
738
|
+
}
|
|
739
|
+
get lastSeq() {
|
|
740
|
+
return this.#seq;
|
|
741
|
+
}
|
|
742
|
+
/** The session's durable state — persist to park, replay to rehydrate. */
|
|
743
|
+
get messages() {
|
|
744
|
+
return [...this.#messages];
|
|
745
|
+
}
|
|
746
|
+
/** External tool calls the loop is currently parked on. */
|
|
747
|
+
get pendingToolCalls() {
|
|
748
|
+
return [...this.#pendingToolCalls.values()];
|
|
749
|
+
}
|
|
750
|
+
get pendingApprovals() {
|
|
751
|
+
return [];
|
|
752
|
+
}
|
|
753
|
+
/** The session's scratch filesystem (see Runner.vfs) — the server's file
|
|
754
|
+
* routes serve deliverables straight from it. */
|
|
755
|
+
get vfs() {
|
|
756
|
+
return this.#config.vfs;
|
|
757
|
+
}
|
|
758
|
+
info() {
|
|
759
|
+
return {
|
|
760
|
+
id: this.id,
|
|
761
|
+
status: this.#status,
|
|
762
|
+
cwd: this.#config.cwd ?? process.cwd(),
|
|
763
|
+
profile: this.#config.profile,
|
|
764
|
+
engine: "provider",
|
|
765
|
+
model: this.#modelId(),
|
|
766
|
+
permissionMode: this.#permissionMode,
|
|
767
|
+
createdAt: this.createdAt,
|
|
768
|
+
lastSeq: this.#seq,
|
|
769
|
+
pendingPermissionCount: 0,
|
|
770
|
+
meta: this.#config.meta,
|
|
771
|
+
title: this.#title(),
|
|
772
|
+
numTurns: this.#numTurns || void 0,
|
|
773
|
+
lastActivityAt: this.#lastActivityAt
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
start() {
|
|
777
|
+
if (this.#started) return this.#turnChain;
|
|
778
|
+
this.#started = true;
|
|
779
|
+
if (this.#config.restore) {
|
|
780
|
+
if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
|
|
781
|
+
return this.#turnChain;
|
|
782
|
+
}
|
|
783
|
+
this.#setStatus("idle");
|
|
784
|
+
if (this.#config.prompt) this.sendMessage(this.#config.prompt);
|
|
785
|
+
return this.#turnChain;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Snapshot durable state, release engine resources, and go inert — the session
|
|
789
|
+
* continues in the snapshot, not in this object. Returns undefined when parking
|
|
790
|
+
* would lose work or has nothing to wait for: a turn in flight, no parked call,
|
|
791
|
+
* or an already-closed/parked runner.
|
|
792
|
+
*/
|
|
793
|
+
park() {
|
|
794
|
+
if (this.#closed || this.#parked) return void 0;
|
|
795
|
+
if (this.#abort || !this.#restingOnDeferred()) return void 0;
|
|
796
|
+
this.#setStatus("parked");
|
|
797
|
+
const parked = [...this.#pendingToolCalls.values()].map((call) => ({
|
|
798
|
+
executionId: call.toolCallId,
|
|
799
|
+
toolName: call.toolName,
|
|
800
|
+
expiresAt: call.expiresAt
|
|
801
|
+
}));
|
|
802
|
+
const state = {
|
|
803
|
+
messages: this.#messages,
|
|
804
|
+
pendingToolCalls: [...this.#pendingToolCalls.values()],
|
|
805
|
+
dispatched: [...this.#dispatched],
|
|
806
|
+
numTurns: this.#numTurns,
|
|
807
|
+
totalUsage: { ...this.#totalUsage },
|
|
808
|
+
turnAccum: this.#turnAccum ? { ...this.#turnAccum } : void 0,
|
|
809
|
+
permissionMode: this.#permissionMode,
|
|
810
|
+
model: this.#modelAlias,
|
|
811
|
+
lastActivityAt: this.#lastActivityAt,
|
|
812
|
+
parkedAt: Date.now()
|
|
813
|
+
};
|
|
814
|
+
const snapshot = {
|
|
815
|
+
engine: "provider",
|
|
816
|
+
id: this.id,
|
|
817
|
+
createdAt: this.createdAt,
|
|
818
|
+
seq: this.#seq,
|
|
819
|
+
events: [...this.#events],
|
|
820
|
+
vfs: this.#config.vfs?.snapshot(),
|
|
821
|
+
parked,
|
|
822
|
+
state
|
|
823
|
+
};
|
|
824
|
+
this.#parked = true;
|
|
825
|
+
this.#listeners.clear();
|
|
826
|
+
try {
|
|
827
|
+
Promise.resolve(this.#config.onClose?.()).catch(() => {});
|
|
828
|
+
} catch {}
|
|
829
|
+
return snapshot;
|
|
830
|
+
}
|
|
831
|
+
sendMessage(text) {
|
|
832
|
+
if (this.#parked) throw new Error("session is parked");
|
|
833
|
+
if (this.#closed) throw new Error("session is closed");
|
|
834
|
+
this.#messages.push({
|
|
835
|
+
role: "user",
|
|
836
|
+
content: text
|
|
837
|
+
});
|
|
838
|
+
this.#emit({
|
|
839
|
+
type: "user_message",
|
|
840
|
+
message: {
|
|
841
|
+
role: "user",
|
|
842
|
+
content: text
|
|
843
|
+
},
|
|
844
|
+
parentToolUseId: null,
|
|
845
|
+
uuid: randomUUID()
|
|
846
|
+
});
|
|
847
|
+
this.#scheduleTurn();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Deliver the result of an external (execute-less) tool call. Appends the
|
|
851
|
+
* tool-result message and, once no calls remain pending, re-enters the loop.
|
|
852
|
+
* Idempotent per toolCallId: unknown/already-settled ids return false.
|
|
853
|
+
*/
|
|
854
|
+
resolveToolCall(toolCallId, output, options) {
|
|
855
|
+
if (!this.#settlePendingCall(toolCallId, output, options?.isError === true)) return false;
|
|
856
|
+
if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
/** Record a parked call's outcome into the message history (so it stays
|
|
860
|
+
* replayable — a dangling tool call without a result is invalid input for
|
|
861
|
+
* providers) and the event log. Does NOT re-enter the loop. */
|
|
862
|
+
#settlePendingCall(toolCallId, output, isError) {
|
|
863
|
+
const pending = this.#pendingToolCalls.get(toolCallId);
|
|
864
|
+
if (!pending || this.#closed || this.#parked) return false;
|
|
865
|
+
this.#pendingToolCalls.delete(toolCallId);
|
|
866
|
+
let insertAt = this.#messages.length;
|
|
867
|
+
while (insertAt > 0 && this.#messages[insertAt - 1].role === "user") insertAt--;
|
|
868
|
+
this.#messages.splice(insertAt, 0, {
|
|
869
|
+
role: "tool",
|
|
870
|
+
content: [{
|
|
871
|
+
type: "tool-result",
|
|
872
|
+
toolCallId,
|
|
873
|
+
toolName: pending.toolName,
|
|
874
|
+
output: isError ? {
|
|
875
|
+
type: "error-text",
|
|
876
|
+
value: textValue(output)
|
|
877
|
+
} : output
|
|
878
|
+
}]
|
|
879
|
+
});
|
|
880
|
+
this.#emit({
|
|
881
|
+
type: "user_message",
|
|
882
|
+
message: {
|
|
883
|
+
role: "user",
|
|
884
|
+
content: [{
|
|
885
|
+
type: "tool_result",
|
|
886
|
+
tool_use_id: toolCallId,
|
|
887
|
+
content: textValue(output),
|
|
888
|
+
is_error: isError || void 0
|
|
889
|
+
}]
|
|
890
|
+
},
|
|
891
|
+
parentToolUseId: null,
|
|
892
|
+
synthetic: true,
|
|
893
|
+
uuid: randomUUID()
|
|
894
|
+
});
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
resolvePermission(_requestId, _decision) {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
/** Emit `file_delivered` — the deliver_file tool's hand-over event (wired by
|
|
901
|
+
* createEngineSession via ToolContextOptions.onFileDelivered). */
|
|
902
|
+
emitFileDelivered(file) {
|
|
903
|
+
if (this.#closed || this.#parked) return;
|
|
904
|
+
this.#emit({
|
|
905
|
+
type: "file_delivered",
|
|
906
|
+
...file
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* One plain generateText over the session's current model, billed into the
|
|
911
|
+
* running turn's usage accumulator — the web_fetch digest pass uses this so
|
|
912
|
+
* its tokens are never lost from the turn's accounting.
|
|
913
|
+
*/
|
|
914
|
+
async generateDigest(prompt) {
|
|
915
|
+
const result = await generateText({
|
|
916
|
+
model: this.#model,
|
|
917
|
+
prompt,
|
|
918
|
+
abortSignal: this.#abort?.signal
|
|
919
|
+
});
|
|
920
|
+
const accum = this.#turnAccum;
|
|
921
|
+
if (accum) {
|
|
922
|
+
accum.input += result.usage.inputTokens ?? 0;
|
|
923
|
+
accum.output += result.usage.outputTokens ?? 0;
|
|
924
|
+
accum.cacheWrite += result.usage.inputTokenDetails?.cacheWriteTokens ?? 0;
|
|
925
|
+
accum.cacheRead += result.usage.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
926
|
+
}
|
|
927
|
+
return result.text;
|
|
928
|
+
}
|
|
929
|
+
async interrupt() {
|
|
930
|
+
if (this.#abort) this.#abort.abort();
|
|
931
|
+
else if (this.#pendingToolCalls.size > 0) {
|
|
932
|
+
const accum = this.#turnAccum ?? {
|
|
933
|
+
startedAt: Date.now(),
|
|
934
|
+
input: 0,
|
|
935
|
+
output: 0,
|
|
936
|
+
cacheWrite: 0,
|
|
937
|
+
cacheRead: 0
|
|
938
|
+
};
|
|
939
|
+
for (const call of Array.from(this.#pendingToolCalls.values())) this.#settlePendingCall(call.toolCallId, {
|
|
940
|
+
type: "text",
|
|
941
|
+
value: "interrupted"
|
|
942
|
+
}, true);
|
|
943
|
+
this.#dispatched.clear();
|
|
944
|
+
this.#numTurns += 1;
|
|
945
|
+
this.#emit({
|
|
946
|
+
type: "turn_result",
|
|
947
|
+
subtype: "error_during_execution",
|
|
948
|
+
isError: true,
|
|
949
|
+
durationMs: Date.now() - accum.startedAt,
|
|
950
|
+
numTurns: this.#numTurns,
|
|
951
|
+
totalCostUsd: 0,
|
|
952
|
+
errors: ["interrupted"],
|
|
953
|
+
usage: turnUsage(accum)
|
|
954
|
+
});
|
|
955
|
+
this.#turnAccum = void 0;
|
|
956
|
+
this.#setStatus("idle");
|
|
957
|
+
}
|
|
958
|
+
await this.#turnChain;
|
|
959
|
+
}
|
|
960
|
+
async setPermissionMode(mode) {
|
|
961
|
+
if (!SUPPORTED_PERMISSION_MODES.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the AI SDK engine`);
|
|
962
|
+
this.#permissionMode = mode;
|
|
963
|
+
this.#emit({
|
|
964
|
+
type: "permission_mode_changed",
|
|
965
|
+
mode
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
async setModel(model) {
|
|
969
|
+
const resolve = this.#config.resolveModel;
|
|
970
|
+
if (!resolve) throw new Error("set_model is not supported by this session");
|
|
971
|
+
this.#model = resolve(model);
|
|
972
|
+
this.#modelAlias = model;
|
|
973
|
+
this.#emit({
|
|
974
|
+
type: "model_changed",
|
|
975
|
+
model
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
fail(message) {
|
|
979
|
+
if (this.#closed) return;
|
|
980
|
+
this.#emit({
|
|
981
|
+
type: "session_error",
|
|
982
|
+
message
|
|
983
|
+
});
|
|
984
|
+
this.#setStatus("failed");
|
|
985
|
+
this.close("error");
|
|
986
|
+
}
|
|
987
|
+
close(reason = "client") {
|
|
988
|
+
if (this.#closed || this.#parked) return;
|
|
989
|
+
this.#closed = true;
|
|
990
|
+
this.#abort?.abort();
|
|
991
|
+
this.#pendingToolCalls.clear();
|
|
992
|
+
this.#dispatched.clear();
|
|
993
|
+
this.#emit({
|
|
994
|
+
type: "session_closed",
|
|
995
|
+
reason
|
|
996
|
+
});
|
|
997
|
+
this.#setStatus("closed");
|
|
998
|
+
try {
|
|
999
|
+
Promise.resolve(this.#config.onClose?.()).catch(() => {});
|
|
1000
|
+
} catch {}
|
|
1001
|
+
}
|
|
1002
|
+
subscribe(listener, afterSeq = 0) {
|
|
1003
|
+
for (const event of this.#events) if (event.seq > afterSeq) listener(event);
|
|
1004
|
+
this.#listeners.add(listener);
|
|
1005
|
+
return () => this.#listeners.delete(listener);
|
|
1006
|
+
}
|
|
1007
|
+
#scheduleTurn() {
|
|
1008
|
+
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Deliver the result of an execution this runner dispatched. Used by the host
|
|
1012
|
+
* when a backend settled out-of-band (a browser bridge answering later, a
|
|
1013
|
+
* deferred executor). Idempotent by executionId.
|
|
1014
|
+
*/
|
|
1015
|
+
settleExecution(executionId, result) {
|
|
1016
|
+
if (this.#closed || this.#parked) return false;
|
|
1017
|
+
if (!this.#pendingToolCalls.has(executionId)) return false;
|
|
1018
|
+
this.#applyExecutionResult(executionId, result);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
/** Hand every parked call the executor owns to it. */
|
|
1022
|
+
#dispatchPending() {
|
|
1023
|
+
const executor = this.#config.executor;
|
|
1024
|
+
if (!executor) return;
|
|
1025
|
+
const executable = this.#config.executableTools;
|
|
1026
|
+
const inFlight = [];
|
|
1027
|
+
let anyDeferred = false;
|
|
1028
|
+
for (const call of Array.from(this.#pendingToolCalls.values())) {
|
|
1029
|
+
if (executable && !executable.includes(call.toolName)) continue;
|
|
1030
|
+
if (this.#dispatched.has(call.toolCallId)) continue;
|
|
1031
|
+
this.#dispatched.add(call.toolCallId);
|
|
1032
|
+
const toolCall = {
|
|
1033
|
+
executionId: call.toolCallId,
|
|
1034
|
+
sessionId: this.id,
|
|
1035
|
+
tool: call.toolName,
|
|
1036
|
+
input: call.input,
|
|
1037
|
+
vfs: this.#config.vfs,
|
|
1038
|
+
limits: this.#config.executionLimits,
|
|
1039
|
+
signal: this.#abort?.signal
|
|
1040
|
+
};
|
|
1041
|
+
const profile = executor.describe?.(toolCall) ?? {};
|
|
1042
|
+
call.deferred = profile.deferred === true ? true : void 0;
|
|
1043
|
+
call.expiresAt = profile.timeoutMs === void 0 ? void 0 : Date.now() + profile.timeoutMs;
|
|
1044
|
+
anyDeferred ||= call.deferred === true;
|
|
1045
|
+
this.#emit({
|
|
1046
|
+
type: "execution_dispatched",
|
|
1047
|
+
executionId: call.toolCallId,
|
|
1048
|
+
toolName: call.toolName,
|
|
1049
|
+
backend: profile.backend ?? this.#config.executionBackend ?? "server",
|
|
1050
|
+
deferred: call.deferred,
|
|
1051
|
+
expiresAt: call.expiresAt
|
|
1052
|
+
});
|
|
1053
|
+
inFlight.push(executor.dispatch(toolCall).then((dispatch) => {
|
|
1054
|
+
if (dispatch.status === "settled") this.#applyExecutionResult(call.toolCallId, dispatch.result);
|
|
1055
|
+
}).catch((error) => {
|
|
1056
|
+
this.#applyExecutionResult(call.toolCallId, {
|
|
1057
|
+
status: "failed",
|
|
1058
|
+
reason: "dispatch_error",
|
|
1059
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1060
|
+
});
|
|
1061
|
+
}));
|
|
1062
|
+
}
|
|
1063
|
+
if (anyDeferred) Promise.allSettled(inFlight).then(() => this.#announceParked());
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* The turn has come to rest on deferred executions: nothing is in flight, and
|
|
1067
|
+
* only a host-delivered result can move it. `status_changed: 'parked'` is the
|
|
1068
|
+
* host's cue to snapshot via {@link park} — a single, correctly-timed signal
|
|
1069
|
+
* rather than an inference from individual dispatch events.
|
|
1070
|
+
*/
|
|
1071
|
+
#announceParked() {
|
|
1072
|
+
if (this.#closed || this.#parked || this.#abort) return;
|
|
1073
|
+
if (this.#restingOnDeferred()) this.#setStatus("parked");
|
|
1074
|
+
}
|
|
1075
|
+
/** The loop is waiting, and everything it waits on can only be answered from
|
|
1076
|
+
* outside this process. One still-live in-process execution means a result is
|
|
1077
|
+
* coming back to THIS runner, and tearing it down would strand it. */
|
|
1078
|
+
#restingOnDeferred() {
|
|
1079
|
+
if (this.#pendingToolCalls.size === 0) return false;
|
|
1080
|
+
for (const call of this.#pendingToolCalls.values()) if (call.deferred !== true) return false;
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
/** Fold an execution's outcome back into the loop, whichever way it went. */
|
|
1084
|
+
#applyExecutionResult(executionId, result) {
|
|
1085
|
+
if (this.#closed || this.#parked) return;
|
|
1086
|
+
this.#dispatched.delete(executionId);
|
|
1087
|
+
if (result.status === "ok") {
|
|
1088
|
+
this.#emit({
|
|
1089
|
+
type: "execution_result",
|
|
1090
|
+
executionId,
|
|
1091
|
+
output: {
|
|
1092
|
+
type: "json",
|
|
1093
|
+
value: result.output
|
|
1094
|
+
},
|
|
1095
|
+
logs: result.logs
|
|
1096
|
+
});
|
|
1097
|
+
this.resolveToolCall(executionId, {
|
|
1098
|
+
type: "json",
|
|
1099
|
+
value: result.output
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
this.#emit({
|
|
1104
|
+
type: "execution_failed",
|
|
1105
|
+
executionId,
|
|
1106
|
+
reason: result.reason,
|
|
1107
|
+
error: result.error,
|
|
1108
|
+
logs: result.logs
|
|
1109
|
+
});
|
|
1110
|
+
this.resolveToolCall(executionId, {
|
|
1111
|
+
type: "text",
|
|
1112
|
+
value: `${result.reason}: ${result.error}`
|
|
1113
|
+
}, { isError: true });
|
|
1114
|
+
}
|
|
1115
|
+
async #runTurn() {
|
|
1116
|
+
if (this.#closed || this.#parked || this.#pendingToolCalls.size > 0) return;
|
|
1117
|
+
if (this.#messages.at(-1)?.role === "assistant") return;
|
|
1118
|
+
this.#setStatus("running");
|
|
1119
|
+
const agent = new ToolLoopAgent({
|
|
1120
|
+
model: this.#model,
|
|
1121
|
+
tools: this.#config.tools ?? {},
|
|
1122
|
+
instructions: this.#config.instructions,
|
|
1123
|
+
stopWhen: isStepCount(this.#config.maxSteps ?? 20)
|
|
1124
|
+
});
|
|
1125
|
+
const abort = new AbortController();
|
|
1126
|
+
this.#abort = abort;
|
|
1127
|
+
const accum = this.#turnAccum ??= {
|
|
1128
|
+
startedAt: Date.now(),
|
|
1129
|
+
input: 0,
|
|
1130
|
+
output: 0,
|
|
1131
|
+
cacheWrite: 0,
|
|
1132
|
+
cacheRead: 0
|
|
1133
|
+
};
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await agent.stream({
|
|
1136
|
+
messages: [...this.#messages],
|
|
1137
|
+
abortSignal: abort.signal
|
|
1138
|
+
});
|
|
1139
|
+
const partials = this.#config.includePartialMessages !== false;
|
|
1140
|
+
let blocks = [];
|
|
1141
|
+
const textBuf = /* @__PURE__ */ new Map();
|
|
1142
|
+
const reasoningBuf = /* @__PURE__ */ new Map();
|
|
1143
|
+
const flush = () => {
|
|
1144
|
+
if (blocks.length === 0) return;
|
|
1145
|
+
this.#emit({
|
|
1146
|
+
type: "assistant_message",
|
|
1147
|
+
message: {
|
|
1148
|
+
role: "assistant",
|
|
1149
|
+
content: blocks,
|
|
1150
|
+
model: this.#modelId()
|
|
1151
|
+
},
|
|
1152
|
+
parentToolUseId: null,
|
|
1153
|
+
uuid: randomUUID()
|
|
1154
|
+
});
|
|
1155
|
+
blocks = [];
|
|
1156
|
+
};
|
|
1157
|
+
const emitToolResult = (toolCallId, content, isError) => {
|
|
1158
|
+
flush();
|
|
1159
|
+
this.#emit({
|
|
1160
|
+
type: "user_message",
|
|
1161
|
+
message: {
|
|
1162
|
+
role: "user",
|
|
1163
|
+
content: [{
|
|
1164
|
+
type: "tool_result",
|
|
1165
|
+
tool_use_id: toolCallId,
|
|
1166
|
+
content,
|
|
1167
|
+
is_error: isError
|
|
1168
|
+
}]
|
|
1169
|
+
},
|
|
1170
|
+
parentToolUseId: null,
|
|
1171
|
+
synthetic: true,
|
|
1172
|
+
uuid: randomUUID()
|
|
1173
|
+
});
|
|
1174
|
+
};
|
|
1175
|
+
let streamError;
|
|
1176
|
+
for await (const part of result.fullStream) {
|
|
1177
|
+
if (this.#closed) break;
|
|
1178
|
+
switch (part.type) {
|
|
1179
|
+
case "text-delta":
|
|
1180
|
+
textBuf.set(part.id, (textBuf.get(part.id) ?? "") + part.text);
|
|
1181
|
+
if (partials) this.#emit({
|
|
1182
|
+
type: "stream_delta",
|
|
1183
|
+
event: {
|
|
1184
|
+
type: "content_block_delta",
|
|
1185
|
+
delta: {
|
|
1186
|
+
type: "text_delta",
|
|
1187
|
+
text: part.text
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
parentToolUseId: null,
|
|
1191
|
+
uuid: randomUUID()
|
|
1192
|
+
});
|
|
1193
|
+
break;
|
|
1194
|
+
case "text-end": {
|
|
1195
|
+
const text = textBuf.get(part.id);
|
|
1196
|
+
textBuf.delete(part.id);
|
|
1197
|
+
if (text) blocks.push({
|
|
1198
|
+
type: "text",
|
|
1199
|
+
text
|
|
1200
|
+
});
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
case "reasoning-delta":
|
|
1204
|
+
reasoningBuf.set(part.id, (reasoningBuf.get(part.id) ?? "") + part.text);
|
|
1205
|
+
if (partials) this.#emit({
|
|
1206
|
+
type: "stream_delta",
|
|
1207
|
+
event: {
|
|
1208
|
+
type: "content_block_delta",
|
|
1209
|
+
delta: {
|
|
1210
|
+
type: "thinking_delta",
|
|
1211
|
+
thinking: part.text
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
parentToolUseId: null,
|
|
1215
|
+
uuid: randomUUID()
|
|
1216
|
+
});
|
|
1217
|
+
break;
|
|
1218
|
+
case "reasoning-end": {
|
|
1219
|
+
const thinking = reasoningBuf.get(part.id);
|
|
1220
|
+
reasoningBuf.delete(part.id);
|
|
1221
|
+
if (thinking) blocks.push({
|
|
1222
|
+
type: "thinking",
|
|
1223
|
+
thinking
|
|
1224
|
+
});
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
case "tool-call":
|
|
1228
|
+
blocks.push({
|
|
1229
|
+
type: "tool_use",
|
|
1230
|
+
id: part.toolCallId,
|
|
1231
|
+
name: part.toolName,
|
|
1232
|
+
input: part.input
|
|
1233
|
+
});
|
|
1234
|
+
flush();
|
|
1235
|
+
break;
|
|
1236
|
+
case "tool-result":
|
|
1237
|
+
emitToolResult(part.toolCallId, typeof part.output === "string" ? part.output : JSON.stringify(part.output));
|
|
1238
|
+
break;
|
|
1239
|
+
case "tool-error":
|
|
1240
|
+
emitToolResult(part.toolCallId, errorText(part.error), true);
|
|
1241
|
+
break;
|
|
1242
|
+
case "finish-step":
|
|
1243
|
+
flush();
|
|
1244
|
+
break;
|
|
1245
|
+
case "error":
|
|
1246
|
+
streamError ??= part.error;
|
|
1247
|
+
break;
|
|
1248
|
+
default: break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
flush();
|
|
1252
|
+
if (streamError !== void 0) throw streamError;
|
|
1253
|
+
if (abort.signal.aborted) throw new Error("interrupted");
|
|
1254
|
+
const [responseMessages, usage, toolCalls, text] = await Promise.all([
|
|
1255
|
+
result.responseMessages,
|
|
1256
|
+
result.totalUsage,
|
|
1257
|
+
result.toolCalls,
|
|
1258
|
+
result.text
|
|
1259
|
+
]);
|
|
1260
|
+
if (this.#closed) return;
|
|
1261
|
+
accum.input += usage.inputTokens ?? 0;
|
|
1262
|
+
accum.output += usage.outputTokens ?? 0;
|
|
1263
|
+
accum.cacheWrite += usage.inputTokenDetails?.cacheWriteTokens ?? 0;
|
|
1264
|
+
accum.cacheRead += usage.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
1265
|
+
this.#messages.push(...responseMessages);
|
|
1266
|
+
const settled = /* @__PURE__ */ new Set();
|
|
1267
|
+
for (const message of responseMessages) {
|
|
1268
|
+
if (message.role !== "tool" || !Array.isArray(message.content)) continue;
|
|
1269
|
+
for (const part of message.content) if (part.type === "tool-result") settled.add(part.toolCallId);
|
|
1270
|
+
}
|
|
1271
|
+
for (const call of toolCalls) {
|
|
1272
|
+
if (settled.has(call.toolCallId)) continue;
|
|
1273
|
+
this.#pendingToolCalls.set(call.toolCallId, {
|
|
1274
|
+
toolCallId: call.toolCallId,
|
|
1275
|
+
toolName: call.toolName,
|
|
1276
|
+
input: call.input
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
if (this.#pendingToolCalls.size > 0) {
|
|
1280
|
+
this.#dispatchPending();
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
this.#finishTurn(text);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
if (this.#closed) return;
|
|
1286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1287
|
+
this.#numTurns += 1;
|
|
1288
|
+
this.#emit({
|
|
1289
|
+
type: "turn_result",
|
|
1290
|
+
subtype: "error_during_execution",
|
|
1291
|
+
isError: true,
|
|
1292
|
+
durationMs: Date.now() - accum.startedAt,
|
|
1293
|
+
numTurns: this.#numTurns,
|
|
1294
|
+
totalCostUsd: 0,
|
|
1295
|
+
errors: [abort.signal.aborted ? "interrupted" : message],
|
|
1296
|
+
usage: turnUsage(accum)
|
|
1297
|
+
});
|
|
1298
|
+
this.#turnAccum = void 0;
|
|
1299
|
+
this.#setStatus("idle");
|
|
1300
|
+
} finally {
|
|
1301
|
+
if (this.#abort === abort) this.#abort = void 0;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
/** Emit the turn's result from the whole-turn accumulator, so a turn that
|
|
1305
|
+
* parked on external tool calls reports every leg's tokens and the full
|
|
1306
|
+
* elapsed time (including the time spent executing those tools). */
|
|
1307
|
+
#finishTurn(text) {
|
|
1308
|
+
const accum = this.#turnAccum ?? {
|
|
1309
|
+
startedAt: Date.now(),
|
|
1310
|
+
input: 0,
|
|
1311
|
+
output: 0,
|
|
1312
|
+
cacheWrite: 0,
|
|
1313
|
+
cacheRead: 0
|
|
1314
|
+
};
|
|
1315
|
+
this.#numTurns += 1;
|
|
1316
|
+
this.#totalUsage.input += accum.input;
|
|
1317
|
+
this.#totalUsage.output += accum.output;
|
|
1318
|
+
this.#totalUsage.cacheWrite += accum.cacheWrite;
|
|
1319
|
+
this.#totalUsage.cacheRead += accum.cacheRead;
|
|
1320
|
+
this.#emit({
|
|
1321
|
+
type: "turn_result",
|
|
1322
|
+
subtype: "success",
|
|
1323
|
+
isError: false,
|
|
1324
|
+
durationMs: Date.now() - accum.startedAt,
|
|
1325
|
+
numTurns: this.#numTurns,
|
|
1326
|
+
totalCostUsd: 0,
|
|
1327
|
+
result: text,
|
|
1328
|
+
usage: turnUsage(accum)
|
|
1329
|
+
});
|
|
1330
|
+
this.#turnAccum = void 0;
|
|
1331
|
+
this.#setStatus("idle");
|
|
1332
|
+
}
|
|
1333
|
+
#modelId() {
|
|
1334
|
+
const model = this.#model;
|
|
1335
|
+
if (typeof model === "string") return model;
|
|
1336
|
+
return model.modelId;
|
|
1337
|
+
}
|
|
1338
|
+
#title() {
|
|
1339
|
+
const metaTitle = this.#config.meta?.title;
|
|
1340
|
+
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
1341
|
+
const prompt = this.#config.prompt;
|
|
1342
|
+
if (!prompt) return void 0;
|
|
1343
|
+
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
1344
|
+
}
|
|
1345
|
+
#setStatus(status, detail) {
|
|
1346
|
+
if (this.#status === status) return;
|
|
1347
|
+
if (this.#status === "closed" || this.#status === "failed") return;
|
|
1348
|
+
this.#status = status;
|
|
1349
|
+
this.#emit({
|
|
1350
|
+
type: "status_changed",
|
|
1351
|
+
status,
|
|
1352
|
+
detail
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
#emit(body) {
|
|
1356
|
+
const event = {
|
|
1357
|
+
...body,
|
|
1358
|
+
seq: ++this.#seq,
|
|
1359
|
+
ts: Date.now()
|
|
1360
|
+
};
|
|
1361
|
+
this.#lastActivityAt = event.ts;
|
|
1362
|
+
this.#events.push(event);
|
|
1363
|
+
for (const listener of this.#listeners) try {
|
|
1364
|
+
listener(event);
|
|
1365
|
+
} catch {}
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
function turnUsage(accum) {
|
|
1369
|
+
return {
|
|
1370
|
+
input_tokens: accum.input,
|
|
1371
|
+
output_tokens: accum.output,
|
|
1372
|
+
cache_creation_input_tokens: accum.cacheWrite,
|
|
1373
|
+
cache_read_input_tokens: accum.cacheRead
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
function textValue(output) {
|
|
1377
|
+
return output.type === "text" ? output.value : JSON.stringify(output.value);
|
|
1378
|
+
}
|
|
1379
|
+
function errorText(error) {
|
|
1380
|
+
return error instanceof Error ? error.message : String(error);
|
|
1381
|
+
}
|
|
1382
|
+
//#endregion
|
|
1383
|
+
//#region src/claude-auth.ts
|
|
1384
|
+
/**
|
|
1385
|
+
* The native Claude Code binary the Agent SDK itself spawns, resolved the way
|
|
1386
|
+
* the SDK resolves it: the platform-specific optional dependency installed next
|
|
1387
|
+
* to the SDK package (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude`).
|
|
1388
|
+
* Probing this binary rather than whatever `claude` is on PATH means an auth
|
|
1389
|
+
* check answers for the executable sessions will actually run — the two can be
|
|
1390
|
+
* different versions logged into different places. Returns undefined when it
|
|
1391
|
+
* can't be found (optional dep skipped, unsupported platform); callers degrade
|
|
1392
|
+
* to 'unknown', and the SDK surfaces its own error if a session is created.
|
|
1393
|
+
*/
|
|
1394
|
+
function resolveBundledClaudeExecutable() {
|
|
1395
|
+
try {
|
|
1396
|
+
const fromSdk = createRequire(createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk"));
|
|
1397
|
+
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
1398
|
+
const platforms = process.platform === "linux" ? [`linux-${process.arch}`, `linux-${process.arch}-musl`] : [`${process.platform}-${process.arch}`];
|
|
1399
|
+
for (const platform of platforms) try {
|
|
1400
|
+
const path = fromSdk.resolve(`@anthropic-ai/claude-agent-sdk-${platform}/claude${suffix}`);
|
|
1401
|
+
if (existsSync(path)) return path;
|
|
1402
|
+
} catch {}
|
|
1403
|
+
} catch {}
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Ask the CLI whether `env` holds usable credentials: `claude auth status`
|
|
1407
|
+
* prints a JSON verdict covering every source the CLI itself consults for that
|
|
1408
|
+
* environment — `<CLAUDE_CONFIG_DIR>/.credentials.json`, the macOS login
|
|
1409
|
+
* Keychain, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, Bedrock/Vertex
|
|
1410
|
+
* (verified against 2.1.217). Only the `loggedIn` boolean is ever read; the
|
|
1411
|
+
* identity fields in the payload (email, org, subscription) never leave the
|
|
1412
|
+
* parse. The exit code is deliberately ignored — 2.1.217 exits 1 on a
|
|
1413
|
+
* logged-out verdict where other versions exit 0 — and anything that doesn't
|
|
1414
|
+
* parse to a `loggedIn` boolean is 'unknown', because `auth status` is not a
|
|
1415
|
+
* stable contract. Never rejects.
|
|
1416
|
+
*/
|
|
1417
|
+
function checkClaudeAuth(env, options = {}) {
|
|
1418
|
+
const executable = options.executable ?? resolveBundledClaudeExecutable();
|
|
1419
|
+
if (!executable) return Promise.resolve("unknown");
|
|
1420
|
+
return new Promise((resolve) => {
|
|
1421
|
+
execFile(executable, ["auth", "status"], {
|
|
1422
|
+
env,
|
|
1423
|
+
timeout: options.timeoutMs ?? 1e4
|
|
1424
|
+
}, (_error, stdout) => {
|
|
1425
|
+
try {
|
|
1426
|
+
const parsed = JSON.parse(stdout);
|
|
1427
|
+
if (typeof parsed.loggedIn === "boolean") {
|
|
1428
|
+
resolve(parsed.loggedIn ? "logged_in" : "logged_out");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
} catch {}
|
|
1432
|
+
resolve("unknown");
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
//#endregion
|
|
1437
|
+
//#region src/quickjs-executor.ts
|
|
1438
|
+
/**
|
|
1439
|
+
* In-process execution backend: runs a tool's untrusted script in the QuickJS
|
|
1440
|
+
* WASM guest. Always settles inline — nothing downstream assumes that, which is
|
|
1441
|
+
* what lets a deferred backend replace it behind the same seam.
|
|
1442
|
+
*/
|
|
1443
|
+
var QuickJsExecutor = class {
|
|
1444
|
+
#options;
|
|
1445
|
+
constructor(options) {
|
|
1446
|
+
this.#options = options;
|
|
1447
|
+
}
|
|
1448
|
+
async dispatch(call) {
|
|
1449
|
+
return {
|
|
1450
|
+
executionId: call.executionId,
|
|
1451
|
+
status: "settled",
|
|
1452
|
+
result: await this.#execute(call)
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
async #execute(call) {
|
|
1456
|
+
if (call.tool !== "eval_script") return {
|
|
1457
|
+
status: "failed",
|
|
1458
|
+
reason: "unsupported_tool",
|
|
1459
|
+
error: `tool '${call.tool}' is not executable by the QuickJS backend`
|
|
1460
|
+
};
|
|
1461
|
+
const script = call.input?.script;
|
|
1462
|
+
if (typeof script !== "string") return {
|
|
1463
|
+
status: "failed",
|
|
1464
|
+
reason: "invalid_input",
|
|
1465
|
+
error: "eval_script requires a string `script` input"
|
|
1466
|
+
};
|
|
1467
|
+
const result = await runScript(this.#options.engine, {
|
|
1468
|
+
script,
|
|
1469
|
+
vfs: call.vfs,
|
|
1470
|
+
signal: call.signal,
|
|
1471
|
+
timeoutMs: call.limits?.timeoutMs ?? this.#options.defaultTimeoutMs ?? 5e3,
|
|
1472
|
+
memoryLimitBytes: call.limits?.memoryLimitBytes ?? this.#options.defaultMemoryLimitBytes ?? 64 * 1024 * 1024,
|
|
1473
|
+
fetchText: this.#allowsNetwork() ? (url) => this.#fetchText(url, call.signal) : void 0
|
|
1474
|
+
});
|
|
1475
|
+
const logs = result.logs.map((l) => `[${l.level}] ${l.text}`);
|
|
1476
|
+
return result.ok ? {
|
|
1477
|
+
status: "ok",
|
|
1478
|
+
output: result.value,
|
|
1479
|
+
logs
|
|
1480
|
+
} : {
|
|
1481
|
+
status: "failed",
|
|
1482
|
+
reason: result.reason,
|
|
1483
|
+
error: result.error,
|
|
1484
|
+
logs
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
#allowsNetwork() {
|
|
1488
|
+
return (this.#options.allowedHosts?.length ?? 0) > 0;
|
|
1489
|
+
}
|
|
1490
|
+
async #fetchText(url, outer) {
|
|
1491
|
+
if (!isHostAllowed(url, this.#options.allowedHosts ?? [])) throw new Error(`host not allowed: ${safeHost(url) ?? url}`);
|
|
1492
|
+
const controller = new AbortController();
|
|
1493
|
+
const onOuterAbort = () => controller.abort();
|
|
1494
|
+
outer?.addEventListener("abort", onOuterAbort);
|
|
1495
|
+
const timer = setTimeout(() => controller.abort(), this.#options.fetchTimeoutMs ?? 1e4);
|
|
1496
|
+
try {
|
|
1497
|
+
return await (this.#options.hostFetch ?? defaultHostFetch)(url, controller.signal);
|
|
1498
|
+
} finally {
|
|
1499
|
+
clearTimeout(timer);
|
|
1500
|
+
outer?.removeEventListener("abort", onOuterAbort);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
async function defaultHostFetch(url, signal) {
|
|
1505
|
+
const response = await fetch(url, { signal });
|
|
1506
|
+
if (!response.ok) throw new Error(`request failed: ${response.status}`);
|
|
1507
|
+
return await response.text();
|
|
1508
|
+
}
|
|
1509
|
+
function safeHost(url) {
|
|
1510
|
+
try {
|
|
1511
|
+
return new URL(url).hostname;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/** Exact hostname match, or a single leading `*.` wildcard covering subdomains
|
|
1517
|
+
* (not the bare parent). Only http(s) — no file:, data:, or other schemes. */
|
|
1518
|
+
function isHostAllowed(url, allowedHosts) {
|
|
1519
|
+
let parsed;
|
|
1520
|
+
try {
|
|
1521
|
+
parsed = new URL(url);
|
|
1522
|
+
} catch {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
1526
|
+
const host = parsed.hostname.toLowerCase();
|
|
1527
|
+
return allowedHosts.some((entry) => {
|
|
1528
|
+
const pattern = entry.trim().toLowerCase();
|
|
1529
|
+
if (!pattern) return false;
|
|
1530
|
+
if (pattern.startsWith("*.")) return host.endsWith(pattern.slice(1));
|
|
1531
|
+
return host === pattern;
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region src/pending-registry.ts
|
|
1536
|
+
var PendingRequestRegistry = class {
|
|
1537
|
+
#slots = /* @__PURE__ */ new Map();
|
|
1538
|
+
get size() {
|
|
1539
|
+
return this.#slots.size;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Register a request and get a promise for its outcome. The promise **never
|
|
1543
|
+
* rejects**: a timeout or cancellation resolves with `ok: false` so callers
|
|
1544
|
+
* feed the failure back into the agent loop instead of unwinding it.
|
|
1545
|
+
*
|
|
1546
|
+
* Re-registering a live id throws — silently replacing it would strand the
|
|
1547
|
+
* first waiter forever.
|
|
1548
|
+
*/
|
|
1549
|
+
register(options) {
|
|
1550
|
+
if (this.#slots.has(options.id)) throw new Error(`pending request '${options.id}' is already registered`);
|
|
1551
|
+
const entry = {
|
|
1552
|
+
id: options.id,
|
|
1553
|
+
kind: options.kind,
|
|
1554
|
+
createdAt: Date.now(),
|
|
1555
|
+
expiresAt: options.timeoutMs === void 0 ? void 0 : Date.now() + options.timeoutMs,
|
|
1556
|
+
meta: options.meta
|
|
1557
|
+
};
|
|
1558
|
+
return new Promise((resolve) => {
|
|
1559
|
+
const slot = {
|
|
1560
|
+
...entry,
|
|
1561
|
+
resolve: (outcome) => {
|
|
1562
|
+
options.onSettle?.(outcome, entry);
|
|
1563
|
+
resolve(outcome);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
if (options.timeoutMs !== void 0) {
|
|
1567
|
+
slot.timer = setTimeout(() => {
|
|
1568
|
+
this.#settle(options.id, {
|
|
1569
|
+
ok: false,
|
|
1570
|
+
reason: "timeout",
|
|
1571
|
+
error: `request timed out after ${options.timeoutMs}ms`,
|
|
1572
|
+
settledBy: "timeout"
|
|
1573
|
+
});
|
|
1574
|
+
}, options.timeoutMs);
|
|
1575
|
+
slot.timer.unref?.();
|
|
1576
|
+
}
|
|
1577
|
+
this.#slots.set(options.id, slot);
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
/** Deliver a result. Returns false for unknown or already-settled ids —
|
|
1581
|
+
* duplicate and late deliveries are no-ops, never a second application. */
|
|
1582
|
+
settle(id, value, settledBy = "client") {
|
|
1583
|
+
return this.#settle(id, {
|
|
1584
|
+
ok: true,
|
|
1585
|
+
value,
|
|
1586
|
+
settledBy
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
/** Fail a request. Same idempotence guarantee as {@link settle}. */
|
|
1590
|
+
fail(id, reason, error, settledBy = "server") {
|
|
1591
|
+
return this.#settle(id, {
|
|
1592
|
+
ok: false,
|
|
1593
|
+
reason,
|
|
1594
|
+
error,
|
|
1595
|
+
settledBy
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
has(id) {
|
|
1599
|
+
return this.#slots.has(id);
|
|
1600
|
+
}
|
|
1601
|
+
get(id) {
|
|
1602
|
+
const slot = this.#slots.get(id);
|
|
1603
|
+
return slot && toEntry(slot);
|
|
1604
|
+
}
|
|
1605
|
+
list(kind) {
|
|
1606
|
+
const entries = [...this.#slots.values()].map(toEntry);
|
|
1607
|
+
return kind ? entries.filter((e) => e.kind === kind) : entries;
|
|
1608
|
+
}
|
|
1609
|
+
/** Fail everything (optionally of one kind) — session close, turn interrupt. */
|
|
1610
|
+
cancelAll(reason, error, kind) {
|
|
1611
|
+
let canceled = 0;
|
|
1612
|
+
for (const slot of Array.from(this.#slots.values())) {
|
|
1613
|
+
if (kind && slot.kind !== kind) continue;
|
|
1614
|
+
if (this.#settle(slot.id, {
|
|
1615
|
+
ok: false,
|
|
1616
|
+
reason,
|
|
1617
|
+
error,
|
|
1618
|
+
settledBy: "server"
|
|
1619
|
+
})) canceled += 1;
|
|
1620
|
+
}
|
|
1621
|
+
return canceled;
|
|
1622
|
+
}
|
|
1623
|
+
#settle(id, outcome) {
|
|
1624
|
+
const slot = this.#slots.get(id);
|
|
1625
|
+
if (!slot) return false;
|
|
1626
|
+
clearTimeout(slot.timer);
|
|
1627
|
+
this.#slots.delete(id);
|
|
1628
|
+
slot.resolve(outcome);
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
function toEntry(slot) {
|
|
1633
|
+
return {
|
|
1634
|
+
id: slot.id,
|
|
1635
|
+
kind: slot.kind,
|
|
1636
|
+
createdAt: slot.createdAt,
|
|
1637
|
+
expiresAt: slot.expiresAt,
|
|
1638
|
+
meta: slot.meta
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
//#endregion
|
|
1642
|
+
//#region src/browser-bridge-executor.ts
|
|
1643
|
+
/**
|
|
1644
|
+
* Executes tool calls in the attached client's own sandbox. The first backend
|
|
1645
|
+
* that genuinely returns `pending`: dispatch puts a request on the wire and
|
|
1646
|
+
* returns, and the result arrives later through {@link resolve}.
|
|
1647
|
+
*
|
|
1648
|
+
* Data locality is the point — documents can stay in the browser and never
|
|
1649
|
+
* reach the server. The tradeoff is trust: whatever comes back is untrusted
|
|
1650
|
+
* input, fine for the user's own data but never a source for authoritative
|
|
1651
|
+
* server state (that is why MCP and secret-bearing tools are never bridged).
|
|
1652
|
+
*/
|
|
1653
|
+
var BrowserBridgeExecutor = class {
|
|
1654
|
+
registry;
|
|
1655
|
+
#options;
|
|
1656
|
+
/** Results that arrive before dispatch registers them (fast client, slow
|
|
1657
|
+
* bookkeeping) would otherwise be dropped — hold them briefly. */
|
|
1658
|
+
#early = /* @__PURE__ */ new Map();
|
|
1659
|
+
constructor(options) {
|
|
1660
|
+
this.#options = options;
|
|
1661
|
+
this.registry = options.registry ?? new PendingRequestRegistry();
|
|
1662
|
+
}
|
|
1663
|
+
async dispatch(call) {
|
|
1664
|
+
const timeoutMs = call.limits?.timeoutMs ?? this.#options.timeoutMs ?? 6e4;
|
|
1665
|
+
const expiresAt = Date.now() + timeoutMs;
|
|
1666
|
+
const frame = {
|
|
1667
|
+
type: "tool_call_request",
|
|
1668
|
+
executionId: call.executionId,
|
|
1669
|
+
toolName: call.tool,
|
|
1670
|
+
input: call.input,
|
|
1671
|
+
vfsSeed: call.vfs?.snapshot(),
|
|
1672
|
+
limits: call.limits,
|
|
1673
|
+
expiresAt
|
|
1674
|
+
};
|
|
1675
|
+
const settled = this.registry.register({
|
|
1676
|
+
id: call.executionId,
|
|
1677
|
+
kind: "tool_call",
|
|
1678
|
+
timeoutMs,
|
|
1679
|
+
meta: {
|
|
1680
|
+
toolName: call.tool,
|
|
1681
|
+
sessionId: call.sessionId
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
if (!this.#options.send(frame)) {
|
|
1685
|
+
this.registry.fail(call.executionId, "no_client", "no client is attached to execute this call");
|
|
1686
|
+
return {
|
|
1687
|
+
executionId: call.executionId,
|
|
1688
|
+
status: "settled",
|
|
1689
|
+
result: toExecutionResult(await settled)
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
const early = this.#early.get(call.executionId);
|
|
1693
|
+
if (early) {
|
|
1694
|
+
this.#early.delete(call.executionId);
|
|
1695
|
+
this.#applyAnswer(call.executionId, early);
|
|
1696
|
+
}
|
|
1697
|
+
const onAbort = () => {
|
|
1698
|
+
this.registry.fail(call.executionId, "aborted", "the turn was interrupted");
|
|
1699
|
+
};
|
|
1700
|
+
call.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1701
|
+
settled.then((outcome) => {
|
|
1702
|
+
call.signal?.removeEventListener("abort", onAbort);
|
|
1703
|
+
if (!outcome.ok && outcome.settledBy !== "client") this.#options.cancel?.(call.executionId, outcome.reason);
|
|
1704
|
+
this.#options.onResult?.(call.executionId, toExecutionResult(outcome));
|
|
1705
|
+
});
|
|
1706
|
+
return {
|
|
1707
|
+
executionId: call.executionId,
|
|
1708
|
+
status: "pending"
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Apply a client's answer. Returns false when the id is unknown or already
|
|
1713
|
+
* settled — a late result after a timeout must not re-open a settled call.
|
|
1714
|
+
*/
|
|
1715
|
+
resolve(executionId, answer) {
|
|
1716
|
+
if (!this.registry.has(executionId)) {
|
|
1717
|
+
this.#early.set(executionId, answer);
|
|
1718
|
+
setTimeout(() => this.#early.delete(executionId), 5e3).unref?.();
|
|
1719
|
+
return false;
|
|
1720
|
+
}
|
|
1721
|
+
return this.#applyAnswer(executionId, answer);
|
|
1722
|
+
}
|
|
1723
|
+
#applyAnswer(executionId, answer) {
|
|
1724
|
+
return "output" in answer ? this.registry.settle(executionId, answer, "client") : this.registry.fail(executionId, answer.reason, answer.error, "client");
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
/** Map a registry outcome onto the executor's result contract. */
|
|
1728
|
+
function toExecutionResult(outcome) {
|
|
1729
|
+
if (outcome.ok && "output" in outcome.value) {
|
|
1730
|
+
const { output, logs } = outcome.value;
|
|
1731
|
+
return {
|
|
1732
|
+
status: "ok",
|
|
1733
|
+
output: output.type === "text" ? output.value : output.value,
|
|
1734
|
+
logs
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
if (outcome.ok) {
|
|
1738
|
+
const failure = outcome.value;
|
|
1739
|
+
return {
|
|
1740
|
+
status: "failed",
|
|
1741
|
+
reason: failure.reason,
|
|
1742
|
+
error: failure.error,
|
|
1743
|
+
logs: failure.logs
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
return {
|
|
1747
|
+
status: "failed",
|
|
1748
|
+
reason: outcome.reason,
|
|
1749
|
+
error: outcome.error
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
//#endregion
|
|
1753
|
+
//#region src/deferred-executor.ts
|
|
1754
|
+
/**
|
|
1755
|
+
* The executor for work that outlives the session's process residency: dispatch
|
|
1756
|
+
* hands the call off and returns `pending` **without holding a promise**, because
|
|
1757
|
+
* the runner it would resolve into is about to be torn down. The result can only
|
|
1758
|
+
* come back through the host — the execution-result route → `settleExecution` on a
|
|
1759
|
+
* rehydrated runner — which is exactly what makes a park durable rather than a
|
|
1760
|
+
* long in-memory await.
|
|
1761
|
+
*
|
|
1762
|
+
* Contrast {@link BrowserBridgeExecutor}, which is also `pending` but keeps its
|
|
1763
|
+
* answer in memory for the ~60s the tab has to reply.
|
|
1764
|
+
*/
|
|
1765
|
+
var DeferredExecutor = class {
|
|
1766
|
+
backend;
|
|
1767
|
+
timeoutMs;
|
|
1768
|
+
#options;
|
|
1769
|
+
constructor(options) {
|
|
1770
|
+
this.#options = options;
|
|
1771
|
+
this.backend = options.backend ?? "remote";
|
|
1772
|
+
this.timeoutMs = options.timeoutMs;
|
|
1773
|
+
}
|
|
1774
|
+
/** Every call this executor takes is deferred — route only the tools that
|
|
1775
|
+
* belong on the remote side to it. */
|
|
1776
|
+
describe() {
|
|
1777
|
+
return {
|
|
1778
|
+
backend: this.backend,
|
|
1779
|
+
deferred: true,
|
|
1780
|
+
timeoutMs: this.timeoutMs
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
async dispatch(call) {
|
|
1784
|
+
await this.#options.onDispatch({
|
|
1785
|
+
executionId: call.executionId,
|
|
1786
|
+
sessionId: call.sessionId,
|
|
1787
|
+
tool: call.tool,
|
|
1788
|
+
input: call.input,
|
|
1789
|
+
vfsSeed: call.vfs?.snapshot(),
|
|
1790
|
+
limits: call.limits,
|
|
1791
|
+
expiresAt: this.timeoutMs === void 0 ? void 0 : Date.now() + this.timeoutMs
|
|
1792
|
+
});
|
|
1793
|
+
return {
|
|
1794
|
+
executionId: call.executionId,
|
|
1795
|
+
status: "pending"
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
//#endregion
|
|
1800
|
+
//#region src/tools.ts
|
|
1801
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
1802
|
+
/**
|
|
1803
|
+
* Build the capability-scoped tool set for a session.
|
|
1804
|
+
*
|
|
1805
|
+
* The agent's authority is exactly what is granted here — there are no built-in
|
|
1806
|
+
* filesystem or shell tools, and nothing reaches the host filesystem: `fs_*`
|
|
1807
|
+
* operate on an in-memory scratch VFS. Tools whose backend is not supplied are
|
|
1808
|
+
* simply absent rather than present-and-failing, so a model cannot be tempted
|
|
1809
|
+
* by a capability the operator did not grant.
|
|
1810
|
+
*/
|
|
1811
|
+
function createToolContext(options) {
|
|
1812
|
+
const vfs = options.vfs ?? createVfs();
|
|
1813
|
+
const definitions = [];
|
|
1814
|
+
definitions.push({
|
|
1815
|
+
name: "fs_list",
|
|
1816
|
+
trust: "authoritative",
|
|
1817
|
+
tool: tool({
|
|
1818
|
+
description: "List files in the scratch filesystem.",
|
|
1819
|
+
inputSchema: z.object({ dir: z.string().default("/").describe("Directory to list") }),
|
|
1820
|
+
execute: async ({ dir }) => ({ files: vfs.list(dir) })
|
|
1821
|
+
})
|
|
1822
|
+
});
|
|
1823
|
+
definitions.push({
|
|
1824
|
+
name: "fs_read",
|
|
1825
|
+
trust: "authoritative",
|
|
1826
|
+
tool: tool({
|
|
1827
|
+
description: "Read a file from the scratch filesystem.",
|
|
1828
|
+
inputSchema: z.object({ path: z.string() }),
|
|
1829
|
+
execute: async ({ path }) => {
|
|
1830
|
+
const content = vfs.read(path);
|
|
1831
|
+
if (content === void 0) return { error: `no such file: ${path}` };
|
|
1832
|
+
return { content: truncate(content) };
|
|
1833
|
+
}
|
|
1834
|
+
})
|
|
1835
|
+
});
|
|
1836
|
+
definitions.push({
|
|
1837
|
+
name: "fs_write",
|
|
1838
|
+
trust: "authoritative",
|
|
1839
|
+
tool: tool({
|
|
1840
|
+
description: "Write a file to the scratch filesystem.",
|
|
1841
|
+
inputSchema: z.object({
|
|
1842
|
+
path: z.string(),
|
|
1843
|
+
content: z.string()
|
|
1844
|
+
}),
|
|
1845
|
+
execute: async ({ path, content }) => {
|
|
1846
|
+
vfs.write(path, content);
|
|
1847
|
+
return {
|
|
1848
|
+
path,
|
|
1849
|
+
bytes: content.length
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
})
|
|
1853
|
+
});
|
|
1854
|
+
if (options.onFileDelivered) {
|
|
1855
|
+
const onFileDelivered = options.onFileDelivered;
|
|
1856
|
+
definitions.push({
|
|
1857
|
+
name: "deliver_file",
|
|
1858
|
+
trust: "authoritative",
|
|
1859
|
+
tool: tool({
|
|
1860
|
+
description: "Hand a file from the scratch filesystem over to the user as a deliverable. Write it with fs_write first, then deliver it.",
|
|
1861
|
+
inputSchema: z.object({
|
|
1862
|
+
path: z.string().describe("Path of an existing file in the scratch filesystem"),
|
|
1863
|
+
description: z.string().optional().describe("What this file is, for the recipient")
|
|
1864
|
+
}),
|
|
1865
|
+
execute: async ({ path, description }) => {
|
|
1866
|
+
const content = vfs.read(path);
|
|
1867
|
+
if (content === void 0) return { error: `no such file: ${path}` };
|
|
1868
|
+
const file = {
|
|
1869
|
+
path,
|
|
1870
|
+
bytes: content.length,
|
|
1871
|
+
description
|
|
1872
|
+
};
|
|
1873
|
+
onFileDelivered(file);
|
|
1874
|
+
return {
|
|
1875
|
+
delivered: true,
|
|
1876
|
+
...file
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
})
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
if (options.search) {
|
|
1883
|
+
const search = options.search;
|
|
1884
|
+
definitions.push({
|
|
1885
|
+
name: "web_search",
|
|
1886
|
+
trust: "authoritative",
|
|
1887
|
+
tool: tool({
|
|
1888
|
+
description: "Search the web for pages relevant to a query.",
|
|
1889
|
+
inputSchema: z.object({
|
|
1890
|
+
query: z.string(),
|
|
1891
|
+
limit: z.number().int().min(1).max(25).default(5)
|
|
1892
|
+
}),
|
|
1893
|
+
execute: async ({ query, limit }) => ({ results: await search(query, limit) })
|
|
1894
|
+
})
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
if (options.download) {
|
|
1898
|
+
const download = options.download;
|
|
1899
|
+
definitions.push({
|
|
1900
|
+
name: "download",
|
|
1901
|
+
trust: "authoritative",
|
|
1902
|
+
tool: tool({
|
|
1903
|
+
description: "Fetch a URL and store its text in the scratch filesystem for later evaluation.",
|
|
1904
|
+
inputSchema: z.object({
|
|
1905
|
+
url: z.string().describe("Absolute http(s) URL"),
|
|
1906
|
+
path: z.string().describe("Where to store it in the scratch filesystem")
|
|
1907
|
+
}),
|
|
1908
|
+
execute: async ({ url, path }) => {
|
|
1909
|
+
try {
|
|
1910
|
+
const { text, contentType } = await download(url);
|
|
1911
|
+
const stored = truncate(text);
|
|
1912
|
+
vfs.write(path, stored);
|
|
1913
|
+
return {
|
|
1914
|
+
path,
|
|
1915
|
+
bytes: stored.length,
|
|
1916
|
+
contentType
|
|
1917
|
+
};
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
})
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
if (options.webFetch) {
|
|
1926
|
+
const webFetch = options.webFetch;
|
|
1927
|
+
definitions.push({
|
|
1928
|
+
name: "web_fetch",
|
|
1929
|
+
trust: "authoritative",
|
|
1930
|
+
tool: tool({
|
|
1931
|
+
description: "Fetch a web page and process its content against a prompt. Returns the answer (or the page as markdown). Distinct from download: use web_fetch to answer a question about a page, download to store raw text for eval_script.",
|
|
1932
|
+
inputSchema: z.object({
|
|
1933
|
+
url: z.string().describe("Absolute http(s) URL"),
|
|
1934
|
+
prompt: z.string().describe("What to extract or answer from the page")
|
|
1935
|
+
}),
|
|
1936
|
+
execute: async ({ url, prompt }) => {
|
|
1937
|
+
try {
|
|
1938
|
+
return await webFetch(url, prompt);
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
})
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
definitions.push({
|
|
1947
|
+
name: "eval_script",
|
|
1948
|
+
trust: "sandboxed",
|
|
1949
|
+
tool: tool({
|
|
1950
|
+
description: "Evaluate a JavaScript snippet in a sandbox to parse, score, or extract from files. Globals: vfs.read(path), vfs.write(path, text), vfs.list(dir), console.log. The value of the last expression is returned. No network or host access.",
|
|
1951
|
+
inputSchema: z.object({ script: z.string() })
|
|
1952
|
+
})
|
|
1953
|
+
});
|
|
1954
|
+
const tools = {};
|
|
1955
|
+
for (const definition of definitions) tools[definition.name] = definition.tool;
|
|
1956
|
+
return {
|
|
1957
|
+
vfs,
|
|
1958
|
+
tools,
|
|
1959
|
+
definitions,
|
|
1960
|
+
sandboxedToolNames: definitions.filter((d) => d.trust === "sandboxed").map((d) => d.name)
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
/** Add host-side MCP tools to a context. They are ALWAYS authoritative: they run
|
|
1964
|
+
* server-side with server credentials, and must never be handed to a browser. */
|
|
1965
|
+
function withMcpTools(context, mcpTools) {
|
|
1966
|
+
const definitions = [...context.definitions];
|
|
1967
|
+
const tools = { ...context.tools };
|
|
1968
|
+
for (const [name, mcpTool] of Object.entries(mcpTools)) {
|
|
1969
|
+
if (context.sandboxedToolNames.includes(name)) throw new Error(`MCP tool '${name}' collides with a sandboxed tool of the same name`);
|
|
1970
|
+
definitions.push({
|
|
1971
|
+
name,
|
|
1972
|
+
trust: "authoritative",
|
|
1973
|
+
tool: mcpTool
|
|
1974
|
+
});
|
|
1975
|
+
tools[name] = mcpTool;
|
|
1976
|
+
}
|
|
1977
|
+
return {
|
|
1978
|
+
...context,
|
|
1979
|
+
tools,
|
|
1980
|
+
definitions
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
function truncate(text) {
|
|
1984
|
+
return text.length > MAX_FILE_BYTES ? text.slice(0, MAX_FILE_BYTES) : text;
|
|
1985
|
+
}
|
|
1986
|
+
//#endregion
|
|
1987
|
+
//#region src/web-fetch.ts
|
|
1988
|
+
const MAX_CACHE_ENTRIES = 64;
|
|
1989
|
+
const MAX_REDIRECTS = 5;
|
|
1990
|
+
function createWebFetch(options = {}) {
|
|
1991
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1992
|
+
const maxContentBytes = options.maxContentBytes ?? 1024 * 1024;
|
|
1993
|
+
const maxMarkdownBytes = options.maxMarkdownBytes ?? 50 * 1024;
|
|
1994
|
+
const cacheTtlMs = options.cacheTtlMs ?? 900 * 1e3;
|
|
1995
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1996
|
+
const fetchPage = async (rawUrl) => {
|
|
1997
|
+
const cached = cache.get(rawUrl);
|
|
1998
|
+
if (cached && cached.expiresAt > Date.now()) return cached.page;
|
|
1999
|
+
let url = parseUrl(rawUrl);
|
|
2000
|
+
if (!url) return {
|
|
2001
|
+
url: rawUrl,
|
|
2002
|
+
error: "only absolute http(s) URLs are supported"
|
|
2003
|
+
};
|
|
2004
|
+
const controller = new AbortController();
|
|
2005
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
|
|
2006
|
+
try {
|
|
2007
|
+
let response;
|
|
2008
|
+
for (let hop = 0;; hop++) {
|
|
2009
|
+
const denied = await denyReason(url, options.allowedHosts);
|
|
2010
|
+
if (denied) return {
|
|
2011
|
+
url: url.href,
|
|
2012
|
+
error: denied
|
|
2013
|
+
};
|
|
2014
|
+
response = await fetchImpl(url.href, {
|
|
2015
|
+
redirect: "manual",
|
|
2016
|
+
signal: controller.signal
|
|
2017
|
+
});
|
|
2018
|
+
if (response.status < 300 || response.status >= 400) break;
|
|
2019
|
+
const location = response.headers.get("location");
|
|
2020
|
+
if (!location) return {
|
|
2021
|
+
url: url.href,
|
|
2022
|
+
error: `redirect (${response.status}) without a location`
|
|
2023
|
+
};
|
|
2024
|
+
const target = parseUrl(new URL(location, url).href);
|
|
2025
|
+
if (!target) return {
|
|
2026
|
+
url: url.href,
|
|
2027
|
+
error: `redirect to unsupported URL: ${location}`
|
|
2028
|
+
};
|
|
2029
|
+
if (target.host !== url.host) return {
|
|
2030
|
+
url: url.href,
|
|
2031
|
+
redirectUrl: target.href,
|
|
2032
|
+
notice: `redirected to a different host (${target.host}); not followed automatically`
|
|
2033
|
+
};
|
|
2034
|
+
if (hop >= MAX_REDIRECTS) return {
|
|
2035
|
+
url: url.href,
|
|
2036
|
+
error: "too many redirects"
|
|
2037
|
+
};
|
|
2038
|
+
url = target;
|
|
2039
|
+
}
|
|
2040
|
+
if (!response.ok) return {
|
|
2041
|
+
url: url.href,
|
|
2042
|
+
error: `request failed: ${response.status}`
|
|
2043
|
+
};
|
|
2044
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
2045
|
+
if (declared > maxContentBytes) return {
|
|
2046
|
+
url: url.href,
|
|
2047
|
+
error: `response too large (${declared} bytes)`
|
|
2048
|
+
};
|
|
2049
|
+
const body = await readCapped(response, maxContentBytes);
|
|
2050
|
+
if (body === void 0) return {
|
|
2051
|
+
url: url.href,
|
|
2052
|
+
error: `response too large (> ${maxContentBytes} bytes)`
|
|
2053
|
+
};
|
|
2054
|
+
const text = (response.headers.get("content-type") ?? "").includes("html") || looksLikeHtml(body) ? htmlToMarkdown(body) : body;
|
|
2055
|
+
const truncated = text.length > maxMarkdownBytes;
|
|
2056
|
+
const page = {
|
|
2057
|
+
url: url.href,
|
|
2058
|
+
markdown: truncated ? text.slice(0, maxMarkdownBytes) : text,
|
|
2059
|
+
truncated: truncated || void 0
|
|
2060
|
+
};
|
|
2061
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
2062
|
+
const oldest = cache.keys().next().value;
|
|
2063
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
2064
|
+
}
|
|
2065
|
+
cache.set(rawUrl, {
|
|
2066
|
+
expiresAt: Date.now() + cacheTtlMs,
|
|
2067
|
+
page
|
|
2068
|
+
});
|
|
2069
|
+
return page;
|
|
2070
|
+
} catch (error) {
|
|
2071
|
+
const message = controller.signal.aborted ? "request timed out" : error instanceof Error ? error.message : String(error);
|
|
2072
|
+
return {
|
|
2073
|
+
url: url.href,
|
|
2074
|
+
error: message
|
|
2075
|
+
};
|
|
2076
|
+
} finally {
|
|
2077
|
+
clearTimeout(timer);
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
return async (rawUrl, prompt) => {
|
|
2081
|
+
const page = await fetchPage(rawUrl);
|
|
2082
|
+
if (page.error || page.notice || !options.digest || page.markdown === void 0) return page;
|
|
2083
|
+
try {
|
|
2084
|
+
const digest = await options.digest(page.markdown, prompt);
|
|
2085
|
+
return {
|
|
2086
|
+
url: page.url,
|
|
2087
|
+
digest,
|
|
2088
|
+
truncated: page.truncated
|
|
2089
|
+
};
|
|
2090
|
+
} catch {
|
|
2091
|
+
return page;
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function parseUrl(raw) {
|
|
2096
|
+
try {
|
|
2097
|
+
const url = new URL(raw);
|
|
2098
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url : void 0;
|
|
2099
|
+
} catch {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
/** SSRF guard: resolve the hostname and refuse private, loopback, and link-local
|
|
2104
|
+
* destinations. Checked per redirect hop. Resolution happens once here and again
|
|
2105
|
+
* inside fetch (a DNS-rebinding TOCTOU); this tier accepts that — operators who
|
|
2106
|
+
* need pinning can supply `fetchImpl` with a pinned agent. */
|
|
2107
|
+
async function denyReason(url, allowedHosts) {
|
|
2108
|
+
const host = url.hostname.toLowerCase();
|
|
2109
|
+
if (allowedHosts && allowedHosts.length > 0 && !hostMatches(host, allowedHosts)) return `host not allowed: ${host}`;
|
|
2110
|
+
if (host === "localhost" || host.endsWith(".localhost")) return `host not allowed: ${host}`;
|
|
2111
|
+
const literal = host.replace(/^\[|\]$/g, "");
|
|
2112
|
+
if (isPrivateAddress(literal)) return `address not allowed: ${literal}`;
|
|
2113
|
+
if (/^[\d.]+$/.test(literal) || literal.includes(":")) return null;
|
|
2114
|
+
let addresses;
|
|
2115
|
+
try {
|
|
2116
|
+
addresses = await lookup(literal, { all: true });
|
|
2117
|
+
} catch {
|
|
2118
|
+
return `cannot resolve host: ${host}`;
|
|
2119
|
+
}
|
|
2120
|
+
for (const { address } of addresses) if (isPrivateAddress(address)) return `host resolves to a private address: ${host}`;
|
|
2121
|
+
return null;
|
|
2122
|
+
}
|
|
2123
|
+
function hostMatches(host, allowedHosts) {
|
|
2124
|
+
return allowedHosts.some((entry) => {
|
|
2125
|
+
const pattern = entry.trim().toLowerCase();
|
|
2126
|
+
if (!pattern) return false;
|
|
2127
|
+
if (pattern.startsWith("*.")) return host.endsWith(pattern.slice(1));
|
|
2128
|
+
return host === pattern;
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
/** Private / loopback / link-local / unspecified, IPv4 and IPv6 (incl. v4-mapped). */
|
|
2132
|
+
function isPrivateAddress(address) {
|
|
2133
|
+
const ip = address.toLowerCase();
|
|
2134
|
+
if (ip.includes(":")) {
|
|
2135
|
+
if (ip === "::" || ip === "::1") return true;
|
|
2136
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip);
|
|
2137
|
+
if (mapped) return isPrivateAddress(mapped[1]);
|
|
2138
|
+
return ip.startsWith("fc") || ip.startsWith("fd") || /^fe[89ab]/.test(ip);
|
|
2139
|
+
}
|
|
2140
|
+
const parts = ip.split(".").map(Number);
|
|
2141
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
|
|
2142
|
+
const [a, b] = parts;
|
|
2143
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
2144
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
2145
|
+
if (a === 169 && b === 254) return true;
|
|
2146
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
2147
|
+
if (a === 192 && b === 168) return true;
|
|
2148
|
+
return a >= 224;
|
|
2149
|
+
}
|
|
2150
|
+
async function readCapped(response, maxBytes) {
|
|
2151
|
+
if (!response.body) {
|
|
2152
|
+
const text = await response.text();
|
|
2153
|
+
return text.length > maxBytes ? void 0 : text;
|
|
2154
|
+
}
|
|
2155
|
+
const reader = response.body.getReader();
|
|
2156
|
+
const decoder = new TextDecoder();
|
|
2157
|
+
let out = "";
|
|
2158
|
+
for (;;) {
|
|
2159
|
+
const { done, value } = await reader.read();
|
|
2160
|
+
if (done) break;
|
|
2161
|
+
out += decoder.decode(value, { stream: true });
|
|
2162
|
+
if (out.length > maxBytes) {
|
|
2163
|
+
await reader.cancel().catch(() => {});
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return out + decoder.decode();
|
|
2168
|
+
}
|
|
2169
|
+
function looksLikeHtml(body) {
|
|
2170
|
+
return /<(!doctype|html|head|body)[\s>]/i.test(body.slice(0, 1024));
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Dependency-free HTML → markdown, tuned for "give the model readable text":
|
|
2174
|
+
* drops non-content subtrees, keeps headings/lists/links/emphasis/code, strips
|
|
2175
|
+
* everything else. Not a spec-grade converter on purpose — a small predictable
|
|
2176
|
+
* transform beats dragging a DOM into core.
|
|
2177
|
+
*/
|
|
2178
|
+
function htmlToMarkdown(html) {
|
|
2179
|
+
let text = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|noscript|svg|template|iframe)\b[\s\S]*?<\/\1>/gi, "").replace(/<(head)\b[\s\S]*?<\/\1>/gi, "");
|
|
2180
|
+
text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, body) => {
|
|
2181
|
+
return `\n\n${"#".repeat(Number(level))} ${stripTags(body).trim()}\n\n`;
|
|
2182
|
+
}).replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, body) => {
|
|
2183
|
+
return `\n\n\`\`\`\n${decodeEntities(body.replace(/<[^>]+>/g, ""))}\n\`\`\`\n\n`;
|
|
2184
|
+
}).replace(/<a\s[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, body) => {
|
|
2185
|
+
const label = stripTags(body).trim();
|
|
2186
|
+
if (!label || href.startsWith("#") || href.startsWith("javascript:")) return label;
|
|
2187
|
+
return label === href ? label : `[${label}](${href})`;
|
|
2188
|
+
}).replace(/<li[^>]*>/gi, "\n- ").replace(/<\/(p|div|section|article|tr|table|ul|ol|blockquote|figure)>/gi, "\n\n").replace(/<(br|hr)\s*\/?>/gi, "\n").replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**").replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*").replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
|
|
2189
|
+
text = decodeEntities(text.replace(/<[^>]+>/g, ""));
|
|
2190
|
+
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim();
|
|
2191
|
+
}
|
|
2192
|
+
function stripTags(html) {
|
|
2193
|
+
return decodeEntities(html.replace(/<[^>]+>/g, ""));
|
|
2194
|
+
}
|
|
2195
|
+
function decodeEntities(text) {
|
|
2196
|
+
return text.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&#x([\da-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16))).replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'|'/g, "'").replace(/&/g, "&");
|
|
2197
|
+
}
|
|
2198
|
+
//#endregion
|
|
2199
|
+
//#region src/engine.ts
|
|
2200
|
+
/** Which capability a wired backend yields, for grant filtering. */
|
|
2201
|
+
const CAPABILITY_TOOLS = {
|
|
2202
|
+
search: "web_search",
|
|
2203
|
+
download: "download",
|
|
2204
|
+
webFetch: "web_fetch",
|
|
2205
|
+
deliverFiles: "deliver_file"
|
|
2206
|
+
};
|
|
2207
|
+
/**
|
|
2208
|
+
* Assemble a model-agnostic session: provider model, capability-scoped tools,
|
|
2209
|
+
* a scratch VFS, and the executor that runs the sandboxed ones.
|
|
2210
|
+
*
|
|
2211
|
+
* This is the piece an operator wires into the server's `createEngineRunner`.
|
|
2212
|
+
*
|
|
2213
|
+
* The host wires the *backends*; the profile and the session request decide which
|
|
2214
|
+
* of them are actually granted (`profile.session`, `config.capabilities`). A
|
|
2215
|
+
* backend that isn't granted is simply not built into the tool set, so withholding
|
|
2216
|
+
* a capability costs the host no branching. No declaration anywhere = everything
|
|
2217
|
+
* the host wired, which is what a host that ignores profiles gets.
|
|
2218
|
+
*/
|
|
2219
|
+
function createEngineSession(options) {
|
|
2220
|
+
const vfs = options.config.vfs ?? createVfs(options.config.restore?.vfs);
|
|
2221
|
+
const executor = options.selectExecutor();
|
|
2222
|
+
const granted = options.config.capabilities ?? options.profile?.session?.capabilities;
|
|
2223
|
+
const isGranted = (key) => granted === void 0 || granted.includes(CAPABILITY_TOOLS[key]);
|
|
2224
|
+
let runner;
|
|
2225
|
+
const webFetchCap = isGranted("webFetch") ? options.capabilities?.webFetch : void 0;
|
|
2226
|
+
const webFetch = typeof webFetchCap === "function" ? webFetchCap : webFetchCap ? createWebFetch({
|
|
2227
|
+
...webFetchCap,
|
|
2228
|
+
digest: webFetchCap.digest === false ? void 0 : webFetchCap.digest ?? ((markdown, prompt) => runner.generateDigest(`Answer the request below using ONLY this web page content.
|
|
2229
|
+
|
|
2230
|
+
<page>\n${markdown}\n</page>\n\nRequest: ${prompt}`))
|
|
2231
|
+
}) : void 0;
|
|
2232
|
+
const base = createToolContext({
|
|
2233
|
+
executor,
|
|
2234
|
+
sessionId: "pending",
|
|
2235
|
+
vfs,
|
|
2236
|
+
search: isGranted("search") ? options.capabilities?.search : void 0,
|
|
2237
|
+
download: isGranted("download") ? options.capabilities?.download : void 0,
|
|
2238
|
+
webFetch,
|
|
2239
|
+
onFileDelivered: options.capabilities?.deliverFiles === false || !isGranted("deliverFiles") ? void 0 : (file) => runner?.emitFileDelivered(file)
|
|
2240
|
+
});
|
|
2241
|
+
const mcpTools = selectMcpTools(options.mcpTools, options.profile?.session?.mcpServers);
|
|
2242
|
+
const context = mcpTools ? withMcpTools(base, mcpTools) : base;
|
|
2243
|
+
runner = new AiSdkRunner({
|
|
2244
|
+
...options.config,
|
|
2245
|
+
languageModel: options.resolveModel(options.profile, options.config),
|
|
2246
|
+
instructions: options.profile?.session?.instructions ?? options.instructions ?? options.config.instructions,
|
|
2247
|
+
tools: context.tools,
|
|
2248
|
+
vfs,
|
|
2249
|
+
executor,
|
|
2250
|
+
executableTools: context.sandboxedToolNames,
|
|
2251
|
+
executionBackend: options.backend ?? "server",
|
|
2252
|
+
executionLimits: options.executionLimits
|
|
2253
|
+
});
|
|
2254
|
+
return runner;
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Restrict a connected tool set to the MCP servers a profile grants, by the
|
|
2258
|
+
* `<server>__<tool>` namespace {@link connectMcpTools} assigns. Undefined `servers`
|
|
2259
|
+
* = no declaration, so every connected server passes through.
|
|
2260
|
+
*
|
|
2261
|
+
* This is how one process-wide MCP connection serves a mixed fleet: the host
|
|
2262
|
+
* connects everything once, each profile grants a subset. The transport configs —
|
|
2263
|
+
* and any credentials in their headers — never leave the host for a profile.
|
|
2264
|
+
*/
|
|
2265
|
+
function selectMcpTools(tools, servers) {
|
|
2266
|
+
if (!tools || servers === void 0) return tools;
|
|
2267
|
+
const allowed = new Set(servers);
|
|
2268
|
+
return Object.fromEntries(Object.entries(tools).filter(([name]) => allowed.has(name.split("__")[0])));
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Connect to MCP servers and return their tools, ready for {@link withMcpTools}.
|
|
2272
|
+
*
|
|
2273
|
+
* Server-side only, with server credentials: these tools are authoritative and
|
|
2274
|
+
* must never be bridged to a browser. `@ai-sdk/mcp` is imported lazily and is an
|
|
2275
|
+
* optional dependency — an operator who wires no MCP servers never needs it.
|
|
2276
|
+
*/
|
|
2277
|
+
async function connectMcpTools(servers, options = {}) {
|
|
2278
|
+
const entries = Object.entries(servers);
|
|
2279
|
+
if (entries.length === 0) return {
|
|
2280
|
+
tools: {},
|
|
2281
|
+
close: async () => {}
|
|
2282
|
+
};
|
|
2283
|
+
const { createMCPClient } = await import("@ai-sdk/mcp");
|
|
2284
|
+
const clients = [];
|
|
2285
|
+
const tools = {};
|
|
2286
|
+
for (const [name, server] of entries) try {
|
|
2287
|
+
const client = await createMCPClient({
|
|
2288
|
+
transport: toTransport(server),
|
|
2289
|
+
onUncaughtError: (error) => options.onError?.(name, error)
|
|
2290
|
+
});
|
|
2291
|
+
clients.push(client);
|
|
2292
|
+
for (const [toolName, mcpTool] of Object.entries(await client.tools())) tools[`${name}__${toolName}`] = mcpTool;
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
options.onError?.(name, error);
|
|
2295
|
+
}
|
|
2296
|
+
return {
|
|
2297
|
+
tools,
|
|
2298
|
+
close: async () => {
|
|
2299
|
+
await Promise.allSettled(clients.map((c) => c.close()));
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Only http/sse: the AI SDK's built-in transports are the remote ones, and its
|
|
2305
|
+
* own docs mark stdio local-only and not deployable. A stdio server here is a
|
|
2306
|
+
* misconfiguration worth surfacing rather than silently dropping — the Claude
|
|
2307
|
+
* engine still supports stdio, since the CLI spawns those itself.
|
|
2308
|
+
*/
|
|
2309
|
+
function toTransport(server) {
|
|
2310
|
+
if (!("url" in server)) throw new Error("stdio MCP servers are not supported by the model-agnostic engine (use an http or sse server, or run this session under a Claude profile)");
|
|
2311
|
+
return server.type === "sse" ? {
|
|
2312
|
+
type: "sse",
|
|
2313
|
+
url: server.url,
|
|
2314
|
+
headers: server.headers
|
|
2315
|
+
} : {
|
|
2316
|
+
type: "http",
|
|
2317
|
+
url: server.url,
|
|
2318
|
+
headers: server.headers
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
//#endregion
|
|
2322
|
+
export { AiSdkRunner, BrowserBridgeExecutor, DeferredExecutor, InputQueue, PendingRequestRegistry, QuickJsExecutor, SessionRunner, checkClaudeAuth, connectMcpTools, createEngineSession, createToolContext, createWebFetch, htmlToMarkdown, isHostAllowed, isPrivateAddress, normalizeSdkMessage, resolveBundledClaudeExecutable, toApiMessage, toExecutionResult, withMcpTools };
|
|
2323
|
+
|
|
2324
|
+
//# sourceMappingURL=index.mjs.map
|