@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/README.md +12 -4
- package/build/index.d.mts +738 -2
- package/build/index.mjs +411 -8
- package/build/index.mjs.map +1 -1
- package/package.json +1 -1
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
|