@workerdeck/core 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.d.mts +562 -8
- package/build/index.mjs +2308 -16
- package/build/index.mjs.map +1 -1
- package/package.json +13 -4
package/build/index.mjs
CHANGED
|
@@ -1,12 +1,108 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { getSessionMessages, query } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION } from "@workerdeck/protocol";
|
|
4
5
|
import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
|
|
5
|
-
import { execFile } from "node:child_process";
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { createVfs, runScript } from "@workerdeck/sandbox";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { lookup } from "node:dns/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
//#region src/attachments.ts
|
|
14
|
+
/** The four the Anthropic API accepts. Notably absent: image/heic — an iPhone's
|
|
15
|
+
* native photo format, which clients must transcode before upload. */
|
|
16
|
+
const IMAGE_TYPES = new Set([
|
|
17
|
+
"image/jpeg",
|
|
18
|
+
"image/png",
|
|
19
|
+
"image/gif",
|
|
20
|
+
"image/webp"
|
|
21
|
+
]);
|
|
22
|
+
/** Textual types whose media type doesn't start with `text/`. */
|
|
23
|
+
const TEXT_TYPES = new Set([
|
|
24
|
+
"application/json",
|
|
25
|
+
"application/xml",
|
|
26
|
+
"application/yaml",
|
|
27
|
+
"application/x-yaml",
|
|
28
|
+
"application/toml",
|
|
29
|
+
"application/javascript",
|
|
30
|
+
"application/typescript",
|
|
31
|
+
"application/x-sh",
|
|
32
|
+
"application/x-httpd-php",
|
|
33
|
+
"application/sql"
|
|
34
|
+
]);
|
|
35
|
+
/** Strips any `; charset=…` parameter and lowercases. */
|
|
36
|
+
function normalizeMediaType(mediaType) {
|
|
37
|
+
return mediaType.split(";")[0].trim().toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
/** How this media type can be sent, or null if it can't be. */
|
|
40
|
+
function attachmentKind(mediaType) {
|
|
41
|
+
const type = normalizeMediaType(mediaType);
|
|
42
|
+
if (IMAGE_TYPES.has(type)) return "image";
|
|
43
|
+
if (type === "application/pdf") return "document";
|
|
44
|
+
if (type.startsWith("text/") || TEXT_TYPES.has(type)) return "text";
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/** Human-readable list for the 415 an unsupported upload gets. */
|
|
48
|
+
const SUPPORTED_ATTACHMENT_TYPES = [
|
|
49
|
+
...IMAGE_TYPES,
|
|
50
|
+
"application/pdf",
|
|
51
|
+
"text/*"
|
|
52
|
+
].join(", ");
|
|
53
|
+
/**
|
|
54
|
+
* Anthropic content blocks for a set of attachments, in the given order.
|
|
55
|
+
*
|
|
56
|
+
* Blocks lead the message and the user's text follows: the model reads the
|
|
57
|
+
* picture, then the instruction about it. Text files are inlined in a named
|
|
58
|
+
* envelope rather than as a bare block, so "here is my config" doesn't read as
|
|
59
|
+
* something the user typed.
|
|
60
|
+
*
|
|
61
|
+
* Structurally typed — `packages/core` models Anthropic content the way
|
|
62
|
+
* `packages/protocol` does, and the caller casts into the SDK's own param type.
|
|
63
|
+
*/
|
|
64
|
+
function attachmentContentBlocks(attachments) {
|
|
65
|
+
return attachments.map((attachment) => {
|
|
66
|
+
const mediaType = normalizeMediaType(attachment.mediaType);
|
|
67
|
+
switch (attachmentKind(mediaType)) {
|
|
68
|
+
case "image": return {
|
|
69
|
+
type: "image",
|
|
70
|
+
source: {
|
|
71
|
+
type: "base64",
|
|
72
|
+
media_type: mediaType,
|
|
73
|
+
data: attachment.data
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
case "document": return {
|
|
77
|
+
type: "document",
|
|
78
|
+
source: {
|
|
79
|
+
type: "base64",
|
|
80
|
+
media_type: mediaType,
|
|
81
|
+
data: attachment.data
|
|
82
|
+
},
|
|
83
|
+
title: attachment.name
|
|
84
|
+
};
|
|
85
|
+
case "text": return {
|
|
86
|
+
type: "text",
|
|
87
|
+
text: `<attachment name="${attachment.name}" type="${mediaType}">\n${decodeText(attachment.data)}\n</attachment>`
|
|
88
|
+
};
|
|
89
|
+
default: throw new Error(`unsupported attachment media type: ${attachment.mediaType}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Strip the bytes: the log-safe half of an attachment. */
|
|
94
|
+
function attachmentRef(attachment) {
|
|
95
|
+
return {
|
|
96
|
+
id: attachment.id,
|
|
97
|
+
name: attachment.name,
|
|
98
|
+
mediaType: attachment.mediaType,
|
|
99
|
+
bytes: attachment.bytes
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function decodeText(base64) {
|
|
103
|
+
return Buffer.from(base64, "base64").toString("utf8");
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
10
106
|
//#region src/input-queue.ts
|
|
11
107
|
/**
|
|
12
108
|
* Push-based single-consumer AsyncIterable bridging imperative sendMessage() calls
|
|
@@ -78,6 +174,171 @@ function toApiMessage(message) {
|
|
|
78
174
|
};
|
|
79
175
|
}
|
|
80
176
|
/**
|
|
177
|
+
* Plan rate-limit windows from the CLI's structured `/usage` data, as `rate_limit`
|
|
178
|
+
* events — the same shape a live `rate_limit_event` produces.
|
|
179
|
+
*
|
|
180
|
+
* Without this a client shows no usage at all until a window *changes*, which the
|
|
181
|
+
* CLI only reports after a turn moves the needle, and never for a session that is
|
|
182
|
+
* only being watched. Polling the snapshot and forwarding it through the existing
|
|
183
|
+
* event means replay, the dashboard and the iOS app all get it for free, with no
|
|
184
|
+
* new protocol surface.
|
|
185
|
+
*
|
|
186
|
+
* `status` is not per-window in the usage payload — 'allowed' is what a session
|
|
187
|
+
* the CLI is running for us is, by construction. A window with no utilization is
|
|
188
|
+
* unknown, not zero, and is dropped rather than reported at 0%.
|
|
189
|
+
*/
|
|
190
|
+
function rateLimitEventsFromUsage(usage) {
|
|
191
|
+
if (!usage.rate_limits_available || !usage.rate_limits) return [];
|
|
192
|
+
const limits = usage.rate_limits;
|
|
193
|
+
const events = [];
|
|
194
|
+
const seen = /* @__PURE__ */ new Set();
|
|
195
|
+
const push = (rateLimitType, window) => {
|
|
196
|
+
if (!window || window.utilization === null || seen.has(rateLimitType)) return;
|
|
197
|
+
seen.add(rateLimitType);
|
|
198
|
+
const resetsAt = window.resets_at ? Date.parse(window.resets_at) : NaN;
|
|
199
|
+
events.push({
|
|
200
|
+
type: "rate_limit",
|
|
201
|
+
info: {
|
|
202
|
+
status: "allowed",
|
|
203
|
+
rateLimitType,
|
|
204
|
+
utilization: window.utilization,
|
|
205
|
+
...Number.isFinite(resetsAt) ? { resetsAt: resetsAt / 1e3 } : {}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
push("five_hour", limits.five_hour);
|
|
210
|
+
push("seven_day", limits.seven_day);
|
|
211
|
+
push("seven_day_opus", limits.seven_day_opus);
|
|
212
|
+
push("seven_day_sonnet", limits.seven_day_sonnet);
|
|
213
|
+
push("seven_day_oauth_apps", limits.seven_day_oauth_apps);
|
|
214
|
+
for (const bucket of limits.model_scoped ?? []) {
|
|
215
|
+
const slug = bucket.display_name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
216
|
+
if (slug) push(`seven_day_${slug}`, bucket);
|
|
217
|
+
}
|
|
218
|
+
return events;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The CLI's MCP status, as `McpServerStatusInfo`.
|
|
222
|
+
*
|
|
223
|
+
* The narrowing is the point: the SDK's config object carries `env` for stdio
|
|
224
|
+
* servers and `headers` for HTTP ones, and both routinely hold API tokens. This
|
|
225
|
+
* is the one place they are dropped, so no client — dashboard, phone, or a host
|
|
226
|
+
* app reading the REST route — can turn "show me my MCP servers" into a
|
|
227
|
+
* credential dump. Only the connection's identity survives.
|
|
228
|
+
*/
|
|
229
|
+
function mcpStatusInfo(status) {
|
|
230
|
+
const config = status.config;
|
|
231
|
+
const transport = config?.type ?? (config?.command ? "stdio" : void 0);
|
|
232
|
+
return {
|
|
233
|
+
name: status.name,
|
|
234
|
+
status: status.status,
|
|
235
|
+
scope: status.scope,
|
|
236
|
+
error: status.error,
|
|
237
|
+
serverInfo: status.serverInfo,
|
|
238
|
+
transport: transport === "stdio" || transport === "http" || transport === "sse" || transport === "sdk" ? transport : void 0,
|
|
239
|
+
command: config?.command,
|
|
240
|
+
args: config?.args,
|
|
241
|
+
url: config?.url,
|
|
242
|
+
tools: status.tools?.map((tool) => ({
|
|
243
|
+
name: tool.name,
|
|
244
|
+
description: tool.description,
|
|
245
|
+
annotations: tool.annotations
|
|
246
|
+
}))
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The CLI's model list, as `ModelOption[]`.
|
|
251
|
+
*
|
|
252
|
+
* Two decisions live here rather than in each client:
|
|
253
|
+
*
|
|
254
|
+
* - **`default` is dropped.** The CLI offers a row whose id is literally
|
|
255
|
+
* `default` ("Default (recommended)"), meaning "whatever I would have picked".
|
|
256
|
+
* It is a legal id to send, but it is not a model: a session running on it
|
|
257
|
+
* reports a real model, so a picker showing it has a row that can never be
|
|
258
|
+
* checked, and a status bar naming it would say "Default" for a session
|
|
259
|
+
* answering as Opus. Which model the default resolved to is a different
|
|
260
|
+
* question, and `system_init` answers it.
|
|
261
|
+
* - **`primary` is derived.** The CLI reports one flat list; Claude Code's own
|
|
262
|
+
* picker shows the newest of each family and files the rest under "more
|
|
263
|
+
* models". The list arrives newest-first, so the first row of each family is
|
|
264
|
+
* the primary one. A heuristic, but a stable one — and doing it once here
|
|
265
|
+
* means the dashboard and the phone group identically.
|
|
266
|
+
*/
|
|
267
|
+
/** What the CLI's `default` row resolves to — the model a session will answer as
|
|
268
|
+
* before it has answered anything. Dropped from the list, kept as this. */
|
|
269
|
+
function defaultModelFromSdk(models) {
|
|
270
|
+
return models.find((model) => model.value === "default")?.resolvedModel;
|
|
271
|
+
}
|
|
272
|
+
function modelOptionsFromSdk(models) {
|
|
273
|
+
const rows = models.filter((model) => model.value !== "default");
|
|
274
|
+
const derivedCounts = /* @__PURE__ */ new Map();
|
|
275
|
+
for (const model of rows) {
|
|
276
|
+
const derived = friendlyModelName(model.resolvedModel ?? model.value);
|
|
277
|
+
if (derived) derivedCounts.set(derived, (derivedCounts.get(derived) ?? 0) + 1);
|
|
278
|
+
}
|
|
279
|
+
const seenFamilies = /* @__PURE__ */ new Set();
|
|
280
|
+
return rows.map((model) => {
|
|
281
|
+
const family = modelFamily(model.resolvedModel ?? model.value);
|
|
282
|
+
const primary = !seenFamilies.has(family);
|
|
283
|
+
seenFamilies.add(family);
|
|
284
|
+
const derived = friendlyModelName(model.resolvedModel ?? model.value);
|
|
285
|
+
return {
|
|
286
|
+
value: model.value,
|
|
287
|
+
resolvedModel: model.resolvedModel,
|
|
288
|
+
displayName: derived && derivedCounts.get(derived) === 1 ? derived : model.displayName,
|
|
289
|
+
description: model.description,
|
|
290
|
+
primary,
|
|
291
|
+
reasoningEfforts: model.supportedEffortLevels ?? (model.supportsEffort === false ? [] : void 0)
|
|
292
|
+
};
|
|
293
|
+
}).map((option, index) => ({
|
|
294
|
+
option,
|
|
295
|
+
index
|
|
296
|
+
})).sort((a, b) => {
|
|
297
|
+
const rankA = familyRank(a.option);
|
|
298
|
+
const rankB = familyRank(b.option);
|
|
299
|
+
return rankA === rankB ? a.index - b.index : rankA - rankB;
|
|
300
|
+
}).map(({ option }) => option);
|
|
301
|
+
}
|
|
302
|
+
const FAMILY_ORDER = [
|
|
303
|
+
"fable",
|
|
304
|
+
"opus",
|
|
305
|
+
"sonnet",
|
|
306
|
+
"haiku"
|
|
307
|
+
];
|
|
308
|
+
function familyRank(option) {
|
|
309
|
+
const rank = FAMILY_ORDER.indexOf(modelFamily(option.resolvedModel ?? option.value));
|
|
310
|
+
return rank === -1 ? FAMILY_ORDER.length : rank;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The name a person says, from a wire model id: 'claude-opus-5[1m]' → "Opus 5",
|
|
314
|
+
* 'claude-haiku-4-5-20251001' → "Haiku 4.5".
|
|
315
|
+
*
|
|
316
|
+
* The CLI's own `displayName` is the family alone ("Opus", "Haiku") or carries a
|
|
317
|
+
* variant instead of a version ("Opus (1M context)"), and the version is the part
|
|
318
|
+
* that answers "is this the current one". It is only ever in the id, so it is
|
|
319
|
+
* read from there. Returns null when the id has no version to read — a bare
|
|
320
|
+
* alias like 'sonnet' — and the CLI's name stands.
|
|
321
|
+
*/
|
|
322
|
+
function friendlyModelName(id) {
|
|
323
|
+
const parts = (id.split("[")[0] ?? id).toLowerCase().split("-").filter(Boolean);
|
|
324
|
+
if (parts[0] === "claude") parts.shift();
|
|
325
|
+
const family = parts.shift();
|
|
326
|
+
if (!family) return null;
|
|
327
|
+
const version = parts.filter((part) => !/^\d{8}$/.test(part));
|
|
328
|
+
if (version.length === 0 || version.some((part) => !/^\d+$/.test(part))) return null;
|
|
329
|
+
return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join(".")}`;
|
|
330
|
+
}
|
|
331
|
+
/** 'claude-opus-4-8[1m]' → "opus". The vendor prefix, the context-window suffix
|
|
332
|
+
* and the version tail are all dropped; what is left is the family a person
|
|
333
|
+
* names. Unrecognisable ids become their own family, so a model this rule has
|
|
334
|
+
* never seen lands in the main list rather than being hidden. */
|
|
335
|
+
function modelFamily(id) {
|
|
336
|
+
const withoutVariant = id.split("[")[0] ?? id;
|
|
337
|
+
const parts = withoutVariant.toLowerCase().split("-");
|
|
338
|
+
if (parts[0] === "claude") parts.shift();
|
|
339
|
+
return parts[0] ?? withoutVariant;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
81
342
|
* Map one SDKMessage to a wire-protocol event body, or null for messages the runner
|
|
82
343
|
* consumes itself (system_init and session-state changes carry runner state and are
|
|
83
344
|
* emitted by the runner with extra context).
|
|
@@ -139,7 +400,7 @@ function normalizeSdkMessage(msg) {
|
|
|
139
400
|
}
|
|
140
401
|
//#endregion
|
|
141
402
|
//#region src/runner.ts
|
|
142
|
-
const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
|
|
403
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS$1 = 3e5;
|
|
143
404
|
/**
|
|
144
405
|
* One live Agent SDK session: owns the query() call, the streaming input queue, the
|
|
145
406
|
* pending-approval table, and a seq-numbered event log that subscribers can replay.
|
|
@@ -165,6 +426,9 @@ var SessionRunner = class {
|
|
|
165
426
|
#input = new InputQueue();
|
|
166
427
|
#query;
|
|
167
428
|
#capabilitiesEmitted = false;
|
|
429
|
+
/** Last plan reported by the usage poll, so `plan_info` is emitted on change
|
|
430
|
+
* rather than once per turn. */
|
|
431
|
+
#subscriptionType;
|
|
168
432
|
#started = false;
|
|
169
433
|
#closed = false;
|
|
170
434
|
#runPromise;
|
|
@@ -198,8 +462,10 @@ var SessionRunner = class {
|
|
|
198
462
|
cwd: this.#config.cwd,
|
|
199
463
|
profile: this.#config.profile,
|
|
200
464
|
engine: "claude",
|
|
465
|
+
capabilities: ENGINE_CAPABILITIES.claude,
|
|
201
466
|
model: this.#model ?? this.#config.model,
|
|
202
467
|
permissionMode: this.#permissionMode,
|
|
468
|
+
canBypassPermissions: this.#config.permissionMode === "bypassPermissions" || this.#config.allowDangerouslySkipPermissions === true,
|
|
203
469
|
apiKeySource: this.#apiKeySource,
|
|
204
470
|
createdAt: this.createdAt,
|
|
205
471
|
lastSeq: this.#seq,
|
|
@@ -226,14 +492,23 @@ var SessionRunner = class {
|
|
|
226
492
|
this.#runPromise = this.#run();
|
|
227
493
|
return this.#runPromise;
|
|
228
494
|
}
|
|
229
|
-
/** Queue a user message for the session (starts the next turn when idle).
|
|
230
|
-
|
|
495
|
+
/** Queue a user message for the session (starts the next turn when idle).
|
|
496
|
+
*
|
|
497
|
+
* `attachments` carry their own bytes; they reach the CLI as content blocks and
|
|
498
|
+
* are logged as references. A message may be attachments alone — an empty text
|
|
499
|
+
* block is not valid API input, so the text is only added when there is some. */
|
|
500
|
+
sendMessage(text, attachments) {
|
|
231
501
|
if (this.#closed) throw new Error("session is closed");
|
|
502
|
+
const blocks = attachments?.length ? attachmentContentBlocks(attachments) : [];
|
|
503
|
+
const content = blocks.length ? [...blocks, ...text ? [{
|
|
504
|
+
type: "text",
|
|
505
|
+
text
|
|
506
|
+
}] : []] : text;
|
|
232
507
|
this.#input.push({
|
|
233
508
|
type: "user",
|
|
234
509
|
message: {
|
|
235
510
|
role: "user",
|
|
236
|
-
content
|
|
511
|
+
content
|
|
237
512
|
},
|
|
238
513
|
parent_tool_use_id: null,
|
|
239
514
|
session_id: this.#sdkSessionId
|
|
@@ -245,9 +520,28 @@ var SessionRunner = class {
|
|
|
245
520
|
content: text
|
|
246
521
|
},
|
|
247
522
|
parentToolUseId: null,
|
|
523
|
+
attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
|
|
248
524
|
uuid: randomUUID()
|
|
249
525
|
});
|
|
250
526
|
}
|
|
527
|
+
/** Live MCP server status, straight from the CLI. Undefined when the engine
|
|
528
|
+
* can't answer (an injected fake query in tests) — the caller 501s rather than
|
|
529
|
+
* pretending the session has no servers. */
|
|
530
|
+
async mcpServers() {
|
|
531
|
+
const query = this.#query;
|
|
532
|
+
if (typeof query?.mcpServerStatus !== "function") return void 0;
|
|
533
|
+
return (await query.mcpServerStatus()).map(mcpStatusInfo);
|
|
534
|
+
}
|
|
535
|
+
async reconnectMcpServer(name) {
|
|
536
|
+
const query = this.#query;
|
|
537
|
+
if (typeof query?.reconnectMcpServer !== "function") throw new Error("this session cannot reconnect MCP servers");
|
|
538
|
+
await query.reconnectMcpServer(name);
|
|
539
|
+
}
|
|
540
|
+
async setMcpServerEnabled(name, enabled) {
|
|
541
|
+
const query = this.#query;
|
|
542
|
+
if (typeof query?.toggleMcpServer !== "function") throw new Error("this session cannot enable or disable MCP servers");
|
|
543
|
+
await query.toggleMcpServer(name, enabled);
|
|
544
|
+
}
|
|
251
545
|
/** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
|
|
252
546
|
resolvePermission(requestId, decision) {
|
|
253
547
|
const pending = this.#pending.get(requestId);
|
|
@@ -323,6 +617,7 @@ var SessionRunner = class {
|
|
|
323
617
|
this.#setStatus("idle");
|
|
324
618
|
this.#fetchCapabilities();
|
|
325
619
|
this.#fetchContextUsage();
|
|
620
|
+
this.#fetchRateLimits();
|
|
326
621
|
}
|
|
327
622
|
for await (const message of this.#query) this.#handleMessage(message);
|
|
328
623
|
if (!this.#closed) {
|
|
@@ -393,6 +688,7 @@ var SessionRunner = class {
|
|
|
393
688
|
maxBudgetUsd: c.maxBudgetUsd,
|
|
394
689
|
resume: c.resume,
|
|
395
690
|
forkSession: c.forkSession,
|
|
691
|
+
effort: c.reasoningEffort,
|
|
396
692
|
includePartialMessages: c.includePartialMessages ?? true,
|
|
397
693
|
canUseTool: this.#canUseTool,
|
|
398
694
|
env: c.env,
|
|
@@ -423,6 +719,7 @@ var SessionRunner = class {
|
|
|
423
719
|
this.#setStatus("running");
|
|
424
720
|
this.#fetchCapabilities();
|
|
425
721
|
this.#fetchContextUsage();
|
|
722
|
+
this.#fetchRateLimits();
|
|
426
723
|
return;
|
|
427
724
|
}
|
|
428
725
|
if (msg.type === "system" && msg.subtype === "session_state_changed") {
|
|
@@ -439,6 +736,7 @@ var SessionRunner = class {
|
|
|
439
736
|
this.#numTurns = body.numTurns;
|
|
440
737
|
if (this.#pending.size === 0) this.#setStatus("idle");
|
|
441
738
|
this.#fetchContextUsage();
|
|
739
|
+
this.#fetchRateLimits();
|
|
442
740
|
}
|
|
443
741
|
}
|
|
444
742
|
}
|
|
@@ -457,11 +755,8 @@ var SessionRunner = class {
|
|
|
457
755
|
this.#capabilitiesEmitted = true;
|
|
458
756
|
this.#emit({
|
|
459
757
|
type: "capabilities",
|
|
460
|
-
models: models
|
|
461
|
-
|
|
462
|
-
displayName: m.displayName,
|
|
463
|
-
description: m.description
|
|
464
|
-
})),
|
|
758
|
+
models: modelOptionsFromSdk(models),
|
|
759
|
+
defaultModel: defaultModelFromSdk(models),
|
|
465
760
|
commands: commands.map((c) => ({
|
|
466
761
|
name: c.name,
|
|
467
762
|
description: c.description,
|
|
@@ -495,9 +790,38 @@ var SessionRunner = class {
|
|
|
495
790
|
});
|
|
496
791
|
} catch {}
|
|
497
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* Snapshot the plan's rate-limit windows and surface them as `rate_limit`
|
|
795
|
+
* events — the same event a live `rate_limit_event` produces, so clients need
|
|
796
|
+
* nothing new to render it.
|
|
797
|
+
*
|
|
798
|
+
* The CLI only *pushes* a window when it changes, which for a session being
|
|
799
|
+
* watched rather than driven can be never; polling is what makes usage show up
|
|
800
|
+
* at all. The control request is marked experimental in the SDK, name included,
|
|
801
|
+
* so it is probed for by name and every failure is silent — one more reason
|
|
802
|
+
* this can only ever be decoration.
|
|
803
|
+
*/
|
|
804
|
+
async #fetchRateLimits() {
|
|
805
|
+
const query = this.#query;
|
|
806
|
+
const fetchUsage = query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
|
|
807
|
+
if (typeof fetchUsage !== "function") return;
|
|
808
|
+
try {
|
|
809
|
+
const usage = await fetchUsage.call(query);
|
|
810
|
+
if (this.#closed) return;
|
|
811
|
+
const subscriptionType = usage.subscription_type;
|
|
812
|
+
if (subscriptionType && subscriptionType !== this.#subscriptionType) {
|
|
813
|
+
this.#subscriptionType = subscriptionType;
|
|
814
|
+
this.#emit({
|
|
815
|
+
type: "plan_info",
|
|
816
|
+
subscriptionType
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
for (const body of rateLimitEventsFromUsage(usage)) this.#emit(body);
|
|
820
|
+
} catch {}
|
|
821
|
+
}
|
|
498
822
|
#canUseTool = (toolName, input, options) => {
|
|
499
823
|
const id = randomUUID();
|
|
500
|
-
const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
824
|
+
const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS$1;
|
|
501
825
|
const request = {
|
|
502
826
|
id,
|
|
503
827
|
toolName,
|
|
@@ -762,6 +1086,7 @@ var AiSdkRunner = class {
|
|
|
762
1086
|
cwd: this.#config.cwd ?? process.cwd(),
|
|
763
1087
|
profile: this.#config.profile,
|
|
764
1088
|
engine: "provider",
|
|
1089
|
+
capabilities: ENGINE_CAPABILITIES.provider,
|
|
765
1090
|
model: this.#modelId(),
|
|
766
1091
|
permissionMode: this.#permissionMode,
|
|
767
1092
|
createdAt: this.createdAt,
|
|
@@ -828,12 +1153,21 @@ var AiSdkRunner = class {
|
|
|
828
1153
|
} catch {}
|
|
829
1154
|
return snapshot;
|
|
830
1155
|
}
|
|
831
|
-
sendMessage(text) {
|
|
1156
|
+
sendMessage(text, attachments) {
|
|
832
1157
|
if (this.#parked) throw new Error("session is parked");
|
|
833
1158
|
if (this.#closed) throw new Error("session is closed");
|
|
1159
|
+
const content = attachments?.length ? [...attachments.map((attachment) => ({
|
|
1160
|
+
type: "file",
|
|
1161
|
+
data: attachment.data,
|
|
1162
|
+
mediaType: normalizeMediaType(attachment.mediaType),
|
|
1163
|
+
filename: attachment.name
|
|
1164
|
+
})), ...text ? [{
|
|
1165
|
+
type: "text",
|
|
1166
|
+
text
|
|
1167
|
+
}] : []] : text;
|
|
834
1168
|
this.#messages.push({
|
|
835
1169
|
role: "user",
|
|
836
|
-
content
|
|
1170
|
+
content
|
|
837
1171
|
});
|
|
838
1172
|
this.#emit({
|
|
839
1173
|
type: "user_message",
|
|
@@ -842,6 +1176,7 @@ var AiSdkRunner = class {
|
|
|
842
1176
|
content: text
|
|
843
1177
|
},
|
|
844
1178
|
parentToolUseId: null,
|
|
1179
|
+
attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
|
|
845
1180
|
uuid: randomUUID()
|
|
846
1181
|
});
|
|
847
1182
|
this.#scheduleTurn();
|
|
@@ -2319,6 +2654,1963 @@ function toTransport(server) {
|
|
|
2319
2654
|
};
|
|
2320
2655
|
}
|
|
2321
2656
|
//#endregion
|
|
2322
|
-
|
|
2657
|
+
//#region src/engines/claude/catalog.ts
|
|
2658
|
+
/**
|
|
2659
|
+
* The Claude engine's model catalog — what a create form offers before any
|
|
2660
|
+
* session has run.
|
|
2661
|
+
*
|
|
2662
|
+
* **Refresh procedure** (release checklist): run `supportedModels()` on a
|
|
2663
|
+
* throwaway SDK query (no tokens spent) and re-apply the shaping rules of
|
|
2664
|
+
* `modelOptionsFromSdk` (`src/normalize.ts`) at authoring time: drop the
|
|
2665
|
+
* `default` sentinel row, derive display names from resolved ids where
|
|
2666
|
+
* unambiguous, mark the newest of each family `primary`, sort by family rank.
|
|
2667
|
+
* A unit test replays the raw extraction through `modelOptionsFromSdk` and
|
|
2668
|
+
* asserts these rows match, so the rules cannot drift.
|
|
2669
|
+
*
|
|
2670
|
+
* Two things the live `capabilities` event can never offer:
|
|
2671
|
+
* - rows for **older models** the CLI no longer reports (hand-maintained, the
|
|
2672
|
+
* accepted cost of a static catalog; the CLI silently downgrades an effort a
|
|
2673
|
+
* model doesn't support, so `reasoningEfforts` is omitted on them and the
|
|
2674
|
+
* engine default set applies);
|
|
2675
|
+
* - an answer on a **cold server**. The live event still exists and remains
|
|
2676
|
+
* the in-session truth for the model switcher; this catalog is the
|
|
2677
|
+
* create-form truth.
|
|
2678
|
+
*
|
|
2679
|
+
* `defaultModel` is deliberately NOT here: a claude profile's default is the
|
|
2680
|
+
* operator's CLI config, unknowable statically.
|
|
2681
|
+
*/
|
|
2682
|
+
const CLAUDE_CATALOG = {
|
|
2683
|
+
provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.221 (Claude Code CLI), extracted 2026-08-05; older-model rows hand-maintained",
|
|
2684
|
+
models: [
|
|
2685
|
+
{
|
|
2686
|
+
value: "claude-fable-5[1m]",
|
|
2687
|
+
resolvedModel: "claude-fable-5",
|
|
2688
|
+
displayName: "Fable 5",
|
|
2689
|
+
description: "Fable 5 · Most capable for your hardest and longest-running tasks",
|
|
2690
|
+
primary: true,
|
|
2691
|
+
reasoningEfforts: [
|
|
2692
|
+
"low",
|
|
2693
|
+
"medium",
|
|
2694
|
+
"high",
|
|
2695
|
+
"xhigh",
|
|
2696
|
+
"max"
|
|
2697
|
+
]
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
value: "opus[1m]",
|
|
2701
|
+
resolvedModel: "claude-opus-5[1m]",
|
|
2702
|
+
displayName: "Opus 5",
|
|
2703
|
+
description: "Opus 5 with 1M context · Best for everyday, complex tasks",
|
|
2704
|
+
primary: true,
|
|
2705
|
+
reasoningEfforts: [
|
|
2706
|
+
"low",
|
|
2707
|
+
"medium",
|
|
2708
|
+
"high",
|
|
2709
|
+
"xhigh",
|
|
2710
|
+
"max"
|
|
2711
|
+
]
|
|
2712
|
+
},
|
|
2713
|
+
{
|
|
2714
|
+
value: "claude-opus-4-8",
|
|
2715
|
+
resolvedModel: "claude-opus-4-8",
|
|
2716
|
+
displayName: "Opus 4.8",
|
|
2717
|
+
description: "Opus 4.8 · Previous Opus generation"
|
|
2718
|
+
},
|
|
2719
|
+
{
|
|
2720
|
+
value: "sonnet",
|
|
2721
|
+
resolvedModel: "claude-sonnet-5",
|
|
2722
|
+
displayName: "Sonnet 5",
|
|
2723
|
+
description: "Sonnet 5 · Efficient for routine tasks",
|
|
2724
|
+
primary: true,
|
|
2725
|
+
reasoningEfforts: [
|
|
2726
|
+
"low",
|
|
2727
|
+
"medium",
|
|
2728
|
+
"high",
|
|
2729
|
+
"xhigh",
|
|
2730
|
+
"max"
|
|
2731
|
+
]
|
|
2732
|
+
},
|
|
2733
|
+
{
|
|
2734
|
+
value: "claude-sonnet-4-6",
|
|
2735
|
+
resolvedModel: "claude-sonnet-4-6",
|
|
2736
|
+
displayName: "Sonnet 4.6",
|
|
2737
|
+
description: "Sonnet 4.6 · Previous Sonnet generation"
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
value: "haiku",
|
|
2741
|
+
resolvedModel: "claude-haiku-4-5-20251001",
|
|
2742
|
+
displayName: "Haiku 4.5",
|
|
2743
|
+
description: "Haiku 4.5 · Fastest for quick answers",
|
|
2744
|
+
primary: true,
|
|
2745
|
+
reasoningEfforts: []
|
|
2746
|
+
}
|
|
2747
|
+
]
|
|
2748
|
+
};
|
|
2749
|
+
//#endregion
|
|
2750
|
+
//#region src/engines/claude/adapter.ts
|
|
2751
|
+
/**
|
|
2752
|
+
* The Claude engine as an adapter — a thin, behaviourally inert wrapper:
|
|
2753
|
+
* `SessionRunner` unchanged, `checkClaudeAuth` as the probe, the static
|
|
2754
|
+
* catalog for create forms. Exists so catalogs, capabilities and availability
|
|
2755
|
+
* have one shape across engines; the runner itself is exactly what
|
|
2756
|
+
* `registry.prepare()` builds.
|
|
2757
|
+
*/
|
|
2758
|
+
const claudeAdapter = {
|
|
2759
|
+
engine: "claude",
|
|
2760
|
+
capabilities: ENGINE_CAPABILITIES.claude,
|
|
2761
|
+
catalog: CLAUDE_CATALOG,
|
|
2762
|
+
async checkAvailability(profile, env) {
|
|
2763
|
+
const status = await checkClaudeAuth(env);
|
|
2764
|
+
if (status === "logged_in") return { available: true };
|
|
2765
|
+
if (status === "logged_out") return {
|
|
2766
|
+
available: false,
|
|
2767
|
+
reason: `no usable Claude credentials for this profile's environment — log in under its config dir (CLAUDE_CONFIG_DIR=${profile.configDir ?? "~/.claude"} claude auth login) or set ANTHROPIC_API_KEY`
|
|
2768
|
+
};
|
|
2769
|
+
return { available: "unknown" };
|
|
2770
|
+
},
|
|
2771
|
+
createRunner({ config, restore }) {
|
|
2772
|
+
if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
|
|
2773
|
+
return new SessionRunner(config);
|
|
2774
|
+
},
|
|
2775
|
+
/**
|
|
2776
|
+
* The Agent SDK's on-disk session store, mapped browser-safe. The SDK reads
|
|
2777
|
+
* the store of the *process* environment — it takes no config dir — so a
|
|
2778
|
+
* profile pin cannot narrow this listing; that matches the route's
|
|
2779
|
+
* pre-adapter behavior exactly (the listing was always process-global).
|
|
2780
|
+
*/
|
|
2781
|
+
async listSessions({ dir, limit, offset }) {
|
|
2782
|
+
return (await listSessions({
|
|
2783
|
+
dir,
|
|
2784
|
+
limit,
|
|
2785
|
+
offset
|
|
2786
|
+
})).map((s) => ({
|
|
2787
|
+
sessionId: s.sessionId,
|
|
2788
|
+
summary: s.summary,
|
|
2789
|
+
lastModified: s.lastModified,
|
|
2790
|
+
createdAt: s.createdAt,
|
|
2791
|
+
customTitle: s.customTitle,
|
|
2792
|
+
firstPrompt: s.firstPrompt,
|
|
2793
|
+
gitBranch: s.gitBranch,
|
|
2794
|
+
cwd: s.cwd
|
|
2795
|
+
}));
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
//#endregion
|
|
2799
|
+
//#region src/engines/codex/jsonrpc.ts
|
|
2800
|
+
/**
|
|
2801
|
+
* A JSON-RPC error response from the peer, or one we return to it. `code`
|
|
2802
|
+
* follows the JSON-RPC 2.0 reserved ranges (-32601 = method not found).
|
|
2803
|
+
*/
|
|
2804
|
+
var JsonRpcError = class extends Error {
|
|
2805
|
+
code;
|
|
2806
|
+
constructor(code, message) {
|
|
2807
|
+
super(message);
|
|
2808
|
+
this.name = "JsonRpcError";
|
|
2809
|
+
this.code = code;
|
|
2810
|
+
}
|
|
2811
|
+
};
|
|
2812
|
+
/**
|
|
2813
|
+
* JSON-RPC over a `codex app-server` child's stdio: **newline-delimited JSON**,
|
|
2814
|
+
* one message per line, and — verified against 0.146.0 — an envelope *without*
|
|
2815
|
+
* the `jsonrpc: "2.0"` field (`{id, method, params}` / `{id, result}` /
|
|
2816
|
+
* `{id, error}`; the binary's own schema marks only those required). Server→
|
|
2817
|
+
* client notifications additionally carry a top-level `emittedAtMs`, ignored
|
|
2818
|
+
* here.
|
|
2819
|
+
*
|
|
2820
|
+
* Transport only: no method knowledge, no process ownership. The process
|
|
2821
|
+
* wrapper (`process.ts`) owns the child and calls {@link fail} when it dies so
|
|
2822
|
+
* every in-flight request rejects instead of hanging.
|
|
2823
|
+
*/
|
|
2824
|
+
var JsonRpcStdioConnection = class {
|
|
2825
|
+
#output;
|
|
2826
|
+
#nextId = 1;
|
|
2827
|
+
#pending = /* @__PURE__ */ new Map();
|
|
2828
|
+
#buffer = "";
|
|
2829
|
+
#closed = false;
|
|
2830
|
+
#notificationHandler;
|
|
2831
|
+
#requestHandler;
|
|
2832
|
+
constructor(options) {
|
|
2833
|
+
this.#output = options.output;
|
|
2834
|
+
options.input.on("data", (chunk) => this.#feed(String(chunk)));
|
|
2835
|
+
options.input.on("error", () => {});
|
|
2836
|
+
options.output.on("error", () => {});
|
|
2837
|
+
}
|
|
2838
|
+
request(method, params) {
|
|
2839
|
+
if (this.#closed) return Promise.reject(/* @__PURE__ */ new Error(`codex app-server is closed (${method})`));
|
|
2840
|
+
const id = this.#nextId++;
|
|
2841
|
+
return new Promise((resolve, reject) => {
|
|
2842
|
+
this.#pending.set(id, {
|
|
2843
|
+
method,
|
|
2844
|
+
resolve,
|
|
2845
|
+
reject
|
|
2846
|
+
});
|
|
2847
|
+
this.#write({
|
|
2848
|
+
id,
|
|
2849
|
+
method,
|
|
2850
|
+
...params === void 0 ? {} : { params }
|
|
2851
|
+
});
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
notify(method, params) {
|
|
2855
|
+
if (this.#closed) return;
|
|
2856
|
+
this.#write({
|
|
2857
|
+
method,
|
|
2858
|
+
...params === void 0 ? {} : { params }
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
onNotification(handler) {
|
|
2862
|
+
this.#notificationHandler = handler;
|
|
2863
|
+
}
|
|
2864
|
+
onRequest(handler) {
|
|
2865
|
+
this.#requestHandler = handler;
|
|
2866
|
+
}
|
|
2867
|
+
/** Reject everything in flight and refuse new traffic — the child is gone
|
|
2868
|
+
* (or the session is over). Idempotent. */
|
|
2869
|
+
fail(message) {
|
|
2870
|
+
if (this.#closed) return;
|
|
2871
|
+
this.#closed = true;
|
|
2872
|
+
const pending = [...this.#pending.values()];
|
|
2873
|
+
this.#pending.clear();
|
|
2874
|
+
for (const entry of pending) entry.reject(/* @__PURE__ */ new Error(`${message} (awaiting ${entry.method})`));
|
|
2875
|
+
}
|
|
2876
|
+
#write(payload) {
|
|
2877
|
+
try {
|
|
2878
|
+
this.#output.write(JSON.stringify(payload) + "\n");
|
|
2879
|
+
} catch {}
|
|
2880
|
+
}
|
|
2881
|
+
#feed(chunk) {
|
|
2882
|
+
this.#buffer += chunk;
|
|
2883
|
+
let newline;
|
|
2884
|
+
while ((newline = this.#buffer.indexOf("\n")) >= 0) {
|
|
2885
|
+
const line = this.#buffer.slice(0, newline).trim();
|
|
2886
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
2887
|
+
if (!line) continue;
|
|
2888
|
+
let message;
|
|
2889
|
+
try {
|
|
2890
|
+
message = JSON.parse(line);
|
|
2891
|
+
} catch {
|
|
2892
|
+
continue;
|
|
2893
|
+
}
|
|
2894
|
+
this.#dispatch(message);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
#dispatch(message) {
|
|
2898
|
+
const { id, method } = message;
|
|
2899
|
+
if (typeof method === "string") {
|
|
2900
|
+
if (id === void 0 || id === null) {
|
|
2901
|
+
this.#notificationHandler?.(method, message.params);
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
const respond = (payload) => this.#write({
|
|
2905
|
+
id,
|
|
2906
|
+
...payload
|
|
2907
|
+
});
|
|
2908
|
+
const handler = this.#requestHandler;
|
|
2909
|
+
if (!handler) {
|
|
2910
|
+
respond({ error: {
|
|
2911
|
+
code: -32601,
|
|
2912
|
+
message: `no handler for server request '${method}'`
|
|
2913
|
+
} });
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
handler(method, message.params, id).then((result) => respond({ result: result ?? {} }), (error) => respond({ error: {
|
|
2917
|
+
code: error instanceof JsonRpcError ? error.code : -32603,
|
|
2918
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2919
|
+
} }));
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
if (id === void 0 || id === null) return;
|
|
2923
|
+
const pending = this.#pending.get(id);
|
|
2924
|
+
if (!pending) return;
|
|
2925
|
+
this.#pending.delete(id);
|
|
2926
|
+
if (message.error !== void 0 && message.error !== null) {
|
|
2927
|
+
const error = message.error;
|
|
2928
|
+
pending.reject(new JsonRpcError(error.code ?? -32603, error.message ?? `request '${pending.method}' failed`));
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
pending.resolve(message.result);
|
|
2932
|
+
}
|
|
2933
|
+
};
|
|
2934
|
+
//#endregion
|
|
2935
|
+
//#region src/engines/codex/runner.ts
|
|
2936
|
+
/**
|
|
2937
|
+
* thread/start's sandbox axis (string form) — our permission modes as codex
|
|
2938
|
+
* sandbox modes: `default` → read-only (reads run; any mutation is refused by
|
|
2939
|
+
* the OS sandbox and — with the ask policy below — escalates to a real
|
|
2940
|
+
* question), `acceptEdits` → workspace-write (in-workspace writes sail
|
|
2941
|
+
* through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
|
|
2942
|
+
*/
|
|
2943
|
+
const THREAD_SANDBOX_BY_MODE = {
|
|
2944
|
+
default: "read-only",
|
|
2945
|
+
acceptEdits: "workspace-write",
|
|
2946
|
+
bypassPermissions: "danger-full-access"
|
|
2947
|
+
};
|
|
2948
|
+
/** turn/start's sandboxPolicy axis (object form — same policy, second shape). */
|
|
2949
|
+
const TURN_SANDBOX_BY_MODE = {
|
|
2950
|
+
default: { type: "readOnly" },
|
|
2951
|
+
acceptEdits: { type: "workspaceWrite" },
|
|
2952
|
+
bypassPermissions: { type: "dangerFullAccess" }
|
|
2953
|
+
};
|
|
2954
|
+
/**
|
|
2955
|
+
* The approval axis, stated as the GRANULAR object on both thread/start and
|
|
2956
|
+
* turn/start — never the string vocabulary, deliberately and unconditionally:
|
|
2957
|
+
* measured against 0.146.0, plain `'untrusted'` never asked anything (a
|
|
2958
|
+
* sandbox-violating write was silently refused, a safe echo auto-approved),
|
|
2959
|
+
* while the granular flags make a blocked action a real server→client
|
|
2960
|
+
* question. Granular policies are gated on `capabilities.experimentalApi` at
|
|
2961
|
+
* initialize; WorkerDeck declares it always and keeps NO non-experimental
|
|
2962
|
+
* fallback — a future binary that rejects either gate fails loudly (see
|
|
2963
|
+
* {@link CodexRunner.#ensureThread}) instead of quietly not asking.
|
|
2964
|
+
*
|
|
2965
|
+
* `default`/`acceptEdits` ask (all flags on — the sandbox axis above already
|
|
2966
|
+
* decides *what needs asking*); `bypassPermissions` asks nothing, same shape.
|
|
2967
|
+
*/
|
|
2968
|
+
const GRANULAR_ASK = { granular: {
|
|
2969
|
+
sandbox_approval: true,
|
|
2970
|
+
rules: true,
|
|
2971
|
+
mcp_elicitations: true,
|
|
2972
|
+
request_permissions: true,
|
|
2973
|
+
skill_approval: true
|
|
2974
|
+
} };
|
|
2975
|
+
const APPROVAL_POLICY_BY_MODE = {
|
|
2976
|
+
default: GRANULAR_ASK,
|
|
2977
|
+
acceptEdits: GRANULAR_ASK,
|
|
2978
|
+
bypassPermissions: { granular: {
|
|
2979
|
+
sandbox_approval: false,
|
|
2980
|
+
rules: false,
|
|
2981
|
+
mcp_elicitations: false,
|
|
2982
|
+
request_permissions: false,
|
|
2983
|
+
skill_approval: false
|
|
2984
|
+
} }
|
|
2985
|
+
};
|
|
2986
|
+
/** Fallback timeout for a pending approval nobody answers — the SessionRunner
|
|
2987
|
+
* default, so unattended codex sessions land the same way Claude ones do. */
|
|
2988
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
|
|
2989
|
+
/**
|
|
2990
|
+
* The experimental per-request decision list, normalized to names: a string
|
|
2991
|
+
* entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
|
|
2992
|
+
* …}`) is named by its key. Undefined = the request stated no list and the
|
|
2993
|
+
* channel's schema enum applies. Present only under `experimentalApi: true` —
|
|
2994
|
+
* which WorkerDeck always declares.
|
|
2995
|
+
*/
|
|
2996
|
+
function offeredDecisions(params) {
|
|
2997
|
+
const raw = params?.availableDecisions;
|
|
2998
|
+
if (!Array.isArray(raw)) return void 0;
|
|
2999
|
+
const names = /* @__PURE__ */ new Set();
|
|
3000
|
+
for (const entry of raw) if (typeof entry === "string") names.add(entry);
|
|
3001
|
+
else if (entry && typeof entry === "object") for (const key of Object.keys(entry)) names.add(key);
|
|
3002
|
+
return names.size > 0 ? names : void 0;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Decision picking for the `{decision: …}` channels (commandExecution,
|
|
3006
|
+
* fileChange), honoring the request's own `availableDecisions`:
|
|
3007
|
+
*
|
|
3008
|
+
* - allow → 'accept' when offered (or when no list was stated). A request
|
|
3009
|
+
* offering only the broader accepts ('acceptForSession',
|
|
3010
|
+
* 'acceptWithExecpolicyAmendment') yields undefined: a one-shot allow must
|
|
3011
|
+
* not be silently widened into a session-wide or persistent policy grant, so
|
|
3012
|
+
* the caller answers with the denial and says why.
|
|
3013
|
+
* - deny → 'decline', always: the response schema declares it unconditionally,
|
|
3014
|
+
* and it was verified live against 0.146.0 answering a request whose
|
|
3015
|
+
* availableDecisions omitted it — the turn completed cleanly. The list's job
|
|
3016
|
+
* is to gate the accept variants, not to take "no, but keep going" away
|
|
3017
|
+
* (its own alternative, 'cancel', would interrupt the whole turn).
|
|
3018
|
+
* - deny+interrupt → 'cancel' (codex's deny-and-interrupt) when offered;
|
|
3019
|
+
* otherwise 'decline', and the caller interrupts the turn itself.
|
|
3020
|
+
*/
|
|
3021
|
+
function pickDecision(behavior, interrupt, offered) {
|
|
3022
|
+
const has = (name) => !offered || offered.has(name);
|
|
3023
|
+
if (behavior === "allow") return has("accept") ? "accept" : void 0;
|
|
3024
|
+
if (interrupt && has("cancel")) return "cancel";
|
|
3025
|
+
return "decline";
|
|
3026
|
+
}
|
|
3027
|
+
/** Codex `requestUserInput` questions in the AskUserQuestion wire shape both
|
|
3028
|
+
* clients already render (QuestionPrompt / QuestionPromptView). */
|
|
3029
|
+
function userQuestionsFromCodex(questions) {
|
|
3030
|
+
return questions.map((question) => ({
|
|
3031
|
+
question: question.question,
|
|
3032
|
+
header: question.header ?? "",
|
|
3033
|
+
options: (question.options ?? []).map((option) => ({
|
|
3034
|
+
label: option.label,
|
|
3035
|
+
description: option.description
|
|
3036
|
+
}))
|
|
3037
|
+
}));
|
|
3038
|
+
}
|
|
3039
|
+
/** The text of a history `userMessage` item: its content entries' text parts
|
|
3040
|
+
* joined. Image parts have no replayable representation (the bytes went to the
|
|
3041
|
+
* model, not into the rollout we can render from) and are skipped. */
|
|
3042
|
+
function historyUserText(item) {
|
|
3043
|
+
if (!Array.isArray(item.content)) return "";
|
|
3044
|
+
return item.content.map((part) => {
|
|
3045
|
+
const candidate = part;
|
|
3046
|
+
return candidate?.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
|
|
3047
|
+
}).filter(Boolean).join("\n");
|
|
3048
|
+
}
|
|
3049
|
+
/** The AskUserQuestion answer convention (question text → chosen label(s),
|
|
3050
|
+
* comma-joined) mapped back to codex's id-keyed shape. Questions the client
|
|
3051
|
+
* did not answer are absent, not empty. */
|
|
3052
|
+
function codexAnswers(questions, answers) {
|
|
3053
|
+
const out = {};
|
|
3054
|
+
for (const question of questions) {
|
|
3055
|
+
const value = answers?.[question.question] ?? answers?.[question.id];
|
|
3056
|
+
if (typeof value === "string" && value.length > 0) out[question.id] = { answers: [value] };
|
|
3057
|
+
}
|
|
3058
|
+
return out;
|
|
3059
|
+
}
|
|
3060
|
+
/** The two channels whose response is `{decision: …}` share their pick logic. */
|
|
3061
|
+
function decisionChannel(describe, itemId) {
|
|
3062
|
+
return {
|
|
3063
|
+
describe,
|
|
3064
|
+
itemId,
|
|
3065
|
+
allow: (_params, _updatedInput, offered) => {
|
|
3066
|
+
const decision = pickDecision("allow", false, offered);
|
|
3067
|
+
return decision ? {
|
|
3068
|
+
response: { decision },
|
|
3069
|
+
decision
|
|
3070
|
+
} : void 0;
|
|
3071
|
+
},
|
|
3072
|
+
deny: (_params, interrupt, offered) => {
|
|
3073
|
+
const decision = pickDecision("deny", interrupt, offered);
|
|
3074
|
+
return {
|
|
3075
|
+
response: { decision },
|
|
3076
|
+
decision
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* The ask channels, wired to the permission surface. Anything not listed here
|
|
3083
|
+
* still gets a JSON-RPC -32601 — never a hang (an unanswered server request
|
|
3084
|
+
* wedges the turn).
|
|
3085
|
+
*/
|
|
3086
|
+
const APPROVAL_CHANNELS = {
|
|
3087
|
+
"item/commandExecution/requestApproval": decisionChannel((raw) => {
|
|
3088
|
+
const params = raw;
|
|
3089
|
+
const command = params.command ?? void 0;
|
|
3090
|
+
return {
|
|
3091
|
+
toolName: "CodexCommand",
|
|
3092
|
+
input: {
|
|
3093
|
+
...command !== void 0 ? { command } : {},
|
|
3094
|
+
...params.cwd ? { cwd: params.cwd } : {},
|
|
3095
|
+
...params.reason ? { reason: params.reason } : {}
|
|
3096
|
+
},
|
|
3097
|
+
title: params.reason ?? (command ? `Codex wants to run: ${command}` : "Codex wants to run a command"),
|
|
3098
|
+
displayName: "Run command",
|
|
3099
|
+
description: params.reason && command ? command : params.cwd ?? void 0,
|
|
3100
|
+
decisionReason: params.reason ?? void 0
|
|
3101
|
+
};
|
|
3102
|
+
}, (raw) => raw.itemId),
|
|
3103
|
+
"item/fileChange/requestApproval": decisionChannel((raw) => {
|
|
3104
|
+
const params = raw;
|
|
3105
|
+
return {
|
|
3106
|
+
toolName: "CodexFileChange",
|
|
3107
|
+
input: {
|
|
3108
|
+
...params.grantRoot ? { grantRoot: params.grantRoot } : {},
|
|
3109
|
+
...params.reason ? { reason: params.reason } : {}
|
|
3110
|
+
},
|
|
3111
|
+
title: params.reason ?? "Codex wants to apply file changes",
|
|
3112
|
+
displayName: "Apply file changes",
|
|
3113
|
+
description: params.grantRoot ? `write access under ${params.grantRoot}` : void 0,
|
|
3114
|
+
decisionReason: params.reason ?? void 0
|
|
3115
|
+
};
|
|
3116
|
+
}, (raw) => raw.itemId),
|
|
3117
|
+
"item/permissions/requestApproval": {
|
|
3118
|
+
describe: (raw) => {
|
|
3119
|
+
const params = raw;
|
|
3120
|
+
return {
|
|
3121
|
+
toolName: "CodexPermissions",
|
|
3122
|
+
input: {
|
|
3123
|
+
...params.permissions ? { permissions: params.permissions } : {},
|
|
3124
|
+
...params.cwd ? { cwd: params.cwd } : {},
|
|
3125
|
+
...params.reason ? { reason: params.reason } : {}
|
|
3126
|
+
},
|
|
3127
|
+
title: params.reason ?? "Codex requests additional permissions",
|
|
3128
|
+
displayName: "Grant permissions",
|
|
3129
|
+
description: void 0,
|
|
3130
|
+
decisionReason: params.reason ?? void 0
|
|
3131
|
+
};
|
|
3132
|
+
},
|
|
3133
|
+
itemId: (raw) => raw.itemId,
|
|
3134
|
+
allow: (raw, updatedInput) => ({ response: { permissions: updatedInput?.permissions ?? raw.permissions ?? {} } }),
|
|
3135
|
+
deny: () => ({ response: { permissions: {} } })
|
|
3136
|
+
},
|
|
3137
|
+
"item/tool/requestUserInput": {
|
|
3138
|
+
describe: (raw) => ({
|
|
3139
|
+
toolName: "AskUserQuestion",
|
|
3140
|
+
input: { questions: userQuestionsFromCodex(raw.questions ?? []) },
|
|
3141
|
+
title: "Codex asks a question",
|
|
3142
|
+
displayName: "Answer questions",
|
|
3143
|
+
description: void 0,
|
|
3144
|
+
decisionReason: void 0
|
|
3145
|
+
}),
|
|
3146
|
+
itemId: (raw) => raw.itemId,
|
|
3147
|
+
allow: (raw, updatedInput) => ({ response: { answers: codexAnswers(raw.questions ?? [], updatedInput?.answers) } }),
|
|
3148
|
+
deny: () => ({ response: { answers: {} } })
|
|
3149
|
+
},
|
|
3150
|
+
"mcpServer/elicitation/request": {
|
|
3151
|
+
describe: (raw) => {
|
|
3152
|
+
const params = raw;
|
|
3153
|
+
return {
|
|
3154
|
+
toolName: "CodexMcpElicitation",
|
|
3155
|
+
input: {
|
|
3156
|
+
...params.serverName ? { serverName: params.serverName } : {},
|
|
3157
|
+
...params.message ? { message: params.message } : {},
|
|
3158
|
+
...params.mode ? { mode: params.mode } : {},
|
|
3159
|
+
...params.requestedSchema !== void 0 ? { requestedSchema: params.requestedSchema } : {},
|
|
3160
|
+
...params.url ? { url: params.url } : {}
|
|
3161
|
+
},
|
|
3162
|
+
title: params.serverName ? `MCP server '${params.serverName}' requests input` : "An MCP server requests input",
|
|
3163
|
+
displayName: "MCP elicitation",
|
|
3164
|
+
description: params.message ?? void 0,
|
|
3165
|
+
decisionReason: void 0
|
|
3166
|
+
};
|
|
3167
|
+
},
|
|
3168
|
+
itemId: () => void 0,
|
|
3169
|
+
allow: (_raw, updatedInput) => ({ response: {
|
|
3170
|
+
action: "accept",
|
|
3171
|
+
...updatedInput !== void 0 ? { content: updatedInput } : {}
|
|
3172
|
+
} }),
|
|
3173
|
+
deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
3176
|
+
/**
|
|
3177
|
+
* Name a subscription window by its measured length, so codex's positional
|
|
3178
|
+
* windows land in the protocol's named vocabulary. The two names clients
|
|
3179
|
+
* already understand are exact matches for codex's durations (300 min = 5h,
|
|
3180
|
+
* 10080 min = 7d); anything else keeps a self-describing key rather than
|
|
3181
|
+
* borrowing a name that would size it wrongly.
|
|
3182
|
+
*/
|
|
3183
|
+
function rateLimitWindowName(minutes) {
|
|
3184
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return void 0;
|
|
3185
|
+
if (minutes === 300) return "five_hour";
|
|
3186
|
+
if (minutes === 10080) return "seven_day";
|
|
3187
|
+
return `window_${minutes}m`;
|
|
3188
|
+
}
|
|
3189
|
+
/**
|
|
3190
|
+
* The Codex engine, over the binary's `app-server` JSON-RPC surface: ONE
|
|
3191
|
+
* `codex app-server` child per *session* (spawned lazily, held across turns),
|
|
3192
|
+
* streaming `item/agentMessage/delta` and the reasoning deltas token-by-token
|
|
3193
|
+
* (`streaming: 'token'`). Follows `SessionRunner`'s event-log/seq/status
|
|
3194
|
+
* discipline with `AiSdkRunner`'s turn-chain (one turn at a time; sendMessage
|
|
3195
|
+
* queues). The first codex transport was `codex exec --experimental-json` (one
|
|
3196
|
+
* child per turn) — retired because its JSONL carries no partial messages, so
|
|
3197
|
+
* a turn could never stream.
|
|
3198
|
+
*
|
|
3199
|
+
* A dead child is a failed *turn*, not a failed session: the thread persists
|
|
3200
|
+
* on disk, the connection is dropped, and the next message spawns a fresh
|
|
3201
|
+
* child that `thread/resume`s the same thread id.
|
|
3202
|
+
*/
|
|
3203
|
+
var CodexRunner = class {
|
|
3204
|
+
id;
|
|
3205
|
+
createdAt;
|
|
3206
|
+
#config;
|
|
3207
|
+
#events = [];
|
|
3208
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
3209
|
+
#seq = 0;
|
|
3210
|
+
#status = "starting";
|
|
3211
|
+
#sdkSessionId;
|
|
3212
|
+
#model;
|
|
3213
|
+
#permissionMode;
|
|
3214
|
+
#reasoningEffort;
|
|
3215
|
+
/** What the binary said the profile's defaults resolve to (thread/start
|
|
3216
|
+
* response) — lets `setModel(undefined)` mean "back to the default" even
|
|
3217
|
+
* though a turn/start override persists for subsequent turns. */
|
|
3218
|
+
#resolvedModel;
|
|
3219
|
+
/** Last reported ChatGPT plan, so `plan_info` is emitted once per change. */
|
|
3220
|
+
#planType;
|
|
3221
|
+
#resolvedEffort;
|
|
3222
|
+
#queue = [];
|
|
3223
|
+
#turnChain = Promise.resolve();
|
|
3224
|
+
#activeTurn;
|
|
3225
|
+
#connection;
|
|
3226
|
+
#threadLoaded = false;
|
|
3227
|
+
#numTurns = 0;
|
|
3228
|
+
#totalCostUsd;
|
|
3229
|
+
#lastActivityAt;
|
|
3230
|
+
#started = false;
|
|
3231
|
+
#closed = false;
|
|
3232
|
+
/** Session temp dir for image attachments (`localImage` takes host paths). */
|
|
3233
|
+
#imageDir;
|
|
3234
|
+
/** Pending server→client approvals, keyed by the surfaced request id. */
|
|
3235
|
+
#approvals = /* @__PURE__ */ new Map();
|
|
3236
|
+
/** True from start() until the resume backfill (the turn chain's first link)
|
|
3237
|
+
* settles — while set, sendMessage defers its user_message echo behind the
|
|
3238
|
+
* chain so a new turn can never precede or interleave the replayed history. */
|
|
3239
|
+
#backfillPending = false;
|
|
3240
|
+
/** The resumed thread's prior turns, stashed by {@link #ensureThread} from
|
|
3241
|
+
* the ONE thread/resume the backfill consumes (`partial` = the response's
|
|
3242
|
+
* turnsBackwardsCursor said older turns exist beyond this page). A mid-life
|
|
3243
|
+
* reconnect also goes through thread/resume, but with no backfill pending
|
|
3244
|
+
* nothing is stashed — history is never replayed twice. */
|
|
3245
|
+
#resumedHistory;
|
|
3246
|
+
/** Set around history replay: {@link #emit} stamps `replay: true` onto the
|
|
3247
|
+
* message events the live item mapping produces. */
|
|
3248
|
+
#replayingHistory = false;
|
|
3249
|
+
constructor(config, id = randomUUID()) {
|
|
3250
|
+
const mode = config.permissionMode ?? "default";
|
|
3251
|
+
if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
|
|
3252
|
+
if (config.forkSession) throw new Error("the codex engine cannot fork a resumed thread");
|
|
3253
|
+
this.#config = config;
|
|
3254
|
+
this.#permissionMode = mode;
|
|
3255
|
+
this.#model = config.model;
|
|
3256
|
+
this.#reasoningEffort = config.reasoningEffort;
|
|
3257
|
+
this.#sdkSessionId = config.resume;
|
|
3258
|
+
this.id = id;
|
|
3259
|
+
this.createdAt = Date.now();
|
|
3260
|
+
}
|
|
3261
|
+
/** The complete child environment — spawn env replaces process.env wholesale,
|
|
3262
|
+
* so this must carry everything a shell would, with the profile's CODEX_HOME
|
|
3263
|
+
* pin winning over operator env. */
|
|
3264
|
+
#childEnv() {
|
|
3265
|
+
const base = this.#config.env ?? process.env;
|
|
3266
|
+
const env = {};
|
|
3267
|
+
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
3268
|
+
if (this.#config.codexHome) env.CODEX_HOME = this.#config.codexHome;
|
|
3269
|
+
return env;
|
|
3270
|
+
}
|
|
3271
|
+
get status() {
|
|
3272
|
+
return this.#status;
|
|
3273
|
+
}
|
|
3274
|
+
get sdkSessionId() {
|
|
3275
|
+
return this.#sdkSessionId;
|
|
3276
|
+
}
|
|
3277
|
+
get lastSeq() {
|
|
3278
|
+
return this.#seq;
|
|
3279
|
+
}
|
|
3280
|
+
get pendingApprovals() {
|
|
3281
|
+
return [...this.#approvals.values()].map((pending) => pending.request);
|
|
3282
|
+
}
|
|
3283
|
+
info() {
|
|
3284
|
+
return {
|
|
3285
|
+
id: this.id,
|
|
3286
|
+
sdkSessionId: this.#sdkSessionId,
|
|
3287
|
+
status: this.#status,
|
|
3288
|
+
cwd: this.#config.cwd,
|
|
3289
|
+
profile: this.#config.profile,
|
|
3290
|
+
engine: "codex",
|
|
3291
|
+
capabilities: ENGINE_CAPABILITIES.codex,
|
|
3292
|
+
model: this.#model ?? this.#resolvedModel,
|
|
3293
|
+
permissionMode: this.#permissionMode,
|
|
3294
|
+
canBypassPermissions: true,
|
|
3295
|
+
createdAt: this.createdAt,
|
|
3296
|
+
lastSeq: this.#seq,
|
|
3297
|
+
pendingPermissionCount: this.#approvals.size,
|
|
3298
|
+
meta: this.#config.meta,
|
|
3299
|
+
title: this.#title(),
|
|
3300
|
+
totalCostUsd: this.#totalCostUsd,
|
|
3301
|
+
numTurns: this.#numTurns || void 0,
|
|
3302
|
+
lastActivityAt: this.#lastActivityAt
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
#title() {
|
|
3306
|
+
const metaTitle = this.#config.meta?.title;
|
|
3307
|
+
if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
|
|
3308
|
+
const prompt = this.#config.prompt;
|
|
3309
|
+
if (!prompt) return void 0;
|
|
3310
|
+
return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
|
|
3311
|
+
}
|
|
3312
|
+
start() {
|
|
3313
|
+
if (this.#started) return this.#turnChain;
|
|
3314
|
+
this.#started = true;
|
|
3315
|
+
if (this.#config.resume && this.#config.backfillHistory !== false) {
|
|
3316
|
+
this.#backfillPending = true;
|
|
3317
|
+
this.#turnChain = this.#turnChain.then(() => this.#backfillHistory());
|
|
3318
|
+
} else this.#setStatus("idle");
|
|
3319
|
+
if (this.#config.prompt) this.sendMessage(this.#config.prompt);
|
|
3320
|
+
return this.#turnChain;
|
|
3321
|
+
}
|
|
3322
|
+
sendMessage(text, attachments) {
|
|
3323
|
+
if (this.#closed) throw new Error("session is closed");
|
|
3324
|
+
const input = this.#buildInput(text, attachments ?? []);
|
|
3325
|
+
const echo = () => this.#emit({
|
|
3326
|
+
type: "user_message",
|
|
3327
|
+
message: {
|
|
3328
|
+
role: "user",
|
|
3329
|
+
content: text
|
|
3330
|
+
},
|
|
3331
|
+
parentToolUseId: null,
|
|
3332
|
+
attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
|
|
3333
|
+
uuid: randomUUID()
|
|
3334
|
+
});
|
|
3335
|
+
if (this.#backfillPending) this.#turnChain = this.#turnChain.then(echo);
|
|
3336
|
+
else echo();
|
|
3337
|
+
this.#queue.push({ input });
|
|
3338
|
+
this.#scheduleTurn();
|
|
3339
|
+
}
|
|
3340
|
+
/**
|
|
3341
|
+
* App-server input for a message with attachments: images land in a session
|
|
3342
|
+
* temp dir and travel as `localImage` host paths, text files inline into the
|
|
3343
|
+
* prompt in the shared named envelope, PDF has no representation (the
|
|
3344
|
+
* gateway's 415 normally refuses it first).
|
|
3345
|
+
*/
|
|
3346
|
+
#buildInput(text, attachments) {
|
|
3347
|
+
const parts = [];
|
|
3348
|
+
for (const attachment of attachments) {
|
|
3349
|
+
const mediaType = normalizeMediaType(attachment.mediaType);
|
|
3350
|
+
switch (attachmentKind(mediaType)) {
|
|
3351
|
+
case "image": {
|
|
3352
|
+
this.#imageDir ??= join(tmpdir(), `workerdeck-codex-${this.id}`);
|
|
3353
|
+
mkdirSync(this.#imageDir, { recursive: true });
|
|
3354
|
+
const ext = mediaType.split("/")[1] ?? "bin";
|
|
3355
|
+
const path = join(this.#imageDir, `${attachment.id}.${ext}`);
|
|
3356
|
+
writeFileSync(path, Buffer.from(attachment.data, "base64"));
|
|
3357
|
+
parts.push({
|
|
3358
|
+
type: "localImage",
|
|
3359
|
+
path
|
|
3360
|
+
});
|
|
3361
|
+
break;
|
|
3362
|
+
}
|
|
3363
|
+
case "text":
|
|
3364
|
+
parts.push({
|
|
3365
|
+
type: "text",
|
|
3366
|
+
text: `<attachment name="${attachment.name}" type="${mediaType}">\n${Buffer.from(attachment.data, "base64").toString("utf8")}\n</attachment>`
|
|
3367
|
+
});
|
|
3368
|
+
break;
|
|
3369
|
+
default: throw new Error(`unsupported attachment media type for the codex engine: ${attachment.mediaType}`);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
if (text) parts.push({
|
|
3373
|
+
type: "text",
|
|
3374
|
+
text
|
|
3375
|
+
});
|
|
3376
|
+
return parts;
|
|
3377
|
+
}
|
|
3378
|
+
/** Resolve a pending approval. Returns false if the id is unknown (e.g.
|
|
3379
|
+
* timed out, or already settled by codex itself). */
|
|
3380
|
+
resolvePermission(requestId, decision) {
|
|
3381
|
+
const pending = this.#approvals.get(requestId);
|
|
3382
|
+
if (!pending) return false;
|
|
3383
|
+
this.#settleApproval(requestId, pending, decision, "client");
|
|
3384
|
+
return true;
|
|
3385
|
+
}
|
|
3386
|
+
async interrupt() {
|
|
3387
|
+
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
3388
|
+
behavior: "deny",
|
|
3389
|
+
message: "interrupted",
|
|
3390
|
+
interrupt: true
|
|
3391
|
+
}, "policy");
|
|
3392
|
+
await this.#interruptTurn();
|
|
3393
|
+
await this.#turnChain;
|
|
3394
|
+
}
|
|
3395
|
+
/** Address the in-flight turn only (no approval sweep) — also the follow-up
|
|
3396
|
+
* for a deny+interrupt whose wire decision couldn't carry the interrupt. */
|
|
3397
|
+
async #interruptTurn() {
|
|
3398
|
+
const active = this.#activeTurn;
|
|
3399
|
+
const connection = this.#connection;
|
|
3400
|
+
if (active && !active.settled) {
|
|
3401
|
+
active.interrupted = true;
|
|
3402
|
+
if (connection && active.turnId && this.#sdkSessionId) try {
|
|
3403
|
+
await connection.request("turn/interrupt", {
|
|
3404
|
+
threadId: this.#sdkSessionId,
|
|
3405
|
+
turnId: active.turnId
|
|
3406
|
+
});
|
|
3407
|
+
} catch {}
|
|
3408
|
+
else if (connection) {
|
|
3409
|
+
connection.close();
|
|
3410
|
+
if (this.#connection === connection) this.#connection = void 0;
|
|
3411
|
+
active.reject(/* @__PURE__ */ new Error("interrupted"));
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
async setPermissionMode(mode) {
|
|
3416
|
+
if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
|
|
3417
|
+
if (this.#activeTurn) throw new Error("cannot change the permission mode mid-turn (the running turn's sandbox is fixed)");
|
|
3418
|
+
this.#permissionMode = mode;
|
|
3419
|
+
this.#emit({
|
|
3420
|
+
type: "permission_mode_changed",
|
|
3421
|
+
mode
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
async setModel(model) {
|
|
3425
|
+
if (this.#activeTurn) throw new Error("cannot change the model mid-turn (the running turn's model is fixed)");
|
|
3426
|
+
this.#model = model;
|
|
3427
|
+
this.#emit({
|
|
3428
|
+
type: "model_changed",
|
|
3429
|
+
model
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
fail(message) {
|
|
3433
|
+
if (this.#closed) return;
|
|
3434
|
+
this.#emit({
|
|
3435
|
+
type: "session_error",
|
|
3436
|
+
message
|
|
3437
|
+
});
|
|
3438
|
+
this.#setStatus("failed");
|
|
3439
|
+
this.close("error");
|
|
3440
|
+
}
|
|
3441
|
+
close(reason = "client") {
|
|
3442
|
+
if (this.#closed) return;
|
|
3443
|
+
this.#closed = true;
|
|
3444
|
+
this.#queue.length = 0;
|
|
3445
|
+
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
3446
|
+
behavior: "deny",
|
|
3447
|
+
message: "Session closed"
|
|
3448
|
+
}, "policy");
|
|
3449
|
+
this.#connection?.close();
|
|
3450
|
+
this.#connection = void 0;
|
|
3451
|
+
this.#activeTurn?.reject(/* @__PURE__ */ new Error("session closed"));
|
|
3452
|
+
if (this.#imageDir) try {
|
|
3453
|
+
rmSync(this.#imageDir, {
|
|
3454
|
+
recursive: true,
|
|
3455
|
+
force: true
|
|
3456
|
+
});
|
|
3457
|
+
} catch {}
|
|
3458
|
+
this.#emit({
|
|
3459
|
+
type: "session_closed",
|
|
3460
|
+
reason
|
|
3461
|
+
});
|
|
3462
|
+
this.#setStatus("closed");
|
|
3463
|
+
}
|
|
3464
|
+
subscribe(listener, afterSeq = 0) {
|
|
3465
|
+
for (const event of this.#events) if (event.seq > afterSeq) listener(event);
|
|
3466
|
+
this.#listeners.add(listener);
|
|
3467
|
+
return () => this.#listeners.delete(listener);
|
|
3468
|
+
}
|
|
3469
|
+
#scheduleTurn() {
|
|
3470
|
+
this.#turnChain = this.#turnChain.then(() => this.#runTurn());
|
|
3471
|
+
}
|
|
3472
|
+
/**
|
|
3473
|
+
* The session's live connection with its thread loaded, (re)building both as
|
|
3474
|
+
* needed: spawn + `initialize`/`initialized` on a fresh child, then
|
|
3475
|
+
* `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
|
|
3476
|
+
* thread orphaned by a dead child). The response's resolved model/effort are
|
|
3477
|
+
* kept so per-turn overrides can name "the profile default" explicitly.
|
|
3478
|
+
*/
|
|
3479
|
+
async #ensureThread() {
|
|
3480
|
+
if (this.#closed) throw new Error("session is closed");
|
|
3481
|
+
let connection = this.#connection;
|
|
3482
|
+
if (!connection) {
|
|
3483
|
+
connection = this.#config.connectFn({ env: this.#childEnv() });
|
|
3484
|
+
this.#connection = connection;
|
|
3485
|
+
this.#threadLoaded = false;
|
|
3486
|
+
connection.onNotification((method, params) => this.#handleNotification(method, params));
|
|
3487
|
+
connection.onRequest((method, params, id) => this.#answerServerRequest(method, params, id));
|
|
3488
|
+
connection.onClose((message) => {
|
|
3489
|
+
if (this.#connection === connection) {
|
|
3490
|
+
this.#connection = void 0;
|
|
3491
|
+
this.#threadLoaded = false;
|
|
3492
|
+
}
|
|
3493
|
+
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
3494
|
+
behavior: "deny",
|
|
3495
|
+
message
|
|
3496
|
+
}, "policy");
|
|
3497
|
+
this.#activeTurn?.reject(new Error(message));
|
|
3498
|
+
});
|
|
3499
|
+
try {
|
|
3500
|
+
await connection.request("initialize", {
|
|
3501
|
+
clientInfo: {
|
|
3502
|
+
name: "workerdeck",
|
|
3503
|
+
title: "WorkerDeck",
|
|
3504
|
+
version: `protocol-${PROTOCOL_VERSION}`
|
|
3505
|
+
},
|
|
3506
|
+
capabilities: { experimentalApi: true }
|
|
3507
|
+
});
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
connection.close();
|
|
3510
|
+
if (this.#connection === connection) this.#connection = void 0;
|
|
3511
|
+
if (error instanceof JsonRpcError) throw new Error("codex app-server rejected initialize (capabilities.experimentalApi: true — required for the granular approval policy, and WorkerDeck has no non-experimental fallback): " + error.message);
|
|
3512
|
+
throw error;
|
|
3513
|
+
}
|
|
3514
|
+
connection.notify("initialized");
|
|
3515
|
+
}
|
|
3516
|
+
if (!this.#threadLoaded) {
|
|
3517
|
+
const options = {
|
|
3518
|
+
cwd: this.#config.cwd,
|
|
3519
|
+
approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
|
|
3520
|
+
sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode]
|
|
3521
|
+
};
|
|
3522
|
+
if (this.#model) options.model = this.#model;
|
|
3523
|
+
const resuming = this.#sdkSessionId !== void 0;
|
|
3524
|
+
const result = resuming ? await connection.request("thread/resume", {
|
|
3525
|
+
threadId: this.#sdkSessionId,
|
|
3526
|
+
...options
|
|
3527
|
+
}) : await connection.request("thread/start", options);
|
|
3528
|
+
if (typeof result?.thread?.id === "string") this.#sdkSessionId = result.thread.id;
|
|
3529
|
+
if (typeof result?.model === "string") this.#resolvedModel = result.model;
|
|
3530
|
+
if (typeof result?.reasoningEffort === "string") this.#resolvedEffort = result.reasoningEffort;
|
|
3531
|
+
if (resuming && this.#backfillPending && !this.#resumedHistory) this.#resumedHistory = {
|
|
3532
|
+
turns: Array.isArray(result?.thread?.turns) ? result.thread.turns : [],
|
|
3533
|
+
partial: typeof result?.turnsBackwardsCursor === "string"
|
|
3534
|
+
};
|
|
3535
|
+
this.#threadLoaded = true;
|
|
3536
|
+
}
|
|
3537
|
+
return connection;
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* On resume, replay the thread's prior turns as `replay: true` events,
|
|
3541
|
+
* seq'd before any live turn — the SessionRunner backfill contract, fed
|
|
3542
|
+
* from `thread/resume`'s own `thread.turns`. When the resume response says
|
|
3543
|
+
* that page is partial (`turnsBackwardsCursor`), the FULL rollout history
|
|
3544
|
+
* is fetched via `thread/read {includeTurns: true}` instead — and if even
|
|
3545
|
+
* that fails, the partial page is replayed under a visible notice rather
|
|
3546
|
+
* than silently posing as the whole thread. Best-effort like the Claude
|
|
3547
|
+
* backfill: an unreadable history never blocks the resume itself.
|
|
3548
|
+
*/
|
|
3549
|
+
async #backfillHistory() {
|
|
3550
|
+
try {
|
|
3551
|
+
if (this.#closed) return;
|
|
3552
|
+
const connection = await this.#ensureThread();
|
|
3553
|
+
const resumed = this.#resumedHistory;
|
|
3554
|
+
this.#resumedHistory = void 0;
|
|
3555
|
+
let turns = resumed?.turns ?? [];
|
|
3556
|
+
let partialReason;
|
|
3557
|
+
if (resumed?.partial) try {
|
|
3558
|
+
const full = (await connection.request("thread/read", {
|
|
3559
|
+
threadId: this.#sdkSessionId,
|
|
3560
|
+
includeTurns: true
|
|
3561
|
+
}))?.thread?.turns;
|
|
3562
|
+
if (Array.isArray(full) && full.length >= turns.length) turns = full;
|
|
3563
|
+
else partialReason = "thread/read returned less history than the resume page";
|
|
3564
|
+
} catch (error) {
|
|
3565
|
+
partialReason = error instanceof Error ? error.message : String(error);
|
|
3566
|
+
}
|
|
3567
|
+
if (partialReason) this.#emit({
|
|
3568
|
+
type: "session_error",
|
|
3569
|
+
message: `Resumed thread history is incomplete — older turns could not be loaded (${partialReason})`
|
|
3570
|
+
});
|
|
3571
|
+
this.#replayTurns(turns);
|
|
3572
|
+
} catch {} finally {
|
|
3573
|
+
this.#backfillPending = false;
|
|
3574
|
+
this.#setStatus("idle");
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
/** Replay historical turns through the SAME item mapping the live path uses. */
|
|
3578
|
+
#replayTurns(turns) {
|
|
3579
|
+
for (const turn of turns) {
|
|
3580
|
+
if (this.#closed) return;
|
|
3581
|
+
const state = this.#newTurnState();
|
|
3582
|
+
this.#replayingHistory = true;
|
|
3583
|
+
try {
|
|
3584
|
+
for (const item of turn.items ?? []) {
|
|
3585
|
+
if (item.type === "userMessage") {
|
|
3586
|
+
const text = historyUserText(item);
|
|
3587
|
+
if (!text) continue;
|
|
3588
|
+
this.#emit({
|
|
3589
|
+
type: "user_message",
|
|
3590
|
+
message: {
|
|
3591
|
+
role: "user",
|
|
3592
|
+
content: text
|
|
3593
|
+
},
|
|
3594
|
+
parentToolUseId: null,
|
|
3595
|
+
uuid: `${state.nonce}:${item.id}`
|
|
3596
|
+
});
|
|
3597
|
+
continue;
|
|
3598
|
+
}
|
|
3599
|
+
this.#handleItemCompleted(item, state);
|
|
3600
|
+
}
|
|
3601
|
+
} finally {
|
|
3602
|
+
this.#replayingHistory = false;
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
/** Fresh per-turn state — one per live turn, and one per REPLAYED turn (the
|
|
3607
|
+
* nonce is the item-id namespace, and its per-turn-ness is the invariant). */
|
|
3608
|
+
#newTurnState() {
|
|
3609
|
+
return {
|
|
3610
|
+
nonce: randomUUID(),
|
|
3611
|
+
interrupted: false,
|
|
3612
|
+
usage: {
|
|
3613
|
+
inputTokens: 0,
|
|
3614
|
+
cachedInputTokens: 0,
|
|
3615
|
+
cacheWriteInputTokens: 0,
|
|
3616
|
+
outputTokens: 0,
|
|
3617
|
+
reasoningOutputTokens: 0,
|
|
3618
|
+
totalTokens: 0
|
|
3619
|
+
},
|
|
3620
|
+
sawUsage: false,
|
|
3621
|
+
toolUseEmitted: /* @__PURE__ */ new Set(),
|
|
3622
|
+
sectionIndex: /* @__PURE__ */ new Map(),
|
|
3623
|
+
settled: false,
|
|
3624
|
+
resolve: () => {},
|
|
3625
|
+
reject: () => {}
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
async #runTurn() {
|
|
3629
|
+
if (this.#closed) return;
|
|
3630
|
+
const turn = this.#queue.shift();
|
|
3631
|
+
if (!turn) return;
|
|
3632
|
+
this.#setStatus("running");
|
|
3633
|
+
const startedAt = Date.now();
|
|
3634
|
+
const active = this.#newTurnState();
|
|
3635
|
+
const outcome = new Promise((resolve, reject) => {
|
|
3636
|
+
active.resolve = (turnResult) => {
|
|
3637
|
+
if (active.settled) return;
|
|
3638
|
+
active.settled = true;
|
|
3639
|
+
resolve(turnResult);
|
|
3640
|
+
};
|
|
3641
|
+
active.reject = (error) => {
|
|
3642
|
+
if (active.settled) return;
|
|
3643
|
+
active.settled = true;
|
|
3644
|
+
reject(error);
|
|
3645
|
+
};
|
|
3646
|
+
});
|
|
3647
|
+
this.#activeTurn = active;
|
|
3648
|
+
try {
|
|
3649
|
+
const connection = await this.#ensureThread();
|
|
3650
|
+
const params = {
|
|
3651
|
+
threadId: this.#sdkSessionId,
|
|
3652
|
+
input: turn.input,
|
|
3653
|
+
cwd: this.#config.cwd,
|
|
3654
|
+
approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
|
|
3655
|
+
sandboxPolicy: TURN_SANDBOX_BY_MODE[this.#permissionMode]
|
|
3656
|
+
};
|
|
3657
|
+
const model = this.#model ?? this.#resolvedModel;
|
|
3658
|
+
if (model) params.model = model;
|
|
3659
|
+
const effort = this.#reasoningEffort ?? this.#resolvedEffort;
|
|
3660
|
+
if (effort) params.effort = effort;
|
|
3661
|
+
connection.request("turn/start", params).then((result) => {
|
|
3662
|
+
const started = result?.turn;
|
|
3663
|
+
if (!started) return;
|
|
3664
|
+
active.turnId ??= started.id;
|
|
3665
|
+
if (started.status && started.status !== "inProgress") active.resolve(started);
|
|
3666
|
+
}, (error) => active.reject(error instanceof Error ? error : new Error(String(error))));
|
|
3667
|
+
const result = await outcome;
|
|
3668
|
+
if (this.#closed) return;
|
|
3669
|
+
if (result.status === "completed") this.#finishTurn("success", startedAt, active);
|
|
3670
|
+
else {
|
|
3671
|
+
const reason = result.status === "interrupted" ? "interrupted" : result.error?.message ?? active.lastError ?? "codex app-server ended the turn without a result";
|
|
3672
|
+
this.#finishTurn("failure", startedAt, active, [reason]);
|
|
3673
|
+
}
|
|
3674
|
+
} catch (error) {
|
|
3675
|
+
if (this.#closed) return;
|
|
3676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3677
|
+
this.#finishTurn("failure", startedAt, active, [active.interrupted ? "interrupted" : message]);
|
|
3678
|
+
} finally {
|
|
3679
|
+
if (this.#activeTurn === active) this.#activeTurn = void 0;
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
#handleNotification(method, params) {
|
|
3683
|
+
if (this.#closed) return;
|
|
3684
|
+
const active = this.#activeTurn;
|
|
3685
|
+
switch (method) {
|
|
3686
|
+
case "thread/started": {
|
|
3687
|
+
const thread = params?.thread;
|
|
3688
|
+
if (typeof thread?.id === "string") this.#sdkSessionId = thread.id;
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
case "turn/started": {
|
|
3692
|
+
const turn = params?.turn;
|
|
3693
|
+
if (active && turn && !active.turnId) active.turnId = turn.id;
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
case "turn/completed": {
|
|
3697
|
+
const turn = params?.turn;
|
|
3698
|
+
if (active && turn) active.resolve(turn);
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
case "item/started":
|
|
3702
|
+
case "item/updated": {
|
|
3703
|
+
if (!active) return;
|
|
3704
|
+
const item = params?.item;
|
|
3705
|
+
if (item) this.#handleItemProgress(item, active);
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
case "item/completed": {
|
|
3709
|
+
if (!active) return;
|
|
3710
|
+
const item = params?.item;
|
|
3711
|
+
if (item) this.#handleItemCompleted(item, active);
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
case "item/agentMessage/delta": {
|
|
3715
|
+
if (!active) return;
|
|
3716
|
+
const delta = params?.delta;
|
|
3717
|
+
if (typeof delta === "string" && delta) this.#emitDelta({
|
|
3718
|
+
type: "text_delta",
|
|
3719
|
+
text: delta
|
|
3720
|
+
});
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
case "item/reasoning/textDelta":
|
|
3724
|
+
case "item/reasoning/summaryTextDelta": {
|
|
3725
|
+
if (!active) return;
|
|
3726
|
+
const payload = params;
|
|
3727
|
+
if (typeof payload?.delta !== "string" || !payload.delta) return;
|
|
3728
|
+
const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
|
|
3729
|
+
const key = `${payload.itemId ?? ""}:${method}`;
|
|
3730
|
+
const previous = active.sectionIndex.get(key);
|
|
3731
|
+
active.sectionIndex.set(key, index);
|
|
3732
|
+
const separator = previous !== void 0 && index > previous ? "\n\n" : "";
|
|
3733
|
+
this.#emitDelta({
|
|
3734
|
+
type: "thinking_delta",
|
|
3735
|
+
thinking: separator + payload.delta
|
|
3736
|
+
});
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3739
|
+
case "thread/tokenUsage/updated": {
|
|
3740
|
+
if (!active) return;
|
|
3741
|
+
const last = params?.tokenUsage?.last;
|
|
3742
|
+
if (!last) return;
|
|
3743
|
+
active.sawUsage = true;
|
|
3744
|
+
active.usage.inputTokens += last.inputTokens ?? 0;
|
|
3745
|
+
active.usage.cachedInputTokens += last.cachedInputTokens ?? 0;
|
|
3746
|
+
active.usage.cacheWriteInputTokens = (active.usage.cacheWriteInputTokens ?? 0) + (last.cacheWriteInputTokens ?? 0);
|
|
3747
|
+
active.usage.outputTokens += last.outputTokens ?? 0;
|
|
3748
|
+
active.usage.reasoningOutputTokens += last.reasoningOutputTokens ?? 0;
|
|
3749
|
+
const update = params;
|
|
3750
|
+
active.contextTokens = last.totalTokens ?? void 0;
|
|
3751
|
+
active.contextWindow = update.tokenUsage?.modelContextWindow ?? void 0;
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
case "account/rateLimits/updated":
|
|
3755
|
+
this.#emitRateLimits(params?.rateLimits);
|
|
3756
|
+
return;
|
|
3757
|
+
case "turn/plan/updated": {
|
|
3758
|
+
if (!active) return;
|
|
3759
|
+
const plan = params?.plan;
|
|
3760
|
+
if (!Array.isArray(plan)) return;
|
|
3761
|
+
this.#emit({
|
|
3762
|
+
type: "sdk_event",
|
|
3763
|
+
payload: {
|
|
3764
|
+
type: "codex.todo_list",
|
|
3765
|
+
id: `${active.nonce}:plan`,
|
|
3766
|
+
items: plan.map((step) => ({
|
|
3767
|
+
text: step.step,
|
|
3768
|
+
completed: step.status === "completed"
|
|
3769
|
+
}))
|
|
3770
|
+
}
|
|
3771
|
+
});
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
case "serverRequest/resolved": {
|
|
3775
|
+
const requestId = params?.requestId;
|
|
3776
|
+
if (requestId === void 0) return;
|
|
3777
|
+
for (const [id, pending] of this.#approvals) if (pending.wireId === requestId) {
|
|
3778
|
+
this.#settleApproval(id, pending, {
|
|
3779
|
+
behavior: "deny",
|
|
3780
|
+
message: "resolved by codex"
|
|
3781
|
+
}, "policy");
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
case "error": {
|
|
3787
|
+
const error = params?.error;
|
|
3788
|
+
if (active && typeof error?.message === "string") active.lastError = error.message;
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
default: return;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
/** Answer a server→client request: the ask channels become pending
|
|
3795
|
+
* permission requests; anything else gets a JSON-RPC -32601 rather than a
|
|
3796
|
+
* hang (an unanswered server request wedges the turn). */
|
|
3797
|
+
async #answerServerRequest(method, params, wireId) {
|
|
3798
|
+
const channel = APPROVAL_CHANNELS[method];
|
|
3799
|
+
if (channel) return this.#requestApproval(channel, method, params, wireId);
|
|
3800
|
+
throw new JsonRpcError(-32601, `workerdeck does not handle server request '${method}'`);
|
|
3801
|
+
}
|
|
3802
|
+
/**
|
|
3803
|
+
* Surface one ask-channel request as a pending {@link PermissionRequest};
|
|
3804
|
+
* the returned promise is the JSON-RPC response, resolved when a
|
|
3805
|
+
* `permission_decision` lands — or by the timeout, an interrupt, turn end,
|
|
3806
|
+
* session close, or codex resolving it itself. Never left hanging.
|
|
3807
|
+
*/
|
|
3808
|
+
#requestApproval(channel, method, params, wireId) {
|
|
3809
|
+
if (method === "item/tool/requestUserInput") {
|
|
3810
|
+
const behavior = this.#config.questionBehavior ?? "ask";
|
|
3811
|
+
if (behavior !== "ask") return Promise.resolve(this.#resolveQuestionByPolicy(channel, params, behavior));
|
|
3812
|
+
}
|
|
3813
|
+
const id = randomUUID();
|
|
3814
|
+
const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
3815
|
+
const itemId = channel.itemId(params);
|
|
3816
|
+
const request = {
|
|
3817
|
+
id,
|
|
3818
|
+
...channel.describe(params),
|
|
3819
|
+
toolUseId: itemId ? `${this.#activeTurn?.nonce ?? "codex"}:${itemId}` : id,
|
|
3820
|
+
expiresAt: Date.now() + timeoutMs
|
|
3821
|
+
};
|
|
3822
|
+
return new Promise((resolve) => {
|
|
3823
|
+
const timer = setTimeout(() => {
|
|
3824
|
+
const pending = this.#approvals.get(id);
|
|
3825
|
+
if (pending) this.#settleApproval(id, pending, {
|
|
3826
|
+
behavior: "deny",
|
|
3827
|
+
message: "Approval timed out"
|
|
3828
|
+
}, "timeout");
|
|
3829
|
+
}, timeoutMs);
|
|
3830
|
+
this.#approvals.set(id, {
|
|
3831
|
+
request,
|
|
3832
|
+
channel,
|
|
3833
|
+
params,
|
|
3834
|
+
offered: offeredDecisions(params),
|
|
3835
|
+
wireId,
|
|
3836
|
+
timer,
|
|
3837
|
+
respond: resolve
|
|
3838
|
+
});
|
|
3839
|
+
this.#emit({
|
|
3840
|
+
type: "permission_requested",
|
|
3841
|
+
request
|
|
3842
|
+
});
|
|
3843
|
+
if (this.#activeTurn) this.#setStatus("awaiting_approval");
|
|
3844
|
+
});
|
|
3845
|
+
}
|
|
3846
|
+
/** 'auto'/'deny' sessions settle codex questions synchronously instead of
|
|
3847
|
+
* pending. Request/resolved events still fire so transcripts and job
|
|
3848
|
+
* webhooks show what was chosen. */
|
|
3849
|
+
#resolveQuestionByPolicy(channel, params, mode) {
|
|
3850
|
+
const itemId = channel.itemId(params);
|
|
3851
|
+
const request = {
|
|
3852
|
+
id: randomUUID(),
|
|
3853
|
+
...channel.describe(params),
|
|
3854
|
+
toolUseId: itemId ? `${this.#activeTurn?.nonce ?? "codex"}:${itemId}` : randomUUID()
|
|
3855
|
+
};
|
|
3856
|
+
this.#emit({
|
|
3857
|
+
type: "permission_requested",
|
|
3858
|
+
request
|
|
3859
|
+
});
|
|
3860
|
+
if (mode === "deny") {
|
|
3861
|
+
this.#emit({
|
|
3862
|
+
type: "permission_resolved",
|
|
3863
|
+
requestId: request.id,
|
|
3864
|
+
behavior: "deny",
|
|
3865
|
+
resolvedBy: "policy",
|
|
3866
|
+
message: "Interactive questions are disabled for this session — choose the most reasonable option yourself and continue."
|
|
3867
|
+
});
|
|
3868
|
+
return { answers: {} };
|
|
3869
|
+
}
|
|
3870
|
+
const answers = {};
|
|
3871
|
+
for (const question of params.questions ?? []) {
|
|
3872
|
+
const first = question.options?.[0]?.label;
|
|
3873
|
+
if (first) answers[question.id] = { answers: [first] };
|
|
3874
|
+
}
|
|
3875
|
+
this.#emit({
|
|
3876
|
+
type: "permission_resolved",
|
|
3877
|
+
requestId: request.id,
|
|
3878
|
+
behavior: "allow",
|
|
3879
|
+
resolvedBy: "policy"
|
|
3880
|
+
});
|
|
3881
|
+
return { answers };
|
|
3882
|
+
}
|
|
3883
|
+
/**
|
|
3884
|
+
* Settle one pending approval: pick the channel's wire response for the
|
|
3885
|
+
* decision, answer the JSON-RPC request, and emit `permission_resolved`.
|
|
3886
|
+
* An allow the request offered no plain accept for becomes the channel's
|
|
3887
|
+
* denial, said out loud — never a silently widened grant, and never a
|
|
3888
|
+
* decision the request didn't offer.
|
|
3889
|
+
*/
|
|
3890
|
+
#settleApproval(id, pending, decision, resolvedBy) {
|
|
3891
|
+
clearTimeout(pending.timer);
|
|
3892
|
+
this.#approvals.delete(id);
|
|
3893
|
+
let behavior = decision.behavior;
|
|
3894
|
+
let message = decision.behavior === "deny" ? decision.message ?? "Denied" : void 0;
|
|
3895
|
+
let sent;
|
|
3896
|
+
if (decision.behavior === "allow") {
|
|
3897
|
+
const allowed = pending.channel.allow(pending.params, decision.updatedInput, pending.offered);
|
|
3898
|
+
if (allowed) sent = allowed;
|
|
3899
|
+
else {
|
|
3900
|
+
behavior = "deny";
|
|
3901
|
+
resolvedBy = "policy";
|
|
3902
|
+
message = "codex offered no plain accept for this request (only broader session/policy grants) — denied instead";
|
|
3903
|
+
sent = pending.channel.deny(pending.params, false, pending.offered);
|
|
3904
|
+
}
|
|
3905
|
+
} else sent = pending.channel.deny(pending.params, decision.interrupt === true, pending.offered);
|
|
3906
|
+
pending.respond(sent.response);
|
|
3907
|
+
this.#emit({
|
|
3908
|
+
type: "permission_resolved",
|
|
3909
|
+
requestId: id,
|
|
3910
|
+
behavior,
|
|
3911
|
+
resolvedBy,
|
|
3912
|
+
message
|
|
3913
|
+
});
|
|
3914
|
+
if (behavior === "deny" && decision.behavior === "deny" && decision.interrupt && sent.decision !== "cancel") this.#interruptTurn();
|
|
3915
|
+
if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
|
|
3916
|
+
}
|
|
3917
|
+
/** Tool calls surface as tool_use when they start; text and reasoning stream
|
|
3918
|
+
* natively via the delta notifications. */
|
|
3919
|
+
#handleItemProgress(item, active) {
|
|
3920
|
+
const id = `${active.nonce}:${item.id}`;
|
|
3921
|
+
if (item.type === "commandExecution" && !active.toolUseEmitted.has(id)) {
|
|
3922
|
+
active.toolUseEmitted.add(id);
|
|
3923
|
+
this.#emitToolUse(id, "CodexCommand", { command: item.command });
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
if (item.type === "mcpToolCall" && !active.toolUseEmitted.has(id)) {
|
|
3927
|
+
active.toolUseEmitted.add(id);
|
|
3928
|
+
this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
#handleItemCompleted(item, active) {
|
|
3932
|
+
const id = `${active.nonce}:${item.id}`;
|
|
3933
|
+
switch (item.type) {
|
|
3934
|
+
case "userMessage": return;
|
|
3935
|
+
case "agentMessage": {
|
|
3936
|
+
const text = typeof item.text === "string" ? item.text : "";
|
|
3937
|
+
this.#emitAssistant(id, [{
|
|
3938
|
+
type: "text",
|
|
3939
|
+
text
|
|
3940
|
+
}]);
|
|
3941
|
+
active.finalText = text;
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
case "reasoning": {
|
|
3945
|
+
const summary = Array.isArray(item.summary) ? item.summary.filter(Boolean) : [];
|
|
3946
|
+
const content = Array.isArray(item.content) ? item.content.filter(Boolean) : [];
|
|
3947
|
+
const thinking = (summary.length > 0 ? summary : content).join("\n\n");
|
|
3948
|
+
if (thinking) this.#emitAssistant(id, [{
|
|
3949
|
+
type: "thinking",
|
|
3950
|
+
thinking
|
|
3951
|
+
}]);
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
case "commandExecution": {
|
|
3955
|
+
if (!active.toolUseEmitted.has(id)) {
|
|
3956
|
+
active.toolUseEmitted.add(id);
|
|
3957
|
+
this.#emitToolUse(id, "CodexCommand", { command: item.command });
|
|
3958
|
+
}
|
|
3959
|
+
const exitCode = item.exitCode ?? void 0;
|
|
3960
|
+
const failed = item.status === "failed" || item.status === "declined" || exitCode !== void 0 && exitCode !== 0;
|
|
3961
|
+
const output = (item.aggregatedOutput ?? "") + (exitCode !== void 0 && exitCode !== 0 ? `\n(exit code ${exitCode})` : "");
|
|
3962
|
+
this.#emitToolResult(id, output, failed);
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
case "fileChange": {
|
|
3966
|
+
this.#emitToolUse(id, "CodexFileChange", { changes: item.changes });
|
|
3967
|
+
const lines = item.changes.map((change) => {
|
|
3968
|
+
return `${(typeof change.kind === "string" ? change.kind : change.kind?.type) ?? "change"}: ${change.path}`;
|
|
3969
|
+
});
|
|
3970
|
+
this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined");
|
|
3971
|
+
return;
|
|
3972
|
+
}
|
|
3973
|
+
case "mcpToolCall": {
|
|
3974
|
+
if (!active.toolUseEmitted.has(id)) {
|
|
3975
|
+
active.toolUseEmitted.add(id);
|
|
3976
|
+
this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
|
|
3977
|
+
}
|
|
3978
|
+
const isError = item.error !== void 0 && item.error !== null || item.status === "failed";
|
|
3979
|
+
this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError);
|
|
3980
|
+
return;
|
|
3981
|
+
}
|
|
3982
|
+
case "webSearch":
|
|
3983
|
+
this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
|
|
3984
|
+
this.#emitToolResult(id, "", false);
|
|
3985
|
+
return;
|
|
3986
|
+
default: {
|
|
3987
|
+
const unknown = item;
|
|
3988
|
+
this.#emit({
|
|
3989
|
+
type: "sdk_event",
|
|
3990
|
+
payload: {
|
|
3991
|
+
type: `codex.${unknown.type}`,
|
|
3992
|
+
item: unknown
|
|
3993
|
+
}
|
|
3994
|
+
});
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
#emitDelta(delta) {
|
|
3999
|
+
if (this.#config.includePartialMessages === false) return;
|
|
4000
|
+
this.#emit({
|
|
4001
|
+
type: "stream_delta",
|
|
4002
|
+
event: {
|
|
4003
|
+
type: "content_block_delta",
|
|
4004
|
+
delta
|
|
4005
|
+
},
|
|
4006
|
+
parentToolUseId: null,
|
|
4007
|
+
uuid: randomUUID()
|
|
4008
|
+
});
|
|
4009
|
+
}
|
|
4010
|
+
#emitAssistant(uuid, content) {
|
|
4011
|
+
this.#emit({
|
|
4012
|
+
type: "assistant_message",
|
|
4013
|
+
message: {
|
|
4014
|
+
role: "assistant",
|
|
4015
|
+
content,
|
|
4016
|
+
model: this.#model ?? this.#resolvedModel
|
|
4017
|
+
},
|
|
4018
|
+
parentToolUseId: null,
|
|
4019
|
+
uuid
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
#emitToolUse(id, name, input) {
|
|
4023
|
+
this.#emit({
|
|
4024
|
+
type: "assistant_message",
|
|
4025
|
+
message: {
|
|
4026
|
+
role: "assistant",
|
|
4027
|
+
content: [{
|
|
4028
|
+
type: "tool_use",
|
|
4029
|
+
id,
|
|
4030
|
+
name,
|
|
4031
|
+
input
|
|
4032
|
+
}],
|
|
4033
|
+
model: this.#model ?? this.#resolvedModel
|
|
4034
|
+
},
|
|
4035
|
+
parentToolUseId: null,
|
|
4036
|
+
uuid: `${id}-use`
|
|
4037
|
+
});
|
|
4038
|
+
}
|
|
4039
|
+
#emitToolResult(toolUseId, content, isError) {
|
|
4040
|
+
this.#emit({
|
|
4041
|
+
type: "user_message",
|
|
4042
|
+
message: {
|
|
4043
|
+
role: "user",
|
|
4044
|
+
content: [{
|
|
4045
|
+
type: "tool_result",
|
|
4046
|
+
tool_use_id: toolUseId,
|
|
4047
|
+
content,
|
|
4048
|
+
is_error: isError || void 0
|
|
4049
|
+
}]
|
|
4050
|
+
},
|
|
4051
|
+
parentToolUseId: null,
|
|
4052
|
+
synthetic: true,
|
|
4053
|
+
uuid: `${toolUseId}-result`
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Per-turn usage re-mapped to the Anthropic accounting convention the whole
|
|
4058
|
+
* stack assumes (GOTCHAS §Codex engine): OpenAI's `inputTokens` includes the
|
|
4059
|
+
* cached share, so input excludes it (else queue token budgets double-count
|
|
4060
|
+
* cache-heavy runs); reasoning tokens are billed output; `totalCostUsd: 0` =
|
|
4061
|
+
* unknown, the AiSdkRunner precedent. Usage is summed from the turn's
|
|
4062
|
+
* `thread/tokenUsage/updated` notifications — `turn/completed` carries none.
|
|
4063
|
+
*/
|
|
4064
|
+
#finishTurn(kind, startedAt, active, errors) {
|
|
4065
|
+
for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
|
|
4066
|
+
behavior: "deny",
|
|
4067
|
+
message: "Turn ended"
|
|
4068
|
+
}, "policy");
|
|
4069
|
+
this.#numTurns += 1;
|
|
4070
|
+
this.#totalCostUsd = 0;
|
|
4071
|
+
const usage = active.sawUsage ? active.usage : void 0;
|
|
4072
|
+
this.#emit({
|
|
4073
|
+
type: "turn_result",
|
|
4074
|
+
subtype: kind === "success" ? "success" : "error_during_execution",
|
|
4075
|
+
isError: kind !== "success",
|
|
4076
|
+
durationMs: Date.now() - startedAt,
|
|
4077
|
+
numTurns: this.#numTurns,
|
|
4078
|
+
totalCostUsd: 0,
|
|
4079
|
+
result: kind === "success" ? active.finalText ?? "" : void 0,
|
|
4080
|
+
errors,
|
|
4081
|
+
usage: usage ? {
|
|
4082
|
+
input_tokens: Math.max(0, usage.inputTokens - usage.cachedInputTokens),
|
|
4083
|
+
output_tokens: usage.outputTokens + usage.reasoningOutputTokens,
|
|
4084
|
+
cache_creation_input_tokens: usage.cacheWriteInputTokens ?? 0,
|
|
4085
|
+
cache_read_input_tokens: usage.cachedInputTokens
|
|
4086
|
+
} : void 0
|
|
4087
|
+
});
|
|
4088
|
+
this.#emitContextUsage(active);
|
|
4089
|
+
this.#setStatus("idle");
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* Subscription windows, mapped onto the protocol's named vocabulary.
|
|
4093
|
+
*
|
|
4094
|
+
* The shapes disagree: codex reports windows *positionally* (`primary` /
|
|
4095
|
+
* `secondary`) with a length in minutes, while `RateLimitInfo.rateLimitType`
|
|
4096
|
+
* is a name whose meaning clients already know — iOS labels `seven_day` as
|
|
4097
|
+
* "Weekly" and derives the pace marker's denominator from it. Naming the
|
|
4098
|
+
* window by its measured duration is therefore the honest mapping rather
|
|
4099
|
+
* than a borrowed one: codex's primary window is 10080 minutes, which *is*
|
|
4100
|
+
* seven days. A duration we have no name for keeps an explicit
|
|
4101
|
+
* `window_<n>m` key — clients render it verbatim and simply draw no pace
|
|
4102
|
+
* marker, which beats mislabeling it as a week.
|
|
4103
|
+
*
|
|
4104
|
+
* `status` is 'allowed' by construction (the session is running), matching
|
|
4105
|
+
* `rateLimitEventsFromUsage`; codex's `rateLimitReachedType` is the one
|
|
4106
|
+
* signal that a limit is actually biting, so it becomes 'rejected'.
|
|
4107
|
+
*/
|
|
4108
|
+
#emitRateLimits(limits) {
|
|
4109
|
+
if (!limits) return;
|
|
4110
|
+
const status = limits.rateLimitReachedType ? "rejected" : "allowed";
|
|
4111
|
+
for (const window of [limits.primary, limits.secondary]) {
|
|
4112
|
+
if (!window || window.usedPercent === null || window.usedPercent === void 0) continue;
|
|
4113
|
+
this.#emit({
|
|
4114
|
+
type: "rate_limit",
|
|
4115
|
+
info: {
|
|
4116
|
+
status,
|
|
4117
|
+
rateLimitType: rateLimitWindowName(window.windowDurationMins),
|
|
4118
|
+
utilization: window.usedPercent,
|
|
4119
|
+
...typeof window.resetsAt === "number" ? { resetsAt: window.resetsAt } : {}
|
|
4120
|
+
}
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
if (limits.planType && limits.planType !== this.#planType) {
|
|
4124
|
+
this.#planType = limits.planType;
|
|
4125
|
+
this.#emit({
|
|
4126
|
+
type: "plan_info",
|
|
4127
|
+
subscriptionType: limits.planType
|
|
4128
|
+
});
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* Context occupancy, after the turn — the same cadence the Claude runner
|
|
4133
|
+
* polls `getContextUsage()` on, so clients need nothing new.
|
|
4134
|
+
*
|
|
4135
|
+
* Emitted only when the binary gave BOTH numbers: the protocol is explicit
|
|
4136
|
+
* that a client renders nothing rather than a 0% ring, and a window of
|
|
4137
|
+
* `null` (which app-server does send) would otherwise divide into a
|
|
4138
|
+
* meaningless percentage. `categories` is empty because codex publishes no
|
|
4139
|
+
* breakdown — clients must not render an empty "Breakdown" section for it.
|
|
4140
|
+
*/
|
|
4141
|
+
#emitContextUsage(active) {
|
|
4142
|
+
const totalTokens = active.contextTokens;
|
|
4143
|
+
const maxTokens = active.contextWindow;
|
|
4144
|
+
if (totalTokens === void 0 || !maxTokens || maxTokens <= 0) return;
|
|
4145
|
+
this.#emit({
|
|
4146
|
+
type: "context_usage",
|
|
4147
|
+
usage: {
|
|
4148
|
+
categories: [],
|
|
4149
|
+
totalTokens,
|
|
4150
|
+
maxTokens,
|
|
4151
|
+
percentage: Math.min(100, totalTokens / maxTokens * 100),
|
|
4152
|
+
model: this.#model ?? this.#resolvedModel
|
|
4153
|
+
}
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
4156
|
+
#setStatus(status, detail) {
|
|
4157
|
+
if (this.#status === status) return;
|
|
4158
|
+
if (this.#status === "closed" || this.#status === "failed") return;
|
|
4159
|
+
this.#status = status;
|
|
4160
|
+
this.#emit({
|
|
4161
|
+
type: "status_changed",
|
|
4162
|
+
status,
|
|
4163
|
+
detail
|
|
4164
|
+
});
|
|
4165
|
+
}
|
|
4166
|
+
#emit(body) {
|
|
4167
|
+
if (this.#replayingHistory && (body.type === "assistant_message" || body.type === "user_message")) body = {
|
|
4168
|
+
...body,
|
|
4169
|
+
replay: true
|
|
4170
|
+
};
|
|
4171
|
+
const event = {
|
|
4172
|
+
...body,
|
|
4173
|
+
seq: ++this.#seq,
|
|
4174
|
+
ts: Date.now()
|
|
4175
|
+
};
|
|
4176
|
+
this.#lastActivityAt = event.ts;
|
|
4177
|
+
this.#events.push(event);
|
|
4178
|
+
for (const listener of this.#listeners) try {
|
|
4179
|
+
listener(event);
|
|
4180
|
+
} catch {}
|
|
4181
|
+
}
|
|
4182
|
+
};
|
|
4183
|
+
//#endregion
|
|
4184
|
+
//#region src/engines/codex/catalog.ts
|
|
4185
|
+
/**
|
|
4186
|
+
* The Codex engine's model catalog, seeded from the binary's own embedded
|
|
4187
|
+
* presets — `@openai/codex@0.146.0` ships its model table inside the
|
|
4188
|
+
* executable, and that table (not the SDK's stale `ModelReasoningEffort`
|
|
4189
|
+
* union) is the truth about which reasoning efforts each model takes.
|
|
4190
|
+
*
|
|
4191
|
+
* **Refresh procedure** (release checklist): extract the embedded JSON from
|
|
4192
|
+
* the platform binary and diff —
|
|
4193
|
+
*
|
|
4194
|
+
* node -e 'const d=require("fs").readFileSync(process.argv[1]);
|
|
4195
|
+
* const s=d.indexOf(`{\n "models": [`);
|
|
4196
|
+
* let i=s,n=0; do{n+=(d[i]===123)-(d[i]===125);i++}while(n);
|
|
4197
|
+
* const c=JSON.parse(d.slice(s,i));
|
|
4198
|
+
* for(const m of c.models) console.log(m.slug, m.display_name,
|
|
4199
|
+
* m.visibility, m.supported_reasoning_levels.map(l=>l.effort).join(","))'\
|
|
4200
|
+
* "$(node -p 'require.resolve("@openai/codex-darwin-arm64/package.json").replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
|
|
4201
|
+
*
|
|
4202
|
+
* Mapping decisions:
|
|
4203
|
+
* - the internal `codex-auto-review` row is dropped (the codex analogue of
|
|
4204
|
+
* dropping the CLI's `default` sentinel);
|
|
4205
|
+
* - `primary` mirrors the binary's own `visibility` field ('list' = shown in
|
|
4206
|
+
* its picker, 'hide' = its "older models"), so both UIs group the way
|
|
4207
|
+
* codex's own picker does;
|
|
4208
|
+
* - `reasoningEfforts` carries `supported_reasoning_levels` verbatim — note
|
|
4209
|
+
* `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
|
|
4210
|
+
*/
|
|
4211
|
+
const CODEX_CATALOG = {
|
|
4212
|
+
provenance: "embedded model presets of @openai/codex@0.146.0 (darwin-arm64 binary), extracted 2026-08-05",
|
|
4213
|
+
models: [
|
|
4214
|
+
{
|
|
4215
|
+
value: "gpt-5.6-sol",
|
|
4216
|
+
resolvedModel: "gpt-5.6-sol",
|
|
4217
|
+
displayName: "GPT-5.6 Sol",
|
|
4218
|
+
description: "Latest frontier agentic coding model.",
|
|
4219
|
+
primary: true,
|
|
4220
|
+
reasoningEfforts: [
|
|
4221
|
+
"low",
|
|
4222
|
+
"medium",
|
|
4223
|
+
"high",
|
|
4224
|
+
"xhigh",
|
|
4225
|
+
"max",
|
|
4226
|
+
"ultra"
|
|
4227
|
+
]
|
|
4228
|
+
},
|
|
4229
|
+
{
|
|
4230
|
+
value: "gpt-5.6-terra",
|
|
4231
|
+
resolvedModel: "gpt-5.6-terra",
|
|
4232
|
+
displayName: "GPT-5.6 Terra",
|
|
4233
|
+
description: "Balanced agentic coding model for everyday work.",
|
|
4234
|
+
primary: true,
|
|
4235
|
+
reasoningEfforts: [
|
|
4236
|
+
"low",
|
|
4237
|
+
"medium",
|
|
4238
|
+
"high",
|
|
4239
|
+
"xhigh",
|
|
4240
|
+
"max",
|
|
4241
|
+
"ultra"
|
|
4242
|
+
]
|
|
4243
|
+
},
|
|
4244
|
+
{
|
|
4245
|
+
value: "gpt-5.6-luna",
|
|
4246
|
+
resolvedModel: "gpt-5.6-luna",
|
|
4247
|
+
displayName: "GPT-5.6 Luna",
|
|
4248
|
+
description: "Fast and affordable agentic coding model.",
|
|
4249
|
+
primary: true,
|
|
4250
|
+
reasoningEfforts: [
|
|
4251
|
+
"low",
|
|
4252
|
+
"medium",
|
|
4253
|
+
"high",
|
|
4254
|
+
"xhigh",
|
|
4255
|
+
"max"
|
|
4256
|
+
]
|
|
4257
|
+
},
|
|
4258
|
+
{
|
|
4259
|
+
value: "gpt-5.5",
|
|
4260
|
+
resolvedModel: "gpt-5.5",
|
|
4261
|
+
displayName: "GPT-5.5",
|
|
4262
|
+
description: "Frontier model for complex coding, research, and real-world work.",
|
|
4263
|
+
primary: true,
|
|
4264
|
+
reasoningEfforts: [
|
|
4265
|
+
"low",
|
|
4266
|
+
"medium",
|
|
4267
|
+
"high",
|
|
4268
|
+
"xhigh"
|
|
4269
|
+
]
|
|
4270
|
+
},
|
|
4271
|
+
{
|
|
4272
|
+
value: "gpt-5.4",
|
|
4273
|
+
resolvedModel: "gpt-5.4",
|
|
4274
|
+
displayName: "GPT-5.4",
|
|
4275
|
+
description: "Strong model for everyday coding.",
|
|
4276
|
+
reasoningEfforts: [
|
|
4277
|
+
"low",
|
|
4278
|
+
"medium",
|
|
4279
|
+
"high",
|
|
4280
|
+
"xhigh"
|
|
4281
|
+
]
|
|
4282
|
+
},
|
|
4283
|
+
{
|
|
4284
|
+
value: "gpt-5.4-mini",
|
|
4285
|
+
resolvedModel: "gpt-5.4-mini",
|
|
4286
|
+
displayName: "GPT-5.4 Mini",
|
|
4287
|
+
description: "Small, fast, and cost-efficient model for simpler coding tasks.",
|
|
4288
|
+
reasoningEfforts: [
|
|
4289
|
+
"low",
|
|
4290
|
+
"medium",
|
|
4291
|
+
"high",
|
|
4292
|
+
"xhigh"
|
|
4293
|
+
]
|
|
4294
|
+
},
|
|
4295
|
+
{
|
|
4296
|
+
value: "gpt-5.2",
|
|
4297
|
+
resolvedModel: "gpt-5.2",
|
|
4298
|
+
displayName: "GPT-5.2",
|
|
4299
|
+
description: "Optimized for professional work and long-running agents.",
|
|
4300
|
+
primary: true,
|
|
4301
|
+
reasoningEfforts: [
|
|
4302
|
+
"low",
|
|
4303
|
+
"medium",
|
|
4304
|
+
"high",
|
|
4305
|
+
"xhigh"
|
|
4306
|
+
]
|
|
4307
|
+
}
|
|
4308
|
+
]
|
|
4309
|
+
};
|
|
4310
|
+
//#endregion
|
|
4311
|
+
//#region src/engines/codex/process.ts
|
|
4312
|
+
/** How much stderr to keep for the exit diagnostic. The binary logs startup
|
|
4313
|
+
* noise there; only the tail explains a death. */
|
|
4314
|
+
const STDERR_TAIL_BYTES = 4096;
|
|
4315
|
+
/**
|
|
4316
|
+
* Spawn one `codex app-server` child and frame JSON-RPC over its stdio — the
|
|
4317
|
+
* real {@link AppServerConnectFn}. The child's env is passed **complete**
|
|
4318
|
+
* (a provided spawn env replaces process.env, never merges with it), with the
|
|
4319
|
+
* profile's CODEX_HOME pin already applied by the runner.
|
|
4320
|
+
*
|
|
4321
|
+
* No spawn cwd: the working directory is a thread/turn parameter, and a cwd
|
|
4322
|
+
* that doesn't exist should fail the *turn* with codex's own error, not the
|
|
4323
|
+
* spawn.
|
|
4324
|
+
*/
|
|
4325
|
+
function connectAppServer(options) {
|
|
4326
|
+
const child = spawn(options.executable, ["app-server"], {
|
|
4327
|
+
env: options.env,
|
|
4328
|
+
stdio: [
|
|
4329
|
+
"pipe",
|
|
4330
|
+
"pipe",
|
|
4331
|
+
"pipe"
|
|
4332
|
+
]
|
|
4333
|
+
});
|
|
4334
|
+
const rpc = new JsonRpcStdioConnection({
|
|
4335
|
+
input: child.stdout,
|
|
4336
|
+
output: child.stdin
|
|
4337
|
+
});
|
|
4338
|
+
let stderrTail = "";
|
|
4339
|
+
child.stderr.on("data", (chunk) => {
|
|
4340
|
+
stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
|
|
4341
|
+
});
|
|
4342
|
+
let closeHandler;
|
|
4343
|
+
let done = false;
|
|
4344
|
+
const settle = (message) => {
|
|
4345
|
+
if (done) return;
|
|
4346
|
+
done = true;
|
|
4347
|
+
rpc.fail(message);
|
|
4348
|
+
closeHandler?.(message);
|
|
4349
|
+
};
|
|
4350
|
+
child.on("error", (error) => settle(`codex app-server failed to start: ${error.message}`));
|
|
4351
|
+
child.on("exit", (code, signal) => {
|
|
4352
|
+
const tail = stderrTail.trim();
|
|
4353
|
+
settle(`codex app-server exited (${signal ?? `code ${code}`})` + (tail ? `: ${tail.slice(-500)}` : ""));
|
|
4354
|
+
});
|
|
4355
|
+
return {
|
|
4356
|
+
request: (method, params) => rpc.request(method, params),
|
|
4357
|
+
notify: (method, params) => rpc.notify(method, params),
|
|
4358
|
+
onNotification: (handler) => rpc.onNotification(handler),
|
|
4359
|
+
onRequest: (handler) => rpc.onRequest(handler),
|
|
4360
|
+
onClose: (handler) => {
|
|
4361
|
+
closeHandler = handler;
|
|
4362
|
+
},
|
|
4363
|
+
close: () => {
|
|
4364
|
+
done = true;
|
|
4365
|
+
rpc.fail("codex app-server connection closed");
|
|
4366
|
+
child.kill();
|
|
4367
|
+
}
|
|
4368
|
+
};
|
|
4369
|
+
}
|
|
4370
|
+
//#endregion
|
|
4371
|
+
//#region src/engines/codex/adapter.ts
|
|
4372
|
+
const NOT_INSTALLED = "@openai/codex is not installed — add it (an optional peer of @workerdeck/core) to run codex profiles";
|
|
4373
|
+
/**
|
|
4374
|
+
* The codex binary sessions will run: the per-platform package installed next
|
|
4375
|
+
* to `@openai/codex`, `vendor/<target-triple>/bin/codex` — the same file the
|
|
4376
|
+
* npm wrapper's own `bin/codex.js` launcher execs. Probing this binary rather
|
|
4377
|
+
* than whatever `codex` is on PATH means the availability answer is about the
|
|
4378
|
+
* executable sessions will actually run. Undefined when it can't be found;
|
|
4379
|
+
* callers degrade to 'unknown'.
|
|
4380
|
+
*/
|
|
4381
|
+
function resolveBundledCodexExecutable() {
|
|
4382
|
+
const triple = targetTriple();
|
|
4383
|
+
if (!triple) return void 0;
|
|
4384
|
+
try {
|
|
4385
|
+
const path = createRequire(createRequire(import.meta.url).resolve("@openai/codex/package.json")).resolve(`@openai/codex-${platformPackageSuffix()}/package.json`).replace(/package\.json$/, `vendor/${triple}/bin/codex`);
|
|
4386
|
+
if (existsSync(path)) return path;
|
|
4387
|
+
} catch {}
|
|
4388
|
+
}
|
|
4389
|
+
function targetTriple() {
|
|
4390
|
+
const { platform, arch } = process;
|
|
4391
|
+
if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
|
|
4392
|
+
if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl";
|
|
4393
|
+
if (platform === "win32") return "x86_64-pc-windows-msvc";
|
|
4394
|
+
}
|
|
4395
|
+
function platformPackageSuffix() {
|
|
4396
|
+
return `${process.platform}-${process.arch}`;
|
|
4397
|
+
}
|
|
4398
|
+
/**
|
|
4399
|
+
* Availability, mirroring **the app-server surface's actual credential chain**
|
|
4400
|
+
* (verified 2026-08-05 against 0.146.0 by driving the raw binary): auth comes
|
|
4401
|
+
* solely from the CODEX_HOME auth store (`codex login`, file or keyring). The
|
|
4402
|
+
* env-key routes are dead ends here — `CODEX_API_KEY` is read only by
|
|
4403
|
+
* `codex exec` (a turn goes out with no credential at all: "Missing bearer"),
|
|
4404
|
+
* and `OPENAI_API_KEY` was never read by either surface. So, in order:
|
|
4405
|
+
*
|
|
4406
|
+
* 1. Binary resolvable, else unavailable with the install reason;
|
|
4407
|
+
* 2. `codex login status` under the profile's complete session env:
|
|
4408
|
+
* exit 0 → available; the "Not logged in" verdict → unavailable, with an
|
|
4409
|
+
* exact remedy when a stranded env key explains the misconfiguration;
|
|
4410
|
+
* anything else (a stray CODEX_ACCESS_TOKEN JWT error, a crashed spawn) →
|
|
4411
|
+
* 'unknown' — the checkClaudeAuth never-overclaim discipline.
|
|
4412
|
+
*
|
|
4413
|
+
* Only the exit code and the fixed verdict line are consulted — never
|
|
4414
|
+
* surfaced: `login status` output includes a masked key fragment. The
|
|
4415
|
+
* `smoke:codex --canary` run is the drift alarm for all of this.
|
|
4416
|
+
*/
|
|
4417
|
+
async function checkCodexAvailability(profile, env, options = {}) {
|
|
4418
|
+
const executable = resolveBundledCodexExecutable();
|
|
4419
|
+
if (!executable) return {
|
|
4420
|
+
available: false,
|
|
4421
|
+
reason: NOT_INSTALLED
|
|
4422
|
+
};
|
|
4423
|
+
const childEnv = {};
|
|
4424
|
+
for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
|
|
4425
|
+
if (profile.codexHome) childEnv.CODEX_HOME = profile.codexHome;
|
|
4426
|
+
return new Promise((resolve) => {
|
|
4427
|
+
execFile(executable, ["login", "status"], {
|
|
4428
|
+
env: childEnv,
|
|
4429
|
+
timeout: options.timeoutMs ?? 1e4
|
|
4430
|
+
}, (error, stdout, stderr) => {
|
|
4431
|
+
if (!error) {
|
|
4432
|
+
resolve({ available: true });
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
if (`${stdout}\n${stderr}`.includes("Not logged in")) {
|
|
4436
|
+
const hint = childEnv.CODEX_API_KEY ? " CODEX_API_KEY is read only by `codex exec`, never by the app-server — run `codex login --with-api-key` under this profile’s CODEX_HOME to persist it." : childEnv.OPENAI_API_KEY ? " OPENAI_API_KEY is not used by codex — run `codex login --with-api-key` under this profile’s CODEX_HOME." : "";
|
|
4437
|
+
resolve({
|
|
4438
|
+
available: false,
|
|
4439
|
+
reason: `codex is not logged in for this profile's environment — run \`codex login\`` + (profile.codexHome ? ` with CODEX_HOME=${profile.codexHome}` : "") + `.${hint}`
|
|
4440
|
+
});
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4443
|
+
resolve({ available: "unknown" });
|
|
4444
|
+
});
|
|
4445
|
+
});
|
|
4446
|
+
}
|
|
4447
|
+
/** `thread/list` page size (its own default is 25) and a hard page bound so a
|
|
4448
|
+
* misbehaving cursor can never spin the listing forever. */
|
|
4449
|
+
const LIST_PAGE_SIZE = 100;
|
|
4450
|
+
const MAX_LIST_PAGES = 40;
|
|
4451
|
+
/** thread/list's `cwd` filter is an EXACT path match (measured, 0.146.0), so
|
|
4452
|
+
* offer both the spelled and canonical forms — macOS listings would otherwise
|
|
4453
|
+
* miss `/tmp/...` threads recorded under `/private/tmp/...`. */
|
|
4454
|
+
function cwdFilter(dir) {
|
|
4455
|
+
const forms = new Set([dir]);
|
|
4456
|
+
try {
|
|
4457
|
+
forms.add(realpathSync(dir));
|
|
4458
|
+
} catch {}
|
|
4459
|
+
return [...forms];
|
|
4460
|
+
}
|
|
4461
|
+
const secondsToMs = (value) => typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
|
|
4462
|
+
/** One thread row in the protocol's browser-safe summary shape. `id` is what
|
|
4463
|
+
* `CreateSessionRequest.resume` feeds `thread/resume` — the row's separate
|
|
4464
|
+
* `sessionId` field is not it. */
|
|
4465
|
+
function summarizeThread(row) {
|
|
4466
|
+
const name = typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
|
|
4467
|
+
const preview = typeof row.preview === "string" && row.preview.length > 0 ? row.preview : void 0;
|
|
4468
|
+
return {
|
|
4469
|
+
sessionId: row.id,
|
|
4470
|
+
summary: name ?? preview ?? row.id,
|
|
4471
|
+
lastModified: secondsToMs(row.updatedAt) ?? secondsToMs(row.createdAt) ?? 0,
|
|
4472
|
+
createdAt: secondsToMs(row.createdAt),
|
|
4473
|
+
customTitle: name,
|
|
4474
|
+
firstPrompt: preview,
|
|
4475
|
+
gitBranch: typeof row.gitInfo?.branch === "string" && row.gitInfo.branch.length > 0 ? row.gitInfo.branch : void 0,
|
|
4476
|
+
cwd: typeof row.cwd === "string" ? row.cwd : void 0
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
/**
|
|
4480
|
+
* CODEX_HOME's threads over ONE short-lived `codex app-server` child: the
|
|
4481
|
+
* runner's own handshake (`experimentalApi` and all — one code path, no
|
|
4482
|
+
* second vocabulary to drift), `thread/list` pages walked by cursor, child
|
|
4483
|
+
* closed before returning. Requires no live session and costs no tokens —
|
|
4484
|
+
* it is how "resume" is offered before anything is running. The `connectFn`
|
|
4485
|
+
* seam exists for the scripted-peer tests; the adapter passes the real
|
|
4486
|
+
* spawn.
|
|
4487
|
+
*/
|
|
4488
|
+
async function listCodexSessions(options) {
|
|
4489
|
+
const childEnv = {};
|
|
4490
|
+
for (const [key, value] of Object.entries(options.env)) if (value !== void 0) childEnv[key] = value;
|
|
4491
|
+
if (options.profile?.codexHome) childEnv.CODEX_HOME = options.profile.codexHome;
|
|
4492
|
+
const connection = options.connectFn({ env: childEnv });
|
|
4493
|
+
const rows = [];
|
|
4494
|
+
try {
|
|
4495
|
+
await connection.request("initialize", {
|
|
4496
|
+
clientInfo: {
|
|
4497
|
+
name: "workerdeck",
|
|
4498
|
+
title: "WorkerDeck",
|
|
4499
|
+
version: `protocol-${PROTOCOL_VERSION}`
|
|
4500
|
+
},
|
|
4501
|
+
capabilities: { experimentalApi: true }
|
|
4502
|
+
});
|
|
4503
|
+
connection.notify("initialized");
|
|
4504
|
+
const base = {
|
|
4505
|
+
limit: LIST_PAGE_SIZE,
|
|
4506
|
+
sortKey: "updated_at",
|
|
4507
|
+
...options.dir ? { cwd: cwdFilter(options.dir) } : {}
|
|
4508
|
+
};
|
|
4509
|
+
const want = options.limit === void 0 ? void 0 : (options.offset ?? 0) + options.limit;
|
|
4510
|
+
let cursor;
|
|
4511
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
4512
|
+
const result = await connection.request("thread/list", {
|
|
4513
|
+
...base,
|
|
4514
|
+
...cursor ? { cursor } : {}
|
|
4515
|
+
});
|
|
4516
|
+
const data = Array.isArray(result?.data) ? result.data : [];
|
|
4517
|
+
rows.push(...data);
|
|
4518
|
+
if (want !== void 0 && rows.length >= want) break;
|
|
4519
|
+
if (data.length === 0 || typeof result?.nextCursor !== "string") break;
|
|
4520
|
+
cursor = result.nextCursor;
|
|
4521
|
+
}
|
|
4522
|
+
} finally {
|
|
4523
|
+
connection.close();
|
|
4524
|
+
}
|
|
4525
|
+
const summaries = rows.filter((row) => typeof row.id === "string" && row.id.length > 0 && !row.ephemeral).map(summarizeThread);
|
|
4526
|
+
const start = options.offset ?? 0;
|
|
4527
|
+
return options.limit === void 0 ? summaries.slice(start) : summaries.slice(start, start + options.limit);
|
|
4528
|
+
}
|
|
4529
|
+
/**
|
|
4530
|
+
* OpenAI Codex as an engine: the codex CLI binary driven over its `app-server`
|
|
4531
|
+
* JSON-RPC surface — structurally the Claude engine's sibling (a local agent
|
|
4532
|
+
* binary with sessions, sandboxing and resume, resolving its own credentials
|
|
4533
|
+
* from the operator's environment). `@openai/codex` — the npm package that
|
|
4534
|
+
* carries the binary — is an **optional peer**: absent, every codex profile
|
|
4535
|
+
* reports unavailable and createRunner throws the same message, and no
|
|
4536
|
+
* consumer downloads a ~40 MB per-platform binary it never uses.
|
|
4537
|
+
*/
|
|
4538
|
+
const codexAdapter = {
|
|
4539
|
+
engine: "codex",
|
|
4540
|
+
capabilities: ENGINE_CAPABILITIES.codex,
|
|
4541
|
+
catalog: CODEX_CATALOG,
|
|
4542
|
+
checkAvailability: (profile, env) => checkCodexAvailability(profile, env),
|
|
4543
|
+
createRunner({ config, profile, restore }) {
|
|
4544
|
+
if (restore) throw new Error("the codex engine cannot rebuild a parked session");
|
|
4545
|
+
const executable = config.codexPathOverride ?? resolveBundledCodexExecutable();
|
|
4546
|
+
if (!executable) throw new Error(NOT_INSTALLED);
|
|
4547
|
+
return new CodexRunner({
|
|
4548
|
+
...config,
|
|
4549
|
+
codexHome: profile?.codexHome,
|
|
4550
|
+
connectFn: (options) => connectAppServer({
|
|
4551
|
+
executable,
|
|
4552
|
+
...options
|
|
4553
|
+
})
|
|
4554
|
+
});
|
|
4555
|
+
},
|
|
4556
|
+
async listSessions(options) {
|
|
4557
|
+
const executable = resolveBundledCodexExecutable();
|
|
4558
|
+
if (!executable) throw new Error(NOT_INSTALLED);
|
|
4559
|
+
return listCodexSessions({
|
|
4560
|
+
...options,
|
|
4561
|
+
connectFn: (connect) => connectAppServer({
|
|
4562
|
+
executable,
|
|
4563
|
+
...connect
|
|
4564
|
+
})
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
};
|
|
4568
|
+
//#endregion
|
|
4569
|
+
//#region src/engines/provider/adapter.ts
|
|
4570
|
+
/**
|
|
4571
|
+
* The model-agnostic provider engine as a pseudo-adapter: capabilities and an
|
|
4572
|
+
* env-var probe live here, but its runners are assembled by the host's
|
|
4573
|
+
* `createEngineRunner` hook (which is where provider credentials are resolved
|
|
4574
|
+
* and model SDKs are imported — neither belongs in this repo's import graph).
|
|
4575
|
+
* The server routes provider creates to the hook; `createRunner` here throws
|
|
4576
|
+
* so a mis-routed call fails loudly instead of quietly building nothing.
|
|
4577
|
+
*
|
|
4578
|
+
* The catalog is empty by the same token: provider model ids are operator-
|
|
4579
|
+
* declared per profile (`provider.models`), not shipped with releases.
|
|
4580
|
+
*/
|
|
4581
|
+
const providerAdapter = {
|
|
4582
|
+
engine: "provider",
|
|
4583
|
+
capabilities: ENGINE_CAPABILITIES.provider,
|
|
4584
|
+
catalog: {
|
|
4585
|
+
models: [],
|
|
4586
|
+
provenance: "provider model ids are operator-declared (provider.models)"
|
|
4587
|
+
},
|
|
4588
|
+
async checkAvailability(profile, env) {
|
|
4589
|
+
const keyEnv = profile.provider?.apiKeyEnv;
|
|
4590
|
+
if (!keyEnv) return { available: "unknown" };
|
|
4591
|
+
const value = env[keyEnv];
|
|
4592
|
+
if (value !== void 0 && value !== "") return { available: true };
|
|
4593
|
+
return {
|
|
4594
|
+
available: false,
|
|
4595
|
+
reason: `${keyEnv} is not set in the server environment (profile '${profile.name}' names it as apiKeyEnv)`
|
|
4596
|
+
};
|
|
4597
|
+
},
|
|
4598
|
+
createRunner() {
|
|
4599
|
+
throw new Error("provider-engine runners are built by the host's createEngineRunner hook, not the adapter");
|
|
4600
|
+
}
|
|
4601
|
+
};
|
|
4602
|
+
//#endregion
|
|
4603
|
+
//#region src/engines/adapter.ts
|
|
4604
|
+
const ADAPTERS = {
|
|
4605
|
+
claude: claudeAdapter,
|
|
4606
|
+
codex: codexAdapter,
|
|
4607
|
+
provider: providerAdapter
|
|
4608
|
+
};
|
|
4609
|
+
/** The in-repo adapter for an engine. An absent `engine` means 'claude'. */
|
|
4610
|
+
function getEngineAdapter(engine) {
|
|
4611
|
+
return ADAPTERS[engine ?? "claude"];
|
|
4612
|
+
}
|
|
4613
|
+
//#endregion
|
|
4614
|
+
export { AiSdkRunner, BrowserBridgeExecutor, CLAUDE_CATALOG, CODEX_CATALOG, CodexRunner, DeferredExecutor, InputQueue, JsonRpcError, JsonRpcStdioConnection, PendingRequestRegistry, QuickJsExecutor, SUPPORTED_ATTACHMENT_TYPES, SessionRunner, attachmentContentBlocks, attachmentKind, attachmentRef, checkClaudeAuth, claudeAdapter, codexAdapter, connectAppServer, connectMcpTools, createEngineSession, createToolContext, createWebFetch, getEngineAdapter, htmlToMarkdown, isHostAllowed, isPrivateAddress, listCodexSessions, mcpStatusInfo, modelOptionsFromSdk, normalizeMediaType, normalizeSdkMessage, providerAdapter, resolveBundledClaudeExecutable, resolveBundledCodexExecutable, toApiMessage, toExecutionResult, withMcpTools };
|
|
2323
4615
|
|
|
2324
4616
|
//# sourceMappingURL=index.mjs.map
|