@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.
@@ -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(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;|&apos;/g, "'").replace(/&amp;/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