@aexol/spectral 0.0.1

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.
@@ -0,0 +1,579 @@
1
+ /**
2
+ * Session-scoped streaming layer for `spectral serve`.
3
+ *
4
+ * Background: prior to this module each WebSocket owned its own `PiBridge`
5
+ * instance and the routes layer enforced single-writer-wins (4001 eviction)
6
+ * to keep that bridge unique per session. That model lost data on browser
7
+ * refresh — the WS close torn down the pi process mid-stream, and a re-open
8
+ * couldn't recover what hadn't yet hit `agent_end` (and thus SQLite).
9
+ *
10
+ * New model:
11
+ * - Pi lifecycle is per **Spectral session**, not per WS.
12
+ * - 0..N WebSockets may attach to the same session simultaneously. Each
13
+ * gets the same broadcast stream of events.
14
+ * - When a WS detaches (close, error, refresh), the pi process keeps
15
+ * running. Closing every tab does NOT cancel the in-flight turn —
16
+ * it runs to completion and persists on `agent_end` as before.
17
+ * - On `attach`, the manager hands back a replay payload: full DB history
18
+ * plus a snapshot of the currently in-flight turn (if any). The client
19
+ * replays the snapshot through the same reducer it uses for live events
20
+ * and continues streaming naturally.
21
+ * - Persistence shape is unchanged: only the final assistant message is
22
+ * written to SQLite on `agent_end`. In-flight events live in memory only.
23
+ * A server crash mid-turn discards the in-flight state — acceptable
24
+ * for MVP.
25
+ *
26
+ * Failure modes:
27
+ * - Pi throws synchronously in `prompt()` → bridge surfaces as `error`
28
+ * event; manager broadcasts and clears `currentTurn`.
29
+ * - One subscriber's `ws.send` throws → caught, logged, removed from the
30
+ * subscriber set; broadcast continues to the rest.
31
+ * - `agent_end` arrives without a current turn (defensive) → broadcast
32
+ * anyway so any late attachers don't get stuck.
33
+ *
34
+ * TODO (future): idle GC. A `SessionStream` with `subscribers.size === 0`
35
+ * and no current turn could be disposed after some grace window (e.g. 5
36
+ * minutes) to release pi resources for chronically-idle sessions. Skipped
37
+ * for now — streams accumulate for the lifetime of the server process.
38
+ */
39
+ import { randomUUID } from "node:crypto";
40
+ import { PiBridge } from "./pi-bridge.js";
41
+ import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
42
+ const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
43
+ /** Safety limit for autonomous refactor loop iterations per session. */
44
+ const MAX_AEXOL_ITERATIONS = 100;
45
+ export class SessionStreamManager {
46
+ store;
47
+ cwd;
48
+ backendUrl;
49
+ machineJwt;
50
+ bridgeFactory;
51
+ agentDir;
52
+ titleLlmCall;
53
+ disableAutoTitle;
54
+ publishMetaEvent;
55
+ streams = new Map();
56
+ /**
57
+ * Sessions for which we've already attempted (or queued) auto-title
58
+ * generation in this server process. Per-process is intentional: a server
59
+ * restart resets the set, but `isDefaultTitle()` still gates the work so a
60
+ * since-renamed session is never overwritten.
61
+ */
62
+ titleGenerationAttempted = new Set();
63
+ disposed = false;
64
+ constructor(opts) {
65
+ this.store = opts.store;
66
+ this.cwd = opts.cwd;
67
+ this.backendUrl = opts.backendUrl;
68
+ this.machineJwt = opts.machineJwt;
69
+ this.bridgeFactory = opts.bridgeFactory ?? DEFAULT_BRIDGE_FACTORY;
70
+ this.agentDir = opts.agentDir;
71
+ this.titleLlmCall = opts.titleLlmCall;
72
+ this.disableAutoTitle = opts.disableAutoTitle === true;
73
+ this.publishMetaEvent = opts.publishMetaEvent;
74
+ }
75
+ /**
76
+ * Attach a subscriber to a session. Lazily creates the underlying pi
77
+ * session on first attach. The caller is responsible for sending the
78
+ * initial `session_ready` frame using the returned replay payload (this
79
+ * keeps wire-protocol concerns in the routes layer).
80
+ *
81
+ * Throws if the session id is unknown in SQLite (caller should turn this
82
+ * into a wire-level error frame + close).
83
+ */
84
+ attach(sessionId, subscriber) {
85
+ if (this.disposed)
86
+ throw new Error("SessionStreamManager disposed");
87
+ const detail = this.store.getSession(sessionId);
88
+ if (!detail)
89
+ throw new Error(`Unknown sessionId: ${sessionId}`);
90
+ let stream = this.streams.get(sessionId);
91
+ if (!stream) {
92
+ stream = this.createStream(sessionId);
93
+ this.streams.set(sessionId, stream);
94
+ }
95
+ stream.subscribers.add(subscriber);
96
+ return {
97
+ history: detail.messages,
98
+ currentTurn: stream.currentTurn ? snapshotTurn(stream.currentTurn) : null,
99
+ ready: stream.ready,
100
+ };
101
+ }
102
+ /**
103
+ * Detach a subscriber. Idempotent. Does NOT dispose the underlying pi
104
+ * session — even when subscribers reach zero, the in-flight turn must
105
+ * complete and persist.
106
+ */
107
+ detach(sessionId, subscriber) {
108
+ const stream = this.streams.get(sessionId);
109
+ if (!stream)
110
+ return;
111
+ stream.subscribers.delete(subscriber);
112
+ // Intentional: do NOT dispose the bridge here. See file-level docs.
113
+ }
114
+ /** True if the session has an in-flight turn (manager-side; not WS-side). */
115
+ hasActiveTurn(sessionId) {
116
+ return this.streams.get(sessionId)?.currentTurn != null;
117
+ }
118
+ /**
119
+ * Persist a user message and forward it to pi. Resolves after the user
120
+ * message is persisted + pi is invoked (NOT after the turn completes —
121
+ * the turn lifetime is observed via the broadcast stream).
122
+ *
123
+ * Broadcast ordering:
124
+ * 1. user message persisted to SQLite
125
+ * 2. `user_message_appended` broadcast to all subscribers (including
126
+ * the originating tab)
127
+ * 3. new `currentTurn` opened
128
+ * 4. `bridge.prompt()` invoked (events arrive asynchronously and are
129
+ * buffered + broadcast as they come)
130
+ *
131
+ * Sticky model selection (Phase 3 — Available Models whitelist):
132
+ * - When `modelId` is provided, we apply it via `bridge.setModel()` and
133
+ * persist to SQLite for cross-restart recovery, BEFORE invoking
134
+ * `bridge.prompt()`. If `setModel` fails (unknown model, registry
135
+ * unavailable, pi-side error) the bridge has already emitted an
136
+ * `error` wire event and we drop the prompt to avoid running it
137
+ * against the wrong model.
138
+ * - When `modelId` is omitted, we look up SQLite. If a previous turn
139
+ * persisted a value, we reapply it on this turn (this is the
140
+ * cross-restart recovery path: a fresh server process has lost pi's
141
+ * in-memory model state, so we re-pin from durable storage).
142
+ * - When neither envelope nor SQLite have a value, we leave model
143
+ * selection to pi's own settings file (pre-Phase-3 behaviour).
144
+ */
145
+ async prompt(sessionId, content, modelId) {
146
+ if (this.disposed)
147
+ throw new Error("SessionStreamManager disposed");
148
+ const stream = this.streams.get(sessionId);
149
+ if (!stream)
150
+ throw new Error(`No active stream for session: ${sessionId}`);
151
+ // Wait for pi to be ready before we persist + invoke. If start failed,
152
+ // surface to all subscribers instead of throwing into the route handler.
153
+ try {
154
+ await stream.ready;
155
+ }
156
+ catch (err) {
157
+ const e = err instanceof Error ? err : new Error(String(err));
158
+ this.broadcast(stream, { type: "error", message: `Agent not ready: ${e.message}` });
159
+ return;
160
+ }
161
+ // Sticky-model resolution & application. Phase 3 (Available Models
162
+ // whitelist). Order:
163
+ // a) If envelope carried a `modelId`, use it.
164
+ // b) Else, look up the per-session persisted modelId in SQLite
165
+ // (cross-restart recovery — server restart wipes pi's in-memory
166
+ // session model state, but our durable store has the last value).
167
+ // c) Else, leave model selection to pi (pre-Phase-3 behaviour).
168
+ //
169
+ // We apply BEFORE persisting the user message: if the bridge can't
170
+ // resolve the model (unknown id, registry unavailable), it has already
171
+ // emitted an `error` wire event and we drop the prompt rather than
172
+ // recording a user message we know we won't be able to respond to.
173
+ //
174
+ // Persistence: only the envelope-supplied value is written back. A
175
+ // recovery-only application (case b) doesn't update the row — the
176
+ // value is already there.
177
+ const effectiveModelId = modelId ?? this.store.getSessionModel(sessionId) ?? undefined;
178
+ if (effectiveModelId && stream.bridge.setModel) {
179
+ const ok = await stream.bridge.setModel(effectiveModelId);
180
+ if (!ok) {
181
+ // Bridge already emitted an error; nothing else to do. We
182
+ // intentionally do not persist the user message — the turn never
183
+ // ran.
184
+ return;
185
+ }
186
+ }
187
+ if (modelId) {
188
+ try {
189
+ this.store.setSessionModel(sessionId, modelId);
190
+ }
191
+ catch (err) {
192
+ // Persisting the sticky model is best-effort: the live turn will
193
+ // still run with the model already applied above. A failure here
194
+ // only affects cross-restart recovery, which is non-critical.
195
+ console.warn(`[spectral] warn: failed to persist sticky model for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
196
+ }
197
+ }
198
+ // 1. Persist user message first (survives mid-prompt failures).
199
+ let stored;
200
+ try {
201
+ stored = this.store.appendMessage(sessionId, { role: "user", content });
202
+ }
203
+ catch (err) {
204
+ const e = err instanceof Error ? err : new Error(String(err));
205
+ this.broadcast(stream, {
206
+ type: "error",
207
+ message: `Failed to persist user message: ${e.message}`,
208
+ });
209
+ return;
210
+ }
211
+ // 2. Broadcast the persisted message so every tab — including the one
212
+ // that sent the prompt — appends an authoritative user turn.
213
+ this.broadcast(stream, { type: "user_message_appended", message: stored });
214
+ // 3. Open a new in-flight turn. Allocated even before pi emits anything
215
+ // so a re-attach immediately after `prompt` sees the turn.
216
+ stream.currentTurn = {
217
+ turnId: randomUUID(),
218
+ startedAt: Date.now(),
219
+ events: [],
220
+ assistantText: "",
221
+ };
222
+ // 4. Fire pi. `prompt` resolves on agent_end; errors are handled inside
223
+ // PiBridge (it emits `error` for us). We don't await — broadcast is
224
+ // driven by the bridge's emit callback.
225
+ void stream.bridge.prompt(content);
226
+ }
227
+ /**
228
+ * Tear down everything. Best-effort: disposes every bridge, drops all
229
+ * subscribers. After this the manager is unusable.
230
+ */
231
+ dispose() {
232
+ if (this.disposed)
233
+ return;
234
+ this.disposed = true;
235
+ for (const stream of this.streams.values()) {
236
+ stream.aexolActive = false;
237
+ try {
238
+ stream.bridge.dispose();
239
+ }
240
+ catch {
241
+ // ignore
242
+ }
243
+ stream.subscribers.clear();
244
+ }
245
+ this.streams.clear();
246
+ }
247
+ /** Test/inspection helper: how many streams are currently tracked. */
248
+ streamCount() {
249
+ return this.streams.size;
250
+ }
251
+ /**
252
+ * Count of sessions with an in-flight turn (i.e. a `currentTurn` set).
253
+ * Used by `gracefulShutdown` to decide whether to keep waiting before
254
+ * tearing down — a non-zero count means at least one assistant response
255
+ * is mid-stream and we'd rather let it finish (within the grace window)
256
+ * than orphan a half-streamed message in the UI.
257
+ *
258
+ * Cheap O(streams) scan; we only call it ~50× during a 5 s graceful
259
+ * shutdown so the linear walk is fine.
260
+ */
261
+ activeTurnCount() {
262
+ let n = 0;
263
+ for (const s of this.streams.values()) {
264
+ if (s.currentTurn != null)
265
+ n++;
266
+ }
267
+ return n;
268
+ }
269
+ /**
270
+ * Tear down a single session's stream — disposes the pi bridge and clears
271
+ * subscribers. Idempotent. Called by the routes layer right before
272
+ * `DELETE /api/sessions/:id` so the SQL cascade doesn't leave a zombie
273
+ * pi process driving events at a session that no longer exists.
274
+ *
275
+ * Does NOT remove the session from the store — that's the caller's job.
276
+ */
277
+ disposeSessionStream(sessionId) {
278
+ const stream = this.streams.get(sessionId);
279
+ if (!stream)
280
+ return;
281
+ stream.aexolActive = false;
282
+ try {
283
+ stream.bridge.dispose();
284
+ }
285
+ catch {
286
+ // ignore
287
+ }
288
+ // Best-effort: notify any still-open subscribers so they close cleanly
289
+ // rather than hanging on a dead pi process. We don't broadcast through
290
+ // `broadcast()` because that would re-enter the dead-subscriber pruning
291
+ // loop on a stream we're about to drop anyway.
292
+ for (const sub of stream.subscribers) {
293
+ if (!sub.isOpen())
294
+ continue;
295
+ try {
296
+ sub.send({ type: "error", message: "Session deleted" });
297
+ }
298
+ catch {
299
+ // ignore
300
+ }
301
+ }
302
+ stream.subscribers.clear();
303
+ this.streams.delete(sessionId);
304
+ }
305
+ /**
306
+ * Tear down every stream whose session belongs to the given list of ids.
307
+ * Used by the project-delete path: the route layer reads the project's
308
+ * session ids from `deleteProject()` and passes them here BEFORE the SQL
309
+ * cascade fires, so no pi process ever observes the FK cascade.
310
+ */
311
+ disposeProjectStreams(sessionIds) {
312
+ for (const sid of sessionIds) {
313
+ this.disposeSessionStream(sid);
314
+ }
315
+ }
316
+ /**
317
+ * Set the autonomous refactor loop state for a session. When `active` is
318
+ * true, the manager will auto-send "continue" after each `agent_end` event
319
+ * until deactivated or the safety limit is reached.
320
+ */
321
+ setAexolActive(sessionId, active) {
322
+ const stream = this.streams.get(sessionId);
323
+ if (stream) {
324
+ stream.aexolActive = active;
325
+ if (!active)
326
+ stream.aexolIterationCount = 0;
327
+ }
328
+ }
329
+ // --- internals ----------------------------------------------------------
330
+ createStream(sessionId) {
331
+ // Resolve cwd from the owning project. Sessions without a project
332
+ // shouldn't exist (FK enforces it), but we fall back to the manager's
333
+ // default cwd if the lookup somehow fails — better than crashing the
334
+ // attach.
335
+ const projectId = this.store.getSessionProjectId(sessionId);
336
+ let cwd = this.cwd;
337
+ if (projectId) {
338
+ const project = this.store.getProject(projectId);
339
+ if (project)
340
+ cwd = project.path;
341
+ }
342
+ // Forward declaration so the bridge factory's emit callback can refer to
343
+ // the stream object that's still being assembled.
344
+ const stream = {
345
+ sessionId,
346
+ cwd,
347
+ bridge: undefined,
348
+ ready: Promise.resolve(),
349
+ startError: null,
350
+ subscribers: new Set(),
351
+ currentTurn: null,
352
+ aexolActive: false,
353
+ aexolIterationCount: 0,
354
+ };
355
+ const bridgeOpts = {
356
+ cwd,
357
+ agentDir: this.agentDir,
358
+ backendUrl: this.backendUrl,
359
+ machineJwt: this.machineJwt,
360
+ emit: (event) => this.handleBridgeEvent(stream, event),
361
+ onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
362
+ try {
363
+ this.store.appendMessage(sessionId, {
364
+ id: messageId,
365
+ role: "assistant",
366
+ content,
367
+ eventsJsonl,
368
+ });
369
+ }
370
+ catch (err) {
371
+ console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
372
+ }
373
+ },
374
+ onError: (err) => {
375
+ console.error(`[spectral] error: pi bridge error: ${err.message}`);
376
+ },
377
+ };
378
+ stream.bridge = this.bridgeFactory(bridgeOpts);
379
+ stream.ready = stream.bridge
380
+ .start()
381
+ .catch((err) => {
382
+ const e = err instanceof Error ? err : new Error(String(err));
383
+ stream.startError = e;
384
+ // Notify any already-attached subscribers so they don't sit on a
385
+ // dead connection. Late attachers see startError via attach->ready.
386
+ this.broadcast(stream, {
387
+ type: "error",
388
+ message: `Failed to start agent: ${e.message}`,
389
+ });
390
+ throw e;
391
+ });
392
+ return stream;
393
+ }
394
+ handleBridgeEvent(stream, event) {
395
+ // Buffer replayable events into the in-flight turn. We intentionally
396
+ // accept events even if currentTurn is null (rare race: pi emits before
397
+ // prompt() opened the turn), in which case we open one defensively so
398
+ // late attachers see the events. The first event in such a case is
399
+ // typically `message_start`.
400
+ if (isReplayable(event)) {
401
+ if (!stream.currentTurn) {
402
+ stream.currentTurn = {
403
+ turnId: randomUUID(),
404
+ startedAt: Date.now(),
405
+ events: [],
406
+ assistantText: "",
407
+ };
408
+ }
409
+ stream.currentTurn.events.push(event);
410
+ if (event.type === "text_delta") {
411
+ stream.currentTurn.assistantText += event.delta;
412
+ }
413
+ }
414
+ // Broadcast first, then maybe close out the turn. agent_end clears the
415
+ // buffer because by that point the assistant message is already in
416
+ // SQLite (PiBridge calls onAssistantMessageComplete on message_end,
417
+ // which fires before agent_end).
418
+ this.broadcast(stream, event);
419
+ if (event.type === "agent_end") {
420
+ const finishedTurn = stream.currentTurn;
421
+ stream.currentTurn = null;
422
+ // Fire-and-forget auto-title generation. Runs only once per session
423
+ // per server lifetime, only when the session is still wearing its
424
+ // default title, and never blocks the user's stream (the user's
425
+ // turn is already complete by the time this runs).
426
+ this.maybeGenerateTitle(stream, finishedTurn);
427
+ // Autonomous refactor loop: when aexolActive is set, auto-send
428
+ // "continue" after each agent_end to keep the loop going.
429
+ if (stream.aexolActive && stream.aexolIterationCount < MAX_AEXOL_ITERATIONS) {
430
+ stream.aexolIterationCount++;
431
+ console.log(`[aexol-loop] iteration ${stream.aexolIterationCount}/${MAX_AEXOL_ITERATIONS}`);
432
+ void this.prompt(stream.sessionId, "continue", undefined).catch((err) => {
433
+ console.error(`[aexol-loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
434
+ stream.aexolActive = false;
435
+ });
436
+ }
437
+ else if (stream.aexolActive) {
438
+ console.log("[aexol-loop] max iterations reached, stopping");
439
+ stream.aexolActive = false;
440
+ }
441
+ }
442
+ else if (event.type === "error") {
443
+ // An error event arriving outside a turn (or bubbling out of one) —
444
+ // discard partial buffer to avoid replaying half a turn that the
445
+ // client has already shown an error for. The error event itself is
446
+ // still broadcast above.
447
+ stream.currentTurn = null;
448
+ }
449
+ }
450
+ /**
451
+ * Auto-title the session if it's still wearing the default title and we
452
+ * haven't already attempted generation in this process. Fire-and-forget
453
+ * (errors are caught, logged, and swallowed) — the user's stream finished
454
+ * before this runs, so blocking would only delay the broadcast.
455
+ */
456
+ maybeGenerateTitle(stream, finishedTurn) {
457
+ if (this.disableAutoTitle)
458
+ return;
459
+ if (this.titleGenerationAttempted.has(stream.sessionId))
460
+ return;
461
+ // Check the persisted title now (manual rename takes precedence).
462
+ const detail = this.store.getSession(stream.sessionId);
463
+ if (!detail || !isDefaultTitle(detail.title))
464
+ return;
465
+ // Find the first user message + the assistant text from the just-finished
466
+ // turn. We deliberately read user content from SQLite (authoritative)
467
+ // and assistant content from the in-memory turn buffer (cheap, and
468
+ // matches what the user just saw).
469
+ const firstUser = detail.messages.find((m) => m.role === "user");
470
+ if (!firstUser || !firstUser.content.trim())
471
+ return;
472
+ let assistantText = finishedTurn?.assistantText ?? "";
473
+ if (!assistantText) {
474
+ // Fallback: pull the most recent assistant message from SQLite. This
475
+ // can happen if `agent_end` fires for a turn whose buffer was cleared
476
+ // by an intervening error event, or when this code path is reached
477
+ // via a synthetic test event.
478
+ const lastAssistant = [...detail.messages]
479
+ .reverse()
480
+ .find((m) => m.role === "assistant");
481
+ assistantText = lastAssistant?.content ?? "";
482
+ }
483
+ // Mark BEFORE awaiting so a second `agent_end` arriving while we're
484
+ // generating doesn't double-fire. Even if generation throws, we leave
485
+ // the entry in place — one-shot semantics, no retries.
486
+ this.titleGenerationAttempted.add(stream.sessionId);
487
+ void this.runTitleGeneration(stream, firstUser.content, assistantText);
488
+ }
489
+ async runTitleGeneration(stream, firstUserMessage, firstAssistantMessage) {
490
+ try {
491
+ const title = await generateSessionTitle(firstUserMessage, firstAssistantMessage, {
492
+ cwd: stream.cwd,
493
+ agentDir: this.agentDir,
494
+ llmCall: this.titleLlmCall,
495
+ });
496
+ if (this.disposed)
497
+ return;
498
+ if (!title)
499
+ return;
500
+ // Re-check the title hasn't been changed underneath us while we were
501
+ // waiting on the LLM (e.g. user manually renamed mid-generation).
502
+ const current = this.store.getSession(stream.sessionId);
503
+ if (!current || !isDefaultTitle(current.title))
504
+ return;
505
+ const updated = this.store.renameSession(stream.sessionId, title);
506
+ if (!updated)
507
+ return;
508
+ // Broadcast to every subscriber of this session so all open tabs
509
+ // update their sidebar in real time. The wire event is independent
510
+ // of the in-flight turn lifecycle, so it's safe to fire post-agent_end.
511
+ this.broadcast(stream, {
512
+ type: "session_renamed",
513
+ sessionId: stream.sessionId,
514
+ title: updated.title,
515
+ });
516
+ // Cross-tab fan-out hint: tabs that don't have THIS session open
517
+ // (and so don't have a per-session ws subscription) still want to
518
+ // refresh their sidebar to show the new title. Best-effort; a
519
+ // failed publish never undoes the rename.
520
+ if (this.publishMetaEvent) {
521
+ try {
522
+ this.publishMetaEvent({
523
+ type: "session_renamed",
524
+ projectId: updated.projectId,
525
+ sessionId: stream.sessionId,
526
+ });
527
+ }
528
+ catch (err) {
529
+ const msg = err instanceof Error ? err.message : String(err);
530
+ console.warn(`[spectral] warn: meta publish for auto-title failed: ${msg}`);
531
+ }
532
+ }
533
+ }
534
+ catch (err) {
535
+ // Defensive: generateSessionTitle already swallows LLM errors. This
536
+ // catches any unexpected throw from rename/broadcast so the manager
537
+ // is never destabilized by a background title task.
538
+ const msg = err instanceof Error ? err.message : String(err);
539
+ console.warn(`[spectral] warn: auto-title pipeline failed: ${msg}`);
540
+ }
541
+ }
542
+ broadcast(stream, event) {
543
+ const dead = [];
544
+ for (const sub of stream.subscribers) {
545
+ if (!sub.isOpen()) {
546
+ dead.push(sub);
547
+ continue;
548
+ }
549
+ try {
550
+ sub.send(event);
551
+ }
552
+ catch (err) {
553
+ console.error(`[spectral] error: failed to send WS frame to subscriber: ${err instanceof Error ? err.message : String(err)}`);
554
+ dead.push(sub);
555
+ }
556
+ }
557
+ for (const sub of dead)
558
+ stream.subscribers.delete(sub);
559
+ }
560
+ }
561
+ function isReplayable(event) {
562
+ return (event.type === "message_start" ||
563
+ event.type === "text_delta" ||
564
+ event.type === "thinking_delta" ||
565
+ event.type === "tool_call" ||
566
+ event.type === "tool_result" ||
567
+ event.type === "message_end" ||
568
+ event.type === "error");
569
+ }
570
+ function snapshotTurn(turn) {
571
+ // Defensive copy of the events array so the snapshot can't be mutated by
572
+ // subsequent buffer pushes. Individual event objects are safe to share —
573
+ // they're never mutated after creation.
574
+ return {
575
+ turnId: turn.turnId,
576
+ startedAt: turn.startedAt,
577
+ events: turn.events.slice(),
578
+ };
579
+ }