@workerdeck/protocol 0.16.0 → 0.18.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,67 @@ 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
+ /**
47
+ * Does this record name an **agent**, as opposed to a task the model merely
48
+ * described?
49
+ *
50
+ * The tracker opens a record for every spawner call and for any nested event
51
+ * whose parent it has not seen, so the list holds two different things wearing
52
+ * one shape. One carries a `subagent_type` — a delegated agent with an identity
53
+ * (`Explore`), whose own work is worth a surface of its own. The other carries
54
+ * only a description, and there is no agent there to open: a row that offered a
55
+ * screen and then showed a frame with nothing in it would be worse than a row
56
+ * that offered nothing.
57
+ *
58
+ * Here rather than in a client because it decides two things a list must not
59
+ * disagree about across surfaces — what is pressable, and what wears the
60
+ * sub-agent colour.
61
+ */
62
+ function isAgentRecord(sub) {
63
+ return (sub.agentType?.trim() ?? "") !== "";
64
+ }
65
+ function subagentLabel(sub) {
66
+ const agent = sub.agentType?.trim();
67
+ const description = sub.description?.trim();
68
+ if (agent && description) return `${agent} · ${description}`;
69
+ return agent || description || "Sub-agent";
70
+ }
20
71
  const DEFAULT_VIEW_CONFIG = {
21
72
  search: "",
22
73
  gateways: [],
23
74
  adapters: [],
24
75
  states: [],
76
+ projects: [],
25
77
  scoped: true,
26
78
  groupBy: "state",
27
79
  sortBy: "recent"
@@ -31,10 +83,90 @@ const DEFAULT_VIEW_CONFIG = {
31
83
  function adaptersOf(rows) {
32
84
  return [...new Set(rows.map((r) => r.adapter))].sort();
33
85
  }
86
+ /**
87
+ * The projects actually present, as `{ key, label }` for a filter control —
88
+ * derived like {@link adaptersOf}, and paired because the two halves differ:
89
+ * the *key* is what {@link ViewConfig.projects} holds (gateway-qualified root,
90
+ * so a rename regroups nothing) and the *label* is what a person picks by.
91
+ *
92
+ * Sorted by label, deduped by key. Two projects with the same name on two
93
+ * gateways therefore stay two entries wearing one word — which is honest: they
94
+ * really are two different directories, and the alternative is a filter that
95
+ * silently selects both.
96
+ */
97
+ function projectsOf(rows) {
98
+ const byKey = /* @__PURE__ */ new Map();
99
+ for (const row of rows) byKey.set(projectKey(row), projectLabel(row));
100
+ return [...byKey].map(([key, label]) => ({
101
+ key,
102
+ label
103
+ })).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
104
+ }
34
105
  function sessionLabel(info) {
35
106
  return info.title ?? info.id.slice(0, 8);
36
107
  }
37
108
  /**
109
+ * The project facet's grouping key: gateway id + the project root, falling
110
+ * back to the session's cwd when no project is declared.
111
+ *
112
+ * The root and not the name, because a name is not a key (two repos can both
113
+ * be called "api", and a rename must regroup nothing); qualified by gateway,
114
+ * because a remote gateway's identical-looking path is another machine's
115
+ * directory — the same rule `ScopeRoot` states. The cwd fallback is what makes
116
+ * grouping by project useful before anyone has written a `.workerdeck.json`:
117
+ * undeclared sessions group by their folder, declared ones by their root, and
118
+ * a session in `packages/ui` joins its repo's group the moment the file
119
+ * exists. Sessions with no cwd at all (a filesystem-less engine) share one
120
+ * per-gateway bucket — see {@link projectLabel}.
121
+ */
122
+ function projectKey(row) {
123
+ return `${row.hostId}:${normalizePath(row.info.project?.root ?? row.info.cwd)}`;
124
+ }
125
+ /**
126
+ * What a project group (or a row's project slot) is called: the declared name,
127
+ * else the cwd's basename — the exact string clients rendered before this
128
+ * feature existed, so an undeclared project looks like today. 'No project' is
129
+ * only ever the no-cwd case (a sandboxed provider session), where there is no
130
+ * folder to name.
131
+ *
132
+ * Takes only the `info` it reads, so a surface holding a bare `SessionInfo` —
133
+ * a row component, an iOS cell — can call it without inventing the rest of a
134
+ * `SessionRow`. That matters more than it looks: this string is what a client
135
+ * renders *in place of* the cwd basename it used to draw, and two spellings of
136
+ * it would put the list and its group headers on different names.
137
+ */
138
+ function projectLabel(row) {
139
+ const name = row.info.project?.name;
140
+ if (name) return name;
141
+ const dir = normalizePath(row.info.cwd);
142
+ return dir.slice(dir.lastIndexOf("/") + 1) || "No project";
143
+ }
144
+ /**
145
+ * Where inside its project a session actually sits — the cwd with the project
146
+ * root taken off the front, or `undefined` when it sits at the root, has no
147
+ * declared project, or has no cwd at all.
148
+ *
149
+ * The companion to {@link projectLabel}, and it exists for one situation: a list
150
+ * **grouped by project**. There the header has already said the project's name,
151
+ * so repeating it on every row spends the row's most valuable line on the one
152
+ * fact the reader already has. What the header cannot say is which *part* of the
153
+ * project a session is working in, and two sessions in the same repo are told
154
+ * apart by exactly that.
155
+ *
156
+ * Undefined is the honest answer for a session at the project root, and callers
157
+ * must render nothing rather than a `.` or a repeated name — the slot simply
158
+ * goes away, which is the point.
159
+ */
160
+ function projectSubpath(row) {
161
+ const root = row.info.project?.root;
162
+ if (root === void 0 || !row.info.cwd) return void 0;
163
+ const base = normalizePath(root);
164
+ const dir = normalizePath(row.info.cwd);
165
+ if (dir === base) return void 0;
166
+ if (!dir.startsWith(`${base}/`)) return void 0;
167
+ return dir.slice(base.length + 1) || void 0;
168
+ }
169
+ /**
38
170
  * This session is a job run — the queue created it, and `JobInfo.sessionId`
39
171
  * points at it.
40
172
  *
@@ -49,7 +181,7 @@ function isJobRun(info) {
49
181
  }
50
182
  function matchesSearch(row, needle) {
51
183
  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);
184
+ 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
185
  }
54
186
  /** Trailing separators dropped and separators unified, so containment is a
55
187
  * plain prefix test on both a posix and a Windows gateway. */
@@ -78,13 +210,13 @@ function scopeActive(config, scope) {
78
210
  function filterRows(rows, config, scope) {
79
211
  const needle = config.search.trim().toLowerCase();
80
212
  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));
213
+ 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
214
  }
83
215
  function facetKey(row, facet) {
84
- return facet === "gateway" ? row.hostId : facet === "adapter" ? row.adapter : row.state;
216
+ return facet === "gateway" ? row.hostId : facet === "adapter" ? row.adapter : facet === "project" ? projectKey(row) : row.state;
85
217
  }
86
218
  function facetLabel(row, facet) {
87
- return facet === "gateway" ? row.hostName : facet === "adapter" ? row.adapter : STATE_LABELS[row.state];
219
+ return facet === "gateway" ? row.hostName : facet === "adapter" ? row.adapter : facet === "project" ? projectLabel(row) : STATE_LABELS[row.state];
88
220
  }
89
221
  /** Comparable rank for a facet: states run worst-first (attention before ended),
90
222
  * the rest alphabetically by their visible label. */
@@ -129,7 +261,7 @@ function subsetSummary(config, scope, shown, total) {
129
261
  if (shown >= total) return void 0;
130
262
  const causes = [];
131
263
  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);
264
+ const facets = (config.gateways.length ? 1 : 0) + (config.adapters.length ? 1 : 0) + (config.states.length ? 1 : 0) + (config.projects?.length ? 1 : 0);
133
265
  if (facets > 0) causes.push(`${facets} filter${facets === 1 ? "" : "s"}`);
134
266
  if (config.search.trim()) causes.push("search");
135
267
  return {
@@ -147,7 +279,7 @@ function subsetSummary(config, scope, shown, total) {
147
279
  * someone made.
148
280
  */
149
281
  function hasFacetFilter(config) {
150
- return config.search.trim().length > 0 || config.gateways.length > 0 || config.adapters.length > 0 || config.states.length > 0;
282
+ return config.search.trim().length > 0 || config.gateways.length > 0 || config.adapters.length > 0 || config.states.length > 0 || (config.projects?.length ?? 0) > 0;
151
283
  }
152
284
  /** "Show me everything": every filter off, including scope. The group/sort
153
285
  * choices are a layout preference and survive. */
@@ -318,6 +450,55 @@ function unseenCount(mark, info) {
318
450
  /** Bumped on any breaking change to events, commands, or REST shapes. */
319
451
  const PROTOCOL_VERSION = 7;
320
452
  /**
453
+ * How much of a tool result a truncating replay keeps.
454
+ *
455
+ * Chosen against the two clients' *own* budgets, and the relationship is the
456
+ * whole point: the terminal theme shows ~400 characters collapsed and ~2,000
457
+ * open, so at 8,000 the collapsed and open states are **byte-identical to an
458
+ * untruncated attach** and only the uncapped "show everything" press ever
459
+ * fetches. That collapses the entire feature to one press, and it is asserted
460
+ * in a test rather than trusted — lowered below the open budget, this would
461
+ * silently clip the open state with no marker, which is the one failure this
462
+ * design must not have.
463
+ *
464
+ * Measured justification: on one 1,270-row session three `tool_result` frames
465
+ * were 641 / 463 / 396 KB, 68% of a 3.1 MB attach. The cut is *structural* —
466
+ * proportional to the thing that is actually large, wherever in the log it sits
467
+ * — which a row window is not.
468
+ */
469
+ const TOOL_RESULT_HEAD_CHARS = 8e3;
470
+ /** How many bytes a base64 payload decodes to, without decoding it. */
471
+ function base64Bytes(data) {
472
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
473
+ return Math.max(0, Math.floor(data.length * 3 / 4) - padding);
474
+ }
475
+ /**
476
+ * Project one `tool_result` content part onto its {@link ImageRefPart}, or
477
+ * `undefined` when the part is not a base64 image and must be delivered as it
478
+ * stands.
479
+ *
480
+ * The rule's **one spelling**, shared by the transform that replaces parts
481
+ * (core), the route that serves them back (server) and the property test that
482
+ * proves the fold is otherwise unchanged (react) — the same reason every other
483
+ * member of this family lives here rather than in whichever package applies it.
484
+ *
485
+ * Deliberately narrow. The corpus holds exactly two non-text part kinds: this
486
+ * one, and the CLI's `tool_reference`, of which every instance across 214
487
+ * sessions totals 122 KB. A "drop non-text parts" rule would sweep those in for
488
+ * no measurable gain, and narrowness is this family's standing habit.
489
+ */
490
+ function imagePartRef(part, index) {
491
+ if (part.type !== "image") return void 0;
492
+ const source = part.source;
493
+ if (!source || source.type !== "base64" || typeof source.data !== "string") return void 0;
494
+ return {
495
+ type: "image_ref",
496
+ media_type: typeof source.media_type === "string" ? source.media_type : "application/octet-stream",
497
+ bytes: base64Bytes(source.data),
498
+ part_index: index
499
+ };
500
+ }
501
+ /**
321
502
  * The static capability record of each engine — the browser-safe default for
322
503
  * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place
323
504
  * the values are written down. Core's adapters *reference* this record and a
@@ -442,6 +623,33 @@ function supportsPermissionMode(engine, mode) {
442
623
  return ENGINE_CAPABILITIES[engine ?? "claude"].permissionModes.includes(mode);
443
624
  }
444
625
  /**
626
+ * How many *settled* sub-agents {@link SessionInfo.subagents} keeps behind the
627
+ * running ones. Small on purpose: the point of the tail is that a list row does
628
+ * not go blank the instant a run finishes, not that it is a history.
629
+ */
630
+ const SUBAGENT_HISTORY = 8;
631
+ /**
632
+ * The list-sized context reading an event carries, or `undefined` for the events
633
+ * that carry none — the rule behind {@link SessionInfo.contextUsage}.
634
+ *
635
+ * Here rather than in each runner for the same reason {@link transcriptActivity}
636
+ * is: it is one rule both sides have to agree on, and three copies of "which
637
+ * events move the reading" is three chances to disagree. Runners fold it in
638
+ * their emit path; **clearing on `conversation_reset` is the caller's half** —
639
+ * this function answers "what does this event say the reading is", and a reset
640
+ * says nothing about the window, it retires the conversation the window
641
+ * described.
642
+ */
643
+ function contextReading(body) {
644
+ if (body.type !== "context_usage") return void 0;
645
+ const { totalTokens, maxTokens, percentage } = body.usage;
646
+ return {
647
+ totalTokens,
648
+ maxTokens,
649
+ percentage
650
+ };
651
+ }
652
+ /**
445
653
  * How many transcript rows an event materializes — the unit behind
446
654
  * {@link SessionInfo.activityCount}.
447
655
  *
@@ -457,6 +665,7 @@ function supportsPermissionMode(engine, mode) {
457
665
  * reducer's row rule changes, change this with it.
458
666
  */
459
667
  function transcriptActivity(body) {
668
+ if ("parentToolUseId" in body && body.parentToolUseId != null) return 0;
460
669
  switch (body.type) {
461
670
  case "assistant_message": {
462
671
  const content = body.message.content;
@@ -563,10 +772,105 @@ function replayCoalesceKey(body) {
563
772
  case "context_usage": return "context_usage";
564
773
  case "rate_limit": return body.info.rateLimitType ? `rate_limit:${body.info.rateLimitType}` : void 0;
565
774
  case "status_changed": return "status_changed";
775
+ case "sdk_event": return body.payload.type === "system" && body.payload.subtype === "status" ? "sdk_event:system:status" : void 0;
566
776
  default: return;
567
777
  }
568
778
  }
779
+ /**
780
+ * Does a **replay** have to deliver this event, or may it be dropped outright?
781
+ *
782
+ * The fifth of the family, and the closest relative of {@link snapshotRetains} —
783
+ * the same claim ("no client can tell") pointed at the wire instead of at a
784
+ * store. The difference from {@link replayCoalesceKey} is that this is not
785
+ * last-write-wins: there is nothing to keep. These are events the reducer reads
786
+ * and *discards*, so a replay that sends them is spending the reader's network
787
+ * on frames whose whole effect is `return base`.
788
+ *
789
+ * Today that is exactly one thing, and it is the second-largest item in a real
790
+ * attach: the `stream_delta`s the reducer does not model. Measured over one
791
+ * 1,270-row session, the delta run was 774 KB, and **~85% of it was frames the
792
+ * reducer throws away** — `input_json_delta` (a tool call's arguments, streamed
793
+ * character by character, 383 KB), `signature_delta` (encrypted-thinking
794
+ * signatures, 153 KB) and the `message_start`/`content_block_start`/`_stop`
795
+ * scaffolding (244 KB). The reducer models two delta kinds, `text_delta` and
796
+ * `thinking_delta`; everything else falls through its switch untouched.
797
+ *
798
+ * What is deliberately **not** dropped, though the arithmetic would allow it:
799
+ *
800
+ * - `thinking_delta` — the Claude SDK delivers thinking blocks whose `thinking`
801
+ * is `''`, and the reducer backfills them from the accumulated streamed text
802
+ * (`streamedThinking`). Dropping these erases every thought from a replayed
803
+ * transcript. This is the same carve-out `snapshotRetains` documents, and it
804
+ * is the reason that rule is provider-engine-only.
805
+ * - `text_delta` — superseded by the `assistant_message` that follows it, which
806
+ * filters the streaming id and rebuilds from the full content blocks. It could
807
+ * go, but only with a lookahead proving the message arrived, and at 24 KB in
808
+ * the measured session it is not worth a rule that has to be right about
809
+ * supersession. A merge is likewise not worth it: a *drop* needs no synthesized
810
+ * event and therefore no invented seq.
811
+ *
812
+ * A live event is never affected — this is about the buffered replay alone — and
813
+ * the caller must never drop the log's highest-seq event whatever this says, for
814
+ * the reason {@link replayCoalesceKey} gives: the replay hold waits for
815
+ * `state.lastSeq` to reach the attach's `session.lastSeq` and would hang on a
816
+ * blank panel forever.
817
+ *
818
+ * The property is the family's usual one and is a test rather than an argument:
819
+ * folding the full log and the retained log through `applyEvent` yields
820
+ * identical state (`packages/react/test/replay-retain.test.ts`).
821
+ */
822
+ function replayRetains(body) {
823
+ if (body.type !== "stream_delta") return true;
824
+ const delta = body.event;
825
+ if (delta.type !== "content_block_delta") return false;
826
+ return delta.delta?.type === "text_delta" || delta.delta?.type === "thinking_delta";
827
+ }
828
+ /**
829
+ * Does a `RunnerSnapshot` keep this event in its persisted log?
830
+ *
831
+ * The fourth of the same family, and the same shape of claim as
832
+ * {@link replayCoalesceKey}: which events a *store* may drop without any client
833
+ * being able to tell. It exists because a snapshot embeds the whole event log,
834
+ * and a log is mostly stream deltas — a four-character token rides a ~180-byte
835
+ * JSON envelope, so the delta run is tens of times the size of the text it
836
+ * spells, sitting on disk *beside* the `assistant_message` that respells it in
837
+ * full. That was affordable while a snapshot was written once, at a park. It is
838
+ * not affordable written after every turn, which is what restart-survival needs.
839
+ *
840
+ * So: everything is retained except `stream_delta`. The reason that is safe is
841
+ * not that deltas are unimportant but that they are **superseded by
842
+ * construction**. The reducer upserts them under one constant id and the
843
+ * following `assistant_message` filters exactly that id out and rebuilds from
844
+ * the full content blocks — and a snapshot may only be taken at a rest point,
845
+ * where the stream loop has exited and flushed. Both exits flush, including the
846
+ * error path: an interrupted turn pushes its half-finished buffers into a
847
+ * durable `assistant_message` before it emits the failed `turn_result`. There is
848
+ * no rest state in which a delta is the only record of anything.
849
+ *
850
+ * **Provider engine only**, and this is the carve-out that must not be lost:
851
+ * against a *Claude* log the rule would be wrong. The Claude SDK delivers
852
+ * thinking blocks whose text is `''`, with the human-readable summary existing
853
+ * only in the delta stream, and the reducer carries the streamed text over to
854
+ * fill them (`transcript.ts`, the `streamedThinking` backfill). Dropping deltas
855
+ * there would silently erase every thought from a restored transcript. Today
856
+ * that is unreachable rather than merely avoided — only the provider engine
857
+ * implements `park()`/`snapshot()` at all, and `#restore` refuses a snapshot
858
+ * from another engine — but an engine that gains one inherits this obligation.
859
+ *
860
+ * Two properties hold it up, both of which are tests rather than arguments:
861
+ * folding the full log and the retained log through `applyEvent` yields
862
+ * identical state (`packages/react/test/snapshot-retain.test.ts`, the same
863
+ * property `replay-coalesce.test.ts` asserts), and the retained log's last event
864
+ * still carries the snapshot's own `seq`. The second matters more than it looks:
865
+ * `transcriptActivity(stream_delta)` is 0, so the count `#restore` recomputes
866
+ * from the log is bit-identical — a client's unread cursor cannot move — and the
867
+ * replay hold waits for `state.lastSeq` to reach the attach's `lastSeq`, which a
868
+ * rule that could drop the final event would hang forever.
869
+ */
870
+ function snapshotRetains(body) {
871
+ return body.type !== "stream_delta";
872
+ }
569
873
  //#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 };
874
+ export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, STATE_LABELS, STATE_ORDER, SUBAGENT_HISTORY, TOOL_RESULT_HEAD_CHARS, Watermarks, adaptersOf, clearFilters, contextReading, filterRows, groupRows, hasFacetFilter, imagePartRef, inScope, isAgentRecord, isJobRun, mergeUsage, orderUsageWindows, projectKey, projectLabel, projectSubpath, projectsOf, replayCoalesceKey, replayRetains, runningSubagents, scopeActive, sessionLabel, sessionState, snapshotRetains, subagentLabel, subsetSummary, supportsPermissionMode, transcriptActivity, transcriptContent, unseenCount, usageInfos, watermarkKey };
571
875
 
572
876
  //# sourceMappingURL=index.mjs.map