@zeph-to/cli 2.14.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 +44 -3
- package/dist/cli.js +11 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/gate.d.ts +8 -3
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +6 -5
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.d.ts.map +1 -0
- package/dist/keep-awake.js +43 -0
- package/dist/listener.d.ts +7 -0
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +79 -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,280 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diskTurnRing = exports.removeTurnRing = exports.sweepTurnRings = exports.readTurnRing = exports.appendTurnRing = exports.turnRingPath = exports.turnRingDir = exports.TURN_RING_TTL_MS = exports.MAX_TURN_RING_FILES = exports.MAX_TURN_RING_BYTES = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const gate_js_1 = require("./gate.js");
|
|
8
|
+
/**
|
|
9
|
+
* What the daemon has already sent a chat viewer, kept on disk so a
|
|
10
|
+
* re-subscribe can replay it.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists at all: the live lane is ephemeral, and the transcript
|
|
13
|
+
* backfill deliberately replays only since the last prompt — finished turns are
|
|
14
|
+
* supposed to already exist in the chat as their completion pushes. Under the
|
|
15
|
+
* `quiet` push dial they do not exist, because that dial suppresses every
|
|
16
|
+
* auto-push without a `high` marker. A turn's closing words were therefore
|
|
17
|
+
* visible while it ran and gone the moment the viewer re-armed, with no record
|
|
18
|
+
* on the phone, on the server, or in the 256KB backfill window.
|
|
19
|
+
*
|
|
20
|
+
* Re-deriving those turns from the transcript is not an option: measured over
|
|
21
|
+
* this machine's transcripts larger than the window, the last 256KB holds a
|
|
22
|
+
* median of one human prompt and none at all in 8 of 19 files — a single
|
|
23
|
+
* `tool_result` line can be most of a megabyte. Projected events are a tool
|
|
24
|
+
* name, a short label and a verdict, so the same bytes hold turns rather than
|
|
25
|
+
* fragments of one.
|
|
26
|
+
*
|
|
27
|
+
* It lives beside `known-sessions.json` under `stateDir()` rather than in
|
|
28
|
+
* `~/.zeph`, for the same reason that file does: this is derived session state
|
|
29
|
+
* that expires, not configuration or keys.
|
|
30
|
+
*
|
|
31
|
+
* Everything here is best-effort. A ring that cannot be written costs the
|
|
32
|
+
* scrollback, never the live send — the daemon's job is to keep talking.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* One ring file. Trimming is by bytes and not by event count on purpose: a
|
|
36
|
+
* prose event carries up to `MAX_EVENT_TEXT_CHARS` (5000) while a tool event's
|
|
37
|
+
* label is clamped at `MAX_EVENT_FIELD_CHARS` (512), so a count would bound two
|
|
38
|
+
* things an order of magnitude apart.
|
|
39
|
+
*/
|
|
40
|
+
exports.MAX_TURN_RING_BYTES = 256 * 1024;
|
|
41
|
+
/** Sessions with a ring. Past this the oldest by mtime are dropped. */
|
|
42
|
+
exports.MAX_TURN_RING_FILES = 20;
|
|
43
|
+
/** A week unwatched. Scrollback older than that is history, not context. */
|
|
44
|
+
exports.TURN_RING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const turnRingDir = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'turns');
|
|
46
|
+
exports.turnRingDir = turnRingDir;
|
|
47
|
+
/**
|
|
48
|
+
* The session name is chosen by whoever sent the watch request, so it never
|
|
49
|
+
* reaches the filesystem — a hash does. Same move as `hashListenerId`, at twice
|
|
50
|
+
* the width: 64 bits, because these names collide within one machine's
|
|
51
|
+
* directory rather than identifying one device.
|
|
52
|
+
*/
|
|
53
|
+
const turnRingPath = (sessionName) => (0, path_1.join)((0, exports.turnRingDir)(), `${(0, crypto_1.createHash)('sha256').update(sessionName).digest('hex').slice(0, 16)}.jsonl`);
|
|
54
|
+
exports.turnRingPath = turnRingPath;
|
|
55
|
+
const isExpired = (path, now) => {
|
|
56
|
+
try {
|
|
57
|
+
return now - (0, fs_1.statSync)(path).mtimeMs > exports.TURN_RING_TTL_MS;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const removeQuietly = (path) => {
|
|
64
|
+
try {
|
|
65
|
+
(0, fs_1.unlinkSync)(path);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* already gone, or not ours to delete */
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* How far under the cap a trim cuts.
|
|
73
|
+
*
|
|
74
|
+
* Trimming exactly to the cap would leave the file one event below it, so the
|
|
75
|
+
* next append crosses again and rewrites the whole thing — a full read, split
|
|
76
|
+
* and rename per event for the rest of the session. Cutting deeper buys roughly
|
|
77
|
+
* a quarter of the ring's worth of appends between rewrites.
|
|
78
|
+
*/
|
|
79
|
+
const TRIM_TARGET_BYTES = Math.floor(exports.MAX_TURN_RING_BYTES * 0.75);
|
|
80
|
+
/** Keep the newest lines that fit under `MAX_TURN_RING_BYTES`. False when the cap still stands broken. */
|
|
81
|
+
const trimToCap = (path) => {
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
if ((0, fs_1.statSync)(path).size <= exports.MAX_TURN_RING_BYTES)
|
|
85
|
+
return true;
|
|
86
|
+
raw = (0, fs_1.readFileSync)(path, 'utf-8');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const lines = raw.split('\n').filter((line) => line.length > 0);
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
let start = lines.length;
|
|
94
|
+
// Newest first, until the next line would not fit.
|
|
95
|
+
while (start > 0) {
|
|
96
|
+
const size = Buffer.byteLength(lines[start - 1], 'utf-8') + 1;
|
|
97
|
+
if (bytes + size > TRIM_TARGET_BYTES)
|
|
98
|
+
break;
|
|
99
|
+
bytes += size;
|
|
100
|
+
start--;
|
|
101
|
+
}
|
|
102
|
+
// A single line longer than the whole cap takes nothing, which would empty
|
|
103
|
+
// the ring — and empty it again on every later append. The newest line
|
|
104
|
+
// always survives; the one after it trims this one away in turn.
|
|
105
|
+
start = Math.min(start, lines.length - 1);
|
|
106
|
+
const kept = lines
|
|
107
|
+
.slice(start)
|
|
108
|
+
.map((line) => `${line}\n`)
|
|
109
|
+
.join('');
|
|
110
|
+
// Through a temp file, unlike the append: a kill mid-append costs the last
|
|
111
|
+
// line, a kill mid-rewrite would cost the whole ring.
|
|
112
|
+
// Random name, exclusive create: a predictable `${path}.tmp` is a file
|
|
113
|
+
// another process on this machine can pre-create as a symlink and have this
|
|
114
|
+
// write follow. `wx` fails instead of following one.
|
|
115
|
+
const tmp = `${path}.${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
(0, fs_1.writeFileSync)(tmp, kept, { mode: 0o600, flag: 'wx' });
|
|
118
|
+
(0, fs_1.renameSync)(tmp, path);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
removeQuietly(tmp);
|
|
123
|
+
// The append landed but the cap did not. Saying the write succeeded
|
|
124
|
+
// would let the file grow past its bound with nothing ever noticing.
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Record events this watch just sent. Call it with the same array that went out
|
|
130
|
+
* — the ring's whole meaning is "what the viewer already received", and a replay
|
|
131
|
+
* is that array sent again.
|
|
132
|
+
*/
|
|
133
|
+
const appendTurnRing = (sessionName, events) => {
|
|
134
|
+
if (!events.length)
|
|
135
|
+
return true;
|
|
136
|
+
let path;
|
|
137
|
+
try {
|
|
138
|
+
// Inside the guard: the name arrives off the wire, and a non-string one
|
|
139
|
+
// makes `createHash().update()` throw where "best-effort" is the whole
|
|
140
|
+
// contract — a ring that cannot be written must never take a tick down.
|
|
141
|
+
path = (0, exports.turnRingPath)(sessionName);
|
|
142
|
+
(0, fs_1.mkdirSync)((0, exports.turnRingDir)(), { recursive: true, mode: 0o700 });
|
|
143
|
+
(0, fs_1.appendFileSync)(path, events.map((event) => `${JSON.stringify(event)}\n`).join(''), { mode: 0o600 });
|
|
144
|
+
// `mode` on mkdir/append applies only when the entry is created, so a
|
|
145
|
+
// directory or file that already exists keeps whatever it had — a
|
|
146
|
+
// pre-created 0755 `turns/` would never become 0700 on its own. The
|
|
147
|
+
// modes are the only thing standing between another local account and a
|
|
148
|
+
// week of tool targets, prompts and URLs, so they are set every time
|
|
149
|
+
// (`config.ts:84` chmods its own file for the same reason).
|
|
150
|
+
(0, fs_1.chmodSync)((0, exports.turnRingDir)(), 0o700);
|
|
151
|
+
(0, fs_1.chmodSync)(path, 0o600);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// The caller says so once per session rather than per tick: a full disk
|
|
155
|
+
// costs the scrollback, and silence about it costs the diagnosis.
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return trimToCap(path);
|
|
159
|
+
};
|
|
160
|
+
exports.appendTurnRing = appendTurnRing;
|
|
161
|
+
/**
|
|
162
|
+
* What this session's viewers have already been sent, oldest first.
|
|
163
|
+
*
|
|
164
|
+
* An expired ring is deleted here rather than merely ignored: returning an empty
|
|
165
|
+
* array and leaving the file would let the next append land on top of pre-expiry
|
|
166
|
+
* content, and the TTL would bound nothing.
|
|
167
|
+
*/
|
|
168
|
+
const readTurnRing = (sessionName, now = Date.now()) => {
|
|
169
|
+
let raw;
|
|
170
|
+
let path;
|
|
171
|
+
try {
|
|
172
|
+
// Same reason as the append: the name is wire input, and hashing a
|
|
173
|
+
// non-string throws.
|
|
174
|
+
path = (0, exports.turnRingPath)(sessionName);
|
|
175
|
+
if (isExpired(path, now)) {
|
|
176
|
+
removeQuietly(path);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
raw = (0, fs_1.readFileSync)(path, 'utf-8');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
const events = [];
|
|
185
|
+
for (const line of raw.split('\n')) {
|
|
186
|
+
if (!line)
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(line);
|
|
190
|
+
// `null`, `5`, `[]` and `{}` are all valid JSON. Without a shape
|
|
191
|
+
// check a hand-edited or planted ring hands slice 02 a non-event to
|
|
192
|
+
// seal and send. `kind` is what every branch downstream switches on,
|
|
193
|
+
// so that is what makes a line an event — the same row-level filter
|
|
194
|
+
// `session-registry.ts:52-62` applies to its own file.
|
|
195
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
196
|
+
continue;
|
|
197
|
+
if (typeof parsed.kind !== 'string')
|
|
198
|
+
continue;
|
|
199
|
+
events.push(parsed);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// A line the daemon was killed halfway through: skip it, keep the
|
|
203
|
+
// rest — again the transcript reader's rule.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return events;
|
|
207
|
+
};
|
|
208
|
+
exports.readTurnRing = readTurnRing;
|
|
209
|
+
/** Drop expired rings, then the oldest ones past `MAX_TURN_RING_FILES`. */
|
|
210
|
+
const sweepTurnRings = (now = Date.now()) => {
|
|
211
|
+
const dir = (0, exports.turnRingDir)();
|
|
212
|
+
let names;
|
|
213
|
+
try {
|
|
214
|
+
names = (0, fs_1.readdirSync)(dir);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const live = [];
|
|
220
|
+
for (const name of names) {
|
|
221
|
+
const path = (0, path_1.join)(dir, name);
|
|
222
|
+
// A kill between the trim's write and its rename strands a temp file.
|
|
223
|
+
// Nothing else would ever remove it, and left in place it counts
|
|
224
|
+
// against MAX_TURN_RING_FILES and evicts a live ring early. Matching the
|
|
225
|
+
// ring's own name shape rather than just the extension also means a
|
|
226
|
+
// stray file dropped in here is cleaned up rather than counted.
|
|
227
|
+
if (!/^[0-9a-f]{16}\.jsonl$/.test(name)) {
|
|
228
|
+
removeQuietly(path);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
let mtimeMs;
|
|
232
|
+
try {
|
|
233
|
+
mtimeMs = (0, fs_1.statSync)(path).mtimeMs;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (now - mtimeMs > exports.TURN_RING_TTL_MS)
|
|
239
|
+
removeQuietly(path);
|
|
240
|
+
else
|
|
241
|
+
live.push({ path, mtimeMs });
|
|
242
|
+
}
|
|
243
|
+
if (live.length <= exports.MAX_TURN_RING_FILES)
|
|
244
|
+
return;
|
|
245
|
+
live.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
246
|
+
for (const stale of live.slice(0, live.length - exports.MAX_TURN_RING_FILES))
|
|
247
|
+
removeQuietly(stale.path);
|
|
248
|
+
};
|
|
249
|
+
exports.sweepTurnRings = sweepTurnRings;
|
|
250
|
+
/**
|
|
251
|
+
* Forget one session's scrollback outright — the machine was told to forget the
|
|
252
|
+
* session. False when the file is still there, which the caller has to say out
|
|
253
|
+
* loud: answering "forgotten" while a week of prompts and tool targets sits on
|
|
254
|
+
* disk is the one wrong answer here.
|
|
255
|
+
*/
|
|
256
|
+
const removeTurnRing = (sessionName) => {
|
|
257
|
+
let path;
|
|
258
|
+
try {
|
|
259
|
+
path = (0, exports.turnRingPath)(sessionName);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return true; // a name that cannot even be hashed has no ring
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
(0, fs_1.unlinkSync)(path);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
// Already gone is the outcome the caller asked for. Checking existence
|
|
270
|
+
// first and then unlinking would call a file that vanished in between a
|
|
271
|
+
// failure, and answer "your scrollback is still there" about nothing.
|
|
272
|
+
return err?.code === 'ENOENT';
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
exports.removeTurnRing = removeTurnRing;
|
|
276
|
+
exports.diskTurnRing = {
|
|
277
|
+
read: (sessionName) => (0, exports.readTurnRing)(sessionName),
|
|
278
|
+
append: (sessionName, events) => (0, exports.appendTurnRing)(sessionName, events),
|
|
279
|
+
sweep: () => (0, exports.sweepTurnRings)(),
|
|
280
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live agent-chat timeline: tail this machine's Claude Code transcripts and push
|
|
3
|
+
* the small events off them over the relay the daemon already holds open.
|
|
4
|
+
*
|
|
5
|
+
* Sibling of the tmux mirror in `listener.ts`, deliberately not the same thing.
|
|
6
|
+
* The mirror sends a picture of a terminal; this sends what the agent *did*, so
|
|
7
|
+
* a phone can read a session the way the Claude app reads one — without the
|
|
8
|
+
* viewer having to parse ANSI, and without any of it becoming a push (no quota,
|
|
9
|
+
* no notification).
|
|
10
|
+
*
|
|
11
|
+
* Registry, lifecycle and framing mirror `activeStreams`/`stopStream`, because
|
|
12
|
+
* a second shape for the same job is a second set of leaks to find. What differs
|
|
13
|
+
* is the source: a file that grows, not a pane that repaints. `transcript-tail`
|
|
14
|
+
* owns that difference and stays pure; this file owns the socket and the clock.
|
|
15
|
+
*
|
|
16
|
+
* Every dependency that touches the world arrives through `TurnWatchDeps`, so
|
|
17
|
+
* the tests drive real ticks with a fake transcript and no WebSocket at all.
|
|
18
|
+
*/
|
|
19
|
+
import { type TurnEvent } from './transcript-tail.js';
|
|
20
|
+
import type { TurnRing } from './turn-ring.js';
|
|
21
|
+
/**
|
|
22
|
+
* Watchers this daemon will run at once — the same ceiling, for the same reason,
|
|
23
|
+
* as `MAX_CONCURRENT_STREAMS`. One machine can host a dozen tmux sessions, and
|
|
24
|
+
* a viewer that opened them all would otherwise have every one polling forever.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MAX_TURN_WATCHERS = 3;
|
|
27
|
+
/**
|
|
28
|
+
* Poll cadence. The plan's user-visible bar is "a tool call shows up within 2s";
|
|
29
|
+
* at 500 ms the read itself is never the reason it misses. Slower than the
|
|
30
|
+
* mirror's 400 ms on purpose — this loop answers "what happened", not "what does
|
|
31
|
+
* the screen look like", and nothing here is animated.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TURN_POLL_INTERVAL_MS = 500;
|
|
34
|
+
/**
|
|
35
|
+
* How long a watch survives without a renew.
|
|
36
|
+
*
|
|
37
|
+
* `watch.stop` is best-effort by construction: a swiped-away native sheet, a
|
|
38
|
+
* killed tab, or a dropped socket destroys the viewer before it can say
|
|
39
|
+
* anything. The lease — not the stop message — is what guarantees this daemon
|
|
40
|
+
* stops reading. Matches the terminal stream's `STREAM_SUB_TTL_SECONDS`.
|
|
41
|
+
*/
|
|
42
|
+
export declare const TURN_LEASE_MS = 60000;
|
|
43
|
+
/**
|
|
44
|
+
* How often a live watch re-asks which transcript its session is writing.
|
|
45
|
+
*
|
|
46
|
+
* `/clear` and a compaction start a new session file under a new name; the old
|
|
47
|
+
* one simply stops growing, so a watcher pinned to the path it resolved at
|
|
48
|
+
* `start` goes quiet forever and looks exactly like an idle agent. Nothing in
|
|
49
|
+
* the file itself can signal this — the answer lives in the session registry —
|
|
50
|
+
* so it is asked for on a cadence rather than discovered.
|
|
51
|
+
*
|
|
52
|
+
* This is not a cheap question, and the number is chosen against its real cost:
|
|
53
|
+
* `resolveTranscript` runs `readPaneInfo`, an uncached blocking
|
|
54
|
+
* `spawnSync('tmux', …)` (`listener.ts`), and the pid-record memo behind it
|
|
55
|
+
* expires every 4s (`remote-agents.ts` SNAPSHOT_TTL_MS), so a recheck is one
|
|
56
|
+
* tmux spawn plus a real directory read — not a memo hit. At 10s per watcher and
|
|
57
|
+
* at most three watchers that stays under what the session report already spends
|
|
58
|
+
* on its own (`SESSION_REPORT_INTERVAL_MS` = 5s, one sweep for the whole
|
|
59
|
+
* machine), while keeping how long a cleared session stays dark to one interval.
|
|
60
|
+
*/
|
|
61
|
+
export declare const TRANSCRIPT_RECHECK_MS = 10000;
|
|
62
|
+
/**
|
|
63
|
+
* Consecutive seal failures before the watch gives up and says so.
|
|
64
|
+
*
|
|
65
|
+
* Dropping a batch that will not seal is right; dropping every batch forever is
|
|
66
|
+
* an empty timeline the viewer cannot tell from an idle session. The mirror
|
|
67
|
+
* draws the same line with `STREAM_MAX_ENCRYPT_FAILURES`: fail closed, then stop
|
|
68
|
+
* and send an error the other side can render.
|
|
69
|
+
*/
|
|
70
|
+
export declare const MAX_TURN_SEAL_FAILURES = 3;
|
|
71
|
+
/**
|
|
72
|
+
* Ceiling on one replay, in events.
|
|
73
|
+
*
|
|
74
|
+
* The viewer keeps a bounded window of live turns, and a replay that filled it
|
|
75
|
+
* on its own would push the turn actually in flight out of the very screen the
|
|
76
|
+
* replay exists to fill. Below the web cap on purpose, so the live tail still
|
|
77
|
+
* has room after a full replay.
|
|
78
|
+
*/
|
|
79
|
+
export declare const MAX_REPLAY_EVENTS = 400;
|
|
80
|
+
/**
|
|
81
|
+
* Plaintext bytes per frame. Ephemeral frames ride API Gateway's 32KB WebSocket
|
|
82
|
+
* limit, and sealing base64-expands what goes in it — so this is deliberately
|
|
83
|
+
* below `SCREEN_PEEK_MAX_BYTES` (24KB, `listener.ts`), which bounds frames that
|
|
84
|
+
* are never sealed.
|
|
85
|
+
*/
|
|
86
|
+
export declare const MAX_TURN_FRAME_BYTES: number;
|
|
87
|
+
/** Wire shape of one batch of timeline events. */
|
|
88
|
+
export type TurnDeltaFrame = {
|
|
89
|
+
subtype: 'agent.turn.delta';
|
|
90
|
+
sessionName: string;
|
|
91
|
+
/** Incarnation of this watch. See `AgentTurnDeltaFrame` (zeph `libs/feed-ui`)
|
|
92
|
+
* for why the pair exists; the mirror stamps frames the same way
|
|
93
|
+
* (`StreamFramePayload.epoch`). */
|
|
94
|
+
epoch: number;
|
|
95
|
+
seq: number;
|
|
96
|
+
/** Plaintext batch — present only when the subscriber supplied no key. */
|
|
97
|
+
events?: TurnEvent[];
|
|
98
|
+
/** Sealed batch — the same array, encrypted for the subscriber. */
|
|
99
|
+
encrypted?: unknown;
|
|
100
|
+
};
|
|
101
|
+
/** What a viewer sends to start, keep, or end a watch. */
|
|
102
|
+
export interface TurnWatchControl {
|
|
103
|
+
subtype?: string;
|
|
104
|
+
targetDeviceId?: string;
|
|
105
|
+
sessionName?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The viewer's device public key. Present when that device can open what it
|
|
108
|
+
* asks to be sealed; absent on a device with no keypair, which then reads
|
|
109
|
+
* the same events in the clear — the convention `useAgentDiff` and
|
|
110
|
+
* `TerminalStreamView` already follow on the web side. Encryption is a paid
|
|
111
|
+
* feature, so refusing to serve an unsealed viewer would quietly make the
|
|
112
|
+
* whole timeline paid too.
|
|
113
|
+
*/
|
|
114
|
+
subscriberPublicKey?: string;
|
|
115
|
+
}
|
|
116
|
+
export type SendTurnFrame = (data: Record<string, unknown>) => void;
|
|
117
|
+
export interface TurnWatchDeps {
|
|
118
|
+
/** This machine's listener device id — control messages that name another are not ours. */
|
|
119
|
+
deviceId: () => string;
|
|
120
|
+
/** tmux session name → transcript file, or null when this session has none (a non-Claude agent). */
|
|
121
|
+
resolveTranscript: (sessionName: string) => string | null;
|
|
122
|
+
/** Whether tmux still holds this session. A watch outlives its pane otherwise. */
|
|
123
|
+
sessionExists: (sessionName: string) => boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Load-or-create this device's keypair. Rejecting means this daemon cannot
|
|
126
|
+
* seal at all, which a viewer that asked for a seal must be told about
|
|
127
|
+
* rather than discovering three dropped batches later.
|
|
128
|
+
*/
|
|
129
|
+
initCrypto: () => Promise<void>;
|
|
130
|
+
/** Seal a batch for the subscriber. Rejecting drops the batch; it never falls back to plaintext. */
|
|
131
|
+
seal: (plaintext: string, subscriberPublicKey: string) => Promise<unknown>;
|
|
132
|
+
log: (message: string) => void;
|
|
133
|
+
/** Where a watch's events are kept so a later watch can replay them. */
|
|
134
|
+
ring: TurnRing;
|
|
135
|
+
now?: () => number;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A registry of transcript watchers plus the control handler that drives it.
|
|
139
|
+
*
|
|
140
|
+
* A factory rather than module state so a test can hold its own, and so two of
|
|
141
|
+
* them never share a clock or a socket by accident.
|
|
142
|
+
*/
|
|
143
|
+
export declare const createTurnWatchers: (deps: TurnWatchDeps) => {
|
|
144
|
+
handle: (req: TurnWatchControl, send: SendTurnFrame) => boolean;
|
|
145
|
+
stop: (sessionName: string, reason: string) => void;
|
|
146
|
+
stopAll: (reason: string) => void;
|
|
147
|
+
tick: (sessionName: string) => Promise<void>;
|
|
148
|
+
size: () => number;
|
|
149
|
+
};
|
|
150
|
+
export type TurnWatchers = ReturnType<typeof createTurnWatchers>;
|
|
151
|
+
//# sourceMappingURL=turn-watch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-watch.d.ts","sourceRoot":"","sources":["../src/turn-watch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAKH,KAAK,SAAS,EACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,QAAS,CAAC;AAEpC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,QAAY,CAAC;AAE9C,kDAAkD;AAClD,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,kBAAkB,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB;;wCAEoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAEpE,MAAM,WAAW,aAAa;IAC1B,2FAA2F;IAC3F,QAAQ,EAAE,MAAM,MAAM,CAAC;IACvB,oGAAoG;IACpG,iBAAiB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC1D,kFAAkF;IAClF,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD;;;;OAIG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,oGAAoG;IACpG,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,wEAAwE;IACxE,IAAI,EAAE,QAAQ,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAuDD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,aAAa;kBA2N7B,gBAAgB,QAAQ,aAAa,KAAG,OAAO;wBAvNzC,MAAM,UAAU,MAAM,KAAG,IAAI;sBAe/B,MAAM,KAAG,IAAI;wBASL,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;;CAoX1D,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC"}
|