@workerdeck/core 0.16.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
3
  import { getSessionInfo, getSessionMessages, listSessions, query } from "@anthropic-ai/claude-agent-sdk";
4
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, replayCoalesceKey, transcriptActivity, transcriptContent } from "@workerdeck/protocol";
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";
@@ -593,6 +593,496 @@ function staleReplaySeqs(events, afterSeq) {
593
593
  }
594
594
  return stale;
595
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
+ }
596
1086
  //#endregion
597
1087
  //#region src/engines/claude/runner.ts
598
1088
  const DEFAULT_APPROVAL_TIMEOUT_MS$1 = 3e5;
@@ -608,7 +1098,7 @@ var SessionRunner = class {
608
1098
  /** {@link SessionRunnerConfig.cwd}, checked once in the constructor. */
609
1099
  #cwd;
610
1100
  #events = [];
611
- #listeners = /* @__PURE__ */ new Set();
1101
+ #subscribers = new SubscriberSet();
612
1102
  #seq = 0;
613
1103
  #activityCount = 0;
614
1104
  /**
@@ -627,6 +1117,28 @@ var SessionRunner = class {
627
1117
  #apiKeySource;
628
1118
  #permissionMode;
629
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();
630
1142
  #totalCostUsd;
631
1143
  #numTurns;
632
1144
  #lastActivityAt;
@@ -683,6 +1195,7 @@ var SessionRunner = class {
683
1195
  lastSeq: this.#seq,
684
1196
  activityCount: this.#activityCount,
685
1197
  pendingPermissionCount: this.#pending.size,
1198
+ subagents: this.#subagents.list(),
686
1199
  meta: this.#config.meta,
687
1200
  scope: this.#config.scope,
688
1201
  title: this.#title(),
@@ -831,6 +1344,12 @@ var SessionRunner = class {
831
1344
  });
832
1345
  this.#setStatus("closed");
833
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
+ }
834
1353
  /**
835
1354
  * Replay buffered events with seq > afterSeq, then deliver live events.
836
1355
  * Returns an unsubscribe function.
@@ -845,15 +1364,7 @@ var SessionRunner = class {
845
1364
  * content below the newer one and are skipped with what they cleared.
846
1365
  */
847
1366
  subscribe(listener, afterSeq = 0, options) {
848
- const stale = options?.coalesceReplay ? staleReplaySeqs(this.#events, afterSeq) : void 0;
849
- for (const event of this.#events) {
850
- if (event.seq <= afterSeq) continue;
851
- if (event.seq < this.#resetSeq && transcriptContent(event)) continue;
852
- if (stale?.has(event.seq)) continue;
853
- listener(event);
854
- }
855
- this.#listeners.add(listener);
856
- return () => this.#listeners.delete(listener);
1367
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
857
1368
  }
858
1369
  async #run() {
859
1370
  const queryFn = this.#config.queryFn ?? query;
@@ -944,6 +1455,7 @@ var SessionRunner = class {
944
1455
  forkSession: c.forkSession,
945
1456
  effort: c.reasoningEffort,
946
1457
  includePartialMessages: c.includePartialMessages ?? true,
1458
+ forwardSubagentText: true,
947
1459
  canUseTool: this.#canUseTool,
948
1460
  env: c.env,
949
1461
  pathToClaudeCodeExecutable: c.pathToClaudeCodeExecutable,
@@ -970,6 +1482,7 @@ var SessionRunner = class {
970
1482
  claudeCodeVersion: msg.claude_code_version,
971
1483
  mcpServers: msg.mcp_servers
972
1484
  });
1485
+ this.#turnOverWhileBlocked = false;
973
1486
  this.#setStatus("running");
974
1487
  this.#fetchCapabilities();
975
1488
  this.#fetchContextUsage();
@@ -978,7 +1491,11 @@ var SessionRunner = class {
978
1491
  return;
979
1492
  }
980
1493
  if (msg.type === "system" && msg.subtype === "session_state_changed") {
981
- 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
+ }
982
1499
  if (msg.state === "idle") this.#setStatus("idle");
983
1500
  else if (msg.state === "running") this.#setStatus("running");
984
1501
  return;
@@ -994,6 +1511,7 @@ var SessionRunner = class {
994
1511
  this.#totalCostUsd = body.totalCostUsd;
995
1512
  this.#numTurns = body.numTurns;
996
1513
  if (this.#pending.size === 0) this.#setStatus("idle");
1514
+ else this.#turnOverWhileBlocked = true;
997
1515
  this.#fetchContextUsage();
998
1516
  this.#fetchRateLimits();
999
1517
  this.#fetchEngineTitle();
@@ -1224,7 +1742,12 @@ var SessionRunner = class {
1224
1742
  resolvedBy,
1225
1743
  message: decision.behavior === "deny" ? decision.message ?? "Denied" : void 0
1226
1744
  });
1227
- 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
+ }
1228
1751
  }
1229
1752
  #setStatus(status, detail) {
1230
1753
  if (this.#status === status && this.#statusDetail === detail) return;
@@ -1246,10 +1769,9 @@ var SessionRunner = class {
1246
1769
  this.#lastActivityAt = event.ts;
1247
1770
  this.#activityCount += transcriptActivity(body);
1248
1771
  if (body.type === "conversation_reset") this.#resetSeq = event.seq;
1772
+ this.#subagents.observe(body, event.ts);
1249
1773
  this.#events.push(event);
1250
- for (const listener of this.#listeners) try {
1251
- listener(event);
1252
- } catch {}
1774
+ this.#subscribers.emit(event);
1253
1775
  }
1254
1776
  };
1255
1777
  /** Answer each AskUserQuestion question with its first option's label — the tool's
@@ -1292,7 +1814,7 @@ var AiSdkRunner = class {
1292
1814
  #config;
1293
1815
  #model;
1294
1816
  #events = [];
1295
- #listeners = /* @__PURE__ */ new Set();
1817
+ #subscribers = new SubscriberSet();
1296
1818
  #seq = 0;
1297
1819
  #activityCount = 0;
1298
1820
  #status = "starting";
@@ -1405,10 +1927,7 @@ var AiSdkRunner = class {
1405
1927
  start() {
1406
1928
  if (this.#started) return this.#turnChain;
1407
1929
  this.#started = true;
1408
- if (this.#config.restore) {
1409
- if (this.#pendingToolCalls.size === 0) this.#scheduleTurn();
1410
- return this.#turnChain;
1411
- }
1930
+ if (this.#config.restore) return this.#turnChain;
1412
1931
  this.#setStatus("idle");
1413
1932
  if (this.#config.prompt) this.sendMessage(this.#config.prompt);
1414
1933
  return this.#turnChain;
@@ -1423,6 +1942,61 @@ var AiSdkRunner = class {
1423
1942
  if (this.#closed || this.#parked) return void 0;
1424
1943
  if (this.#abort || !this.#restingOnDeferred()) return void 0;
1425
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() {
1426
2000
  const parked = [...this.#pendingToolCalls.values()].map((call) => ({
1427
2001
  executionId: call.toolCallId,
1428
2002
  toolName: call.toolName,
@@ -1440,22 +2014,16 @@ var AiSdkRunner = class {
1440
2014
  lastActivityAt: this.#lastActivityAt,
1441
2015
  parkedAt: Date.now()
1442
2016
  };
1443
- const snapshot = {
2017
+ return {
1444
2018
  engine: "provider",
1445
2019
  id: this.id,
1446
2020
  createdAt: this.createdAt,
1447
2021
  seq: this.#seq,
1448
- events: [...this.#events],
2022
+ events: this.#events.filter((event) => snapshotRetains(event)),
1449
2023
  vfs: this.#config.vfs?.snapshot(),
1450
2024
  parked,
1451
2025
  state
1452
2026
  };
1453
- this.#parked = true;
1454
- this.#listeners.clear();
1455
- try {
1456
- Promise.resolve(this.#config.onClose?.()).catch(() => {});
1457
- } catch {}
1458
- return snapshot;
1459
2027
  }
1460
2028
  sendMessage(text, attachments) {
1461
2029
  if (this.#parked) throw new Error("session is parked");
@@ -1638,15 +2206,14 @@ var AiSdkRunner = class {
1638
2206
  Promise.resolve(this.#config.onClose?.()).catch(() => {});
1639
2207
  } catch {}
1640
2208
  }
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
+ }
1641
2215
  subscribe(listener, afterSeq = 0, options) {
1642
- const stale = options?.coalesceReplay ? staleReplaySeqs(this.#events, afterSeq) : void 0;
1643
- for (const event of this.#events) {
1644
- if (event.seq <= afterSeq) continue;
1645
- if (stale?.has(event.seq)) continue;
1646
- listener(event);
1647
- }
1648
- this.#listeners.add(listener);
1649
- return () => this.#listeners.delete(listener);
2216
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
1650
2217
  }
1651
2218
  #scheduleTurn() {
1652
2219
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -2036,9 +2603,7 @@ var AiSdkRunner = class {
2036
2603
  this.#lastActivityAt = event.ts;
2037
2604
  this.#activityCount += transcriptActivity(body);
2038
2605
  this.#events.push(event);
2039
- for (const listener of this.#listeners) try {
2040
- listener(event);
2041
- } catch {}
2606
+ this.#subscribers.emit(event);
2042
2607
  }
2043
2608
  };
2044
2609
  function turnUsage(accum) {
@@ -3795,7 +4360,7 @@ var CodexRunner = class {
3795
4360
  /** {@link CodexRunnerConfig.cwd}, checked once in the constructor. */
3796
4361
  #cwd;
3797
4362
  #events = [];
3798
- #listeners = /* @__PURE__ */ new Set();
4363
+ #subscribers = new SubscriberSet();
3799
4364
  #seq = 0;
3800
4365
  #activityCount = 0;
3801
4366
  #status = "starting";
@@ -4136,15 +4701,14 @@ var CodexRunner = class {
4136
4701
  });
4137
4702
  this.#setStatus("closed");
4138
4703
  }
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
+ }
4139
4710
  subscribe(listener, afterSeq = 0, options) {
4140
- const stale = options?.coalesceReplay ? staleReplaySeqs(this.#events, afterSeq) : void 0;
4141
- for (const event of this.#events) {
4142
- if (event.seq <= afterSeq) continue;
4143
- if (stale?.has(event.seq)) continue;
4144
- listener(event);
4145
- }
4146
- this.#listeners.add(listener);
4147
- return () => this.#listeners.delete(listener);
4711
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
4148
4712
  }
4149
4713
  #scheduleTurn() {
4150
4714
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -5010,9 +5574,7 @@ var CodexRunner = class {
5010
5574
  this.#lastActivityAt = event.ts;
5011
5575
  this.#activityCount += transcriptActivity(body);
5012
5576
  this.#events.push(event);
5013
- for (const listener of this.#listeners) try {
5014
- listener(event);
5015
- } catch {}
5577
+ this.#subscribers.emit(event);
5016
5578
  }
5017
5579
  };
5018
5580
  //#endregion
@@ -5446,6 +6008,6 @@ function getEngineAdapter(engine) {
5446
6008
  return ADAPTERS[engine ?? "claude"];
5447
6009
  }
5448
6010
  //#endregion
5449
- 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 };
5450
6012
 
5451
6013
  //# sourceMappingURL=index.mjs.map