@workerdeck/protocol 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
@@ -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. */
@@ -318,6 +406,55 @@ function unseenCount(mark, info) {
318
406
  /** Bumped on any breaking change to events, commands, or REST shapes. */
319
407
  const PROTOCOL_VERSION = 7;
320
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
+ /**
321
458
  * The static capability record of each engine — the browser-safe default for
322
459
  * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place
323
460
  * the values are written down. Core's adapters *reference* this record and a
@@ -442,6 +579,12 @@ function supportsPermissionMode(engine, mode) {
442
579
  return ENGINE_CAPABILITIES[engine ?? "claude"].permissionModes.includes(mode);
443
580
  }
444
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
+ /**
445
588
  * How many transcript rows an event materializes — the unit behind
446
589
  * {@link SessionInfo.activityCount}.
447
590
  *
@@ -457,6 +600,7 @@ function supportsPermissionMode(engine, mode) {
457
600
  * reducer's row rule changes, change this with it.
458
601
  */
459
602
  function transcriptActivity(body) {
603
+ if ("parentToolUseId" in body && body.parentToolUseId != null) return 0;
460
604
  switch (body.type) {
461
605
  case "assistant_message": {
462
606
  const content = body.message.content;
@@ -563,10 +707,105 @@ function replayCoalesceKey(body) {
563
707
  case "context_usage": return "context_usage";
564
708
  case "rate_limit": return body.info.rateLimitType ? `rate_limit:${body.info.rateLimitType}` : void 0;
565
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;
566
711
  default: return;
567
712
  }
568
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
+ }
569
808
  //#endregion
570
- export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, Watermarks, adaptersOf, clearFilters, filterRows, groupRows, hasFacetFilter, inScope, isJobRun, mergeUsage, orderUsageWindows, replayCoalesceKey, scopeActive, sessionLabel, sessionState, subsetSummary, supportsPermissionMode, transcriptActivity, transcriptContent, unseenCount, usageInfos, 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 };
571
810
 
572
811
  //# sourceMappingURL=index.mjs.map