@zeph-to/cli 2.15.0 → 2.16.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.
@@ -0,0 +1,502 @@
1
+ "use strict";
2
+ /**
3
+ * Live agent-chat timeline: tail this machine's Claude Code transcripts and push
4
+ * the small events off them over the relay the daemon already holds open.
5
+ *
6
+ * Sibling of the tmux mirror in `listener.ts`, deliberately not the same thing.
7
+ * The mirror sends a picture of a terminal; this sends what the agent *did*, so
8
+ * a phone can read a session the way the Claude app reads one — without the
9
+ * viewer having to parse ANSI, and without any of it becoming a push (no quota,
10
+ * no notification).
11
+ *
12
+ * Registry, lifecycle and framing mirror `activeStreams`/`stopStream`, because
13
+ * a second shape for the same job is a second set of leaks to find. What differs
14
+ * is the source: a file that grows, not a pane that repaints. `transcript-tail`
15
+ * owns that difference and stays pure; this file owns the socket and the clock.
16
+ *
17
+ * Every dependency that touches the world arrives through `TurnWatchDeps`, so
18
+ * the tests drive real ticks with a fake transcript and no WebSocket at all.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.createTurnWatchers = exports.MAX_TURN_FRAME_BYTES = exports.MAX_REPLAY_EVENTS = exports.MAX_TURN_SEAL_FAILURES = exports.TRANSCRIPT_RECHECK_MS = exports.TURN_LEASE_MS = exports.TURN_POLL_INTERVAL_MS = exports.MAX_TURN_WATCHERS = void 0;
22
+ const transcript_tail_js_1 = require("./transcript-tail.js");
23
+ /**
24
+ * Watchers this daemon will run at once — the same ceiling, for the same reason,
25
+ * as `MAX_CONCURRENT_STREAMS`. One machine can host a dozen tmux sessions, and
26
+ * a viewer that opened them all would otherwise have every one polling forever.
27
+ */
28
+ exports.MAX_TURN_WATCHERS = 3;
29
+ /**
30
+ * Poll cadence. The plan's user-visible bar is "a tool call shows up within 2s";
31
+ * at 500 ms the read itself is never the reason it misses. Slower than the
32
+ * mirror's 400 ms on purpose — this loop answers "what happened", not "what does
33
+ * the screen look like", and nothing here is animated.
34
+ */
35
+ exports.TURN_POLL_INTERVAL_MS = 500;
36
+ /**
37
+ * How long a watch survives without a renew.
38
+ *
39
+ * `watch.stop` is best-effort by construction: a swiped-away native sheet, a
40
+ * killed tab, or a dropped socket destroys the viewer before it can say
41
+ * anything. The lease — not the stop message — is what guarantees this daemon
42
+ * stops reading. Matches the terminal stream's `STREAM_SUB_TTL_SECONDS`.
43
+ */
44
+ exports.TURN_LEASE_MS = 60_000;
45
+ /**
46
+ * How often a live watch re-asks which transcript its session is writing.
47
+ *
48
+ * `/clear` and a compaction start a new session file under a new name; the old
49
+ * one simply stops growing, so a watcher pinned to the path it resolved at
50
+ * `start` goes quiet forever and looks exactly like an idle agent. Nothing in
51
+ * the file itself can signal this — the answer lives in the session registry —
52
+ * so it is asked for on a cadence rather than discovered.
53
+ *
54
+ * This is not a cheap question, and the number is chosen against its real cost:
55
+ * `resolveTranscript` runs `readPaneInfo`, an uncached blocking
56
+ * `spawnSync('tmux', …)` (`listener.ts`), and the pid-record memo behind it
57
+ * expires every 4s (`remote-agents.ts` SNAPSHOT_TTL_MS), so a recheck is one
58
+ * tmux spawn plus a real directory read — not a memo hit. At 10s per watcher and
59
+ * at most three watchers that stays under what the session report already spends
60
+ * on its own (`SESSION_REPORT_INTERVAL_MS` = 5s, one sweep for the whole
61
+ * machine), while keeping how long a cleared session stays dark to one interval.
62
+ */
63
+ exports.TRANSCRIPT_RECHECK_MS = 10_000;
64
+ /**
65
+ * Consecutive seal failures before the watch gives up and says so.
66
+ *
67
+ * Dropping a batch that will not seal is right; dropping every batch forever is
68
+ * an empty timeline the viewer cannot tell from an idle session. The mirror
69
+ * draws the same line with `STREAM_MAX_ENCRYPT_FAILURES`: fail closed, then stop
70
+ * and send an error the other side can render.
71
+ */
72
+ exports.MAX_TURN_SEAL_FAILURES = 3;
73
+ /**
74
+ * Ceiling on one replay, in events.
75
+ *
76
+ * The viewer keeps a bounded window of live turns, and a replay that filled it
77
+ * on its own would push the turn actually in flight out of the very screen the
78
+ * replay exists to fill. Below the web cap on purpose, so the live tail still
79
+ * has room after a full replay.
80
+ */
81
+ exports.MAX_REPLAY_EVENTS = 400;
82
+ /**
83
+ * Plaintext bytes per frame. Ephemeral frames ride API Gateway's 32KB WebSocket
84
+ * limit, and sealing base64-expands what goes in it — so this is deliberately
85
+ * below `SCREEN_PEEK_MAX_BYTES` (24KB, `listener.ts`), which bounds frames that
86
+ * are never sealed.
87
+ */
88
+ exports.MAX_TURN_FRAME_BYTES = 12 * 1024;
89
+ const isWatchSubtype = (subtype) => subtype === 'agent.turn.watch.start' ||
90
+ subtype === 'agent.turn.watch.stop' ||
91
+ subtype === 'agent.turn.watch.renew';
92
+ /**
93
+ * The part of a backfill the ring has not already sent.
94
+ *
95
+ * A restart re-reads the same region of the transcript the ring was filled
96
+ * from, so without this the first frames after every re-open would repeat the
97
+ * turn the replay just drew. An event with no `at` is kept: a transcript entry
98
+ * with no timestamp gives the cut nothing to compare, and showing a turn twice
99
+ * is recoverable where dropping one is not.
100
+ *
101
+ * The line is read off the ring at cut time rather than tracked as the watcher
102
+ * sends, so it says exactly what has been recorded — a live batch the seal
103
+ * refused was never appended and so never moves it. (A replayed page is the
104
+ * other case: it came out of the ring, so it counts whether or not this viewer
105
+ * received it, and the next re-open replays it again.) Reading it here also
106
+ * removes a race, since `beginWatch` starts the replay without awaiting it and
107
+ * a cached line could still be unset when the first poll lands.
108
+ */
109
+ const afterRingTail = (events, held) => {
110
+ let tail;
111
+ for (const event of held) {
112
+ if (event.at && (!tail || event.at > tail))
113
+ tail = event.at;
114
+ }
115
+ return tail ? events.filter((event) => !event.at || event.at > tail) : events;
116
+ };
117
+ /**
118
+ * A registry of transcript watchers plus the control handler that drives it.
119
+ *
120
+ * A factory rather than module state so a test can hold its own, and so two of
121
+ * them never share a clock or a socket by accident.
122
+ */
123
+ const createTurnWatchers = (deps) => {
124
+ const now = deps.now ?? Date.now;
125
+ const watchers = new Map();
126
+ const stop = (sessionName, reason) => {
127
+ const watcher = watchers.get(sessionName);
128
+ if (!watcher)
129
+ return;
130
+ clearTimeout(watcher.timer);
131
+ // Drop every reference the timer closure was holding alive — the tail
132
+ // state carries a partial-line buffer, and a watcher left in the map is
133
+ // that buffer left in the heap.
134
+ watcher.timer = undefined;
135
+ watchers.delete(sessionName);
136
+ const secs = (now() - watcher.startedAt) / 1000;
137
+ deps.log(`⧉ turn-watch ${sessionName} stopped (${reason}): ${watcher.events} events over ${secs.toFixed(1)}s`);
138
+ };
139
+ const stopAll = (reason) => {
140
+ for (const sessionName of [...watchers.keys()])
141
+ stop(sessionName, reason);
142
+ };
143
+ /**
144
+ * One read of one transcript. Exported through the returned object so tests
145
+ * drive it directly instead of waiting on a real timer — the same shape the
146
+ * mirror's cadence tests use.
147
+ */
148
+ const tick = async (sessionName) => {
149
+ const watcher = watchers.get(sessionName);
150
+ if (!watcher)
151
+ return;
152
+ if (now() >= watcher.expiresAt) {
153
+ stop(sessionName, 'lease expired');
154
+ return;
155
+ }
156
+ // The pane can go away under a watch that a viewer keeps renewing. Left
157
+ // alone it would poll a dead session's transcript forever — and if the
158
+ // name is reused, poll the wrong one. The mirror reaps on the same tick
159
+ // for the same reason (`listener.ts` lease check).
160
+ if (!deps.sessionExists(sessionName)) {
161
+ stop(sessionName, 'tmux session gone');
162
+ return;
163
+ }
164
+ try {
165
+ await readAndEmit(watcher);
166
+ }
167
+ finally {
168
+ // Re-arm even if the read threw. A watcher that stops re-arming never
169
+ // reaches its own lease check either, so it would hold one of
170
+ // MAX_TURN_WATCHERS slots until the socket closed.
171
+ if (watchers.get(sessionName) === watcher)
172
+ arm(watcher);
173
+ }
174
+ };
175
+ const readAndEmit = async (watcher) => {
176
+ const { sessionName } = watcher;
177
+ // Follow the session if it started writing somewhere else. Keeping the
178
+ // old path would be a watch that never reports again and never says why.
179
+ if (now() - watcher.checkedAt >= exports.TRANSCRIPT_RECHECK_MS) {
180
+ watcher.checkedAt = now();
181
+ const current = deps.resolveTranscript(sessionName);
182
+ if (current && current !== watcher.transcriptPath) {
183
+ deps.log(`⧉ turn-watch ${sessionName}: transcript rotated — following the new session file`);
184
+ reseed(watcher, current, watcher.subscriberPublicKey, watcher.send);
185
+ }
186
+ }
187
+ const read = (0, transcript_tail_js_1.readTranscriptDelta)(watcher.transcriptPath, watcher.tail);
188
+ // `null` is the idle case and the common one: nothing was appended, so
189
+ // nothing was read, parsed, or allocated.
190
+ if (read) {
191
+ watcher.tail = read.state;
192
+ if (read.droppedLines) {
193
+ deps.log(`⧉ turn-watch ${sessionName}: dropped ${read.droppedLines} oversized line(s)`);
194
+ }
195
+ if (read.lines.length) {
196
+ const projected = (0, transcript_tail_js_1.projectTranscriptEntries)(read.lines, { sinceLastPrompt: watcher.backfilling });
197
+ const backfilling = watcher.backfilling;
198
+ watcher.backfilling = false;
199
+ // Cut before the send, not after: the ring records what went out,
200
+ // so recording the uncut batch would make the next replay resend
201
+ // what this one just skipped.
202
+ const events = backfilling ? afterRingTail(projected, deps.ring.read(sessionName)) : projected;
203
+ if (events.length && (await emit(watcher, events)))
204
+ recordSent(watcher, events);
205
+ }
206
+ }
207
+ };
208
+ const arm = (watcher) => {
209
+ watcher.timer = setTimeout(() => {
210
+ void tick(watcher.sessionName);
211
+ }, exports.TURN_POLL_INTERVAL_MS);
212
+ // A pending read must never hold the process open — the daemon's
213
+ // lifetime is the socket's, not this loop's.
214
+ watcher.timer.unref?.();
215
+ };
216
+ const emit = async (watcher, events) => {
217
+ const frame = {
218
+ subtype: 'agent.turn.delta',
219
+ sessionName: watcher.sessionName,
220
+ epoch: watcher.epoch,
221
+ seq: ++watcher.seq,
222
+ };
223
+ if (watcher.subscriberPublicKey) {
224
+ try {
225
+ frame.encrypted = await deps.seal(JSON.stringify(events), watcher.subscriberPublicKey);
226
+ watcher.sealFailures = 0;
227
+ }
228
+ catch (err) {
229
+ // Fail closed. A sealed watch that quietly starts sending in the
230
+ // clear is indistinguishable from a relay that stripped the key,
231
+ // so the batch is dropped instead.
232
+ deps.log(`⧉ turn-watch ${watcher.sessionName}: seal failed (${err instanceof Error ? err.message : err}) — batch dropped`);
233
+ if (++watcher.sealFailures >= exports.MAX_TURN_SEAL_FAILURES) {
234
+ // Silence would read as "this session is idle". Say what
235
+ // happened and stop, so the viewer can show it and re-arm.
236
+ watcher.send({
237
+ subtype: 'agent.turn.watch.error',
238
+ sessionName: watcher.sessionName,
239
+ error: 'seal_failed',
240
+ });
241
+ stop(watcher.sessionName, 'seal failed');
242
+ }
243
+ return false;
244
+ }
245
+ }
246
+ else {
247
+ frame.events = events;
248
+ }
249
+ watcher.events += events.length;
250
+ watcher.send(frame);
251
+ return true;
252
+ };
253
+ /**
254
+ * Everything a batch that actually went out changes.
255
+ *
256
+ * The ring's whole meaning is "what this viewer has already been handed", so
257
+ * it is written here and nowhere else — a batch the seal refused never
258
+ * reached anyone and must not come back as scrollback.
259
+ */
260
+ const recordSent = (watcher, events) => {
261
+ let wrote = false;
262
+ try {
263
+ wrote = deps.ring.append(watcher.sessionName, events);
264
+ }
265
+ catch {
266
+ // The ring is injected, so a caller's implementation can throw where
267
+ // this one returns false. Either way the live send has already
268
+ // happened and must not be undone by the record of it.
269
+ }
270
+ // Once per watch, not per tick: a full disk stays full, and a line every
271
+ // 500ms would bury the log it is trying to explain.
272
+ if (!wrote && !watcher.ringWriteFailed) {
273
+ watcher.ringWriteFailed = true;
274
+ // Deliberately not "the scrollback will be short": the same false
275
+ // covers a trim that could not run, where the file is over its cap
276
+ // rather than under-filled. One line that is true of both.
277
+ deps.log(`⧉ turn-watch ${watcher.sessionName}: ring write failed — scrollback for this session is unreliable`);
278
+ }
279
+ };
280
+ /**
281
+ * Send what the ring holds, oldest first, before the transcript is read.
282
+ *
283
+ * This is the reason the ring exists: the backfill replays only since the
284
+ * last prompt, on the assumption that finished turns are already in the chat
285
+ * as their completion pushes — which under the `quiet` dial they are not.
286
+ *
287
+ * Paged, because one frame is bounded by the transport and a week of turns
288
+ * is not. Through `emit`, because that is where a subscriber's seal is
289
+ * applied; not through `recordSent`, because these events are already in the
290
+ * ring and re-appending them would double it on every re-open.
291
+ */
292
+ const replayRing = async (watcher) => {
293
+ const held = deps.ring.read(watcher.sessionName);
294
+ if (!held.length)
295
+ return;
296
+ const recent = held.length > exports.MAX_REPLAY_EVENTS ? held.slice(held.length - exports.MAX_REPLAY_EVENTS) : held;
297
+ // A second `start` on this same watcher re-seeds it and begins its own
298
+ // replay, and the object identity check below cannot see that — it is
299
+ // the same object. The epoch is what a re-seed changes, so a replay
300
+ // that has been superseded stops here instead of interleaving its pages
301
+ // with the newer one's.
302
+ const epoch = watcher.epoch;
303
+ const superseded = () => watchers.get(watcher.sessionName) !== watcher || watcher.epoch !== epoch;
304
+ let page = [];
305
+ let bytes = 0;
306
+ for (const event of recent) {
307
+ const size = Buffer.byteLength(JSON.stringify(event), 'utf-8') + 1;
308
+ // `page.length &&` — one event bigger than the whole budget still
309
+ // goes, alone. Prose is clamped at MAX_EVENT_TEXT_CHARS characters,
310
+ // not bytes, so a Korean paragraph can reach ~15KB plaintext; the
311
+ // budget is what keeps a *batch* well inside the transport, not a
312
+ // promise about every single event.
313
+ if (page.length && bytes + size > exports.MAX_TURN_FRAME_BYTES) {
314
+ // A refused page is not the end of the history: `emit` already
315
+ // counts seal failures and ends the watch at
316
+ // MAX_TURN_SEAL_FAILURES — which the viewer is told about — so
317
+ // stopping here on one transient failure would leave scrollback
318
+ // silently short, indistinguishable from a quiet session.
319
+ await emit(watcher, page);
320
+ if (superseded())
321
+ return;
322
+ page = [];
323
+ bytes = 0;
324
+ }
325
+ page.push(event);
326
+ bytes += size;
327
+ }
328
+ if (page.length)
329
+ await emit(watcher, page);
330
+ };
331
+ /**
332
+ * Handle one relay message. Returns true when it was ours, so the caller's
333
+ * handler chain stops — the same contract as `handleStreamControl`.
334
+ */
335
+ const handle = (req, send) => {
336
+ if (!isWatchSubtype(req.subtype))
337
+ return false;
338
+ // The relay fans control messages out to every connection this user has,
339
+ // so two machines running the same tmux session name both see this one.
340
+ // Addressing decides here, exactly as it does for stream control.
341
+ if (req.targetDeviceId !== deps.deviceId())
342
+ return false;
343
+ if (!req.sessionName)
344
+ return true;
345
+ if (req.subtype === 'agent.turn.watch.stop') {
346
+ stop(req.sessionName, 'viewer left');
347
+ return true;
348
+ }
349
+ const existing = watchers.get(req.sessionName);
350
+ if (existing) {
351
+ existing.expiresAt = now() + exports.TURN_LEASE_MS;
352
+ if (req.subtype !== 'agent.turn.watch.start') {
353
+ send({ subtype: 'agent.turn.watch.ok', sessionName: req.sessionName });
354
+ return true;
355
+ }
356
+ // A start is a viewer arriving, not a heartbeat — and the viewer it
357
+ // replaces may have left mid-turn without its `stop` landing. Treat
358
+ // it as a fresh watch on a live registry entry: everything the
359
+ // constructor below decides is decided again.
360
+ //
361
+ // Skipping this is what left the timeline blank on the exact path the
362
+ // lease exists for (swipe away, come back inside 60s): the old
363
+ // watcher's offset is already at EOF, so nothing backfills and
364
+ // nothing has been appended yet.
365
+ const transcriptPath = deps.resolveTranscript(req.sessionName);
366
+ if (!transcriptPath) {
367
+ // The session died and a new one took its name, or it was never
368
+ // Claude Code. Either way the old file is not this session's.
369
+ stop(req.sessionName, 'transcript gone');
370
+ send({ subtype: 'agent.turn.watch.error', sessionName: req.sessionName, error: 'no_transcript' });
371
+ return true;
372
+ }
373
+ beginWatch(existing, transcriptPath, req.subscriberPublicKey, send);
374
+ return true;
375
+ }
376
+ if (req.subtype === 'agent.turn.watch.renew') {
377
+ // Nothing to renew — say so rather than silently doing nothing, so
378
+ // the viewer can start one instead of waiting on a watch that ended.
379
+ send({ subtype: 'agent.turn.watch.gone', sessionName: req.sessionName });
380
+ return true;
381
+ }
382
+ if (watchers.size >= exports.MAX_TURN_WATCHERS) {
383
+ send({ subtype: 'agent.turn.watch.error', sessionName: req.sessionName, error: 'watch_limit' });
384
+ return true;
385
+ }
386
+ const transcriptPath = deps.resolveTranscript(req.sessionName);
387
+ if (!transcriptPath) {
388
+ // Not an error: a Codex or Gemini session has no Claude transcript.
389
+ // The viewer needs to say "no live timeline here" rather than show an
390
+ // empty screen that reads as a hang.
391
+ send({ subtype: 'agent.turn.watch.error', sessionName: req.sessionName, error: 'no_transcript' });
392
+ return true;
393
+ }
394
+ const watcher = {
395
+ sessionName: req.sessionName,
396
+ transcriptPath,
397
+ subscriberPublicKey: req.subscriberPublicKey,
398
+ tail: (0, transcript_tail_js_1.initialTailState)(),
399
+ timer: undefined,
400
+ expiresAt: now() + exports.TURN_LEASE_MS,
401
+ seq: 0,
402
+ epoch: 0,
403
+ sealFailures: 0,
404
+ checkedAt: 0,
405
+ send,
406
+ backfilling: true,
407
+ events: 0,
408
+ startedAt: now(),
409
+ };
410
+ watchers.set(req.sessionName, watcher);
411
+ // Once per new watch, not per tick: the sweep is a directory read, and a
412
+ // machine that has run agents for months is the only one it matters for.
413
+ deps.ring.sweep();
414
+ beginWatch(watcher, transcriptPath, req.subscriberPublicKey, send);
415
+ return true;
416
+ };
417
+ /**
418
+ * Everything a `start` means, in one place.
419
+ *
420
+ * Both branches of `handle` — a watcher that already exists and one just
421
+ * built — end here, because a start means the same thing either way: point
422
+ * at the transcript, tell the viewer, begin reading. Two copies of that
423
+ * sequence is how the next step added to it ends up in only one of them.
424
+ */
425
+ const beginWatch = (watcher, transcriptPath, subscriberPublicKey, send) => {
426
+ reseed(watcher, transcriptPath, subscriberPublicKey, send);
427
+ send({ subtype: 'agent.turn.watch.ok', sessionName: watcher.sessionName });
428
+ void sealThenRead(watcher, subscriberPublicKey);
429
+ };
430
+ /**
431
+ * Point a watcher at a transcript and hand it to a viewer, from scratch.
432
+ *
433
+ * Shared by "new watch" and "a start on an existing one" so the two can never
434
+ * disagree about what a start means — the second case is where the whole
435
+ * backfill contract used to fall through.
436
+ */
437
+ const reseed = (watcher, transcriptPath, subscriberPublicKey, send) => {
438
+ // Disarm first. `startTicking` below arms a fresh timer into
439
+ // `watcher.timer`, and whatever was already pending there would be
440
+ // overwritten while still scheduled — it fires, re-arms its own chain,
441
+ // and now the session is polled twice per interval, once more for every
442
+ // re-start, with only the newest handle reachable by `stop`.
443
+ clearTimeout(watcher.timer);
444
+ watcher.timer = undefined;
445
+ watcher.transcriptPath = transcriptPath;
446
+ // Backfill again: the point of a start is that someone is looking now.
447
+ watcher.tail = (0, transcript_tail_js_1.initialTailState)();
448
+ watcher.backfilling = true;
449
+ // A watch is keyed by session, not by viewer, so a keyless device that
450
+ // arrived first must not pin the session to plaintext for one that can
451
+ // read it sealed. A start always re-states who is watching and how.
452
+ watcher.subscriberPublicKey = subscriberPublicKey;
453
+ watcher.send = send;
454
+ watcher.sealFailures = 0;
455
+ // New counter incarnation, so the viewer resets rather than discarding
456
+ // everything below its old high-water mark.
457
+ watcher.epoch += 1;
458
+ watcher.seq = 0;
459
+ watcher.ringWriteFailed = false;
460
+ watcher.checkedAt = now();
461
+ deps.log(`⧉ turn-watch ${watcher.sessionName} started (${subscriberPublicKey ? 'sealed' : 'plaintext'})`);
462
+ };
463
+ /**
464
+ * Settle the encryption question, then read once.
465
+ *
466
+ * Mostly this is the handshake: a viewer that asked for a seal gets the
467
+ * keypair loaded before any batch is built, and a failure ends the watch
468
+ * rather than letting the first batches die one at a time — three silent
469
+ * drops read as an idle session, which is the one thing this surface must
470
+ * never look like when it is broken.
471
+ *
472
+ * The read that follows is immediate so the in-flight turn is on screen when
473
+ * the chat opens, not one poll interval later.
474
+ */
475
+ const sealThenRead = async (watcher, subscriberPublicKey) => {
476
+ if (subscriberPublicKey) {
477
+ try {
478
+ await deps.initCrypto();
479
+ }
480
+ catch (err) {
481
+ deps.log(`⧉ turn-watch ${watcher.sessionName}: device crypto init failed (${err instanceof Error ? err.message : err}) — refusing to watch (fail-closed)`);
482
+ watcher.send({
483
+ subtype: 'agent.turn.watch.error',
484
+ sessionName: watcher.sessionName,
485
+ error: 'e2ee_unavailable',
486
+ });
487
+ stop(watcher.sessionName, 'e2ee unavailable');
488
+ return;
489
+ }
490
+ if (watchers.get(watcher.sessionName) !== watcher)
491
+ return;
492
+ }
493
+ // Before the read, so the chat draws its history and then its live turn
494
+ // in the order they happened.
495
+ await replayRing(watcher);
496
+ if (watchers.get(watcher.sessionName) !== watcher)
497
+ return;
498
+ await tick(watcher.sessionName);
499
+ };
500
+ return { handle, stop, stopAll, tick, size: () => watchers.size };
501
+ };
502
+ exports.createTurnWatchers = createTurnWatchers;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",