@workerdeck/core 0.9.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.mts CHANGED
@@ -189,6 +189,10 @@ interface Runner {
189
189
  reconnectMcpServer?(name: string): Promise<void>;
190
190
  /** Enable or disable one MCP server by name. Throws if it fails. */
191
191
  setMcpServerEnabled?(name: string, enabled: boolean): Promise<void>;
192
+ /** Set (or clear, with undefined) the host's display title — `meta.title`, which
193
+ * `info().title` prefers over the derived one. A host-facing edit only: nothing
194
+ * is sent to the engine. */
195
+ setTitle(title: string | undefined): void;
192
196
  /** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
193
197
  resolvePermission(requestId: string, decision: PermissionDecision): boolean;
194
198
  interrupt(): Promise<void>;
@@ -253,6 +257,9 @@ declare class SessionRunner implements Runner {
253
257
  get apiKeySource(): string | undefined;
254
258
  get pendingApprovals(): PermissionRequest[];
255
259
  info(): SessionInfo;
260
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
261
+ * it (undefined) restores the derived title. The engine is never told. */
262
+ setTitle(title: string | undefined): void;
256
263
  /** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
257
264
  start(): Promise<void>;
258
265
  /** Queue a user message for the session (starts the next turn when idle).
@@ -444,6 +451,9 @@ declare class AiSdkRunner implements Runner {
444
451
  * deferred executor). Idempotent by executionId.
445
452
  */
446
453
  settleExecution(executionId: string, result: ToolExecutionResult): boolean;
454
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
455
+ * it (undefined) restores the derived title. The engine is never told. */
456
+ setTitle(title: string | undefined): void;
447
457
  }
448
458
  //#endregion
449
459
  //#region src/claude-auth.d.ts
@@ -1118,6 +1128,32 @@ type AppServerWebSearchItem = {
1118
1128
  type: 'webSearch';
1119
1129
  query: string;
1120
1130
  };
1131
+ /**
1132
+ * A picture the model made with codex's built-in `image_gen` tool.
1133
+ *
1134
+ * `savedPath` is an absolute path on the **host** — by default under
1135
+ * `$CODEX_HOME/generated_images/`, or inside the workspace when the model was
1136
+ * told the asset belongs to the project. It is the only reference we get: the
1137
+ * app-server never sends the bytes, and neither do we (the event log carries
1138
+ * references, never base64 — see the protocol's note on attachments).
1139
+ *
1140
+ * `result` is an undocumented free-form string. Treated as untrusted length:
1141
+ * short values are shown, anything long enough to be an encoded image is not.
1142
+ */
1143
+ type AppServerImageGenerationItem = {
1144
+ id: string;
1145
+ type: 'imageGeneration';
1146
+ status: string;
1147
+ revisedPrompt?: string | null;
1148
+ result: string;
1149
+ savedPath?: string;
1150
+ };
1151
+ /** The model *looked at* an image on disk (`path`, host-absolute). */
1152
+ type AppServerImageViewItem = {
1153
+ id: string;
1154
+ type: 'imageView';
1155
+ path: string;
1156
+ };
1121
1157
  /** The user's own message, echoed back as an item — dropped (the runner
1122
1158
  * already emitted its `user_message`). */
1123
1159
  type AppServerUserMessageItem = {
@@ -1125,7 +1161,7 @@ type AppServerUserMessageItem = {
1125
1161
  type: 'userMessage';
1126
1162
  content?: unknown;
1127
1163
  };
1128
- type AppServerItem = AppServerAgentMessageItem | AppServerReasoningItem | AppServerCommandExecutionItem | AppServerFileChangeItem | AppServerMcpToolCallItem | AppServerWebSearchItem | AppServerUserMessageItem;
1164
+ type AppServerItem = AppServerAgentMessageItem | AppServerReasoningItem | AppServerCommandExecutionItem | AppServerFileChangeItem | AppServerMcpToolCallItem | AppServerWebSearchItem | AppServerImageGenerationItem | AppServerImageViewItem | AppServerUserMessageItem;
1129
1165
  /** The `Turn` object of `turn/started` / `turn/completed`. */
1130
1166
  type AppServerTurn = {
1131
1167
  id: string; /** 'inProgress' | 'completed' | 'failed' | 'interrupted' — open. */
@@ -1319,6 +1355,9 @@ declare class CodexRunner implements Runner {
1319
1355
  get lastSeq(): number;
1320
1356
  get pendingApprovals(): PermissionRequest[];
1321
1357
  info(): SessionInfo;
1358
+ /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
1359
+ * it (undefined) restores the derived title. The engine is never told. */
1360
+ setTitle(title: string | undefined): void;
1322
1361
  start(): Promise<void>;
1323
1362
  sendMessage(text: string, attachments?: readonly AttachmentInput[]): void;
1324
1363
  /** Resolve a pending approval. Returns false if the id is unknown (e.g.
@@ -1330,6 +1369,32 @@ declare class CodexRunner implements Runner {
1330
1369
  fail(message: string): void;
1331
1370
  close(reason?: 'client' | 'server' | 'error'): void;
1332
1371
  subscribe(listener: SessionEventListener, afterSeq?: number): () => void;
1372
+ /**
1373
+ * The session's MCP servers, live from the binary.
1374
+ *
1375
+ * Two sources merged, because codex splits them: `mcpServerStatus/list` says
1376
+ * what is configured and what each server exposes (including every tool's
1377
+ * full JSON Schema, which the Agent SDK does not give us), and the
1378
+ * `mcpServer/startupStatus/updated` notifications say which of them are
1379
+ * actually up.
1380
+ *
1381
+ * Answers **before the session has connected**, over a throwaway child, for
1382
+ * the same reason the skill list does: a codex session spawns nothing until
1383
+ * it has work, and a panel that said "no MCP servers configured" until the
1384
+ * first turn would be stating something false about the operator's config.
1385
+ * The request blocks until the servers are enumerated (measured: complete on
1386
+ * the very first call), so there is no half-populated answer to race.
1387
+ *
1388
+ * Resolves undefined only when there is genuinely nothing to say — the
1389
+ * session is closed, or the child could not be spoken to. The route turns
1390
+ * that into a 501.
1391
+ *
1392
+ * **Listing only.** There is no per-server reconnect or toggle on this
1393
+ * transport — hence no `reconnectMcpServer`/`setMcpServerEnabled` here, and
1394
+ * `ENGINE_CAPABILITIES.codex.mcpServerActions: false` so clients render the
1395
+ * panel read-only instead of offering buttons that cannot work.
1396
+ */
1397
+ mcpServers(): Promise<McpServerStatusInfo[] | undefined>;
1333
1398
  }
1334
1399
  //#endregion
1335
1400
  //#region src/engines/codex/process.d.ts
package/build/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION } from "@workerdeck/protocol";
4
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, transcriptActivity } from "@workerdeck/protocol";
5
5
  import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
6
6
  import { execFile, spawn } from "node:child_process";
7
- import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { createVfs, runScript } from "@workerdeck/sandbox";
9
9
  import { z } from "zod";
10
10
  import { lookup } from "node:dns/promises";
@@ -413,6 +413,7 @@ var SessionRunner = class {
413
413
  #events = [];
414
414
  #listeners = /* @__PURE__ */ new Set();
415
415
  #seq = 0;
416
+ #activityCount = 0;
416
417
  #status = "starting";
417
418
  #statusDetail;
418
419
  #sdkSessionId;
@@ -469,6 +470,7 @@ var SessionRunner = class {
469
470
  apiKeySource: this.#apiKeySource,
470
471
  createdAt: this.createdAt,
471
472
  lastSeq: this.#seq,
473
+ activityCount: this.#activityCount,
472
474
  pendingPermissionCount: this.#pending.size,
473
475
  meta: this.#config.meta,
474
476
  title: this.#title(),
@@ -484,6 +486,17 @@ var SessionRunner = class {
484
486
  if (!prompt) return void 0;
485
487
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
486
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
+ }
487
500
  /** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
488
501
  start() {
489
502
  if (this.#started) return this.#runPromise;
@@ -946,6 +959,7 @@ var SessionRunner = class {
946
959
  ts: Date.now()
947
960
  };
948
961
  this.#lastActivityAt = event.ts;
962
+ this.#activityCount += transcriptActivity(body);
949
963
  this.#events.push(event);
950
964
  for (const listener of this.#listeners) try {
951
965
  listener(event);
@@ -994,6 +1008,7 @@ var AiSdkRunner = class {
994
1008
  #events = [];
995
1009
  #listeners = /* @__PURE__ */ new Set();
996
1010
  #seq = 0;
1011
+ #activityCount = 0;
997
1012
  #status = "starting";
998
1013
  #permissionMode;
999
1014
  #messages = [];
@@ -1042,6 +1057,7 @@ var AiSdkRunner = class {
1042
1057
  if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
1043
1058
  this.#seq = snapshot.seq;
1044
1059
  this.#events = [...snapshot.events];
1060
+ this.#activityCount = this.#events.reduce((total, event) => total + transcriptActivity(event), 0);
1045
1061
  this.#messages = [...state.messages];
1046
1062
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
1047
1063
  this.#dispatched = new Set(state.dispatched);
@@ -1091,6 +1107,7 @@ var AiSdkRunner = class {
1091
1107
  permissionMode: this.#permissionMode,
1092
1108
  createdAt: this.createdAt,
1093
1109
  lastSeq: this.#seq,
1110
+ activityCount: this.#activityCount,
1094
1111
  pendingPermissionCount: 0,
1095
1112
  meta: this.#config.meta,
1096
1113
  title: this.#title(),
@@ -1677,6 +1694,17 @@ var AiSdkRunner = class {
1677
1694
  if (!prompt) return void 0;
1678
1695
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
1679
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
+ }
1680
1708
  #setStatus(status, detail) {
1681
1709
  if (this.#status === status) return;
1682
1710
  if (this.#status === "closed" || this.#status === "failed") return;
@@ -1694,6 +1722,7 @@ var AiSdkRunner = class {
1694
1722
  ts: Date.now()
1695
1723
  };
1696
1724
  this.#lastActivityAt = event.ts;
1725
+ this.#activityCount += transcriptActivity(body);
1697
1726
  this.#events.push(event);
1698
1727
  for (const listener of this.#listeners) try {
1699
1728
  listener(event);
@@ -2987,6 +3016,118 @@ const APPROVAL_POLICY_BY_MODE = {
2987
3016
  * default, so unattended codex sessions land the same way Claude ones do. */
2988
3017
  const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
2989
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
+ /**
2990
3131
  * The experimental per-request decision list, normalized to names: a string
2991
3132
  * entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
2992
3133
  * …}`) is named by its key. Undefined = the request stated no list and the
@@ -3207,6 +3348,7 @@ var CodexRunner = class {
3207
3348
  #events = [];
3208
3349
  #listeners = /* @__PURE__ */ new Set();
3209
3350
  #seq = 0;
3351
+ #activityCount = 0;
3210
3352
  #status = "starting";
3211
3353
  #sdkSessionId;
3212
3354
  #model;
@@ -3246,6 +3388,21 @@ var CodexRunner = class {
3246
3388
  /** Set around history replay: {@link #emit} stamps `replay: true` onto the
3247
3389
  * message events the live item mapping produces. */
3248
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();
3249
3406
  constructor(config, id = randomUUID()) {
3250
3407
  const mode = config.permissionMode ?? "default";
3251
3408
  if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
@@ -3294,6 +3451,7 @@ var CodexRunner = class {
3294
3451
  canBypassPermissions: true,
3295
3452
  createdAt: this.createdAt,
3296
3453
  lastSeq: this.#seq,
3454
+ activityCount: this.#activityCount,
3297
3455
  pendingPermissionCount: this.#approvals.size,
3298
3456
  meta: this.#config.meta,
3299
3457
  title: this.#title(),
@@ -3309,6 +3467,17 @@ var CodexRunner = class {
3309
3467
  if (!prompt) return void 0;
3310
3468
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
3311
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
+ }
3312
3481
  start() {
3313
3482
  if (this.#started) return this.#turnChain;
3314
3483
  this.#started = true;
@@ -3317,8 +3486,62 @@ var CodexRunner = class {
3317
3486
  this.#turnChain = this.#turnChain.then(() => this.#backfillHistory());
3318
3487
  } else this.#setStatus("idle");
3319
3488
  if (this.#config.prompt) this.sendMessage(this.#config.prompt);
3489
+ if (!this.#config.prompt && !this.#config.resume) this.#probeSkills();
3320
3490
  return this.#turnChain;
3321
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
+ }
3322
3545
  sendMessage(text, attachments) {
3323
3546
  if (this.#closed) throw new Error("session is closed");
3324
3547
  const input = this.#buildInput(text, attachments ?? []);
@@ -3534,9 +3757,117 @@ var CodexRunner = class {
3534
3757
  };
3535
3758
  this.#threadLoaded = true;
3536
3759
  }
3760
+ this.#refreshSkills(connection);
3537
3761
  return connection;
3538
3762
  }
3539
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
+ /**
3540
3871
  * On resume, replay the thread's prior turns as `replay: true` events,
3541
3872
  * seq'd before any live turn — the SessionRunner backfill contract, fed
3542
3873
  * from `thread/resume`'s own `thread.turns`. When the resume response says
@@ -3751,6 +4082,21 @@ var CodexRunner = class {
3751
4082
  active.contextWindow = update.tokenUsage?.modelContextWindow ?? void 0;
3752
4083
  return;
3753
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
+ }
3754
4100
  case "account/rateLimits/updated":
3755
4101
  this.#emitRateLimits(params?.rateLimits);
3756
4102
  return;
@@ -3926,6 +4272,12 @@ var CodexRunner = class {
3926
4272
  if (item.type === "mcpToolCall" && !active.toolUseEmitted.has(id)) {
3927
4273
  active.toolUseEmitted.add(id);
3928
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);
3929
4281
  }
3930
4282
  }
3931
4283
  #handleItemCompleted(item, active) {
@@ -3983,6 +4335,18 @@ var CodexRunner = class {
3983
4335
  this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
3984
4336
  this.#emitToolResult(id, "", false);
3985
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;
3986
4350
  default: {
3987
4351
  const unknown = item;
3988
4352
  this.#emit({
@@ -4174,6 +4538,7 @@ var CodexRunner = class {
4174
4538
  ts: Date.now()
4175
4539
  };
4176
4540
  this.#lastActivityAt = event.ts;
4541
+ this.#activityCount += transcriptActivity(body);
4177
4542
  this.#events.push(event);
4178
4543
  for (const listener of this.#listeners) try {
4179
4544
  listener(event);