@workerdeck/protocol 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
@@ -13,15 +13,48 @@ const STATE_LABELS = {
13
13
  };
14
14
  function sessionState(info) {
15
15
  if (info.pendingPermissionCount > 0 || info.status === "awaiting_approval") return "attention";
16
- if (info.status === "running" || info.status === "starting") return "working";
17
16
  if (info.status === "failed" || info.status === "closed") return "ended";
17
+ if (info.status === "running" || info.status === "starting") return "working";
18
+ if (runningSubagents(info).length > 0) return "working";
18
19
  return "idle";
19
20
  }
21
+ /**
22
+ * The sub-agents a list row draws as live.
23
+ *
24
+ * `sessionState` deliberately does **not** grow a `subagents` bucket — a fifth
25
+ * state would split `working` in two for every client that filters by it,
26
+ * including the ones that have not shipped this yet. Instead `working` *counts*
27
+ * them: a synchronous `Task` keeps the turn in flight so the status already
28
+ * says `working`, and a **background** agent — which outlives its turn on
29
+ * purpose — is the carve-out the extra arm in `sessionState` exists for.
30
+ * That is what makes "sub-agents are an annotation on a working row" true
31
+ * rather than assumed: the row is in the working bucket whichever kind is
32
+ * running, and this list only says more about it.
33
+ */
34
+ function runningSubagents(info) {
35
+ return (info.subagents ?? []).filter((sub) => sub.status === "running");
36
+ }
37
+ /**
38
+ * A sub-agent's identity on one line: `Explore · find the auth check`.
39
+ *
40
+ * The same two fields `taskLabel` builds its transcript row from, minus the
41
+ * `Task(…)` wrapper — a list row is already inside a session, so naming the tool
42
+ * spends the width that the description needs. Falls back to the bare agent type,
43
+ * then to a generic word: a row with no label at all reads as a rendering bug,
44
+ * and an engine is free to send neither field.
45
+ */
46
+ function subagentLabel(sub) {
47
+ const agent = sub.agentType?.trim();
48
+ const description = sub.description?.trim();
49
+ if (agent && description) return `${agent} · ${description}`;
50
+ return agent || description || "Sub-agent";
51
+ }
20
52
  const DEFAULT_VIEW_CONFIG = {
21
53
  search: "",
22
54
  gateways: [],
23
55
  adapters: [],
24
56
  states: [],
57
+ projects: [],
25
58
  scoped: true,
26
59
  groupBy: "state",
27
60
  sortBy: "recent"
@@ -31,10 +64,65 @@ const DEFAULT_VIEW_CONFIG = {
31
64
  function adaptersOf(rows) {
32
65
  return [...new Set(rows.map((r) => r.adapter))].sort();
33
66
  }
67
+ /**
68
+ * The projects actually present, as `{ key, label }` for a filter control —
69
+ * derived like {@link adaptersOf}, and paired because the two halves differ:
70
+ * the *key* is what {@link ViewConfig.projects} holds (gateway-qualified root,
71
+ * so a rename regroups nothing) and the *label* is what a person picks by.
72
+ *
73
+ * Sorted by label, deduped by key. Two projects with the same name on two
74
+ * gateways therefore stay two entries wearing one word — which is honest: they
75
+ * really are two different directories, and the alternative is a filter that
76
+ * silently selects both.
77
+ */
78
+ function projectsOf(rows) {
79
+ const byKey = /* @__PURE__ */ new Map();
80
+ for (const row of rows) byKey.set(projectKey(row), projectLabel(row));
81
+ return [...byKey].map(([key, label]) => ({
82
+ key,
83
+ label
84
+ })).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
85
+ }
34
86
  function sessionLabel(info) {
35
87
  return info.title ?? info.id.slice(0, 8);
36
88
  }
37
89
  /**
90
+ * The project facet's grouping key: gateway id + the project root, falling
91
+ * back to the session's cwd when no project is declared.
92
+ *
93
+ * The root and not the name, because a name is not a key (two repos can both
94
+ * be called "api", and a rename must regroup nothing); qualified by gateway,
95
+ * because a remote gateway's identical-looking path is another machine's
96
+ * directory — the same rule `ScopeRoot` states. The cwd fallback is what makes
97
+ * grouping by project useful before anyone has written a `.workerdeck.json`:
98
+ * undeclared sessions group by their folder, declared ones by their root, and
99
+ * a session in `packages/ui` joins its repo's group the moment the file
100
+ * exists. Sessions with no cwd at all (a filesystem-less engine) share one
101
+ * per-gateway bucket — see {@link projectLabel}.
102
+ */
103
+ function projectKey(row) {
104
+ return `${row.hostId}:${normalizePath(row.info.project?.root ?? row.info.cwd)}`;
105
+ }
106
+ /**
107
+ * What a project group (or a row's project slot) is called: the declared name,
108
+ * else the cwd's basename — the exact string clients rendered before this
109
+ * feature existed, so an undeclared project looks like today. 'No project' is
110
+ * only ever the no-cwd case (a sandboxed provider session), where there is no
111
+ * folder to name.
112
+ *
113
+ * Takes only the `info` it reads, so a surface holding a bare `SessionInfo` —
114
+ * a row component, an iOS cell — can call it without inventing the rest of a
115
+ * `SessionRow`. That matters more than it looks: this string is what a client
116
+ * renders *in place of* the cwd basename it used to draw, and two spellings of
117
+ * it would put the list and its group headers on different names.
118
+ */
119
+ function projectLabel(row) {
120
+ const name = row.info.project?.name;
121
+ if (name) return name;
122
+ const dir = normalizePath(row.info.cwd);
123
+ return dir.slice(dir.lastIndexOf("/") + 1) || "No project";
124
+ }
125
+ /**
38
126
  * This session is a job run — the queue created it, and `JobInfo.sessionId`
39
127
  * points at it.
40
128
  *
@@ -49,7 +137,7 @@ function isJobRun(info) {
49
137
  }
50
138
  function matchesSearch(row, needle) {
51
139
  if (!needle) return true;
52
- return sessionLabel(row.info).toLowerCase().includes(needle) || row.info.cwd.toLowerCase().includes(needle) || row.hostName.toLowerCase().includes(needle) || row.adapter.toLowerCase().includes(needle) || row.info.id.startsWith(needle);
140
+ return sessionLabel(row.info).toLowerCase().includes(needle) || row.info.cwd.toLowerCase().includes(needle) || (row.info.project?.name.toLowerCase().includes(needle) ?? false) || row.hostName.toLowerCase().includes(needle) || row.adapter.toLowerCase().includes(needle) || row.info.id.startsWith(needle);
53
141
  }
54
142
  /** Trailing separators dropped and separators unified, so containment is a
55
143
  * plain prefix test on both a posix and a Windows gateway. */
@@ -78,13 +166,13 @@ function scopeActive(config, scope) {
78
166
  function filterRows(rows, config, scope) {
79
167
  const needle = config.search.trim().toLowerCase();
80
168
  const scoping = scopeActive(config, scope) ? scope : void 0;
81
- return rows.filter((row) => (config.gateways.length === 0 || config.gateways.includes(row.hostId)) && (config.adapters.length === 0 || config.adapters.includes(row.adapter)) && (config.states.length === 0 || config.states.includes(row.state)) && (!scoping || inScope(row, scoping)) && matchesSearch(row, needle));
169
+ return rows.filter((row) => (config.gateways.length === 0 || config.gateways.includes(row.hostId)) && (config.adapters.length === 0 || config.adapters.includes(row.adapter)) && (config.states.length === 0 || config.states.includes(row.state)) && (!config.projects?.length || config.projects.includes(projectKey(row))) && (!scoping || inScope(row, scoping)) && matchesSearch(row, needle));
82
170
  }
83
171
  function facetKey(row, facet) {
84
- return facet === "gateway" ? row.hostId : facet === "adapter" ? row.adapter : row.state;
172
+ return facet === "gateway" ? row.hostId : facet === "adapter" ? row.adapter : facet === "project" ? projectKey(row) : row.state;
85
173
  }
86
174
  function facetLabel(row, facet) {
87
- return facet === "gateway" ? row.hostName : facet === "adapter" ? row.adapter : STATE_LABELS[row.state];
175
+ return facet === "gateway" ? row.hostName : facet === "adapter" ? row.adapter : facet === "project" ? projectLabel(row) : STATE_LABELS[row.state];
88
176
  }
89
177
  /** Comparable rank for a facet: states run worst-first (attention before ended),
90
178
  * the rest alphabetically by their visible label. */
@@ -129,7 +217,7 @@ function subsetSummary(config, scope, shown, total) {
129
217
  if (shown >= total) return void 0;
130
218
  const causes = [];
131
219
  if (scope && scopeActive(config, scope)) causes.push(scope.label);
132
- const facets = (config.gateways.length ? 1 : 0) + (config.adapters.length ? 1 : 0) + (config.states.length ? 1 : 0);
220
+ const facets = (config.gateways.length ? 1 : 0) + (config.adapters.length ? 1 : 0) + (config.states.length ? 1 : 0) + (config.projects?.length ? 1 : 0);
133
221
  if (facets > 0) causes.push(`${facets} filter${facets === 1 ? "" : "s"}`);
134
222
  if (config.search.trim()) causes.push("search");
135
223
  return {
@@ -147,7 +235,7 @@ function subsetSummary(config, scope, shown, total) {
147
235
  * someone made.
148
236
  */
149
237
  function hasFacetFilter(config) {
150
- return config.search.trim().length > 0 || config.gateways.length > 0 || config.adapters.length > 0 || config.states.length > 0;
238
+ return config.search.trim().length > 0 || config.gateways.length > 0 || config.adapters.length > 0 || config.states.length > 0 || (config.projects?.length ?? 0) > 0;
151
239
  }
152
240
  /** "Show me everything": every filter off, including scope. The group/sort
153
241
  * choices are a layout preference and survive. */
@@ -160,6 +248,74 @@ function clearFilters(config) {
160
248
  };
161
249
  }
162
250
  //#endregion
251
+ //#region src/usage.ts
252
+ /**
253
+ * The usage a client should render: the gateway's per-profile state where it has
254
+ * the window, this session's own reading where it does not.
255
+ *
256
+ * Why the profile wins outright rather than by comparing timestamps: the
257
+ * gateway's `ProfileUsageTracker` is fed from **every** session on the profile —
258
+ * including this one, from seq 0 — and keeps the newest reading per window by
259
+ * the event's own `ts`. So for any window it holds, it holds a reading at least
260
+ * as new as the one in this transcript, and a timestamp comparison could only
261
+ * ever go wrong: the reducer keeps a *single* `updatedAt` for the whole map, so
262
+ * a `five_hour` reading from this morning is dated with the afternoon's
263
+ * `seven_day` event and would beat a genuinely fresher profile entry.
264
+ *
265
+ * The session half is not a fallback for correctness but for *coverage*: the
266
+ * profile map is in-memory, so a restarted gateway serves nothing until a
267
+ * session reports again, and a session with no profile has no account state at
268
+ * all. In both cases the transcript's reading is the only one there is, and it
269
+ * is dated honestly (see {@link SessionUsage.updatedAt}) rather than as now.
270
+ *
271
+ * Absent stays absent throughout: a window nobody has reported is **unknown,
272
+ * never 0%**, and this returns an empty map rather than inventing entries.
273
+ */
274
+ function mergeUsage(session, profile) {
275
+ const out = {};
276
+ for (const [key, info] of Object.entries(session.rateLimits ?? {})) out[key] = {
277
+ info,
278
+ updatedAt: session.updatedAt ?? 0
279
+ };
280
+ for (const [key, window] of Object.entries(profile ?? {})) out[key] = window;
281
+ return out;
282
+ }
283
+ /**
284
+ * The windows in reading order: the session window, the weekly one, then the
285
+ * per-model weeklies alphabetically.
286
+ *
287
+ * Discovered rather than hardcoded — the engine's set of windows is an open
288
+ * union and has grown before — but ordered, so the first two always mean the
289
+ * same thing wherever they are drawn. A window with no `utilization` is
290
+ * **unknown, not zero**, and is dropped entirely rather than rendered as an
291
+ * empty bar that reads as "plenty left".
292
+ *
293
+ * Here rather than in a client because two surfaces now render the same windows
294
+ * from different sources — the session panel from its merged state, the
295
+ * dashboard's profile page straight off `ProfileInfo.usage` — and a list that
296
+ * ordered or filtered differently would be the same account described two ways.
297
+ */
298
+ function orderUsageWindows(usage) {
299
+ const all = Object.entries(usage ?? {}).filter(([, w]) => w.info.utilization !== void 0).map(([key, w]) => ({
300
+ key,
301
+ info: w.info,
302
+ updatedAt: w.updatedAt,
303
+ inferredReset: w.inferredReset
304
+ }));
305
+ const named = ["five_hour", "seven_day"].flatMap((key) => all.filter((w) => w.key === key));
306
+ const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
307
+ return [...named, ...perModel];
308
+ }
309
+ /** The flat `rateLimitType → reading` map every existing renderer takes, out of
310
+ * the dated form. Undefined in, undefined out — so a surface can keep telling
311
+ * "no reading" apart from "an empty one". */
312
+ function usageInfos(usage) {
313
+ if (!usage) return void 0;
314
+ const out = {};
315
+ for (const [key, window] of Object.entries(usage)) out[key] = window.info;
316
+ return out;
317
+ }
318
+ //#endregion
163
319
  //#region src/watermarks.ts
164
320
  /** Entries older than this are dropped on write — a session deleted months ago
165
321
  * should not keep a row in storage forever. */
@@ -250,6 +406,55 @@ function unseenCount(mark, info) {
250
406
  /** Bumped on any breaking change to events, commands, or REST shapes. */
251
407
  const PROTOCOL_VERSION = 7;
252
408
  /**
409
+ * How much of a tool result a truncating replay keeps.
410
+ *
411
+ * Chosen against the two clients' *own* budgets, and the relationship is the
412
+ * whole point: the terminal theme shows ~400 characters collapsed and ~2,000
413
+ * open, so at 8,000 the collapsed and open states are **byte-identical to an
414
+ * untruncated attach** and only the uncapped "show everything" press ever
415
+ * fetches. That collapses the entire feature to one press, and it is asserted
416
+ * in a test rather than trusted — lowered below the open budget, this would
417
+ * silently clip the open state with no marker, which is the one failure this
418
+ * design must not have.
419
+ *
420
+ * Measured justification: on one 1,270-row session three `tool_result` frames
421
+ * were 641 / 463 / 396 KB, 68% of a 3.1 MB attach. The cut is *structural* —
422
+ * proportional to the thing that is actually large, wherever in the log it sits
423
+ * — which a row window is not.
424
+ */
425
+ const TOOL_RESULT_HEAD_CHARS = 8e3;
426
+ /** How many bytes a base64 payload decodes to, without decoding it. */
427
+ function base64Bytes(data) {
428
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
429
+ return Math.max(0, Math.floor(data.length * 3 / 4) - padding);
430
+ }
431
+ /**
432
+ * Project one `tool_result` content part onto its {@link ImageRefPart}, or
433
+ * `undefined` when the part is not a base64 image and must be delivered as it
434
+ * stands.
435
+ *
436
+ * The rule's **one spelling**, shared by the transform that replaces parts
437
+ * (core), the route that serves them back (server) and the property test that
438
+ * proves the fold is otherwise unchanged (react) — the same reason every other
439
+ * member of this family lives here rather than in whichever package applies it.
440
+ *
441
+ * Deliberately narrow. The corpus holds exactly two non-text part kinds: this
442
+ * one, and the CLI's `tool_reference`, of which every instance across 214
443
+ * sessions totals 122 KB. A "drop non-text parts" rule would sweep those in for
444
+ * no measurable gain, and narrowness is this family's standing habit.
445
+ */
446
+ function imagePartRef(part, index) {
447
+ if (part.type !== "image") return void 0;
448
+ const source = part.source;
449
+ if (!source || source.type !== "base64" || typeof source.data !== "string") return void 0;
450
+ return {
451
+ type: "image_ref",
452
+ media_type: typeof source.media_type === "string" ? source.media_type : "application/octet-stream",
453
+ bytes: base64Bytes(source.data),
454
+ part_index: index
455
+ };
456
+ }
457
+ /**
253
458
  * The static capability record of each engine — the browser-safe default for
254
459
  * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place
255
460
  * the values are written down. Core's adapters *reference* this record and a
@@ -374,6 +579,12 @@ function supportsPermissionMode(engine, mode) {
374
579
  return ENGINE_CAPABILITIES[engine ?? "claude"].permissionModes.includes(mode);
375
580
  }
376
581
  /**
582
+ * How many *settled* sub-agents {@link SessionInfo.subagents} keeps behind the
583
+ * running ones. Small on purpose: the point of the tail is that a list row does
584
+ * not go blank the instant a run finishes, not that it is a history.
585
+ */
586
+ const SUBAGENT_HISTORY = 8;
587
+ /**
377
588
  * How many transcript rows an event materializes — the unit behind
378
589
  * {@link SessionInfo.activityCount}.
379
590
  *
@@ -389,6 +600,7 @@ function supportsPermissionMode(engine, mode) {
389
600
  * reducer's row rule changes, change this with it.
390
601
  */
391
602
  function transcriptActivity(body) {
603
+ if ("parentToolUseId" in body && body.parentToolUseId != null) return 0;
392
604
  switch (body.type) {
393
605
  case "assistant_message": {
394
606
  const content = body.message.content;
@@ -402,7 +614,198 @@ function transcriptActivity(body) {
402
614
  default: return 0;
403
615
  }
404
616
  }
617
+ /**
618
+ * Whether an event is **transcript content** — whether the reducer
619
+ * (`@workerdeck/react`'s `transcript.ts`, and its Swift mirror) mutates
620
+ * `items` when it applies it. The rule behind `conversation_reset`'s replay
621
+ * semantics: the runner keeps its whole event log, but `subscribe()` skips
622
+ * content below the latest reset so an attaching client does not resurrect a
623
+ * cleared conversation — while every *state-bearing* event (`system_init`,
624
+ * `capabilities`, `skills`, `status_changed`, usage and rate-limit readings,
625
+ * `file_produced`, permission bookkeeping) still replays, because a fresh
626
+ * attacher with no model list and no cwd is broken, not cleared.
627
+ *
628
+ * Deliberately **broader than `transcriptActivity() > 0`**: stream deltas,
629
+ * tool results (synthetic user messages) and execution lifecycle events count
630
+ * zero rows but still mutate items — replaying them across a reset would leave
631
+ * orphaned deltas and results with no parent message.
632
+ *
633
+ * `conversation_reset` itself is content under this rule, and that is load-
634
+ * bearing twice: a *superseded* reset (below a newer one) is skipped with the
635
+ * conversation it cleared, while the latest reset always replays (the skip is
636
+ * strictly-below), which is what clears a reconnecting client that still holds
637
+ * pre-reset rows.
638
+ *
639
+ * Lives here beside {@link transcriptActivity} for the same reason: the
640
+ * reducer owns the rule and the runners filter with it, and the two sides may
641
+ * not import each other. If the reducer's items-mutating set changes, change
642
+ * this with it. Unknown/future event types are NOT content — the safe failure
643
+ * is replaying a stale row, never withholding state.
644
+ */
645
+ function transcriptContent(body) {
646
+ switch (body.type) {
647
+ case "user_message":
648
+ case "assistant_message":
649
+ case "stream_delta":
650
+ case "turn_result":
651
+ case "execution_dispatched":
652
+ case "execution_result":
653
+ case "execution_failed":
654
+ case "file_delivered":
655
+ case "session_error":
656
+ case "session_closed":
657
+ case "conversation_reset": return true;
658
+ default: return false;
659
+ }
660
+ }
661
+ /**
662
+ * The dedupe key for an event that is **last-write-wins** on replay, or
663
+ * `undefined` for one that must always be delivered.
664
+ *
665
+ * The problem: the runner polls context usage and the plan's rate limits after
666
+ * every turn, so a fifty-turn session's log holds fifty context readings and
667
+ * fifty per rate-limit window. Replaying all of them is not merely wasteful —
668
+ * it is *visible*. A client applies each in turn, so opening a session shows
669
+ * the usage meters counting up from the session's first reading to its last
670
+ * over the length of the replay, announcing history as if it were news.
671
+ *
672
+ * The fix is a backwards scan over the buffered log keeping the first
673
+ * occurrence of each key, which is `staleReplaySeqs` in `@workerdeck/core`.
674
+ * The key is per *window* for rate limits, not per event type: the reducer
675
+ * stores them keyed by window ("so five_hour and seven_day updates don't
676
+ * clobber each other"), so a single key would keep only the most recently
677
+ * polled window and silently drop the others.
678
+ *
679
+ * **This is a claim about the reducer**, which is why it lives here rather
680
+ * than in core: only the server coalesces, but only `@workerdeck/react` can
681
+ * prove the rule correct, and neither package may import the other. The
682
+ * property that must hold is that coalescing is *unobservable* — folding the
683
+ * full log and the coalesced log through `applyEvent` yields identical state.
684
+ * `packages/react/test/replay-coalesce.test.ts` asserts exactly that, over
685
+ * every event kind. Extend the rule only with a case that test still passes.
686
+ *
687
+ * Three kinds are deliberately **excluded** despite looking eligible:
688
+ *
689
+ * - `capabilities` — `defaultModel: event.defaultModel ?? base.defaultModel`
690
+ * is a fallback *merge*, so a later event without one would erase an earlier
691
+ * event's. (It is also emitted once per session, so there is nothing to win.)
692
+ * - `model_changed` — `undefined` means "reset to the server default" and the
693
+ * reducer *keeps* the last known model, so the last event alone is not the
694
+ * same as the fold.
695
+ * - `system_init` — pure replace for the reducer, but the server's
696
+ * `watchAuthSource` reads the **first** one to decide an auth policy, and
697
+ * parking treats each as a resume point.
698
+ *
699
+ * Coalescing never drops the highest-seq event, and that is load-bearing
700
+ * rather than incidental: the globally-last event is by definition the last of
701
+ * its own key, so it always survives. `useClaudeSession`'s replay hold waits
702
+ * for `state.lastSeq` to reach the attach's `session.lastSeq`, and would hang
703
+ * on a blank panel forever if a coalescer could swallow the final event.
704
+ */
705
+ function replayCoalesceKey(body) {
706
+ switch (body.type) {
707
+ case "context_usage": return "context_usage";
708
+ case "rate_limit": return body.info.rateLimitType ? `rate_limit:${body.info.rateLimitType}` : void 0;
709
+ case "status_changed": return "status_changed";
710
+ case "sdk_event": return body.payload.type === "system" && body.payload.subtype === "status" ? "sdk_event:system:status" : void 0;
711
+ default: return;
712
+ }
713
+ }
714
+ /**
715
+ * Does a **replay** have to deliver this event, or may it be dropped outright?
716
+ *
717
+ * The fifth of the family, and the closest relative of {@link snapshotRetains} —
718
+ * the same claim ("no client can tell") pointed at the wire instead of at a
719
+ * store. The difference from {@link replayCoalesceKey} is that this is not
720
+ * last-write-wins: there is nothing to keep. These are events the reducer reads
721
+ * and *discards*, so a replay that sends them is spending the reader's network
722
+ * on frames whose whole effect is `return base`.
723
+ *
724
+ * Today that is exactly one thing, and it is the second-largest item in a real
725
+ * attach: the `stream_delta`s the reducer does not model. Measured over one
726
+ * 1,270-row session, the delta run was 774 KB, and **~85% of it was frames the
727
+ * reducer throws away** — `input_json_delta` (a tool call's arguments, streamed
728
+ * character by character, 383 KB), `signature_delta` (encrypted-thinking
729
+ * signatures, 153 KB) and the `message_start`/`content_block_start`/`_stop`
730
+ * scaffolding (244 KB). The reducer models two delta kinds, `text_delta` and
731
+ * `thinking_delta`; everything else falls through its switch untouched.
732
+ *
733
+ * What is deliberately **not** dropped, though the arithmetic would allow it:
734
+ *
735
+ * - `thinking_delta` — the Claude SDK delivers thinking blocks whose `thinking`
736
+ * is `''`, and the reducer backfills them from the accumulated streamed text
737
+ * (`streamedThinking`). Dropping these erases every thought from a replayed
738
+ * transcript. This is the same carve-out `snapshotRetains` documents, and it
739
+ * is the reason that rule is provider-engine-only.
740
+ * - `text_delta` — superseded by the `assistant_message` that follows it, which
741
+ * filters the streaming id and rebuilds from the full content blocks. It could
742
+ * go, but only with a lookahead proving the message arrived, and at 24 KB in
743
+ * the measured session it is not worth a rule that has to be right about
744
+ * supersession. A merge is likewise not worth it: a *drop* needs no synthesized
745
+ * event and therefore no invented seq.
746
+ *
747
+ * A live event is never affected — this is about the buffered replay alone — and
748
+ * the caller must never drop the log's highest-seq event whatever this says, for
749
+ * the reason {@link replayCoalesceKey} gives: the replay hold waits for
750
+ * `state.lastSeq` to reach the attach's `session.lastSeq` and would hang on a
751
+ * blank panel forever.
752
+ *
753
+ * The property is the family's usual one and is a test rather than an argument:
754
+ * folding the full log and the retained log through `applyEvent` yields
755
+ * identical state (`packages/react/test/replay-retain.test.ts`).
756
+ */
757
+ function replayRetains(body) {
758
+ if (body.type !== "stream_delta") return true;
759
+ const delta = body.event;
760
+ if (delta.type !== "content_block_delta") return false;
761
+ return delta.delta?.type === "text_delta" || delta.delta?.type === "thinking_delta";
762
+ }
763
+ /**
764
+ * Does a `RunnerSnapshot` keep this event in its persisted log?
765
+ *
766
+ * The fourth of the same family, and the same shape of claim as
767
+ * {@link replayCoalesceKey}: which events a *store* may drop without any client
768
+ * being able to tell. It exists because a snapshot embeds the whole event log,
769
+ * and a log is mostly stream deltas — a four-character token rides a ~180-byte
770
+ * JSON envelope, so the delta run is tens of times the size of the text it
771
+ * spells, sitting on disk *beside* the `assistant_message` that respells it in
772
+ * full. That was affordable while a snapshot was written once, at a park. It is
773
+ * not affordable written after every turn, which is what restart-survival needs.
774
+ *
775
+ * So: everything is retained except `stream_delta`. The reason that is safe is
776
+ * not that deltas are unimportant but that they are **superseded by
777
+ * construction**. The reducer upserts them under one constant id and the
778
+ * following `assistant_message` filters exactly that id out and rebuilds from
779
+ * the full content blocks — and a snapshot may only be taken at a rest point,
780
+ * where the stream loop has exited and flushed. Both exits flush, including the
781
+ * error path: an interrupted turn pushes its half-finished buffers into a
782
+ * durable `assistant_message` before it emits the failed `turn_result`. There is
783
+ * no rest state in which a delta is the only record of anything.
784
+ *
785
+ * **Provider engine only**, and this is the carve-out that must not be lost:
786
+ * against a *Claude* log the rule would be wrong. The Claude SDK delivers
787
+ * thinking blocks whose text is `''`, with the human-readable summary existing
788
+ * only in the delta stream, and the reducer carries the streamed text over to
789
+ * fill them (`transcript.ts`, the `streamedThinking` backfill). Dropping deltas
790
+ * there would silently erase every thought from a restored transcript. Today
791
+ * that is unreachable rather than merely avoided — only the provider engine
792
+ * implements `park()`/`snapshot()` at all, and `#restore` refuses a snapshot
793
+ * from another engine — but an engine that gains one inherits this obligation.
794
+ *
795
+ * Two properties hold it up, both of which are tests rather than arguments:
796
+ * folding the full log and the retained log through `applyEvent` yields
797
+ * identical state (`packages/react/test/snapshot-retain.test.ts`, the same
798
+ * property `replay-coalesce.test.ts` asserts), and the retained log's last event
799
+ * still carries the snapshot's own `seq`. The second matters more than it looks:
800
+ * `transcriptActivity(stream_delta)` is 0, so the count `#restore` recomputes
801
+ * from the log is bit-identical — a client's unread cursor cannot move — and the
802
+ * replay hold waits for `state.lastSeq` to reach the attach's `lastSeq`, which a
803
+ * rule that could drop the final event would hang forever.
804
+ */
805
+ function snapshotRetains(body) {
806
+ return body.type !== "stream_delta";
807
+ }
405
808
  //#endregion
406
- export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, Watermarks, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, unseenCount, watermarkKey };
809
+ export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, Watermarks, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, imagePartRef, inScope, isJobRun, mergeUsage, orderUsageWindows, projectKey, projectLabel, projectsOf, replayCoalesceKey, replayRetains, runningSubagents, scopeActive, sessionLabel, sessionState, snapshotRetains, subagentLabel, subsetSummary, supportsPermissionMode, transcriptActivity, transcriptContent, unseenCount, usageInfos, watermarkKey };
407
810
 
408
811
  //# sourceMappingURL=index.mjs.map