@workerdeck/core 0.7.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.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
- sendMessage(text) {
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: text
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.map((m) => ({
461
- value: m.value,
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: text
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
- export { AiSdkRunner, BrowserBridgeExecutor, DeferredExecutor, InputQueue, PendingRequestRegistry, QuickJsExecutor, SessionRunner, checkClaudeAuth, connectMcpTools, createEngineSession, createToolContext, createWebFetch, htmlToMarkdown, isHostAllowed, isPrivateAddress, normalizeSdkMessage, resolveBundledClaudeExecutable, toApiMessage, toExecutionResult, withMcpTools };
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