@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.
- package/README.md +25 -3
- package/dist/cli.js +7 -2
- package/dist/gate.d.ts +8 -3
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +6 -5
- package/dist/listener.d.ts +7 -0
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +71 -6
- package/dist/presence.d.ts +14 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +84 -0
- package/dist/remote-agents.d.ts +35 -0
- package/dist/remote-agents.d.ts.map +1 -1
- package/dist/remote-agents.js +60 -1
- package/dist/session-registry.d.ts +8 -1
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +7 -14
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +3 -2
- package/dist/test-state-dir.d.ts +10 -0
- package/dist/test-state-dir.d.ts.map +1 -0
- package/dist/test-state-dir.js +32 -0
- package/dist/transcript-tail.d.ts +147 -0
- package/dist/transcript-tail.d.ts.map +1 -0
- package/dist/transcript-tail.js +397 -0
- package/dist/turn-ring.d.ts +85 -0
- package/dist/turn-ring.d.ts.map +1 -0
- package/dist/turn-ring.js +280 -0
- package/dist/turn-watch.d.ts +151 -0
- package/dist/turn-watch.d.ts.map +1 -0
- package/dist/turn-watch.js +502 -0
- package/package.json +1 -1
|
@@ -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;
|