@workerdeck/core 0.7.0 → 0.11.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
- import { randomUUID } from "node:crypto";
3
- import { getSessionMessages, query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, transcriptActivity } 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, statSync, 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.
@@ -152,6 +413,7 @@ var SessionRunner = class {
152
413
  #events = [];
153
414
  #listeners = /* @__PURE__ */ new Set();
154
415
  #seq = 0;
416
+ #activityCount = 0;
155
417
  #status = "starting";
156
418
  #statusDetail;
157
419
  #sdkSessionId;
@@ -165,6 +427,9 @@ var SessionRunner = class {
165
427
  #input = new InputQueue();
166
428
  #query;
167
429
  #capabilitiesEmitted = false;
430
+ /** Last plan reported by the usage poll, so `plan_info` is emitted on change
431
+ * rather than once per turn. */
432
+ #subscriptionType;
168
433
  #started = false;
169
434
  #closed = false;
170
435
  #runPromise;
@@ -198,11 +463,14 @@ var SessionRunner = class {
198
463
  cwd: this.#config.cwd,
199
464
  profile: this.#config.profile,
200
465
  engine: "claude",
466
+ capabilities: ENGINE_CAPABILITIES.claude,
201
467
  model: this.#model ?? this.#config.model,
202
468
  permissionMode: this.#permissionMode,
469
+ canBypassPermissions: this.#config.permissionMode === "bypassPermissions" || this.#config.allowDangerouslySkipPermissions === true,
203
470
  apiKeySource: this.#apiKeySource,
204
471
  createdAt: this.createdAt,
205
472
  lastSeq: this.#seq,
473
+ activityCount: this.#activityCount,
206
474
  pendingPermissionCount: this.#pending.size,
207
475
  meta: this.#config.meta,
208
476
  title: this.#title(),
@@ -218,6 +486,17 @@ var SessionRunner = class {
218
486
  if (!prompt) return void 0;
219
487
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
220
488
  }
489
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
490
+ * it (undefined) restores the derived title. The engine is never told. */
491
+ setTitle(title) {
492
+ const meta = { ...this.#config.meta };
493
+ if (title) meta.title = title;
494
+ else delete meta.title;
495
+ this.#config = {
496
+ ...this.#config,
497
+ meta
498
+ };
499
+ }
221
500
  /** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
222
501
  start() {
223
502
  if (this.#started) return this.#runPromise;
@@ -226,14 +505,23 @@ var SessionRunner = class {
226
505
  this.#runPromise = this.#run();
227
506
  return this.#runPromise;
228
507
  }
229
- /** Queue a user message for the session (starts the next turn when idle). */
230
- sendMessage(text) {
508
+ /** Queue a user message for the session (starts the next turn when idle).
509
+ *
510
+ * `attachments` carry their own bytes; they reach the CLI as content blocks and
511
+ * are logged as references. A message may be attachments alone — an empty text
512
+ * block is not valid API input, so the text is only added when there is some. */
513
+ sendMessage(text, attachments) {
231
514
  if (this.#closed) throw new Error("session is closed");
515
+ const blocks = attachments?.length ? attachmentContentBlocks(attachments) : [];
516
+ const content = blocks.length ? [...blocks, ...text ? [{
517
+ type: "text",
518
+ text
519
+ }] : []] : text;
232
520
  this.#input.push({
233
521
  type: "user",
234
522
  message: {
235
523
  role: "user",
236
- content: text
524
+ content
237
525
  },
238
526
  parent_tool_use_id: null,
239
527
  session_id: this.#sdkSessionId
@@ -245,9 +533,28 @@ var SessionRunner = class {
245
533
  content: text
246
534
  },
247
535
  parentToolUseId: null,
536
+ attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
248
537
  uuid: randomUUID()
249
538
  });
250
539
  }
540
+ /** Live MCP server status, straight from the CLI. Undefined when the engine
541
+ * can't answer (an injected fake query in tests) — the caller 501s rather than
542
+ * pretending the session has no servers. */
543
+ async mcpServers() {
544
+ const query = this.#query;
545
+ if (typeof query?.mcpServerStatus !== "function") return void 0;
546
+ return (await query.mcpServerStatus()).map(mcpStatusInfo);
547
+ }
548
+ async reconnectMcpServer(name) {
549
+ const query = this.#query;
550
+ if (typeof query?.reconnectMcpServer !== "function") throw new Error("this session cannot reconnect MCP servers");
551
+ await query.reconnectMcpServer(name);
552
+ }
553
+ async setMcpServerEnabled(name, enabled) {
554
+ const query = this.#query;
555
+ if (typeof query?.toggleMcpServer !== "function") throw new Error("this session cannot enable or disable MCP servers");
556
+ await query.toggleMcpServer(name, enabled);
557
+ }
251
558
  /** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
252
559
  resolvePermission(requestId, decision) {
253
560
  const pending = this.#pending.get(requestId);
@@ -323,6 +630,7 @@ var SessionRunner = class {
323
630
  this.#setStatus("idle");
324
631
  this.#fetchCapabilities();
325
632
  this.#fetchContextUsage();
633
+ this.#fetchRateLimits();
326
634
  }
327
635
  for await (const message of this.#query) this.#handleMessage(message);
328
636
  if (!this.#closed) {
@@ -393,6 +701,7 @@ var SessionRunner = class {
393
701
  maxBudgetUsd: c.maxBudgetUsd,
394
702
  resume: c.resume,
395
703
  forkSession: c.forkSession,
704
+ effort: c.reasoningEffort,
396
705
  includePartialMessages: c.includePartialMessages ?? true,
397
706
  canUseTool: this.#canUseTool,
398
707
  env: c.env,
@@ -423,6 +732,7 @@ var SessionRunner = class {
423
732
  this.#setStatus("running");
424
733
  this.#fetchCapabilities();
425
734
  this.#fetchContextUsage();
735
+ this.#fetchRateLimits();
426
736
  return;
427
737
  }
428
738
  if (msg.type === "system" && msg.subtype === "session_state_changed") {
@@ -439,6 +749,7 @@ var SessionRunner = class {
439
749
  this.#numTurns = body.numTurns;
440
750
  if (this.#pending.size === 0) this.#setStatus("idle");
441
751
  this.#fetchContextUsage();
752
+ this.#fetchRateLimits();
442
753
  }
443
754
  }
444
755
  }
@@ -457,11 +768,8 @@ var SessionRunner = class {
457
768
  this.#capabilitiesEmitted = true;
458
769
  this.#emit({
459
770
  type: "capabilities",
460
- models: models.map((m) => ({
461
- value: m.value,
462
- displayName: m.displayName,
463
- description: m.description
464
- })),
771
+ models: modelOptionsFromSdk(models),
772
+ defaultModel: defaultModelFromSdk(models),
465
773
  commands: commands.map((c) => ({
466
774
  name: c.name,
467
775
  description: c.description,
@@ -495,9 +803,38 @@ var SessionRunner = class {
495
803
  });
496
804
  } catch {}
497
805
  }
806
+ /**
807
+ * Snapshot the plan's rate-limit windows and surface them as `rate_limit`
808
+ * events — the same event a live `rate_limit_event` produces, so clients need
809
+ * nothing new to render it.
810
+ *
811
+ * The CLI only *pushes* a window when it changes, which for a session being
812
+ * watched rather than driven can be never; polling is what makes usage show up
813
+ * at all. The control request is marked experimental in the SDK, name included,
814
+ * so it is probed for by name and every failure is silent — one more reason
815
+ * this can only ever be decoration.
816
+ */
817
+ async #fetchRateLimits() {
818
+ const query = this.#query;
819
+ const fetchUsage = query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
820
+ if (typeof fetchUsage !== "function") return;
821
+ try {
822
+ const usage = await fetchUsage.call(query);
823
+ if (this.#closed) return;
824
+ const subscriptionType = usage.subscription_type;
825
+ if (subscriptionType && subscriptionType !== this.#subscriptionType) {
826
+ this.#subscriptionType = subscriptionType;
827
+ this.#emit({
828
+ type: "plan_info",
829
+ subscriptionType
830
+ });
831
+ }
832
+ for (const body of rateLimitEventsFromUsage(usage)) this.#emit(body);
833
+ } catch {}
834
+ }
498
835
  #canUseTool = (toolName, input, options) => {
499
836
  const id = randomUUID();
500
- const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
837
+ const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS$1;
501
838
  const request = {
502
839
  id,
503
840
  toolName,
@@ -622,6 +959,7 @@ var SessionRunner = class {
622
959
  ts: Date.now()
623
960
  };
624
961
  this.#lastActivityAt = event.ts;
962
+ this.#activityCount += transcriptActivity(body);
625
963
  this.#events.push(event);
626
964
  for (const listener of this.#listeners) try {
627
965
  listener(event);
@@ -670,6 +1008,7 @@ var AiSdkRunner = class {
670
1008
  #events = [];
671
1009
  #listeners = /* @__PURE__ */ new Set();
672
1010
  #seq = 0;
1011
+ #activityCount = 0;
673
1012
  #status = "starting";
674
1013
  #permissionMode;
675
1014
  #messages = [];
@@ -718,6 +1057,7 @@ var AiSdkRunner = class {
718
1057
  if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
719
1058
  this.#seq = snapshot.seq;
720
1059
  this.#events = [...snapshot.events];
1060
+ this.#activityCount = this.#events.reduce((total, event) => total + transcriptActivity(event), 0);
721
1061
  this.#messages = [...state.messages];
722
1062
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
723
1063
  this.#dispatched = new Set(state.dispatched);
@@ -762,10 +1102,12 @@ var AiSdkRunner = class {
762
1102
  cwd: this.#config.cwd ?? process.cwd(),
763
1103
  profile: this.#config.profile,
764
1104
  engine: "provider",
1105
+ capabilities: ENGINE_CAPABILITIES.provider,
765
1106
  model: this.#modelId(),
766
1107
  permissionMode: this.#permissionMode,
767
1108
  createdAt: this.createdAt,
768
1109
  lastSeq: this.#seq,
1110
+ activityCount: this.#activityCount,
769
1111
  pendingPermissionCount: 0,
770
1112
  meta: this.#config.meta,
771
1113
  title: this.#title(),
@@ -828,12 +1170,21 @@ var AiSdkRunner = class {
828
1170
  } catch {}
829
1171
  return snapshot;
830
1172
  }
831
- sendMessage(text) {
1173
+ sendMessage(text, attachments) {
832
1174
  if (this.#parked) throw new Error("session is parked");
833
1175
  if (this.#closed) throw new Error("session is closed");
1176
+ const content = attachments?.length ? [...attachments.map((attachment) => ({
1177
+ type: "file",
1178
+ data: attachment.data,
1179
+ mediaType: normalizeMediaType(attachment.mediaType),
1180
+ filename: attachment.name
1181
+ })), ...text ? [{
1182
+ type: "text",
1183
+ text
1184
+ }] : []] : text;
834
1185
  this.#messages.push({
835
1186
  role: "user",
836
- content: text
1187
+ content
837
1188
  });
838
1189
  this.#emit({
839
1190
  type: "user_message",
@@ -842,6 +1193,7 @@ var AiSdkRunner = class {
842
1193
  content: text
843
1194
  },
844
1195
  parentToolUseId: null,
1196
+ attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
845
1197
  uuid: randomUUID()
846
1198
  });
847
1199
  this.#scheduleTurn();
@@ -1342,6 +1694,17 @@ var AiSdkRunner = class {
1342
1694
  if (!prompt) return void 0;
1343
1695
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
1344
1696
  }
1697
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
1698
+ * it (undefined) restores the derived title. The engine is never told. */
1699
+ setTitle(title) {
1700
+ const meta = { ...this.#config.meta };
1701
+ if (title) meta.title = title;
1702
+ else delete meta.title;
1703
+ this.#config = {
1704
+ ...this.#config,
1705
+ meta
1706
+ };
1707
+ }
1345
1708
  #setStatus(status, detail) {
1346
1709
  if (this.#status === status) return;
1347
1710
  if (this.#status === "closed" || this.#status === "failed") return;
@@ -1359,6 +1722,7 @@ var AiSdkRunner = class {
1359
1722
  ts: Date.now()
1360
1723
  };
1361
1724
  this.#lastActivityAt = event.ts;
1725
+ this.#activityCount += transcriptActivity(body);
1362
1726
  this.#events.push(event);
1363
1727
  for (const listener of this.#listeners) try {
1364
1728
  listener(event);
@@ -2319,6 +2683,2299 @@ function toTransport(server) {
2319
2683
  };
2320
2684
  }
2321
2685
  //#endregion
2322
- export { AiSdkRunner, BrowserBridgeExecutor, DeferredExecutor, InputQueue, PendingRequestRegistry, QuickJsExecutor, SessionRunner, checkClaudeAuth, connectMcpTools, createEngineSession, createToolContext, createWebFetch, htmlToMarkdown, isHostAllowed, isPrivateAddress, normalizeSdkMessage, resolveBundledClaudeExecutable, toApiMessage, toExecutionResult, withMcpTools };
2686
+ //#region src/engines/claude/catalog.ts
2687
+ /**
2688
+ * The Claude engine's model catalog — what a create form offers before any
2689
+ * session has run.
2690
+ *
2691
+ * **Refresh procedure** (release checklist): run `supportedModels()` on a
2692
+ * throwaway SDK query (no tokens spent) and re-apply the shaping rules of
2693
+ * `modelOptionsFromSdk` (`src/normalize.ts`) at authoring time: drop the
2694
+ * `default` sentinel row, derive display names from resolved ids where
2695
+ * unambiguous, mark the newest of each family `primary`, sort by family rank.
2696
+ * A unit test replays the raw extraction through `modelOptionsFromSdk` and
2697
+ * asserts these rows match, so the rules cannot drift.
2698
+ *
2699
+ * Two things the live `capabilities` event can never offer:
2700
+ * - rows for **older models** the CLI no longer reports (hand-maintained, the
2701
+ * accepted cost of a static catalog; the CLI silently downgrades an effort a
2702
+ * model doesn't support, so `reasoningEfforts` is omitted on them and the
2703
+ * engine default set applies);
2704
+ * - an answer on a **cold server**. The live event still exists and remains
2705
+ * the in-session truth for the model switcher; this catalog is the
2706
+ * create-form truth.
2707
+ *
2708
+ * `defaultModel` is deliberately NOT here: a claude profile's default is the
2709
+ * operator's CLI config, unknowable statically.
2710
+ */
2711
+ const CLAUDE_CATALOG = {
2712
+ provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.221 (Claude Code CLI), extracted 2026-08-05; older-model rows hand-maintained",
2713
+ models: [
2714
+ {
2715
+ value: "claude-fable-5[1m]",
2716
+ resolvedModel: "claude-fable-5",
2717
+ displayName: "Fable 5",
2718
+ description: "Fable 5 · Most capable for your hardest and longest-running tasks",
2719
+ primary: true,
2720
+ reasoningEfforts: [
2721
+ "low",
2722
+ "medium",
2723
+ "high",
2724
+ "xhigh",
2725
+ "max"
2726
+ ]
2727
+ },
2728
+ {
2729
+ value: "opus[1m]",
2730
+ resolvedModel: "claude-opus-5[1m]",
2731
+ displayName: "Opus 5",
2732
+ description: "Opus 5 with 1M context · Best for everyday, complex tasks",
2733
+ primary: true,
2734
+ reasoningEfforts: [
2735
+ "low",
2736
+ "medium",
2737
+ "high",
2738
+ "xhigh",
2739
+ "max"
2740
+ ]
2741
+ },
2742
+ {
2743
+ value: "claude-opus-4-8",
2744
+ resolvedModel: "claude-opus-4-8",
2745
+ displayName: "Opus 4.8",
2746
+ description: "Opus 4.8 · Previous Opus generation"
2747
+ },
2748
+ {
2749
+ value: "sonnet",
2750
+ resolvedModel: "claude-sonnet-5",
2751
+ displayName: "Sonnet 5",
2752
+ description: "Sonnet 5 · Efficient for routine tasks",
2753
+ primary: true,
2754
+ reasoningEfforts: [
2755
+ "low",
2756
+ "medium",
2757
+ "high",
2758
+ "xhigh",
2759
+ "max"
2760
+ ]
2761
+ },
2762
+ {
2763
+ value: "claude-sonnet-4-6",
2764
+ resolvedModel: "claude-sonnet-4-6",
2765
+ displayName: "Sonnet 4.6",
2766
+ description: "Sonnet 4.6 · Previous Sonnet generation"
2767
+ },
2768
+ {
2769
+ value: "haiku",
2770
+ resolvedModel: "claude-haiku-4-5-20251001",
2771
+ displayName: "Haiku 4.5",
2772
+ description: "Haiku 4.5 · Fastest for quick answers",
2773
+ primary: true,
2774
+ reasoningEfforts: []
2775
+ }
2776
+ ]
2777
+ };
2778
+ //#endregion
2779
+ //#region src/engines/claude/adapter.ts
2780
+ /**
2781
+ * The Claude engine as an adapter — a thin, behaviourally inert wrapper:
2782
+ * `SessionRunner` unchanged, `checkClaudeAuth` as the probe, the static
2783
+ * catalog for create forms. Exists so catalogs, capabilities and availability
2784
+ * have one shape across engines; the runner itself is exactly what
2785
+ * `registry.prepare()` builds.
2786
+ */
2787
+ const claudeAdapter = {
2788
+ engine: "claude",
2789
+ capabilities: ENGINE_CAPABILITIES.claude,
2790
+ catalog: CLAUDE_CATALOG,
2791
+ async checkAvailability(profile, env) {
2792
+ const status = await checkClaudeAuth(env);
2793
+ if (status === "logged_in") return { available: true };
2794
+ if (status === "logged_out") return {
2795
+ available: false,
2796
+ 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`
2797
+ };
2798
+ return { available: "unknown" };
2799
+ },
2800
+ createRunner({ config, restore }) {
2801
+ if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
2802
+ return new SessionRunner(config);
2803
+ },
2804
+ /**
2805
+ * The Agent SDK's on-disk session store, mapped browser-safe. The SDK reads
2806
+ * the store of the *process* environment — it takes no config dir — so a
2807
+ * profile pin cannot narrow this listing; that matches the route's
2808
+ * pre-adapter behavior exactly (the listing was always process-global).
2809
+ */
2810
+ async listSessions({ dir, limit, offset }) {
2811
+ return (await listSessions({
2812
+ dir,
2813
+ limit,
2814
+ offset
2815
+ })).map((s) => ({
2816
+ sessionId: s.sessionId,
2817
+ summary: s.summary,
2818
+ lastModified: s.lastModified,
2819
+ createdAt: s.createdAt,
2820
+ customTitle: s.customTitle,
2821
+ firstPrompt: s.firstPrompt,
2822
+ gitBranch: s.gitBranch,
2823
+ cwd: s.cwd
2824
+ }));
2825
+ }
2826
+ };
2827
+ //#endregion
2828
+ //#region src/engines/codex/jsonrpc.ts
2829
+ /**
2830
+ * A JSON-RPC error response from the peer, or one we return to it. `code`
2831
+ * follows the JSON-RPC 2.0 reserved ranges (-32601 = method not found).
2832
+ */
2833
+ var JsonRpcError = class extends Error {
2834
+ code;
2835
+ constructor(code, message) {
2836
+ super(message);
2837
+ this.name = "JsonRpcError";
2838
+ this.code = code;
2839
+ }
2840
+ };
2841
+ /**
2842
+ * JSON-RPC over a `codex app-server` child's stdio: **newline-delimited JSON**,
2843
+ * one message per line, and — verified against 0.146.0 — an envelope *without*
2844
+ * the `jsonrpc: "2.0"` field (`{id, method, params}` / `{id, result}` /
2845
+ * `{id, error}`; the binary's own schema marks only those required). Server→
2846
+ * client notifications additionally carry a top-level `emittedAtMs`, ignored
2847
+ * here.
2848
+ *
2849
+ * Transport only: no method knowledge, no process ownership. The process
2850
+ * wrapper (`process.ts`) owns the child and calls {@link fail} when it dies so
2851
+ * every in-flight request rejects instead of hanging.
2852
+ */
2853
+ var JsonRpcStdioConnection = class {
2854
+ #output;
2855
+ #nextId = 1;
2856
+ #pending = /* @__PURE__ */ new Map();
2857
+ #buffer = "";
2858
+ #closed = false;
2859
+ #notificationHandler;
2860
+ #requestHandler;
2861
+ constructor(options) {
2862
+ this.#output = options.output;
2863
+ options.input.on("data", (chunk) => this.#feed(String(chunk)));
2864
+ options.input.on("error", () => {});
2865
+ options.output.on("error", () => {});
2866
+ }
2867
+ request(method, params) {
2868
+ if (this.#closed) return Promise.reject(/* @__PURE__ */ new Error(`codex app-server is closed (${method})`));
2869
+ const id = this.#nextId++;
2870
+ return new Promise((resolve, reject) => {
2871
+ this.#pending.set(id, {
2872
+ method,
2873
+ resolve,
2874
+ reject
2875
+ });
2876
+ this.#write({
2877
+ id,
2878
+ method,
2879
+ ...params === void 0 ? {} : { params }
2880
+ });
2881
+ });
2882
+ }
2883
+ notify(method, params) {
2884
+ if (this.#closed) return;
2885
+ this.#write({
2886
+ method,
2887
+ ...params === void 0 ? {} : { params }
2888
+ });
2889
+ }
2890
+ onNotification(handler) {
2891
+ this.#notificationHandler = handler;
2892
+ }
2893
+ onRequest(handler) {
2894
+ this.#requestHandler = handler;
2895
+ }
2896
+ /** Reject everything in flight and refuse new traffic — the child is gone
2897
+ * (or the session is over). Idempotent. */
2898
+ fail(message) {
2899
+ if (this.#closed) return;
2900
+ this.#closed = true;
2901
+ const pending = [...this.#pending.values()];
2902
+ this.#pending.clear();
2903
+ for (const entry of pending) entry.reject(/* @__PURE__ */ new Error(`${message} (awaiting ${entry.method})`));
2904
+ }
2905
+ #write(payload) {
2906
+ try {
2907
+ this.#output.write(JSON.stringify(payload) + "\n");
2908
+ } catch {}
2909
+ }
2910
+ #feed(chunk) {
2911
+ this.#buffer += chunk;
2912
+ let newline;
2913
+ while ((newline = this.#buffer.indexOf("\n")) >= 0) {
2914
+ const line = this.#buffer.slice(0, newline).trim();
2915
+ this.#buffer = this.#buffer.slice(newline + 1);
2916
+ if (!line) continue;
2917
+ let message;
2918
+ try {
2919
+ message = JSON.parse(line);
2920
+ } catch {
2921
+ continue;
2922
+ }
2923
+ this.#dispatch(message);
2924
+ }
2925
+ }
2926
+ #dispatch(message) {
2927
+ const { id, method } = message;
2928
+ if (typeof method === "string") {
2929
+ if (id === void 0 || id === null) {
2930
+ this.#notificationHandler?.(method, message.params);
2931
+ return;
2932
+ }
2933
+ const respond = (payload) => this.#write({
2934
+ id,
2935
+ ...payload
2936
+ });
2937
+ const handler = this.#requestHandler;
2938
+ if (!handler) {
2939
+ respond({ error: {
2940
+ code: -32601,
2941
+ message: `no handler for server request '${method}'`
2942
+ } });
2943
+ return;
2944
+ }
2945
+ handler(method, message.params, id).then((result) => respond({ result: result ?? {} }), (error) => respond({ error: {
2946
+ code: error instanceof JsonRpcError ? error.code : -32603,
2947
+ message: error instanceof Error ? error.message : String(error)
2948
+ } }));
2949
+ return;
2950
+ }
2951
+ if (id === void 0 || id === null) return;
2952
+ const pending = this.#pending.get(id);
2953
+ if (!pending) return;
2954
+ this.#pending.delete(id);
2955
+ if (message.error !== void 0 && message.error !== null) {
2956
+ const error = message.error;
2957
+ pending.reject(new JsonRpcError(error.code ?? -32603, error.message ?? `request '${pending.method}' failed`));
2958
+ return;
2959
+ }
2960
+ pending.resolve(message.result);
2961
+ }
2962
+ };
2963
+ //#endregion
2964
+ //#region src/engines/codex/runner.ts
2965
+ /**
2966
+ * thread/start's sandbox axis (string form) — our permission modes as codex
2967
+ * sandbox modes: `default` → read-only (reads run; any mutation is refused by
2968
+ * the OS sandbox and — with the ask policy below — escalates to a real
2969
+ * question), `acceptEdits` → workspace-write (in-workspace writes sail
2970
+ * through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
2971
+ */
2972
+ const THREAD_SANDBOX_BY_MODE = {
2973
+ default: "read-only",
2974
+ acceptEdits: "workspace-write",
2975
+ bypassPermissions: "danger-full-access"
2976
+ };
2977
+ /** turn/start's sandboxPolicy axis (object form — same policy, second shape). */
2978
+ const TURN_SANDBOX_BY_MODE = {
2979
+ default: { type: "readOnly" },
2980
+ acceptEdits: { type: "workspaceWrite" },
2981
+ bypassPermissions: { type: "dangerFullAccess" }
2982
+ };
2983
+ /**
2984
+ * The approval axis, stated as the GRANULAR object on both thread/start and
2985
+ * turn/start — never the string vocabulary, deliberately and unconditionally:
2986
+ * measured against 0.146.0, plain `'untrusted'` never asked anything (a
2987
+ * sandbox-violating write was silently refused, a safe echo auto-approved),
2988
+ * while the granular flags make a blocked action a real server→client
2989
+ * question. Granular policies are gated on `capabilities.experimentalApi` at
2990
+ * initialize; WorkerDeck declares it always and keeps NO non-experimental
2991
+ * fallback — a future binary that rejects either gate fails loudly (see
2992
+ * {@link CodexRunner.#ensureThread}) instead of quietly not asking.
2993
+ *
2994
+ * `default`/`acceptEdits` ask (all flags on — the sandbox axis above already
2995
+ * decides *what needs asking*); `bypassPermissions` asks nothing, same shape.
2996
+ */
2997
+ const GRANULAR_ASK = { granular: {
2998
+ sandbox_approval: true,
2999
+ rules: true,
3000
+ mcp_elicitations: true,
3001
+ request_permissions: true,
3002
+ skill_approval: true
3003
+ } };
3004
+ const APPROVAL_POLICY_BY_MODE = {
3005
+ default: GRANULAR_ASK,
3006
+ acceptEdits: GRANULAR_ASK,
3007
+ bypassPermissions: { granular: {
3008
+ sandbox_approval: false,
3009
+ rules: false,
3010
+ mcp_elicitations: false,
3011
+ request_permissions: false,
3012
+ skill_approval: false
3013
+ } }
3014
+ };
3015
+ /** Fallback timeout for a pending approval nobody answers — the SessionRunner
3016
+ * default, so unattended codex sessions land the same way Claude ones do. */
3017
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
3018
+ /**
3019
+ * Tool name for codex's built-in `image_gen`. A stable string because it is a
3020
+ * rendering contract: both clients key an icon (and, where they can reach the
3021
+ * host filesystem, an inline preview) off it.
3022
+ */
3023
+ const CODEX_IMAGE_TOOL = "CodexImageGeneration";
3024
+ /** Longest `result` worth putting in a tool card. The field is free-form and
3025
+ * undocumented; anything past this is assumed to be an encoded image rather
3026
+ * than a sentence, and encoded images do not go in the event log. */
3027
+ const MAX_IMAGE_RESULT_CHARS = 512;
3028
+ const shortResult = (result) => result.length > 0 && result.length <= MAX_IMAGE_RESULT_CHARS && !result.startsWith("data:");
3029
+ /**
3030
+ * `file_produced.fileId` — derived from the path, not minted fresh.
3031
+ *
3032
+ * Two properties fall out of that and both are load-bearing: codex reports the
3033
+ * same `savedPath` on the progress item and again on the completed one, so a
3034
+ * derived id makes the second emission a no-op instead of a duplicate row; and
3035
+ * a session rebuilt from a snapshot re-derives the same ids, so a client's
3036
+ * cached URL still resolves after a park/restore.
3037
+ */
3038
+ function producedFileId(path) {
3039
+ return createHash("sha256").update(path).digest("hex").slice(0, 32);
3040
+ }
3041
+ /** Media type from the extension, for the handful a client renders inline.
3042
+ * Undefined for everything else — the route sniffs, and guessing here is how a
3043
+ * text file ends up labelled `image/png`. */
3044
+ function producedMediaType(path) {
3045
+ return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
3046
+ }
3047
+ const PRODUCED_MEDIA_TYPES = {
3048
+ png: "image/png",
3049
+ jpg: "image/jpeg",
3050
+ jpeg: "image/jpeg",
3051
+ gif: "image/gif",
3052
+ webp: "image/webp",
3053
+ svg: "image/svg+xml",
3054
+ pdf: "application/pdf"
3055
+ };
3056
+ /**
3057
+ * Codex's `SkillMetadata` as the protocol states it. `interface.shortDescription`
3058
+ * beats the legacy top-level one (codex's own comment says to prefer it), and
3059
+ * `enabled` defaults to true — an entry codex listed without the field is one it
3060
+ * considers live, and defaulting to false would hide working skills.
3061
+ */
3062
+ function skillInfo(skill) {
3063
+ return {
3064
+ name: skill.name,
3065
+ ...skill.description ? { description: skill.description } : {},
3066
+ ...skill.interface?.shortDescription ?? skill.shortDescription ? { shortDescription: skill.interface?.shortDescription ?? skill.shortDescription } : {},
3067
+ ...skill.interface?.displayName ? { displayName: skill.interface.displayName } : {},
3068
+ ...skill.interface?.defaultPrompt ? { defaultPrompt: skill.interface.defaultPrompt } : {},
3069
+ ...skill.scope ? { scope: skill.scope } : {},
3070
+ enabled: skill.enabled !== false
3071
+ };
3072
+ }
3073
+ /**
3074
+ * Codex's MCP status → the protocol's, which is Claude Code's vocabulary
3075
+ * ('connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled').
3076
+ *
3077
+ * Two inputs, and the auth one wins where it applies: a server that started
3078
+ * fine but has no credential is *needs-auth*, not connected, because that is
3079
+ * the thing the operator has to act on. `notLoggedIn` is the only auth value
3080
+ * that means "unusable" — `unsupported` is the normal answer for a stdio server
3081
+ * that has no auth concept at all.
3082
+ *
3083
+ * A server with no startup notification yet is 'pending', not 'connected':
3084
+ * `mcpServerStatus/list` alone only proves it is *configured*.
3085
+ */
3086
+ function mcpStatusOf(authStatus, update, hasTools) {
3087
+ if (update?.status === "failed") return update.failureReason === "reauthenticationRequired" ? "needs-auth" : "failed";
3088
+ if (update?.status === "cancelled") return "failed";
3089
+ if (authStatus === "notLoggedIn") return "needs-auth";
3090
+ if (update?.status === "ready") return "connected";
3091
+ if (hasTools) return "connected";
3092
+ return "pending";
3093
+ }
3094
+ /** One `mcpServerStatus/list` entry as the protocol states it. */
3095
+ function mcpServerInfo(server, update) {
3096
+ const tools = Object.entries(server.tools ?? {}).flatMap(([key, tool]) => {
3097
+ if (!tool) return [];
3098
+ const annotations = tool.annotations;
3099
+ return [{
3100
+ name: tool.name ?? key,
3101
+ ...tool.description ? { description: tool.description } : {},
3102
+ ...tool.inputSchema !== void 0 ? { inputSchema: tool.inputSchema } : {},
3103
+ ...annotations ? { annotations: {
3104
+ ...annotations.readOnlyHint != null ? { readOnly: annotations.readOnlyHint } : {},
3105
+ ...annotations.destructiveHint != null ? { destructive: annotations.destructiveHint } : {},
3106
+ ...annotations.openWorldHint != null ? { openWorld: annotations.openWorldHint } : {}
3107
+ } } : {}
3108
+ }];
3109
+ });
3110
+ return {
3111
+ name: server.name,
3112
+ status: mcpStatusOf(server.authStatus ?? void 0, update, tools.length > 0),
3113
+ ...update?.error ? { error: update.error } : {},
3114
+ ...server.serverInfo?.name ? { serverInfo: {
3115
+ name: server.serverInfo.name,
3116
+ version: server.serverInfo.version ?? ""
3117
+ } } : {},
3118
+ ...tools.length > 0 ? { tools } : {}
3119
+ };
3120
+ }
3121
+ /** What the card shows while the picture is being made, and after. `savedPath`
3122
+ * only exists once it lands — a client keys its preview off it, so it is a
3123
+ * field rather than a sentence in the result text. */
3124
+ function imageGenerationInput(item) {
3125
+ return {
3126
+ ...item.revisedPrompt ? { prompt: item.revisedPrompt } : {},
3127
+ ...item.savedPath ? { savedPath: item.savedPath } : {}
3128
+ };
3129
+ }
3130
+ /**
3131
+ * The experimental per-request decision list, normalized to names: a string
3132
+ * entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
3133
+ * …}`) is named by its key. Undefined = the request stated no list and the
3134
+ * channel's schema enum applies. Present only under `experimentalApi: true` —
3135
+ * which WorkerDeck always declares.
3136
+ */
3137
+ function offeredDecisions(params) {
3138
+ const raw = params?.availableDecisions;
3139
+ if (!Array.isArray(raw)) return void 0;
3140
+ const names = /* @__PURE__ */ new Set();
3141
+ for (const entry of raw) if (typeof entry === "string") names.add(entry);
3142
+ else if (entry && typeof entry === "object") for (const key of Object.keys(entry)) names.add(key);
3143
+ return names.size > 0 ? names : void 0;
3144
+ }
3145
+ /**
3146
+ * Decision picking for the `{decision: …}` channels (commandExecution,
3147
+ * fileChange), honoring the request's own `availableDecisions`:
3148
+ *
3149
+ * - allow → 'accept' when offered (or when no list was stated). A request
3150
+ * offering only the broader accepts ('acceptForSession',
3151
+ * 'acceptWithExecpolicyAmendment') yields undefined: a one-shot allow must
3152
+ * not be silently widened into a session-wide or persistent policy grant, so
3153
+ * the caller answers with the denial and says why.
3154
+ * - deny → 'decline', always: the response schema declares it unconditionally,
3155
+ * and it was verified live against 0.146.0 answering a request whose
3156
+ * availableDecisions omitted it — the turn completed cleanly. The list's job
3157
+ * is to gate the accept variants, not to take "no, but keep going" away
3158
+ * (its own alternative, 'cancel', would interrupt the whole turn).
3159
+ * - deny+interrupt → 'cancel' (codex's deny-and-interrupt) when offered;
3160
+ * otherwise 'decline', and the caller interrupts the turn itself.
3161
+ */
3162
+ function pickDecision(behavior, interrupt, offered) {
3163
+ const has = (name) => !offered || offered.has(name);
3164
+ if (behavior === "allow") return has("accept") ? "accept" : void 0;
3165
+ if (interrupt && has("cancel")) return "cancel";
3166
+ return "decline";
3167
+ }
3168
+ /** Codex `requestUserInput` questions in the AskUserQuestion wire shape both
3169
+ * clients already render (QuestionPrompt / QuestionPromptView). */
3170
+ function userQuestionsFromCodex(questions) {
3171
+ return questions.map((question) => ({
3172
+ question: question.question,
3173
+ header: question.header ?? "",
3174
+ options: (question.options ?? []).map((option) => ({
3175
+ label: option.label,
3176
+ description: option.description
3177
+ }))
3178
+ }));
3179
+ }
3180
+ /** The text of a history `userMessage` item: its content entries' text parts
3181
+ * joined. Image parts have no replayable representation (the bytes went to the
3182
+ * model, not into the rollout we can render from) and are skipped. */
3183
+ function historyUserText(item) {
3184
+ if (!Array.isArray(item.content)) return "";
3185
+ return item.content.map((part) => {
3186
+ const candidate = part;
3187
+ return candidate?.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
3188
+ }).filter(Boolean).join("\n");
3189
+ }
3190
+ /** The AskUserQuestion answer convention (question text → chosen label(s),
3191
+ * comma-joined) mapped back to codex's id-keyed shape. Questions the client
3192
+ * did not answer are absent, not empty. */
3193
+ function codexAnswers(questions, answers) {
3194
+ const out = {};
3195
+ for (const question of questions) {
3196
+ const value = answers?.[question.question] ?? answers?.[question.id];
3197
+ if (typeof value === "string" && value.length > 0) out[question.id] = { answers: [value] };
3198
+ }
3199
+ return out;
3200
+ }
3201
+ /** The two channels whose response is `{decision: …}` share their pick logic. */
3202
+ function decisionChannel(describe, itemId) {
3203
+ return {
3204
+ describe,
3205
+ itemId,
3206
+ allow: (_params, _updatedInput, offered) => {
3207
+ const decision = pickDecision("allow", false, offered);
3208
+ return decision ? {
3209
+ response: { decision },
3210
+ decision
3211
+ } : void 0;
3212
+ },
3213
+ deny: (_params, interrupt, offered) => {
3214
+ const decision = pickDecision("deny", interrupt, offered);
3215
+ return {
3216
+ response: { decision },
3217
+ decision
3218
+ };
3219
+ }
3220
+ };
3221
+ }
3222
+ /**
3223
+ * The ask channels, wired to the permission surface. Anything not listed here
3224
+ * still gets a JSON-RPC -32601 — never a hang (an unanswered server request
3225
+ * wedges the turn).
3226
+ */
3227
+ const APPROVAL_CHANNELS = {
3228
+ "item/commandExecution/requestApproval": decisionChannel((raw) => {
3229
+ const params = raw;
3230
+ const command = params.command ?? void 0;
3231
+ return {
3232
+ toolName: "CodexCommand",
3233
+ input: {
3234
+ ...command !== void 0 ? { command } : {},
3235
+ ...params.cwd ? { cwd: params.cwd } : {},
3236
+ ...params.reason ? { reason: params.reason } : {}
3237
+ },
3238
+ title: params.reason ?? (command ? `Codex wants to run: ${command}` : "Codex wants to run a command"),
3239
+ displayName: "Run command",
3240
+ description: params.reason && command ? command : params.cwd ?? void 0,
3241
+ decisionReason: params.reason ?? void 0
3242
+ };
3243
+ }, (raw) => raw.itemId),
3244
+ "item/fileChange/requestApproval": decisionChannel((raw) => {
3245
+ const params = raw;
3246
+ return {
3247
+ toolName: "CodexFileChange",
3248
+ input: {
3249
+ ...params.grantRoot ? { grantRoot: params.grantRoot } : {},
3250
+ ...params.reason ? { reason: params.reason } : {}
3251
+ },
3252
+ title: params.reason ?? "Codex wants to apply file changes",
3253
+ displayName: "Apply file changes",
3254
+ description: params.grantRoot ? `write access under ${params.grantRoot}` : void 0,
3255
+ decisionReason: params.reason ?? void 0
3256
+ };
3257
+ }, (raw) => raw.itemId),
3258
+ "item/permissions/requestApproval": {
3259
+ describe: (raw) => {
3260
+ const params = raw;
3261
+ return {
3262
+ toolName: "CodexPermissions",
3263
+ input: {
3264
+ ...params.permissions ? { permissions: params.permissions } : {},
3265
+ ...params.cwd ? { cwd: params.cwd } : {},
3266
+ ...params.reason ? { reason: params.reason } : {}
3267
+ },
3268
+ title: params.reason ?? "Codex requests additional permissions",
3269
+ displayName: "Grant permissions",
3270
+ description: void 0,
3271
+ decisionReason: params.reason ?? void 0
3272
+ };
3273
+ },
3274
+ itemId: (raw) => raw.itemId,
3275
+ allow: (raw, updatedInput) => ({ response: { permissions: updatedInput?.permissions ?? raw.permissions ?? {} } }),
3276
+ deny: () => ({ response: { permissions: {} } })
3277
+ },
3278
+ "item/tool/requestUserInput": {
3279
+ describe: (raw) => ({
3280
+ toolName: "AskUserQuestion",
3281
+ input: { questions: userQuestionsFromCodex(raw.questions ?? []) },
3282
+ title: "Codex asks a question",
3283
+ displayName: "Answer questions",
3284
+ description: void 0,
3285
+ decisionReason: void 0
3286
+ }),
3287
+ itemId: (raw) => raw.itemId,
3288
+ allow: (raw, updatedInput) => ({ response: { answers: codexAnswers(raw.questions ?? [], updatedInput?.answers) } }),
3289
+ deny: () => ({ response: { answers: {} } })
3290
+ },
3291
+ "mcpServer/elicitation/request": {
3292
+ describe: (raw) => {
3293
+ const params = raw;
3294
+ return {
3295
+ toolName: "CodexMcpElicitation",
3296
+ input: {
3297
+ ...params.serverName ? { serverName: params.serverName } : {},
3298
+ ...params.message ? { message: params.message } : {},
3299
+ ...params.mode ? { mode: params.mode } : {},
3300
+ ...params.requestedSchema !== void 0 ? { requestedSchema: params.requestedSchema } : {},
3301
+ ...params.url ? { url: params.url } : {}
3302
+ },
3303
+ title: params.serverName ? `MCP server '${params.serverName}' requests input` : "An MCP server requests input",
3304
+ displayName: "MCP elicitation",
3305
+ description: params.message ?? void 0,
3306
+ decisionReason: void 0
3307
+ };
3308
+ },
3309
+ itemId: () => void 0,
3310
+ allow: (_raw, updatedInput) => ({ response: {
3311
+ action: "accept",
3312
+ ...updatedInput !== void 0 ? { content: updatedInput } : {}
3313
+ } }),
3314
+ deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
3315
+ }
3316
+ };
3317
+ /**
3318
+ * Name a subscription window by its measured length, so codex's positional
3319
+ * windows land in the protocol's named vocabulary. The two names clients
3320
+ * already understand are exact matches for codex's durations (300 min = 5h,
3321
+ * 10080 min = 7d); anything else keeps a self-describing key rather than
3322
+ * borrowing a name that would size it wrongly.
3323
+ */
3324
+ function rateLimitWindowName(minutes) {
3325
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return void 0;
3326
+ if (minutes === 300) return "five_hour";
3327
+ if (minutes === 10080) return "seven_day";
3328
+ return `window_${minutes}m`;
3329
+ }
3330
+ /**
3331
+ * The Codex engine, over the binary's `app-server` JSON-RPC surface: ONE
3332
+ * `codex app-server` child per *session* (spawned lazily, held across turns),
3333
+ * streaming `item/agentMessage/delta` and the reasoning deltas token-by-token
3334
+ * (`streaming: 'token'`). Follows `SessionRunner`'s event-log/seq/status
3335
+ * discipline with `AiSdkRunner`'s turn-chain (one turn at a time; sendMessage
3336
+ * queues). The first codex transport was `codex exec --experimental-json` (one
3337
+ * child per turn) — retired because its JSONL carries no partial messages, so
3338
+ * a turn could never stream.
3339
+ *
3340
+ * A dead child is a failed *turn*, not a failed session: the thread persists
3341
+ * on disk, the connection is dropped, and the next message spawns a fresh
3342
+ * child that `thread/resume`s the same thread id.
3343
+ */
3344
+ var CodexRunner = class {
3345
+ id;
3346
+ createdAt;
3347
+ #config;
3348
+ #events = [];
3349
+ #listeners = /* @__PURE__ */ new Set();
3350
+ #seq = 0;
3351
+ #activityCount = 0;
3352
+ #status = "starting";
3353
+ #sdkSessionId;
3354
+ #model;
3355
+ #permissionMode;
3356
+ #reasoningEffort;
3357
+ /** What the binary said the profile's defaults resolve to (thread/start
3358
+ * response) — lets `setModel(undefined)` mean "back to the default" even
3359
+ * though a turn/start override persists for subsequent turns. */
3360
+ #resolvedModel;
3361
+ /** Last reported ChatGPT plan, so `plan_info` is emitted once per change. */
3362
+ #planType;
3363
+ #resolvedEffort;
3364
+ #queue = [];
3365
+ #turnChain = Promise.resolve();
3366
+ #activeTurn;
3367
+ #connection;
3368
+ #threadLoaded = false;
3369
+ #numTurns = 0;
3370
+ #totalCostUsd;
3371
+ #lastActivityAt;
3372
+ #started = false;
3373
+ #closed = false;
3374
+ /** Session temp dir for image attachments (`localImage` takes host paths). */
3375
+ #imageDir;
3376
+ /** Pending server→client approvals, keyed by the surfaced request id. */
3377
+ #approvals = /* @__PURE__ */ new Map();
3378
+ /** True from start() until the resume backfill (the turn chain's first link)
3379
+ * settles — while set, sendMessage defers its user_message echo behind the
3380
+ * chain so a new turn can never precede or interleave the replayed history. */
3381
+ #backfillPending = false;
3382
+ /** The resumed thread's prior turns, stashed by {@link #ensureThread} from
3383
+ * the ONE thread/resume the backfill consumes (`partial` = the response's
3384
+ * turnsBackwardsCursor said older turns exist beyond this page). A mid-life
3385
+ * reconnect also goes through thread/resume, but with no backfill pending
3386
+ * nothing is stashed — history is never replayed twice. */
3387
+ #resumedHistory;
3388
+ /** Set around history replay: {@link #emit} stamps `replay: true` onto the
3389
+ * message events the live item mapping produces. */
3390
+ #replayingHistory = false;
3391
+ /** Last `skills` payload emitted, serialized — the comparison that keeps a
3392
+ * `skills/changed` storm (the watcher fires per touched file) from filling
3393
+ * the event log with identical lists. */
3394
+ #skillsFingerprint;
3395
+ /** In-flight `skills/list`, so a burst of `skills/changed` makes one call.
3396
+ * The pending promise is reused rather than queued: the request has no
3397
+ * arguments, so a second one would ask the same question. */
3398
+ #skillsRefresh;
3399
+ /** Host paths already announced via `file_produced`, so the same picture
3400
+ * reported on both the progress and the completed item registers once. */
3401
+ #producedPaths = /* @__PURE__ */ new Set();
3402
+ /** Per-server liveness, accumulated from `mcpServer/startupStatus/updated`.
3403
+ * `mcpServerStatus/list` does not carry a status field at all, so without
3404
+ * this every server would read as "configured" and never as up or down. */
3405
+ #mcpStatus = /* @__PURE__ */ new Map();
3406
+ constructor(config, id = randomUUID()) {
3407
+ const mode = config.permissionMode ?? "default";
3408
+ if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
3409
+ if (config.forkSession) throw new Error("the codex engine cannot fork a resumed thread");
3410
+ this.#config = config;
3411
+ this.#permissionMode = mode;
3412
+ this.#model = config.model;
3413
+ this.#reasoningEffort = config.reasoningEffort;
3414
+ this.#sdkSessionId = config.resume;
3415
+ this.id = id;
3416
+ this.createdAt = Date.now();
3417
+ }
3418
+ /** The complete child environment — spawn env replaces process.env wholesale,
3419
+ * so this must carry everything a shell would, with the profile's CODEX_HOME
3420
+ * pin winning over operator env. */
3421
+ #childEnv() {
3422
+ const base = this.#config.env ?? process.env;
3423
+ const env = {};
3424
+ for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
3425
+ if (this.#config.codexHome) env.CODEX_HOME = this.#config.codexHome;
3426
+ return env;
3427
+ }
3428
+ get status() {
3429
+ return this.#status;
3430
+ }
3431
+ get sdkSessionId() {
3432
+ return this.#sdkSessionId;
3433
+ }
3434
+ get lastSeq() {
3435
+ return this.#seq;
3436
+ }
3437
+ get pendingApprovals() {
3438
+ return [...this.#approvals.values()].map((pending) => pending.request);
3439
+ }
3440
+ info() {
3441
+ return {
3442
+ id: this.id,
3443
+ sdkSessionId: this.#sdkSessionId,
3444
+ status: this.#status,
3445
+ cwd: this.#config.cwd,
3446
+ profile: this.#config.profile,
3447
+ engine: "codex",
3448
+ capabilities: ENGINE_CAPABILITIES.codex,
3449
+ model: this.#model ?? this.#resolvedModel,
3450
+ permissionMode: this.#permissionMode,
3451
+ canBypassPermissions: true,
3452
+ createdAt: this.createdAt,
3453
+ lastSeq: this.#seq,
3454
+ activityCount: this.#activityCount,
3455
+ pendingPermissionCount: this.#approvals.size,
3456
+ meta: this.#config.meta,
3457
+ title: this.#title(),
3458
+ totalCostUsd: this.#totalCostUsd,
3459
+ numTurns: this.#numTurns || void 0,
3460
+ lastActivityAt: this.#lastActivityAt
3461
+ };
3462
+ }
3463
+ #title() {
3464
+ const metaTitle = this.#config.meta?.title;
3465
+ if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
3466
+ const prompt = this.#config.prompt;
3467
+ if (!prompt) return void 0;
3468
+ return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
3469
+ }
3470
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
3471
+ * it (undefined) restores the derived title. The engine is never told. */
3472
+ setTitle(title) {
3473
+ const meta = { ...this.#config.meta };
3474
+ if (title) meta.title = title;
3475
+ else delete meta.title;
3476
+ this.#config = {
3477
+ ...this.#config,
3478
+ meta
3479
+ };
3480
+ }
3481
+ start() {
3482
+ if (this.#started) return this.#turnChain;
3483
+ this.#started = true;
3484
+ if (this.#config.resume && this.#config.backfillHistory !== false) {
3485
+ this.#backfillPending = true;
3486
+ this.#turnChain = this.#turnChain.then(() => this.#backfillHistory());
3487
+ } else this.#setStatus("idle");
3488
+ if (this.#config.prompt) this.sendMessage(this.#config.prompt);
3489
+ if (!this.#config.prompt && !this.#config.resume) this.#probeSkills();
3490
+ return this.#turnChain;
3491
+ }
3492
+ /**
3493
+ * List skills over a **throwaway** connection, for a session with nothing else
3494
+ * to do yet.
3495
+ *
3496
+ * `skills/list` needs a live child but not a thread, so this spawns one, asks,
3497
+ * and closes it — rather than bringing up the session's own child early and
3498
+ * leaving a codex process parked behind every session someone created and
3499
+ * never typed into. The session's real connection re-lists when it arrives;
3500
+ * the fingerprint compare in {@link #refreshSkills} makes that a no-op.
3501
+ *
3502
+ * Entirely best-effort and never awaited: a missing binary, a failed spawn or
3503
+ * a rejected handshake here must not turn a session that has not started into
3504
+ * a session that failed.
3505
+ */
3506
+ async #probeSkills() {
3507
+ let connection;
3508
+ try {
3509
+ connection = await this.#openScratchConnection();
3510
+ if (this.#closed) return;
3511
+ await this.#refreshSkills(connection);
3512
+ } catch {} finally {
3513
+ connection?.close();
3514
+ }
3515
+ }
3516
+ /**
3517
+ * A handshaken child that is **not** the session's — for the questions a
3518
+ * client can ask before the session has anything to run (its skills, its MCP
3519
+ * servers). The caller owns it and must close it.
3520
+ *
3521
+ * No onNotification/onRequest/onClose wiring on purpose: this child answers
3522
+ * one question and goes away, so its notifications are noise and its death is
3523
+ * not the session's problem. The alternative — bringing the session's real
3524
+ * child up early — would park a codex process behind every session someone
3525
+ * created and never typed into.
3526
+ */
3527
+ async #openScratchConnection() {
3528
+ const connection = this.#config.connectFn({ env: this.#childEnv() });
3529
+ try {
3530
+ await connection.request("initialize", {
3531
+ clientInfo: {
3532
+ name: "workerdeck",
3533
+ title: "WorkerDeck",
3534
+ version: `protocol-${PROTOCOL_VERSION}`
3535
+ },
3536
+ capabilities: { experimentalApi: true }
3537
+ });
3538
+ connection.notify("initialized");
3539
+ return connection;
3540
+ } catch (error) {
3541
+ connection.close();
3542
+ throw error;
3543
+ }
3544
+ }
3545
+ sendMessage(text, attachments) {
3546
+ if (this.#closed) throw new Error("session is closed");
3547
+ const input = this.#buildInput(text, attachments ?? []);
3548
+ const echo = () => this.#emit({
3549
+ type: "user_message",
3550
+ message: {
3551
+ role: "user",
3552
+ content: text
3553
+ },
3554
+ parentToolUseId: null,
3555
+ attachments: attachments?.length ? attachments.map(attachmentRef) : void 0,
3556
+ uuid: randomUUID()
3557
+ });
3558
+ if (this.#backfillPending) this.#turnChain = this.#turnChain.then(echo);
3559
+ else echo();
3560
+ this.#queue.push({ input });
3561
+ this.#scheduleTurn();
3562
+ }
3563
+ /**
3564
+ * App-server input for a message with attachments: images land in a session
3565
+ * temp dir and travel as `localImage` host paths, text files inline into the
3566
+ * prompt in the shared named envelope, PDF has no representation (the
3567
+ * gateway's 415 normally refuses it first).
3568
+ */
3569
+ #buildInput(text, attachments) {
3570
+ const parts = [];
3571
+ for (const attachment of attachments) {
3572
+ const mediaType = normalizeMediaType(attachment.mediaType);
3573
+ switch (attachmentKind(mediaType)) {
3574
+ case "image": {
3575
+ this.#imageDir ??= join(tmpdir(), `workerdeck-codex-${this.id}`);
3576
+ mkdirSync(this.#imageDir, { recursive: true });
3577
+ const ext = mediaType.split("/")[1] ?? "bin";
3578
+ const path = join(this.#imageDir, `${attachment.id}.${ext}`);
3579
+ writeFileSync(path, Buffer.from(attachment.data, "base64"));
3580
+ parts.push({
3581
+ type: "localImage",
3582
+ path
3583
+ });
3584
+ break;
3585
+ }
3586
+ case "text":
3587
+ parts.push({
3588
+ type: "text",
3589
+ text: `<attachment name="${attachment.name}" type="${mediaType}">\n${Buffer.from(attachment.data, "base64").toString("utf8")}\n</attachment>`
3590
+ });
3591
+ break;
3592
+ default: throw new Error(`unsupported attachment media type for the codex engine: ${attachment.mediaType}`);
3593
+ }
3594
+ }
3595
+ if (text) parts.push({
3596
+ type: "text",
3597
+ text
3598
+ });
3599
+ return parts;
3600
+ }
3601
+ /** Resolve a pending approval. Returns false if the id is unknown (e.g.
3602
+ * timed out, or already settled by codex itself). */
3603
+ resolvePermission(requestId, decision) {
3604
+ const pending = this.#approvals.get(requestId);
3605
+ if (!pending) return false;
3606
+ this.#settleApproval(requestId, pending, decision, "client");
3607
+ return true;
3608
+ }
3609
+ async interrupt() {
3610
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
3611
+ behavior: "deny",
3612
+ message: "interrupted",
3613
+ interrupt: true
3614
+ }, "policy");
3615
+ await this.#interruptTurn();
3616
+ await this.#turnChain;
3617
+ }
3618
+ /** Address the in-flight turn only (no approval sweep) — also the follow-up
3619
+ * for a deny+interrupt whose wire decision couldn't carry the interrupt. */
3620
+ async #interruptTurn() {
3621
+ const active = this.#activeTurn;
3622
+ const connection = this.#connection;
3623
+ if (active && !active.settled) {
3624
+ active.interrupted = true;
3625
+ if (connection && active.turnId && this.#sdkSessionId) try {
3626
+ await connection.request("turn/interrupt", {
3627
+ threadId: this.#sdkSessionId,
3628
+ turnId: active.turnId
3629
+ });
3630
+ } catch {}
3631
+ else if (connection) {
3632
+ connection.close();
3633
+ if (this.#connection === connection) this.#connection = void 0;
3634
+ active.reject(/* @__PURE__ */ new Error("interrupted"));
3635
+ }
3636
+ }
3637
+ }
3638
+ async setPermissionMode(mode) {
3639
+ if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
3640
+ if (this.#activeTurn) throw new Error("cannot change the permission mode mid-turn (the running turn's sandbox is fixed)");
3641
+ this.#permissionMode = mode;
3642
+ this.#emit({
3643
+ type: "permission_mode_changed",
3644
+ mode
3645
+ });
3646
+ }
3647
+ async setModel(model) {
3648
+ if (this.#activeTurn) throw new Error("cannot change the model mid-turn (the running turn's model is fixed)");
3649
+ this.#model = model;
3650
+ this.#emit({
3651
+ type: "model_changed",
3652
+ model
3653
+ });
3654
+ }
3655
+ fail(message) {
3656
+ if (this.#closed) return;
3657
+ this.#emit({
3658
+ type: "session_error",
3659
+ message
3660
+ });
3661
+ this.#setStatus("failed");
3662
+ this.close("error");
3663
+ }
3664
+ close(reason = "client") {
3665
+ if (this.#closed) return;
3666
+ this.#closed = true;
3667
+ this.#queue.length = 0;
3668
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
3669
+ behavior: "deny",
3670
+ message: "Session closed"
3671
+ }, "policy");
3672
+ this.#connection?.close();
3673
+ this.#connection = void 0;
3674
+ this.#activeTurn?.reject(/* @__PURE__ */ new Error("session closed"));
3675
+ if (this.#imageDir) try {
3676
+ rmSync(this.#imageDir, {
3677
+ recursive: true,
3678
+ force: true
3679
+ });
3680
+ } catch {}
3681
+ this.#emit({
3682
+ type: "session_closed",
3683
+ reason
3684
+ });
3685
+ this.#setStatus("closed");
3686
+ }
3687
+ subscribe(listener, afterSeq = 0) {
3688
+ for (const event of this.#events) if (event.seq > afterSeq) listener(event);
3689
+ this.#listeners.add(listener);
3690
+ return () => this.#listeners.delete(listener);
3691
+ }
3692
+ #scheduleTurn() {
3693
+ this.#turnChain = this.#turnChain.then(() => this.#runTurn());
3694
+ }
3695
+ /**
3696
+ * The session's live connection with its thread loaded, (re)building both as
3697
+ * needed: spawn + `initialize`/`initialized` on a fresh child, then
3698
+ * `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
3699
+ * thread orphaned by a dead child). The response's resolved model/effort are
3700
+ * kept so per-turn overrides can name "the profile default" explicitly.
3701
+ */
3702
+ async #ensureThread() {
3703
+ if (this.#closed) throw new Error("session is closed");
3704
+ let connection = this.#connection;
3705
+ if (!connection) {
3706
+ connection = this.#config.connectFn({ env: this.#childEnv() });
3707
+ this.#connection = connection;
3708
+ this.#threadLoaded = false;
3709
+ connection.onNotification((method, params) => this.#handleNotification(method, params));
3710
+ connection.onRequest((method, params, id) => this.#answerServerRequest(method, params, id));
3711
+ connection.onClose((message) => {
3712
+ if (this.#connection === connection) {
3713
+ this.#connection = void 0;
3714
+ this.#threadLoaded = false;
3715
+ }
3716
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
3717
+ behavior: "deny",
3718
+ message
3719
+ }, "policy");
3720
+ this.#activeTurn?.reject(new Error(message));
3721
+ });
3722
+ try {
3723
+ await connection.request("initialize", {
3724
+ clientInfo: {
3725
+ name: "workerdeck",
3726
+ title: "WorkerDeck",
3727
+ version: `protocol-${PROTOCOL_VERSION}`
3728
+ },
3729
+ capabilities: { experimentalApi: true }
3730
+ });
3731
+ } catch (error) {
3732
+ connection.close();
3733
+ if (this.#connection === connection) this.#connection = void 0;
3734
+ 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);
3735
+ throw error;
3736
+ }
3737
+ connection.notify("initialized");
3738
+ }
3739
+ if (!this.#threadLoaded) {
3740
+ const options = {
3741
+ cwd: this.#config.cwd,
3742
+ approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
3743
+ sandbox: THREAD_SANDBOX_BY_MODE[this.#permissionMode]
3744
+ };
3745
+ if (this.#model) options.model = this.#model;
3746
+ const resuming = this.#sdkSessionId !== void 0;
3747
+ const result = resuming ? await connection.request("thread/resume", {
3748
+ threadId: this.#sdkSessionId,
3749
+ ...options
3750
+ }) : await connection.request("thread/start", options);
3751
+ if (typeof result?.thread?.id === "string") this.#sdkSessionId = result.thread.id;
3752
+ if (typeof result?.model === "string") this.#resolvedModel = result.model;
3753
+ if (typeof result?.reasoningEffort === "string") this.#resolvedEffort = result.reasoningEffort;
3754
+ if (resuming && this.#backfillPending && !this.#resumedHistory) this.#resumedHistory = {
3755
+ turns: Array.isArray(result?.thread?.turns) ? result.thread.turns : [],
3756
+ partial: typeof result?.turnsBackwardsCursor === "string"
3757
+ };
3758
+ this.#threadLoaded = true;
3759
+ }
3760
+ this.#refreshSkills(connection);
3761
+ return connection;
3762
+ }
3763
+ /**
3764
+ * Re-read `skills/list` and publish it, if it changed.
3765
+ *
3766
+ * **`cwds` is passed explicitly, and must be.** The schema documents the empty
3767
+ * case as "the current session working directory", which reads like the
3768
+ * thread's — it is not. Measured against 0.146.0: with no `cwds`, and *after*
3769
+ * a `thread/start` carrying this session's cwd, the response comes back keyed
3770
+ * to the app-server child's own process directory (for WorkerDeck, wherever
3771
+ * the gateway was launched) and reports no repo-scoped skills at all. So a
3772
+ * project's own `.codex/skills/**` were invisible until this argument existed.
3773
+ *
3774
+ * Best-effort throughout. A binary too old to know the method, a broken
3775
+ * manifest, a child that died mid-call — none of that is worth failing a
3776
+ * session over, and the panel simply stays absent.
3777
+ */
3778
+ async #refreshSkills(connection) {
3779
+ if (this.#skillsRefresh) return this.#skillsRefresh;
3780
+ const run = (async () => {
3781
+ try {
3782
+ const result = await connection.request("skills/list", { cwds: [this.#config.cwd] });
3783
+ if (this.#closed) return;
3784
+ const entries = Array.isArray(result?.data) ? result.data : [];
3785
+ const seen = /* @__PURE__ */ new Set();
3786
+ const skills = [];
3787
+ for (const entry of entries) for (const skill of entry?.skills ?? []) {
3788
+ if (typeof skill?.name !== "string" || seen.has(skill.name)) continue;
3789
+ seen.add(skill.name);
3790
+ skills.push(skillInfo(skill));
3791
+ }
3792
+ skills.sort((a, b) => a.name.localeCompare(b.name));
3793
+ const fingerprint = JSON.stringify(skills);
3794
+ if (fingerprint === this.#skillsFingerprint) return;
3795
+ this.#skillsFingerprint = fingerprint;
3796
+ this.#emit({
3797
+ type: "skills",
3798
+ skills
3799
+ });
3800
+ } catch {} finally {
3801
+ this.#skillsRefresh = void 0;
3802
+ }
3803
+ })();
3804
+ this.#skillsRefresh = run;
3805
+ return run;
3806
+ }
3807
+ /**
3808
+ * The session's MCP servers, live from the binary.
3809
+ *
3810
+ * Two sources merged, because codex splits them: `mcpServerStatus/list` says
3811
+ * what is configured and what each server exposes (including every tool's
3812
+ * full JSON Schema, which the Agent SDK does not give us), and the
3813
+ * `mcpServer/startupStatus/updated` notifications say which of them are
3814
+ * actually up.
3815
+ *
3816
+ * Answers **before the session has connected**, over a throwaway child, for
3817
+ * the same reason the skill list does: a codex session spawns nothing until
3818
+ * it has work, and a panel that said "no MCP servers configured" until the
3819
+ * first turn would be stating something false about the operator's config.
3820
+ * The request blocks until the servers are enumerated (measured: complete on
3821
+ * the very first call), so there is no half-populated answer to race.
3822
+ *
3823
+ * Resolves undefined only when there is genuinely nothing to say — the
3824
+ * session is closed, or the child could not be spoken to. The route turns
3825
+ * that into a 501.
3826
+ *
3827
+ * **Listing only.** There is no per-server reconnect or toggle on this
3828
+ * transport — hence no `reconnectMcpServer`/`setMcpServerEnabled` here, and
3829
+ * `ENGINE_CAPABILITIES.codex.mcpServerActions: false` so clients render the
3830
+ * panel read-only instead of offering buttons that cannot work.
3831
+ */
3832
+ async mcpServers() {
3833
+ if (this.#closed) return void 0;
3834
+ const live = this.#connection;
3835
+ let scratch;
3836
+ try {
3837
+ return ((await (live ?? (scratch = await this.#openScratchConnection())).request("mcpServerStatus/list", {}))?.data ?? []).map((server) => mcpServerInfo(server, this.#mcpStatus.get(server.name)));
3838
+ } catch {
3839
+ return;
3840
+ } finally {
3841
+ scratch?.close();
3842
+ }
3843
+ }
3844
+ /**
3845
+ * Announce a file the ENGINE wrote on the host, so a client can fetch it
3846
+ * without the operator having declared its directory as a host-file root.
3847
+ *
3848
+ * Deliberately narrow: only paths codex reports as *written by its own tool*
3849
+ * belong here. A path the model merely read (`imageView`) is an agent-chosen
3850
+ * claim, and those keep going through `/fs/*` and its root allowlist — see
3851
+ * the note on `file_produced` in the protocol.
3852
+ */
3853
+ #emitFileProduced(path, toolUseId) {
3854
+ if (this.#producedPaths.has(path)) return;
3855
+ this.#producedPaths.add(path);
3856
+ let bytes;
3857
+ try {
3858
+ const stat = statSync(path);
3859
+ if (stat.isFile()) bytes = stat.size;
3860
+ } catch {}
3861
+ this.#emit({
3862
+ type: "file_produced",
3863
+ fileId: producedFileId(path),
3864
+ path,
3865
+ ...producedMediaType(path) ? { mediaType: producedMediaType(path) } : {},
3866
+ ...bytes !== void 0 ? { bytes } : {},
3867
+ toolUseId
3868
+ });
3869
+ }
3870
+ /**
3871
+ * On resume, replay the thread's prior turns as `replay: true` events,
3872
+ * seq'd before any live turn — the SessionRunner backfill contract, fed
3873
+ * from `thread/resume`'s own `thread.turns`. When the resume response says
3874
+ * that page is partial (`turnsBackwardsCursor`), the FULL rollout history
3875
+ * is fetched via `thread/read {includeTurns: true}` instead — and if even
3876
+ * that fails, the partial page is replayed under a visible notice rather
3877
+ * than silently posing as the whole thread. Best-effort like the Claude
3878
+ * backfill: an unreadable history never blocks the resume itself.
3879
+ */
3880
+ async #backfillHistory() {
3881
+ try {
3882
+ if (this.#closed) return;
3883
+ const connection = await this.#ensureThread();
3884
+ const resumed = this.#resumedHistory;
3885
+ this.#resumedHistory = void 0;
3886
+ let turns = resumed?.turns ?? [];
3887
+ let partialReason;
3888
+ if (resumed?.partial) try {
3889
+ const full = (await connection.request("thread/read", {
3890
+ threadId: this.#sdkSessionId,
3891
+ includeTurns: true
3892
+ }))?.thread?.turns;
3893
+ if (Array.isArray(full) && full.length >= turns.length) turns = full;
3894
+ else partialReason = "thread/read returned less history than the resume page";
3895
+ } catch (error) {
3896
+ partialReason = error instanceof Error ? error.message : String(error);
3897
+ }
3898
+ if (partialReason) this.#emit({
3899
+ type: "session_error",
3900
+ message: `Resumed thread history is incomplete — older turns could not be loaded (${partialReason})`
3901
+ });
3902
+ this.#replayTurns(turns);
3903
+ } catch {} finally {
3904
+ this.#backfillPending = false;
3905
+ this.#setStatus("idle");
3906
+ }
3907
+ }
3908
+ /** Replay historical turns through the SAME item mapping the live path uses. */
3909
+ #replayTurns(turns) {
3910
+ for (const turn of turns) {
3911
+ if (this.#closed) return;
3912
+ const state = this.#newTurnState();
3913
+ this.#replayingHistory = true;
3914
+ try {
3915
+ for (const item of turn.items ?? []) {
3916
+ if (item.type === "userMessage") {
3917
+ const text = historyUserText(item);
3918
+ if (!text) continue;
3919
+ this.#emit({
3920
+ type: "user_message",
3921
+ message: {
3922
+ role: "user",
3923
+ content: text
3924
+ },
3925
+ parentToolUseId: null,
3926
+ uuid: `${state.nonce}:${item.id}`
3927
+ });
3928
+ continue;
3929
+ }
3930
+ this.#handleItemCompleted(item, state);
3931
+ }
3932
+ } finally {
3933
+ this.#replayingHistory = false;
3934
+ }
3935
+ }
3936
+ }
3937
+ /** Fresh per-turn state — one per live turn, and one per REPLAYED turn (the
3938
+ * nonce is the item-id namespace, and its per-turn-ness is the invariant). */
3939
+ #newTurnState() {
3940
+ return {
3941
+ nonce: randomUUID(),
3942
+ interrupted: false,
3943
+ usage: {
3944
+ inputTokens: 0,
3945
+ cachedInputTokens: 0,
3946
+ cacheWriteInputTokens: 0,
3947
+ outputTokens: 0,
3948
+ reasoningOutputTokens: 0,
3949
+ totalTokens: 0
3950
+ },
3951
+ sawUsage: false,
3952
+ toolUseEmitted: /* @__PURE__ */ new Set(),
3953
+ sectionIndex: /* @__PURE__ */ new Map(),
3954
+ settled: false,
3955
+ resolve: () => {},
3956
+ reject: () => {}
3957
+ };
3958
+ }
3959
+ async #runTurn() {
3960
+ if (this.#closed) return;
3961
+ const turn = this.#queue.shift();
3962
+ if (!turn) return;
3963
+ this.#setStatus("running");
3964
+ const startedAt = Date.now();
3965
+ const active = this.#newTurnState();
3966
+ const outcome = new Promise((resolve, reject) => {
3967
+ active.resolve = (turnResult) => {
3968
+ if (active.settled) return;
3969
+ active.settled = true;
3970
+ resolve(turnResult);
3971
+ };
3972
+ active.reject = (error) => {
3973
+ if (active.settled) return;
3974
+ active.settled = true;
3975
+ reject(error);
3976
+ };
3977
+ });
3978
+ this.#activeTurn = active;
3979
+ try {
3980
+ const connection = await this.#ensureThread();
3981
+ const params = {
3982
+ threadId: this.#sdkSessionId,
3983
+ input: turn.input,
3984
+ cwd: this.#config.cwd,
3985
+ approvalPolicy: APPROVAL_POLICY_BY_MODE[this.#permissionMode],
3986
+ sandboxPolicy: TURN_SANDBOX_BY_MODE[this.#permissionMode]
3987
+ };
3988
+ const model = this.#model ?? this.#resolvedModel;
3989
+ if (model) params.model = model;
3990
+ const effort = this.#reasoningEffort ?? this.#resolvedEffort;
3991
+ if (effort) params.effort = effort;
3992
+ connection.request("turn/start", params).then((result) => {
3993
+ const started = result?.turn;
3994
+ if (!started) return;
3995
+ active.turnId ??= started.id;
3996
+ if (started.status && started.status !== "inProgress") active.resolve(started);
3997
+ }, (error) => active.reject(error instanceof Error ? error : new Error(String(error))));
3998
+ const result = await outcome;
3999
+ if (this.#closed) return;
4000
+ if (result.status === "completed") this.#finishTurn("success", startedAt, active);
4001
+ else {
4002
+ const reason = result.status === "interrupted" ? "interrupted" : result.error?.message ?? active.lastError ?? "codex app-server ended the turn without a result";
4003
+ this.#finishTurn("failure", startedAt, active, [reason]);
4004
+ }
4005
+ } catch (error) {
4006
+ if (this.#closed) return;
4007
+ const message = error instanceof Error ? error.message : String(error);
4008
+ this.#finishTurn("failure", startedAt, active, [active.interrupted ? "interrupted" : message]);
4009
+ } finally {
4010
+ if (this.#activeTurn === active) this.#activeTurn = void 0;
4011
+ }
4012
+ }
4013
+ #handleNotification(method, params) {
4014
+ if (this.#closed) return;
4015
+ const active = this.#activeTurn;
4016
+ switch (method) {
4017
+ case "thread/started": {
4018
+ const thread = params?.thread;
4019
+ if (typeof thread?.id === "string") this.#sdkSessionId = thread.id;
4020
+ return;
4021
+ }
4022
+ case "turn/started": {
4023
+ const turn = params?.turn;
4024
+ if (active && turn && !active.turnId) active.turnId = turn.id;
4025
+ return;
4026
+ }
4027
+ case "turn/completed": {
4028
+ const turn = params?.turn;
4029
+ if (active && turn) active.resolve(turn);
4030
+ return;
4031
+ }
4032
+ case "item/started":
4033
+ case "item/updated": {
4034
+ if (!active) return;
4035
+ const item = params?.item;
4036
+ if (item) this.#handleItemProgress(item, active);
4037
+ return;
4038
+ }
4039
+ case "item/completed": {
4040
+ if (!active) return;
4041
+ const item = params?.item;
4042
+ if (item) this.#handleItemCompleted(item, active);
4043
+ return;
4044
+ }
4045
+ case "item/agentMessage/delta": {
4046
+ if (!active) return;
4047
+ const delta = params?.delta;
4048
+ if (typeof delta === "string" && delta) this.#emitDelta({
4049
+ type: "text_delta",
4050
+ text: delta
4051
+ });
4052
+ return;
4053
+ }
4054
+ case "item/reasoning/textDelta":
4055
+ case "item/reasoning/summaryTextDelta": {
4056
+ if (!active) return;
4057
+ const payload = params;
4058
+ if (typeof payload?.delta !== "string" || !payload.delta) return;
4059
+ const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
4060
+ const key = `${payload.itemId ?? ""}:${method}`;
4061
+ const previous = active.sectionIndex.get(key);
4062
+ active.sectionIndex.set(key, index);
4063
+ const separator = previous !== void 0 && index > previous ? "\n\n" : "";
4064
+ this.#emitDelta({
4065
+ type: "thinking_delta",
4066
+ thinking: separator + payload.delta
4067
+ });
4068
+ return;
4069
+ }
4070
+ case "thread/tokenUsage/updated": {
4071
+ if (!active) return;
4072
+ const last = params?.tokenUsage?.last;
4073
+ if (!last) return;
4074
+ active.sawUsage = true;
4075
+ active.usage.inputTokens += last.inputTokens ?? 0;
4076
+ active.usage.cachedInputTokens += last.cachedInputTokens ?? 0;
4077
+ active.usage.cacheWriteInputTokens = (active.usage.cacheWriteInputTokens ?? 0) + (last.cacheWriteInputTokens ?? 0);
4078
+ active.usage.outputTokens += last.outputTokens ?? 0;
4079
+ active.usage.reasoningOutputTokens += last.reasoningOutputTokens ?? 0;
4080
+ const update = params;
4081
+ active.contextTokens = last.totalTokens ?? void 0;
4082
+ active.contextWindow = update.tokenUsage?.modelContextWindow ?? void 0;
4083
+ return;
4084
+ }
4085
+ case "mcpServer/startupStatus/updated": {
4086
+ const update = params;
4087
+ if (typeof update?.name !== "string") return;
4088
+ this.#mcpStatus.set(update.name, {
4089
+ status: typeof update.status === "string" ? update.status : "starting",
4090
+ ...update.error ? { error: update.error } : {},
4091
+ ...update.failureReason ? { failureReason: update.failureReason } : {}
4092
+ });
4093
+ return;
4094
+ }
4095
+ case "skills/changed": {
4096
+ const connection = this.#connection;
4097
+ if (connection) this.#refreshSkills(connection);
4098
+ return;
4099
+ }
4100
+ case "account/rateLimits/updated":
4101
+ this.#emitRateLimits(params?.rateLimits);
4102
+ return;
4103
+ case "turn/plan/updated": {
4104
+ if (!active) return;
4105
+ const plan = params?.plan;
4106
+ if (!Array.isArray(plan)) return;
4107
+ this.#emit({
4108
+ type: "sdk_event",
4109
+ payload: {
4110
+ type: "codex.todo_list",
4111
+ id: `${active.nonce}:plan`,
4112
+ items: plan.map((step) => ({
4113
+ text: step.step,
4114
+ completed: step.status === "completed"
4115
+ }))
4116
+ }
4117
+ });
4118
+ return;
4119
+ }
4120
+ case "serverRequest/resolved": {
4121
+ const requestId = params?.requestId;
4122
+ if (requestId === void 0) return;
4123
+ for (const [id, pending] of this.#approvals) if (pending.wireId === requestId) {
4124
+ this.#settleApproval(id, pending, {
4125
+ behavior: "deny",
4126
+ message: "resolved by codex"
4127
+ }, "policy");
4128
+ return;
4129
+ }
4130
+ return;
4131
+ }
4132
+ case "error": {
4133
+ const error = params?.error;
4134
+ if (active && typeof error?.message === "string") active.lastError = error.message;
4135
+ return;
4136
+ }
4137
+ default: return;
4138
+ }
4139
+ }
4140
+ /** Answer a server→client request: the ask channels become pending
4141
+ * permission requests; anything else gets a JSON-RPC -32601 rather than a
4142
+ * hang (an unanswered server request wedges the turn). */
4143
+ async #answerServerRequest(method, params, wireId) {
4144
+ const channel = APPROVAL_CHANNELS[method];
4145
+ if (channel) return this.#requestApproval(channel, method, params, wireId);
4146
+ throw new JsonRpcError(-32601, `workerdeck does not handle server request '${method}'`);
4147
+ }
4148
+ /**
4149
+ * Surface one ask-channel request as a pending {@link PermissionRequest};
4150
+ * the returned promise is the JSON-RPC response, resolved when a
4151
+ * `permission_decision` lands — or by the timeout, an interrupt, turn end,
4152
+ * session close, or codex resolving it itself. Never left hanging.
4153
+ */
4154
+ #requestApproval(channel, method, params, wireId) {
4155
+ if (method === "item/tool/requestUserInput") {
4156
+ const behavior = this.#config.questionBehavior ?? "ask";
4157
+ if (behavior !== "ask") return Promise.resolve(this.#resolveQuestionByPolicy(channel, params, behavior));
4158
+ }
4159
+ const id = randomUUID();
4160
+ const timeoutMs = this.#config.approvalTimeoutMs ?? this.#config.defaultApprovalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
4161
+ const itemId = channel.itemId(params);
4162
+ const request = {
4163
+ id,
4164
+ ...channel.describe(params),
4165
+ toolUseId: itemId ? `${this.#activeTurn?.nonce ?? "codex"}:${itemId}` : id,
4166
+ expiresAt: Date.now() + timeoutMs
4167
+ };
4168
+ return new Promise((resolve) => {
4169
+ const timer = setTimeout(() => {
4170
+ const pending = this.#approvals.get(id);
4171
+ if (pending) this.#settleApproval(id, pending, {
4172
+ behavior: "deny",
4173
+ message: "Approval timed out"
4174
+ }, "timeout");
4175
+ }, timeoutMs);
4176
+ this.#approvals.set(id, {
4177
+ request,
4178
+ channel,
4179
+ params,
4180
+ offered: offeredDecisions(params),
4181
+ wireId,
4182
+ timer,
4183
+ respond: resolve
4184
+ });
4185
+ this.#emit({
4186
+ type: "permission_requested",
4187
+ request
4188
+ });
4189
+ if (this.#activeTurn) this.#setStatus("awaiting_approval");
4190
+ });
4191
+ }
4192
+ /** 'auto'/'deny' sessions settle codex questions synchronously instead of
4193
+ * pending. Request/resolved events still fire so transcripts and job
4194
+ * webhooks show what was chosen. */
4195
+ #resolveQuestionByPolicy(channel, params, mode) {
4196
+ const itemId = channel.itemId(params);
4197
+ const request = {
4198
+ id: randomUUID(),
4199
+ ...channel.describe(params),
4200
+ toolUseId: itemId ? `${this.#activeTurn?.nonce ?? "codex"}:${itemId}` : randomUUID()
4201
+ };
4202
+ this.#emit({
4203
+ type: "permission_requested",
4204
+ request
4205
+ });
4206
+ if (mode === "deny") {
4207
+ this.#emit({
4208
+ type: "permission_resolved",
4209
+ requestId: request.id,
4210
+ behavior: "deny",
4211
+ resolvedBy: "policy",
4212
+ message: "Interactive questions are disabled for this session — choose the most reasonable option yourself and continue."
4213
+ });
4214
+ return { answers: {} };
4215
+ }
4216
+ const answers = {};
4217
+ for (const question of params.questions ?? []) {
4218
+ const first = question.options?.[0]?.label;
4219
+ if (first) answers[question.id] = { answers: [first] };
4220
+ }
4221
+ this.#emit({
4222
+ type: "permission_resolved",
4223
+ requestId: request.id,
4224
+ behavior: "allow",
4225
+ resolvedBy: "policy"
4226
+ });
4227
+ return { answers };
4228
+ }
4229
+ /**
4230
+ * Settle one pending approval: pick the channel's wire response for the
4231
+ * decision, answer the JSON-RPC request, and emit `permission_resolved`.
4232
+ * An allow the request offered no plain accept for becomes the channel's
4233
+ * denial, said out loud — never a silently widened grant, and never a
4234
+ * decision the request didn't offer.
4235
+ */
4236
+ #settleApproval(id, pending, decision, resolvedBy) {
4237
+ clearTimeout(pending.timer);
4238
+ this.#approvals.delete(id);
4239
+ let behavior = decision.behavior;
4240
+ let message = decision.behavior === "deny" ? decision.message ?? "Denied" : void 0;
4241
+ let sent;
4242
+ if (decision.behavior === "allow") {
4243
+ const allowed = pending.channel.allow(pending.params, decision.updatedInput, pending.offered);
4244
+ if (allowed) sent = allowed;
4245
+ else {
4246
+ behavior = "deny";
4247
+ resolvedBy = "policy";
4248
+ message = "codex offered no plain accept for this request (only broader session/policy grants) — denied instead";
4249
+ sent = pending.channel.deny(pending.params, false, pending.offered);
4250
+ }
4251
+ } else sent = pending.channel.deny(pending.params, decision.interrupt === true, pending.offered);
4252
+ pending.respond(sent.response);
4253
+ this.#emit({
4254
+ type: "permission_resolved",
4255
+ requestId: id,
4256
+ behavior,
4257
+ resolvedBy,
4258
+ message
4259
+ });
4260
+ if (behavior === "deny" && decision.behavior === "deny" && decision.interrupt && sent.decision !== "cancel") this.#interruptTurn();
4261
+ if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
4262
+ }
4263
+ /** Tool calls surface as tool_use when they start; text and reasoning stream
4264
+ * natively via the delta notifications. */
4265
+ #handleItemProgress(item, active) {
4266
+ const id = `${active.nonce}:${item.id}`;
4267
+ if (item.type === "commandExecution" && !active.toolUseEmitted.has(id)) {
4268
+ active.toolUseEmitted.add(id);
4269
+ this.#emitToolUse(id, "CodexCommand", { command: item.command });
4270
+ return;
4271
+ }
4272
+ if (item.type === "mcpToolCall" && !active.toolUseEmitted.has(id)) {
4273
+ active.toolUseEmitted.add(id);
4274
+ this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
4275
+ return;
4276
+ }
4277
+ if (item.type === "imageGeneration" && !active.toolUseEmitted.has(id)) {
4278
+ active.toolUseEmitted.add(id);
4279
+ this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
4280
+ if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
4281
+ }
4282
+ }
4283
+ #handleItemCompleted(item, active) {
4284
+ const id = `${active.nonce}:${item.id}`;
4285
+ switch (item.type) {
4286
+ case "userMessage": return;
4287
+ case "agentMessage": {
4288
+ const text = typeof item.text === "string" ? item.text : "";
4289
+ this.#emitAssistant(id, [{
4290
+ type: "text",
4291
+ text
4292
+ }]);
4293
+ active.finalText = text;
4294
+ return;
4295
+ }
4296
+ case "reasoning": {
4297
+ const summary = Array.isArray(item.summary) ? item.summary.filter(Boolean) : [];
4298
+ const content = Array.isArray(item.content) ? item.content.filter(Boolean) : [];
4299
+ const thinking = (summary.length > 0 ? summary : content).join("\n\n");
4300
+ if (thinking) this.#emitAssistant(id, [{
4301
+ type: "thinking",
4302
+ thinking
4303
+ }]);
4304
+ return;
4305
+ }
4306
+ case "commandExecution": {
4307
+ if (!active.toolUseEmitted.has(id)) {
4308
+ active.toolUseEmitted.add(id);
4309
+ this.#emitToolUse(id, "CodexCommand", { command: item.command });
4310
+ }
4311
+ const exitCode = item.exitCode ?? void 0;
4312
+ const failed = item.status === "failed" || item.status === "declined" || exitCode !== void 0 && exitCode !== 0;
4313
+ const output = (item.aggregatedOutput ?? "") + (exitCode !== void 0 && exitCode !== 0 ? `\n(exit code ${exitCode})` : "");
4314
+ this.#emitToolResult(id, output, failed);
4315
+ return;
4316
+ }
4317
+ case "fileChange": {
4318
+ this.#emitToolUse(id, "CodexFileChange", { changes: item.changes });
4319
+ const lines = item.changes.map((change) => {
4320
+ return `${(typeof change.kind === "string" ? change.kind : change.kind?.type) ?? "change"}: ${change.path}`;
4321
+ });
4322
+ this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined");
4323
+ return;
4324
+ }
4325
+ case "mcpToolCall": {
4326
+ if (!active.toolUseEmitted.has(id)) {
4327
+ active.toolUseEmitted.add(id);
4328
+ this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
4329
+ }
4330
+ const isError = item.error !== void 0 && item.error !== null || item.status === "failed";
4331
+ this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError);
4332
+ return;
4333
+ }
4334
+ case "webSearch":
4335
+ this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
4336
+ this.#emitToolResult(id, "", false);
4337
+ return;
4338
+ case "imageGeneration": {
4339
+ active.toolUseEmitted.add(id);
4340
+ this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
4341
+ if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
4342
+ const lines = [item.savedPath ? `Saved to ${item.savedPath}` : "No saved path reported", ...shortResult(item.result) ? [item.result] : []];
4343
+ this.#emitToolResult(id, lines.join("\n"), item.status === "failed");
4344
+ return;
4345
+ }
4346
+ case "imageView":
4347
+ this.#emitToolUse(id, "CodexImageView", { path: item.path });
4348
+ this.#emitToolResult(id, item.path, false);
4349
+ return;
4350
+ default: {
4351
+ const unknown = item;
4352
+ this.#emit({
4353
+ type: "sdk_event",
4354
+ payload: {
4355
+ type: `codex.${unknown.type}`,
4356
+ item: unknown
4357
+ }
4358
+ });
4359
+ }
4360
+ }
4361
+ }
4362
+ #emitDelta(delta) {
4363
+ if (this.#config.includePartialMessages === false) return;
4364
+ this.#emit({
4365
+ type: "stream_delta",
4366
+ event: {
4367
+ type: "content_block_delta",
4368
+ delta
4369
+ },
4370
+ parentToolUseId: null,
4371
+ uuid: randomUUID()
4372
+ });
4373
+ }
4374
+ #emitAssistant(uuid, content) {
4375
+ this.#emit({
4376
+ type: "assistant_message",
4377
+ message: {
4378
+ role: "assistant",
4379
+ content,
4380
+ model: this.#model ?? this.#resolvedModel
4381
+ },
4382
+ parentToolUseId: null,
4383
+ uuid
4384
+ });
4385
+ }
4386
+ #emitToolUse(id, name, input) {
4387
+ this.#emit({
4388
+ type: "assistant_message",
4389
+ message: {
4390
+ role: "assistant",
4391
+ content: [{
4392
+ type: "tool_use",
4393
+ id,
4394
+ name,
4395
+ input
4396
+ }],
4397
+ model: this.#model ?? this.#resolvedModel
4398
+ },
4399
+ parentToolUseId: null,
4400
+ uuid: `${id}-use`
4401
+ });
4402
+ }
4403
+ #emitToolResult(toolUseId, content, isError) {
4404
+ this.#emit({
4405
+ type: "user_message",
4406
+ message: {
4407
+ role: "user",
4408
+ content: [{
4409
+ type: "tool_result",
4410
+ tool_use_id: toolUseId,
4411
+ content,
4412
+ is_error: isError || void 0
4413
+ }]
4414
+ },
4415
+ parentToolUseId: null,
4416
+ synthetic: true,
4417
+ uuid: `${toolUseId}-result`
4418
+ });
4419
+ }
4420
+ /**
4421
+ * Per-turn usage re-mapped to the Anthropic accounting convention the whole
4422
+ * stack assumes (GOTCHAS §Codex engine): OpenAI's `inputTokens` includes the
4423
+ * cached share, so input excludes it (else queue token budgets double-count
4424
+ * cache-heavy runs); reasoning tokens are billed output; `totalCostUsd: 0` =
4425
+ * unknown, the AiSdkRunner precedent. Usage is summed from the turn's
4426
+ * `thread/tokenUsage/updated` notifications — `turn/completed` carries none.
4427
+ */
4428
+ #finishTurn(kind, startedAt, active, errors) {
4429
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
4430
+ behavior: "deny",
4431
+ message: "Turn ended"
4432
+ }, "policy");
4433
+ this.#numTurns += 1;
4434
+ this.#totalCostUsd = 0;
4435
+ const usage = active.sawUsage ? active.usage : void 0;
4436
+ this.#emit({
4437
+ type: "turn_result",
4438
+ subtype: kind === "success" ? "success" : "error_during_execution",
4439
+ isError: kind !== "success",
4440
+ durationMs: Date.now() - startedAt,
4441
+ numTurns: this.#numTurns,
4442
+ totalCostUsd: 0,
4443
+ result: kind === "success" ? active.finalText ?? "" : void 0,
4444
+ errors,
4445
+ usage: usage ? {
4446
+ input_tokens: Math.max(0, usage.inputTokens - usage.cachedInputTokens),
4447
+ output_tokens: usage.outputTokens + usage.reasoningOutputTokens,
4448
+ cache_creation_input_tokens: usage.cacheWriteInputTokens ?? 0,
4449
+ cache_read_input_tokens: usage.cachedInputTokens
4450
+ } : void 0
4451
+ });
4452
+ this.#emitContextUsage(active);
4453
+ this.#setStatus("idle");
4454
+ }
4455
+ /**
4456
+ * Subscription windows, mapped onto the protocol's named vocabulary.
4457
+ *
4458
+ * The shapes disagree: codex reports windows *positionally* (`primary` /
4459
+ * `secondary`) with a length in minutes, while `RateLimitInfo.rateLimitType`
4460
+ * is a name whose meaning clients already know — iOS labels `seven_day` as
4461
+ * "Weekly" and derives the pace marker's denominator from it. Naming the
4462
+ * window by its measured duration is therefore the honest mapping rather
4463
+ * than a borrowed one: codex's primary window is 10080 minutes, which *is*
4464
+ * seven days. A duration we have no name for keeps an explicit
4465
+ * `window_<n>m` key — clients render it verbatim and simply draw no pace
4466
+ * marker, which beats mislabeling it as a week.
4467
+ *
4468
+ * `status` is 'allowed' by construction (the session is running), matching
4469
+ * `rateLimitEventsFromUsage`; codex's `rateLimitReachedType` is the one
4470
+ * signal that a limit is actually biting, so it becomes 'rejected'.
4471
+ */
4472
+ #emitRateLimits(limits) {
4473
+ if (!limits) return;
4474
+ const status = limits.rateLimitReachedType ? "rejected" : "allowed";
4475
+ for (const window of [limits.primary, limits.secondary]) {
4476
+ if (!window || window.usedPercent === null || window.usedPercent === void 0) continue;
4477
+ this.#emit({
4478
+ type: "rate_limit",
4479
+ info: {
4480
+ status,
4481
+ rateLimitType: rateLimitWindowName(window.windowDurationMins),
4482
+ utilization: window.usedPercent,
4483
+ ...typeof window.resetsAt === "number" ? { resetsAt: window.resetsAt } : {}
4484
+ }
4485
+ });
4486
+ }
4487
+ if (limits.planType && limits.planType !== this.#planType) {
4488
+ this.#planType = limits.planType;
4489
+ this.#emit({
4490
+ type: "plan_info",
4491
+ subscriptionType: limits.planType
4492
+ });
4493
+ }
4494
+ }
4495
+ /**
4496
+ * Context occupancy, after the turn — the same cadence the Claude runner
4497
+ * polls `getContextUsage()` on, so clients need nothing new.
4498
+ *
4499
+ * Emitted only when the binary gave BOTH numbers: the protocol is explicit
4500
+ * that a client renders nothing rather than a 0% ring, and a window of
4501
+ * `null` (which app-server does send) would otherwise divide into a
4502
+ * meaningless percentage. `categories` is empty because codex publishes no
4503
+ * breakdown — clients must not render an empty "Breakdown" section for it.
4504
+ */
4505
+ #emitContextUsage(active) {
4506
+ const totalTokens = active.contextTokens;
4507
+ const maxTokens = active.contextWindow;
4508
+ if (totalTokens === void 0 || !maxTokens || maxTokens <= 0) return;
4509
+ this.#emit({
4510
+ type: "context_usage",
4511
+ usage: {
4512
+ categories: [],
4513
+ totalTokens,
4514
+ maxTokens,
4515
+ percentage: Math.min(100, totalTokens / maxTokens * 100),
4516
+ model: this.#model ?? this.#resolvedModel
4517
+ }
4518
+ });
4519
+ }
4520
+ #setStatus(status, detail) {
4521
+ if (this.#status === status) return;
4522
+ if (this.#status === "closed" || this.#status === "failed") return;
4523
+ this.#status = status;
4524
+ this.#emit({
4525
+ type: "status_changed",
4526
+ status,
4527
+ detail
4528
+ });
4529
+ }
4530
+ #emit(body) {
4531
+ if (this.#replayingHistory && (body.type === "assistant_message" || body.type === "user_message")) body = {
4532
+ ...body,
4533
+ replay: true
4534
+ };
4535
+ const event = {
4536
+ ...body,
4537
+ seq: ++this.#seq,
4538
+ ts: Date.now()
4539
+ };
4540
+ this.#lastActivityAt = event.ts;
4541
+ this.#activityCount += transcriptActivity(body);
4542
+ this.#events.push(event);
4543
+ for (const listener of this.#listeners) try {
4544
+ listener(event);
4545
+ } catch {}
4546
+ }
4547
+ };
4548
+ //#endregion
4549
+ //#region src/engines/codex/catalog.ts
4550
+ /**
4551
+ * The Codex engine's model catalog, seeded from the binary's own embedded
4552
+ * presets — `@openai/codex@0.146.0` ships its model table inside the
4553
+ * executable, and that table (not the SDK's stale `ModelReasoningEffort`
4554
+ * union) is the truth about which reasoning efforts each model takes.
4555
+ *
4556
+ * **Refresh procedure** (release checklist): extract the embedded JSON from
4557
+ * the platform binary and diff —
4558
+ *
4559
+ * node -e 'const d=require("fs").readFileSync(process.argv[1]);
4560
+ * const s=d.indexOf(`{\n "models": [`);
4561
+ * let i=s,n=0; do{n+=(d[i]===123)-(d[i]===125);i++}while(n);
4562
+ * const c=JSON.parse(d.slice(s,i));
4563
+ * for(const m of c.models) console.log(m.slug, m.display_name,
4564
+ * m.visibility, m.supported_reasoning_levels.map(l=>l.effort).join(","))'\
4565
+ * "$(node -p 'require.resolve("@openai/codex-darwin-arm64/package.json").replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
4566
+ *
4567
+ * Mapping decisions:
4568
+ * - the internal `codex-auto-review` row is dropped (the codex analogue of
4569
+ * dropping the CLI's `default` sentinel);
4570
+ * - `primary` mirrors the binary's own `visibility` field ('list' = shown in
4571
+ * its picker, 'hide' = its "older models"), so both UIs group the way
4572
+ * codex's own picker does;
4573
+ * - `reasoningEfforts` carries `supported_reasoning_levels` verbatim — note
4574
+ * `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
4575
+ */
4576
+ const CODEX_CATALOG = {
4577
+ provenance: "embedded model presets of @openai/codex@0.146.0 (darwin-arm64 binary), extracted 2026-08-05",
4578
+ models: [
4579
+ {
4580
+ value: "gpt-5.6-sol",
4581
+ resolvedModel: "gpt-5.6-sol",
4582
+ displayName: "GPT-5.6 Sol",
4583
+ description: "Latest frontier agentic coding model.",
4584
+ primary: true,
4585
+ reasoningEfforts: [
4586
+ "low",
4587
+ "medium",
4588
+ "high",
4589
+ "xhigh",
4590
+ "max",
4591
+ "ultra"
4592
+ ]
4593
+ },
4594
+ {
4595
+ value: "gpt-5.6-terra",
4596
+ resolvedModel: "gpt-5.6-terra",
4597
+ displayName: "GPT-5.6 Terra",
4598
+ description: "Balanced agentic coding model for everyday work.",
4599
+ primary: true,
4600
+ reasoningEfforts: [
4601
+ "low",
4602
+ "medium",
4603
+ "high",
4604
+ "xhigh",
4605
+ "max",
4606
+ "ultra"
4607
+ ]
4608
+ },
4609
+ {
4610
+ value: "gpt-5.6-luna",
4611
+ resolvedModel: "gpt-5.6-luna",
4612
+ displayName: "GPT-5.6 Luna",
4613
+ description: "Fast and affordable agentic coding model.",
4614
+ primary: true,
4615
+ reasoningEfforts: [
4616
+ "low",
4617
+ "medium",
4618
+ "high",
4619
+ "xhigh",
4620
+ "max"
4621
+ ]
4622
+ },
4623
+ {
4624
+ value: "gpt-5.5",
4625
+ resolvedModel: "gpt-5.5",
4626
+ displayName: "GPT-5.5",
4627
+ description: "Frontier model for complex coding, research, and real-world work.",
4628
+ primary: true,
4629
+ reasoningEfforts: [
4630
+ "low",
4631
+ "medium",
4632
+ "high",
4633
+ "xhigh"
4634
+ ]
4635
+ },
4636
+ {
4637
+ value: "gpt-5.4",
4638
+ resolvedModel: "gpt-5.4",
4639
+ displayName: "GPT-5.4",
4640
+ description: "Strong model for everyday coding.",
4641
+ reasoningEfforts: [
4642
+ "low",
4643
+ "medium",
4644
+ "high",
4645
+ "xhigh"
4646
+ ]
4647
+ },
4648
+ {
4649
+ value: "gpt-5.4-mini",
4650
+ resolvedModel: "gpt-5.4-mini",
4651
+ displayName: "GPT-5.4 Mini",
4652
+ description: "Small, fast, and cost-efficient model for simpler coding tasks.",
4653
+ reasoningEfforts: [
4654
+ "low",
4655
+ "medium",
4656
+ "high",
4657
+ "xhigh"
4658
+ ]
4659
+ },
4660
+ {
4661
+ value: "gpt-5.2",
4662
+ resolvedModel: "gpt-5.2",
4663
+ displayName: "GPT-5.2",
4664
+ description: "Optimized for professional work and long-running agents.",
4665
+ primary: true,
4666
+ reasoningEfforts: [
4667
+ "low",
4668
+ "medium",
4669
+ "high",
4670
+ "xhigh"
4671
+ ]
4672
+ }
4673
+ ]
4674
+ };
4675
+ //#endregion
4676
+ //#region src/engines/codex/process.ts
4677
+ /** How much stderr to keep for the exit diagnostic. The binary logs startup
4678
+ * noise there; only the tail explains a death. */
4679
+ const STDERR_TAIL_BYTES = 4096;
4680
+ /**
4681
+ * Spawn one `codex app-server` child and frame JSON-RPC over its stdio — the
4682
+ * real {@link AppServerConnectFn}. The child's env is passed **complete**
4683
+ * (a provided spawn env replaces process.env, never merges with it), with the
4684
+ * profile's CODEX_HOME pin already applied by the runner.
4685
+ *
4686
+ * No spawn cwd: the working directory is a thread/turn parameter, and a cwd
4687
+ * that doesn't exist should fail the *turn* with codex's own error, not the
4688
+ * spawn.
4689
+ */
4690
+ function connectAppServer(options) {
4691
+ const child = spawn(options.executable, ["app-server"], {
4692
+ env: options.env,
4693
+ stdio: [
4694
+ "pipe",
4695
+ "pipe",
4696
+ "pipe"
4697
+ ]
4698
+ });
4699
+ const rpc = new JsonRpcStdioConnection({
4700
+ input: child.stdout,
4701
+ output: child.stdin
4702
+ });
4703
+ let stderrTail = "";
4704
+ child.stderr.on("data", (chunk) => {
4705
+ stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
4706
+ });
4707
+ let closeHandler;
4708
+ let done = false;
4709
+ const settle = (message) => {
4710
+ if (done) return;
4711
+ done = true;
4712
+ rpc.fail(message);
4713
+ closeHandler?.(message);
4714
+ };
4715
+ child.on("error", (error) => settle(`codex app-server failed to start: ${error.message}`));
4716
+ child.on("exit", (code, signal) => {
4717
+ const tail = stderrTail.trim();
4718
+ settle(`codex app-server exited (${signal ?? `code ${code}`})` + (tail ? `: ${tail.slice(-500)}` : ""));
4719
+ });
4720
+ return {
4721
+ request: (method, params) => rpc.request(method, params),
4722
+ notify: (method, params) => rpc.notify(method, params),
4723
+ onNotification: (handler) => rpc.onNotification(handler),
4724
+ onRequest: (handler) => rpc.onRequest(handler),
4725
+ onClose: (handler) => {
4726
+ closeHandler = handler;
4727
+ },
4728
+ close: () => {
4729
+ done = true;
4730
+ rpc.fail("codex app-server connection closed");
4731
+ child.kill();
4732
+ }
4733
+ };
4734
+ }
4735
+ //#endregion
4736
+ //#region src/engines/codex/adapter.ts
4737
+ const NOT_INSTALLED = "@openai/codex is not installed — add it (an optional peer of @workerdeck/core) to run codex profiles";
4738
+ /**
4739
+ * The codex binary sessions will run: the per-platform package installed next
4740
+ * to `@openai/codex`, `vendor/<target-triple>/bin/codex` — the same file the
4741
+ * npm wrapper's own `bin/codex.js` launcher execs. Probing this binary rather
4742
+ * than whatever `codex` is on PATH means the availability answer is about the
4743
+ * executable sessions will actually run. Undefined when it can't be found;
4744
+ * callers degrade to 'unknown'.
4745
+ */
4746
+ function resolveBundledCodexExecutable() {
4747
+ const triple = targetTriple();
4748
+ if (!triple) return void 0;
4749
+ try {
4750
+ 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`);
4751
+ if (existsSync(path)) return path;
4752
+ } catch {}
4753
+ }
4754
+ function targetTriple() {
4755
+ const { platform, arch } = process;
4756
+ if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
4757
+ if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl";
4758
+ if (platform === "win32") return "x86_64-pc-windows-msvc";
4759
+ }
4760
+ function platformPackageSuffix() {
4761
+ return `${process.platform}-${process.arch}`;
4762
+ }
4763
+ /**
4764
+ * Availability, mirroring **the app-server surface's actual credential chain**
4765
+ * (verified 2026-08-05 against 0.146.0 by driving the raw binary): auth comes
4766
+ * solely from the CODEX_HOME auth store (`codex login`, file or keyring). The
4767
+ * env-key routes are dead ends here — `CODEX_API_KEY` is read only by
4768
+ * `codex exec` (a turn goes out with no credential at all: "Missing bearer"),
4769
+ * and `OPENAI_API_KEY` was never read by either surface. So, in order:
4770
+ *
4771
+ * 1. Binary resolvable, else unavailable with the install reason;
4772
+ * 2. `codex login status` under the profile's complete session env:
4773
+ * exit 0 → available; the "Not logged in" verdict → unavailable, with an
4774
+ * exact remedy when a stranded env key explains the misconfiguration;
4775
+ * anything else (a stray CODEX_ACCESS_TOKEN JWT error, a crashed spawn) →
4776
+ * 'unknown' — the checkClaudeAuth never-overclaim discipline.
4777
+ *
4778
+ * Only the exit code and the fixed verdict line are consulted — never
4779
+ * surfaced: `login status` output includes a masked key fragment. The
4780
+ * `smoke:codex --canary` run is the drift alarm for all of this.
4781
+ */
4782
+ async function checkCodexAvailability(profile, env, options = {}) {
4783
+ const executable = resolveBundledCodexExecutable();
4784
+ if (!executable) return {
4785
+ available: false,
4786
+ reason: NOT_INSTALLED
4787
+ };
4788
+ const childEnv = {};
4789
+ for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
4790
+ if (profile.codexHome) childEnv.CODEX_HOME = profile.codexHome;
4791
+ return new Promise((resolve) => {
4792
+ execFile(executable, ["login", "status"], {
4793
+ env: childEnv,
4794
+ timeout: options.timeoutMs ?? 1e4
4795
+ }, (error, stdout, stderr) => {
4796
+ if (!error) {
4797
+ resolve({ available: true });
4798
+ return;
4799
+ }
4800
+ if (`${stdout}\n${stderr}`.includes("Not logged in")) {
4801
+ 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." : "";
4802
+ resolve({
4803
+ available: false,
4804
+ reason: `codex is not logged in for this profile's environment — run \`codex login\`` + (profile.codexHome ? ` with CODEX_HOME=${profile.codexHome}` : "") + `.${hint}`
4805
+ });
4806
+ return;
4807
+ }
4808
+ resolve({ available: "unknown" });
4809
+ });
4810
+ });
4811
+ }
4812
+ /** `thread/list` page size (its own default is 25) and a hard page bound so a
4813
+ * misbehaving cursor can never spin the listing forever. */
4814
+ const LIST_PAGE_SIZE = 100;
4815
+ const MAX_LIST_PAGES = 40;
4816
+ /** thread/list's `cwd` filter is an EXACT path match (measured, 0.146.0), so
4817
+ * offer both the spelled and canonical forms — macOS listings would otherwise
4818
+ * miss `/tmp/...` threads recorded under `/private/tmp/...`. */
4819
+ function cwdFilter(dir) {
4820
+ const forms = new Set([dir]);
4821
+ try {
4822
+ forms.add(realpathSync(dir));
4823
+ } catch {}
4824
+ return [...forms];
4825
+ }
4826
+ const secondsToMs = (value) => typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
4827
+ /** One thread row in the protocol's browser-safe summary shape. `id` is what
4828
+ * `CreateSessionRequest.resume` feeds `thread/resume` — the row's separate
4829
+ * `sessionId` field is not it. */
4830
+ function summarizeThread(row) {
4831
+ const name = typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
4832
+ const preview = typeof row.preview === "string" && row.preview.length > 0 ? row.preview : void 0;
4833
+ return {
4834
+ sessionId: row.id,
4835
+ summary: name ?? preview ?? row.id,
4836
+ lastModified: secondsToMs(row.updatedAt) ?? secondsToMs(row.createdAt) ?? 0,
4837
+ createdAt: secondsToMs(row.createdAt),
4838
+ customTitle: name,
4839
+ firstPrompt: preview,
4840
+ gitBranch: typeof row.gitInfo?.branch === "string" && row.gitInfo.branch.length > 0 ? row.gitInfo.branch : void 0,
4841
+ cwd: typeof row.cwd === "string" ? row.cwd : void 0
4842
+ };
4843
+ }
4844
+ /**
4845
+ * CODEX_HOME's threads over ONE short-lived `codex app-server` child: the
4846
+ * runner's own handshake (`experimentalApi` and all — one code path, no
4847
+ * second vocabulary to drift), `thread/list` pages walked by cursor, child
4848
+ * closed before returning. Requires no live session and costs no tokens —
4849
+ * it is how "resume" is offered before anything is running. The `connectFn`
4850
+ * seam exists for the scripted-peer tests; the adapter passes the real
4851
+ * spawn.
4852
+ */
4853
+ async function listCodexSessions(options) {
4854
+ const childEnv = {};
4855
+ for (const [key, value] of Object.entries(options.env)) if (value !== void 0) childEnv[key] = value;
4856
+ if (options.profile?.codexHome) childEnv.CODEX_HOME = options.profile.codexHome;
4857
+ const connection = options.connectFn({ env: childEnv });
4858
+ const rows = [];
4859
+ try {
4860
+ await connection.request("initialize", {
4861
+ clientInfo: {
4862
+ name: "workerdeck",
4863
+ title: "WorkerDeck",
4864
+ version: `protocol-${PROTOCOL_VERSION}`
4865
+ },
4866
+ capabilities: { experimentalApi: true }
4867
+ });
4868
+ connection.notify("initialized");
4869
+ const base = {
4870
+ limit: LIST_PAGE_SIZE,
4871
+ sortKey: "updated_at",
4872
+ ...options.dir ? { cwd: cwdFilter(options.dir) } : {}
4873
+ };
4874
+ const want = options.limit === void 0 ? void 0 : (options.offset ?? 0) + options.limit;
4875
+ let cursor;
4876
+ for (let page = 0; page < MAX_LIST_PAGES; page++) {
4877
+ const result = await connection.request("thread/list", {
4878
+ ...base,
4879
+ ...cursor ? { cursor } : {}
4880
+ });
4881
+ const data = Array.isArray(result?.data) ? result.data : [];
4882
+ rows.push(...data);
4883
+ if (want !== void 0 && rows.length >= want) break;
4884
+ if (data.length === 0 || typeof result?.nextCursor !== "string") break;
4885
+ cursor = result.nextCursor;
4886
+ }
4887
+ } finally {
4888
+ connection.close();
4889
+ }
4890
+ const summaries = rows.filter((row) => typeof row.id === "string" && row.id.length > 0 && !row.ephemeral).map(summarizeThread);
4891
+ const start = options.offset ?? 0;
4892
+ return options.limit === void 0 ? summaries.slice(start) : summaries.slice(start, start + options.limit);
4893
+ }
4894
+ /**
4895
+ * OpenAI Codex as an engine: the codex CLI binary driven over its `app-server`
4896
+ * JSON-RPC surface — structurally the Claude engine's sibling (a local agent
4897
+ * binary with sessions, sandboxing and resume, resolving its own credentials
4898
+ * from the operator's environment). `@openai/codex` — the npm package that
4899
+ * carries the binary — is an **optional peer**: absent, every codex profile
4900
+ * reports unavailable and createRunner throws the same message, and no
4901
+ * consumer downloads a ~40 MB per-platform binary it never uses.
4902
+ */
4903
+ const codexAdapter = {
4904
+ engine: "codex",
4905
+ capabilities: ENGINE_CAPABILITIES.codex,
4906
+ catalog: CODEX_CATALOG,
4907
+ checkAvailability: (profile, env) => checkCodexAvailability(profile, env),
4908
+ createRunner({ config, profile, restore }) {
4909
+ if (restore) throw new Error("the codex engine cannot rebuild a parked session");
4910
+ const executable = config.codexPathOverride ?? resolveBundledCodexExecutable();
4911
+ if (!executable) throw new Error(NOT_INSTALLED);
4912
+ return new CodexRunner({
4913
+ ...config,
4914
+ codexHome: profile?.codexHome,
4915
+ connectFn: (options) => connectAppServer({
4916
+ executable,
4917
+ ...options
4918
+ })
4919
+ });
4920
+ },
4921
+ async listSessions(options) {
4922
+ const executable = resolveBundledCodexExecutable();
4923
+ if (!executable) throw new Error(NOT_INSTALLED);
4924
+ return listCodexSessions({
4925
+ ...options,
4926
+ connectFn: (connect) => connectAppServer({
4927
+ executable,
4928
+ ...connect
4929
+ })
4930
+ });
4931
+ }
4932
+ };
4933
+ //#endregion
4934
+ //#region src/engines/provider/adapter.ts
4935
+ /**
4936
+ * The model-agnostic provider engine as a pseudo-adapter: capabilities and an
4937
+ * env-var probe live here, but its runners are assembled by the host's
4938
+ * `createEngineRunner` hook (which is where provider credentials are resolved
4939
+ * and model SDKs are imported — neither belongs in this repo's import graph).
4940
+ * The server routes provider creates to the hook; `createRunner` here throws
4941
+ * so a mis-routed call fails loudly instead of quietly building nothing.
4942
+ *
4943
+ * The catalog is empty by the same token: provider model ids are operator-
4944
+ * declared per profile (`provider.models`), not shipped with releases.
4945
+ */
4946
+ const providerAdapter = {
4947
+ engine: "provider",
4948
+ capabilities: ENGINE_CAPABILITIES.provider,
4949
+ catalog: {
4950
+ models: [],
4951
+ provenance: "provider model ids are operator-declared (provider.models)"
4952
+ },
4953
+ async checkAvailability(profile, env) {
4954
+ const keyEnv = profile.provider?.apiKeyEnv;
4955
+ if (!keyEnv) return { available: "unknown" };
4956
+ const value = env[keyEnv];
4957
+ if (value !== void 0 && value !== "") return { available: true };
4958
+ return {
4959
+ available: false,
4960
+ reason: `${keyEnv} is not set in the server environment (profile '${profile.name}' names it as apiKeyEnv)`
4961
+ };
4962
+ },
4963
+ createRunner() {
4964
+ throw new Error("provider-engine runners are built by the host's createEngineRunner hook, not the adapter");
4965
+ }
4966
+ };
4967
+ //#endregion
4968
+ //#region src/engines/adapter.ts
4969
+ const ADAPTERS = {
4970
+ claude: claudeAdapter,
4971
+ codex: codexAdapter,
4972
+ provider: providerAdapter
4973
+ };
4974
+ /** The in-repo adapter for an engine. An absent `engine` means 'claude'. */
4975
+ function getEngineAdapter(engine) {
4976
+ return ADAPTERS[engine ?? "claude"];
4977
+ }
4978
+ //#endregion
4979
+ 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
4980
 
2324
4981
  //# sourceMappingURL=index.mjs.map