@workerdeck/core 0.15.0 → 0.17.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
- import { getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, transcriptActivity } from "@workerdeck/protocol";
3
+ import { getSessionInfo, getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, imagePartRef, replayCoalesceKey, replayRetains, snapshotRetains, transcriptActivity, transcriptContent } from "@workerdeck/protocol";
5
5
  import { ToolLoopAgent, generateText, isStepCount, tool } from "ai";
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import { lookup } from "node:dns/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
- //#region src/attachments.ts
13
+ //#region src/lib/attachments.ts
14
14
  /** The four the Anthropic API accepts. Notably absent: image/heic — an iPhone's
15
15
  * native photo format, which clients must transcode before upload. */
16
16
  const IMAGE_TYPES = new Set([
@@ -103,7 +103,7 @@ function decodeText(base64) {
103
103
  return Buffer.from(base64, "base64").toString("utf8");
104
104
  }
105
105
  //#endregion
106
- //#region src/input-queue.ts
106
+ //#region src/lib/input-queue.ts
107
107
  /**
108
108
  * Push-based single-consumer AsyncIterable bridging imperative sendMessage() calls
109
109
  * into the streaming `prompt` the Agent SDK consumes.
@@ -162,7 +162,160 @@ var InputQueue = class {
162
162
  }
163
163
  };
164
164
  //#endregion
165
- //#region src/normalize.ts
165
+ //#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
+ const MAX_PATCH_LINES = 400;
190
+ function capHunks(hunks) {
191
+ const kept = [];
192
+ let lines = 0;
193
+ for (const hunk of hunks) {
194
+ if (lines + hunk.lines.length > MAX_PATCH_LINES && kept.length > 0) return {
195
+ hunks: kept,
196
+ truncated: true
197
+ };
198
+ kept.push(hunk);
199
+ lines += hunk.lines.length;
200
+ }
201
+ return { hunks: kept };
202
+ }
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
+ function isHunk(value) {
206
+ const hunk = value;
207
+ 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
+ }
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
+ function filePatchFromToolResult(result) {
218
+ const output = result;
219
+ if (!output || !Array.isArray(output.structuredPatch)) return void 0;
220
+ const hunks = output.structuredPatch.filter(isHunk);
221
+ if (hunks.length === 0) return void 0;
222
+ const { hunks: kept, truncated } = capHunks(hunks);
223
+ return {
224
+ ...typeof output.filePath === "string" && { path: output.filePath },
225
+ ...output.type === "create" || output.originalFile === null ? { kind: "create" } : output.type === "update" || typeof output.originalFile === "string" ? { kind: "update" } : {},
226
+ hunks: kept,
227
+ ...truncated && { truncated }
228
+ };
229
+ }
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
+ function parseUnifiedDiff(diff, path) {
245
+ const hunks = [];
246
+ let current;
247
+ for (const line of diff.split("\n")) {
248
+ const header = HUNK_HEADER.exec(line);
249
+ if (header) {
250
+ current = {
251
+ oldStart: Number(header[1]),
252
+ oldLines: header[2] === void 0 ? 1 : Number(header[2]),
253
+ newStart: Number(header[3]),
254
+ newLines: header[4] === void 0 ? 1 : Number(header[4]),
255
+ lines: []
256
+ };
257
+ hunks.push(current);
258
+ continue;
259
+ }
260
+ if (!current) continue;
261
+ if (line.startsWith(" ") || line.startsWith("-") || line.startsWith("+")) current.lines.push(line);
262
+ else if (line === "") current.lines.push(" ");
263
+ else current = void 0;
264
+ }
265
+ if (hunks.length === 0) return void 0;
266
+ const { hunks: kept, truncated } = capHunks(hunks);
267
+ return {
268
+ ...path && { path },
269
+ hunks: kept,
270
+ ...truncated && { truncated }
271
+ };
272
+ }
273
+ //#endregion
274
+ //#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. */
278
+ function singleToolResult(message) {
279
+ const content = message.content;
280
+ if (!Array.isArray(content)) return false;
281
+ return content.filter((block) => block.type === "tool_result").length === 1;
282
+ }
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
+ 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
+ function isSyntheticUserText(message) {
313
+ const content = message.content;
314
+ const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((block) => block.type === "text")?.text : void 0;
315
+ if (typeof text !== "string") return false;
316
+ const head = text.trimStart();
317
+ return SYNTHETIC_USER_PREFIXES.some((prefix) => head.startsWith(prefix));
318
+ }
166
319
  function toApiMessage(message) {
167
320
  const m = message;
168
321
  return {
@@ -351,14 +504,18 @@ function normalizeSdkMessage(msg) {
351
504
  parentToolUseId: msg.parent_tool_use_id,
352
505
  uuid: msg.uuid
353
506
  };
354
- case "user": return {
355
- type: "user_message",
356
- message: toApiMessage(msg.message),
357
- parentToolUseId: msg.parent_tool_use_id,
358
- replay: "isReplay" in msg && msg.isReplay === true ? true : void 0,
359
- synthetic: msg.isSynthetic === true ? true : void 0,
360
- uuid: msg.uuid
361
- };
507
+ case "user": {
508
+ const message = toApiMessage(msg.message);
509
+ return {
510
+ type: "user_message",
511
+ message,
512
+ parentToolUseId: msg.parent_tool_use_id,
513
+ replay: "isReplay" in msg && msg.isReplay === true ? true : void 0,
514
+ synthetic: msg.isSynthetic === true || msg.origin?.kind === "task-notification" || isSyntheticUserText(message) ? true : void 0,
515
+ patch: singleToolResult(message) ? filePatchFromToolResult(msg.tool_use_result) : void 0,
516
+ uuid: msg.uuid
517
+ };
518
+ }
362
519
  case "stream_event": return {
363
520
  type: "stream_delta",
364
521
  event: msg.event,
@@ -376,6 +533,10 @@ function normalizeSdkMessage(msg) {
376
533
  errors: msg.subtype === "success" ? void 0 : msg.errors,
377
534
  usage: msg.usage
378
535
  };
536
+ case "conversation_reset": return {
537
+ type: "conversation_reset",
538
+ sdkSessionId: msg.new_conversation_id
539
+ };
379
540
  case "rate_limit_event": return {
380
541
  type: "rate_limit",
381
542
  info: {
@@ -399,7 +560,531 @@ function normalizeSdkMessage(msg) {
399
560
  }
400
561
  }
401
562
  //#endregion
402
- //#region src/runner.ts
563
+ //#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
+ function staleReplaySeqs(events, afterSeq) {
584
+ const stale = /* @__PURE__ */ new Set();
585
+ const seen = /* @__PURE__ */ new Set();
586
+ for (let index = events.length - 1; index >= 0; index--) {
587
+ const event = events[index];
588
+ if (event.seq <= afterSeq) break;
589
+ const key = replayCoalesceKey(event);
590
+ if (key === void 0) continue;
591
+ if (seen.has(key)) stale.add(event.seq);
592
+ else seen.add(key);
593
+ }
594
+ return stale;
595
+ }
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. Claude's alone; the other
611
+ * engines pass 0.
612
+ * 3. `coalesceReplay` — last-write-wins state readings superseded later in the
613
+ * same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
614
+ * reducer reads and discards. Opt-in, and only sound for a consumer whose
615
+ * handling of those events is last-write-wins.
616
+ * 4. `imageRefs` — a base64 image part is delivered as an `image_ref` address
617
+ * (protocol's {@link imagePartRef}), its bytes one REST fetch away. Applied
618
+ * **before** rule 5, because it stamps indices from the stored part array
619
+ * which rule 5 then reshapes. Unlike rule 5 this also applies to the live
620
+ * path (see `SubscriberSet`), which is the one place these two rules differ.
621
+ * 5. `truncateResults` — a huge `tool_result` block is delivered as its head
622
+ * plus the markers that say so. **Never mutates the stored event**: the live
623
+ * path, the parking snapshot and the fetch route all need the whole thing,
624
+ * so this builds a copy and the log stays the log.
625
+ *
626
+ * The highest-seq event is delivered whatever rules 2 and 3 say — a client's
627
+ * replay hold waits for `state.lastSeq` to reach the attach's and would
628
+ * otherwise hang forever — but it is still *truncated* when rule 4 applies. A
629
+ * session that ends on a `find /` puts its 641 KB frame exactly there.
630
+ */
631
+ function replaySlice(events, options) {
632
+ const { afterSeq, resetSeq = 0, coalesceReplay, truncateResults, imageRefs } = options;
633
+ const stale = coalesceReplay ? staleReplaySeqs(events, afterSeq) : void 0;
634
+ const lastSeq = events[events.length - 1]?.seq ?? 0;
635
+ const out = [];
636
+ for (const event of events) {
637
+ if (event.seq <= afterSeq) continue;
638
+ if (event.seq < resetSeq && transcriptContent(event)) continue;
639
+ if (stale?.has(event.seq)) continue;
640
+ if (coalesceReplay && event.seq !== lastSeq && !replayRetains(event)) continue;
641
+ let delivered = event;
642
+ if (imageRefs) delivered = refImageParts(delivered);
643
+ if (truncateResults) delivered = truncateResultBlocks(delivered);
644
+ out.push(delivered);
645
+ }
646
+ return out;
647
+ }
648
+ /**
649
+ * A copy of `event` whose oversized `tool_result` blocks carry their head and
650
+ * say so — or `event` itself, unchanged and un-copied, when nothing is over the
651
+ * budget. That identity matters: an attach is mostly small events, and a fresh
652
+ * object for every one of them would cost more than the feature saves.
653
+ *
654
+ * Blocks are measured and cut **individually**. A message answering three calls
655
+ * where one is a `find /` keeps the two small results whole, which is what makes
656
+ * the per-block marker (rather than a per-event one) honest.
657
+ */
658
+ function truncateResultBlocks(event) {
659
+ if (event.type !== "user_message") return event;
660
+ const content = event.message.content;
661
+ if (!Array.isArray(content)) return event;
662
+ let cut = false;
663
+ const blocks = content.map((block) => {
664
+ if (block.type !== "tool_result") return block;
665
+ const result = block;
666
+ if (result.truncated) return block;
667
+ const total = resultChars(result.content);
668
+ if (total <= TOOL_RESULT_HEAD_CHARS) return block;
669
+ cut = true;
670
+ return {
671
+ ...result,
672
+ content: headOf(result.content, TOOL_RESULT_HEAD_CHARS),
673
+ truncated: true,
674
+ total_chars: total
675
+ };
676
+ });
677
+ if (!cut) return event;
678
+ return {
679
+ ...event,
680
+ message: {
681
+ ...event.message,
682
+ content: blocks
683
+ }
684
+ };
685
+ }
686
+ /** Characters in a result's content, in the same terms a reader sees it: the
687
+ * string itself, or every text part of a block list joined by newlines — which
688
+ * is exactly what `blockText` in the reducer builds. Non-text parts (an image
689
+ * block) contribute nothing, because they are not what is large here and
690
+ * slicing them would corrupt them. */
691
+ function resultChars(content) {
692
+ if (typeof content === "string") return content.length;
693
+ if (!Array.isArray(content)) return 0;
694
+ return content.reduce((total, part, index) => total + (typeof part.text === "string" ? part.text.length + (index > 0 ? 1 : 0) : 0), 0);
695
+ }
696
+ /** The first `chars` characters, in the content's own shape — a string stays a
697
+ * string, a block list stays a block list (cut at the part that crosses the
698
+ * budget, with the remaining parts dropped). Shape-preserving on purpose: the
699
+ * reducer, both renderers and the copy button all read this the same way they
700
+ * read a whole one, so truncation is a shorter result and never a different
701
+ * kind of one. */
702
+ function headOf(content, chars) {
703
+ if (typeof content === "string") return content.slice(0, chars);
704
+ if (!Array.isArray(content)) return content;
705
+ const parts = [];
706
+ let used = 0;
707
+ for (const part of content) {
708
+ if (part.type === "image_ref") {
709
+ parts.push(part);
710
+ continue;
711
+ }
712
+ if (typeof part.text !== "string") continue;
713
+ if (used >= chars) continue;
714
+ const text = part.text.slice(0, chars - used);
715
+ parts.push({
716
+ ...part,
717
+ text
718
+ });
719
+ used += text.length + 1;
720
+ }
721
+ return parts;
722
+ }
723
+ /**
724
+ * A copy of `event` whose `tool_result` blocks carry `image_ref` addresses in
725
+ * place of their base64 image parts — or `event` itself, unchanged and
726
+ * un-copied, when it holds none. Same identity rule as
727
+ * {@link truncateResultBlocks}, and it matters more here: an event carrying an
728
+ * image at all is the exception, so the common path must not allocate.
729
+ *
730
+ * **Never mutates the stored event.** The log is what the parking snapshot
731
+ * embeds, what `Runner.eventAt` reads, and therefore what the fetch route
732
+ * serves the bytes back from — a drop that reached the log would 404 the very
733
+ * lazy-load this rule promises.
734
+ *
735
+ * Indices are stamped from the **stored** array, which is why this runs *before*
736
+ * truncation rather than after: `headOf` reshapes a block's parts, so an address
737
+ * computed on its output would name the wrong part of the stored block. That
738
+ * ordering is asserted in `replay-image-ref.test.ts`, not merely intended.
739
+ */
740
+ function refImageParts(event) {
741
+ if (event.type !== "user_message") return event;
742
+ const content = event.message.content;
743
+ if (!Array.isArray(content)) return event;
744
+ let changed = false;
745
+ const blocks = content.map((block) => {
746
+ if (block.type !== "tool_result") return block;
747
+ const result = block;
748
+ const parts = result.content;
749
+ if (!Array.isArray(parts)) return block;
750
+ let blockChanged = false;
751
+ const mapped = parts.map((part, index) => {
752
+ const ref = imagePartRef(part, index);
753
+ if (!ref) return part;
754
+ blockChanged = true;
755
+ return ref;
756
+ });
757
+ if (!blockChanged) return block;
758
+ changed = true;
759
+ return {
760
+ ...result,
761
+ content: mapped
762
+ };
763
+ });
764
+ if (!changed) return event;
765
+ return {
766
+ ...event,
767
+ message: {
768
+ ...event.message,
769
+ content: blocks
770
+ }
771
+ };
772
+ }
773
+ //#endregion
774
+ //#region src/lib/subscribers.ts
775
+ var SubscriberSet = class {
776
+ #listeners = /* @__PURE__ */ new Map();
777
+ /**
778
+ * Replay `events` to `listener` under `options`, then hold it for live
779
+ * delivery. Returns the unsubscribe.
780
+ *
781
+ * The replay runs *before* the listener joins the set, which is the ordering
782
+ * every runner already had and is load-bearing: joining first would deliver a
783
+ * live event emitted mid-replay ahead of the buffered events preceding it.
784
+ */
785
+ subscribe(events, listener, afterSeq = 0, options, resetSeq = 0) {
786
+ const asked = options ?? {};
787
+ for (const event of replaySlice(events, {
788
+ ...asked,
789
+ afterSeq,
790
+ resetSeq
791
+ })) listener(event);
792
+ this.#listeners.set(listener, asked);
793
+ return () => {
794
+ this.#listeners.delete(listener);
795
+ };
796
+ }
797
+ /** Drop every subscriber — a park, which ends the session's live stream. */
798
+ clear() {
799
+ this.#listeners.clear();
800
+ }
801
+ /** Fan one event out, transformed per subscriber. */
802
+ emit(event) {
803
+ for (const [listener, asked] of this.#listeners) try {
804
+ listener(asked.imageRefs ? refImageParts(event) : event);
805
+ } catch {}
806
+ }
807
+ };
808
+ //#endregion
809
+ //#region src/engines/claude/subagents.ts
810
+ /**
811
+ * The rollup behind `SessionInfo.subagents` — what a sessions list (which never
812
+ * attaches) can know about the sub-agents running inside a session. Fed from
813
+ * `SessionRunner.#emit`, the one chokepoint every event passes through, so the
814
+ * resume backfill — which replays history through the same path — reconstructs
815
+ * it with no persistence of its own. Grouping is by Task id throughout, never
816
+ * adjacency: parallel sub-agents interleave in the stream, the same fact that
817
+ * broke the terminal theme's positional row model.
818
+ *
819
+ * Three decisions live here rather than in the protocol doc:
820
+ *
821
+ * **What counts as a spawn.** A record opens when a *top-level* assistant
822
+ * message carries a `tool_use` named `Task` or `Agent` — the moment the
823
+ * sub-agent exists, so a just-spawned agent is visible before its first nested
824
+ * event, with the block's input in hand for its labels. Both names are observed
825
+ * SDK spellings (`Task` synchronous, `Agent` async), and the name is a
826
+ * convention, not a law — a session that spawned three `Agent`s under a tracker
827
+ * that only knew `Task` reported all three as label-less failures. So three
828
+ * more openers back the allowlist up: the CLI's own `task_started` system event
829
+ * (which positively names the `tool_use_id` an agent runs under, with the brief
830
+ * as labels), the launch acknowledgement (below), and — as before — any nested
831
+ * event whose `parentToolUseId` has no record: an id that events demonstrably
832
+ * nest under *is* a sub-agent, whatever the spawning call was named. A fallback
833
+ * record never saw an input, so it stays label-less until a named signal fills
834
+ * it in rather than resetting an accumulated count.
835
+ *
836
+ * **A background agent's `tool_result` is a launch receipt, not a verdict.**
837
+ * An async agent's spawn call resolves seconds after the spawn with "Async
838
+ * agent launched successfully. (This tool result is internal metadata …)" —
839
+ * long before the agent has done anything — and its actual outcome travels on
840
+ * a `task_notification` system event instead (`status: 'completed'` is `done`,
841
+ * any other way of stopping is `failed`: the report the notification exists to
842
+ * deliver never came). Settling on the receipt would read "0 of 3 agents
843
+ * running" while three agents burn tokens, so a non-error result on a record
844
+ * known to be background never settles it. Known how: the `task_started` event
845
+ * live, or the receipt's own wrapper text on a resume — the stored transcript
846
+ * carries none of the CLI's system events, so, exactly as
847
+ * `isSyntheticUserText` documents for the `<task-notification>` blob, the text
848
+ * is the only signal the replayed path has.
849
+ *
850
+ * **What an interrupted turn leaves behind.** A Task whose `tool_result` never
851
+ * arrives — interrupt, session error, a turn or budget cap — would otherwise
852
+ * read `running` on an idle session forever, a lie a list re-renders at every
853
+ * poll. So the end of a turn settles every still-running record as `failed`:
854
+ * the report never came, which is the one thing `done` could have claimed. The
855
+ * sweep keys on `turn_result`, on the status coming to rest (`idle` — which is
856
+ * how a resumed history that ends mid-Task settles, since the backfill replays
857
+ * no `turn_result` — or a terminal state), and on the session closing. A real
858
+ * verdict arriving anyway outranks the sweep's inference. The sweep's premise
859
+ * — "the turn ended, so anything still running was cut off" — is false for a
860
+ * background agent, which is *designed* to outlive its turn: the real session
861
+ * behind this file ended three turns while its agents ran, and every
862
+ * `turn_result` re-branded live, working agents as failures. So the turn and
863
+ * idle sweeps spare a record marked background by a **live** signal. They do
864
+ * not spare one whose only evidence is replayed: the backfill describes a
865
+ * process that is gone, and a background agent the old process died inside can
866
+ * never notify — `running` would be the forever-lie again. `session_closed`
867
+ * and the terminal statuses settle everything, background included, for the
868
+ * same reason: the process hosting those agents is gone.
869
+ */
870
+ var SubagentTracker = class {
871
+ #records = /* @__PURE__ */ new Map();
872
+ #settleCounter = 0;
873
+ /** Fold one emitted event body into the rollup, in log order. */
874
+ observe(body, ts) {
875
+ switch (body.type) {
876
+ case "assistant_message":
877
+ if (body.parentToolUseId != null) {
878
+ const record = this.#recordFor(body.parentToolUseId, ts);
879
+ record.toolCount += toolUseBlocks(body.message.content).length;
880
+ return;
881
+ }
882
+ for (const block of toolUseBlocks(body.message.content)) {
883
+ if (!SPAWNER_NAMES.has(block.name)) continue;
884
+ this.#open(block, ts);
885
+ }
886
+ return;
887
+ case "user_message": {
888
+ if (body.parentToolUseId != null) {
889
+ this.#recordFor(body.parentToolUseId, ts);
890
+ return;
891
+ }
892
+ const note = parseTaskNotification(firstText(body.message.content));
893
+ if (note) {
894
+ const record = this.#recordFor(note.toolUseId, ts);
895
+ const status = note.status === "completed" ? "done" : "failed";
896
+ if (record.status !== status) this.#settle(record, status);
897
+ return;
898
+ }
899
+ const content = body.message.content;
900
+ if (typeof content === "string") return;
901
+ for (const block of content) {
902
+ if (block.type !== "tool_result") continue;
903
+ const result = block;
904
+ if (typeof result.tool_use_id !== "string") continue;
905
+ if (result.is_error !== true && isLaunchAck(result.content)) {
906
+ const record = this.#recordFor(result.tool_use_id, ts);
907
+ if (record.background !== "live") record.background = body.replay === true ? "replay" : "live";
908
+ continue;
909
+ }
910
+ const record = this.#records.get(result.tool_use_id);
911
+ if (!record) continue;
912
+ if (result.is_error !== true && record.background !== void 0) continue;
913
+ const status = result.is_error === true ? "failed" : "done";
914
+ if (record.status === status) continue;
915
+ this.#settle(record, status);
916
+ }
917
+ return;
918
+ }
919
+ case "sdk_event": {
920
+ const p = body.payload;
921
+ if (p.type !== "system" || typeof p.tool_use_id !== "string") return;
922
+ if (p.subtype === "task_started") {
923
+ const record = this.#recordFor(p.tool_use_id, ts);
924
+ record.background = "live";
925
+ record.agentType ??= cleaned(p.subagent_type);
926
+ record.description ??= cleaned(p.description);
927
+ return;
928
+ }
929
+ if (p.subtype === "task_notification") {
930
+ const record = this.#recordFor(p.tool_use_id, ts);
931
+ const status = p.status === "completed" ? "done" : "failed";
932
+ if (record.status !== status) this.#settle(record, status);
933
+ return;
934
+ }
935
+ return;
936
+ }
937
+ case "turn_result":
938
+ this.#sweep(false);
939
+ return;
940
+ case "session_closed":
941
+ this.#sweep(true);
942
+ return;
943
+ case "status_changed":
944
+ if (body.status === "idle") this.#sweep(false);
945
+ else if (body.status === "failed" || body.status === "closed") this.#sweep(true);
946
+ return;
947
+ case "conversation_reset":
948
+ this.#records.clear();
949
+ return;
950
+ default: return;
951
+ }
952
+ }
953
+ /**
954
+ * The rollup as `SessionInfo.subagents` serves it: spawn order (the
955
+ * transcript's own), fresh objects, and `undefined` when there is nothing to
956
+ * say — absent and empty mean the same thing to a client, and an empty array
957
+ * on every row of a 1.2s-polled list is bytes spent saying nothing.
958
+ */
959
+ list() {
960
+ if (this.#records.size === 0) return void 0;
961
+ const out = [];
962
+ for (const r of this.#records.values()) out.push({
963
+ toolUseId: r.toolUseId,
964
+ agentType: r.agentType,
965
+ description: r.description,
966
+ status: r.status,
967
+ startedAt: r.startedAt,
968
+ toolCount: r.toolCount
969
+ });
970
+ return out;
971
+ }
972
+ #recordFor(toolUseId, ts) {
973
+ let record = this.#records.get(toolUseId);
974
+ if (!record) {
975
+ record = {
976
+ toolUseId,
977
+ status: "running",
978
+ startedAt: ts,
979
+ toolCount: 0
980
+ };
981
+ this.#records.set(toolUseId, record);
982
+ }
983
+ return record;
984
+ }
985
+ #open(block, ts) {
986
+ const record = this.#recordFor(block.id, ts);
987
+ const input = block.input;
988
+ record.agentType ??= cleaned(input?.subagent_type);
989
+ record.description ??= cleaned(input?.description);
990
+ }
991
+ /**
992
+ * End of turn (`final: false`): anything still running was cut off before
993
+ * its report — except a background agent the live process still hosts, which
994
+ * is designed to outlive the turn and settles by notification instead. End
995
+ * of session (`final: true`): everything, background included, because the
996
+ * process those agents lived in is gone.
997
+ */
998
+ #sweep(final) {
999
+ for (const record of this.#records.values()) {
1000
+ if (record.status !== "running") continue;
1001
+ if (!final && record.background === "live") continue;
1002
+ this.#settle(record, "failed");
1003
+ }
1004
+ }
1005
+ #settle(record, status) {
1006
+ record.status = status;
1007
+ record.settledOrder = ++this.#settleCounter;
1008
+ let settled = 0;
1009
+ for (const r of this.#records.values()) if (r.settledOrder !== void 0) settled++;
1010
+ while (settled > SUBAGENT_HISTORY) {
1011
+ let oldestId;
1012
+ let oldestOrder = Infinity;
1013
+ for (const r of this.#records.values()) {
1014
+ if (r.settledOrder === void 0 || r.settledOrder >= oldestOrder) continue;
1015
+ oldestId = r.toolUseId;
1016
+ oldestOrder = r.settledOrder;
1017
+ }
1018
+ if (oldestId === void 0) break;
1019
+ this.#records.delete(oldestId);
1020
+ settled--;
1021
+ }
1022
+ }
1023
+ };
1024
+ /** The spawner names observed in the wild: `Task` runs the agent inside the
1025
+ * turn, `Agent` launches it in the background. Deliberately just these two —
1026
+ * a third spelling is caught by `task_started`, the launch receipt, or the
1027
+ * nested-event fallback, so widening this to every tool would only turn
1028
+ * ordinary calls into phantom agents. */
1029
+ const SPAWNER_NAMES = new Set(["Task", "Agent"]);
1030
+ /** The async spawn's immediate `tool_result` — "Async agent launched
1031
+ * successfully. (This tool result is internal metadata …)" — recognized by its
1032
+ * wrapper text because on a resume that text is the only signal there is (the
1033
+ * `SYNTHETIC_USER_PREFIXES` argument; the CLI's system events are not stored).
1034
+ * Live, `task_started` marks the record first and this is redundant armor. */
1035
+ const isLaunchAck = (content) => {
1036
+ const text = typeof content === "string" ? content : firstText(Array.isArray(content) ? content : []);
1037
+ return typeof text === "string" && text.trimStart().startsWith("Async agent launched");
1038
+ };
1039
+ /** A background agent stopping, parsed from the `<task-notification>` wrapper
1040
+ * the CLI writes into the transcript. Field-tolerant on purpose: only the
1041
+ * `tool-use-id` (this rollup's key) and the `status` verdict are read. */
1042
+ const parseTaskNotification = (text) => {
1043
+ if (text === void 0 || !text.trimStart().startsWith("<task-notification>")) return void 0;
1044
+ const toolUseId = /<tool-use-id>\s*([^<\s]+)\s*<\/tool-use-id>/.exec(text)?.[1];
1045
+ if (toolUseId === void 0) return void 0;
1046
+ return {
1047
+ toolUseId,
1048
+ status: /<status>\s*([^<]*?)\s*<\/status>/.exec(text)?.[1] ?? ""
1049
+ };
1050
+ };
1051
+ /** The first text of a message body, however the content is spelled — the
1052
+ * stored transcript uses bare strings, the live stream uses blocks. */
1053
+ const firstText = (content) => {
1054
+ if (typeof content === "string") return content;
1055
+ for (const block of content) {
1056
+ const b = block;
1057
+ if (b?.type === "text" && typeof b.text === "string") return b.text;
1058
+ }
1059
+ };
1060
+ /** Trim, drop blank, clip at the same 80 the terminal theme's `taskLabel` uses.
1061
+ * Model-authored input rides every row of a polled sessions list, so it is
1062
+ * bounded here rather than trusted — a 10KB `description` would be paid for at
1063
+ * every poll. */
1064
+ const cleaned = (value) => {
1065
+ if (typeof value !== "string") return void 0;
1066
+ const text = value.trim();
1067
+ if (text === "") return void 0;
1068
+ return text.length > 80 ? text.slice(0, 79) + "…" : text;
1069
+ };
1070
+ /** The `tool_use` blocks of a message body, however the content is spelled. */
1071
+ function toolUseBlocks(content) {
1072
+ if (typeof content === "string") return [];
1073
+ const blocks = [];
1074
+ for (const block of content) {
1075
+ if (block.type !== "tool_use") continue;
1076
+ const b = block;
1077
+ if (typeof b.id !== "string" || typeof b.name !== "string") continue;
1078
+ blocks.push({
1079
+ id: b.id,
1080
+ name: b.name,
1081
+ input: b.input
1082
+ });
1083
+ }
1084
+ return blocks;
1085
+ }
1086
+ //#endregion
1087
+ //#region src/engines/claude/runner.ts
403
1088
  const DEFAULT_APPROVAL_TIMEOUT_MS$1 = 3e5;
404
1089
  /**
405
1090
  * One live Agent SDK session: owns the query() call, the streaming input queue, the
@@ -413,9 +1098,18 @@ var SessionRunner = class {
413
1098
  /** {@link SessionRunnerConfig.cwd}, checked once in the constructor. */
414
1099
  #cwd;
415
1100
  #events = [];
416
- #listeners = /* @__PURE__ */ new Set();
1101
+ #subscribers = new SubscriberSet();
417
1102
  #seq = 0;
418
1103
  #activityCount = 0;
1104
+ /**
1105
+ * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
1106
+ * never truncated — it still carries the state-bearing events (`capabilities`,
1107
+ * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
1108
+ * but `subscribe()` skips transcript *content* strictly below this mark, so a
1109
+ * replay does not resurrect a cleared conversation. A later reset supersedes
1110
+ * an earlier one by overwriting it.
1111
+ */
1112
+ #resetSeq = 0;
419
1113
  #status = "starting";
420
1114
  #statusDetail;
421
1115
  #sdkSessionId;
@@ -423,6 +1117,28 @@ var SessionRunner = class {
423
1117
  #apiKeySource;
424
1118
  #permissionMode;
425
1119
  #pending = /* @__PURE__ */ new Map();
1120
+ /**
1121
+ * The turn ended while an approval was standing, and nothing has started a
1122
+ * new one since.
1123
+ *
1124
+ * `awaiting_approval` rightly outranks `idle` for display, so a turn-over
1125
+ * signal arriving under a standing approval cannot be applied when it lands.
1126
+ * It used to be **discarded** for that reason, which is a different thing
1127
+ * from outranked: the settle path then asserted `running` on the assumption
1128
+ * that an answered approval means work resumes, and when the turn was already
1129
+ * over — an interrupt, a timeout — the session claimed to be running one that
1130
+ * had produced its result. Status is purely edge-driven here, with no poll and
1131
+ * no reconciliation anywhere, so that single dropped edge never came back and
1132
+ * every client rendered it faithfully for the life of the session.
1133
+ *
1134
+ * So the fact is *deferred* rather than dropped, and it is deliberately
1135
+ * cleared the moment work genuinely resumes — a turn-over belongs to the turn
1136
+ * that produced it and must not settle the next one.
1137
+ */
1138
+ #turnOverWhileBlocked = false;
1139
+ /** The read-time sub-agent rollup (`SessionInfo.subagents`), fed from #emit —
1140
+ * the one chokepoint — so the resume backfill reconstructs it for free. */
1141
+ #subagents = new SubagentTracker();
426
1142
  #totalCostUsd;
427
1143
  #numTurns;
428
1144
  #lastActivityAt;
@@ -432,6 +1148,9 @@ var SessionRunner = class {
432
1148
  /** Last plan reported by the usage poll, so `plan_info` is emitted on change
433
1149
  * rather than once per turn. */
434
1150
  #subscriptionType;
1151
+ /** The title the CLI gave this thread (see `#fetchEngineTitle`). Undefined
1152
+ * until it has one — a session gets its summary a turn or two in. */
1153
+ #engineTitle;
435
1154
  #started = false;
436
1155
  #closed = false;
437
1156
  #runPromise;
@@ -476,6 +1195,7 @@ var SessionRunner = class {
476
1195
  lastSeq: this.#seq,
477
1196
  activityCount: this.#activityCount,
478
1197
  pendingPermissionCount: this.#pending.size,
1198
+ subagents: this.#subagents.list(),
479
1199
  meta: this.#config.meta,
480
1200
  scope: this.#config.scope,
481
1201
  title: this.#title(),
@@ -484,9 +1204,20 @@ var SessionRunner = class {
484
1204
  lastActivityAt: this.#lastActivityAt
485
1205
  };
486
1206
  }
1207
+ /**
1208
+ * Three sources, most-deliberate first: the host's own rename (`meta.title`),
1209
+ * the title the CLI gave this thread (`#engineTitle`), then the first prompt
1210
+ * truncated.
1211
+ *
1212
+ * The rename outranks everything by design — a person naming a session must
1213
+ * not have it renamed under them by a model — which is also why the engine
1214
+ * title is *only ever read* while `meta.title` is unset (see
1215
+ * `#fetchEngineTitle`), rather than read and then discarded here.
1216
+ */
487
1217
  #title() {
488
1218
  const metaTitle = this.#config.meta?.title;
489
1219
  if (typeof metaTitle === "string" && metaTitle.length > 0) return metaTitle;
1220
+ if (this.#engineTitle) return this.#engineTitle;
490
1221
  const prompt = this.#config.prompt;
491
1222
  if (!prompt) return void 0;
492
1223
  return prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
@@ -613,14 +1344,27 @@ var SessionRunner = class {
613
1344
  });
614
1345
  this.#setStatus("closed");
615
1346
  }
1347
+ /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
1348
+ * "show everything" on one row, so a per-runner seq index would be a map
1349
+ * maintained on every emit to save a walk nobody makes twice a minute. */
1350
+ eventAt(seq) {
1351
+ return this.#events.find((event) => event.seq === seq);
1352
+ }
616
1353
  /**
617
1354
  * Replay buffered events with seq > afterSeq, then deliver live events.
618
1355
  * Returns an unsubscribe function.
1356
+ *
1357
+ * Replay honours the reset watermark: transcript content below the latest
1358
+ * `conversation_reset` is skipped (the reducer would clear it again anyway,
1359
+ * and a pre-reset client that never learned the reducer's case would render
1360
+ * a conversation the engine has discarded), while state-bearing events —
1361
+ * which are emitted once and never again — always replay. The reset event
1362
+ * itself replays (the skip is strictly-below), which is what clears a
1363
+ * reconnecting client still holding pre-reset rows; superseded resets are
1364
+ * content below the newer one and are skipped with what they cleared.
619
1365
  */
620
- subscribe(listener, afterSeq = 0) {
621
- for (const event of this.#events) if (event.seq > afterSeq) listener(event);
622
- this.#listeners.add(listener);
623
- return () => this.#listeners.delete(listener);
1366
+ subscribe(listener, afterSeq = 0, options) {
1367
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
624
1368
  }
625
1369
  async #run() {
626
1370
  const queryFn = this.#config.queryFn ?? query;
@@ -676,14 +1420,17 @@ var SessionRunner = class {
676
1420
  }
677
1421
  for (const m of messages) {
678
1422
  if (this.#closed) return;
679
- if (m.type === "user") this.#emit({
680
- type: "user_message",
681
- message: toApiMessage(m.message),
682
- parentToolUseId: m.parent_tool_use_id,
683
- replay: true,
684
- uuid: m.uuid
685
- });
686
- else if (m.type === "assistant") this.#emit({
1423
+ if (m.type === "user") {
1424
+ const message = toApiMessage(m.message);
1425
+ this.#emit({
1426
+ type: "user_message",
1427
+ message,
1428
+ parentToolUseId: m.parent_tool_use_id,
1429
+ replay: true,
1430
+ synthetic: isSyntheticUserText(message) ? true : void 0,
1431
+ uuid: m.uuid
1432
+ });
1433
+ } else if (m.type === "assistant") this.#emit({
687
1434
  type: "assistant_message",
688
1435
  message: toApiMessage(m.message),
689
1436
  parentToolUseId: m.parent_tool_use_id,
@@ -708,6 +1455,7 @@ var SessionRunner = class {
708
1455
  forkSession: c.forkSession,
709
1456
  effort: c.reasoningEffort,
710
1457
  includePartialMessages: c.includePartialMessages ?? true,
1458
+ forwardSubagentText: true,
711
1459
  canUseTool: this.#canUseTool,
712
1460
  env: c.env,
713
1461
  pathToClaudeCodeExecutable: c.pathToClaudeCodeExecutable,
@@ -734,14 +1482,20 @@ var SessionRunner = class {
734
1482
  claudeCodeVersion: msg.claude_code_version,
735
1483
  mcpServers: msg.mcp_servers
736
1484
  });
1485
+ this.#turnOverWhileBlocked = false;
737
1486
  this.#setStatus("running");
738
1487
  this.#fetchCapabilities();
739
1488
  this.#fetchContextUsage();
740
1489
  this.#fetchRateLimits();
1490
+ this.#fetchEngineTitle();
741
1491
  return;
742
1492
  }
743
1493
  if (msg.type === "system" && msg.subtype === "session_state_changed") {
744
- if (this.#pending.size > 0) return;
1494
+ if (this.#pending.size > 0) {
1495
+ if (msg.state === "idle") this.#turnOverWhileBlocked = true;
1496
+ else if (msg.state === "running") this.#turnOverWhileBlocked = false;
1497
+ return;
1498
+ }
745
1499
  if (msg.state === "idle") this.#setStatus("idle");
746
1500
  else if (msg.state === "running") this.#setStatus("running");
747
1501
  return;
@@ -749,12 +1503,18 @@ var SessionRunner = class {
749
1503
  const body = normalizeSdkMessage(msg);
750
1504
  if (body) {
751
1505
  this.#emit(body);
1506
+ if (body.type === "conversation_reset") {
1507
+ if (body.sdkSessionId) this.#sdkSessionId = body.sdkSessionId;
1508
+ this.#fetchContextUsage();
1509
+ }
752
1510
  if (body.type === "turn_result") {
753
1511
  this.#totalCostUsd = body.totalCostUsd;
754
1512
  this.#numTurns = body.numTurns;
755
1513
  if (this.#pending.size === 0) this.#setStatus("idle");
1514
+ else this.#turnOverWhileBlocked = true;
756
1515
  this.#fetchContextUsage();
757
1516
  this.#fetchRateLimits();
1517
+ this.#fetchEngineTitle();
758
1518
  }
759
1519
  }
760
1520
  }
@@ -784,6 +1544,44 @@ var SessionRunner = class {
784
1544
  });
785
1545
  } catch {}
786
1546
  }
1547
+ /**
1548
+ * Adopt the title the CLI gave this thread — the "friendly title" it writes a
1549
+ * turn or two into a session, and the name a resumed thread already carries.
1550
+ *
1551
+ * A **poll, not an observation**, and unavoidably so: no member of the SDK's
1552
+ * `SDKMessage` union carries it (the whole union was checked). It lives on
1553
+ * `SDKSessionInfo`, which only `getSessionInfo` / `listSessions` return — the
1554
+ * same record `GET /sdk-sessions` already serves as `SdkSessionSummary`. So it
1555
+ * is read at init and after each turn, which is also roughly the rate at which
1556
+ * it changes.
1557
+ *
1558
+ * Two rules:
1559
+ * - **Never while `meta.title` is set.** A rename is a person's decision and a
1560
+ * generated summary must not overwrite it. Not read at all in that case, so
1561
+ * there is no stored value waiting to resurface if the rename is cleared —
1562
+ * the next turn simply fetches it again.
1563
+ * - `summary` falls back to the first prompt when the session has no real
1564
+ * title yet, so it is taken only when it *differs* from `firstPrompt`.
1565
+ * Otherwise `#title()`'s own prompt fallback covers it, and the two would
1566
+ * disagree only in how they truncate.
1567
+ *
1568
+ * Best-effort throughout: an unreadable transcript, a session file that is not
1569
+ * there yet, an SDK without the function — all leave the title as it was.
1570
+ */
1571
+ async #fetchEngineTitle() {
1572
+ const metaTitle = this.#config.meta?.title;
1573
+ if (typeof metaTitle === "string" && metaTitle.length > 0) return;
1574
+ const sdkSessionId = this.#sdkSessionId;
1575
+ if (!sdkSessionId) return;
1576
+ const read = this.#config.sessionInfoFn ?? getSessionInfo;
1577
+ try {
1578
+ const info = await read(sdkSessionId, { dir: this.#cwd });
1579
+ if (this.#closed || !info) return;
1580
+ const summary = info.summary && info.summary !== info.firstPrompt ? info.summary : void 0;
1581
+ const title = info.customTitle || summary;
1582
+ if (title) this.#engineTitle = title;
1583
+ } catch {}
1584
+ }
787
1585
  /** Snapshot the context window after a turn and surface it as an event. Optional-chained
788
1586
  * and best-effort for the same reasons as #fetchCapabilities. */
789
1587
  async #fetchContextUsage() {
@@ -944,7 +1742,12 @@ var SessionRunner = class {
944
1742
  resolvedBy,
945
1743
  message: decision.behavior === "deny" ? decision.message ?? "Denied" : void 0
946
1744
  });
947
- if (this.#pending.size === 0 && this.#status === "awaiting_approval") this.#setStatus("running");
1745
+ if (this.#pending.size === 0) {
1746
+ const endedWhileBlocked = this.#turnOverWhileBlocked;
1747
+ this.#turnOverWhileBlocked = false;
1748
+ if (endedWhileBlocked) this.#setStatus("idle");
1749
+ else if (this.#status === "awaiting_approval") this.#setStatus("running");
1750
+ }
948
1751
  }
949
1752
  #setStatus(status, detail) {
950
1753
  if (this.#status === status && this.#statusDetail === detail) return;
@@ -965,10 +1768,10 @@ var SessionRunner = class {
965
1768
  };
966
1769
  this.#lastActivityAt = event.ts;
967
1770
  this.#activityCount += transcriptActivity(body);
1771
+ if (body.type === "conversation_reset") this.#resetSeq = event.seq;
1772
+ this.#subagents.observe(body, event.ts);
968
1773
  this.#events.push(event);
969
- for (const listener of this.#listeners) try {
970
- listener(event);
971
- } catch {}
1774
+ this.#subscribers.emit(event);
972
1775
  }
973
1776
  };
974
1777
  /** Answer each AskUserQuestion question with its first option's label — the tool's
@@ -986,7 +1789,7 @@ function recommendedAnswers(input) {
986
1789
  return answers;
987
1790
  }
988
1791
  //#endregion
989
- //#region src/ai-sdk-runner.ts
1792
+ //#region src/engines/provider/runner.ts
990
1793
  /** Permission modes this engine can honor. The rest of the protocol vocabulary
991
1794
  * (acceptEdits/plan/auto) is Claude Code CLI semantics with no meaning here —
992
1795
  * setPermissionMode rejects them, which the server surfaces as protocol_error. */
@@ -1011,7 +1814,7 @@ var AiSdkRunner = class {
1011
1814
  #config;
1012
1815
  #model;
1013
1816
  #events = [];
1014
- #listeners = /* @__PURE__ */ new Set();
1817
+ #subscribers = new SubscriberSet();
1015
1818
  #seq = 0;
1016
1819
  #activityCount = 0;
1017
1820
  #status = "starting";
@@ -1124,10 +1927,7 @@ var AiSdkRunner = class {
1124
1927
  start() {
1125
1928
  if (this.#started) return this.#turnChain;
1126
1929
  this.#started = true;
1127
- if (this.#config.restore) {
1128
- if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
1129
- return this.#turnChain;
1130
- }
1930
+ if (this.#config.restore) return this.#turnChain;
1131
1931
  this.#setStatus("idle");
1132
1932
  if (this.#config.prompt) this.sendMessage(this.#config.prompt);
1133
1933
  return this.#turnChain;
@@ -1142,6 +1942,61 @@ var AiSdkRunner = class {
1142
1942
  if (this.#closed || this.#parked) return void 0;
1143
1943
  if (this.#abort || !this.#restingOnDeferred()) return void 0;
1144
1944
  this.#setStatus("parked");
1945
+ const snapshot = this.#buildSnapshot();
1946
+ this.#parked = true;
1947
+ this.#subscribers.clear();
1948
+ try {
1949
+ Promise.resolve(this.#config.onClose?.()).catch(() => {});
1950
+ } catch {}
1951
+ return snapshot;
1952
+ }
1953
+ /**
1954
+ * The same snapshot, taken without ending anything.
1955
+ *
1956
+ * `park()` and this are two operations that happen to produce the same value,
1957
+ * and the difference is the whole point: `park()` *ends* the live runner
1958
+ * (inert, listeners dropped, `onClose` called), which is right for deferred
1959
+ * execution — the session has nothing to do for possibly days — and wrong for
1960
+ * restart-survival, where the session is active and someone is mid-
1961
+ * conversation. This one changes nothing at all: no status emit, no listener
1962
+ * clear, no disposer. The host writes the value through to durable storage
1963
+ * after each turn and keeps the runner live and warm, so a restart rebuilds
1964
+ * from the last write through the existing `restore` path and the next message
1965
+ * costs no wake.
1966
+ *
1967
+ * The gate is `park()`'s minus the requirement that there be something parked:
1968
+ *
1969
+ * - `#abort` set is refused for the reason it always was — a `generate()` in
1970
+ * flight has produced messages that are not in the history yet, so the
1971
+ * snapshot would be of a turn that half-happened.
1972
+ * - Pending calls that are **not** all deferred are refused, which is
1973
+ * `park()`'s rule wearing a different hat. An in-process execution's result
1974
+ * is coming back to *this* runner and dies with the process; a restore would
1975
+ * wait on it forever, and `state.dispatched` is what would stop the rebuilt
1976
+ * runner from simply calling it again.
1977
+ * - Idle with nothing pending — the case `park()` exists to refuse — is
1978
+ * exactly the case this exists to allow.
1979
+ */
1980
+ snapshot() {
1981
+ if (this.#closed || this.#parked || this.#abort) return void 0;
1982
+ if (this.#pendingToolCalls.size > 0 && !this.#restingOnDeferred()) return void 0;
1983
+ return this.#buildSnapshot();
1984
+ }
1985
+ /**
1986
+ * The snapshot value itself, shared so a park and a write-through cannot
1987
+ * disagree about what a session *is*.
1988
+ *
1989
+ * The event log is filtered through {@link snapshotRetains} — the persisted
1990
+ * log drops stream deltas, which are superseded by the `assistant_message`
1991
+ * that flushes them and would otherwise be tens of times the size of the text
1992
+ * they spell. Parks get it too, and should: a park sits on disk for days.
1993
+ *
1994
+ * The `parked` list and `state.parkedAt` are honest under both callers. An
1995
+ * idle write-through has no pending calls, so `parked` is empty and the host
1996
+ * arms no watchdogs; `parkedAt` is "when this was taken", which is what
1997
+ * `#restore` needs to discount a turn's clock either way.
1998
+ */
1999
+ #buildSnapshot() {
1145
2000
  const parked = [...this.#pendingToolCalls.values()].map((call) => ({
1146
2001
  executionId: call.toolCallId,
1147
2002
  toolName: call.toolName,
@@ -1159,22 +2014,16 @@ var AiSdkRunner = class {
1159
2014
  lastActivityAt: this.#lastActivityAt,
1160
2015
  parkedAt: Date.now()
1161
2016
  };
1162
- const snapshot = {
2017
+ return {
1163
2018
  engine: "provider",
1164
2019
  id: this.id,
1165
2020
  createdAt: this.createdAt,
1166
2021
  seq: this.#seq,
1167
- events: [...this.#events],
2022
+ events: this.#events.filter((event) => snapshotRetains(event)),
1168
2023
  vfs: this.#config.vfs?.snapshot(),
1169
2024
  parked,
1170
2025
  state
1171
2026
  };
1172
- this.#parked = true;
1173
- this.#listeners.clear();
1174
- try {
1175
- Promise.resolve(this.#config.onClose?.()).catch(() => {});
1176
- } catch {}
1177
- return snapshot;
1178
2027
  }
1179
2028
  sendMessage(text, attachments) {
1180
2029
  if (this.#parked) throw new Error("session is parked");
@@ -1357,10 +2206,14 @@ var AiSdkRunner = class {
1357
2206
  Promise.resolve(this.#config.onClose?.()).catch(() => {});
1358
2207
  } catch {}
1359
2208
  }
1360
- subscribe(listener, afterSeq = 0) {
1361
- for (const event of this.#events) if (event.seq > afterSeq) listener(event);
1362
- this.#listeners.add(listener);
1363
- return () => this.#listeners.delete(listener);
2209
+ /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
2210
+ * "show everything" on one row, so a per-runner seq index would be a map
2211
+ * maintained on every emit to save a walk nobody makes twice a minute. */
2212
+ eventAt(seq) {
2213
+ return this.#events.find((event) => event.seq === seq);
2214
+ }
2215
+ subscribe(listener, afterSeq = 0, options) {
2216
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
1364
2217
  }
1365
2218
  #scheduleTurn() {
1366
2219
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -1489,29 +2342,29 @@ var AiSdkRunner = class {
1489
2342
  cacheWrite: 0,
1490
2343
  cacheRead: 0
1491
2344
  };
2345
+ let blocks = [];
2346
+ const textBuf = /* @__PURE__ */ new Map();
2347
+ const reasoningBuf = /* @__PURE__ */ new Map();
2348
+ const flush = () => {
2349
+ if (blocks.length === 0) return;
2350
+ this.#emit({
2351
+ type: "assistant_message",
2352
+ message: {
2353
+ role: "assistant",
2354
+ content: blocks,
2355
+ model: this.#modelId()
2356
+ },
2357
+ parentToolUseId: null,
2358
+ uuid: randomUUID()
2359
+ });
2360
+ blocks = [];
2361
+ };
1492
2362
  try {
1493
2363
  const result = await agent.stream({
1494
2364
  messages: [...this.#messages],
1495
2365
  abortSignal: abort.signal
1496
2366
  });
1497
2367
  const partials = this.#config.includePartialMessages !== false;
1498
- let blocks = [];
1499
- const textBuf = /* @__PURE__ */ new Map();
1500
- const reasoningBuf = /* @__PURE__ */ new Map();
1501
- const flush = () => {
1502
- if (blocks.length === 0) return;
1503
- this.#emit({
1504
- type: "assistant_message",
1505
- message: {
1506
- role: "assistant",
1507
- content: blocks,
1508
- model: this.#modelId()
1509
- },
1510
- parentToolUseId: null,
1511
- uuid: randomUUID()
1512
- });
1513
- blocks = [];
1514
- };
1515
2368
  const emitToolResult = (toolCallId, content, isError) => {
1516
2369
  flush();
1517
2370
  this.#emit({
@@ -1641,6 +2494,15 @@ var AiSdkRunner = class {
1641
2494
  this.#finishTurn(text);
1642
2495
  } catch (error) {
1643
2496
  if (this.#closed) return;
2497
+ for (const [, thinking] of reasoningBuf) if (thinking) blocks.push({
2498
+ type: "thinking",
2499
+ thinking
2500
+ });
2501
+ for (const [, text] of textBuf) if (text) blocks.push({
2502
+ type: "text",
2503
+ text
2504
+ });
2505
+ flush();
1644
2506
  const message = error instanceof Error ? error.message : String(error);
1645
2507
  this.#numTurns += 1;
1646
2508
  this.#emit({
@@ -1741,9 +2603,7 @@ var AiSdkRunner = class {
1741
2603
  this.#lastActivityAt = event.ts;
1742
2604
  this.#activityCount += transcriptActivity(body);
1743
2605
  this.#events.push(event);
1744
- for (const listener of this.#listeners) try {
1745
- listener(event);
1746
- } catch {}
2606
+ this.#subscribers.emit(event);
1747
2607
  }
1748
2608
  };
1749
2609
  function turnUsage(accum) {
@@ -1761,7 +2621,7 @@ function errorText(error) {
1761
2621
  return error instanceof Error ? error.message : String(error);
1762
2622
  }
1763
2623
  //#endregion
1764
- //#region src/claude-auth.ts
2624
+ //#region src/engines/claude/auth.ts
1765
2625
  /**
1766
2626
  * The native Claude Code binary the Agent SDK itself spawns, resolved the way
1767
2627
  * the SDK resolves it: the platform-specific optional dependency installed next
@@ -1815,7 +2675,7 @@ function checkClaudeAuth(env, options = {}) {
1815
2675
  });
1816
2676
  }
1817
2677
  //#endregion
1818
- //#region src/quickjs-executor.ts
2678
+ //#region src/executors/quickjs-executor.ts
1819
2679
  /**
1820
2680
  * In-process execution backend: runs a tool's untrusted script in the QuickJS
1821
2681
  * WASM guest. Always settles inline — nothing downstream assumes that, which is
@@ -1913,7 +2773,7 @@ function isHostAllowed(url, allowedHosts) {
1913
2773
  });
1914
2774
  }
1915
2775
  //#endregion
1916
- //#region src/pending-registry.ts
2776
+ //#region src/lib/pending-registry.ts
1917
2777
  var PendingRequestRegistry = class {
1918
2778
  #slots = /* @__PURE__ */ new Map();
1919
2779
  get size() {
@@ -2020,7 +2880,7 @@ function toEntry(slot) {
2020
2880
  };
2021
2881
  }
2022
2882
  //#endregion
2023
- //#region src/browser-bridge-executor.ts
2883
+ //#region src/executors/browser-bridge-executor.ts
2024
2884
  /**
2025
2885
  * Executes tool calls in the attached client's own sandbox. The first backend
2026
2886
  * that genuinely returns `pending`: dispatch puts a request on the wire and
@@ -2131,7 +2991,7 @@ function toExecutionResult(outcome) {
2131
2991
  };
2132
2992
  }
2133
2993
  //#endregion
2134
- //#region src/deferred-executor.ts
2994
+ //#region src/executors/deferred-executor.ts
2135
2995
  /**
2136
2996
  * The executor for work that outlives the session's process residency: dispatch
2137
2997
  * hands the call off and returns `pending` **without holding a promise**, because
@@ -2178,7 +3038,7 @@ var DeferredExecutor = class {
2178
3038
  }
2179
3039
  };
2180
3040
  //#endregion
2181
- //#region src/tools.ts
3041
+ //#region src/engines/provider/tools.ts
2182
3042
  const MAX_FILE_BYTES = 1024 * 1024;
2183
3043
  /**
2184
3044
  * Build the capability-scoped tool set for a session.
@@ -2394,7 +3254,7 @@ function truncate(text) {
2394
3254
  return text.length > MAX_FILE_BYTES ? text.slice(0, MAX_FILE_BYTES) : text;
2395
3255
  }
2396
3256
  //#endregion
2397
- //#region src/web-fetch.ts
3257
+ //#region src/engines/provider/web-fetch.ts
2398
3258
  const MAX_CACHE_ENTRIES = 64;
2399
3259
  const MAX_REDIRECTS = 5;
2400
3260
  function createWebFetch(options = {}) {
@@ -2606,7 +3466,7 @@ function decodeEntities(text) {
2606
3466
  return text.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&#x([\da-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16))).replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, "&");
2607
3467
  }
2608
3468
  //#endregion
2609
- //#region src/engine.ts
3469
+ //#region src/engines/provider/session.ts
2610
3470
  /** Which capability a wired backend yields, for grant filtering. */
2611
3471
  const CAPABILITY_TOOLS = {
2612
3472
  search: "web_search",
@@ -3315,15 +4175,29 @@ function userQuestionsFromCodex(questions) {
3315
4175
  }))
3316
4176
  }));
3317
4177
  }
3318
- /** The text of a history `userMessage` item: its content entries' text parts
3319
- * joined. Image parts have no replayable representation (the bytes went to the
3320
- * model, not into the rollout we can render from) and are skipped. */
4178
+ /**
4179
+ * The text of a history `userMessage` item: its content entries' text parts
4180
+ * joined.
4181
+ *
4182
+ * Image parts have no replayable representation — the bytes went to the model,
4183
+ * not into the rollout we can render from — so they are named rather than
4184
+ * dropped. A prompt that was *only* an image used to produce an empty string,
4185
+ * which the caller read as "nothing to replay" and skipped: the turn lost its
4186
+ * user row and, with it, the prompt mark the scrubber navigates by, so a resumed
4187
+ * thread had answers with no visible question. A word in place of the picture is
4188
+ * a smaller lie than a turn that never happened.
4189
+ */
3321
4190
  function historyUserText(item) {
3322
4191
  if (!Array.isArray(item.content)) return "";
3323
- return item.content.map((part) => {
4192
+ let images = 0;
4193
+ const text = item.content.map((part) => {
3324
4194
  const candidate = part;
3325
- return candidate?.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
4195
+ if (candidate?.type === "text" && typeof candidate.text === "string") return candidate.text;
4196
+ if (typeof candidate?.type === "string" && candidate.type.toLowerCase().includes("image")) images += 1;
4197
+ return "";
3326
4198
  }).filter(Boolean).join("\n");
4199
+ if (text) return text;
4200
+ return images > 0 ? `[${images === 1 ? "image" : `${images} images`}]` : "";
3327
4201
  }
3328
4202
  /** The AskUserQuestion answer convention (question text → chosen label(s),
3329
4203
  * comma-joined) mapped back to codex's id-keyed shape. Questions the client
@@ -3486,7 +4360,7 @@ var CodexRunner = class {
3486
4360
  /** {@link CodexRunnerConfig.cwd}, checked once in the constructor. */
3487
4361
  #cwd;
3488
4362
  #events = [];
3489
- #listeners = /* @__PURE__ */ new Set();
4363
+ #subscribers = new SubscriberSet();
3490
4364
  #seq = 0;
3491
4365
  #activityCount = 0;
3492
4366
  #status = "starting";
@@ -3827,10 +4701,14 @@ var CodexRunner = class {
3827
4701
  });
3828
4702
  this.#setStatus("closed");
3829
4703
  }
3830
- subscribe(listener, afterSeq = 0) {
3831
- for (const event of this.#events) if (event.seq > afterSeq) listener(event);
3832
- this.#listeners.add(listener);
3833
- return () => this.#listeners.delete(listener);
4704
+ /** See `Runner.eventAt`. A linear scan: the one caller is a reader pressing
4705
+ * "show everything" on one row, so a per-runner seq index would be a map
4706
+ * maintained on every emit to save a walk nobody makes twice a minute. */
4707
+ eventAt(seq) {
4708
+ return this.#events.find((event) => event.seq === seq);
4709
+ }
4710
+ subscribe(listener, afterSeq = 0, options) {
4711
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
3834
4712
  }
3835
4713
  #scheduleTurn() {
3836
4714
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -4155,131 +5033,137 @@ var CodexRunner = class {
4155
5033
  }
4156
5034
  #handleNotification(method, params) {
4157
5035
  if (this.#closed) return;
5036
+ this.#notifications[method]?.(params);
5037
+ }
5038
+ /** Reasoning deltas arrive on two methods that differ only in which section
5039
+ * counter they advance; the section key carries the method so the two streams
5040
+ * never share a boundary. Section boundaries (a new summary/content entry)
5041
+ * render as paragraph breaks — the completed item joins sections with '\n\n'. */
5042
+ #reasoningDelta(method) {
5043
+ return (params) => {
5044
+ const active = this.#activeTurn;
5045
+ if (!active) return;
5046
+ const payload = params;
5047
+ if (typeof payload?.delta !== "string" || !payload.delta) return;
5048
+ const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
5049
+ const key = `${payload.itemId ?? ""}:${method}`;
5050
+ const previous = active.sectionIndex.get(key);
5051
+ active.sectionIndex.set(key, index);
5052
+ const separator = previous !== void 0 && index > previous ? "\n\n" : "";
5053
+ this.#emitDelta({
5054
+ type: "thinking_delta",
5055
+ thinking: separator + payload.delta
5056
+ });
5057
+ };
5058
+ }
5059
+ /** One item-progress handler serves `item/started` and `item/updated`. */
5060
+ #itemProgress = (params) => {
4158
5061
  const active = this.#activeTurn;
4159
- switch (method) {
4160
- case "thread/started": {
4161
- const thread = params?.thread;
4162
- if (typeof thread?.id === "string") this.#sdkSessionId = thread.id;
4163
- return;
4164
- }
4165
- case "turn/started": {
4166
- const turn = params?.turn;
4167
- if (active && turn && !active.turnId) active.turnId = turn.id;
4168
- return;
4169
- }
4170
- case "turn/completed": {
4171
- const turn = params?.turn;
4172
- if (active && turn) active.resolve(turn);
4173
- return;
4174
- }
4175
- case "item/started":
4176
- case "item/updated": {
4177
- if (!active) return;
4178
- const item = params?.item;
4179
- if (item) this.#handleItemProgress(item, active);
4180
- return;
4181
- }
4182
- case "item/completed": {
4183
- if (!active) return;
4184
- const item = params?.item;
4185
- if (item) this.#handleItemCompleted(item, active);
4186
- return;
4187
- }
4188
- case "item/agentMessage/delta": {
4189
- if (!active) return;
4190
- const delta = params?.delta;
4191
- if (typeof delta === "string" && delta) this.#emitDelta({
4192
- type: "text_delta",
4193
- text: delta
4194
- });
4195
- return;
4196
- }
4197
- case "item/reasoning/textDelta":
4198
- case "item/reasoning/summaryTextDelta": {
4199
- if (!active) return;
4200
- const payload = params;
4201
- if (typeof payload?.delta !== "string" || !payload.delta) return;
4202
- const index = payload.contentIndex ?? payload.summaryIndex ?? 0;
4203
- const key = `${payload.itemId ?? ""}:${method}`;
4204
- const previous = active.sectionIndex.get(key);
4205
- active.sectionIndex.set(key, index);
4206
- const separator = previous !== void 0 && index > previous ? "\n\n" : "";
4207
- this.#emitDelta({
4208
- type: "thinking_delta",
4209
- thinking: separator + payload.delta
4210
- });
4211
- return;
4212
- }
4213
- case "thread/tokenUsage/updated": {
4214
- if (!active) return;
4215
- const last = params?.tokenUsage?.last;
4216
- if (!last) return;
4217
- active.sawUsage = true;
4218
- active.usage.inputTokens += last.inputTokens ?? 0;
4219
- active.usage.cachedInputTokens += last.cachedInputTokens ?? 0;
4220
- active.usage.cacheWriteInputTokens = (active.usage.cacheWriteInputTokens ?? 0) + (last.cacheWriteInputTokens ?? 0);
4221
- active.usage.outputTokens += last.outputTokens ?? 0;
4222
- active.usage.reasoningOutputTokens += last.reasoningOutputTokens ?? 0;
4223
- const update = params;
4224
- active.contextTokens = last.totalTokens ?? void 0;
4225
- active.contextWindow = update.tokenUsage?.modelContextWindow ?? void 0;
4226
- return;
4227
- }
4228
- case "mcpServer/startupStatus/updated": {
4229
- const update = params;
4230
- if (typeof update?.name !== "string") return;
4231
- this.#mcpStatus.set(update.name, {
4232
- status: typeof update.status === "string" ? update.status : "starting",
4233
- ...update.error ? { error: update.error } : {},
4234
- ...update.failureReason ? { failureReason: update.failureReason } : {}
4235
- });
4236
- return;
4237
- }
4238
- case "skills/changed": {
4239
- const connection = this.#connection;
4240
- if (connection) this.#refreshSkills(connection);
4241
- return;
4242
- }
4243
- case "account/rateLimits/updated":
4244
- this.#emitRateLimits(params?.rateLimits);
4245
- return;
4246
- case "turn/plan/updated": {
4247
- if (!active) return;
4248
- const plan = params?.plan;
4249
- if (!Array.isArray(plan)) return;
4250
- this.#emit({
4251
- type: "sdk_event",
4252
- payload: {
4253
- type: "codex.todo_list",
4254
- id: `${active.nonce}:plan`,
4255
- items: plan.map((step) => ({
4256
- text: step.step,
4257
- completed: step.status === "completed"
4258
- }))
4259
- }
4260
- });
4261
- return;
4262
- }
4263
- case "serverRequest/resolved": {
4264
- const requestId = params?.requestId;
4265
- if (requestId === void 0) return;
4266
- for (const [id, pending] of this.#approvals) if (pending.wireId === requestId) {
4267
- this.#settleApproval(id, pending, {
4268
- behavior: "deny",
4269
- message: "resolved by codex"
4270
- }, "policy");
4271
- return;
5062
+ if (!active) return;
5063
+ const item = params?.item;
5064
+ if (item) this.#handleItemProgress(item, active);
5065
+ };
5066
+ /** The notification dispatch table — every method the child emits that this
5067
+ * runner maps, in one place. Handlers read `this.#activeTurn` themselves:
5068
+ * dispatch is synchronous, so the read is the same one the old switch made. */
5069
+ #notifications = {
5070
+ "thread/started": (params) => {
5071
+ const thread = params?.thread;
5072
+ if (typeof thread?.id === "string") this.#sdkSessionId = thread.id;
5073
+ },
5074
+ "turn/started": (params) => {
5075
+ const active = this.#activeTurn;
5076
+ const turn = params?.turn;
5077
+ if (active && turn && !active.turnId) active.turnId = turn.id;
5078
+ },
5079
+ "turn/completed": (params) => {
5080
+ const active = this.#activeTurn;
5081
+ const turn = params?.turn;
5082
+ if (active && turn) active.resolve(turn);
5083
+ },
5084
+ "item/started": this.#itemProgress,
5085
+ "item/updated": this.#itemProgress,
5086
+ "item/completed": (params) => {
5087
+ const active = this.#activeTurn;
5088
+ if (!active) return;
5089
+ const item = params?.item;
5090
+ if (item) this.#handleItemCompleted(item, active);
5091
+ },
5092
+ "item/agentMessage/delta": (params) => {
5093
+ if (!this.#activeTurn) return;
5094
+ const delta = params?.delta;
5095
+ if (typeof delta === "string" && delta) this.#emitDelta({
5096
+ type: "text_delta",
5097
+ text: delta
5098
+ });
5099
+ },
5100
+ "item/reasoning/textDelta": this.#reasoningDelta("item/reasoning/textDelta"),
5101
+ "item/reasoning/summaryTextDelta": this.#reasoningDelta("item/reasoning/summaryTextDelta"),
5102
+ "thread/tokenUsage/updated": (params) => {
5103
+ const active = this.#activeTurn;
5104
+ if (!active) return;
5105
+ const last = params?.tokenUsage?.last;
5106
+ if (!last) return;
5107
+ active.sawUsage = true;
5108
+ active.usage.inputTokens += last.inputTokens ?? 0;
5109
+ active.usage.cachedInputTokens += last.cachedInputTokens ?? 0;
5110
+ active.usage.cacheWriteInputTokens = (active.usage.cacheWriteInputTokens ?? 0) + (last.cacheWriteInputTokens ?? 0);
5111
+ active.usage.outputTokens += last.outputTokens ?? 0;
5112
+ active.usage.reasoningOutputTokens += last.reasoningOutputTokens ?? 0;
5113
+ const update = params;
5114
+ active.contextTokens = last.totalTokens ?? void 0;
5115
+ active.contextWindow = update.tokenUsage?.modelContextWindow ?? void 0;
5116
+ },
5117
+ "mcpServer/startupStatus/updated": (params) => {
5118
+ const update = params;
5119
+ if (typeof update?.name !== "string") return;
5120
+ this.#mcpStatus.set(update.name, {
5121
+ status: typeof update.status === "string" ? update.status : "starting",
5122
+ ...update.error ? { error: update.error } : {},
5123
+ ...update.failureReason ? { failureReason: update.failureReason } : {}
5124
+ });
5125
+ },
5126
+ "skills/changed": () => {
5127
+ const connection = this.#connection;
5128
+ if (connection) this.#refreshSkills(connection);
5129
+ },
5130
+ "account/rateLimits/updated": (params) => {
5131
+ this.#emitRateLimits(params?.rateLimits);
5132
+ },
5133
+ "turn/plan/updated": (params) => {
5134
+ const active = this.#activeTurn;
5135
+ if (!active) return;
5136
+ const plan = params?.plan;
5137
+ if (!Array.isArray(plan)) return;
5138
+ this.#emit({
5139
+ type: "sdk_event",
5140
+ payload: {
5141
+ type: "codex.todo_list",
5142
+ id: `${active.nonce}:plan`,
5143
+ items: plan.map((step) => ({
5144
+ text: step.step,
5145
+ completed: step.status === "completed"
5146
+ }))
4272
5147
  }
5148
+ });
5149
+ },
5150
+ "serverRequest/resolved": (params) => {
5151
+ const requestId = params?.requestId;
5152
+ if (requestId === void 0) return;
5153
+ for (const [id, pending] of this.#approvals) if (pending.wireId === requestId) {
5154
+ this.#settleApproval(id, pending, {
5155
+ behavior: "deny",
5156
+ message: "resolved by codex"
5157
+ }, "policy");
4273
5158
  return;
4274
5159
  }
4275
- case "error": {
4276
- const error = params?.error;
4277
- if (active && typeof error?.message === "string") active.lastError = error.message;
4278
- return;
4279
- }
4280
- default: return;
5160
+ },
5161
+ "error": (params) => {
5162
+ const active = this.#activeTurn;
5163
+ const error = params?.error;
5164
+ if (active && typeof error?.message === "string") active.lastError = error.message;
4281
5165
  }
4282
- }
5166
+ };
4283
5167
  /** Answer a server→client request: the ask channels become pending
4284
5168
  * permission requests; anything else gets a JSON-RPC -32601 rather than a
4285
5169
  * hang (an unanswered server request wedges the turn). */
@@ -4425,83 +5309,89 @@ var CodexRunner = class {
4425
5309
  }
4426
5310
  #handleItemCompleted(item, active) {
4427
5311
  const id = `${active.nonce}:${item.id}`;
4428
- switch (item.type) {
4429
- case "userMessage": return;
4430
- case "agentMessage": {
4431
- const text = typeof item.text === "string" ? item.text : "";
4432
- this.#emitAssistant(id, [{
4433
- type: "text",
4434
- text
4435
- }]);
4436
- active.finalText = text;
4437
- return;
4438
- }
4439
- case "reasoning": {
4440
- const summary = Array.isArray(item.summary) ? item.summary.filter(Boolean) : [];
4441
- const content = Array.isArray(item.content) ? item.content.filter(Boolean) : [];
4442
- const thinking = (summary.length > 0 ? summary : content).join("\n\n");
4443
- if (thinking) this.#emitAssistant(id, [{
4444
- type: "thinking",
4445
- thinking
4446
- }]);
4447
- return;
4448
- }
4449
- case "commandExecution": {
4450
- if (!active.toolUseEmitted.has(id)) {
4451
- active.toolUseEmitted.add(id);
4452
- this.#emitToolUse(id, "CodexCommand", { command: item.command });
4453
- }
4454
- const exitCode = item.exitCode ?? void 0;
4455
- const failed = item.status === "failed" || item.status === "declined" || exitCode !== void 0 && exitCode !== 0;
4456
- const output = (item.aggregatedOutput ?? "") + (exitCode !== void 0 && exitCode !== 0 ? `\n(exit code ${exitCode})` : "");
4457
- this.#emitToolResult(id, output, failed);
4458
- return;
4459
- }
4460
- case "fileChange": {
4461
- this.#emitToolUse(id, "CodexFileChange", { changes: item.changes });
4462
- const lines = item.changes.map((change) => {
4463
- return `${(typeof change.kind === "string" ? change.kind : change.kind?.type) ?? "change"}: ${change.path}`;
4464
- });
4465
- this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined");
4466
- return;
4467
- }
4468
- case "mcpToolCall": {
4469
- if (!active.toolUseEmitted.has(id)) {
4470
- active.toolUseEmitted.add(id);
4471
- this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
4472
- }
4473
- const isError = item.error !== void 0 && item.error !== null || item.status === "failed";
4474
- this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError);
4475
- return;
5312
+ const handler = this.#itemCompleted[item.type];
5313
+ if (handler) {
5314
+ handler(item, active, id);
5315
+ return;
5316
+ }
5317
+ const unknown = item;
5318
+ this.#emit({
5319
+ type: "sdk_event",
5320
+ payload: {
5321
+ type: `codex.${unknown.type}`,
5322
+ item: unknown
4476
5323
  }
4477
- case "webSearch":
4478
- this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
4479
- this.#emitToolResult(id, "", false);
4480
- return;
4481
- case "imageGeneration": {
5324
+ });
5325
+ }
5326
+ /**
5327
+ * The completed-item mapping, one handler per member of the {@link AppServerItem}
5328
+ * union. The mapped type is the invariant made checkable: model a new item
5329
+ * type in `types.ts` and this table fails to compile until it says what the
5330
+ * item becomes on the wire — the old switch silently fell through to the
5331
+ * unknown-item passthrough instead. (The runtime still receives types the
5332
+ * union has never heard of; those take the passthrough above.)
5333
+ */
5334
+ #itemCompleted = {
5335
+ userMessage: () => {},
5336
+ agentMessage: (item, active, id) => {
5337
+ const text = typeof item.text === "string" ? item.text : "";
5338
+ this.#emitAssistant(id, [{
5339
+ type: "text",
5340
+ text
5341
+ }]);
5342
+ active.finalText = text;
5343
+ },
5344
+ reasoning: (item, _active, id) => {
5345
+ const summary = Array.isArray(item.summary) ? item.summary.filter(Boolean) : [];
5346
+ const content = Array.isArray(item.content) ? item.content.filter(Boolean) : [];
5347
+ const thinking = (summary.length > 0 ? summary : content).join("\n\n");
5348
+ if (thinking) this.#emitAssistant(id, [{
5349
+ type: "thinking",
5350
+ thinking
5351
+ }]);
5352
+ },
5353
+ commandExecution: (item, active, id) => {
5354
+ if (!active.toolUseEmitted.has(id)) {
4482
5355
  active.toolUseEmitted.add(id);
4483
- this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
4484
- if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
4485
- const lines = [item.savedPath ? `Saved to ${item.savedPath}` : "No saved path reported", ...shortResult(item.result) ? [item.result] : []];
4486
- this.#emitToolResult(id, lines.join("\n"), item.status === "failed");
4487
- return;
5356
+ this.#emitToolUse(id, "CodexCommand", { command: item.command });
4488
5357
  }
4489
- case "imageView":
4490
- this.#emitToolUse(id, "CodexImageView", { path: item.path });
4491
- this.#emitToolResult(id, item.path, false);
4492
- return;
4493
- default: {
4494
- const unknown = item;
4495
- this.#emit({
4496
- type: "sdk_event",
4497
- payload: {
4498
- type: `codex.${unknown.type}`,
4499
- item: unknown
4500
- }
4501
- });
5358
+ const exitCode = item.exitCode ?? void 0;
5359
+ const failed = item.status === "failed" || item.status === "declined" || exitCode !== void 0 && exitCode !== 0;
5360
+ const output = (item.aggregatedOutput ?? "") + (exitCode !== void 0 && exitCode !== 0 ? `\n(exit code ${exitCode})` : "");
5361
+ this.#emitToolResult(id, output, failed);
5362
+ },
5363
+ fileChange: (item, _active, id) => {
5364
+ this.#emitToolUse(id, "CodexFileChange", { changes: item.changes });
5365
+ const lines = item.changes.map((change) => {
5366
+ return `${(typeof change.kind === "string" ? change.kind : change.kind?.type) ?? "change"}: ${change.path}`;
5367
+ });
5368
+ const only = item.changes.length === 1 ? item.changes[0] : void 0;
5369
+ this.#emitToolResult(id, lines.join("\n") || item.status, item.status === "failed" || item.status === "declined", only?.diff ? parseUnifiedDiff(only.diff, only.path) : void 0);
5370
+ },
5371
+ mcpToolCall: (item, active, id) => {
5372
+ if (!active.toolUseEmitted.has(id)) {
5373
+ active.toolUseEmitted.add(id);
5374
+ this.#emitToolUse(id, `mcp__${item.server}__${item.tool}`, item.arguments);
4502
5375
  }
5376
+ const isError = item.error !== void 0 && item.error !== null || item.status === "failed";
5377
+ this.#emitToolResult(id, item.error?.message ?? (item.result === void 0 || item.result === null ? "" : JSON.stringify(item.result)), isError);
5378
+ },
5379
+ webSearch: (item, _active, id) => {
5380
+ this.#emitToolUse(id, "CodexWebSearch", { query: item.query });
5381
+ this.#emitToolResult(id, "", false);
5382
+ },
5383
+ imageGeneration: (item, active, id) => {
5384
+ active.toolUseEmitted.add(id);
5385
+ this.#emitToolUse(id, CODEX_IMAGE_TOOL, imageGenerationInput(item));
5386
+ if (item.savedPath) this.#emitFileProduced(item.savedPath, id);
5387
+ const lines = [item.savedPath ? `Saved to ${item.savedPath}` : "No saved path reported", ...shortResult(item.result) ? [item.result] : []];
5388
+ this.#emitToolResult(id, lines.join("\n"), item.status === "failed");
5389
+ },
5390
+ imageView: (item, _active, id) => {
5391
+ this.#emitToolUse(id, "CodexImageView", { path: item.path });
5392
+ this.#emitToolResult(id, item.path, false);
4503
5393
  }
4504
- }
5394
+ };
4505
5395
  #emitDelta(delta) {
4506
5396
  if (this.#config.includePartialMessages === false) return;
4507
5397
  this.#emit({
@@ -4543,7 +5433,7 @@ var CodexRunner = class {
4543
5433
  uuid: `${id}-use`
4544
5434
  });
4545
5435
  }
4546
- #emitToolResult(toolUseId, content, isError) {
5436
+ #emitToolResult(toolUseId, content, isError, patch) {
4547
5437
  this.#emit({
4548
5438
  type: "user_message",
4549
5439
  message: {
@@ -4557,6 +5447,7 @@ var CodexRunner = class {
4557
5447
  },
4558
5448
  parentToolUseId: null,
4559
5449
  synthetic: true,
5450
+ patch,
4560
5451
  uuid: `${toolUseId}-result`
4561
5452
  });
4562
5453
  }
@@ -4683,9 +5574,7 @@ var CodexRunner = class {
4683
5574
  this.#lastActivityAt = event.ts;
4684
5575
  this.#activityCount += transcriptActivity(body);
4685
5576
  this.#events.push(event);
4686
- for (const listener of this.#listeners) try {
4687
- listener(event);
4688
- } catch {}
5577
+ this.#subscribers.emit(event);
4689
5578
  }
4690
5579
  };
4691
5580
  //#endregion
@@ -5119,6 +6008,6 @@ function getEngineAdapter(engine) {
5119
6008
  return ADAPTERS[engine ?? "claude"];
5120
6009
  }
5121
6010
  //#endregion
5122
- export { AiSdkRunner, BrowserBridgeExecutor, CLAUDE_CATALOG, CODEX_CATALOG, CodexRunner, DeferredExecutor, InputQueue, JsonRpcError, JsonRpcStdioConnection, PendingRequestRegistry, QuickJsExecutor, SUPPORTED_ATTACHMENT_TYPES, SessionRunner, attachmentContentBlocks, attachmentKind, attachmentRef, checkClaudeAuth, claudeAdapter, codexAdapter, connectAppServer, connectMcpTools, createEngineSession, createToolContext, createWebFetch, getEngineAdapter, htmlToMarkdown, isHostAllowed, isPrivateAddress, listCodexSessions, mcpStatusInfo, modelOptionsFromSdk, normalizeMediaType, normalizeSdkMessage, providerAdapter, resolveBundledClaudeExecutable, resolveBundledCodexExecutable, toApiMessage, toExecutionResult, withHostTools, withMcpTools };
6011
+ export { AiSdkRunner, BrowserBridgeExecutor, CLAUDE_CATALOG, CODEX_CATALOG, CodexRunner, DeferredExecutor, InputQueue, JsonRpcError, JsonRpcStdioConnection, PendingRequestRegistry, QuickJsExecutor, SUPPORTED_ATTACHMENT_TYPES, SessionRunner, attachmentContentBlocks, attachmentKind, attachmentRef, checkClaudeAuth, claudeAdapter, codexAdapter, connectAppServer, connectMcpTools, createEngineSession, createToolContext, createWebFetch, getEngineAdapter, htmlToMarkdown, isHostAllowed, isPrivateAddress, listCodexSessions, mcpStatusInfo, modelOptionsFromSdk, normalizeMediaType, normalizeSdkMessage, providerAdapter, replaySlice, resolveBundledClaudeExecutable, resolveBundledCodexExecutable, toApiMessage, toExecutionResult, truncateResultBlocks, withHostTools, withMcpTools };
5123
6012
 
5124
6013
  //# sourceMappingURL=index.mjs.map