@workerdeck/core 0.22.0 → 1.0.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,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { getSessionInfo, getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, contextReading, imagePartRef, replayCoalesceKey, replayRetains, snapshotRetains, transcriptActivity, transcriptContent } from "@workerdeck/protocol";
4
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, contextReading, imagePartRef, replayCoalesceKey, replayRetains, snapshotRetains, transcriptActivity, transcriptContent, transcriptProse } from "@workerdeck/protocol";
5
5
  import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -11,15 +11,12 @@ import { lookup } from "node:dns/promises";
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import { dirname, join, resolve, sep } from "node:path";
13
13
  //#region src/lib/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
14
  const IMAGE_TYPES = new Set([
17
15
  "image/jpeg",
18
16
  "image/png",
19
17
  "image/gif",
20
18
  "image/webp"
21
19
  ]);
22
- /** Textual types whose media type doesn't start with `text/`. */
23
20
  const TEXT_TYPES = new Set([
24
21
  "application/json",
25
22
  "application/xml",
@@ -32,11 +29,9 @@ const TEXT_TYPES = new Set([
32
29
  "application/x-httpd-php",
33
30
  "application/sql"
34
31
  ]);
35
- /** Strips any `; charset=…` parameter and lowercases. */
36
32
  function normalizeMediaType(mediaType) {
37
33
  return mediaType.split(";")[0].trim().toLowerCase();
38
34
  }
39
- /** How this media type can be sent, or null if it can't be. */
40
35
  function attachmentKind(mediaType) {
41
36
  const type = normalizeMediaType(mediaType);
42
37
  if (IMAGE_TYPES.has(type)) return "image";
@@ -44,23 +39,11 @@ function attachmentKind(mediaType) {
44
39
  if (type.startsWith("text/") || TEXT_TYPES.has(type)) return "text";
45
40
  return null;
46
41
  }
47
- /** Human-readable list for the 415 an unsupported upload gets. */
48
42
  const SUPPORTED_ATTACHMENT_TYPES = [
49
43
  ...IMAGE_TYPES,
50
44
  "application/pdf",
51
45
  "text/*"
52
46
  ].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
47
  function attachmentContentBlocks(attachments) {
65
48
  return attachments.map((attachment) => {
66
49
  const mediaType = normalizeMediaType(attachment.mediaType);
@@ -90,7 +73,6 @@ function attachmentContentBlocks(attachments) {
90
73
  }
91
74
  });
92
75
  }
93
- /** Strip the bytes: the log-safe half of an attachment. */
94
76
  function attachmentRef(attachment) {
95
77
  return {
96
78
  id: attachment.id,
@@ -104,10 +86,6 @@ function decodeText(base64) {
104
86
  }
105
87
  //#endregion
106
88
  //#region src/lib/input-queue.ts
107
- /**
108
- * Push-based single-consumer AsyncIterable bridging imperative sendMessage() calls
109
- * into the streaming `prompt` the Agent SDK consumes.
110
- */
111
89
  var InputQueue = class {
112
90
  #buffer = [];
113
91
  #waiter = null;
@@ -163,30 +141,8 @@ var InputQueue = class {
163
141
  };
164
142
  //#endregion
165
143
  //#region src/lib/patch.ts
166
- /**
167
- * Turning an engine's edit output into the wire's {@link FilePatch}.
168
- *
169
- * Both engines know exactly which lines of which file changed, and both say so
170
- * in their own vocabulary: the Claude SDK hands over a `structuredPatch` array
171
- * on `SDKUserMessage.tool_use_result`, codex puts a unified diff string on each
172
- * `fileChange` item. A client can reconstruct neither — it has never seen the
173
- * file — so anything not normalized here is a diff that renders without line
174
- * numbers.
175
- *
176
- * Normalizing in the runner rather than in each client is the point: one shape
177
- * reaches the wire, and the dashboard, the extension and the phone all render
178
- * from it without a per-engine branch or a diff parser of their own.
179
- */
180
- /**
181
- * The most lines a patch may put on the wire.
182
- *
183
- * A patch is replayed on every attach and captured into parking snapshots, so
184
- * "the diff is big" must not become "this session is expensive to open forever".
185
- * Whole hunks are kept or dropped — half a hunk has misleading line numbers —
186
- * and the drop is flagged so a renderer can say the diff is partial instead of
187
- * presenting it as the whole change.
188
- */
189
144
  const MAX_PATCH_LINES = 400;
145
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
190
146
  function capHunks(hunks) {
191
147
  const kept = [];
192
148
  let lines = 0;
@@ -200,25 +156,15 @@ function capHunks(hunks) {
200
156
  }
201
157
  return { hunks: kept };
202
158
  }
203
- /** Structural, not `instanceof`: this reads a field the SDK types as `unknown`,
204
- * and a shape check is the only honest way to know what arrived. */
205
159
  function isHunk(value) {
206
160
  const hunk = value;
207
161
  return !!hunk && typeof hunk.oldStart === "number" && typeof hunk.oldLines === "number" && typeof hunk.newStart === "number" && typeof hunk.newLines === "number" && Array.isArray(hunk.lines) && hunk.lines.every((line) => typeof line === "string");
208
162
  }
209
- /**
210
- * A {@link FilePatch} from the Claude SDK's structured tool output
211
- * (`SDKUserMessage.tool_use_result` for Edit/Write/NotebookEdit).
212
- *
213
- * Everything else on that object is deliberately left behind — `originalFile`
214
- * alone is the entire pre-edit file, which is precisely what must not be logged
215
- * (see `FilePatch`'s own note).
216
- */
217
163
  function filePatchFromToolResult(result) {
218
164
  const output = result;
219
- if (!output || !Array.isArray(output.structuredPatch)) return void 0;
165
+ if (!output || !Array.isArray(output.structuredPatch)) return;
220
166
  const hunks = output.structuredPatch.filter(isHunk);
221
- if (hunks.length === 0) return void 0;
167
+ if (hunks.length === 0) return;
222
168
  const { hunks: kept, truncated } = capHunks(hunks);
223
169
  return {
224
170
  ...typeof output.filePath === "string" && { path: output.filePath },
@@ -227,20 +173,6 @@ function filePatchFromToolResult(result) {
227
173
  ...truncated && { truncated }
228
174
  };
229
175
  }
230
- /** `@@ -oldStart,oldLines +newStart,newLines @@` — the counts are optional and
231
- * mean 1 when absent, which is what a single-line hunk looks like. */
232
- const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
233
- /**
234
- * A {@link FilePatch} from a unified diff — codex's `fileChange.diff`.
235
- *
236
- * Only the hunks are read. A diff's `---`/`+++` header names the file, but codex
237
- * already reports the path on the change itself, and a header path is often
238
- * relative or `/dev/null`, so the caller's path is the one worth trusting.
239
- *
240
- * Returns undefined when there is no hunk header at all: that is not a unified
241
- * diff, and inventing hunk numbers for it would put wrong line numbers on screen
242
- * — worse than none.
243
- */
244
176
  function parseUnifiedDiff(diff, path) {
245
177
  const hunks = [];
246
178
  let current;
@@ -262,7 +194,7 @@ function parseUnifiedDiff(diff, path) {
262
194
  else if (line === "") current.lines.push(" ");
263
195
  else current = void 0;
264
196
  }
265
- if (hunks.length === 0) return void 0;
197
+ if (hunks.length === 0) return;
266
198
  const { hunks: kept, truncated } = capHunks(hunks);
267
199
  return {
268
200
  ...path && { path },
@@ -272,43 +204,18 @@ function parseUnifiedDiff(diff, path) {
272
204
  }
273
205
  //#endregion
274
206
  //#region src/lib/normalize.ts
275
- /** Does this message answer exactly one tool call? A patch is per-file-edit and
276
- * the message says nothing about which of two results it describes, so anything
277
- * else gets no patch rather than a diff pinned to the wrong call. */
207
+ const FAMILY_ORDER = [
208
+ "fable",
209
+ "opus",
210
+ "sonnet",
211
+ "haiku"
212
+ ];
278
213
  function singleToolResult(message) {
279
214
  const content = message.content;
280
215
  if (!Array.isArray(content)) return false;
281
216
  return content.filter((block) => block.type === "tool_result").length === 1;
282
217
  }
283
- /**
284
- * The wrappers the CLI writes into the transcript when the *harness* is talking
285
- * to the model rather than a person talking to the session.
286
- *
287
- * Deliberately a text test, and only these two. The live path has structure to
288
- * go on (`isSynthetic`, `origin.kind`), but **the resumed path has none**: the
289
- * SDK's `SessionMessage` carries exactly `message`, `uuid`, `session_id`,
290
- * `parent_tool_use_id`, `parent_agent_id` and `timestamp` — every one of
291
- * `isMeta`, `isSidechain`, `promptSource` and `origin` is dropped between the
292
- * stored JSONL and what `getSessionMessages` hands back (verified against real
293
- * transcripts). So on resume this is the only signal there is, and without it a
294
- * `<task-notification>` blob comes back as a blue user row and a scrubber mark,
295
- * as if someone had typed it.
296
- *
297
- * `<local-command-caveat>` is here for symmetry and cheap insurance: the SDK
298
- * filters `isMeta` entries out of a resumed transcript itself today, which is
299
- * not a contract anyone wrote down.
300
- *
301
- * What is *not* here matters as much:
302
- * - `<local-command-stdout>` — the reducer turns it into a notice row on
303
- * purpose; marking it synthetic would delete a row both paths show.
304
- * - `<command-name>` — that is a person running a slash command. The reducer
305
- * renders it as the command line they typed; hiding it would erase the turn's
306
- * cause.
307
- */
308
218
  const SYNTHETIC_USER_PREFIXES = ["<task-notification>", "<local-command-caveat>"];
309
- /** First text block's leading tag, for the test above. Tool results and images
310
- * carry no text and are never synthetic by this rule (a tool result is already
311
- * a tool result to every renderer). */
312
219
  function isSyntheticUserText(message) {
313
220
  const content = message.content;
314
221
  const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((block) => block.type === "text")?.text : void 0;
@@ -326,20 +233,6 @@ function toApiMessage(message) {
326
233
  usage: m.usage
327
234
  };
328
235
  }
329
- /**
330
- * Plan rate-limit windows from the CLI's structured `/usage` data, as `rate_limit`
331
- * events — the same shape a live `rate_limit_event` produces.
332
- *
333
- * Without this a client shows no usage at all until a window *changes*, which the
334
- * CLI only reports after a turn moves the needle, and never for a session that is
335
- * only being watched. Polling the snapshot and forwarding it through the existing
336
- * event means replay, the dashboard and the iOS app all get it for free, with no
337
- * new protocol surface.
338
- *
339
- * `status` is not per-window in the usage payload — 'allowed' is what a session
340
- * the CLI is running for us is, by construction. A window with no utilization is
341
- * unknown, not zero, and is dropped rather than reported at 0%.
342
- */
343
236
  function rateLimitEventsFromUsage(usage) {
344
237
  if (!usage.rate_limits_available || !usage.rate_limits) return [];
345
238
  const limits = usage.rate_limits;
@@ -370,15 +263,6 @@ function rateLimitEventsFromUsage(usage) {
370
263
  }
371
264
  return events;
372
265
  }
373
- /**
374
- * The CLI's MCP status, as `McpServerStatusInfo`.
375
- *
376
- * The narrowing is the point: the SDK's config object carries `env` for stdio
377
- * servers and `headers` for HTTP ones, and both routinely hold API tokens. This
378
- * is the one place they are dropped, so no client — dashboard, phone, or a host
379
- * app reading the REST route — can turn "show me my MCP servers" into a
380
- * credential dump. Only the connection's identity survives.
381
- */
382
266
  function mcpStatusInfo(status) {
383
267
  const config = status.config;
384
268
  const transport = config?.type ?? (config?.command ? "stdio" : void 0);
@@ -399,26 +283,6 @@ function mcpStatusInfo(status) {
399
283
  }))
400
284
  };
401
285
  }
402
- /**
403
- * The CLI's model list, as `ModelOption[]`.
404
- *
405
- * Two decisions live here rather than in each client:
406
- *
407
- * - **`default` is dropped.** The CLI offers a row whose id is literally
408
- * `default` ("Default (recommended)"), meaning "whatever I would have picked".
409
- * It is a legal id to send, but it is not a model: a session running on it
410
- * reports a real model, so a picker showing it has a row that can never be
411
- * checked, and a status bar naming it would say "Default" for a session
412
- * answering as Opus. Which model the default resolved to is a different
413
- * question, and `system_init` answers it.
414
- * - **`primary` is derived.** The CLI reports one flat list; Claude Code's own
415
- * picker shows the newest of each family and files the rest under "more
416
- * models". The list arrives newest-first, so the first row of each family is
417
- * the primary one. A heuristic, but a stable one — and doing it once here
418
- * means the dashboard and the phone group identically.
419
- */
420
- /** What the CLI's `default` row resolves to — the model a session will answer as
421
- * before it has answered anything. Dropped from the list, kept as this. */
422
286
  function defaultModelFromSdk(models) {
423
287
  return models.find((model) => model.value === "default")?.resolvedModel;
424
288
  }
@@ -452,50 +316,26 @@ function modelOptionsFromSdk(models) {
452
316
  return rankA === rankB ? a.index - b.index : rankA - rankB;
453
317
  }).map(({ option }) => option);
454
318
  }
455
- const FAMILY_ORDER = [
456
- "fable",
457
- "opus",
458
- "sonnet",
459
- "haiku"
460
- ];
461
319
  function familyRank(option) {
462
320
  const rank = FAMILY_ORDER.indexOf(modelFamily(option.resolvedModel ?? option.value));
463
321
  return rank === -1 ? FAMILY_ORDER.length : rank;
464
322
  }
465
- /**
466
- * The name a person says, from a wire model id: 'claude-opus-5[1m]' → "Opus 5",
467
- * 'claude-haiku-4-5-20251001' → "Haiku 4.5".
468
- *
469
- * The CLI's own `displayName` is the family alone ("Opus", "Haiku") or carries a
470
- * variant instead of a version ("Opus (1M context)"), and the version is the part
471
- * that answers "is this the current one". It is only ever in the id, so it is
472
- * read from there. Returns null when the id has no version to read — a bare
473
- * alias like 'sonnet' — and the CLI's name stands.
474
- */
475
- function friendlyModelName(id) {
323
+ function modelIdParts(id) {
476
324
  const parts = (id.split("[")[0] ?? id).toLowerCase().split("-").filter(Boolean);
477
325
  if (parts[0] === "claude") parts.shift();
326
+ return parts;
327
+ }
328
+ function friendlyModelName(id) {
329
+ const parts = modelIdParts(id);
478
330
  const family = parts.shift();
479
331
  if (!family) return null;
480
332
  const version = parts.filter((part) => !/^\d{8}$/.test(part));
481
333
  if (version.length === 0 || version.some((part) => !/^\d+$/.test(part))) return null;
482
334
  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join(".")}`;
483
335
  }
484
- /** 'claude-opus-4-8[1m]' → "opus". The vendor prefix, the context-window suffix
485
- * and the version tail are all dropped; what is left is the family a person
486
- * names. Unrecognisable ids become their own family, so a model this rule has
487
- * never seen lands in the main list rather than being hidden. */
488
336
  function modelFamily(id) {
489
- const withoutVariant = id.split("[")[0] ?? id;
490
- const parts = withoutVariant.toLowerCase().split("-");
491
- if (parts[0] === "claude") parts.shift();
492
- return parts[0] ?? withoutVariant;
337
+ return modelIdParts(id)[0] ?? id;
493
338
  }
494
- /**
495
- * Map one SDKMessage to a wire-protocol event body, or null for messages the runner
496
- * consumes itself (system_init and session-state changes carry runner state and are
497
- * emitted by the runner with extra context).
498
- */
499
339
  function normalizeSdkMessage(msg) {
500
340
  switch (msg.type) {
501
341
  case "assistant": return {
@@ -560,26 +400,73 @@ function normalizeSdkMessage(msg) {
560
400
  }
561
401
  }
562
402
  //#endregion
403
+ //#region src/lib/event-log.ts
404
+ var EventLog = class {
405
+ #events = [];
406
+ #seq = 0;
407
+ #activityCount = 0;
408
+ #proseCount = 0;
409
+ #contextUsage;
410
+ #resetSeq = 0;
411
+ #lastActivityAt;
412
+ get events() {
413
+ return this.#events;
414
+ }
415
+ get seq() {
416
+ return this.#seq;
417
+ }
418
+ get activityCount() {
419
+ return this.#activityCount;
420
+ }
421
+ /** The unread badge's unit — see `transcriptProse`. Folded here so a restored log recomputes it. */
422
+ get proseCount() {
423
+ return this.#proseCount;
424
+ }
425
+ get contextUsage() {
426
+ return this.#contextUsage;
427
+ }
428
+ get resetSeq() {
429
+ return this.#resetSeq;
430
+ }
431
+ get lastActivityAt() {
432
+ return this.#lastActivityAt;
433
+ }
434
+ append(body) {
435
+ const event = {
436
+ ...body,
437
+ seq: ++this.#seq,
438
+ ts: Date.now()
439
+ };
440
+ this.#lastActivityAt = event.ts;
441
+ this.#fold(event);
442
+ this.#events.push(event);
443
+ return event;
444
+ }
445
+ at(seq) {
446
+ return this.#events.find((event) => event.seq === seq);
447
+ }
448
+ restore(events, seq, lastActivityAt) {
449
+ this.#events = [...events];
450
+ this.#seq = seq;
451
+ this.#activityCount = 0;
452
+ this.#proseCount = 0;
453
+ this.#contextUsage = void 0;
454
+ this.#resetSeq = 0;
455
+ for (const event of this.#events) this.#fold(event);
456
+ this.#lastActivityAt = lastActivityAt;
457
+ }
458
+ #fold(event) {
459
+ this.#activityCount += transcriptActivity(event);
460
+ this.#proseCount += transcriptProse(event);
461
+ this.#contextUsage = contextReading(event) ?? this.#contextUsage;
462
+ if (event.type === "conversation_reset") {
463
+ this.#resetSeq = event.seq;
464
+ this.#contextUsage = void 0;
465
+ }
466
+ }
467
+ };
468
+ //#endregion
563
469
  //#region src/lib/replay.ts
564
- /**
565
- * Which buffered events a coalesced replay should skip: everything superseded
566
- * by a later event with the same {@link replayCoalesceKey}.
567
- *
568
- * A **backwards** scan, keeping the first occurrence of each key — which is the
569
- * whole trick. Walking forwards would need a second pass to know which of the
570
- * fifty context readings was the last one; walking backwards, the first one you
571
- * meet *is* the last one, and everything after it (in scan order) is history.
572
- *
573
- * Note what this does **not** do: it never reorders and never touches an event
574
- * with no key. Transcript content is an ordered fold — a stream delta
575
- * accumulates onto a message, a tool result attaches to a call that came
576
- * earlier, a turn result finalizes — so it must arrive exactly as it was
577
- * emitted. Only last-write-wins *state* is eligible, and `replayCoalesceKey`
578
- * is where that judgement lives.
579
- *
580
- * `afterSeq` is honoured so the scan agrees with the caller's replay window: an
581
- * event the caller was never going to send must not suppress one it was.
582
- */
583
470
  function staleReplaySeqs(events, afterSeq) {
584
471
  const stale = /* @__PURE__ */ new Set();
585
472
  const seen = /* @__PURE__ */ new Set();
@@ -593,45 +480,6 @@ function staleReplaySeqs(events, afterSeq) {
593
480
  }
594
481
  return stale;
595
482
  }
596
- /**
597
- * The one replay body, and what a socket receives from it.
598
- *
599
- * Every runner had a byte-identical copy of this loop — three spellings of four
600
- * rules, one of which ("never drop the highest-seq event, whatever the rule
601
- * says") is load-bearing and was three copies of a comment. Not a base class:
602
- * the runners share nothing else, and a base class would have to own `#emit`,
603
- * the most engine-specific method each of them has.
604
- *
605
- * The rules, in the order they are applied:
606
- *
607
- * 1. `afterSeq` — the caller already holds everything at or below it.
608
- * 2. `resetSeq` — transcript *content* strictly below the latest
609
- * `conversation_reset` is skipped, so a re-attach cannot resurrect a cleared
610
- * conversation while state events still replay. **Every engine that can emit
611
- * a reset must track it and pass it** — this was Claude's alone only for as
612
- * long as Claude's was the only engine that could produce the event, and the
613
- * failure when a runner forgets is quiet: the end state is right for a
614
- * current reducer, so nothing looks broken while every attach re-sends the
615
- * whole cleared conversation for the process's lifetime.
616
- * 3. `coalesceReplay` — last-write-wins state readings superseded later in the
617
- * same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
618
- * reducer reads and discards. Opt-in, and only sound for a consumer whose
619
- * handling of those events is last-write-wins.
620
- * 4. `imageRefs` — a base64 image part is delivered as an `image_ref` address
621
- * (protocol's {@link imagePartRef}), its bytes one REST fetch away. Applied
622
- * **before** rule 5, because it stamps indices from the stored part array
623
- * which rule 5 then reshapes. Unlike rule 5 this also applies to the live
624
- * path (see `SubscriberSet`), which is the one place these two rules differ.
625
- * 5. `truncateResults` — a huge `tool_result` block is delivered as its head
626
- * plus the markers that say so. **Never mutates the stored event**: the live
627
- * path, the parking snapshot and the fetch route all need the whole thing,
628
- * so this builds a copy and the log stays the log.
629
- *
630
- * The highest-seq event is delivered whatever rules 2 and 3 say — a client's
631
- * replay hold waits for `state.lastSeq` to reach the attach's and would
632
- * otherwise hang forever — but it is still *truncated* when rule 4 applies. A
633
- * session that ends on a `find /` puts its 641 KB frame exactly there.
634
- */
635
483
  function replaySlice(events, options) {
636
484
  const { afterSeq, resetSeq = 0, coalesceReplay, truncateResults, imageRefs } = options;
637
485
  const stale = coalesceReplay ? staleReplaySeqs(events, afterSeq) : void 0;
@@ -649,16 +497,6 @@ function replaySlice(events, options) {
649
497
  }
650
498
  return out;
651
499
  }
652
- /**
653
- * A copy of `event` whose oversized `tool_result` blocks carry their head and
654
- * say so — or `event` itself, unchanged and un-copied, when nothing is over the
655
- * budget. That identity matters: an attach is mostly small events, and a fresh
656
- * object for every one of them would cost more than the feature saves.
657
- *
658
- * Blocks are measured and cut **individually**. A message answering three calls
659
- * where one is a `find /` keeps the two small results whole, which is what makes
660
- * the per-block marker (rather than a per-event one) honest.
661
- */
662
500
  function truncateResultBlocks(event) {
663
501
  if (event.type !== "user_message") return event;
664
502
  const content = event.message.content;
@@ -687,22 +525,11 @@ function truncateResultBlocks(event) {
687
525
  }
688
526
  };
689
527
  }
690
- /** Characters in a result's content, in the same terms a reader sees it: the
691
- * string itself, or every text part of a block list joined by newlines — which
692
- * is exactly what `blockText` in the reducer builds. Non-text parts (an image
693
- * block) contribute nothing, because they are not what is large here and
694
- * slicing them would corrupt them. */
695
528
  function resultChars(content) {
696
529
  if (typeof content === "string") return content.length;
697
530
  if (!Array.isArray(content)) return 0;
698
531
  return content.reduce((total, part, index) => total + (typeof part.text === "string" ? part.text.length + (index > 0 ? 1 : 0) : 0), 0);
699
532
  }
700
- /** The first `chars` characters, in the content's own shape — a string stays a
701
- * string, a block list stays a block list (cut at the part that crosses the
702
- * budget, with the remaining parts dropped). Shape-preserving on purpose: the
703
- * reducer, both renderers and the copy button all read this the same way they
704
- * read a whole one, so truncation is a shorter result and never a different
705
- * kind of one. */
706
533
  function headOf(content, chars) {
707
534
  if (typeof content === "string") return content.slice(0, chars);
708
535
  if (!Array.isArray(content)) return content;
@@ -724,23 +551,6 @@ function headOf(content, chars) {
724
551
  }
725
552
  return parts;
726
553
  }
727
- /**
728
- * A copy of `event` whose `tool_result` blocks carry `image_ref` addresses in
729
- * place of their base64 image parts — or `event` itself, unchanged and
730
- * un-copied, when it holds none. Same identity rule as
731
- * {@link truncateResultBlocks}, and it matters more here: an event carrying an
732
- * image at all is the exception, so the common path must not allocate.
733
- *
734
- * **Never mutates the stored event.** The log is what the parking snapshot
735
- * embeds, what `Runner.eventAt` reads, and therefore what the fetch route
736
- * serves the bytes back from — a drop that reached the log would 404 the very
737
- * lazy-load this rule promises.
738
- *
739
- * Indices are stamped from the **stored** array, which is why this runs *before*
740
- * truncation rather than after: `headOf` reshapes a block's parts, so an address
741
- * computed on its output would name the wrong part of the stored block. That
742
- * ordering is asserted in `replay-image-ref.test.ts`, not merely intended.
743
- */
744
554
  function refImageParts(event) {
745
555
  if (event.type !== "user_message") return event;
746
556
  const content = event.message.content;
@@ -778,14 +588,6 @@ function refImageParts(event) {
778
588
  //#region src/lib/subscribers.ts
779
589
  var SubscriberSet = class {
780
590
  #listeners = /* @__PURE__ */ new Map();
781
- /**
782
- * Replay `events` to `listener` under `options`, then hold it for live
783
- * delivery. Returns the unsubscribe.
784
- *
785
- * The replay runs *before* the listener joins the set, which is the ordering
786
- * every runner already had and is load-bearing: joining first would deliver a
787
- * live event emitted mid-replay ahead of the buffered events preceding it.
788
- */
789
591
  subscribe(events, listener, afterSeq = 0, options, resetSeq = 0) {
790
592
  const asked = options ?? {};
791
593
  for (const event of replaySlice(events, {
@@ -798,11 +600,9 @@ var SubscriberSet = class {
798
600
  this.#listeners.delete(listener);
799
601
  };
800
602
  }
801
- /** Drop every subscriber — a park, which ends the session's live stream. */
802
603
  clear() {
803
604
  this.#listeners.clear();
804
605
  }
805
- /** Fan one event out, transformed per subscriber. */
806
606
  emit(event) {
807
607
  for (const [listener, asked] of this.#listeners) try {
808
608
  listener(asked.imageRefs ? refImageParts(event) : event);
@@ -810,71 +610,34 @@ var SubscriberSet = class {
810
610
  }
811
611
  };
812
612
  //#endregion
613
+ //#region src/lib/title.ts
614
+ function hostTitle(meta) {
615
+ const title = meta?.title;
616
+ return typeof title === "string" && title.length > 0 ? title : void 0;
617
+ }
618
+ function sessionTitle(config, engineTitle) {
619
+ const host = hostTitle(config.meta);
620
+ if (host) return host;
621
+ if (engineTitle) return engineTitle;
622
+ const prompt = config.prompt;
623
+ if (!prompt) return;
624
+ return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
625
+ }
626
+ function withTitle(config, title) {
627
+ const meta = { ...config.meta };
628
+ if (title) meta.title = title;
629
+ else delete meta.title;
630
+ return {
631
+ ...config,
632
+ meta
633
+ };
634
+ }
635
+ //#endregion
813
636
  //#region src/engines/claude/subagents.ts
814
- /**
815
- * The rollup behind `SessionInfo.subagents` — what a sessions list (which never
816
- * attaches) can know about the sub-agents running inside a session. Fed from
817
- * `SessionRunner.#emit`, the one chokepoint every event passes through, so the
818
- * resume backfill — which replays history through the same path — reconstructs
819
- * it with no persistence of its own. Grouping is by Task id throughout, never
820
- * adjacency: parallel sub-agents interleave in the stream, the same fact that
821
- * broke the terminal theme's positional row model.
822
- *
823
- * Three decisions live here rather than in the protocol doc:
824
- *
825
- * **What counts as a spawn.** A record opens when a *top-level* assistant
826
- * message carries a `tool_use` named `Task` or `Agent` — the moment the
827
- * sub-agent exists, so a just-spawned agent is visible before its first nested
828
- * event, with the block's input in hand for its labels. Both names are observed
829
- * SDK spellings (`Task` synchronous, `Agent` async), and the name is a
830
- * convention, not a law — a session that spawned three `Agent`s under a tracker
831
- * that only knew `Task` reported all three as label-less failures. So three
832
- * more openers back the allowlist up: the CLI's own `task_started` system event
833
- * (which positively names the `tool_use_id` an agent runs under, with the brief
834
- * as labels), the launch acknowledgement (below), and — as before — any nested
835
- * event whose `parentToolUseId` has no record: an id that events demonstrably
836
- * nest under *is* a sub-agent, whatever the spawning call was named. A fallback
837
- * record never saw an input, so it stays label-less until a named signal fills
838
- * it in rather than resetting an accumulated count.
839
- *
840
- * **A background agent's `tool_result` is a launch receipt, not a verdict.**
841
- * An async agent's spawn call resolves seconds after the spawn with "Async
842
- * agent launched successfully. (This tool result is internal metadata …)" —
843
- * long before the agent has done anything — and its actual outcome travels on
844
- * a `task_notification` system event instead (`status: 'completed'` is `done`,
845
- * any other way of stopping is `failed`: the report the notification exists to
846
- * deliver never came). Settling on the receipt would read "0 of 3 agents
847
- * running" while three agents burn tokens, so a non-error result on a record
848
- * known to be background never settles it. Known how: the `task_started` event
849
- * live, or the receipt's own wrapper text on a resume — the stored transcript
850
- * carries none of the CLI's system events, so, exactly as
851
- * `isSyntheticUserText` documents for the `<task-notification>` blob, the text
852
- * is the only signal the replayed path has.
853
- *
854
- * **What an interrupted turn leaves behind.** A Task whose `tool_result` never
855
- * arrives — interrupt, session error, a turn or budget cap — would otherwise
856
- * read `running` on an idle session forever, a lie a list re-renders at every
857
- * poll. So the end of a turn settles every still-running record as `failed`:
858
- * the report never came, which is the one thing `done` could have claimed. The
859
- * sweep keys on `turn_result`, on the status coming to rest (`idle` — which is
860
- * how a resumed history that ends mid-Task settles, since the backfill replays
861
- * no `turn_result` — or a terminal state), and on the session closing. A real
862
- * verdict arriving anyway outranks the sweep's inference. The sweep's premise
863
- * — "the turn ended, so anything still running was cut off" — is false for a
864
- * background agent, which is *designed* to outlive its turn: the real session
865
- * behind this file ended three turns while its agents ran, and every
866
- * `turn_result` re-branded live, working agents as failures. So the turn and
867
- * idle sweeps spare a record marked background by a **live** signal. They do
868
- * not spare one whose only evidence is replayed: the backfill describes a
869
- * process that is gone, and a background agent the old process died inside can
870
- * never notify — `running` would be the forever-lie again. `session_closed`
871
- * and the terminal statuses settle everything, background included, for the
872
- * same reason: the process hosting those agents is gone.
873
- */
637
+ const SPAWNER_NAMES = new Set(["Task", "Agent"]);
874
638
  var SubagentTracker = class {
875
639
  #records = /* @__PURE__ */ new Map();
876
640
  #settleCounter = 0;
877
- /** Fold one emitted event body into the rollup, in log order. */
878
641
  observe(body, ts) {
879
642
  switch (body.type) {
880
643
  case "assistant_message":
@@ -954,14 +717,8 @@ var SubagentTracker = class {
954
717
  default: return;
955
718
  }
956
719
  }
957
- /**
958
- * The rollup as `SessionInfo.subagents` serves it: spawn order (the
959
- * transcript's own), fresh objects, and `undefined` when there is nothing to
960
- * say — absent and empty mean the same thing to a client, and an empty array
961
- * on every row of a 1.2s-polled list is bytes spent saying nothing.
962
- */
963
720
  list() {
964
- if (this.#records.size === 0) return void 0;
721
+ if (this.#records.size === 0) return;
965
722
  const out = [];
966
723
  for (const r of this.#records.values()) out.push({
967
724
  toolUseId: r.toolUseId,
@@ -992,13 +749,6 @@ var SubagentTracker = class {
992
749
  record.agentType ??= cleaned(input?.subagent_type);
993
750
  record.description ??= cleaned(input?.description);
994
751
  }
995
- /**
996
- * End of turn (`final: false`): anything still running was cut off before
997
- * its report — except a background agent the live process still hosts, which
998
- * is designed to outlive the turn and settles by notification instead. End
999
- * of session (`final: true`): everything, background included, because the
1000
- * process those agents lived in is gone.
1001
- */
1002
752
  #sweep(final) {
1003
753
  for (const record of this.#records.values()) {
1004
754
  if (record.status !== "running") continue;
@@ -1025,53 +775,32 @@ var SubagentTracker = class {
1025
775
  }
1026
776
  }
1027
777
  };
1028
- /** The spawner names observed in the wild: `Task` runs the agent inside the
1029
- * turn, `Agent` launches it in the background. Deliberately just these two —
1030
- * a third spelling is caught by `task_started`, the launch receipt, or the
1031
- * nested-event fallback, so widening this to every tool would only turn
1032
- * ordinary calls into phantom agents. */
1033
- const SPAWNER_NAMES = new Set(["Task", "Agent"]);
1034
- /** The async spawn's immediate `tool_result` — "Async agent launched
1035
- * successfully. (This tool result is internal metadata …)" — recognized by its
1036
- * wrapper text because on a resume that text is the only signal there is (the
1037
- * `SYNTHETIC_USER_PREFIXES` argument; the CLI's system events are not stored).
1038
- * Live, `task_started` marks the record first and this is redundant armor. */
1039
- const isLaunchAck = (content) => {
778
+ function isLaunchAck(content) {
1040
779
  const text = typeof content === "string" ? content : firstText(Array.isArray(content) ? content : []);
1041
780
  return typeof text === "string" && text.trimStart().startsWith("Async agent launched");
1042
- };
1043
- /** A background agent stopping, parsed from the `<task-notification>` wrapper
1044
- * the CLI writes into the transcript. Field-tolerant on purpose: only the
1045
- * `tool-use-id` (this rollup's key) and the `status` verdict are read. */
1046
- const parseTaskNotification = (text) => {
1047
- if (text === void 0 || !text.trimStart().startsWith("<task-notification>")) return void 0;
781
+ }
782
+ function parseTaskNotification(text) {
783
+ if (text === void 0 || !text.trimStart().startsWith("<task-notification>")) return;
1048
784
  const toolUseId = /<tool-use-id>\s*([^<\s]+)\s*<\/tool-use-id>/.exec(text)?.[1];
1049
- if (toolUseId === void 0) return void 0;
785
+ if (toolUseId === void 0) return;
1050
786
  return {
1051
787
  toolUseId,
1052
788
  status: /<status>\s*([^<]*?)\s*<\/status>/.exec(text)?.[1] ?? ""
1053
789
  };
1054
- };
1055
- /** The first text of a message body, however the content is spelled — the
1056
- * stored transcript uses bare strings, the live stream uses blocks. */
1057
- const firstText = (content) => {
790
+ }
791
+ function firstText(content) {
1058
792
  if (typeof content === "string") return content;
1059
793
  for (const block of content) {
1060
794
  const b = block;
1061
795
  if (b?.type === "text" && typeof b.text === "string") return b.text;
1062
796
  }
1063
- };
1064
- /** Trim, drop blank, clip at the same 80 the terminal theme's `taskLabel` uses.
1065
- * Model-authored input rides every row of a polled sessions list, so it is
1066
- * bounded here rather than trusted — a 10KB `description` would be paid for at
1067
- * every poll. */
1068
- const cleaned = (value) => {
1069
- if (typeof value !== "string") return void 0;
797
+ }
798
+ function cleaned(value) {
799
+ if (typeof value !== "string") return;
1070
800
  const text = value.trim();
1071
- if (text === "") return void 0;
801
+ if (text === "") return;
1072
802
  return text.length > 80 ? text.slice(0, 79) + "…" : text;
1073
- };
1074
- /** The `tool_use` blocks of a message body, however the content is spelled. */
803
+ }
1075
804
  function toolUseBlocks(content) {
1076
805
  if (typeof content === "string") return [];
1077
806
  const blocks = [];
@@ -1090,37 +819,13 @@ function toolUseBlocks(content) {
1090
819
  //#endregion
1091
820
  //#region src/engines/claude/runner.ts
1092
821
  const DEFAULT_APPROVAL_TIMEOUT_MS$1 = 3e5;
1093
- /**
1094
- * One live Agent SDK session: owns the query() call, the streaming input queue, the
1095
- * pending-approval table, and a seq-numbered event log that subscribers can replay.
1096
- * No transport — the server (or any host) subscribes and bridges to the wire.
1097
- */
1098
822
  var SessionRunner = class {
1099
823
  id;
1100
824
  createdAt;
1101
825
  #config;
1102
- /** {@link SessionRunnerConfig.cwd}, checked once in the constructor. */
1103
826
  #cwd;
1104
- #events = [];
827
+ #log = new EventLog();
1105
828
  #subscribers = new SubscriberSet();
1106
- #seq = 0;
1107
- /**
1108
- * Latest context-window reading, retained from the last `context_usage` this
1109
- * runner emitted so `GET /sessions` can answer it without an attach — see
1110
- * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
1111
- * construction the same number the transcript last drew.
1112
- */
1113
- #contextUsage;
1114
- #activityCount = 0;
1115
- /**
1116
- * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
1117
- * never truncated — it still carries the state-bearing events (`capabilities`,
1118
- * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
1119
- * but `subscribe()` skips transcript *content* strictly below this mark, so a
1120
- * replay does not resurrect a cleared conversation. A later reset supersedes
1121
- * an earlier one by overwriting it.
1122
- */
1123
- #resetSeq = 0;
1124
829
  #status = "starting";
1125
830
  #statusDetail;
1126
831
  #sdkSessionId;
@@ -1128,39 +833,14 @@ var SessionRunner = class {
1128
833
  #apiKeySource;
1129
834
  #permissionMode;
1130
835
  #pending = /* @__PURE__ */ new Map();
1131
- /**
1132
- * The turn ended while an approval was standing, and nothing has started a
1133
- * new one since.
1134
- *
1135
- * `awaiting_approval` rightly outranks `idle` for display, so a turn-over
1136
- * signal arriving under a standing approval cannot be applied when it lands.
1137
- * It used to be **discarded** for that reason, which is a different thing
1138
- * from outranked: the settle path then asserted `running` on the assumption
1139
- * that an answered approval means work resumes, and when the turn was already
1140
- * over — an interrupt, a timeout — the session claimed to be running one that
1141
- * had produced its result. Status is purely edge-driven here, with no poll and
1142
- * no reconciliation anywhere, so that single dropped edge never came back and
1143
- * every client rendered it faithfully for the life of the session.
1144
- *
1145
- * So the fact is *deferred* rather than dropped, and it is deliberately
1146
- * cleared the moment work genuinely resumes — a turn-over belongs to the turn
1147
- * that produced it and must not settle the next one.
1148
- */
1149
836
  #turnOverWhileBlocked = false;
1150
- /** The read-time sub-agent rollup (`SessionInfo.subagents`), fed from #emit —
1151
- * the one chokepoint — so the resume backfill reconstructs it for free. */
1152
837
  #subagents = new SubagentTracker();
1153
838
  #totalCostUsd;
1154
839
  #numTurns;
1155
- #lastActivityAt;
1156
840
  #input = new InputQueue();
1157
841
  #query;
1158
842
  #capabilitiesEmitted = false;
1159
- /** Last plan reported by the usage poll, so `plan_info` is emitted on change
1160
- * rather than once per turn. */
1161
843
  #subscriptionType;
1162
- /** The title the CLI gave this thread (see `#fetchEngineTitle`). Undefined
1163
- * until it has one — a session gets its summary a turn or two in. */
1164
844
  #engineTitle;
1165
845
  #started = false;
1166
846
  #closed = false;
@@ -1180,9 +860,8 @@ var SessionRunner = class {
1180
860
  return this.#sdkSessionId;
1181
861
  }
1182
862
  get lastSeq() {
1183
- return this.#seq;
863
+ return this.#log.seq;
1184
864
  }
1185
- /** 'oauth' = claude.ai subscription credentials; other values are API-key provenance. */
1186
865
  get apiKeySource() {
1187
866
  return this.#apiKeySource;
1188
867
  }
@@ -1203,49 +882,23 @@ var SessionRunner = class {
1203
882
  canBypassPermissions: this.#config.permissionMode === "bypassPermissions" || this.#config.allowDangerouslySkipPermissions === true,
1204
883
  apiKeySource: this.#apiKeySource,
1205
884
  createdAt: this.createdAt,
1206
- lastSeq: this.#seq,
1207
- activityCount: this.#activityCount,
1208
- contextUsage: this.#contextUsage,
885
+ lastSeq: this.#log.seq,
886
+ activityCount: this.#log.activityCount,
887
+ proseCount: this.#log.proseCount,
888
+ contextUsage: this.#log.contextUsage,
1209
889
  pendingPermissionCount: this.#pending.size,
1210
890
  subagents: this.#subagents.list(),
1211
891
  meta: this.#config.meta,
1212
892
  scope: this.#config.scope,
1213
- title: this.#title(),
893
+ title: sessionTitle(this.#config, this.#engineTitle),
1214
894
  totalCostUsd: this.#totalCostUsd,
1215
895
  numTurns: this.#numTurns,
1216
- lastActivityAt: this.#lastActivityAt
896
+ lastActivityAt: this.#log.lastActivityAt
1217
897
  };
1218
898
  }
1219
- /**
1220
- * Three sources, most-deliberate first: the host's own rename (`meta.title`),
1221
- * the title the CLI gave this thread (`#engineTitle`), then the first prompt
1222
- * truncated.
1223
- *
1224
- * The rename outranks everything by design — a person naming a session must
1225
- * not have it renamed under them by a model — which is also why the engine
1226
- * title is *only ever read* while `meta.title` is unset (see
1227
- * `#fetchEngineTitle`), rather than read and then discarded here.
1228
- */
1229
- #title() {
1230
- const metaTitle = this.#config.meta?.title;
1231
- if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
1232
- if (this.#engineTitle) return this.#engineTitle;
1233
- const prompt = this.#config.prompt;
1234
- if (!prompt) return void 0;
1235
- return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
1236
- }
1237
- /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
1238
- * it (undefined) restores the derived title. The engine is never told. */
1239
899
  setTitle(title) {
1240
- const meta = { ...this.#config.meta };
1241
- if (title) meta.title = title;
1242
- else delete meta.title;
1243
- this.#config = {
1244
- ...this.#config,
1245
- meta
1246
- };
900
+ this.#config = withTitle(this.#config, title);
1247
901
  }
1248
- /** Begin the session. Idempotent; returns the run promise (resolves when the query ends). */
1249
902
  start() {
1250
903
  if (this.#started) return this.#runPromise;
1251
904
  this.#started = true;
@@ -1253,11 +906,6 @@ var SessionRunner = class {
1253
906
  this.#runPromise = this.#run();
1254
907
  return this.#runPromise;
1255
908
  }
1256
- /** Queue a user message for the session (starts the next turn when idle).
1257
- *
1258
- * `attachments` carry their own bytes; they reach the CLI as content blocks and
1259
- * are logged as references. A message may be attachments alone — an empty text
1260
- * block is not valid API input, so the text is only added when there is some. */
1261
909
  sendMessage(text, attachments) {
1262
910
  if (this.#closed) throw new Error("session is closed");
1263
911
  const blocks = attachments?.length ? attachmentContentBlocks(attachments) : [];
@@ -1285,12 +933,9 @@ var SessionRunner = class {
1285
933
  uuid: randomUUID()
1286
934
  });
1287
935
  }
1288
- /** Live MCP server status, straight from the CLI. Undefined when the engine
1289
- * can't answer (an injected fake query in tests) — the caller 501s rather than
1290
- * pretending the session has no servers. */
1291
936
  async mcpServers() {
1292
937
  const query = this.#query;
1293
- if (typeof query?.mcpServerStatus !== "function") return void 0;
938
+ if (typeof query?.mcpServerStatus !== "function") return;
1294
939
  return (await query.mcpServerStatus()).map(mcpStatusInfo);
1295
940
  }
1296
941
  async reconnectMcpServer(name) {
@@ -1303,7 +948,6 @@ var SessionRunner = class {
1303
948
  if (typeof query?.toggleMcpServer !== "function") throw new Error("this session cannot enable or disable MCP servers");
1304
949
  await query.toggleMcpServer(name, enabled);
1305
950
  }
1306
- /** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
1307
951
  resolvePermission(requestId, decision) {
1308
952
  const pending = this.#pending.get(requestId);
1309
953
  if (!pending) return false;
@@ -1313,24 +957,6 @@ var SessionRunner = class {
1313
957
  async interrupt() {
1314
958
  await this.#query?.interrupt();
1315
959
  }
1316
- /**
1317
- * Reset the conversation by sending the `/clear` the CLI already honors.
1318
- *
1319
- * Deliberately not a second mechanism: this engine's reset arrives *from the
1320
- * SDK*, and `normalizeSdkMessage` turns the CLI's report of it into the
1321
- * `conversation_reset` event (adopting the new conversation id and re-polling
1322
- * context usage on the way through). Reimplementing the clear here would give
1323
- * one engine two ways to reach the same state, and only one of them would get
1324
- * the id adoption right. So the command and the composer's `/clear` are one
1325
- * behaviour, and this method is the thin end of it.
1326
- *
1327
- * The one place it differs from the other two engines: this resolves when the
1328
- * `/clear` has been **handed to the CLI**, not when the reset has happened —
1329
- * the CLI queues its own streamed input, so waiting is its job, and there is
1330
- * no chain here to ride. The observable contract is the same (a clear sent
1331
- * mid-turn queues rather than cutting the turn short); only the moment the
1332
- * promise settles is weaker, and no caller depends on it.
1333
- */
1334
960
  async clearContext() {
1335
961
  if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
1336
962
  this.sendMessage("/clear");
@@ -1343,7 +969,6 @@ var SessionRunner = class {
1343
969
  mode
1344
970
  });
1345
971
  }
1346
- /** Switch the model for subsequent responses; undefined = back to the default. */
1347
972
  async setModel(model) {
1348
973
  await this.#query?.setModel(model);
1349
974
  this.#model = model;
@@ -1352,7 +977,6 @@ var SessionRunner = class {
1352
977
  model
1353
978
  });
1354
979
  }
1355
- /** Emit a session_error and terminate. For host-enforced policy (e.g. requireApiKey). */
1356
980
  fail(message) {
1357
981
  if (this.#closed) return;
1358
982
  this.#emit({
@@ -1362,7 +986,6 @@ var SessionRunner = class {
1362
986
  this.#setStatus("failed");
1363
987
  this.close("error");
1364
988
  }
1365
- /** Terminate the session and the underlying CLI subprocess. */
1366
989
  close(reason = "client") {
1367
990
  if (this.#closed) return;
1368
991
  this.#closed = true;
@@ -1378,27 +1001,11 @@ var SessionRunner = class {
1378
1001
  });
1379
1002
  this.#setStatus("closed");
1380
1003
  }
1381
- /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
1382
- * "show everything" on one row, so a per-runner seq index would be a map
1383
- * maintained on every emit to save a walk nobody makes twice a minute. */
1384
1004
  eventAt(seq) {
1385
- return this.#events.find((event) => event.seq === seq);
1005
+ return this.#log.at(seq);
1386
1006
  }
1387
- /**
1388
- * Replay buffered events with seq > afterSeq, then deliver live events.
1389
- * Returns an unsubscribe function.
1390
- *
1391
- * Replay honours the reset watermark: transcript content below the latest
1392
- * `conversation_reset` is skipped (the reducer would clear it again anyway,
1393
- * and a pre-reset client that never learned the reducer's case would render
1394
- * a conversation the engine has discarded), while state-bearing events —
1395
- * which are emitted once and never again — always replay. The reset event
1396
- * itself replays (the skip is strictly-below), which is what clears a
1397
- * reconnecting client still holding pre-reset rows; superseded resets are
1398
- * content below the newer one and are skipped with what they cleared.
1399
- */
1400
1007
  subscribe(listener, afterSeq = 0, options) {
1401
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
1008
+ return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
1402
1009
  }
1403
1010
  async #run() {
1404
1011
  const queryFn = this.#config.queryFn ?? query;
@@ -1436,12 +1043,6 @@ var SessionRunner = class {
1436
1043
  }
1437
1044
  }
1438
1045
  }
1439
- /**
1440
- * On resume, emit the prior session's transcript as replay events (seq'd before any
1441
- * live event). The SDK only re-streams *user* messages on resume; assistant history
1442
- * would otherwise be lost to clients attaching after a server restart. Duplicated
1443
- * user messages are deduped client-side by uuid.
1444
- */
1445
1046
  async #backfillHistory() {
1446
1047
  const c = this.#config;
1447
1048
  if (!c.resume || c.backfillHistory === false) return;
@@ -1552,11 +1153,6 @@ var SessionRunner = class {
1552
1153
  }
1553
1154
  }
1554
1155
  }
1555
- /** Ask the CLI what models/commands it supports and surface them as an event
1556
- * (replayed to late attachers). Called eagerly for promptless sessions and again
1557
- * on init — the flag keeps it a single emit. Optional-chained: injected fake
1558
- * queries in tests may not implement these, and a failure must not affect the
1559
- * session. */
1560
1156
  async #fetchCapabilities() {
1561
1157
  if (this.#capabilitiesEmitted) return;
1562
1158
  const query = this.#query;
@@ -1578,33 +1174,8 @@ var SessionRunner = class {
1578
1174
  });
1579
1175
  } catch {}
1580
1176
  }
1581
- /**
1582
- * Adopt the title the CLI gave this thread — the "friendly title" it writes a
1583
- * turn or two into a session, and the name a resumed thread already carries.
1584
- *
1585
- * A **poll, not an observation**, and unavoidably so: no member of the SDK's
1586
- * `SDKMessage` union carries it (the whole union was checked). It lives on
1587
- * `SDKSessionInfo`, which only `getSessionInfo` / `listSessions` return — the
1588
- * same record `GET /sdk-sessions` already serves as `SdkSessionSummary`. So it
1589
- * is read at init and after each turn, which is also roughly the rate at which
1590
- * it changes.
1591
- *
1592
- * Two rules:
1593
- * - **Never while `meta.title` is set.** A rename is a person's decision and a
1594
- * generated summary must not overwrite it. Not read at all in that case, so
1595
- * there is no stored value waiting to resurface if the rename is cleared —
1596
- * the next turn simply fetches it again.
1597
- * - `summary` falls back to the first prompt when the session has no real
1598
- * title yet, so it is taken only when it *differs* from `firstPrompt`.
1599
- * Otherwise `#title()`'s own prompt fallback covers it, and the two would
1600
- * disagree only in how they truncate.
1601
- *
1602
- * Best-effort throughout: an unreadable transcript, a session file that is not
1603
- * there yet, an SDK without the function — all leave the title as it was.
1604
- */
1605
1177
  async #fetchEngineTitle() {
1606
- const metaTitle = this.#config.meta?.title;
1607
- if (typeof metaTitle === "string" && metaTitle.length > 0) return;
1178
+ if (hostTitle(this.#config.meta)) return;
1608
1179
  const sdkSessionId = this.#sdkSessionId;
1609
1180
  if (!sdkSessionId) return;
1610
1181
  const read = this.#config.sessionInfoFn ?? getSessionInfo;
@@ -1616,8 +1187,6 @@ var SessionRunner = class {
1616
1187
  if (title) this.#engineTitle = title;
1617
1188
  } catch {}
1618
1189
  }
1619
- /** Snapshot the context window after a turn and surface it as an event. Optional-chained
1620
- * and best-effort for the same reasons as #fetchCapabilities. */
1621
1190
  async #fetchContextUsage() {
1622
1191
  const query = this.#query;
1623
1192
  if (typeof query?.getContextUsage !== "function") return;
@@ -1640,17 +1209,6 @@ var SessionRunner = class {
1640
1209
  });
1641
1210
  } catch {}
1642
1211
  }
1643
- /**
1644
- * Snapshot the plan's rate-limit windows and surface them as `rate_limit`
1645
- * events — the same event a live `rate_limit_event` produces, so clients need
1646
- * nothing new to render it.
1647
- *
1648
- * The CLI only *pushes* a window when it changes, which for a session being
1649
- * watched rather than driven can be never; polling is what makes usage show up
1650
- * at all. The control request is marked experimental in the SDK, name included,
1651
- * so it is probed for by name and every failure is silent — one more reason
1652
- * this can only ever be decoration.
1653
- */
1654
1212
  async #fetchRateLimits() {
1655
1213
  const query = this.#query;
1656
1214
  const fetchUsage = query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
@@ -1716,10 +1274,6 @@ var SessionRunner = class {
1716
1274
  this.#setStatus("awaiting_approval");
1717
1275
  });
1718
1276
  };
1719
- /** 'auto'/'deny' sessions settle AskUserQuestion synchronously instead of pending:
1720
- * 'auto' picks each question's first (recommended) option, 'deny' sends the model
1721
- * back to decide for itself. Request/resolved events still fire so transcripts and
1722
- * job webhooks show what was chosen. */
1723
1277
  #resolveQuestionByPolicy(request, mode) {
1724
1278
  this.#emit({
1725
1279
  type: "permission_requested",
@@ -1795,26 +1349,11 @@ var SessionRunner = class {
1795
1349
  });
1796
1350
  }
1797
1351
  #emit(body) {
1798
- const event = {
1799
- ...body,
1800
- seq: ++this.#seq,
1801
- ts: Date.now()
1802
- };
1803
- this.#lastActivityAt = event.ts;
1804
- this.#activityCount += transcriptActivity(body);
1805
- this.#contextUsage = contextReading(body) ?? this.#contextUsage;
1806
- if (body.type === "conversation_reset") {
1807
- this.#resetSeq = event.seq;
1808
- this.#contextUsage = void 0;
1809
- }
1352
+ const event = this.#log.append(body);
1810
1353
  this.#subagents.observe(body, event.ts);
1811
- this.#events.push(event);
1812
1354
  this.#subscribers.emit(event);
1813
1355
  }
1814
1356
  };
1815
- /** Answer each AskUserQuestion question with its first option's label — the tool's
1816
- * convention puts the recommended choice first. Keyed by question text, the shape the
1817
- * CLI expects back in `updatedInput.answers`. */
1818
1357
  function recommendedAnswers(input) {
1819
1358
  const answers = {};
1820
1359
  const questions = Array.isArray(input.questions) ? input.questions : [];
@@ -1828,60 +1367,26 @@ function recommendedAnswers(input) {
1828
1367
  }
1829
1368
  //#endregion
1830
1369
  //#region src/engines/provider/runner.ts
1831
- /** Permission modes this engine can honor. The rest of the protocol vocabulary
1832
- * (acceptEdits/plan/auto) is Claude Code CLI semantics with no meaning here —
1833
- * setPermissionMode rejects them, which the server surfaces as protocol_error. */
1834
1370
  const SUPPORTED_PERMISSION_MODES = [
1835
1371
  "default",
1836
1372
  "bypassPermissions",
1837
1373
  "dontAsk"
1838
1374
  ];
1839
- /**
1840
- * Model-agnostic runner over the AI SDK v7 ToolLoopAgent. The session's durable
1841
- * state is its ModelMessage history: every turn — including continuation after an
1842
- * externally-executed tool call — is a fresh streamed call over that history
1843
- * (message-state replay; the loop cannot be suspended). Output is emitted as it
1844
- * happens: `stream_delta` per token (unless includePartialMessages is false) and
1845
- * assistant/tool messages per step. Emits the same seq-numbered SessionEvent log
1846
- * as SessionRunner; engine-specific CLI telemetry (system_init, capabilities,
1847
- * rate_limit, ...) is simply never emitted.
1848
- */
1849
1375
  var AiSdkRunner = class {
1850
1376
  id;
1851
1377
  createdAt;
1852
1378
  #config;
1853
1379
  #model;
1854
- #events = [];
1380
+ #log = new EventLog();
1855
1381
  #subscribers = new SubscriberSet();
1856
- #seq = 0;
1857
- /**
1858
- * Latest context-window reading, retained from the last `context_usage` this
1859
- * runner emitted so `GET /sessions` can answer it without an attach — see
1860
- * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
1861
- * construction the same number the transcript last drew.
1862
- */
1863
- #contextUsage;
1864
- #activityCount = 0;
1865
- /**
1866
- * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
1867
- * never truncated — it still carries the state-bearing events (`capabilities`,
1868
- * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
1869
- * but `subscribe()` skips transcript *content* strictly below this mark, so a
1870
- * replay does not resurrect a cleared conversation. A later reset supersedes
1871
- * an earlier one by overwriting it.
1872
- */
1873
- #resetSeq = 0;
1874
1382
  #status = "starting";
1383
+ #statusDetail;
1875
1384
  #permissionMode;
1876
1385
  #messages = [];
1877
1386
  #pendingToolCalls = /* @__PURE__ */ new Map();
1878
- /** Calls already handed to the executor, so a re-park never double-dispatches. */
1879
1387
  #dispatched = /* @__PURE__ */ new Set();
1880
1388
  #turnChain = Promise.resolve();
1881
1389
  #abort;
1882
- /** Accumulates across every leg of one turn. A turn that parks on external
1883
- * tool calls spans several generate() calls; usage and elapsed time must
1884
- * cover all of them, not just the leg that happens to finish. */
1885
1390
  #turnAccum;
1886
1391
  #numTurns = 0;
1887
1392
  #totalUsage = {
@@ -1890,15 +1395,11 @@ var AiSdkRunner = class {
1890
1395
  cacheWrite: 0,
1891
1396
  cacheRead: 0
1892
1397
  };
1893
- #lastActivityAt;
1894
1398
  #started = false;
1895
1399
  #closed = false;
1896
- /** Parked: state has been snapshotted and this instance is inert. Not closed —
1897
- * the session lives on in the snapshot and resumes as a new instance. */
1898
1400
  #parked = false;
1899
- /** Model alias as requested (not the resolved provider id) — what set_model was
1900
- * given, so a rehydrated session can re-resolve the same choice. */
1901
1401
  #modelAlias;
1402
+ #pendingApprovals = /* @__PURE__ */ new Map();
1902
1403
  constructor(config, id = randomUUID()) {
1903
1404
  const mode = config.permissionMode ?? "default";
1904
1405
  if (!SUPPORTED_PERMISSION_MODES.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the AI SDK engine`);
@@ -1910,23 +1411,11 @@ var AiSdkRunner = class {
1910
1411
  this.createdAt = config.restore?.createdAt ?? Date.now();
1911
1412
  if (config.restore) this.#restore(config.restore);
1912
1413
  }
1913
- /** Adopt a parked session's state. The event log and seq counter come back
1914
- * verbatim: a client reattaching with `afterSeq` must see one unbroken stream
1915
- * across the teardown, not a second session that restarts at 1. */
1916
1414
  #restore(snapshot) {
1917
1415
  if (snapshot.engine !== "provider") throw new Error(`cannot restore a '${snapshot.engine}' snapshot into the AI SDK engine`);
1918
1416
  const state = snapshot.state;
1919
1417
  if (!state || !Array.isArray(state.messages)) throw new Error("session snapshot is missing its provider-engine state");
1920
- this.#seq = snapshot.seq;
1921
- this.#events = [...snapshot.events];
1922
- this.#activityCount = 0;
1923
- for (const event of this.#events) {
1924
- this.#activityCount += transcriptActivity(event);
1925
- if (event.type === "conversation_reset") {
1926
- this.#resetSeq = event.seq;
1927
- this.#contextUsage = void 0;
1928
- } else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1929
- }
1418
+ this.#log.restore(snapshot.events, snapshot.seq, state.lastActivityAt);
1930
1419
  this.#messages = [...state.messages];
1931
1420
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
1932
1421
  this.#dispatched = new Set(state.dispatched);
@@ -1935,7 +1424,6 @@ var AiSdkRunner = class {
1935
1424
  this.#turnAccum = state.turnAccum ? { ...state.turnAccum } : void 0;
1936
1425
  if (this.#turnAccum && state.parkedAt !== void 0) this.#turnAccum.startedAt += Date.now() - state.parkedAt;
1937
1426
  this.#permissionMode = state.permissionMode;
1938
- this.#lastActivityAt = state.lastActivityAt;
1939
1427
  this.#status = this.#pendingToolCalls.size > 0 ? "parked" : "idle";
1940
1428
  if (state.model !== void 0 && state.model !== this.#modelAlias && this.#config.resolveModel) {
1941
1429
  this.#modelAlias = state.model;
@@ -1946,21 +1434,17 @@ var AiSdkRunner = class {
1946
1434
  return this.#status;
1947
1435
  }
1948
1436
  get lastSeq() {
1949
- return this.#seq;
1437
+ return this.#log.seq;
1950
1438
  }
1951
- /** The session's durable state — persist to park, replay to rehydrate. */
1952
1439
  get messages() {
1953
1440
  return [...this.#messages];
1954
1441
  }
1955
- /** External tool calls the loop is currently parked on. */
1956
1442
  get pendingToolCalls() {
1957
1443
  return [...this.#pendingToolCalls.values()];
1958
1444
  }
1959
1445
  get pendingApprovals() {
1960
- return [];
1446
+ return [...this.#pendingApprovals.values()].map((a) => a.request);
1961
1447
  }
1962
- /** The session's scratch filesystem (see Runner.vfs) — the server's file
1963
- * routes serve deliverables straight from it. */
1964
1448
  get vfs() {
1965
1449
  return this.#config.vfs;
1966
1450
  }
@@ -1971,19 +1455,23 @@ var AiSdkRunner = class {
1971
1455
  cwd: this.#config.cwd ?? "",
1972
1456
  profile: this.#config.profile,
1973
1457
  engine: "provider",
1974
- capabilities: ENGINE_CAPABILITIES.provider,
1458
+ capabilities: this.#config.shouldApprove ? {
1459
+ ...ENGINE_CAPABILITIES.provider,
1460
+ interactiveApprovals: true
1461
+ } : ENGINE_CAPABILITIES.provider,
1975
1462
  model: this.#modelId(),
1976
1463
  permissionMode: this.#permissionMode,
1977
1464
  createdAt: this.createdAt,
1978
- lastSeq: this.#seq,
1979
- activityCount: this.#activityCount,
1980
- contextUsage: this.#contextUsage,
1981
- pendingPermissionCount: 0,
1465
+ lastSeq: this.#log.seq,
1466
+ activityCount: this.#log.activityCount,
1467
+ proseCount: this.#log.proseCount,
1468
+ contextUsage: this.#log.contextUsage,
1469
+ pendingPermissionCount: this.#pendingApprovals.size,
1982
1470
  meta: this.#config.meta,
1983
1471
  scope: this.#config.scope,
1984
- title: this.#title(),
1472
+ title: sessionTitle(this.#config),
1985
1473
  numTurns: this.#numTurns || void 0,
1986
- lastActivityAt: this.#lastActivityAt
1474
+ lastActivityAt: this.#log.lastActivityAt
1987
1475
  };
1988
1476
  }
1989
1477
  start() {
@@ -1994,15 +1482,9 @@ var AiSdkRunner = class {
1994
1482
  if (this.#config.prompt) this.sendMessage(this.#config.prompt);
1995
1483
  return this.#turnChain;
1996
1484
  }
1997
- /**
1998
- * Snapshot durable state, release engine resources, and go inert — the session
1999
- * continues in the snapshot, not in this object. Returns undefined when parking
2000
- * would lose work or has nothing to wait for: a turn in flight, no parked call,
2001
- * or an already-closed/parked runner.
2002
- */
2003
1485
  park() {
2004
- if (this.#closed || this.#parked) return void 0;
2005
- if (this.#abort || !this.#restingOnDeferred()) return void 0;
1486
+ if (this.#closed || this.#parked) return;
1487
+ if (this.#abort || !this.#restingOnDeferred()) return;
2006
1488
  this.#setStatus("parked");
2007
1489
  const snapshot = this.#buildSnapshot();
2008
1490
  this.#parked = true;
@@ -2012,52 +1494,11 @@ var AiSdkRunner = class {
2012
1494
  } catch {}
2013
1495
  return snapshot;
2014
1496
  }
2015
- /**
2016
- * The same snapshot, taken without ending anything.
2017
- *
2018
- * `park()` and this are two operations that happen to produce the same value,
2019
- * and the difference is the whole point: `park()` *ends* the live runner
2020
- * (inert, listeners dropped, `onClose` called), which is right for deferred
2021
- * execution — the session has nothing to do for possibly days — and wrong for
2022
- * restart-survival, where the session is active and someone is mid-
2023
- * conversation. This one changes nothing at all: no status emit, no listener
2024
- * clear, no disposer. The host writes the value through to durable storage
2025
- * after each turn and keeps the runner live and warm, so a restart rebuilds
2026
- * from the last write through the existing `restore` path and the next message
2027
- * costs no wake.
2028
- *
2029
- * The gate is `park()`'s minus the requirement that there be something parked:
2030
- *
2031
- * - `#abort` set is refused for the reason it always was — a `generate()` in
2032
- * flight has produced messages that are not in the history yet, so the
2033
- * snapshot would be of a turn that half-happened.
2034
- * - Pending calls that are **not** all deferred are refused, which is
2035
- * `park()`'s rule wearing a different hat. An in-process execution's result
2036
- * is coming back to *this* runner and dies with the process; a restore would
2037
- * wait on it forever, and `state.dispatched` is what would stop the rebuilt
2038
- * runner from simply calling it again.
2039
- * - Idle with nothing pending — the case `park()` exists to refuse — is
2040
- * exactly the case this exists to allow.
2041
- */
2042
1497
  snapshot() {
2043
- if (this.#closed || this.#parked || this.#abort) return void 0;
2044
- if (this.#pendingToolCalls.size > 0 && !this.#restingOnDeferred()) return void 0;
1498
+ if (this.#closed || this.#parked || this.#abort) return;
1499
+ if (this.#pendingToolCalls.size > 0 && !this.#restingOnDeferred()) return;
2045
1500
  return this.#buildSnapshot();
2046
1501
  }
2047
- /**
2048
- * The snapshot value itself, shared so a park and a write-through cannot
2049
- * disagree about what a session *is*.
2050
- *
2051
- * The event log is filtered through {@link snapshotRetains} — the persisted
2052
- * log drops stream deltas, which are superseded by the `assistant_message`
2053
- * that flushes them and would otherwise be tens of times the size of the text
2054
- * they spell. Parks get it too, and should: a park sits on disk for days.
2055
- *
2056
- * The `parked` list and `state.parkedAt` are honest under both callers. An
2057
- * idle write-through has no pending calls, so `parked` is empty and the host
2058
- * arms no watchdogs; `parkedAt` is "when this was taken", which is what
2059
- * `#restore` needs to discount a turn's clock either way.
2060
- */
2061
1502
  #buildSnapshot() {
2062
1503
  const parked = [...this.#pendingToolCalls.values()].map((call) => ({
2063
1504
  executionId: call.toolCallId,
@@ -2073,15 +1514,15 @@ var AiSdkRunner = class {
2073
1514
  turnAccum: this.#turnAccum ? { ...this.#turnAccum } : void 0,
2074
1515
  permissionMode: this.#permissionMode,
2075
1516
  model: this.#modelAlias,
2076
- lastActivityAt: this.#lastActivityAt,
1517
+ lastActivityAt: this.#log.lastActivityAt,
2077
1518
  parkedAt: Date.now()
2078
1519
  };
2079
1520
  return {
2080
1521
  engine: "provider",
2081
1522
  id: this.id,
2082
1523
  createdAt: this.createdAt,
2083
- seq: this.#seq,
2084
- events: this.#events.filter((event) => snapshotRetains(event)),
1524
+ seq: this.#log.seq,
1525
+ events: this.#log.events.filter((event) => snapshotRetains(event)),
2085
1526
  vfs: this.#config.vfs?.snapshot(),
2086
1527
  parked,
2087
1528
  state
@@ -2115,19 +1556,11 @@ var AiSdkRunner = class {
2115
1556
  });
2116
1557
  this.#scheduleTurn();
2117
1558
  }
2118
- /**
2119
- * Deliver the result of an external (execute-less) tool call. Appends the
2120
- * tool-result message and, once no calls remain pending, re-enters the loop.
2121
- * Idempotent per toolCallId: unknown/already-settled ids return false.
2122
- */
2123
1559
  resolveToolCall(toolCallId, output, options) {
2124
1560
  if (!this.#settlePendingCall(toolCallId, output, options?.isError === true)) return false;
2125
1561
  if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
2126
1562
  return true;
2127
1563
  }
2128
- /** Record a parked call's outcome into the message history (so it stays
2129
- * replayable — a dangling tool call without a result is invalid input for
2130
- * providers) and the event log. Does NOT re-enter the loop. */
2131
1564
  #settlePendingCall(toolCallId, output, isError) {
2132
1565
  const pending = this.#pendingToolCalls.get(toolCallId);
2133
1566
  if (!pending || this.#closed || this.#parked) return false;
@@ -2163,11 +1596,38 @@ var AiSdkRunner = class {
2163
1596
  });
2164
1597
  return true;
2165
1598
  }
2166
- resolvePermission(_requestId, _decision) {
2167
- return false;
1599
+ resolvePermission(requestId, decision) {
1600
+ const approval = this.#pendingApprovals.get(requestId);
1601
+ if (!approval) return false;
1602
+ clearTimeout(approval.timer);
1603
+ this.#pendingApprovals.delete(requestId);
1604
+ const source = "client";
1605
+ if (decision.behavior === "allow") {
1606
+ this.#emit({
1607
+ type: "permission_resolved",
1608
+ requestId,
1609
+ behavior: "allow",
1610
+ resolvedBy: source
1611
+ });
1612
+ this.#dispatchSingle(approval.toolCallId);
1613
+ } else {
1614
+ const message = decision.message ?? "Permission denied by user";
1615
+ this.#emit({
1616
+ type: "permission_resolved",
1617
+ requestId,
1618
+ behavior: "deny",
1619
+ resolvedBy: source,
1620
+ message
1621
+ });
1622
+ this.#applyExecutionResult(approval.toolCallId, {
1623
+ status: "failed",
1624
+ reason: "permission_denied",
1625
+ error: message
1626
+ });
1627
+ if (decision.interrupt) this.interrupt();
1628
+ }
1629
+ return true;
2168
1630
  }
2169
- /** Emit `file_delivered` — the deliver_file tool's hand-over event (wired by
2170
- * createEngineSession via ToolContextOptions.onFileDelivered). */
2171
1631
  emitFileDelivered(file) {
2172
1632
  if (this.#closed || this.#parked) return;
2173
1633
  this.#emit({
@@ -2175,11 +1635,6 @@ var AiSdkRunner = class {
2175
1635
  ...file
2176
1636
  });
2177
1637
  }
2178
- /**
2179
- * One plain generateText over the session's current model, billed into the
2180
- * running turn's usage accumulator — the web_fetch digest pass uses this so
2181
- * its tokens are never lost from the turn's accounting.
2182
- */
2183
1638
  async generateDigest(prompt) {
2184
1639
  const result = await generateText({
2185
1640
  model: this.#model,
@@ -2195,19 +1650,6 @@ var AiSdkRunner = class {
2195
1650
  }
2196
1651
  return result.text;
2197
1652
  }
2198
- /**
2199
- * Reset the conversation: drop the message array the next turn would have
2200
- * been built from. There is no engine round trip — this runner *is* where the
2201
- * transcript lives, so clearing it is the whole operation.
2202
- *
2203
- * Two things ride along, both already written elsewhere and both load-bearing
2204
- * here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
2205
- * reading described a conversation that no longer exists), and the same arm
2206
- * in `restore` keeps a parked session that comes back after a clear from
2207
- * resurrecting it. Pending tool calls are NOT swept: a parked call is work a
2208
- * backend still owes an answer for, and a clear is not an interrupt — the
2209
- * refusal below is what keeps the two apart.
2210
- */
2211
1653
  async clearContext() {
2212
1654
  if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
2213
1655
  const run = this.#turnChain.then(() => {
@@ -2283,6 +1725,8 @@ var AiSdkRunner = class {
2283
1725
  this.#abort?.abort();
2284
1726
  this.#pendingToolCalls.clear();
2285
1727
  this.#dispatched.clear();
1728
+ for (const { timer } of this.#pendingApprovals.values()) clearTimeout(timer);
1729
+ this.#pendingApprovals.clear();
2286
1730
  this.#emit({
2287
1731
  type: "session_closed",
2288
1732
  reason
@@ -2292,92 +1736,131 @@ var AiSdkRunner = class {
2292
1736
  Promise.resolve(this.#config.onClose?.()).catch(() => {});
2293
1737
  } catch {}
2294
1738
  }
2295
- /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
2296
- * "show everything" on one row, so a per-runner seq index would be a map
2297
- * maintained on every emit to save a walk nobody makes twice a minute. */
2298
1739
  eventAt(seq) {
2299
- return this.#events.find((event) => event.seq === seq);
1740
+ return this.#log.at(seq);
2300
1741
  }
2301
1742
  subscribe(listener, afterSeq = 0, options) {
2302
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
1743
+ return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
2303
1744
  }
2304
1745
  #scheduleTurn() {
2305
1746
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
2306
1747
  }
2307
- /**
2308
- * Deliver the result of an execution this runner dispatched. Used by the host
2309
- * when a backend settled out-of-band (a browser bridge answering later, a
2310
- * deferred executor). Idempotent by executionId.
2311
- */
2312
1748
  settleExecution(executionId, result) {
2313
1749
  if (this.#closed || this.#parked) return false;
2314
1750
  if (!this.#pendingToolCalls.has(executionId)) return false;
2315
1751
  this.#applyExecutionResult(executionId, result);
2316
1752
  return true;
2317
1753
  }
2318
- /** Hand every parked call the executor owns to it. */
2319
1754
  #dispatchPending() {
2320
1755
  const executor = this.#config.executor;
2321
1756
  if (!executor) return;
2322
1757
  const executable = this.#config.executableTools;
1758
+ const needsApproval = this.#permissionMode === "default" && this.#config.shouldApprove;
2323
1759
  const inFlight = [];
2324
1760
  let anyDeferred = false;
1761
+ let anyAwaiting = false;
2325
1762
  for (const call of Array.from(this.#pendingToolCalls.values())) {
2326
1763
  if (executable && !executable.includes(call.toolName)) continue;
2327
1764
  if (this.#dispatched.has(call.toolCallId)) continue;
2328
- this.#dispatched.add(call.toolCallId);
2329
- const toolCall = {
2330
- executionId: call.toolCallId,
2331
- sessionId: this.id,
2332
- tool: call.toolName,
2333
- input: call.input,
2334
- vfs: this.#config.vfs,
2335
- limits: this.#config.executionLimits,
2336
- signal: this.#abort?.signal
2337
- };
2338
- const profile = executor.describe?.(toolCall) ?? {};
2339
- call.deferred = profile.deferred === true ? true : void 0;
2340
- call.expiresAt = profile.timeoutMs === void 0 ? void 0 : Date.now() + profile.timeoutMs;
2341
- anyDeferred ||= call.deferred === true;
2342
- this.#emit({
2343
- type: "execution_dispatched",
2344
- executionId: call.toolCallId,
1765
+ if (needsApproval && needsApproval({
2345
1766
  toolName: call.toolName,
2346
- backend: profile.backend ?? this.#config.executionBackend ?? "server",
2347
- deferred: call.deferred,
2348
- expiresAt: call.expiresAt
2349
- });
2350
- inFlight.push(executor.dispatch(toolCall).then((dispatch) => {
2351
- if (dispatch.status === "settled") this.#applyExecutionResult(call.toolCallId, dispatch.result);
2352
- }).catch((error) => {
2353
- this.#applyExecutionResult(call.toolCallId, {
2354
- status: "failed",
2355
- reason: "dispatch_error",
2356
- error: error instanceof Error ? error.message : String(error)
1767
+ input: call.input
1768
+ })) {
1769
+ if ([...this.#pendingApprovals.values()].some((a) => a.toolCallId === call.toolCallId)) {
1770
+ anyAwaiting = true;
1771
+ continue;
1772
+ }
1773
+ const requestId = randomUUID();
1774
+ const timeoutMs = this.#config.approvalTimeoutMs ?? 12e4;
1775
+ const request = {
1776
+ id: requestId,
1777
+ toolName: call.toolName,
1778
+ input: call.input,
1779
+ toolUseId: call.toolCallId,
1780
+ title: `Agent wants to run ${call.toolName}`,
1781
+ displayName: call.toolName,
1782
+ expiresAt: Date.now() + timeoutMs
1783
+ };
1784
+ const timer = setTimeout(() => {
1785
+ if (!this.#pendingApprovals.has(requestId)) return;
1786
+ this.resolvePermission(requestId, {
1787
+ behavior: "deny",
1788
+ message: "Approval timed out"
1789
+ });
1790
+ }, timeoutMs);
1791
+ this.#pendingApprovals.set(requestId, {
1792
+ request,
1793
+ toolCallId: call.toolCallId,
1794
+ timer
1795
+ });
1796
+ this.#emit({
1797
+ type: "permission_requested",
1798
+ request
2357
1799
  });
2358
- }));
1800
+ anyAwaiting = true;
1801
+ continue;
1802
+ }
1803
+ this.#dispatched.add(call.toolCallId);
1804
+ const dispatched = this.#dispatchCall(executor, call);
1805
+ anyDeferred ||= dispatched.deferred;
1806
+ inFlight.push(dispatched.promise);
2359
1807
  }
1808
+ if (anyAwaiting) this.#setStatus("awaiting_approval");
2360
1809
  if (anyDeferred) Promise.allSettled(inFlight).then(() => this.#announceParked());
2361
1810
  }
2362
- /**
2363
- * The turn has come to rest on deferred executions: nothing is in flight, and
2364
- * only a host-delivered result can move it. `status_changed: 'parked'` is the
2365
- * host's cue to snapshot via {@link park} — a single, correctly-timed signal
2366
- * rather than an inference from individual dispatch events.
2367
- */
1811
+ #dispatchSingle(toolCallId) {
1812
+ const executor = this.#config.executor;
1813
+ if (!executor) return;
1814
+ const call = this.#pendingToolCalls.get(toolCallId);
1815
+ if (!call || this.#dispatched.has(toolCallId)) return;
1816
+ this.#dispatched.add(toolCallId);
1817
+ const dispatched = this.#dispatchCall(executor, call);
1818
+ if (dispatched.deferred) dispatched.promise.then(() => this.#announceParked());
1819
+ }
1820
+ #dispatchCall(executor, call) {
1821
+ const toolCall = {
1822
+ executionId: call.toolCallId,
1823
+ sessionId: this.id,
1824
+ tool: call.toolName,
1825
+ input: call.input,
1826
+ vfs: this.#config.vfs,
1827
+ limits: this.#config.executionLimits,
1828
+ signal: this.#abort?.signal
1829
+ };
1830
+ const profile = executor.describe?.(toolCall) ?? {};
1831
+ call.deferred = profile.deferred === true ? true : void 0;
1832
+ call.expiresAt = profile.timeoutMs === void 0 ? void 0 : Date.now() + profile.timeoutMs;
1833
+ this.#emit({
1834
+ type: "execution_dispatched",
1835
+ executionId: call.toolCallId,
1836
+ toolName: call.toolName,
1837
+ backend: profile.backend ?? this.#config.executionBackend ?? "server",
1838
+ deferred: call.deferred,
1839
+ expiresAt: call.expiresAt
1840
+ });
1841
+ const promise = executor.dispatch(toolCall).then((dispatch) => {
1842
+ if (dispatch.status === "settled") this.#applyExecutionResult(call.toolCallId, dispatch.result);
1843
+ }).catch((error) => {
1844
+ this.#applyExecutionResult(call.toolCallId, {
1845
+ status: "failed",
1846
+ reason: "dispatch_error",
1847
+ error: error instanceof Error ? error.message : String(error)
1848
+ });
1849
+ });
1850
+ return {
1851
+ deferred: call.deferred === true,
1852
+ promise
1853
+ };
1854
+ }
2368
1855
  #announceParked() {
2369
1856
  if (this.#closed || this.#parked || this.#abort) return;
2370
1857
  if (this.#restingOnDeferred()) this.#setStatus("parked");
2371
1858
  }
2372
- /** The loop is waiting, and everything it waits on can only be answered from
2373
- * outside this process. One still-live in-process execution means a result is
2374
- * coming back to THIS runner, and tearing it down would strand it. */
2375
1859
  #restingOnDeferred() {
2376
1860
  if (this.#pendingToolCalls.size === 0) return false;
2377
1861
  for (const call of this.#pendingToolCalls.values()) if (call.deferred !== true) return false;
2378
1862
  return true;
2379
1863
  }
2380
- /** Fold an execution's outcome back into the loop, whichever way it went. */
2381
1864
  #applyExecutionResult(executionId, result) {
2382
1865
  if (this.#closed || this.#parked) return;
2383
1866
  this.#dispatched.delete(executionId);
@@ -2607,9 +2090,6 @@ var AiSdkRunner = class {
2607
2090
  if (this.#abort === abort) this.#abort = void 0;
2608
2091
  }
2609
2092
  }
2610
- /** Emit the turn's result from the whole-turn accumulator, so a turn that
2611
- * parked on external tool calls reports every leg's tokens and the full
2612
- * elapsed time (including the time spent executing those tools). */
2613
2093
  #finishTurn(text) {
2614
2094
  const accum = this.#turnAccum ?? {
2615
2095
  startedAt: Date.now(),
@@ -2641,39 +2121,17 @@ var AiSdkRunner = class {
2641
2121
  if (typeof model === "string") return model;
2642
2122
  return model.modelId;
2643
2123
  }
2644
- #title() {
2645
- const metaTitle = this.#config.meta?.title;
2646
- if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
2647
- const prompt = this.#config.prompt;
2648
- if (!prompt) return void 0;
2649
- return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
2650
- }
2651
- /**
2652
- * This session's MCP servers, as the host assembled them.
2653
- *
2654
- * Always answers — an empty list when no MCP was wired — because the
2655
- * alternative (undefined, which the server turns into a 501) says "this
2656
- * engine cannot tell you", and this engine can: the host that built the
2657
- * session is the only party who knows, and it has been asked.
2658
- */
2659
2124
  async mcpServers() {
2660
2125
  return await this.#config.reportMcpServers?.() ?? [];
2661
2126
  }
2662
- /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
2663
- * it (undefined) restores the derived title. The engine is never told. */
2664
2127
  setTitle(title) {
2665
- const meta = { ...this.#config.meta };
2666
- if (title) meta.title = title;
2667
- else delete meta.title;
2668
- this.#config = {
2669
- ...this.#config,
2670
- meta
2671
- };
2128
+ this.#config = withTitle(this.#config, title);
2672
2129
  }
2673
2130
  #setStatus(status, detail) {
2674
- if (this.#status === status) return;
2131
+ if (this.#status === status && this.#statusDetail === detail) return;
2675
2132
  if (this.#status === "closed" || this.#status === "failed") return;
2676
2133
  this.#status = status;
2134
+ this.#statusDetail = detail;
2677
2135
  this.#emit({
2678
2136
  type: "status_changed",
2679
2137
  status,
@@ -2681,20 +2139,7 @@ var AiSdkRunner = class {
2681
2139
  });
2682
2140
  }
2683
2141
  #emit(body) {
2684
- const event = {
2685
- ...body,
2686
- seq: ++this.#seq,
2687
- ts: Date.now()
2688
- };
2689
- this.#lastActivityAt = event.ts;
2690
- this.#activityCount += transcriptActivity(body);
2691
- this.#contextUsage = contextReading(body) ?? this.#contextUsage;
2692
- if (body.type === "conversation_reset") {
2693
- this.#resetSeq = event.seq;
2694
- this.#contextUsage = void 0;
2695
- }
2696
- this.#events.push(event);
2697
- this.#subscribers.emit(event);
2142
+ this.#subscribers.emit(this.#log.append(body));
2698
2143
  }
2699
2144
  };
2700
2145
  function turnUsage(accum) {
@@ -2713,16 +2158,6 @@ function errorText(error) {
2713
2158
  }
2714
2159
  //#endregion
2715
2160
  //#region src/engines/claude/auth.ts
2716
- /**
2717
- * The native Claude Code binary the Agent SDK itself spawns, resolved the way
2718
- * the SDK resolves it: the platform-specific optional dependency installed next
2719
- * to the SDK package (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude`).
2720
- * Probing this binary rather than whatever `claude` is on PATH means an auth
2721
- * check answers for the executable sessions will actually run — the two can be
2722
- * different versions logged into different places. Returns undefined when it
2723
- * can't be found (optional dep skipped, unsupported platform); callers degrade
2724
- * to 'unknown', and the SDK surfaces its own error if a session is created.
2725
- */
2726
2161
  function resolveBundledClaudeExecutable() {
2727
2162
  try {
2728
2163
  const fromSdk = createRequire(createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk"));
@@ -2734,18 +2169,6 @@ function resolveBundledClaudeExecutable() {
2734
2169
  } catch {}
2735
2170
  } catch {}
2736
2171
  }
2737
- /**
2738
- * Ask the CLI whether `env` holds usable credentials: `claude auth status`
2739
- * prints a JSON verdict covering every source the CLI itself consults for that
2740
- * environment — `<CLAUDE_CONFIG_DIR>/.credentials.json`, the macOS login
2741
- * Keychain, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, Bedrock/Vertex
2742
- * (verified against 2.1.217). Only the `loggedIn` boolean is ever read; the
2743
- * identity fields in the payload (email, org, subscription) never leave the
2744
- * parse. The exit code is deliberately ignored — 2.1.217 exits 1 on a
2745
- * logged-out verdict where other versions exit 0 — and anything that doesn't
2746
- * parse to a `loggedIn` boolean is 'unknown', because `auth status` is not a
2747
- * stable contract. Never rejects.
2748
- */
2749
2172
  function checkClaudeAuth(env, options = {}) {
2750
2173
  const executable = options.executable ?? resolveBundledClaudeExecutable();
2751
2174
  if (!executable) return Promise.resolve("unknown");
@@ -2767,11 +2190,6 @@ function checkClaudeAuth(env, options = {}) {
2767
2190
  }
2768
2191
  //#endregion
2769
2192
  //#region src/executors/quickjs-executor.ts
2770
- /**
2771
- * In-process execution backend: runs a tool's untrusted script in the QuickJS
2772
- * WASM guest. Always settles inline — nothing downstream assumes that, which is
2773
- * what lets a deferred backend replace it behind the same seam.
2774
- */
2775
2193
  var QuickJsExecutor = class {
2776
2194
  #options;
2777
2195
  constructor(options) {
@@ -2845,8 +2263,6 @@ function safeHost(url) {
2845
2263
  return;
2846
2264
  }
2847
2265
  }
2848
- /** Exact hostname match, or a single leading `*.` wildcard covering subdomains
2849
- * (not the bare parent). Only http(s) — no file:, data:, or other schemes. */
2850
2266
  function isHostAllowed(url, allowedHosts) {
2851
2267
  let parsed;
2852
2268
  try {
@@ -2870,14 +2286,6 @@ var PendingRequestRegistry = class {
2870
2286
  get size() {
2871
2287
  return this.#slots.size;
2872
2288
  }
2873
- /**
2874
- * Register a request and get a promise for its outcome. The promise **never
2875
- * rejects**: a timeout or cancellation resolves with `ok: false` so callers
2876
- * feed the failure back into the agent loop instead of unwinding it.
2877
- *
2878
- * Re-registering a live id throws — silently replacing it would strand the
2879
- * first waiter forever.
2880
- */
2881
2289
  register(options) {
2882
2290
  if (this.#slots.has(options.id)) throw new Error(`pending request '${options.id}' is already registered`);
2883
2291
  const entry = {
@@ -2909,8 +2317,6 @@ var PendingRequestRegistry = class {
2909
2317
  this.#slots.set(options.id, slot);
2910
2318
  });
2911
2319
  }
2912
- /** Deliver a result. Returns false for unknown or already-settled ids —
2913
- * duplicate and late deliveries are no-ops, never a second application. */
2914
2320
  settle(id, value, settledBy = "client") {
2915
2321
  return this.#settle(id, {
2916
2322
  ok: true,
@@ -2918,7 +2324,6 @@ var PendingRequestRegistry = class {
2918
2324
  settledBy
2919
2325
  });
2920
2326
  }
2921
- /** Fail a request. Same idempotence guarantee as {@link settle}. */
2922
2327
  fail(id, reason, error, settledBy = "server") {
2923
2328
  return this.#settle(id, {
2924
2329
  ok: false,
@@ -2938,7 +2343,6 @@ var PendingRequestRegistry = class {
2938
2343
  const entries = [...this.#slots.values()].map(toEntry);
2939
2344
  return kind ? entries.filter((e) => e.kind === kind) : entries;
2940
2345
  }
2941
- /** Fail everything (optionally of one kind) — session close, turn interrupt. */
2942
2346
  cancelAll(reason, error, kind) {
2943
2347
  let canceled = 0;
2944
2348
  for (const slot of Array.from(this.#slots.values())) {
@@ -2972,21 +2376,9 @@ function toEntry(slot) {
2972
2376
  }
2973
2377
  //#endregion
2974
2378
  //#region src/executors/browser-bridge-executor.ts
2975
- /**
2976
- * Executes tool calls in the attached client's own sandbox. The first backend
2977
- * that genuinely returns `pending`: dispatch puts a request on the wire and
2978
- * returns, and the result arrives later through {@link resolve}.
2979
- *
2980
- * Data locality is the point — documents can stay in the browser and never
2981
- * reach the server. The tradeoff is trust: whatever comes back is untrusted
2982
- * input, fine for the user's own data but never a source for authoritative
2983
- * server state (that is why MCP and secret-bearing tools are never bridged).
2984
- */
2985
2379
  var BrowserBridgeExecutor = class {
2986
2380
  registry;
2987
2381
  #options;
2988
- /** Results that arrive before dispatch registers them (fast client, slow
2989
- * bookkeeping) would otherwise be dropped — hold them briefly. */
2990
2382
  #early = /* @__PURE__ */ new Map();
2991
2383
  constructor(options) {
2992
2384
  this.#options = options;
@@ -3040,10 +2432,6 @@ var BrowserBridgeExecutor = class {
3040
2432
  status: "pending"
3041
2433
  };
3042
2434
  }
3043
- /**
3044
- * Apply a client's answer. Returns false when the id is unknown or already
3045
- * settled — a late result after a timeout must not re-open a settled call.
3046
- */
3047
2435
  resolve(executionId, answer) {
3048
2436
  if (!this.registry.has(executionId)) {
3049
2437
  this.#early.set(executionId, answer);
@@ -3056,13 +2444,12 @@ var BrowserBridgeExecutor = class {
3056
2444
  return "output" in answer ? this.registry.settle(executionId, answer, "client") : this.registry.fail(executionId, answer.reason, answer.error, "client");
3057
2445
  }
3058
2446
  };
3059
- /** Map a registry outcome onto the executor's result contract. */
3060
2447
  function toExecutionResult(outcome) {
3061
2448
  if (outcome.ok && "output" in outcome.value) {
3062
2449
  const { output, logs } = outcome.value;
3063
2450
  return {
3064
2451
  status: "ok",
3065
- output: output.type === "text" ? output.value : output.value,
2452
+ output: output.value,
3066
2453
  logs
3067
2454
  };
3068
2455
  }
@@ -3083,17 +2470,6 @@ function toExecutionResult(outcome) {
3083
2470
  }
3084
2471
  //#endregion
3085
2472
  //#region src/executors/deferred-executor.ts
3086
- /**
3087
- * The executor for work that outlives the session's process residency: dispatch
3088
- * hands the call off and returns `pending` **without holding a promise**, because
3089
- * the runner it would resolve into is about to be torn down. The result can only
3090
- * come back through the host — the execution-result route → `settleExecution` on a
3091
- * rehydrated runner — which is exactly what makes a park durable rather than a
3092
- * long in-memory await.
3093
- *
3094
- * Contrast {@link BrowserBridgeExecutor}, which is also `pending` but keeps its
3095
- * answer in memory for the ~60s the tab has to reply.
3096
- */
3097
2473
  var DeferredExecutor = class {
3098
2474
  backend;
3099
2475
  timeoutMs;
@@ -3103,8 +2479,6 @@ var DeferredExecutor = class {
3103
2479
  this.backend = options.backend ?? "remote";
3104
2480
  this.timeoutMs = options.timeoutMs;
3105
2481
  }
3106
- /** Every call this executor takes is deferred — route only the tools that
3107
- * belong on the remote side to it. */
3108
2482
  describe() {
3109
2483
  return {
3110
2484
  backend: this.backend,
@@ -3131,15 +2505,6 @@ var DeferredExecutor = class {
3131
2505
  //#endregion
3132
2506
  //#region src/engines/provider/tools.ts
3133
2507
  const MAX_FILE_BYTES = 1024 * 1024;
3134
- /**
3135
- * Build the capability-scoped tool set for a session.
3136
- *
3137
- * The agent's authority is exactly what is granted here — there are no built-in
3138
- * filesystem or shell tools, and nothing reaches the host filesystem: `fs_*`
3139
- * operate on an in-memory scratch VFS. Tools whose backend is not supplied are
3140
- * simply absent rather than present-and-failing, so a model cannot be tempted
3141
- * by a capability the operator did not grant.
3142
- */
3143
2508
  function createToolContext(options) {
3144
2509
  const vfs = options.vfs ?? createVfs();
3145
2510
  const definitions = [];
@@ -3292,29 +2657,12 @@ function createToolContext(options) {
3292
2657
  sandboxedToolNames: definitions.filter((d) => d.trust === "sandboxed").map((d) => d.name)
3293
2658
  };
3294
2659
  }
3295
- /** Add host-side MCP tools to a context. They are ALWAYS authoritative: they run
3296
- * server-side with server credentials, and must never be handed to a browser. */
3297
2660
  function withMcpTools(context, mcpTools) {
3298
2661
  return withHostTools(context, Object.fromEntries(Object.entries(mcpTools).map(([name, mcpTool]) => [name, {
3299
2662
  tool: mcpTool,
3300
2663
  trust: "authoritative"
3301
2664
  }])), "MCP tool");
3302
2665
  }
3303
- /**
3304
- * Add host-supplied tools to a context at an explicit trust level.
3305
- *
3306
- * The trust level is the whole point of the seam: {@link withMcpTools} can only
3307
- * produce authoritative tools, so a host tool that *should* be sandboxed — and
3308
- * therefore executable in the browser tab that asked for it — had no way to be
3309
- * expressed at all. Here the host says which it is, and the contradictions are
3310
- * refused rather than silently resolved:
3311
- *
3312
- * - a `sandboxed` tool carrying `execute` would run inline in this process with
3313
- * the gateway's ambient authority, which is exactly what sandboxing it was
3314
- * meant to prevent;
3315
- * - an `authoritative` tool *without* `execute` would park the turn on a call no
3316
- * executor claims, and the session would simply stop.
3317
- */
3318
2666
  function withHostTools(context, hostTools, kind = "host tool") {
3319
2667
  const entries = Object.entries(hostTools);
3320
2668
  if (entries.length === 0) return context;
@@ -3461,10 +2809,6 @@ function parseUrl(raw) {
3461
2809
  return;
3462
2810
  }
3463
2811
  }
3464
- /** SSRF guard: resolve the hostname and refuse private, loopback, and link-local
3465
- * destinations. Checked per redirect hop. Resolution happens once here and again
3466
- * inside fetch (a DNS-rebinding TOCTOU); this tier accepts that — operators who
3467
- * need pinning can supply `fetchImpl` with a pinned agent. */
3468
2812
  async function denyReason(url, allowedHosts) {
3469
2813
  const host = url.hostname.toLowerCase();
3470
2814
  if (allowedHosts && allowedHosts.length > 0 && !hostMatches(host, allowedHosts)) return `host not allowed: ${host}`;
@@ -3489,7 +2833,6 @@ function hostMatches(host, allowedHosts) {
3489
2833
  return host === pattern;
3490
2834
  });
3491
2835
  }
3492
- /** Private / loopback / link-local / unspecified, IPv4 and IPv6 (incl. v4-mapped). */
3493
2836
  function isPrivateAddress(address) {
3494
2837
  const ip = address.toLowerCase();
3495
2838
  if (ip.includes(":")) {
@@ -3530,12 +2873,6 @@ async function readCapped(response, maxBytes) {
3530
2873
  function looksLikeHtml(body) {
3531
2874
  return /<(!doctype|html|head|body)[\s>]/i.test(body.slice(0, 1024));
3532
2875
  }
3533
- /**
3534
- * Dependency-free HTML → markdown, tuned for "give the model readable text":
3535
- * drops non-content subtrees, keeps headings/lists/links/emphasis/code, strips
3536
- * everything else. Not a spec-grade converter on purpose — a small predictable
3537
- * transform beats dragging a DOM into core.
3538
- */
3539
2876
  function htmlToMarkdown(html) {
3540
2877
  let text = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|noscript|svg|template|iframe)\b[\s\S]*?<\/\1>/gi, "").replace(/<(head)\b[\s\S]*?<\/\1>/gi, "");
3541
2878
  text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, body) => {
@@ -3558,28 +2895,27 @@ function decodeEntities(text) {
3558
2895
  }
3559
2896
  //#endregion
3560
2897
  //#region src/engines/provider/session.ts
3561
- /** Which capability a wired backend yields, for grant filtering. */
3562
2898
  const CAPABILITY_TOOLS = {
3563
2899
  search: "web_search",
3564
2900
  download: "download",
3565
2901
  webFetch: "web_fetch",
3566
2902
  deliverFiles: "deliver_file"
3567
2903
  };
3568
- /**
3569
- * Assemble a model-agnostic session: provider model, capability-scoped tools,
3570
- * a scratch VFS, and the executor that runs the sandboxed ones.
3571
- *
3572
- * This is the piece an operator wires into the server's `createEngineRunner`.
3573
- *
3574
- * The host wires the *backends*; the profile and the session request decide which
3575
- * of them are actually granted (`profile.session`, `config.capabilities`). A
3576
- * backend that isn't granted is simply not built into the tool set, so withholding
3577
- * a capability costs the host no branching. No declaration anywhere = everything
3578
- * the host wired, which is what a host that ignores profiles gets.
3579
- */
3580
2904
  function createEngineSession(options) {
3581
2905
  const vfs = options.config.vfs ?? createVfs(options.config.restore ? options.config.restore.vfs : options.seedVfs);
3582
- const executor = options.selectExecutor();
2906
+ const executor = options.selectExecutor.length > 0 ? {
2907
+ describe(call) {
2908
+ const target = options.selectExecutor(call);
2909
+ const backend = typeof options.backend === "function" ? options.backend(call) : options.backend;
2910
+ return {
2911
+ ...target.describe?.(call),
2912
+ ...backend ? { backend } : {}
2913
+ };
2914
+ },
2915
+ dispatch(call) {
2916
+ return options.selectExecutor(call).dispatch(call);
2917
+ }
2918
+ } : options.selectExecutor();
3583
2919
  const granted = options.config.capabilities ?? options.profile?.session?.capabilities;
3584
2920
  const isGranted = (key) => granted === void 0 || granted.includes(CAPABILITY_TOOLS[key]);
3585
2921
  let runner;
@@ -3613,26 +2949,14 @@ function createEngineSession(options) {
3613
2949
  vfs,
3614
2950
  executor,
3615
2951
  executableTools: context.sandboxedToolNames,
3616
- executionBackend: options.backend ?? "server",
2952
+ executionBackend: typeof options.backend === "function" ? void 0 : options.backend ?? "server",
3617
2953
  executionLimits: options.executionLimits,
2954
+ shouldApprove: options.shouldApprove,
2955
+ approvalTimeoutMs: options.approvalTimeoutMs,
3618
2956
  reportMcpServers: options.mcp ? () => Promise.resolve(declaredServers === void 0 ? options.mcp.servers : options.mcp.servers.filter((s) => declaredServers.includes(s.name))) : void 0
3619
2957
  }, options.id);
3620
2958
  return runner;
3621
2959
  }
3622
- /**
3623
- * Refuse to build a session whose profile names an MCP server that isn't there.
3624
- *
3625
- * A profile's `mcpServers` list is a **declaration**, not a filter: an embedder
3626
- * who wrote it meant the agent to have those tools. Honouring it partially is
3627
- * the worst failure mode this engine has — the session starts, reports healthy,
3628
- * and the agent apologises its way through every request that needed the server,
3629
- * with one warning line in a log nobody is reading.
3630
- *
3631
- * With a {@link McpConnection} the check is exact (did this server connect?).
3632
- * With a bare tool set all we can see is whether any tool carries the server's
3633
- * namespace, so a genuinely tool-less server would trip it — the fix there is to
3634
- * pass `mcp` rather than to weaken this.
3635
- */
3636
2960
  function requireDeclaredServers(profileName, declared, mcp, tools) {
3637
2961
  if (!declared || declared.length === 0) return;
3638
2962
  const missing = declared.filter((name) => {
@@ -3649,33 +2973,11 @@ function requireDeclaredServers(profileName, declared, mcp, tools) {
3649
2973
  }).join(", ");
3650
2974
  throw new Error(`profile '${profileName}' declares MCP server(s) that are not connected: ${reasons}. A session missing a declared server is a session whose agent silently cannot do its job.`);
3651
2975
  }
3652
- /**
3653
- * Restrict a connected tool set to the MCP servers a profile grants, by the
3654
- * `<server>__<tool>` namespace {@link connectMcpTools} assigns. Undefined `servers`
3655
- * = no declaration, so every connected server passes through.
3656
- *
3657
- * This is how one process-wide MCP connection serves a mixed fleet: the host
3658
- * connects everything once, each profile grants a subset. The transport configs —
3659
- * and any credentials in their headers — never leave the host for a profile.
3660
- */
3661
2976
  function selectMcpTools(tools, servers) {
3662
2977
  if (!tools || servers === void 0) return tools;
3663
2978
  const allowed = new Set(servers);
3664
2979
  return Object.fromEntries(Object.entries(tools).filter(([name]) => allowed.has(name.split("__")[0])));
3665
2980
  }
3666
- /**
3667
- * Connect to MCP servers and return their tools, ready for {@link withMcpTools}.
3668
- *
3669
- * Server-side only, with server credentials: these tools are authoritative and
3670
- * must never be bridged to a browser. `@ai-sdk/mcp` is imported lazily and is an
3671
- * optional dependency — an operator who wires no MCP servers never needs it.
3672
- *
3673
- * **A stateless MCP server must answer `GET` with 405.** The client opens the
3674
- * SSE stream with a `GET` before it sends anything, and a POST-only server
3675
- * mounted under a framework's default 404 makes the whole connect fail with an
3676
- * error that names neither the method nor the route. This is the single most
3677
- * common way an otherwise-correct MCP mount fails.
3678
- */
3679
2981
  async function connectMcpTools(servers, options = {}) {
3680
2982
  const entries = Object.entries(servers);
3681
2983
  if (entries.length === 0) return {
@@ -3717,7 +3019,7 @@ async function connectMcpTools(servers, options = {}) {
3717
3019
  options.onError?.(name, error);
3718
3020
  if (options.required) {
3719
3021
  await closeAll();
3720
- throw new Error(`MCP server '${name}' failed to connect: ${message}`);
3022
+ throw new Error(`MCP server '${name}' failed to connect: ${message}`, { cause: error });
3721
3023
  }
3722
3024
  }
3723
3025
  }
@@ -3727,7 +3029,6 @@ async function connectMcpTools(servers, options = {}) {
3727
3029
  close: closeAll
3728
3030
  };
3729
3031
  }
3730
- /** The connection's identity, minus its secrets — `headers` never travel. */
3731
3032
  function describeServer(server) {
3732
3033
  if ("url" in server) return {
3733
3034
  transport: server.type === "sse" ? "sse" : "http",
@@ -3739,12 +3040,6 @@ function describeServer(server) {
3739
3040
  args: server.args
3740
3041
  };
3741
3042
  }
3742
- /**
3743
- * The AI SDK hands back its own `Tool`, whose `inputSchema` may be a zod schema
3744
- * or a `jsonSchema()` wrapper. Only the latter carries a JSON Schema document,
3745
- * so that is the only case where parameters are reported — `McpServerToolInfo`
3746
- * models the absence deliberately, and inventing one here would be worse.
3747
- */
3748
3043
  function toToolInfo(name, mcpTool) {
3749
3044
  const { description, inputSchema } = mcpTool ?? {};
3750
3045
  return {
@@ -3753,12 +3048,6 @@ function toToolInfo(name, mcpTool) {
3753
3048
  inputSchema: inputSchema?.jsonSchema
3754
3049
  };
3755
3050
  }
3756
- /**
3757
- * Only http/sse: the AI SDK's built-in transports are the remote ones, and its
3758
- * own docs mark stdio local-only and not deployable. A stdio server here is a
3759
- * misconfiguration worth surfacing rather than silently dropping — the Claude
3760
- * engine still supports stdio, since the CLI spawns those itself.
3761
- */
3762
3051
  function toTransport(server) {
3763
3052
  if (!("url" in server)) throw new Error("stdio MCP servers are not supported by the model-agnostic engine (use an http or sse server, or run this session under a Claude profile)");
3764
3053
  return server.type === "sse" ? {
@@ -3773,30 +3062,6 @@ function toTransport(server) {
3773
3062
  }
3774
3063
  //#endregion
3775
3064
  //#region src/engines/claude/catalog.ts
3776
- /**
3777
- * The Claude engine's model catalog — what a create form offers before any
3778
- * session has run.
3779
- *
3780
- * **Refresh procedure** (release checklist): run `supportedModels()` on a
3781
- * throwaway SDK query (no tokens spent) and re-apply the shaping rules of
3782
- * `modelOptionsFromSdk` (`src/normalize.ts`) at authoring time: drop the
3783
- * `default` sentinel row, derive display names from resolved ids where
3784
- * unambiguous, mark the newest of each family `primary`, sort by family rank.
3785
- * A unit test replays the raw extraction through `modelOptionsFromSdk` and
3786
- * asserts these rows match, so the rules cannot drift.
3787
- *
3788
- * Two things the live `capabilities` event can never offer:
3789
- * - rows for **older models** the CLI no longer reports (hand-maintained, the
3790
- * accepted cost of a static catalog; the CLI silently downgrades an effort a
3791
- * model doesn't support, so `reasoningEfforts` is omitted on them and the
3792
- * engine default set applies);
3793
- * - an answer on a **cold server**. The live event still exists and remains
3794
- * the in-session truth for the model switcher; this catalog is the
3795
- * create-form truth.
3796
- *
3797
- * `defaultModel` is deliberately NOT here: a claude profile's default is the
3798
- * operator's CLI config, unknowable statically.
3799
- */
3800
3065
  const CLAUDE_CATALOG = {
3801
3066
  provenance: "supportedModels() of @anthropic-ai/claude-agent-sdk 0.3.221 (Claude Code CLI), extracted 2026-08-05; older-model rows hand-maintained",
3802
3067
  models: [
@@ -3866,13 +3131,6 @@ const CLAUDE_CATALOG = {
3866
3131
  };
3867
3132
  //#endregion
3868
3133
  //#region src/engines/claude/adapter.ts
3869
- /**
3870
- * The Claude engine as an adapter — a thin, behaviourally inert wrapper:
3871
- * `SessionRunner` unchanged, `checkClaudeAuth` as the probe, the static
3872
- * catalog for create forms. Exists so catalogs, capabilities and availability
3873
- * have one shape across engines; the runner itself is exactly what
3874
- * `registry.prepare()` builds.
3875
- */
3876
3134
  const claudeAdapter = {
3877
3135
  engine: "claude",
3878
3136
  capabilities: ENGINE_CAPABILITIES.claude,
@@ -3890,12 +3148,6 @@ const claudeAdapter = {
3890
3148
  if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
3891
3149
  return new SessionRunner(config, id);
3892
3150
  },
3893
- /**
3894
- * The Agent SDK's on-disk session store, mapped browser-safe. The SDK reads
3895
- * the store of the *process* environment — it takes no config dir — so a
3896
- * profile pin cannot narrow this listing; that matches the route's
3897
- * pre-adapter behavior exactly (the listing was always process-global).
3898
- */
3899
3151
  async listSessions({ dir, limit, offset }) {
3900
3152
  return (await listSessions({
3901
3153
  dir,
@@ -3913,10 +3165,28 @@ const claudeAdapter = {
3913
3165
  }));
3914
3166
  }
3915
3167
  };
3168
+ //#endregion
3169
+ //#region src/engines/codex/connect.ts
3916
3170
  /**
3917
- * A JSON-RPC error response from the peer, or one we return to it. `code`
3918
- * follows the JSON-RPC 2.0 reserved ranges (-32601 = method not found).
3171
+ * The operator's environment reaches the child whole WorkerDeck resolves no credential of its
3172
+ * own — and the profile's CODEX_HOME is the one key it pins, last, so a profile always wins over
3173
+ * an inherited value. Every path that spawns or connects to an app-server goes through here.
3919
3174
  */
3175
+ function codexChildEnv(base, codexHome) {
3176
+ const env = {};
3177
+ for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
3178
+ if (codexHome) env.CODEX_HOME = codexHome;
3179
+ return env;
3180
+ }
3181
+ /** `experimentalApi` gates the granular approval policy and there is no non-experimental fallback, so it is not per-call-site. */
3182
+ const INITIALIZE_PARAMS = {
3183
+ clientInfo: {
3184
+ name: "workerdeck",
3185
+ title: "WorkerDeck",
3186
+ version: `protocol-${PROTOCOL_VERSION}`
3187
+ },
3188
+ capabilities: { experimentalApi: true }
3189
+ };
3920
3190
  var JsonRpcError = class extends Error {
3921
3191
  code;
3922
3192
  constructor(code, message) {
@@ -3925,18 +3195,6 @@ var JsonRpcError = class extends Error {
3925
3195
  this.code = code;
3926
3196
  }
3927
3197
  };
3928
- /**
3929
- * JSON-RPC over a `codex app-server` child's stdio: **newline-delimited JSON**,
3930
- * one message per line, and — verified against 0.146.0 — an envelope *without*
3931
- * the `jsonrpc: "2.0"` field (`{id, method, params}` / `{id, result}` /
3932
- * `{id, error}`; the binary's own schema marks only those required). Server→
3933
- * client notifications additionally carry a top-level `emittedAtMs`, ignored
3934
- * here.
3935
- *
3936
- * Transport only: no method knowledge, no process ownership. The process
3937
- * wrapper (`process.ts`) owns the child and calls {@link fail} when it dies so
3938
- * every in-flight request rejects instead of hanging.
3939
- */
3940
3198
  var JsonRpcStdioConnection = class {
3941
3199
  #output;
3942
3200
  #nextId = 1;
@@ -3944,7 +3202,6 @@ var JsonRpcStdioConnection = class {
3944
3202
  #buffer = "";
3945
3203
  #closed = false;
3946
3204
  #notificationHandler;
3947
- /** Where {@link CODEX_TRACE_ENV} pointed, or undefined — read once. */
3948
3205
  #trace;
3949
3206
  #requestHandler;
3950
3207
  constructor(options) {
@@ -3983,8 +3240,6 @@ var JsonRpcStdioConnection = class {
3983
3240
  onRequest(handler) {
3984
3241
  this.#requestHandler = handler;
3985
3242
  }
3986
- /** Reject everything in flight and refuse new traffic — the child is gone
3987
- * (or the session is over). Idempotent. */
3988
3243
  fail(message) {
3989
3244
  if (this.#closed) return;
3990
3245
  this.#closed = true;
@@ -4014,15 +3269,6 @@ var JsonRpcStdioConnection = class {
4014
3269
  this.#dispatch(message);
4015
3270
  }
4016
3271
  }
4017
- /**
4018
- * Append one inbound message to the trace file, when the operator asked for
4019
- * one. **Notifications and server→client requests only** — a response body is
4020
- * not needed to answer the questions this exists for, and `account/*` results
4021
- * are the one place app-server traffic can carry a masked credential
4022
- * fragment, which nothing of ours writes to disk (see the auth red lines).
4023
- * Best-effort and synchronous: a debug sink that loses lines proves nothing,
4024
- * and a debug sink that throws must not take the session with it.
4025
- */
4026
3272
  #traceLine(message) {
4027
3273
  if (!this.#trace) return;
4028
3274
  const method = message.method;
@@ -4071,50 +3317,12 @@ var JsonRpcStdioConnection = class {
4071
3317
  };
4072
3318
  //#endregion
4073
3319
  //#region src/engines/codex/subagents.ts
4074
- /**
4075
- * The codex side of `SessionInfo.subagents` — and the attribution table that
4076
- * gives every event a spawned agent produces its `parentToolUseId`.
4077
- *
4078
- * Codex's signal is stronger than the claude engine's, so this is deliberately
4079
- * NOT that tracker generalised (`engines/claude/subagents.ts` infers spawns
4080
- * from tool names and verdicts from result-text sniffing, ~290 lines of module
4081
- * doc explaining the inference). Here nothing is inferred: `subAgentActivity
4082
- * {kind: 'started'}` on the owning thread positively announces an agent, names
4083
- * it (`agentPath`), keys it (`agentThreadId` — the id every one of its later
4084
- * notifications carries) and hands over the model's own `spawn_agent` call id;
4085
- * the agent's end is its own thread's `turn/completed`, status included. So a
4086
- * record is keyed by **thread id** — the wire's handle — while exposing a
4087
- * **tool-use id** — the protocol's: `parentToolUseId` on nested events must
4088
- * equal the anchor `tool_use`'s id for `subagentItems` (the frame membership
4089
- * rule every client shares) to reassemble the sidechain, and this map is where
4090
- * the two vocabularies meet.
4091
- *
4092
- * Two decisions worth their prose:
4093
- *
4094
- * **A record survives the runner's turns.** Codex agents are designed to
4095
- * outlive the root turn that spawned them (`sendInput`/`resumeAgent` address a
4096
- * thread that kept existing), so — unlike a pending approval — nothing here is
4097
- * swept when a root turn ends. What does end every agent is the app-server
4098
- * process itself: the runner calls {@link sweep} when the child dies or the
4099
- * session closes, because an agent whose host process is gone can never report,
4100
- * and `running` on a closed session would be a lie a polled list re-renders
4101
- * forever (the claude tracker's argument, inherited whole).
4102
- *
4103
- * **The settled tail is bounded, running records never are** — the same
4104
- * {@link SUBAGENT_HISTORY} discipline as the claude tracker, and enforced at
4105
- * settle time for the same reason: a settle happens once per agent, `list()`
4106
- * once per row of a 1.2s-polled sessions list.
4107
- */
4108
3320
  var CodexAgentTracker = class {
4109
3321
  #byThread = /* @__PURE__ */ new Map();
4110
3322
  #settleCounter = 0;
4111
- /** The record whose thread this is — the attribution lookup. */
4112
3323
  get(agentThreadId) {
4113
3324
  return this.#byThread.get(agentThreadId);
4114
3325
  }
4115
- /** Open (or return) the record for a thread. Fill-in, never overwrite: a
4116
- * label-less fallback record keeps its accumulated count and its already
4117
- * published toolUseId when the announcing item arrives late. */
4118
3326
  open(agentThreadId, toolUseId, agentType, ts) {
4119
3327
  let record = this.#byThread.get(agentThreadId);
4120
3328
  if (!record) {
@@ -4131,8 +3339,6 @@ var CodexAgentTracker = class {
4131
3339
  record.agentType ??= agentType;
4132
3340
  return record;
4133
3341
  }
4134
- /** The agent's thread ran again (`kind: 'interacted'`, or a fresh
4135
- * `turn/started` on its thread): a settled verdict no longer describes it. */
4136
3342
  revive(record) {
4137
3343
  record.status = "running";
4138
3344
  record.settledOrder = void 0;
@@ -4153,41 +3359,21 @@ var CodexAgentTracker = class {
4153
3359
  settled--;
4154
3360
  }
4155
3361
  }
4156
- /** A real verdict for one agent — its thread's `turn/completed`, or the
4157
- * `interrupted` activity edge. */
4158
3362
  settle(record, status) {
4159
3363
  if (record.status === status) return;
4160
3364
  this.#settle(record, status);
4161
3365
  }
4162
- /** The process the agents lived in is gone (child death, session close):
4163
- * everything still running is settled as failed — the report can never come. */
4164
3366
  sweep() {
4165
3367
  for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
4166
3368
  }
4167
- /**
4168
- * The conversation these agents belonged to is gone (a `conversation_reset`).
4169
- *
4170
- * Deliberately NOT {@link CodexAgentTracker.sweep}: that settles the running
4171
- * ones as failed and keeps the rows, which is right when the *process* dies —
4172
- * the transcript still holds the anchor `tool_use` each row points at, and a
4173
- * row that vanished would leave that card unexplained. A clear is the other
4174
- * way round. The anchors go with the transcript, so a surviving row would
4175
- * publish a `toolUseId` that resolves to nothing — and clients key a
4176
- * pressable, enterable agent line off exactly that id.
4177
- */
4178
3369
  forget() {
4179
3370
  this.#byThread.clear();
4180
3371
  }
4181
- /** The thread ids currently tracked — what a clear remembers so a still-running
4182
- * agent's later traffic can be dropped rather than re-anchored. */
4183
3372
  threadIds() {
4184
3373
  return Array.from(this.#byThread.keys());
4185
3374
  }
4186
- /** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
4187
- * objects, and `undefined` when there is nothing to say (absent and empty
4188
- * mean the same thing to a client, and bytes on a polled list are paid for). */
4189
3375
  list() {
4190
- if (this.#byThread.size === 0) return void 0;
3376
+ if (this.#byThread.size === 0) return;
4191
3377
  const out = [];
4192
3378
  for (const r of this.#byThread.values()) out.push({
4193
3379
  toolUseId: r.toolUseId,
@@ -4201,63 +3387,12 @@ var CodexAgentTracker = class {
4201
3387
  };
4202
3388
  //#endregion
4203
3389
  //#region src/engines/codex/trust.ts
4204
- /**
4205
- * Codex project trust: will this session's cwd get its `.codex/config.toml`?
4206
- *
4207
- * Codex only layers a project's `.codex/config.toml` onto the operator's base
4208
- * config when the project is *trusted* (a `[projects."<path>"]
4209
- * trust_level = "trusted"` entry in `$CODEX_HOME/config.toml`), and the
4210
- * app-server surface has no trust prompt — that lives in the TUI. So under
4211
- * WorkerDeck an untrusted project's config, MCP servers included, is silently
4212
- * ignored: no error, no notice, servers just missing. The runner asks this
4213
- * module at session start whether that is about to happen, so the transcript
4214
- * can say so.
4215
- *
4216
- * Semantics, all measured against both the bundled 0.146.0 and 0.149.0
4217
- * (2026-08-22, via `codex mcp list` from probe cwds and via `thread/start` +
4218
- * `mcpServerStatus/list` on the app-server surface — identical answers):
4219
- *
4220
- * - **Discovery**: config layers come from the cwd and its ancestors up to and
4221
- * including the nearest directory containing `.git` (dir or file). With no
4222
- * git anywhere above, the cwd alone is consulted. Directories above the
4223
- * nearest git root never contribute, trusted or not.
4224
- * - **Trust per layer**: an exact entry for the layer's own canonical path
4225
- * decides (an explicit `"untrusted"` beats inherited trust); without one the
4226
- * layer inherits from the chain's git root — trusted iff the git root has a
4227
- * trusted entry, where a linked worktree's root also counts its main
4228
- * repository's entry (the `.git` file's gitdir names it). A trusted
4229
- * mid-chain directory does NOT trust its children, and plain path
4230
- * containment without git confers nothing.
4231
- * - **Canonical paths**: codex matches entries against the canonicalized cwd —
4232
- * a macOS `/tmp/...` entry never matches the `/private/tmp/...` it points
4233
- * at, while the reverse spelling works (and the app-server canonicalizes its
4234
- * `cwd` param too). Both sides here are realpath'd, which can only err
4235
- * toward silence.
4236
- * - **The gate is sandbox-scoped**: `thread/start` under `workspace-write` or
4237
- * `danger-full-access` (permission modes `acceptEdits`/`bypassPermissions`)
4238
- * WRITES the trust entry itself and loads the config — only `read-only`
4239
- * (mode `default`) leaves the project untrusted and the config ignored. A
4240
- * later `turn/start` with a wider sandboxPolicy does not heal the thread
4241
- * (measured): the caller probes `default`-mode sessions only, and the notice
4242
- * stays true for the session it opens.
4243
- * - `trust_level`'s vocabulary is exactly `trusted`/`untrusted`; any other
4244
- * value fails codex's bootstrap outright ("unknown variant"), so a config
4245
- * carrying one probes silent — that session announces its own failure.
4246
- *
4247
- * The correctness bar for every degrade path: a FALSE notice — warning about a
4248
- * project codex actually trusts — is worse than a missed one. The narrow TOML
4249
- * reader below refuses (→ silence) anything it cannot interpret with
4250
- * certainty, rather than guessing.
4251
- */
4252
3390
  const BARE_KEY = /[A-Za-z0-9_-]/;
4253
3391
  function skipWs(text, pos) {
4254
3392
  let i = pos;
4255
3393
  while (i < text.length && (text[i] === " " || text[i] === " ")) i++;
4256
3394
  return i;
4257
3395
  }
4258
- /** One-line TOML basic string starting at `pos` (which must be `"`). Undefined
4259
- * on an escape TOML doesn't define or a close quote that never comes — the
4260
- * caller refuses the file rather than guessing what codex would read. */
4261
3396
  function parseBasicString(text, pos) {
4262
3397
  let out = "";
4263
3398
  let i = pos + 1;
@@ -4279,12 +3414,12 @@ function parseBasicString(text, pos) {
4279
3414
  else if (esc === "u" || esc === "U") {
4280
3415
  const width = esc === "u" ? 4 : 8;
4281
3416
  const hex = text.slice(i + 2, i + 2 + width);
4282
- if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return void 0;
3417
+ if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return;
4283
3418
  const code = Number.parseInt(hex, 16);
4284
- if (code > 1114111) return void 0;
3419
+ if (code > 1114111) return;
4285
3420
  out += String.fromCodePoint(code);
4286
3421
  i += width;
4287
- } else return void 0;
3422
+ } else return;
4288
3423
  i += 2;
4289
3424
  continue;
4290
3425
  }
@@ -4292,17 +3427,14 @@ function parseBasicString(text, pos) {
4292
3427
  i++;
4293
3428
  }
4294
3429
  }
4295
- /** One-line TOML literal string starting at `pos` (which must be `'`). */
4296
3430
  function parseLiteralString(text, pos) {
4297
3431
  const close = text.indexOf("'", pos + 1);
4298
- if (close === -1) return void 0;
3432
+ if (close === -1) return;
4299
3433
  return {
4300
3434
  value: text.slice(pos + 1, close),
4301
3435
  end: close + 1
4302
3436
  };
4303
3437
  }
4304
- /** A dotted key path — bare, `"basic"` and `'literal'` keys, whitespace around
4305
- * the dots — as found in table headers and on the left of assignments. */
4306
3438
  function parseKeyPath(text, pos) {
4307
3439
  const keys = [];
4308
3440
  let i = pos;
@@ -4311,7 +3443,7 @@ function parseKeyPath(text, pos) {
4311
3443
  const ch = text[i];
4312
3444
  if (ch === "\"" || ch === "'") {
4313
3445
  const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4314
- if (!str) return void 0;
3446
+ if (!str) return;
4315
3447
  keys.push(str.value);
4316
3448
  i = str.end;
4317
3449
  } else if (ch !== void 0 && BARE_KEY.test(ch)) {
@@ -4328,23 +3460,14 @@ function parseKeyPath(text, pos) {
4328
3460
  i++;
4329
3461
  }
4330
3462
  }
4331
- /**
4332
- * Scan an assignment's value (or the continuation line of a multi-line array),
4333
- * confirming where it ends. Returns the bracket depth carried onto the next
4334
- * line (0 = the value is complete) plus the string itself when the whole value
4335
- * was one plain one-line string. Undefined refuses the file: multi-line
4336
- * strings are where a line reader starts misreading string *content* as
4337
- * sections and entries — the exact mistake that could flip a real trust entry
4338
- * — so they are not parsed around, they end the attempt.
4339
- */
4340
3463
  function scanValueLine(text, pos, depth) {
4341
3464
  let i = skipWs(text, pos);
4342
3465
  if (depth === 0 && (text[i] === "\"" || text[i] === "'")) {
4343
- if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
3466
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
4344
3467
  const str = text[i] === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4345
- if (!str) return void 0;
3468
+ if (!str) return;
4346
3469
  const rest = skipWs(text, str.end);
4347
- if (rest < text.length && text[rest] !== "#") return void 0;
3470
+ if (rest < text.length && text[rest] !== "#") return;
4348
3471
  return {
4349
3472
  depth: 0,
4350
3473
  value: str.value
@@ -4354,33 +3477,21 @@ function scanValueLine(text, pos, depth) {
4354
3477
  const ch = text[i];
4355
3478
  if (ch === "#") break;
4356
3479
  if (ch === "\"" || ch === "'") {
4357
- if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return void 0;
3480
+ if (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) return;
4358
3481
  const str = ch === "\"" ? parseBasicString(text, i) : parseLiteralString(text, i);
4359
- if (!str) return void 0;
3482
+ if (!str) return;
4360
3483
  i = str.end;
4361
3484
  continue;
4362
3485
  }
4363
3486
  if (ch === "[" || ch === "{") depth++;
4364
3487
  else if (ch === "]" || ch === "}") {
4365
3488
  depth--;
4366
- if (depth < 0) return void 0;
3489
+ if (depth < 0) return;
4367
3490
  }
4368
3491
  i++;
4369
3492
  }
4370
3493
  return { depth };
4371
3494
  }
4372
- /**
4373
- * The `[projects."<path>"] trust_level = "..."` entries of a codex
4374
- * `config.toml`, by a deliberately narrow reader (core takes no TOML
4375
- * dependency for this). Handles what codex itself writes plus the reasonable
4376
- * hand-edits — comments, CRLF, whitespace, quoted keys with escapes, literal
4377
- * and bare keys, `[projects]`-with-dotted-keys and top-level dotted forms,
4378
- * single-line inline tables, multi-line arrays — and returns **undefined for
4379
- * anything else it meets anywhere in the file** (multi-line strings,
4380
- * `projects` as an inline table, array-of-tables, junk): the caller treats
4381
- * undefined as "cannot know" and stays silent. Conflicting duplicate entries
4382
- * also refuse — invalid for TOML, and guessing wrong is a false notice.
4383
- */
4384
3495
  function parseProjectTrustEntries(source) {
4385
3496
  const entries = /* @__PURE__ */ new Map();
4386
3497
  let section = [];
@@ -4389,7 +3500,7 @@ function parseProjectTrustEntries(source) {
4389
3500
  const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
4390
3501
  if (carryDepth > 0) {
4391
3502
  const scanned = scanValueLine(line, 0, carryDepth);
4392
- if (!scanned) return void 0;
3503
+ if (!scanned) return;
4393
3504
  carryDepth = scanned.depth;
4394
3505
  continue;
4395
3506
  }
@@ -4398,43 +3509,35 @@ function parseProjectTrustEntries(source) {
4398
3509
  if (line[start] === "[") {
4399
3510
  const array = line.startsWith("[[", start);
4400
3511
  const path = parseKeyPath(line, start + (array ? 2 : 1));
4401
- if (!path) return void 0;
3512
+ if (!path) return;
4402
3513
  const close = array ? "]]" : "]";
4403
- if (!line.startsWith(close, path.end)) return void 0;
3514
+ if (!line.startsWith(close, path.end)) return;
4404
3515
  const rest = skipWs(line, path.end + close.length);
4405
- if (rest < line.length && line[rest] !== "#") return void 0;
4406
- if (array && path.value[0] === "projects") return void 0;
3516
+ if (rest < line.length && line[rest] !== "#") return;
3517
+ if (array && path.value[0] === "projects") return;
4407
3518
  section = path.value;
4408
3519
  continue;
4409
3520
  }
4410
3521
  const key = parseKeyPath(line, start);
4411
- if (!key) return void 0;
4412
- if (line[key.end] !== "=") return void 0;
3522
+ if (!key) return;
3523
+ if (line[key.end] !== "=") return;
4413
3524
  const scanned = scanValueLine(line, key.end + 1, 0);
4414
- if (!scanned) return void 0;
3525
+ if (!scanned) return;
4415
3526
  carryDepth = scanned.depth;
4416
3527
  const full = [...section, ...key.value];
4417
3528
  if (full[0] !== "projects") continue;
4418
- if (full.length < 3) return void 0;
3529
+ if (full.length < 3) return;
4419
3530
  if (full.length === 3 && full[2] === "trust_level") {
4420
- if (carryDepth !== 0 || scanned.value === void 0) return void 0;
3531
+ if (carryDepth !== 0 || scanned.value === void 0) return;
4421
3532
  const project = full[1];
4422
3533
  const existing = entries.get(project);
4423
- if (existing !== void 0 && existing !== scanned.value) return void 0;
3534
+ if (existing !== void 0 && existing !== scanned.value) return;
4424
3535
  entries.set(project, scanned.value);
4425
3536
  }
4426
3537
  }
4427
- if (carryDepth > 0) return void 0;
3538
+ if (carryDepth > 0) return;
4428
3539
  return entries;
4429
3540
  }
4430
- /**
4431
- * A linked worktree inherits trust from its main repository's entry (measured:
4432
- * trusting the main repo path loads the worktree's project config). The
4433
- * worktree's `.git` is a FILE whose `gitdir:` line names
4434
- * `<main>/.git/worktrees/<name>`; the directory owning that `.git` is the
4435
- * anchor to look up. Anything unreadable or shaped differently resolves false
4436
- * — this route can only ADD trust, i.e. silence, never a false notice.
4437
- */
4438
3541
  function mainRepositoryTrusted(gitRootDir, canonical) {
4439
3542
  const gitPath = join(gitRootDir, ".git");
4440
3543
  try {
@@ -4453,15 +3556,6 @@ function mainRepositoryTrusted(gitRootDir, canonical) {
4453
3556
  return false;
4454
3557
  }
4455
3558
  }
4456
- /**
4457
- * The notice for a codex session about to run on a cwd whose
4458
- * `.codex/config.toml` codex will ignore, or undefined when there is nothing
4459
- * to say — no project config anywhere codex would look, the project is
4460
- * trusted, or the situation cannot be established with certainty. Read-only
4461
- * throughout: WorkerDeck never writes trust entries (adjacent to the auth red
4462
- * lines — trusting a directory is the operator's decision, made in codex's
4463
- * own prompt or by their own hand).
4464
- */
4465
3559
  function untrustedProjectNotice(options) {
4466
3560
  let cwd;
4467
3561
  try {
@@ -4492,17 +3586,17 @@ function untrustedProjectNotice(options) {
4492
3586
  return false;
4493
3587
  }
4494
3588
  });
4495
- if (layers.length === 0) return void 0;
3589
+ if (layers.length === 0) return;
4496
3590
  const homeConfigPath = join(options.codexHome, "config.toml");
4497
3591
  let source = "";
4498
3592
  try {
4499
3593
  source = readFileSync(homeConfigPath, "utf8");
4500
3594
  } catch (error) {
4501
- if (error.code !== "ENOENT") return void 0;
3595
+ if (error.code !== "ENOENT") return;
4502
3596
  }
4503
3597
  const entries = parseProjectTrustEntries(source);
4504
- if (!entries) return void 0;
4505
- for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return void 0;
3598
+ if (!entries) return;
3599
+ for (const value of entries.values()) if (value !== "trusted" && value !== "untrusted") return;
4506
3600
  const canonical = /* @__PURE__ */ new Map();
4507
3601
  for (const [key, value] of entries) {
4508
3602
  let path = key;
@@ -4518,59 +3612,25 @@ function untrustedProjectNotice(options) {
4518
3612
  if (entry !== void 0) return entry !== "trusted";
4519
3613
  return !rootTrusted;
4520
3614
  });
4521
- if (ignored.length === 0) return void 0;
3615
+ if (ignored.length === 0) return;
4522
3616
  const trustDir = gitRoot ?? cwd;
4523
3617
  const configs = ignored.map((layer) => join(layer, ".codex", "config.toml"));
4524
3618
  return `codex does not trust this directory, so ${configs.length === 1 ? `its project config (${configs[0]}) is` : `its project configs (${configs.join(", ")}) are`} being ignored — MCP servers and settings declared there will be missing from this session. To trust it, run codex once in ${trustDir} and accept the trust prompt, or add [projects."${trustDir}"] with trust_level = "trusted" to ${homeConfigPath}.`;
4525
3619
  }
4526
3620
  //#endregion
4527
3621
  //#region src/engines/codex/runner.ts
4528
- /**
4529
- * thread/start's sandbox axis (string form) — our permission modes as codex
4530
- * sandbox modes: `default` → read-only (reads run; any mutation is refused by
4531
- * the OS sandbox and — with the ask policy below — escalates to a real
4532
- * question), `acceptEdits` → workspace-write (in-workspace writes sail
4533
- * through, the acceptEdits grant), `bypassPermissions` → danger-full-access.
4534
- * `auto` rides the SAME sandbox as acceptEdits — it is not a wider grant, it
4535
- * only moves *who answers* the approvals (see {@link APPROVALS_REVIEWER_BY_MODE}).
4536
- */
4537
3622
  const THREAD_SANDBOX_BY_MODE = {
4538
3623
  default: "read-only",
4539
3624
  acceptEdits: "workspace-write",
4540
3625
  auto: "workspace-write",
4541
3626
  bypassPermissions: "danger-full-access"
4542
3627
  };
4543
- /**
4544
- * turn/start's sandboxPolicy axis (object form — same policy, second shape).
4545
- *
4546
- * The `workspaceWrite` entries here are a SHAPE, not the whole policy: every
4547
- * unstated field of that variant is serde-defaulted by the app-server, so
4548
- * sending it bare silently overrides the operator's `[sandbox_workspace_write]`
4549
- * — `network_access` back to false, `writable_roots` back to empty — on every
4550
- * turn. {@link CodexRunner.#turnSandboxPolicy} restates those fields from
4551
- * `config/read`; nothing else may send this map's `workspaceWrite` entries
4552
- * directly.
4553
- */
4554
3628
  const TURN_SANDBOX_BY_MODE = {
4555
3629
  default: { type: "readOnly" },
4556
3630
  acceptEdits: { type: "workspaceWrite" },
4557
3631
  auto: { type: "workspaceWrite" },
4558
3632
  bypassPermissions: { type: "dangerFullAccess" }
4559
3633
  };
4560
- /**
4561
- * The approval axis, stated as the GRANULAR object on both thread/start and
4562
- * turn/start — never the string vocabulary, deliberately and unconditionally:
4563
- * measured against 0.146.0, plain `'untrusted'` never asked anything (a
4564
- * sandbox-violating write was silently refused, a safe echo auto-approved),
4565
- * while the granular flags make a blocked action a real server→client
4566
- * question. Granular policies are gated on `capabilities.experimentalApi` at
4567
- * initialize; WorkerDeck declares it always and keeps NO non-experimental
4568
- * fallback — a future binary that rejects either gate fails loudly (see
4569
- * {@link CodexRunner.#ensureThread}) instead of quietly not asking.
4570
- *
4571
- * `default`/`acceptEdits` ask (all flags on — the sandbox axis above already
4572
- * decides *what needs asking*); `bypassPermissions` asks nothing, same shape.
4573
- */
4574
3634
  const GRANULAR_ASK = { granular: {
4575
3635
  sandbox_approval: true,
4576
3636
  rules: true,
@@ -4585,11 +3645,6 @@ const GRANULAR_NEVER = { granular: {
4585
3645
  request_permissions: false,
4586
3646
  skill_approval: false
4587
3647
  } };
4588
- /**
4589
- * Notifications whose meaning is scoped to ONE thread, and which are therefore
4590
- * only ever read off the session's own. Everything else (items, deltas) is
4591
- * accepted from any thread on the connection — see `#handleNotification`.
4592
- */
4593
3648
  const THREAD_SCOPED_NOTIFICATIONS = new Set([
4594
3649
  "turn/started",
4595
3650
  "turn/completed",
@@ -4601,62 +3656,20 @@ const APPROVAL_POLICY_BY_MODE = {
4601
3656
  auto: GRANULAR_ASK,
4602
3657
  bypassPermissions: GRANULAR_NEVER
4603
3658
  };
4604
- /**
4605
- * The THIRD approval axis — *who reviews*, independent of the sandbox axis and
4606
- * the ask axis above. Codex's `approvalsReviewer` (thread/start and turn/start,
4607
- * present since 0.146.0) routes every approval request either to the user
4608
- * (`'user'`, codex's own default) or to `'auto_review'`: a prompted subagent
4609
- * that gathers context and applies a risk framework before allowing or denying.
4610
- * That is codex's "Approve for me" preset, and our `auto` mode is exactly it.
4611
- *
4612
- * Sent explicitly for EVERY mode rather than omitted for the default — a thread
4613
- * inherits `approvalsReviewer` across turns ("this turn and subsequent turns"),
4614
- * so leaving it unset would let a stale reviewer from an earlier turn survive a
4615
- * mode switch back to a user-reviewed mode. Stating it every time makes the
4616
- * mode the single source of truth.
4617
- *
4618
- * NOTE the asymmetry with the Claude engine's `auto`: that classifier is
4619
- * operator-configurable (`autoMode.environment`, allow/soft_deny/hard_deny);
4620
- * this reviewer has no configuration surface at all.
4621
- */
4622
3659
  const APPROVALS_REVIEWER_BY_MODE = {
4623
3660
  default: "user",
4624
3661
  acceptEdits: "user",
4625
3662
  auto: "auto_review",
4626
3663
  bypassPermissions: "user"
4627
3664
  };
4628
- /** Fallback timeout for a pending approval nobody answers — the SessionRunner
4629
- * default, so unattended codex sessions land the same way Claude ones do. */
4630
3665
  const DEFAULT_APPROVAL_TIMEOUT_MS = 3e5;
4631
- /**
4632
- * Tool name for codex's built-in `image_gen`. A stable string because it is a
4633
- * rendering contract: both clients key an icon (and, where they can reach the
4634
- * host filesystem, an inline preview) off it.
4635
- */
4636
3666
  const CODEX_IMAGE_TOOL = "CodexImageGeneration";
4637
- /**
4638
- * Tool name for a spawned agent's anchor `tool_use` — the claude engine's
4639
- * `Task` in this engine's vocabulary. Codex never sends such a call: the model's
4640
- * `spawn_agent` surfaces only as the `subAgentActivity` marker item, so the
4641
- * runner authors the call itself, because everything downstream is built on a
4642
- * top-level `tool_use` existing — `terminalBlocks` absorbs a sidechain into the
4643
- * call whose id its events carry as `parentToolUseId`, the takeover frames by
4644
- * it, and `taskIdentity` labels it from the input's `subagent_type`. Not a new
4645
- * wire idea, just a row: the same shape every other codex tool card uses.
4646
- */
4647
3667
  const CODEX_AGENT_TOOL = "CodexAgent";
4648
- /** Tool name for the model's collab-agent calls (`wait`, `sendInput`, …), the
4649
- * `tool` field carried in the input. One name for the whole open axis rather
4650
- * than a name per verb, so a future verb renders instead of vanishing. */
4651
3668
  const CODEX_COLLAB_TOOL = "CodexCollab";
4652
- /** An agent's name is its path's basename: '/root/date_one' → 'date_one'. */
4653
3669
  function agentName(agentPath) {
4654
- if (typeof agentPath !== "string") return void 0;
3670
+ if (typeof agentPath !== "string") return;
4655
3671
  return agentPath.split("/").filter(Boolean).at(-1) || void 0;
4656
3672
  }
4657
- /** The collab card's input: the verb always, the rich fields only when codex
4658
- * actually filled them (measured against 0.146.0 they arrive empty — the card
4659
- * must not render five null columns to say 'wait'). */
4660
3673
  function collabInput(item) {
4661
3674
  return {
4662
3675
  tool: item.tool,
@@ -4665,9 +3678,6 @@ function collabInput(item) {
4665
3678
  ...item.model ? { model: item.model } : {}
4666
3679
  };
4667
3680
  }
4668
- /** A completed turn's answer, from its summary `items` page — the last
4669
- * `agentMessage` text. For a sub-agent's thread this is the agent's report,
4670
- * which is exactly what belongs in the anchor's `tool_result`. */
4671
3681
  function turnReport(turn) {
4672
3682
  const items = Array.isArray(turn.items) ? turn.items : [];
4673
3683
  for (let index = items.length - 1; index >= 0; index--) {
@@ -4675,29 +3685,13 @@ function turnReport(turn) {
4675
3685
  if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) return item.text;
4676
3686
  }
4677
3687
  }
4678
- /** Longest `result` worth putting in a tool card. The field is free-form and
4679
- * undocumented; anything past this is assumed to be an encoded image rather
4680
- * than a sentence, and encoded images do not go in the event log. */
4681
3688
  const MAX_IMAGE_RESULT_CHARS = 512;
4682
- const shortResult = (result) => result.length > 0 && result.length <= MAX_IMAGE_RESULT_CHARS && !result.startsWith("data:");
4683
- /**
4684
- * `file_produced.fileId` — derived from the path, not minted fresh.
4685
- *
4686
- * Two properties fall out of that and both are load-bearing: codex reports the
4687
- * same `savedPath` on the progress item and again on the completed one, so a
4688
- * derived id makes the second emission a no-op instead of a duplicate row; and
4689
- * a session rebuilt from a snapshot re-derives the same ids, so a client's
4690
- * cached URL still resolves after a park/restore.
4691
- */
3689
+ function shortResult(result) {
3690
+ return result.length > 0 && result.length <= MAX_IMAGE_RESULT_CHARS && !result.startsWith("data:");
3691
+ }
4692
3692
  function producedFileId(path) {
4693
3693
  return createHash("sha256").update(path).digest("hex").slice(0, 32);
4694
3694
  }
4695
- /** Media type from the extension, for the handful a client renders inline.
4696
- * Undefined for everything else — the route sniffs, and guessing here is how a
4697
- * text file ends up labelled `image/png`. */
4698
- function producedMediaType(path) {
4699
- return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
4700
- }
4701
3695
  const PRODUCED_MEDIA_TYPES = {
4702
3696
  png: "image/png",
4703
3697
  jpg: "image/jpeg",
@@ -4707,12 +3701,9 @@ const PRODUCED_MEDIA_TYPES = {
4707
3701
  svg: "image/svg+xml",
4708
3702
  pdf: "application/pdf"
4709
3703
  };
4710
- /**
4711
- * Codex's `SkillMetadata` as the protocol states it. `interface.shortDescription`
4712
- * beats the legacy top-level one (codex's own comment says to prefer it), and
4713
- * `enabled` defaults to true — an entry codex listed without the field is one it
4714
- * considers live, and defaulting to false would hide working skills.
4715
- */
3704
+ function producedMediaType(path) {
3705
+ return PRODUCED_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
3706
+ }
4716
3707
  function skillInfo(skill) {
4717
3708
  return {
4718
3709
  name: skill.name,
@@ -4724,19 +3715,6 @@ function skillInfo(skill) {
4724
3715
  enabled: skill.enabled !== false
4725
3716
  };
4726
3717
  }
4727
- /**
4728
- * Codex's MCP status → the protocol's, which is Claude Code's vocabulary
4729
- * ('connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled').
4730
- *
4731
- * Two inputs, and the auth one wins where it applies: a server that started
4732
- * fine but has no credential is *needs-auth*, not connected, because that is
4733
- * the thing the operator has to act on. `notLoggedIn` is the only auth value
4734
- * that means "unusable" — `unsupported` is the normal answer for a stdio server
4735
- * that has no auth concept at all.
4736
- *
4737
- * A server with no startup notification yet is 'pending', not 'connected':
4738
- * `mcpServerStatus/list` alone only proves it is *configured*.
4739
- */
4740
3718
  function mcpStatusOf(authStatus, update, hasTools) {
4741
3719
  if (update?.status === "failed") return update.failureReason === "reauthenticationRequired" ? "needs-auth" : "failed";
4742
3720
  if (update?.status === "cancelled") return "failed";
@@ -4745,7 +3723,6 @@ function mcpStatusOf(authStatus, update, hasTools) {
4745
3723
  if (hasTools) return "connected";
4746
3724
  return "pending";
4747
3725
  }
4748
- /** One `mcpServerStatus/list` entry as the protocol states it. */
4749
3726
  function mcpServerInfo(server, update) {
4750
3727
  const tools = Object.entries(server.tools ?? {}).flatMap(([key, tool]) => {
4751
3728
  if (!tool) return [];
@@ -4772,55 +3749,26 @@ function mcpServerInfo(server, update) {
4772
3749
  ...tools.length > 0 ? { tools } : {}
4773
3750
  };
4774
3751
  }
4775
- /** What the card shows while the picture is being made, and after. `savedPath`
4776
- * only exists once it lands — a client keys its preview off it, so it is a
4777
- * field rather than a sentence in the result text. */
4778
3752
  function imageGenerationInput(item) {
4779
3753
  return {
4780
3754
  ...item.revisedPrompt ? { prompt: item.revisedPrompt } : {},
4781
3755
  ...item.savedPath ? { savedPath: item.savedPath } : {}
4782
3756
  };
4783
3757
  }
4784
- /**
4785
- * The experimental per-request decision list, normalized to names: a string
4786
- * entry is its own name, a structured entry (`{acceptWithExecpolicyAmendment:
4787
- * …}`) is named by its key. Undefined = the request stated no list and the
4788
- * channel's schema enum applies. Present only under `experimentalApi: true` —
4789
- * which WorkerDeck always declares.
4790
- */
4791
3758
  function offeredDecisions(params) {
4792
3759
  const raw = params?.availableDecisions;
4793
- if (!Array.isArray(raw)) return void 0;
3760
+ if (!Array.isArray(raw)) return;
4794
3761
  const names = /* @__PURE__ */ new Set();
4795
3762
  for (const entry of raw) if (typeof entry === "string") names.add(entry);
4796
3763
  else if (entry && typeof entry === "object") for (const key of Object.keys(entry)) names.add(key);
4797
3764
  return names.size > 0 ? names : void 0;
4798
3765
  }
4799
- /**
4800
- * Decision picking for the `{decision: …}` channels (commandExecution,
4801
- * fileChange), honoring the request's own `availableDecisions`:
4802
- *
4803
- * - allow → 'accept' when offered (or when no list was stated). A request
4804
- * offering only the broader accepts ('acceptForSession',
4805
- * 'acceptWithExecpolicyAmendment') yields undefined: a one-shot allow must
4806
- * not be silently widened into a session-wide or persistent policy grant, so
4807
- * the caller answers with the denial and says why.
4808
- * - deny → 'decline', always: the response schema declares it unconditionally,
4809
- * and it was verified live against 0.146.0 answering a request whose
4810
- * availableDecisions omitted it — the turn completed cleanly. The list's job
4811
- * is to gate the accept variants, not to take "no, but keep going" away
4812
- * (its own alternative, 'cancel', would interrupt the whole turn).
4813
- * - deny+interrupt → 'cancel' (codex's deny-and-interrupt) when offered;
4814
- * otherwise 'decline', and the caller interrupts the turn itself.
4815
- */
4816
3766
  function pickDecision(behavior, interrupt, offered) {
4817
3767
  const has = (name) => !offered || offered.has(name);
4818
3768
  if (behavior === "allow") return has("accept") ? "accept" : void 0;
4819
3769
  if (interrupt && has("cancel")) return "cancel";
4820
3770
  return "decline";
4821
3771
  }
4822
- /** Codex `requestUserInput` questions in the AskUserQuestion wire shape both
4823
- * clients already render (QuestionPrompt / QuestionPromptView). */
4824
3772
  function userQuestionsFromCodex(questions) {
4825
3773
  return questions.map((question) => ({
4826
3774
  question: question.question,
@@ -4831,18 +3779,6 @@ function userQuestionsFromCodex(questions) {
4831
3779
  }))
4832
3780
  }));
4833
3781
  }
4834
- /**
4835
- * The text of a history `userMessage` item: its content entries' text parts
4836
- * joined.
4837
- *
4838
- * Image parts have no replayable representation — the bytes went to the model,
4839
- * not into the rollout we can render from — so they are named rather than
4840
- * dropped. A prompt that was *only* an image used to produce an empty string,
4841
- * which the caller read as "nothing to replay" and skipped: the turn lost its
4842
- * user row and, with it, the prompt mark the scrubber navigates by, so a resumed
4843
- * thread had answers with no visible question. A word in place of the picture is
4844
- * a smaller lie than a turn that never happened.
4845
- */
4846
3782
  function historyUserText(item) {
4847
3783
  if (!Array.isArray(item.content)) return "";
4848
3784
  let images = 0;
@@ -4855,9 +3791,6 @@ function historyUserText(item) {
4855
3791
  if (text) return text;
4856
3792
  return images > 0 ? `[${images === 1 ? "image" : `${images} images`}]` : "";
4857
3793
  }
4858
- /** The AskUserQuestion answer convention (question text → chosen label(s),
4859
- * comma-joined) mapped back to codex's id-keyed shape. Questions the client
4860
- * did not answer are absent, not empty. */
4861
3794
  function codexAnswers(questions, answers) {
4862
3795
  const out = {};
4863
3796
  for (const question of questions) {
@@ -4866,7 +3799,6 @@ function codexAnswers(questions, answers) {
4866
3799
  }
4867
3800
  return out;
4868
3801
  }
4869
- /** The two channels whose response is `{decision: …}` share their pick logic. */
4870
3802
  function decisionChannel(describe, itemId) {
4871
3803
  return {
4872
3804
  describe,
@@ -4887,11 +3819,6 @@ function decisionChannel(describe, itemId) {
4887
3819
  }
4888
3820
  };
4889
3821
  }
4890
- /**
4891
- * The ask channels, wired to the permission surface. Anything not listed here
4892
- * still gets a JSON-RPC -32601 — never a hang (an unanswered server request
4893
- * wedges the turn).
4894
- */
4895
3822
  const APPROVAL_CHANNELS = {
4896
3823
  "item/commandExecution/requestApproval": decisionChannel((raw) => {
4897
3824
  const params = raw;
@@ -4982,129 +3909,48 @@ const APPROVAL_CHANNELS = {
4982
3909
  deny: (_raw, interrupt) => ({ response: { action: interrupt ? "cancel" : "decline" } })
4983
3910
  }
4984
3911
  };
4985
- /**
4986
- * Name a subscription window by its measured length, so codex's positional
4987
- * windows land in the protocol's named vocabulary. The two names clients
4988
- * already understand are exact matches for codex's durations (300 min = 5h,
4989
- * 10080 min = 7d); anything else keeps a self-describing key rather than
4990
- * borrowing a name that would size it wrongly.
4991
- */
4992
3912
  function rateLimitWindowName(minutes) {
4993
- if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return void 0;
3913
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return;
4994
3914
  if (minutes === 300) return "five_hour";
4995
3915
  if (minutes === 10080) return "seven_day";
4996
3916
  return `window_${minutes}m`;
4997
3917
  }
4998
- /**
4999
- * The Codex engine, over the binary's `app-server` JSON-RPC surface: ONE
5000
- * `codex app-server` child per *session* (spawned lazily, held across turns),
5001
- * streaming `item/agentMessage/delta` and the reasoning deltas token-by-token
5002
- * (`streaming: 'token'`). Follows `SessionRunner`'s event-log/seq/status
5003
- * discipline with `AiSdkRunner`'s turn-chain (one turn at a time; sendMessage
5004
- * queues). The first codex transport was `codex exec --experimental-json` (one
5005
- * child per turn) — retired because its JSONL carries no partial messages, so
5006
- * a turn could never stream.
5007
- *
5008
- * A dead child is a failed *turn*, not a failed session: the thread persists
5009
- * on disk, the connection is dropped, and the next message spawns a fresh
5010
- * child that `thread/resume`s the same thread id.
5011
- */
5012
3918
  var CodexRunner = class {
5013
3919
  id;
5014
3920
  createdAt;
5015
3921
  #config;
5016
- /** {@link CodexRunnerConfig.cwd}, checked once in the constructor. */
5017
3922
  #cwd;
5018
- #events = [];
3923
+ #log = new EventLog();
5019
3924
  #subscribers = new SubscriberSet();
5020
- #seq = 0;
5021
- /**
5022
- * Latest context-window reading, retained from the last `context_usage` this
5023
- * runner emitted so `GET /sessions` can answer it without an attach — see
5024
- * `SessionInfo.contextUsage`. Folded in the emit path, so it is by
5025
- * construction the same number the transcript last drew.
5026
- */
5027
- #contextUsage;
5028
- #activityCount = 0;
5029
- /**
5030
- * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
5031
- * never truncated — it still carries the state-bearing events (`capabilities`,
5032
- * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
5033
- * but `subscribe()` skips transcript *content* strictly below this mark, so a
5034
- * replay does not resurrect a cleared conversation. A later reset supersedes
5035
- * an earlier one by overwriting it.
5036
- */
5037
- #resetSeq = 0;
5038
3925
  #status = "starting";
3926
+ #statusDetail;
5039
3927
  #sdkSessionId;
5040
3928
  #model;
5041
3929
  #permissionMode;
5042
3930
  #reasoningEffort;
5043
- /** What the binary said the profile's defaults resolve to (thread/start
5044
- * response) — lets `setModel(undefined)` mean "back to the default" even
5045
- * though a turn/start override persists for subsequent turns. */
5046
3931
  #resolvedModel;
5047
- /** Last reported ChatGPT plan, so `plan_info` is emitted once per change. */
5048
3932
  #planType;
5049
3933
  #resolvedEffort;
5050
3934
  #queue = [];
5051
3935
  #turnChain = Promise.resolve();
5052
3936
  #activeTurn;
5053
3937
  #connection;
5054
- /** Per-child, from `config/read`; undefined = read failed, send the bare shape. */
5055
3938
  #workspaceWrite;
5056
3939
  #threadLoaded = false;
5057
3940
  #numTurns = 0;
5058
3941
  #totalCostUsd;
5059
- #lastActivityAt;
5060
3942
  #started = false;
5061
3943
  #closed = false;
5062
- /** Session temp dir for image attachments (`localImage` takes host paths). */
5063
3944
  #imageDir;
5064
- /** Pending server→client approvals, keyed by the surfaced request id. */
5065
3945
  #approvals = /* @__PURE__ */ new Map();
5066
- /** True from start() until the resume backfill (the turn chain's first link)
5067
- * settles — while set, sendMessage defers its user_message echo behind the
5068
- * chain so a new turn can never precede or interleave the replayed history. */
5069
3946
  #backfillPending = false;
5070
- /** The resumed thread's prior turns, stashed by {@link #ensureThread} from
5071
- * the ONE thread/resume the backfill consumes (`partial` = the response's
5072
- * turnsBackwardsCursor said older turns exist beyond this page). A mid-life
5073
- * reconnect also goes through thread/resume, but with no backfill pending
5074
- * nothing is stashed — history is never replayed twice. */
5075
3947
  #resumedHistory;
5076
- /** Set around history replay: {@link #emit} stamps `replay: true` onto the
5077
- * message events the live item mapping produces. */
5078
3948
  #replayingHistory = false;
5079
- /** Last `skills` payload emitted, serialized — the comparison that keeps a
5080
- * `skills/changed` storm (the watcher fires per touched file) from filling
5081
- * the event log with identical lists. */
5082
3949
  #skillsFingerprint;
5083
- /** In-flight `skills/list`, so a burst of `skills/changed` makes one call.
5084
- * The pending promise is reused rather than queued: the request has no
5085
- * arguments, so a second one would ask the same question. */
5086
3950
  #skillsRefresh;
5087
- /** Host paths already announced via `file_produced`, so the same picture
5088
- * reported on both the progress and the completed item registers once. */
5089
3951
  #producedPaths = /* @__PURE__ */ new Set();
5090
- /** Per-server liveness, accumulated from `mcpServer/startupStatus/updated`.
5091
- * `mcpServerStatus/list` does not carry a status field at all, so without
5092
- * this every server would read as "configured" and never as up or down. */
5093
3952
  #mcpStatus = /* @__PURE__ */ new Map();
5094
- /** The spawned agents, keyed by their thread ids — the attribution table
5095
- * behind `parentToolUseId` and the rollup behind `info().subagents`. Runner-
5096
- * level, not per-turn: an agent's thread outlives the root turn that spawned
5097
- * it, and only the child process dying (or the session closing) ends them
5098
- * all — see the module doc in `subagents.ts`. */
5099
3953
  #agents = new CodexAgentTracker();
5100
- /** Threads that belonged to a conversation this session has cleared — the
5101
- * agents that were still running when it happened. Their notifications keep
5102
- * arriving on the same connection (a clear does not interrupt them and does
5103
- * not drop the child), and without this {@link CodexRunner.#agentFor} would
5104
- * mint them a fresh anchor and stream the cleared conversation's agent work
5105
- * into the new one. Never pruned: it is a handful of uuids for the session's
5106
- * life, and a late report from a long-dead agent is exactly what it exists to
5107
- * catch. */
5108
3954
  #clearedThreads = /* @__PURE__ */ new Set();
5109
3955
  constructor(config, id = randomUUID()) {
5110
3956
  const mode = config.permissionMode ?? "default";
@@ -5120,15 +3966,8 @@ var CodexRunner = class {
5120
3966
  this.id = id;
5121
3967
  this.createdAt = Date.now();
5122
3968
  }
5123
- /** The complete child environment — spawn env replaces process.env wholesale,
5124
- * so this must carry everything a shell would, with the profile's CODEX_HOME
5125
- * pin winning over operator env. */
5126
3969
  #childEnv() {
5127
- const base = this.#config.env ?? process.env;
5128
- const env = {};
5129
- for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
5130
- if (this.#config.codexHome) env.CODEX_HOME = this.#config.codexHome;
5131
- return env;
3970
+ return codexChildEnv(this.#config.env ?? process.env, this.#config.codexHome);
5132
3971
  }
5133
3972
  get status() {
5134
3973
  return this.#status;
@@ -5137,7 +3976,7 @@ var CodexRunner = class {
5137
3976
  return this.#sdkSessionId;
5138
3977
  }
5139
3978
  get lastSeq() {
5140
- return this.#seq;
3979
+ return this.#log.seq;
5141
3980
  }
5142
3981
  get pendingApprovals() {
5143
3982
  return [...this.#approvals.values()].map((pending) => pending.request);
@@ -5155,36 +3994,22 @@ var CodexRunner = class {
5155
3994
  permissionMode: this.#permissionMode,
5156
3995
  canBypassPermissions: true,
5157
3996
  createdAt: this.createdAt,
5158
- lastSeq: this.#seq,
5159
- activityCount: this.#activityCount,
5160
- contextUsage: this.#contextUsage,
3997
+ lastSeq: this.#log.seq,
3998
+ activityCount: this.#log.activityCount,
3999
+ proseCount: this.#log.proseCount,
4000
+ contextUsage: this.#log.contextUsage,
5161
4001
  pendingPermissionCount: this.#approvals.size,
5162
4002
  meta: this.#config.meta,
5163
4003
  scope: this.#config.scope,
5164
- title: this.#title(),
4004
+ title: sessionTitle(this.#config),
5165
4005
  totalCostUsd: this.#totalCostUsd,
5166
4006
  numTurns: this.#numTurns || void 0,
5167
- lastActivityAt: this.#lastActivityAt,
4007
+ lastActivityAt: this.#log.lastActivityAt,
5168
4008
  subagents: this.#agents.list()
5169
4009
  };
5170
4010
  }
5171
- #title() {
5172
- const metaTitle = this.#config.meta?.title;
5173
- if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
5174
- const prompt = this.#config.prompt;
5175
- if (!prompt) return void 0;
5176
- return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
5177
- }
5178
- /** Host-facing rename: writes `meta.title`, which `#title()` prefers. Clearing
5179
- * it (undefined) restores the derived title. The engine is never told. */
5180
4011
  setTitle(title) {
5181
- const meta = { ...this.#config.meta };
5182
- if (title) meta.title = title;
5183
- else delete meta.title;
5184
- this.#config = {
5185
- ...this.#config,
5186
- meta
5187
- };
4012
+ this.#config = withTitle(this.#config, title);
5188
4013
  }
5189
4014
  start() {
5190
4015
  if (this.#started) return this.#turnChain;
@@ -5198,20 +4023,6 @@ var CodexRunner = class {
5198
4023
  if (!this.#config.prompt && !this.#config.resume) this.#probeSkills();
5199
4024
  return this.#turnChain;
5200
4025
  }
5201
- /**
5202
- * One-time transcript notice for the codex trust gap: a `default`-mode
5203
- * session (read-only sandbox) on an untrusted cwd has its
5204
- * `.codex/config.toml` — MCP servers included — silently ignored, and the
5205
- * app-server surface has no trust prompt to say so (the TUI's prompt is
5206
- * where the entry normally gets written). `acceptEdits`/`bypassPermissions`
5207
- * sessions are exempt because their `thread/start` (workspace-write /
5208
- * danger-full-access sandbox) writes the trust entry itself and loads the
5209
- * config — measured against 0.146.0 and 0.149.0; a notice there would be
5210
- * false. Emitted as `session_error`, which both clients render as an inline
5211
- * notice while the session keeps running (the backfill-history precedent),
5212
- * so nothing new rides the wire. Every degrade path is silence: a false
5213
- * warning on a trusted project is worse than a missed one.
5214
- */
5215
4026
  #warnUntrustedProject() {
5216
4027
  if (this.#permissionMode !== "default") return;
5217
4028
  try {
@@ -5229,20 +4040,6 @@ var CodexRunner = class {
5229
4040
  });
5230
4041
  } catch {}
5231
4042
  }
5232
- /**
5233
- * List skills over a **throwaway** connection, for a session with nothing else
5234
- * to do yet.
5235
- *
5236
- * `skills/list` needs a live child but not a thread, so this spawns one, asks,
5237
- * and closes it — rather than bringing up the session's own child early and
5238
- * leaving a codex process parked behind every session someone created and
5239
- * never typed into. The session's real connection re-lists when it arrives;
5240
- * the fingerprint compare in {@link #refreshSkills} makes that a no-op.
5241
- *
5242
- * Entirely best-effort and never awaited: a missing binary, a failed spawn or
5243
- * a rejected handshake here must not turn a session that has not started into
5244
- * a session that failed.
5245
- */
5246
4043
  async #probeSkills() {
5247
4044
  let connection;
5248
4045
  try {
@@ -5253,28 +4050,10 @@ var CodexRunner = class {
5253
4050
  connection?.close();
5254
4051
  }
5255
4052
  }
5256
- /**
5257
- * A handshaken child that is **not** the session's — for the questions a
5258
- * client can ask before the session has anything to run (its skills, its MCP
5259
- * servers). The caller owns it and must close it.
5260
- *
5261
- * No onNotification/onRequest/onClose wiring on purpose: this child answers
5262
- * one question and goes away, so its notifications are noise and its death is
5263
- * not the session's problem. The alternative — bringing the session's real
5264
- * child up early — would park a codex process behind every session someone
5265
- * created and never typed into.
5266
- */
5267
4053
  async #openScratchConnection() {
5268
4054
  const connection = this.#config.connectFn({ env: this.#childEnv() });
5269
4055
  try {
5270
- await connection.request("initialize", {
5271
- clientInfo: {
5272
- name: "workerdeck",
5273
- title: "WorkerDeck",
5274
- version: `protocol-${PROTOCOL_VERSION}`
5275
- },
5276
- capabilities: { experimentalApi: true }
5277
- });
4056
+ await connection.request("initialize", INITIALIZE_PARAMS);
5278
4057
  connection.notify("initialized");
5279
4058
  return connection;
5280
4059
  } catch (error) {
@@ -5309,12 +4088,6 @@ var CodexRunner = class {
5309
4088
  this.#queue.push({ input });
5310
4089
  this.#scheduleTurn();
5311
4090
  }
5312
- /**
5313
- * App-server input for a message with attachments: images land in a session
5314
- * temp dir and travel as `localImage` host paths, text files inline into the
5315
- * prompt in the shared named envelope, PDF has no representation (the
5316
- * gateway's 415 normally refuses it first).
5317
- */
5318
4091
  #buildInput(text, attachments) {
5319
4092
  const parts = [];
5320
4093
  for (const attachment of attachments) {
@@ -5347,8 +4120,6 @@ var CodexRunner = class {
5347
4120
  });
5348
4121
  return parts;
5349
4122
  }
5350
- /** Resolve a pending approval. Returns false if the id is unknown (e.g.
5351
- * timed out, or already settled by codex itself). */
5352
4123
  resolvePermission(requestId, decision) {
5353
4124
  const pending = this.#approvals.get(requestId);
5354
4125
  if (!pending) return false;
@@ -5364,42 +4135,12 @@ var CodexRunner = class {
5364
4135
  await this.#interruptTurn();
5365
4136
  await this.#turnChain;
5366
4137
  }
5367
- /**
5368
- * Reset the conversation: a **fresh thread on the same session**.
5369
- *
5370
- * Codex has no clear/reset RPC — `thread/compact/start` summarises and
5371
- * continues, `thread/fork` makes a second thread, and neither is "same
5372
- * session, empty context". So the analog is to stop resuming the old thread
5373
- * and start a new one, which is the path a dead child already takes minus the
5374
- * resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
5375
- * resumable from `GET /sdk-sessions`.
5376
- *
5377
- * Two things it does on the way through, both mirroring the Claude engine's
5378
- * SDK-driven reset (`engines/claude/runner.ts`):
5379
- *
5380
- * 1. **The new thread id is adopted before `conversation_reset` is emitted**,
5381
- * whenever a child is already up — the eager `thread/start` costs no
5382
- * tokens and no model call, and it is what keeps the dormant record from
5383
- * ever naming the conversation that was just cleared. With no child there
5384
- * is nothing to start against and the id is simply dropped; the parking
5385
- * service treats a resumable session with no engine session id as one with
5386
- * nothing to come back to, and forgets the stale record.
5387
- * 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
5388
- * arm. Codex cannot re-poll it the way Claude does — the only source is
5389
- * `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
5390
- * no reading at all until the next turn runs, and the protocol's rule
5391
- * applies: render nothing rather than a stale ring or a 0%.
5392
- *
5393
- * The turn counter stays monotonic across this, on purpose (it is an unread
5394
- * cursor, not an item count), and so does `activityCount` — `#emit` owns both.
5395
- */
5396
4138
  async clearContext() {
5397
4139
  if (this.#closed) throw new Error("session is closed");
5398
4140
  const run = this.#turnChain.then(() => this.#clearNow());
5399
4141
  this.#turnChain = run.then(() => void 0, () => void 0);
5400
4142
  await run;
5401
4143
  }
5402
- /** The clear itself, only ever called as a turn-chain link. */
5403
4144
  async #clearNow() {
5404
4145
  if (this.#closed) throw new Error("session is closed");
5405
4146
  const previousThread = this.#sdkSessionId;
@@ -5424,8 +4165,6 @@ var CodexRunner = class {
5424
4165
  sdkSessionId: this.#sdkSessionId
5425
4166
  });
5426
4167
  }
5427
- /** Address the in-flight turn only (no approval sweep) — also the follow-up
5428
- * for a deny+interrupt whose wire decision couldn't carry the interrupt. */
5429
4168
  async #interruptTurn() {
5430
4169
  const active = this.#activeTurn;
5431
4170
  const connection = this.#connection;
@@ -5494,37 +4233,15 @@ var CodexRunner = class {
5494
4233
  });
5495
4234
  this.#setStatus("closed");
5496
4235
  }
5497
- /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
5498
- * "show everything" on one row, so a per-runner seq index would be a map
5499
- * maintained on every emit to save a walk nobody makes twice a minute. */
5500
4236
  eventAt(seq) {
5501
- return this.#events.find((event) => event.seq === seq);
4237
+ return this.#log.at(seq);
5502
4238
  }
5503
4239
  subscribe(listener, afterSeq = 0, options) {
5504
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
4240
+ return this.#subscribers.subscribe(this.#log.events, listener, afterSeq, options, this.#log.resetSeq);
5505
4241
  }
5506
4242
  #scheduleTurn() {
5507
4243
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
5508
4244
  }
5509
- /**
5510
- * Read `[sandbox_workspace_write]` as codex resolves it for this session's
5511
- * cwd, once per child, so {@link CodexRunner.#turnSandboxPolicy} can restate
5512
- * it verbatim.
5513
- *
5514
- * Why this exists at all: `turn/start`'s object-form sandbox policy is
5515
- * serde-defaulted field by field, so `{type: 'workspaceWrite'}` bare means
5516
- * `networkAccess: false, writableRoots: []` NO MATTER what the operator
5517
- * configured — and we must keep sending the object every turn, because
5518
- * restating it is what makes a between-turns permission-mode switch take
5519
- * effect. Measured against 0.149.0 with `network_access = true` set: the
5520
- * bare object produced `curl: (6) Could not resolve host`, the fully-stated
5521
- * object and an omitted policy both produced `200`. `read-only` is not
5522
- * affected — the setting is scoped to workspace-write, as its name says, and
5523
- * a read-only sandbox has no network either way.
5524
- *
5525
- * A failure here is not fatal: `#workspaceWrite` stays undefined and we send
5526
- * the bare shape, which is exactly the behaviour that shipped before.
5527
- */
5528
4245
  async #readWorkspaceWrite(connection) {
5529
4246
  this.#workspaceWrite = void 0;
5530
4247
  try {
@@ -5539,7 +4256,6 @@ var CodexRunner = class {
5539
4256
  };
5540
4257
  } catch {}
5541
4258
  }
5542
- /** The mode's turn-level sandbox policy, with the operator's workspace-write settings intact. */
5543
4259
  #turnSandboxPolicy() {
5544
4260
  const policy = TURN_SANDBOX_BY_MODE[this.#permissionMode];
5545
4261
  if (policy?.type !== "workspaceWrite" || !this.#workspaceWrite) return policy;
@@ -5548,13 +4264,6 @@ var CodexRunner = class {
5548
4264
  ...this.#workspaceWrite
5549
4265
  };
5550
4266
  }
5551
- /**
5552
- * The session's live connection with its thread loaded, (re)building both as
5553
- * needed: spawn + `initialize`/`initialized` on a fresh child, then
5554
- * `thread/start` (new) or `thread/resume` (a create-request `resume`, or a
5555
- * thread orphaned by a dead child). The response's resolved model/effort are
5556
- * kept so per-turn overrides can name "the profile default" explicitly.
5557
- */
5558
4267
  async #ensureThread() {
5559
4268
  if (this.#closed) throw new Error("session is closed");
5560
4269
  let connection = this.#connection;
@@ -5577,18 +4286,11 @@ var CodexRunner = class {
5577
4286
  this.#activeTurn?.reject(new Error(message));
5578
4287
  });
5579
4288
  try {
5580
- await connection.request("initialize", {
5581
- clientInfo: {
5582
- name: "workerdeck",
5583
- title: "WorkerDeck",
5584
- version: `protocol-${PROTOCOL_VERSION}`
5585
- },
5586
- capabilities: { experimentalApi: true }
5587
- });
4289
+ await connection.request("initialize", INITIALIZE_PARAMS);
5588
4290
  } catch (error) {
5589
4291
  connection.close();
5590
4292
  if (this.#connection === connection) this.#connection = void 0;
5591
- 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);
4293
+ 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, { cause: error });
5592
4294
  throw error;
5593
4295
  }
5594
4296
  connection.notify("initialized");
@@ -5619,21 +4321,6 @@ var CodexRunner = class {
5619
4321
  this.#refreshSkills(connection);
5620
4322
  return connection;
5621
4323
  }
5622
- /**
5623
- * Re-read `skills/list` and publish it, if it changed.
5624
- *
5625
- * **`cwds` is passed explicitly, and must be.** The schema documents the empty
5626
- * case as "the current session working directory", which reads like the
5627
- * thread's — it is not. Measured against 0.146.0: with no `cwds`, and *after*
5628
- * a `thread/start` carrying this session's cwd, the response comes back keyed
5629
- * to the app-server child's own process directory (for WorkerDeck, wherever
5630
- * the gateway was launched) and reports no repo-scoped skills at all. So a
5631
- * project's own `.codex/skills/**` were invisible until this argument existed.
5632
- *
5633
- * Best-effort throughout. A binary too old to know the method, a broken
5634
- * manifest, a child that died mid-call — none of that is worth failing a
5635
- * session over, and the panel simply stays absent.
5636
- */
5637
4324
  async #refreshSkills(connection) {
5638
4325
  if (this.#skillsRefresh) return this.#skillsRefresh;
5639
4326
  const run = (async () => {
@@ -5663,33 +4350,8 @@ var CodexRunner = class {
5663
4350
  this.#skillsRefresh = run;
5664
4351
  return run;
5665
4352
  }
5666
- /**
5667
- * The session's MCP servers, live from the binary.
5668
- *
5669
- * Two sources merged, because codex splits them: `mcpServerStatus/list` says
5670
- * what is configured and what each server exposes (including every tool's
5671
- * full JSON Schema, which the Agent SDK does not give us), and the
5672
- * `mcpServer/startupStatus/updated` notifications say which of them are
5673
- * actually up.
5674
- *
5675
- * Answers **before the session has connected**, over a throwaway child, for
5676
- * the same reason the skill list does: a codex session spawns nothing until
5677
- * it has work, and a panel that said "no MCP servers configured" until the
5678
- * first turn would be stating something false about the operator's config.
5679
- * The request blocks until the servers are enumerated (measured: complete on
5680
- * the very first call), so there is no half-populated answer to race.
5681
- *
5682
- * Resolves undefined only when there is genuinely nothing to say — the
5683
- * session is closed, or the child could not be spoken to. The route turns
5684
- * that into a 501.
5685
- *
5686
- * **Listing only.** There is no per-server reconnect or toggle on this
5687
- * transport — hence no `reconnectMcpServer`/`setMcpServerEnabled` here, and
5688
- * `ENGINE_CAPABILITIES.codex.mcpServerActions: false` so clients render the
5689
- * panel read-only instead of offering buttons that cannot work.
5690
- */
5691
4353
  async mcpServers() {
5692
- if (this.#closed) return void 0;
4354
+ if (this.#closed) return;
5693
4355
  const live = this.#connection;
5694
4356
  let scratch;
5695
4357
  try {
@@ -5700,15 +4362,6 @@ var CodexRunner = class {
5700
4362
  scratch?.close();
5701
4363
  }
5702
4364
  }
5703
- /**
5704
- * Announce a file the ENGINE wrote on the host, so a client can fetch it
5705
- * without the operator having declared its directory as a host-file root.
5706
- *
5707
- * Deliberately narrow: only paths codex reports as *written by its own tool*
5708
- * belong here. A path the model merely read (`imageView`) is an agent-chosen
5709
- * claim, and those keep going through `/fs/*` and its root allowlist — see
5710
- * the note on `file_produced` in the protocol.
5711
- */
5712
4365
  #emitFileProduced(path, toolUseId) {
5713
4366
  if (this.#producedPaths.has(path)) return;
5714
4367
  this.#producedPaths.add(path);
@@ -5726,16 +4379,6 @@ var CodexRunner = class {
5726
4379
  toolUseId
5727
4380
  });
5728
4381
  }
5729
- /**
5730
- * On resume, replay the thread's prior turns as `replay: true` events,
5731
- * seq'd before any live turn — the SessionRunner backfill contract, fed
5732
- * from `thread/resume`'s own `thread.turns`. When the resume response says
5733
- * that page is partial (`turnsBackwardsCursor`), the FULL rollout history
5734
- * is fetched via `thread/read {includeTurns: true}` instead — and if even
5735
- * that fails, the partial page is replayed under a visible notice rather
5736
- * than silently posing as the whole thread. Best-effort like the Claude
5737
- * backfill: an unreadable history never blocks the resume itself.
5738
- */
5739
4382
  async #backfillHistory() {
5740
4383
  try {
5741
4384
  if (this.#closed) return;
@@ -5764,7 +4407,6 @@ var CodexRunner = class {
5764
4407
  this.#setStatus("idle");
5765
4408
  }
5766
4409
  }
5767
- /** Replay historical turns through the SAME item mapping the live path uses. */
5768
4410
  #replayTurns(turns) {
5769
4411
  for (const turn of turns) {
5770
4412
  if (this.#closed) return;
@@ -5793,8 +4435,6 @@ var CodexRunner = class {
5793
4435
  }
5794
4436
  }
5795
4437
  }
5796
- /** Fresh per-turn state — one per live turn, and one per REPLAYED turn (the
5797
- * nonce is the item-id namespace, and its per-turn-ness is the invariant). */
5798
4438
  #newTurnState() {
5799
4439
  return {
5800
4440
  nonce: randomUUID(),
@@ -5883,10 +4523,6 @@ var CodexRunner = class {
5883
4523
  }
5884
4524
  this.#notifications[method]?.(params);
5885
4525
  }
5886
- /** Whether a notification is about the session's own thread. A notification
5887
- * with no `threadId` counts as the root's: every thread-scoped method the
5888
- * schema defines carries one, so an absent id means an older or narrower
5889
- * shape, not a sub-agent. */
5890
4526
  #isRootThread(params) {
5891
4527
  const threadId = this.#threadIdOf(params);
5892
4528
  if (threadId === void 0) return true;
@@ -5896,42 +4532,18 @@ var CodexRunner = class {
5896
4532
  const threadId = params?.threadId;
5897
4533
  return typeof threadId === "string" ? threadId : void 0;
5898
4534
  }
5899
- /**
5900
- * The agent behind a notification's `threadId` — the attribution every item
5901
- * and delta handler asks before emitting, so two agents streaming
5902
- * concurrently into this one connection come apart again by the id each
5903
- * frame carries, never by any mutable "current agent".
5904
- *
5905
- * A non-root thread with no record still gets one: a thread emitting items on
5906
- * this connection *is* an agent, whatever announced it (codex runs threads of
5907
- * its own for review/compact, and a `subAgentActivity` could in principle be
5908
- * missed) — the claude tracker's nested-event fallback, on a stronger signal.
5909
- * The minted record is label-less and its anchor is authored here, because an
5910
- * attributed event whose parent id matches no top-level `tool_use` would
5911
- * render inline rather than as a frame; a late `started` edge fills the name
5912
- * in. Root-thread traffic — and, defensively, the pre-thread shapes with no
5913
- * id at all — stays unattributed (`undefined`).
5914
- */
5915
4535
  #agentFor(params) {
5916
4536
  const threadId = this.#threadIdOf(params);
5917
- if (threadId === void 0 || threadId === this.#sdkSessionId) return void 0;
4537
+ if (threadId === void 0 || threadId === this.#sdkSessionId) return;
5918
4538
  const known = this.#agents.get(threadId);
5919
4539
  if (known) return known;
5920
- if (this.#clearedThreads.has(threadId)) return void 0;
4540
+ if (this.#clearedThreads.has(threadId)) return;
5921
4541
  const nonce = this.#activeTurn?.nonce ?? "codex";
5922
4542
  const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
5923
4543
  record.anchored = true;
5924
4544
  this.#emitToolUse(record.toolUseId, CODEX_AGENT_TOOL, { agentThreadId: threadId });
5925
4545
  return record;
5926
4546
  }
5927
- /**
5928
- * A child thread's `turn/completed` is that AGENT's completion — the one
5929
- * codex sends (`subAgentActivity` has no 'completed' kind, verified live).
5930
- * The verdict is the turn's own status, and the report is the completed
5931
- * turn's final message, delivered as the anchor's `tool_result` so the row
5932
- * settles exactly the way a claude `Task`'s does. Deliberately not gated on
5933
- * `#activeTurn`: an agent finishing between root turns still finished.
5934
- */
5935
4547
  #settleAgentTurn(params) {
5936
4548
  const threadId = this.#threadIdOf(params);
5937
4549
  const record = threadId ? this.#agents.get(threadId) : void 0;
@@ -5942,11 +4554,6 @@ var CodexRunner = class {
5942
4554
  const report = (turn ? turnReport(turn) : void 0) ?? turn?.error?.message ?? (status === "done" ? "" : turn?.status ?? "failed");
5943
4555
  this.#emitToolResult(record.toolUseId, report, status === "failed");
5944
4556
  }
5945
- /** Reasoning deltas arrive on two methods that differ only in which section
5946
- * counter they advance; the section key carries the method so the two streams
5947
- * never share a boundary (and item ids are per-thread, so two agents' streams
5948
- * never share one either). Section boundaries (a new summary/content entry)
5949
- * render as paragraph breaks — the completed item joins sections with '\n\n'. */
5950
4557
  #reasoningDelta(method) {
5951
4558
  return (params) => {
5952
4559
  const active = this.#activeTurn;
@@ -5954,26 +4561,23 @@ var CodexRunner = class {
5954
4561
  const payload = params;
5955
4562
  if (typeof payload?.delta !== "string" || !payload.delta) return;
5956
4563
  const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
5957
- const key = `${payload.itemId ?? ""}:${method}`;
4564
+ const agent = this.#agentFor(params);
4565
+ const key = `${agent?.toolUseId ?? ""}:${payload.itemId ?? ""}:${method}`;
5958
4566
  const previous = active.sectionIndex.get(key);
5959
4567
  active.sectionIndex.set(key, index);
5960
4568
  const separator = previous !== void 0 && index > previous ? "\n\n" : "";
5961
4569
  this.#emitDelta({
5962
4570
  type: "thinking_delta",
5963
4571
  thinking: separator + payload.delta
5964
- }, this.#agentFor(params)?.toolUseId ?? null);
4572
+ }, agent?.toolUseId ?? null);
5965
4573
  };
5966
4574
  }
5967
- /** One item-progress handler serves `item/started` and `item/updated`. */
5968
4575
  #itemProgress = (params) => {
5969
4576
  const active = this.#activeTurn;
5970
4577
  if (!active) return;
5971
4578
  const item = params?.item;
5972
4579
  if (item) this.#handleItemProgress(item, active, this.#agentFor(params));
5973
4580
  };
5974
- /** The notification dispatch table — every method the child emits that this
5975
- * runner maps, in one place. Handlers read `this.#activeTurn` themselves:
5976
- * dispatch is synchronous, so the read is the same one the old switch made. */
5977
4581
  #notifications = {
5978
4582
  "thread/started": (params) => {
5979
4583
  const thread = params?.thread;
@@ -6068,26 +4672,17 @@ var CodexRunner = class {
6068
4672
  return;
6069
4673
  }
6070
4674
  },
6071
- "error": (params) => {
4675
+ error: (params) => {
6072
4676
  const active = this.#activeTurn;
6073
4677
  const error = params?.error;
6074
4678
  if (active && typeof error?.message === "string") active.lastError = error.message;
6075
4679
  }
6076
4680
  };
6077
- /** Answer a server→client request: the ask channels become pending
6078
- * permission requests; anything else gets a JSON-RPC -32601 rather than a
6079
- * hang (an unanswered server request wedges the turn). */
6080
4681
  async #answerServerRequest(method, params, wireId) {
6081
4682
  const channel = APPROVAL_CHANNELS[method];
6082
4683
  if (channel) return this.#requestApproval(channel, method, params, wireId);
6083
4684
  throw new JsonRpcError(-32601, `workerdeck does not handle server request '${method}'`);
6084
4685
  }
6085
- /**
6086
- * Surface one ask-channel request as a pending {@link PermissionRequest};
6087
- * the returned promise is the JSON-RPC response, resolved when a
6088
- * `permission_decision` lands — or by the timeout, an interrupt, turn end,
6089
- * session close, or codex resolving it itself. Never left hanging.
6090
- */
6091
4686
  #requestApproval(channel, method, params, wireId) {
6092
4687
  if (method === "item/tool/requestUserInput") {
6093
4688
  const behavior = this.#config.questionBehavior ?? "ask";
@@ -6126,9 +4721,6 @@ var CodexRunner = class {
6126
4721
  if (this.#activeTurn) this.#setStatus("awaiting_approval");
6127
4722
  });
6128
4723
  }
6129
- /** 'auto'/'deny' sessions settle codex questions synchronously instead of
6130
- * pending. Request/resolved events still fire so transcripts and job
6131
- * webhooks show what was chosen. */
6132
4724
  #resolveQuestionByPolicy(channel, params, mode) {
6133
4725
  const itemId = channel.itemId(params);
6134
4726
  const request = {
@@ -6163,13 +4755,6 @@ var CodexRunner = class {
6163
4755
  });
6164
4756
  return { answers };
6165
4757
  }
6166
- /**
6167
- * Settle one pending approval: pick the channel's wire response for the
6168
- * decision, answer the JSON-RPC request, and emit `permission_resolved`.
6169
- * An allow the request offered no plain accept for becomes the channel's
6170
- * denial, said out loud — never a silently widened grant, and never a
6171
- * decision the request didn't offer.
6172
- */
6173
4758
  #settleApproval(id, pending, decision, resolvedBy) {
6174
4759
  clearTimeout(pending.timer);
6175
4760
  this.#approvals.delete(id);
@@ -6197,9 +4782,6 @@ var CodexRunner = class {
6197
4782
  if (behavior === "deny" && decision.behavior === "deny" && decision.interrupt && sent.decision !== "cancel") this.#interruptTurn();
6198
4783
  if (!this.#closed && this.#approvals.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
6199
4784
  }
6200
- /** Tool calls surface as tool_use when they start; text and reasoning stream
6201
- * natively via the delta notifications. `agent` is the sub-agent whose thread
6202
- * the item arrived on — undefined for the session's own. */
6203
4785
  #handleItemProgress(item, active, agent) {
6204
4786
  const id = `${active.nonce}:${item.id}`;
6205
4787
  if (item.type === "subAgentActivity") {
@@ -6243,14 +4825,6 @@ var CodexRunner = class {
6243
4825
  }
6244
4826
  });
6245
4827
  }
6246
- /**
6247
- * The completed-item mapping, one handler per member of the {@link AppServerItem}
6248
- * union. The mapped type is the invariant made checkable: model a new item
6249
- * type in `types.ts` and this table fails to compile until it says what the
6250
- * item becomes on the wire — the old switch silently fell through to the
6251
- * unknown-item passthrough instead. (The runtime still receives types the
6252
- * union has never heard of; those take the passthrough above.)
6253
- */
6254
4828
  #itemCompleted = {
6255
4829
  userMessage: (item, active, _id, agent) => {
6256
4830
  if (!agent) return;
@@ -6390,10 +4964,6 @@ var CodexRunner = class {
6390
4964
  uuid
6391
4965
  });
6392
4966
  }
6393
- /** `agent` (rather than a bare parent id) because a nested call is also the
6394
- * agent's progress reading: `SubagentInfo.toolCount` ticks here, once per
6395
- * card — the `counted` set is what keeps an upserted re-emission (the
6396
- * finished imageGeneration input) from counting one picture twice. */
6397
4967
  #emitToolUse(id, name, input, agent) {
6398
4968
  if (agent && !agent.counted.has(id)) {
6399
4969
  agent.counted.add(id);
@@ -6433,14 +5003,6 @@ var CodexRunner = class {
6433
5003
  uuid: `${toolUseId}-result`
6434
5004
  });
6435
5005
  }
6436
- /**
6437
- * Per-turn usage re-mapped to the Anthropic accounting convention the whole
6438
- * stack assumes (GOTCHAS §Codex engine): OpenAI's `inputTokens` includes the
6439
- * cached share, so input excludes it (else queue token budgets double-count
6440
- * cache-heavy runs); reasoning tokens are billed output; `totalCostUsd: 0` =
6441
- * unknown, the AiSdkRunner precedent. Usage is summed from the turn's
6442
- * `thread/tokenUsage/updated` notifications — `turn/completed` carries none.
6443
- */
6444
5006
  #finishTurn(kind, startedAt, active, errors) {
6445
5007
  for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
6446
5008
  behavior: "deny",
@@ -6468,23 +5030,6 @@ var CodexRunner = class {
6468
5030
  this.#emitContextUsage(active);
6469
5031
  this.#setStatus("idle");
6470
5032
  }
6471
- /**
6472
- * Subscription windows, mapped onto the protocol's named vocabulary.
6473
- *
6474
- * The shapes disagree: codex reports windows *positionally* (`primary` /
6475
- * `secondary`) with a length in minutes, while `RateLimitInfo.rateLimitType`
6476
- * is a name whose meaning clients already know — iOS labels `seven_day` as
6477
- * "Weekly" and derives the pace marker's denominator from it. Naming the
6478
- * window by its measured duration is therefore the honest mapping rather
6479
- * than a borrowed one: codex's primary window is 10080 minutes, which *is*
6480
- * seven days. A duration we have no name for keeps an explicit
6481
- * `window_<n>m` key — clients render it verbatim and simply draw no pace
6482
- * marker, which beats mislabeling it as a week.
6483
- *
6484
- * `status` is 'allowed' by construction (the session is running), matching
6485
- * `rateLimitEventsFromUsage`; codex's `rateLimitReachedType` is the one
6486
- * signal that a limit is actually biting, so it becomes 'rejected'.
6487
- */
6488
5033
  #emitRateLimits(limits) {
6489
5034
  if (!limits) return;
6490
5035
  const status = limits.rateLimitReachedType ? "rejected" : "allowed";
@@ -6508,16 +5053,6 @@ var CodexRunner = class {
6508
5053
  });
6509
5054
  }
6510
5055
  }
6511
- /**
6512
- * Context occupancy, after the turn — the same cadence the Claude runner
6513
- * polls `getContextUsage()` on, so clients need nothing new.
6514
- *
6515
- * Emitted only when the binary gave BOTH numbers: the protocol is explicit
6516
- * that a client renders nothing rather than a 0% ring, and a window of
6517
- * `null` (which app-server does send) would otherwise divide into a
6518
- * meaningless percentage. `categories` is empty because codex publishes no
6519
- * breakdown — clients must not render an empty "Breakdown" section for it.
6520
- */
6521
5056
  #emitContextUsage(active) {
6522
5057
  const totalTokens = active.contextTokens;
6523
5058
  const maxTokens = active.contextWindow;
@@ -6534,9 +5069,10 @@ var CodexRunner = class {
6534
5069
  });
6535
5070
  }
6536
5071
  #setStatus(status, detail) {
6537
- if (this.#status === status) return;
5072
+ if (this.#status === status && this.#statusDetail === detail) return;
6538
5073
  if (this.#status === "closed" || this.#status === "failed") return;
6539
5074
  this.#status = status;
5075
+ this.#statusDetail = detail;
6540
5076
  this.#emit({
6541
5077
  type: "status_changed",
6542
5078
  status,
@@ -6548,32 +5084,26 @@ var CodexRunner = class {
6548
5084
  ...body,
6549
5085
  replay: true
6550
5086
  };
6551
- const event = {
6552
- ...body,
6553
- seq: ++this.#seq,
6554
- ts: Date.now()
6555
- };
6556
- this.#lastActivityAt = event.ts;
6557
- this.#activityCount += transcriptActivity(body);
6558
- this.#contextUsage = contextReading(body) ?? this.#contextUsage;
6559
- if (body.type === "conversation_reset") {
6560
- this.#resetSeq = event.seq;
6561
- this.#contextUsage = void 0;
6562
- }
6563
- this.#events.push(event);
6564
- this.#subscribers.emit(event);
5087
+ this.#subscribers.emit(this.#log.append(body));
6565
5088
  }
6566
5089
  };
6567
5090
  //#endregion
6568
5091
  //#region src/engines/codex/catalog.ts
6569
5092
  /**
6570
- * The Codex engine's model catalog, seeded from the binary's own embedded
6571
- * presets `@openai/codex@0.146.0` ships its model table inside the
6572
- * executable, and that table (not the SDK's stale `ModelReasoningEffort`
6573
- * union) is the truth about which reasoning efforts each model takes.
5093
+ * The Codex engine's model catalog, seeded from the binary's own embedded model
5094
+ * table (see `provenance` for the version) — that table, not the SDK's stale
5095
+ * `ModelReasoningEffort` union, is the truth about which reasoning efforts each
5096
+ * model takes. Mapping decisions: the internal `codex-auto-review` row is dropped
5097
+ * (the codex analogue of the CLI's `default` sentinel), `primary` mirrors the
5098
+ * binary's own `visibility` field so both UIs group the way codex's picker does,
5099
+ * and `reasoningEfforts` carries `supported_reasoning_levels` verbatim — `max`
5100
+ * and `ultra` go beyond the SDK union, so trust the binary and keep strings open.
6574
5101
  *
6575
- * **Refresh procedure** (release checklist): extract the embedded JSON from
6576
- * the platform binary and diff —
5102
+ * **Refresh procedure** (release checklist): extract the embedded JSON from the
5103
+ * platform binary and diff. The two-hop resolve is NOT optional under pnpm's
5104
+ * strict layout the platform package is a dependency of `@openai/codex` and
5105
+ * resolves only from that wrapper's location (MODULE_NOT_FOUND otherwise), the
5106
+ * same two hops `resolveBundledCodexExecutable` makes.
6577
5107
  *
6578
5108
  * node -e 'const d=require("fs").readFileSync(process.argv[1]);
6579
5109
  * const s=d.indexOf(`{\n "models": [`);
@@ -6585,20 +5115,6 @@ var CodexRunner = class {
6585
5115
  * const w=require.resolve("@openai/codex/package.json");
6586
5116
  * createRequire(w).resolve("@openai/codex-darwin-arm64/package.json")
6587
5117
  * .replace("package.json","vendor/aarch64-apple-darwin/bin/codex")')"
6588
- *
6589
- * The two-hop resolve is NOT optional: under pnpm's strict layout the platform
6590
- * package is a dependency of `@openai/codex`, so it resolves only from that
6591
- * wrapper's location, never from the repo root. Resolving it directly throws
6592
- * MODULE_NOT_FOUND — the same two hops `resolveBundledCodexExecutable` makes.
6593
- *
6594
- * Mapping decisions:
6595
- * - the internal `codex-auto-review` row is dropped (the codex analogue of
6596
- * dropping the CLI's `default` sentinel);
6597
- * - `primary` mirrors the binary's own `visibility` field ('list' = shown in
6598
- * its picker, 'hide' = its "older models"), so both UIs group the way
6599
- * codex's own picker does;
6600
- * - `reasoningEfforts` carries `supported_reasoning_levels` verbatim — note
6601
- * `max`/`ultra` beyond the SDK union; trust the binary, keep strings open.
6602
5118
  */
6603
5119
  const CODEX_CATALOG = {
6604
5120
  provenance: "embedded model presets of @openai/codex@0.149.0 (darwin-arm64 binary), extracted 2026-08-22",
@@ -6701,19 +5217,7 @@ const CODEX_CATALOG = {
6701
5217
  };
6702
5218
  //#endregion
6703
5219
  //#region src/engines/codex/process.ts
6704
- /** How much stderr to keep for the exit diagnostic. The binary logs startup
6705
- * noise there; only the tail explains a death. */
6706
5220
  const STDERR_TAIL_BYTES = 4096;
6707
- /**
6708
- * Spawn one `codex app-server` child and frame JSON-RPC over its stdio — the
6709
- * real {@link AppServerConnectFn}. The child's env is passed **complete**
6710
- * (a provided spawn env replaces process.env, never merges with it), with the
6711
- * profile's CODEX_HOME pin already applied by the runner.
6712
- *
6713
- * No spawn cwd: the working directory is a thread/turn parameter, and a cwd
6714
- * that doesn't exist should fail the *turn* with codex's own error, not the
6715
- * spawn.
6716
- */
6717
5221
  function connectAppServer(options) {
6718
5222
  const child = spawn(options.executable, ["app-server"], {
6719
5223
  env: options.env,
@@ -6762,17 +5266,9 @@ function connectAppServer(options) {
6762
5266
  //#endregion
6763
5267
  //#region src/engines/codex/adapter.ts
6764
5268
  const NOT_INSTALLED = "@openai/codex is not installed — add it (an optional peer of @workerdeck/core) to run codex profiles";
6765
- /**
6766
- * The codex binary sessions will run: the per-platform package installed next
6767
- * to `@openai/codex`, `vendor/<target-triple>/bin/codex` — the same file the
6768
- * npm wrapper's own `bin/codex.js` launcher execs. Probing this binary rather
6769
- * than whatever `codex` is on PATH means the availability answer is about the
6770
- * executable sessions will actually run. Undefined when it can't be found;
6771
- * callers degrade to 'unknown'.
6772
- */
6773
5269
  function resolveBundledCodexExecutable() {
6774
5270
  const triple = targetTriple();
6775
- if (!triple) return void 0;
5271
+ if (!triple) return;
6776
5272
  try {
6777
5273
  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`);
6778
5274
  if (existsSync(path)) return path;
@@ -6787,34 +5283,13 @@ function targetTriple() {
6787
5283
  function platformPackageSuffix() {
6788
5284
  return `${process.platform}-${process.arch}`;
6789
5285
  }
6790
- /**
6791
- * Availability, mirroring **the app-server surface's actual credential chain**
6792
- * (verified 2026-08-05 against 0.146.0 by driving the raw binary): auth comes
6793
- * solely from the CODEX_HOME auth store (`codex login`, file or keyring). The
6794
- * env-key routes are dead ends here — `CODEX_API_KEY` is read only by
6795
- * `codex exec` (a turn goes out with no credential at all: "Missing bearer"),
6796
- * and `OPENAI_API_KEY` was never read by either surface. So, in order:
6797
- *
6798
- * 1. Binary resolvable, else unavailable with the install reason;
6799
- * 2. `codex login status` under the profile's complete session env:
6800
- * exit 0 → available; the "Not logged in" verdict → unavailable, with an
6801
- * exact remedy when a stranded env key explains the misconfiguration;
6802
- * anything else (a stray CODEX_ACCESS_TOKEN JWT error, a crashed spawn) →
6803
- * 'unknown' — the checkClaudeAuth never-overclaim discipline.
6804
- *
6805
- * Only the exit code and the fixed verdict line are consulted — never
6806
- * surfaced: `login status` output includes a masked key fragment. The
6807
- * `smoke:codex --canary` run is the drift alarm for all of this.
6808
- */
6809
5286
  async function checkCodexAvailability(profile, env, options = {}) {
6810
5287
  const executable = resolveBundledCodexExecutable();
6811
5288
  if (!executable) return {
6812
5289
  available: false,
6813
5290
  reason: NOT_INSTALLED
6814
5291
  };
6815
- const childEnv = {};
6816
- for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
6817
- if (profile.codexHome) childEnv.CODEX_HOME = profile.codexHome;
5292
+ const childEnv = codexChildEnv(env, profile.codexHome);
6818
5293
  return new Promise((resolve) => {
6819
5294
  execFile(executable, ["login", "status"], {
6820
5295
  env: childEnv,
@@ -6836,13 +5311,8 @@ async function checkCodexAvailability(profile, env, options = {}) {
6836
5311
  });
6837
5312
  });
6838
5313
  }
6839
- /** `thread/list` page size (its own default is 25) and a hard page bound so a
6840
- * misbehaving cursor can never spin the listing forever. */
6841
5314
  const LIST_PAGE_SIZE = 100;
6842
5315
  const MAX_LIST_PAGES = 40;
6843
- /** thread/list's `cwd` filter is an EXACT path match (measured, 0.146.0), so
6844
- * offer both the spelled and canonical forms — macOS listings would otherwise
6845
- * miss `/tmp/...` threads recorded under `/private/tmp/...`. */
6846
5316
  function cwdFilter(dir) {
6847
5317
  const forms = new Set([dir]);
6848
5318
  try {
@@ -6850,10 +5320,9 @@ function cwdFilter(dir) {
6850
5320
  } catch {}
6851
5321
  return [...forms];
6852
5322
  }
6853
- const secondsToMs = (value) => typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
6854
- /** One thread row in the protocol's browser-safe summary shape. `id` is what
6855
- * `CreateSessionRequest.resume` feeds `thread/resume` — the row's separate
6856
- * `sessionId` field is not it. */
5323
+ function secondsToMs(value) {
5324
+ return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : void 0;
5325
+ }
6857
5326
  function summarizeThread(row) {
6858
5327
  const name = typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
6859
5328
  const preview = typeof row.preview === "string" && row.preview.length > 0 ? row.preview : void 0;
@@ -6868,30 +5337,12 @@ function summarizeThread(row) {
6868
5337
  cwd: typeof row.cwd === "string" ? row.cwd : void 0
6869
5338
  };
6870
5339
  }
6871
- /**
6872
- * CODEX_HOME's threads over ONE short-lived `codex app-server` child: the
6873
- * runner's own handshake (`experimentalApi` and all — one code path, no
6874
- * second vocabulary to drift), `thread/list` pages walked by cursor, child
6875
- * closed before returning. Requires no live session and costs no tokens —
6876
- * it is how "resume" is offered before anything is running. The `connectFn`
6877
- * seam exists for the scripted-peer tests; the adapter passes the real
6878
- * spawn.
6879
- */
6880
5340
  async function listCodexSessions(options) {
6881
- const childEnv = {};
6882
- for (const [key, value] of Object.entries(options.env)) if (value !== void 0) childEnv[key] = value;
6883
- if (options.profile?.codexHome) childEnv.CODEX_HOME = options.profile.codexHome;
5341
+ const childEnv = codexChildEnv(options.env, options.profile?.codexHome);
6884
5342
  const connection = options.connectFn({ env: childEnv });
6885
5343
  const rows = [];
6886
5344
  try {
6887
- await connection.request("initialize", {
6888
- clientInfo: {
6889
- name: "workerdeck",
6890
- title: "WorkerDeck",
6891
- version: `protocol-${PROTOCOL_VERSION}`
6892
- },
6893
- capabilities: { experimentalApi: true }
6894
- });
5345
+ await connection.request("initialize", INITIALIZE_PARAMS);
6895
5346
  connection.notify("initialized");
6896
5347
  const base = {
6897
5348
  limit: LIST_PAGE_SIZE,
@@ -6918,15 +5369,6 @@ async function listCodexSessions(options) {
6918
5369
  const start = options.offset ?? 0;
6919
5370
  return options.limit === void 0 ? summaries.slice(start) : summaries.slice(start, start + options.limit);
6920
5371
  }
6921
- /**
6922
- * OpenAI Codex as an engine: the codex CLI binary driven over its `app-server`
6923
- * JSON-RPC surface — structurally the Claude engine's sibling (a local agent
6924
- * binary with sessions, sandboxing and resume, resolving its own credentials
6925
- * from the operator's environment). `@openai/codex` — the npm package that
6926
- * carries the binary — is an **optional peer**: absent, every codex profile
6927
- * reports unavailable and createRunner throws the same message, and no
6928
- * consumer downloads a ~40 MB per-platform binary it never uses.
6929
- */
6930
5372
  const codexAdapter = {
6931
5373
  engine: "codex",
6932
5374
  capabilities: ENGINE_CAPABILITIES.codex,
@@ -6959,17 +5401,6 @@ const codexAdapter = {
6959
5401
  };
6960
5402
  //#endregion
6961
5403
  //#region src/engines/provider/adapter.ts
6962
- /**
6963
- * The model-agnostic provider engine as a pseudo-adapter: capabilities and an
6964
- * env-var probe live here, but its runners are assembled by the host's
6965
- * `createEngineRunner` hook (which is where provider credentials are resolved
6966
- * and model SDKs are imported — neither belongs in this repo's import graph).
6967
- * The server routes provider creates to the hook; `createRunner` here throws
6968
- * so a mis-routed call fails loudly instead of quietly building nothing.
6969
- *
6970
- * The catalog is empty by the same token: provider model ids are operator-
6971
- * declared per profile (`provider.models`), not shipped with releases.
6972
- */
6973
5404
  const providerAdapter = {
6974
5405
  engine: "provider",
6975
5406
  capabilities: ENGINE_CAPABILITIES.provider,
@@ -6998,7 +5429,6 @@ const ADAPTERS = {
6998
5429
  codex: codexAdapter,
6999
5430
  provider: providerAdapter
7000
5431
  };
7001
- /** The in-repo adapter for an engine. An absent `engine` means 'claude'. */
7002
5432
  function getEngineAdapter(engine) {
7003
5433
  return ADAPTERS[engine ?? "claude"];
7004
5434
  }