@workerdeck/protocol 0.23.0 → 1.0.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
@@ -18,47 +18,9 @@ function sessionState(info) {
18
18
  if (runningSubagents(info).length > 0) return "working";
19
19
  return "idle";
20
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
21
  function runningSubagents(info) {
35
22
  return (info.subagents ?? []).filter((sub) => sub.status === "running");
36
23
  }
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
24
  function isAgentRecord(sub) {
63
25
  return (sub.agentType?.trim() ?? "") !== "";
64
26
  }
@@ -78,22 +40,9 @@ const DEFAULT_VIEW_CONFIG = {
78
40
  groupBy: "state",
79
41
  sortBy: "recent"
80
42
  };
81
- /** The adapters actually present, for the filter chips — derived rather than
82
- * enumerated, so a new engine needs no change here. */
83
43
  function adaptersOf(rows) {
84
44
  return [...new Set(rows.map((r) => r.adapter))].sort();
85
45
  }
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
46
  function projectsOf(rows) {
98
47
  const byKey = /* @__PURE__ */ new Map();
99
48
  for (const row of rows) byKey.set(projectKey(row), projectLabel(row));
@@ -105,77 +54,24 @@ function projectsOf(rows) {
105
54
  function sessionLabel(info) {
106
55
  return info.title ?? info.id.slice(0, 8);
107
56
  }
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
57
  function projectKey(row) {
123
58
  return `${row.hostId}:${normalizePath(row.info.project?.root ?? row.info.cwd)}`;
124
59
  }
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
60
  function projectLabel(row) {
139
61
  const name = row.info.project?.name;
140
62
  if (name) return name;
141
63
  const dir = normalizePath(row.info.cwd);
142
64
  return dir.slice(dir.lastIndexOf("/") + 1) || "No project";
143
65
  }
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
66
  function projectSubpath(row) {
161
67
  const root = row.info.project?.root;
162
- if (root === void 0 || !row.info.cwd) return void 0;
68
+ if (root === void 0 || !row.info.cwd) return;
163
69
  const base = normalizePath(root);
164
70
  const dir = normalizePath(row.info.cwd);
165
- if (dir === base) return void 0;
166
- if (!dir.startsWith(`${base}/`)) return void 0;
71
+ if (dir === base) return;
72
+ if (!dir.startsWith(`${base}/`)) return;
167
73
  return dir.slice(base.length + 1) || void 0;
168
74
  }
169
- /**
170
- * This session is a job run — the queue created it, and `JobInfo.sessionId`
171
- * points at it.
172
- *
173
- * A job run is an ordinary registry session in every other respect, which is
174
- * what makes this worth spelling once: a client that renders jobs on their own
175
- * surface should not list them again among the sessions, and a client with no
176
- * jobs surface (the extension, the phone) should, or they would be invisible.
177
- * The queue stamps `meta.jobId`; nothing else may write that key.
178
- */
179
75
  function isJobRun(info) {
180
76
  return typeof info.meta?.jobId === "string";
181
77
  }
@@ -183,8 +79,6 @@ function matchesSearch(row, needle) {
183
79
  if (!needle) return true;
184
80
  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);
185
81
  }
186
- /** Trailing separators dropped and separators unified, so containment is a
187
- * plain prefix test on both a posix and a Windows gateway. */
188
82
  function normalizePath(path) {
189
83
  return path.replace(/\\/g, "/").replace(/\/+$/, "");
190
84
  }
@@ -193,17 +87,9 @@ function isWithin(root, path) {
193
87
  const dir = normalizePath(path);
194
88
  return dir === base || dir.startsWith(`${base}/`);
195
89
  }
196
- /**
197
- * Is this session inside one of the host's folders? A gateway-tagged root only
198
- * ever matches its own gateway; an untagged one only matches a loopback gateway,
199
- * because a remote gateway's identical-looking path is a different machine's
200
- * directory.
201
- */
202
90
  function inScope(row, scope) {
203
91
  return scope.roots.some((root) => (root.hostId ? root.hostId.toLowerCase() === row.hostId.toLowerCase() : row.local) && isWithin(root.path, row.info.cwd));
204
92
  }
205
- /** Whether the scope filter is actually hiding anything — it is inert with no
206
- * folder open, and that is the difference between a default and a filter. */
207
93
  function scopeActive(config, scope) {
208
94
  return config.scoped && scope !== void 0;
209
95
  }
@@ -218,24 +104,18 @@ function facetKey(row, facet) {
218
104
  function facetLabel(row, facet) {
219
105
  return facet === "gateway" ? row.hostName : facet === "adapter" ? row.adapter : facet === "project" ? projectLabel(row) : STATE_LABELS[row.state];
220
106
  }
221
- /** Comparable rank for a facet: states run worst-first (attention before ended),
222
- * the rest alphabetically by their visible label. */
223
107
  function facetRank(row, facet) {
224
108
  if (facet === "state") return String(STATE_ORDER.indexOf(row.state));
225
109
  return facetLabel(row, facet).toLowerCase();
226
110
  }
227
- const byRecency = (a, b) => (b.info.lastActivityAt ?? b.info.createdAt) - (a.info.lastActivityAt ?? a.info.createdAt);
111
+ function byRecency(a, b) {
112
+ return (b.info.lastActivityAt ?? b.info.createdAt) - (a.info.lastActivityAt ?? a.info.createdAt);
113
+ }
228
114
  function compare(a, b, sortBy) {
229
115
  if (sortBy === "recent") return byRecency(a, b);
230
116
  if (sortBy === "name") return sessionLabel(a.info).localeCompare(sessionLabel(b.info), void 0, { sensitivity: "base" }) || byRecency(a, b);
231
117
  return facetRank(a, sortBy).localeCompare(facetRank(b, sortBy)) || byRecency(a, b);
232
118
  }
233
- /**
234
- * The list as rendered: filtered, grouped, and sorted within each group. Groups
235
- * themselves come out in the sort's own order — grouping by state and sorting by
236
- * name should still put "Needs attention" first, so groups are ordered by their
237
- * facet rank, never by the row sort.
238
- */
239
119
  function groupRows(rows, config) {
240
120
  const sorted = [...rows].sort((a, b) => compare(a, b, config.sortBy));
241
121
  if (config.groupBy === "none") return sorted.length ? [{
@@ -258,7 +138,7 @@ function groupRows(rows, config) {
258
138
  return [...groups.values()].sort((a, b) => a.rank.localeCompare(b.rank));
259
139
  }
260
140
  function subsetSummary(config, scope, shown, total) {
261
- if (shown >= total) return void 0;
141
+ if (shown >= total) return;
262
142
  const causes = [];
263
143
  if (scope && scopeActive(config, scope)) causes.push(scope.label);
264
144
  const facets = (config.gateways.length ? 1 : 0) + (config.adapters.length ? 1 : 0) + (config.states.length ? 1 : 0) + (config.projects?.length ? 1 : 0);
@@ -270,19 +150,9 @@ function subsetSummary(config, scope, shown, total) {
270
150
  causes
271
151
  };
272
152
  }
273
- /**
274
- * Is anything OTHER than the workspace scope narrowing the list?
275
- *
276
- * The distinction an empty list turns on: "this project has no sessions" wants a
277
- * different sentence, and a different way out, from "your filters match none".
278
- * Scope is excluded because it is on by default — it is the state, not a choice
279
- * someone made.
280
- */
281
153
  function hasFacetFilter(config) {
282
154
  return config.search.trim().length > 0 || config.gateways.length > 0 || config.adapters.length > 0 || config.states.length > 0 || (config.projects?.length ?? 0) > 0;
283
155
  }
284
- /** "Show me everything": every filter off, including scope. The group/sort
285
- * choices are a layout preference and survive. */
286
156
  function clearFilters(config) {
287
157
  return {
288
158
  ...DEFAULT_VIEW_CONFIG,
@@ -293,28 +163,6 @@ function clearFilters(config) {
293
163
  }
294
164
  //#endregion
295
165
  //#region src/usage.ts
296
- /**
297
- * The usage a client should render: the gateway's per-profile state where it has
298
- * the window, this session's own reading where it does not.
299
- *
300
- * Why the profile wins outright rather than by comparing timestamps: the
301
- * gateway's `ProfileUsageTracker` is fed from **every** session on the profile —
302
- * including this one, from seq 0 — and keeps the newest reading per window by
303
- * the event's own `ts`. So for any window it holds, it holds a reading at least
304
- * as new as the one in this transcript, and a timestamp comparison could only
305
- * ever go wrong: the reducer keeps a *single* `updatedAt` for the whole map, so
306
- * a `five_hour` reading from this morning is dated with the afternoon's
307
- * `seven_day` event and would beat a genuinely fresher profile entry.
308
- *
309
- * The session half is not a fallback for correctness but for *coverage*: the
310
- * profile map is in-memory, so a restarted gateway serves nothing until a
311
- * session reports again, and a session with no profile has no account state at
312
- * all. In both cases the transcript's reading is the only one there is, and it
313
- * is dated honestly (see {@link SessionUsage.updatedAt}) rather than as now.
314
- *
315
- * Absent stays absent throughout: a window nobody has reported is **unknown,
316
- * never 0%**, and this returns an empty map rather than inventing entries.
317
- */
318
166
  function mergeUsage(session, profile) {
319
167
  const out = {};
320
168
  for (const [key, info] of Object.entries(session.rateLimits ?? {})) out[key] = {
@@ -324,21 +172,6 @@ function mergeUsage(session, profile) {
324
172
  for (const [key, window] of Object.entries(profile ?? {})) out[key] = window;
325
173
  return out;
326
174
  }
327
- /**
328
- * The windows in reading order: the session window, the weekly one, then the
329
- * per-model weeklies alphabetically.
330
- *
331
- * Discovered rather than hardcoded — the engine's set of windows is an open
332
- * union and has grown before — but ordered, so the first two always mean the
333
- * same thing wherever they are drawn. A window with no `utilization` is
334
- * **unknown, not zero**, and is dropped entirely rather than rendered as an
335
- * empty bar that reads as "plenty left".
336
- *
337
- * Here rather than in a client because two surfaces now render the same windows
338
- * from different sources — the session panel from its merged state, the
339
- * dashboard's profile page straight off `ProfileInfo.usage` — and a list that
340
- * ordered or filtered differently would be the same account described two ways.
341
- */
342
175
  function orderUsageWindows(usage) {
343
176
  const all = Object.entries(usage ?? {}).filter(([, w]) => w.info.utilization !== void 0).map(([key, w]) => ({
344
177
  key,
@@ -350,23 +183,19 @@ function orderUsageWindows(usage) {
350
183
  const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
351
184
  return [...named, ...perModel];
352
185
  }
353
- /** The flat `rateLimitType → reading` map every existing renderer takes, out of
354
- * the dated form. Undefined in, undefined out — so a surface can keep telling
355
- * "no reading" apart from "an empty one". */
356
186
  function usageInfos(usage) {
357
- if (!usage) return void 0;
187
+ if (!usage) return;
358
188
  const out = {};
359
189
  for (const [key, window] of Object.entries(usage)) out[key] = window.info;
360
190
  return out;
361
191
  }
362
192
  //#endregion
363
193
  //#region src/watermarks.ts
364
- /** Entries older than this are dropped on write — a session deleted months ago
365
- * should not keep a row in storage forever. */
366
194
  const MAX_AGE_MS = 720 * 60 * 60 * 1e3;
367
- /** How stale "last here" is allowed to get before a write happens anyway. */
368
195
  const TOUCH_MS = 6e4;
369
- const watermarkKey = (hostId, sessionId) => `${hostId}:${sessionId}`;
196
+ function watermarkKey(hostId, sessionId) {
197
+ return `${hostId}:${sessionId}`;
198
+ }
370
199
  var Watermarks = class {
371
200
  #store;
372
201
  #cache;
@@ -377,35 +206,24 @@ var Watermarks = class {
377
206
  get(hostId, sessionId) {
378
207
  return this.#cache[watermarkKey(hostId, sessionId)];
379
208
  }
380
- /** Every mark, for a caller deriving unread counts over a whole list. */
381
209
  all() {
382
210
  return this.#cache;
383
211
  }
384
- /**
385
- * Record what is on screen now. Monotonic on purpose: a transcript that
386
- * *shrank* (a compaction, a fresh attach mid-replay) must not walk the mark
387
- * backwards and resurrect rows the user already read.
388
- *
389
- * Returns whether the mark actually moved, because an unread badge is computed
390
- * from it and nothing else will say so: rows read in a panel do not touch the
391
- * sessions poll, so a caller that doesn't hear about this has no other way to
392
- * learn the count is now wrong.
393
- */
394
212
  mark(hostId, sessionId, seen, now = Date.now()) {
395
213
  const id = watermarkKey(hostId, sessionId);
396
214
  const previous = this.#cache[id];
397
215
  const next = {
398
216
  itemCount: Math.max(previous?.itemCount ?? 0, seen.itemCount ?? 0),
399
217
  activity: Math.max(previous?.activity ?? 0, seen.activity ?? 0),
218
+ prose: seen.prose === void 0 ? previous?.prose : Math.max(previous?.prose ?? 0, seen.prose),
400
219
  turns: Math.max(previous?.turns ?? 0, seen.turns ?? 0),
401
220
  seenAt: now
402
221
  };
403
- if (previous && previous.itemCount === next.itemCount && previous.activity === next.activity && previous.turns === next.turns && next.seenAt - previous.seenAt < TOUCH_MS) return false;
222
+ if (previous && previous.itemCount === next.itemCount && previous.activity === next.activity && previous.prose === next.prose && previous.turns === next.turns && next.seenAt - previous.seenAt < TOUCH_MS) return false;
404
223
  this.#cache[id] = next;
405
224
  this.#store.write(this.#prune(now));
406
225
  return true;
407
226
  }
408
- /** Forget a session — it was deleted, and its mark is now noise. */
409
227
  forget(hostId, sessionId) {
410
228
  const id = watermarkKey(hostId, sessionId);
411
229
  if (!(id in this.#cache)) return;
@@ -419,78 +237,28 @@ var Watermarks = class {
419
237
  }
420
238
  };
421
239
  /**
422
- * Rows this client has not seen, from the rollup alone.
423
- *
424
- * `activityCount` is the unit that makes an honest badge: turns undercount badly
425
- * (five tool calls in one turn is one turn) and a stream sequence overcounts
426
- * absurdly (every delta). Turns stay the fallback for a gateway too old to
427
- * report it.
428
- *
429
- * A session never visited returns 0 — "never opened" is not "unread", and a
430
- * badge that counted every session's whole history on first launch would be
431
- * noise on the one day it should be quiet.
240
+ * The badge's number, in the best unit the pair can agree on: prose the human has not
241
+ * read, else rows, else turns. The ladder is what keeps an older gateway (no `proseCount`
242
+ * on the wire) badging exactly as it did before rather than going silent.
432
243
  */
433
244
  function unseenCount(mark, info) {
434
245
  if (!mark) return 0;
246
+ if (info.proseCount !== void 0) return Math.max(0, info.proseCount - (mark.prose ?? info.proseCount));
435
247
  if (info.activityCount !== void 0) return Math.max(0, info.activityCount - mark.activity);
436
248
  return Math.max(0, (info.turns ?? 0) - mark.turns);
437
249
  }
438
250
  //#endregion
439
251
  //#region src/index.ts
440
- /**
441
- * @workerdeck/protocol — the wire protocol between a workerdeck server and its clients.
442
- *
443
- * One session = one ordered stream of {@link SessionEvent}s (each stamped with a monotonically
444
- * increasing `seq`) plus a small command set ({@link SessionCommand}). Clients attach over
445
- * WebSocket, optionally replaying from a known `seq`, and drive the session with commands.
446
- *
447
- * This package is dependency-free and browser-safe. Anthropic API message content is modeled
448
- * structurally (see {@link ApiMessage}) so clients don't need the Agent SDK to render transcripts.
449
- */
450
- /** Bumped on any breaking change to events, commands, or REST shapes. */
451
- const PROTOCOL_VERSION = 7;
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
- */
252
+ const PROTOCOL_VERSION = 1;
469
253
  const TOOL_RESULT_HEAD_CHARS = 8e3;
470
- /** How many bytes a base64 payload decodes to, without decoding it. */
471
254
  function base64Bytes(data) {
472
255
  const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
473
256
  return Math.max(0, Math.floor(data.length * 3 / 4) - padding);
474
257
  }
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
258
  function imagePartRef(part, index) {
491
- if (part.type !== "image") return void 0;
259
+ if (part.type !== "image") return;
492
260
  const source = part.source;
493
- if (!source || source.type !== "base64" || typeof source.data !== "string") return void 0;
261
+ if (!source || source.type !== "base64" || typeof source.data !== "string") return;
494
262
  return {
495
263
  type: "image_ref",
496
264
  media_type: typeof source.media_type === "string" ? source.media_type : "application/octet-stream",
@@ -498,14 +266,6 @@ function imagePartRef(part, index) {
498
266
  part_index: index
499
267
  };
500
268
  }
501
- /**
502
- * The static capability record of each engine — the browser-safe default for
503
- * `ProfileInfo.capabilities` / `SessionInfo.capabilities`, and the single place
504
- * the values are written down. Core's adapters *reference* this record and a
505
- * conformance test compares runner behaviour against it, so it cannot silently
506
- * diverge from the code. When both a wire copy and this default exist, the wire
507
- * copy wins.
508
- */
509
269
  const ENGINE_CAPABILITIES = {
510
270
  claude: {
511
271
  interactiveApprovals: true,
@@ -612,40 +372,12 @@ const ENGINE_CAPABILITIES = {
612
372
  streaming: "token"
613
373
  }
614
374
  };
615
- /**
616
- * Permission modes the model-agnostic provider engine understands.
617
- * @deprecated Read `ENGINE_CAPABILITIES.provider.permissionModes` (this is an
618
- * alias of it, kept for protocol-5 consumers).
619
- */
620
- const PROVIDER_PERMISSION_MODES = ENGINE_CAPABILITIES.provider.permissionModes;
621
- /**
622
- * Whether a profile's engine can run a permission mode. The single source of
623
- * truth for the restriction: create forms filter what they offer with it, the
624
- * gateway rejects with it. An absent `engine` means 'claude' (every mode).
625
- */
626
375
  function supportsPermissionMode(engine, mode) {
627
376
  return ENGINE_CAPABILITIES[engine ?? "claude"].permissionModes.includes(mode);
628
377
  }
629
- /**
630
- * How many *settled* sub-agents {@link SessionInfo.subagents} keeps behind the
631
- * running ones. Small on purpose: the point of the tail is that a list row does
632
- * not go blank the instant a run finishes, not that it is a history.
633
- */
634
378
  const SUBAGENT_HISTORY = 8;
635
- /**
636
- * The list-sized context reading an event carries, or `undefined` for the events
637
- * that carry none — the rule behind {@link SessionInfo.contextUsage}.
638
- *
639
- * Here rather than in each runner for the same reason {@link transcriptActivity}
640
- * is: it is one rule both sides have to agree on, and three copies of "which
641
- * events move the reading" is three chances to disagree. Runners fold it in
642
- * their emit path; **clearing on `conversation_reset` is the caller's half** —
643
- * this function answers "what does this event say the reading is", and a reset
644
- * says nothing about the window, it retires the conversation the window
645
- * described.
646
- */
647
379
  function contextReading(body) {
648
- if (body.type !== "context_usage") return void 0;
380
+ if (body.type !== "context_usage") return;
649
381
  const { totalTokens, maxTokens, percentage } = body.usage;
650
382
  return {
651
383
  totalTokens,
@@ -653,21 +385,6 @@ function contextReading(body) {
653
385
  percentage
654
386
  };
655
387
  }
656
- /**
657
- * How many transcript rows an event materializes — the unit behind
658
- * {@link SessionInfo.activityCount}.
659
- *
660
- * Deliberately the *reducer's* rule (`@workerdeck/react`'s `transcript.ts`), not
661
- * a server-side approximation: one row per content block of an assistant
662
- * message (a text, a thought, each tool call), one for a user message, one per
663
- * turn result, delivered file or error. Everything else — status changes, usage
664
- * readings, stream deltas, permission bookkeeping — is state, not a row, and
665
- * counts zero.
666
- *
667
- * It lives in `protocol` because both sides need it and neither may import the
668
- * other: the runners count with it, and any client compares the totals. If the
669
- * reducer's row rule changes, change this with it.
670
- */
671
388
  function transcriptActivity(body) {
672
389
  if ("parentToolUseId" in body && body.parentToolUseId != null) return 0;
673
390
  switch (body.type) {
@@ -684,33 +401,40 @@ function transcriptActivity(body) {
684
401
  }
685
402
  }
686
403
  /**
687
- * Whether an event is **transcript content** — whether the reducer
688
- * (`@workerdeck/react`'s `transcript.ts`, and its Swift mirror) mutates
689
- * `items` when it applies it. The rule behind `conversation_reset`'s replay
690
- * semantics: the runner keeps its whole event log, but `subscribe()` skips
691
- * content below the latest reset so an attaching client does not resurrect a
692
- * cleared conversation — while every *state-bearing* event (`system_init`,
693
- * `capabilities`, `skills`, `status_changed`, usage and rate-limit readings,
694
- * `file_produced`, permission bookkeeping) still replays, because a fresh
695
- * attacher with no model list and no cwd is broken, not cleared.
696
- *
697
- * Deliberately **broader than `transcriptActivity() > 0`**: stream deltas,
698
- * tool results (synthetic user messages) and execution lifecycle events count
699
- * zero rows but still mutate items — replaying them across a reset would leave
700
- * orphaned deltas and results with no parent message.
701
- *
702
- * `conversation_reset` itself is content under this rule, and that is load-
703
- * bearing twice: a *superseded* reset (below a newer one) is skipped with the
704
- * conversation it cleared, while the latest reset always replays (the skip is
705
- * strictly-below), which is what clears a reconnecting client that still holds
706
- * pre-reset rows.
707
- *
708
- * Lives here beside {@link transcriptActivity} for the same reason: the
709
- * reducer owns the rule and the runners filter with it, and the two sides may
710
- * not import each other. If the reducer's items-mutating set changes, change
711
- * this with it. Unknown/future event types are NOT content — the safe failure
712
- * is replaying a stale row, never withholding state.
404
+ * The unread badge's unit: output **addressed to the human**, not evidence of work.
405
+ *
406
+ * `transcriptActivity` counts a tool call and a paragraph alike, which is honest as
407
+ * "how much has happened" and wrong as "how much is there to read" — a session that
408
+ * tool-loops for a minute ticks 6, 7, 8 with nothing said yet. This scores the same
409
+ * events through a narrower door:
410
+ *
411
+ * - assistant `text` blocks only — `thinking` is not addressed to anyone and `tool_use`
412
+ * is the noise being filtered out;
413
+ * - the **sub-agent carve-out is inherited** (`parentToolUseId != null` scores 0): prose a
414
+ * sub-agent wrote to its parent is not addressed to the human either;
415
+ * - a `turn_result` counts only when it **failed**, an interrupt or an error being a thing
416
+ * the human is owed; a successful turn already carried its own prose and would otherwise
417
+ * double-count every answer;
418
+ * - `session_error` and `file_delivered` count — both are output, not work;
419
+ * - `stream_delta` scores 0, exactly as in `transcriptActivity`. The badge is therefore
420
+ * correct within one poll of a message *completing*, never mid-stream, which is the
421
+ * deliberate price of leaving the streaming path alone.
713
422
  */
423
+ function transcriptProse(body) {
424
+ if ("parentToolUseId" in body && body.parentToolUseId != null) return 0;
425
+ switch (body.type) {
426
+ case "assistant_message": {
427
+ const content = body.message.content;
428
+ if (typeof content === "string") return content.trim() === "" ? 0 : 1;
429
+ return content.filter((block) => block.type === "text" && typeof block.text === "string" && block.text.trim() !== "").length;
430
+ }
431
+ case "user_message": return body.synthetic ? 0 : 1;
432
+ case "turn_result": return body.isError ? 1 : 0;
433
+ case "file_delivered":
434
+ case "session_error": return 1;
435
+ default: return 0;
436
+ }
437
+ }
714
438
  function transcriptContent(body) {
715
439
  switch (body.type) {
716
440
  case "user_message":
@@ -727,50 +451,6 @@ function transcriptContent(body) {
727
451
  default: return false;
728
452
  }
729
453
  }
730
- /**
731
- * The dedupe key for an event that is **last-write-wins** on replay, or
732
- * `undefined` for one that must always be delivered.
733
- *
734
- * The problem: the runner polls context usage and the plan's rate limits after
735
- * every turn, so a fifty-turn session's log holds fifty context readings and
736
- * fifty per rate-limit window. Replaying all of them is not merely wasteful —
737
- * it is *visible*. A client applies each in turn, so opening a session shows
738
- * the usage meters counting up from the session's first reading to its last
739
- * over the length of the replay, announcing history as if it were news.
740
- *
741
- * The fix is a backwards scan over the buffered log keeping the first
742
- * occurrence of each key, which is `staleReplaySeqs` in `@workerdeck/core`.
743
- * The key is per *window* for rate limits, not per event type: the reducer
744
- * stores them keyed by window ("so five_hour and seven_day updates don't
745
- * clobber each other"), so a single key would keep only the most recently
746
- * polled window and silently drop the others.
747
- *
748
- * **This is a claim about the reducer**, which is why it lives here rather
749
- * than in core: only the server coalesces, but only `@workerdeck/react` can
750
- * prove the rule correct, and neither package may import the other. The
751
- * property that must hold is that coalescing is *unobservable* — folding the
752
- * full log and the coalesced log through `applyEvent` yields identical state.
753
- * `packages/react/test/replay-coalesce.test.ts` asserts exactly that, over
754
- * every event kind. Extend the rule only with a case that test still passes.
755
- *
756
- * Three kinds are deliberately **excluded** despite looking eligible:
757
- *
758
- * - `capabilities` — `defaultModel: event.defaultModel ?? base.defaultModel`
759
- * is a fallback *merge*, so a later event without one would erase an earlier
760
- * event's. (It is also emitted once per session, so there is nothing to win.)
761
- * - `model_changed` — `undefined` means "reset to the server default" and the
762
- * reducer *keeps* the last known model, so the last event alone is not the
763
- * same as the fold.
764
- * - `system_init` — pure replace for the reducer, but the server's
765
- * `watchAuthSource` reads the **first** one to decide an auth policy, and
766
- * parking treats each as a resume point.
767
- *
768
- * Coalescing never drops the highest-seq event, and that is load-bearing
769
- * rather than incidental: the globally-last event is by definition the last of
770
- * its own key, so it always survives. `useClaudeSession`'s replay hold waits
771
- * for `state.lastSeq` to reach the attach's `session.lastSeq`, and would hang
772
- * on a blank panel forever if a coalescer could swallow the final event.
773
- */
774
454
  function replayCoalesceKey(body) {
775
455
  switch (body.type) {
776
456
  case "context_usage": return "context_usage";
@@ -780,101 +460,16 @@ function replayCoalesceKey(body) {
780
460
  default: return;
781
461
  }
782
462
  }
783
- /**
784
- * Does a **replay** have to deliver this event, or may it be dropped outright?
785
- *
786
- * The fifth of the family, and the closest relative of {@link snapshotRetains} —
787
- * the same claim ("no client can tell") pointed at the wire instead of at a
788
- * store. The difference from {@link replayCoalesceKey} is that this is not
789
- * last-write-wins: there is nothing to keep. These are events the reducer reads
790
- * and *discards*, so a replay that sends them is spending the reader's network
791
- * on frames whose whole effect is `return base`.
792
- *
793
- * Today that is exactly one thing, and it is the second-largest item in a real
794
- * attach: the `stream_delta`s the reducer does not model. Measured over one
795
- * 1,270-row session, the delta run was 774 KB, and **~85% of it was frames the
796
- * reducer throws away** — `input_json_delta` (a tool call's arguments, streamed
797
- * character by character, 383 KB), `signature_delta` (encrypted-thinking
798
- * signatures, 153 KB) and the `message_start`/`content_block_start`/`_stop`
799
- * scaffolding (244 KB). The reducer models two delta kinds, `text_delta` and
800
- * `thinking_delta`; everything else falls through its switch untouched.
801
- *
802
- * What is deliberately **not** dropped, though the arithmetic would allow it:
803
- *
804
- * - `thinking_delta` — the Claude SDK delivers thinking blocks whose `thinking`
805
- * is `''`, and the reducer backfills them from the accumulated streamed text
806
- * (`streamedThinking`). Dropping these erases every thought from a replayed
807
- * transcript. This is the same carve-out `snapshotRetains` documents, and it
808
- * is the reason that rule is provider-engine-only.
809
- * - `text_delta` — superseded by the `assistant_message` that follows it, which
810
- * filters the streaming id and rebuilds from the full content blocks. It could
811
- * go, but only with a lookahead proving the message arrived, and at 24 KB in
812
- * the measured session it is not worth a rule that has to be right about
813
- * supersession. A merge is likewise not worth it: a *drop* needs no synthesized
814
- * event and therefore no invented seq.
815
- *
816
- * A live event is never affected — this is about the buffered replay alone — and
817
- * the caller must never drop the log's highest-seq event whatever this says, for
818
- * the reason {@link replayCoalesceKey} gives: the replay hold waits for
819
- * `state.lastSeq` to reach the attach's `session.lastSeq` and would hang on a
820
- * blank panel forever.
821
- *
822
- * The property is the family's usual one and is a test rather than an argument:
823
- * folding the full log and the retained log through `applyEvent` yields
824
- * identical state (`packages/react/test/replay-retain.test.ts`).
825
- */
826
463
  function replayRetains(body) {
827
464
  if (body.type !== "stream_delta") return true;
828
465
  const delta = body.event;
829
466
  if (delta.type !== "content_block_delta") return false;
830
467
  return delta.delta?.type === "text_delta" || delta.delta?.type === "thinking_delta";
831
468
  }
832
- /**
833
- * Does a `RunnerSnapshot` keep this event in its persisted log?
834
- *
835
- * The fourth of the same family, and the same shape of claim as
836
- * {@link replayCoalesceKey}: which events a *store* may drop without any client
837
- * being able to tell. It exists because a snapshot embeds the whole event log,
838
- * and a log is mostly stream deltas — a four-character token rides a ~180-byte
839
- * JSON envelope, so the delta run is tens of times the size of the text it
840
- * spells, sitting on disk *beside* the `assistant_message` that respells it in
841
- * full. That was affordable while a snapshot was written once, at a park. It is
842
- * not affordable written after every turn, which is what restart-survival needs.
843
- *
844
- * So: everything is retained except `stream_delta`. The reason that is safe is
845
- * not that deltas are unimportant but that they are **superseded by
846
- * construction**. The reducer upserts them under one constant id and the
847
- * following `assistant_message` filters exactly that id out and rebuilds from
848
- * the full content blocks — and a snapshot may only be taken at a rest point,
849
- * where the stream loop has exited and flushed. Both exits flush, including the
850
- * error path: an interrupted turn pushes its half-finished buffers into a
851
- * durable `assistant_message` before it emits the failed `turn_result`. There is
852
- * no rest state in which a delta is the only record of anything.
853
- *
854
- * **Provider engine only**, and this is the carve-out that must not be lost:
855
- * against a *Claude* log the rule would be wrong. The Claude SDK delivers
856
- * thinking blocks whose text is `''`, with the human-readable summary existing
857
- * only in the delta stream, and the reducer carries the streamed text over to
858
- * fill them (`transcript.ts`, the `streamedThinking` backfill). Dropping deltas
859
- * there would silently erase every thought from a restored transcript. Today
860
- * that is unreachable rather than merely avoided — only the provider engine
861
- * implements `park()`/`snapshot()` at all, and `#restore` refuses a snapshot
862
- * from another engine — but an engine that gains one inherits this obligation.
863
- *
864
- * Two properties hold it up, both of which are tests rather than arguments:
865
- * folding the full log and the retained log through `applyEvent` yields
866
- * identical state (`packages/react/test/snapshot-retain.test.ts`, the same
867
- * property `replay-coalesce.test.ts` asserts), and the retained log's last event
868
- * still carries the snapshot's own `seq`. The second matters more than it looks:
869
- * `transcriptActivity(stream_delta)` is 0, so the count `#restore` recomputes
870
- * from the log is bit-identical — a client's unread cursor cannot move — and the
871
- * replay hold waits for `state.lastSeq` to reach the attach's `lastSeq`, which a
872
- * rule that could drop the final event would hang forever.
873
- */
874
469
  function snapshotRetains(body) {
875
470
  return body.type !== "stream_delta";
876
471
  }
877
472
  //#endregion
878
- 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 };
473
+ export { DEFAULT_VIEW_CONFIG, ENGINE_CAPABILITIES, PROTOCOL_VERSION, 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, transcriptProse, unseenCount, usageInfos, watermarkKey };
879
474
 
880
475
  //# sourceMappingURL=index.mjs.map