@hwip/cli 0.0.1 → 0.1.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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,630 @@
|
|
|
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.pagesOf = 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
|
+
const subagent_transcripts_js_1 = require("./subagent-transcripts.js");
|
|
24
|
+
/**
|
|
25
|
+
* Watchers this daemon will run at once — the same ceiling, for the same reason,
|
|
26
|
+
* as `MAX_CONCURRENT_STREAMS`. One machine can host a dozen tmux sessions, and
|
|
27
|
+
* a viewer that opened them all would otherwise have every one polling forever.
|
|
28
|
+
*/
|
|
29
|
+
exports.MAX_TURN_WATCHERS = 3;
|
|
30
|
+
/** Unknown transcript entry kinds a watch logs, at most. */
|
|
31
|
+
const MAX_UNKNOWN_KINDS_LOGGED = 16;
|
|
32
|
+
/**
|
|
33
|
+
* Poll cadence. The plan's user-visible bar is "a tool call shows up within 2s";
|
|
34
|
+
* at 500 ms the read itself is never the reason it misses. Slower than the
|
|
35
|
+
* mirror's 400 ms on purpose — this loop answers "what happened", not "what does
|
|
36
|
+
* the screen look like", and nothing here is animated.
|
|
37
|
+
*/
|
|
38
|
+
exports.TURN_POLL_INTERVAL_MS = 500;
|
|
39
|
+
/**
|
|
40
|
+
* How long a watch survives without a renew.
|
|
41
|
+
*
|
|
42
|
+
* `watch.stop` is best-effort by construction: a swiped-away native sheet, a
|
|
43
|
+
* killed tab, or a dropped socket destroys the viewer before it can say
|
|
44
|
+
* anything. The lease — not the stop message — is what guarantees this daemon
|
|
45
|
+
* stops reading. Matches the terminal stream's `STREAM_SUB_TTL_SECONDS`.
|
|
46
|
+
*/
|
|
47
|
+
exports.TURN_LEASE_MS = 60_000;
|
|
48
|
+
/**
|
|
49
|
+
* How often a live watch re-asks which transcript its session is writing.
|
|
50
|
+
*
|
|
51
|
+
* `/clear` and a compaction start a new session file under a new name; the old
|
|
52
|
+
* one simply stops growing, so a watcher pinned to the path it resolved at
|
|
53
|
+
* `start` goes quiet forever and looks exactly like an idle agent. Nothing in
|
|
54
|
+
* the file itself can signal this — the answer lives in the session registry —
|
|
55
|
+
* so it is asked for on a cadence rather than discovered.
|
|
56
|
+
*
|
|
57
|
+
* This is not a cheap question, and the number is chosen against its real cost:
|
|
58
|
+
* `resolveTranscript` runs `readPaneInfo`, an uncached blocking
|
|
59
|
+
* `spawnSync('tmux', …)` (`listener.ts`), and the pid-record memo behind it
|
|
60
|
+
* expires every 4s (`remote-agents.ts` SNAPSHOT_TTL_MS), so a recheck is one
|
|
61
|
+
* tmux spawn plus a real directory read — not a memo hit. At 10s per watcher and
|
|
62
|
+
* at most three watchers that stays under what the session report already spends
|
|
63
|
+
* on its own (`SESSION_REPORT_INTERVAL_MS` = 5s, one sweep for the whole
|
|
64
|
+
* machine), while keeping how long a cleared session stays dark to one interval.
|
|
65
|
+
*/
|
|
66
|
+
exports.TRANSCRIPT_RECHECK_MS = 10_000;
|
|
67
|
+
/**
|
|
68
|
+
* Consecutive seal failures before the watch gives up and says so.
|
|
69
|
+
*
|
|
70
|
+
* Dropping a batch that will not seal is right; dropping every batch forever is
|
|
71
|
+
* an empty timeline the viewer cannot tell from an idle session. The mirror
|
|
72
|
+
* draws the same line with `STREAM_MAX_ENCRYPT_FAILURES`: fail closed, then stop
|
|
73
|
+
* and send an error the other side can render.
|
|
74
|
+
*
|
|
75
|
+
* A live tick stops at its first unsealable page (see `readAndEmit`), so one
|
|
76
|
+
* busy tick spends one failure; a replay's pages each spend one, as they did
|
|
77
|
+
* before live ticks were paged.
|
|
78
|
+
*/
|
|
79
|
+
exports.MAX_TURN_SEAL_FAILURES = 3;
|
|
80
|
+
/**
|
|
81
|
+
* Ceiling on one replay, in events.
|
|
82
|
+
*
|
|
83
|
+
* The viewer keeps a bounded window of live turns, and a replay that filled it
|
|
84
|
+
* on its own would push the turn actually in flight out of the very screen the
|
|
85
|
+
* replay exists to fill. Below the web cap on purpose, so the live tail still
|
|
86
|
+
* has room after a full replay.
|
|
87
|
+
*/
|
|
88
|
+
exports.MAX_REPLAY_EVENTS = 400;
|
|
89
|
+
/**
|
|
90
|
+
* Plaintext bytes per frame. Ephemeral frames ride API Gateway's 32KB WebSocket
|
|
91
|
+
* limit, and sealing base64-expands what goes in it — so this is deliberately
|
|
92
|
+
* below `SCREEN_PEEK_MAX_BYTES` (24KB, `listener.ts`), which bounds frames that
|
|
93
|
+
* are never sealed.
|
|
94
|
+
*/
|
|
95
|
+
exports.MAX_TURN_FRAME_BYTES = 12 * 1024;
|
|
96
|
+
const isWatchSubtype = (subtype) => subtype === 'agent.turn.watch.start' ||
|
|
97
|
+
subtype === 'agent.turn.watch.stop' ||
|
|
98
|
+
subtype === 'agent.turn.watch.renew';
|
|
99
|
+
/**
|
|
100
|
+
* The part of a backfill the ring has not already sent.
|
|
101
|
+
*
|
|
102
|
+
* A restart re-reads the same region of the transcript the ring was filled
|
|
103
|
+
* from, so without this the first frames after every re-open would repeat the
|
|
104
|
+
* turn the replay just drew. An event with no `at` is kept: a transcript entry
|
|
105
|
+
* with no timestamp gives the cut nothing to compare, and showing a turn twice
|
|
106
|
+
* is recoverable where dropping one is not.
|
|
107
|
+
*
|
|
108
|
+
* The line is read off the ring at cut time rather than tracked as the watcher
|
|
109
|
+
* sends, so it says exactly what has been recorded — a live batch the seal
|
|
110
|
+
* refused was never appended and so never moves it. (A replayed page is the
|
|
111
|
+
* other case: it came out of the ring, so it counts whether or not this viewer
|
|
112
|
+
* received it, and the next re-open replays it again.) Reading it here also
|
|
113
|
+
* removes a race, since `beginWatch` starts the replay without awaiting it and
|
|
114
|
+
* a cached line could still be unset when the first poll lands.
|
|
115
|
+
*/
|
|
116
|
+
const afterRingTail = (events, held) => {
|
|
117
|
+
let tail;
|
|
118
|
+
for (const event of held) {
|
|
119
|
+
if (event.at && (!tail || event.at > tail))
|
|
120
|
+
tail = event.at;
|
|
121
|
+
}
|
|
122
|
+
return tail ? events.filter((event) => !event.at || event.at > tail) : events;
|
|
123
|
+
};
|
|
124
|
+
/** The part of the ring a start replays: its newest `MAX_REPLAY_EVENTS`. */
|
|
125
|
+
const replayedOf = (held) => held.length > exports.MAX_REPLAY_EVENTS ? held.slice(held.length - exports.MAX_REPLAY_EVENTS) : held;
|
|
126
|
+
const lastTodo = (events) => {
|
|
127
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
128
|
+
const event = events[i];
|
|
129
|
+
if (event.kind === 'todo')
|
|
130
|
+
return event;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
};
|
|
134
|
+
const sameTodo = (a, b) => a.total === b.total && a.done === b.done && JSON.stringify(a.items) === JSON.stringify(b.items);
|
|
135
|
+
/**
|
|
136
|
+
* The backfill's list, even when `afterRingTail` cut it.
|
|
137
|
+
*
|
|
138
|
+
* A list made while nobody watched carries the time of its last change, which
|
|
139
|
+
* can be older than events the ring holds from a later watch — and the cut
|
|
140
|
+
* drops it. The list is the session's state rather than a line of history, so
|
|
141
|
+
* it goes out anyway, unless the last list the replay sent is the same one. Only
|
|
142
|
+
* the replayed part of the ring counts: a list older than that window is in the
|
|
143
|
+
* ring but was never handed to this viewer.
|
|
144
|
+
*/
|
|
145
|
+
const withUnsentTodo = (events, projected, held) => {
|
|
146
|
+
const todo = lastTodo(projected);
|
|
147
|
+
if (!todo || events.includes(todo))
|
|
148
|
+
return events;
|
|
149
|
+
const replayed = lastTodo(replayedOf(held));
|
|
150
|
+
return replayed && sameTodo(replayed, todo) ? events : [...events, todo];
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Cut a batch into frames of at most MAX_TURN_FRAME_BYTES of plaintext. One
|
|
154
|
+
* event bigger than the whole budget still goes, alone: prose is clamped at
|
|
155
|
+
* MAX_EVENT_TEXT_CHARS characters, not bytes, so a Korean paragraph can reach
|
|
156
|
+
* ~15KB — the budget keeps a *batch* inside the transport, it is not a promise
|
|
157
|
+
* about every single event.
|
|
158
|
+
*/
|
|
159
|
+
const pagesOf = (events) => {
|
|
160
|
+
// A page serialises as `[e1,e2,…]`: each event plus its comma, and one more
|
|
161
|
+
// byte for the brackets' remainder — so a page starts at 1, not 0.
|
|
162
|
+
const pages = [];
|
|
163
|
+
let page = [];
|
|
164
|
+
let bytes = 1;
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
const size = Buffer.byteLength(JSON.stringify(event), 'utf-8') + 1;
|
|
167
|
+
if (page.length && bytes + size > exports.MAX_TURN_FRAME_BYTES) {
|
|
168
|
+
pages.push(page);
|
|
169
|
+
page = [];
|
|
170
|
+
bytes = 1;
|
|
171
|
+
}
|
|
172
|
+
page.push(event);
|
|
173
|
+
bytes += size;
|
|
174
|
+
}
|
|
175
|
+
if (page.length)
|
|
176
|
+
pages.push(page);
|
|
177
|
+
return pages;
|
|
178
|
+
};
|
|
179
|
+
exports.pagesOf = pagesOf;
|
|
180
|
+
/**
|
|
181
|
+
* A registry of transcript watchers plus the control handler that drives it.
|
|
182
|
+
*
|
|
183
|
+
* A factory rather than module state so a test can hold its own, and so two of
|
|
184
|
+
* them never share a clock or a socket by accident.
|
|
185
|
+
*/
|
|
186
|
+
const createTurnWatchers = (deps) => {
|
|
187
|
+
const now = deps.now ?? Date.now;
|
|
188
|
+
const watchers = new Map();
|
|
189
|
+
/** Still the same incarnation it was at `epoch` — not stopped, not re-seeded since. */
|
|
190
|
+
const isCurrent = (watcher, epoch) => watchers.get(watcher.sessionName) === watcher && watcher.epoch === epoch;
|
|
191
|
+
const stop = (sessionName, reason) => {
|
|
192
|
+
const watcher = watchers.get(sessionName);
|
|
193
|
+
if (!watcher)
|
|
194
|
+
return;
|
|
195
|
+
clearTimeout(watcher.timer);
|
|
196
|
+
// Drop every reference the timer closure was holding alive — the tail
|
|
197
|
+
// state carries a partial-line buffer, and a watcher left in the map is
|
|
198
|
+
// that buffer left in the heap.
|
|
199
|
+
watcher.timer = undefined;
|
|
200
|
+
watchers.delete(sessionName);
|
|
201
|
+
const secs = (now() - watcher.startedAt) / 1000;
|
|
202
|
+
deps.log(`⧉ turn-watch ${sessionName} stopped (${reason}): ${watcher.events} events over ${secs.toFixed(1)}s`);
|
|
203
|
+
};
|
|
204
|
+
const stopAll = (reason) => {
|
|
205
|
+
for (const sessionName of [...watchers.keys()])
|
|
206
|
+
stop(sessionName, reason);
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* One read of one transcript. Exported through the returned object so tests
|
|
210
|
+
* drive it directly instead of waiting on a real timer — the same shape the
|
|
211
|
+
* mirror's cadence tests use.
|
|
212
|
+
*/
|
|
213
|
+
const tick = async (sessionName) => {
|
|
214
|
+
const watcher = watchers.get(sessionName);
|
|
215
|
+
if (!watcher)
|
|
216
|
+
return;
|
|
217
|
+
if (now() >= watcher.expiresAt) {
|
|
218
|
+
stop(sessionName, 'lease expired');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// The pane can go away under a watch that a viewer keeps renewing. Left
|
|
222
|
+
// alone it would poll a dead session's transcript forever — and if the
|
|
223
|
+
// name is reused, poll the wrong one. The mirror reaps on the same tick
|
|
224
|
+
// for the same reason (`listener.ts` lease check).
|
|
225
|
+
if (!deps.sessionExists(sessionName)) {
|
|
226
|
+
stop(sessionName, 'tmux session gone');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await readAndEmit(watcher);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// A throwing dep (resolver, ring, transport) must end one watch,
|
|
234
|
+
// not the daemon: tick is also fired as a floating promise from
|
|
235
|
+
// the timer, where a rejection would be unhandled.
|
|
236
|
+
deps.log(`⧉ turn-watch ${sessionName}: read failed (${err instanceof Error ? err.message : err}) — stopping the watch`);
|
|
237
|
+
stop(sessionName, 'read failed');
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
// Re-arm even if the read threw. A watcher that stops re-arming never
|
|
241
|
+
// reaches its own lease check either, so it would hold one of
|
|
242
|
+
// MAX_TURN_WATCHERS slots until the socket closed.
|
|
243
|
+
if (watchers.get(sessionName) === watcher)
|
|
244
|
+
arm(watcher);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const readAndEmit = async (watcher) => {
|
|
248
|
+
const { sessionName } = watcher;
|
|
249
|
+
// Follow the session if it started writing somewhere else. Keeping the
|
|
250
|
+
// old path would be a watch that never reports again and never says why.
|
|
251
|
+
if (now() - watcher.checkedAt >= exports.TRANSCRIPT_RECHECK_MS) {
|
|
252
|
+
watcher.checkedAt = now();
|
|
253
|
+
const current = deps.resolveTranscript(sessionName);
|
|
254
|
+
if (current && current.path !== watcher.transcriptPath) {
|
|
255
|
+
deps.log(`⧉ turn-watch ${sessionName}: transcript rotated — following the new session file`);
|
|
256
|
+
reseed(watcher, current, watcher.subscriberPublicKey, watcher.send);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const read = (0, transcript_tail_js_1.readSource)({ path: watcher.transcriptPath, project: watcher.project, read: watcher.read }, watcher.tail);
|
|
260
|
+
// `null` is the idle case and the common one: nothing was appended, so
|
|
261
|
+
// nothing was read, parsed, or allocated.
|
|
262
|
+
if (read) {
|
|
263
|
+
watcher.tail = read.state;
|
|
264
|
+
if (read.droppedLines) {
|
|
265
|
+
deps.log(`⧉ turn-watch ${sessionName}: dropped ${read.droppedLines} oversized line(s)`);
|
|
266
|
+
}
|
|
267
|
+
if (read.lines.length) {
|
|
268
|
+
const projected = watcher.project(read.lines, {
|
|
269
|
+
sinceLastPrompt: watcher.backfilling,
|
|
270
|
+
// A subagent's transcript is nothing but sidechain entries.
|
|
271
|
+
includeSidechain: (0, subagent_transcripts_js_1.isSubagentTranscriptPath)(watcher.transcriptPath),
|
|
272
|
+
todo: watcher.todo,
|
|
273
|
+
onTodo: (list) => (watcher.todo = list),
|
|
274
|
+
onUnknown: (raw) => {
|
|
275
|
+
// Vendor strings: printable, short, and a bounded few per watch.
|
|
276
|
+
const what = raw.replace(/[^\x20-\x7e]/g, '?').slice(0, 80);
|
|
277
|
+
watcher.unknownLogged ??= new Set();
|
|
278
|
+
if (watcher.unknownLogged.has(what) || watcher.unknownLogged.size >= MAX_UNKNOWN_KINDS_LOGGED)
|
|
279
|
+
return;
|
|
280
|
+
watcher.unknownLogged.add(what);
|
|
281
|
+
deps.log(`⧉ turn-watch ${sessionName}: skipped a ${what} this version does not draw — the timeline is missing those rows`);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const backfilling = watcher.backfilling;
|
|
285
|
+
watcher.backfilling = false;
|
|
286
|
+
// Cut before the send, not after: the ring records what went out,
|
|
287
|
+
// so recording the uncut batch would make the next replay resend
|
|
288
|
+
// what this one just skipped.
|
|
289
|
+
const held = backfilling ? deps.ring.read(sessionName) : [];
|
|
290
|
+
const events = backfilling ? withUnsentTodo(afterRingTail(projected, held), projected, held) : projected;
|
|
291
|
+
// Paged like the replay: a busy tick is otherwise one frame of any
|
|
292
|
+
// size, past the transport's frame limit (see MAX_TURN_FRAME_BYTES).
|
|
293
|
+
// A page that will not seal drops the rest of the tick — as a whole
|
|
294
|
+
// batch was dropped before paging — so a failure counts once per
|
|
295
|
+
// tick. A stop or a re-seed during a seal ends the batch too: the
|
|
296
|
+
// rest belongs to a tail the watcher no longer reads.
|
|
297
|
+
const epoch = watcher.epoch;
|
|
298
|
+
for (const page of (0, exports.pagesOf)(events)) {
|
|
299
|
+
if (!(await emit(watcher, page)))
|
|
300
|
+
return;
|
|
301
|
+
recordSent(watcher, page);
|
|
302
|
+
if (!isCurrent(watcher, epoch))
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const arm = (watcher) => {
|
|
309
|
+
watcher.timer = setTimeout(() => {
|
|
310
|
+
void tick(watcher.sessionName);
|
|
311
|
+
}, exports.TURN_POLL_INTERVAL_MS);
|
|
312
|
+
// A pending read must never hold the process open — the daemon's
|
|
313
|
+
// lifetime is the socket's, not this loop's.
|
|
314
|
+
watcher.timer.unref?.();
|
|
315
|
+
};
|
|
316
|
+
const emit = async (watcher, events) => {
|
|
317
|
+
const frame = {
|
|
318
|
+
subtype: 'agent.turn.delta',
|
|
319
|
+
sessionName: watcher.sessionName,
|
|
320
|
+
epoch: watcher.epoch,
|
|
321
|
+
seq: ++watcher.seq,
|
|
322
|
+
};
|
|
323
|
+
if (watcher.subscriberPublicKey) {
|
|
324
|
+
try {
|
|
325
|
+
frame.encrypted = await deps.seal(JSON.stringify(events), watcher.subscriberPublicKey, watcher.sessionName);
|
|
326
|
+
watcher.sealFailures = 0;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
// Fail closed. A sealed watch that quietly starts sending in the
|
|
330
|
+
// clear is indistinguishable from a relay that stripped the key,
|
|
331
|
+
// so the batch is dropped instead.
|
|
332
|
+
deps.log(`⧉ turn-watch ${watcher.sessionName}: seal failed (${err instanceof Error ? err.message : err}) — batch dropped`);
|
|
333
|
+
if (++watcher.sealFailures >= exports.MAX_TURN_SEAL_FAILURES) {
|
|
334
|
+
// Silence would read as "this session is idle". Say what
|
|
335
|
+
// happened and stop, so the viewer can show it and re-arm.
|
|
336
|
+
watcher.send({
|
|
337
|
+
subtype: 'agent.turn.watch.error',
|
|
338
|
+
sessionName: watcher.sessionName,
|
|
339
|
+
error: 'seal_failed',
|
|
340
|
+
});
|
|
341
|
+
stop(watcher.sessionName, 'seal failed');
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
frame.events = events;
|
|
348
|
+
}
|
|
349
|
+
watcher.events += events.length;
|
|
350
|
+
watcher.send(frame);
|
|
351
|
+
return true;
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Everything a batch that actually went out changes.
|
|
355
|
+
*
|
|
356
|
+
* The ring's whole meaning is "what this viewer has already been handed", so
|
|
357
|
+
* it is written here and nowhere else — a batch the seal refused never
|
|
358
|
+
* reached anyone and must not come back as scrollback.
|
|
359
|
+
*/
|
|
360
|
+
const recordSent = (watcher, events) => {
|
|
361
|
+
let wrote = false;
|
|
362
|
+
try {
|
|
363
|
+
wrote = deps.ring.append(watcher.sessionName, events);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// The ring is injected, so a caller's implementation can throw where
|
|
367
|
+
// this one returns false. Either way the live send has already
|
|
368
|
+
// happened and must not be undone by the record of it.
|
|
369
|
+
}
|
|
370
|
+
// Once per watch, not per tick: a full disk stays full, and a line every
|
|
371
|
+
// 500ms would bury the log it is trying to explain.
|
|
372
|
+
if (!wrote && !watcher.ringWriteFailed) {
|
|
373
|
+
watcher.ringWriteFailed = true;
|
|
374
|
+
// Deliberately not "the scrollback will be short": the same false
|
|
375
|
+
// covers a trim that could not run, where the file is over its cap
|
|
376
|
+
// rather than under-filled. One line that is true of both.
|
|
377
|
+
deps.log(`⧉ turn-watch ${watcher.sessionName}: ring write failed — scrollback for this session is unreliable`);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* Send what the ring holds, oldest first, before the transcript is read.
|
|
382
|
+
*
|
|
383
|
+
* This is the reason the ring exists: the backfill replays only since the
|
|
384
|
+
* last prompt, on the assumption that finished turns are already in the chat
|
|
385
|
+
* as their completion pushes — which under the `quiet` dial they are not.
|
|
386
|
+
*
|
|
387
|
+
* Paged, because one frame is bounded by the transport and a day of turns
|
|
388
|
+
* is not. Through `emit`, because that is where a subscriber's seal is
|
|
389
|
+
* applied; not through `recordSent`, because these events are already in the
|
|
390
|
+
* ring and re-appending them would double it on every re-open.
|
|
391
|
+
*/
|
|
392
|
+
const replayRing = async (watcher) => {
|
|
393
|
+
const held = deps.ring.read(watcher.sessionName);
|
|
394
|
+
if (!held.length)
|
|
395
|
+
return;
|
|
396
|
+
const recent = replayedOf(held);
|
|
397
|
+
// A second `start` on this same watcher re-seeds it and begins its own
|
|
398
|
+
// replay, and the object identity check below cannot see that — it is
|
|
399
|
+
// the same object. The epoch is what a re-seed changes, so a replay
|
|
400
|
+
// that has been superseded stops here instead of interleaving its pages
|
|
401
|
+
// with the newer one's.
|
|
402
|
+
const epoch = watcher.epoch;
|
|
403
|
+
for (const page of (0, exports.pagesOf)(recent)) {
|
|
404
|
+
// A refused page is not the end of the history: `emit` already
|
|
405
|
+
// counts seal failures and ends the watch at MAX_TURN_SEAL_FAILURES
|
|
406
|
+
// — which the viewer is told about — so stopping here on one
|
|
407
|
+
// transient failure would leave scrollback silently short,
|
|
408
|
+
// indistinguishable from a quiet session.
|
|
409
|
+
await emit(watcher, page);
|
|
410
|
+
if (!isCurrent(watcher, epoch))
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Handle one relay message. Returns true when it was ours, so the caller's
|
|
416
|
+
* handler chain stops — the same contract as `handleStreamControl`.
|
|
417
|
+
*/
|
|
418
|
+
const handle = (req, send) => {
|
|
419
|
+
if (!isWatchSubtype(req.subtype))
|
|
420
|
+
return false;
|
|
421
|
+
// The relay fans control messages out to every connection this user has,
|
|
422
|
+
// so two machines running the same tmux session name both see this one.
|
|
423
|
+
// Addressing decides here, exactly as it does for stream control.
|
|
424
|
+
if (req.targetDeviceId !== deps.deviceId())
|
|
425
|
+
return false;
|
|
426
|
+
if (!req.sessionName)
|
|
427
|
+
return true;
|
|
428
|
+
if (req.subtype === 'agent.turn.watch.stop') {
|
|
429
|
+
stop(req.sessionName, 'viewer left');
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
const existing = watchers.get(req.sessionName);
|
|
433
|
+
if (existing) {
|
|
434
|
+
existing.expiresAt = now() + exports.TURN_LEASE_MS;
|
|
435
|
+
if (req.subtype !== 'agent.turn.watch.start') {
|
|
436
|
+
send({ subtype: 'agent.turn.watch.ok', sessionName: req.sessionName });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
// A start is a viewer arriving, not a heartbeat — and the viewer it
|
|
440
|
+
// replaces may have left mid-turn without its `stop` landing. Treat
|
|
441
|
+
// it as a fresh watch on a live registry entry: everything the
|
|
442
|
+
// constructor below decides is decided again.
|
|
443
|
+
//
|
|
444
|
+
// Skipping this is what left the timeline blank on the exact path the
|
|
445
|
+
// lease exists for (swipe away, come back inside 60s): the old
|
|
446
|
+
// watcher's offset is already at EOF, so nothing backfills and
|
|
447
|
+
// nothing has been appended yet.
|
|
448
|
+
const { source, reason } = resolveForViewer(req.sessionName);
|
|
449
|
+
if (!source) {
|
|
450
|
+
// The session died and a new one took its name, or its agent has
|
|
451
|
+
// no transcript we follow. Either way the old file is not this
|
|
452
|
+
// session's — and this watch *was* reading one a moment ago, so
|
|
453
|
+
// it is a loss, not an agent that never had a timeline.
|
|
454
|
+
stop(req.sessionName, 'transcript gone');
|
|
455
|
+
refuse(req.sessionName, 'the transcript this watch was following is gone', reason, send);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
beginWatch(existing, source, req.subscriberPublicKey, send);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
if (req.subtype === 'agent.turn.watch.renew') {
|
|
462
|
+
// Nothing to renew — say so rather than silently doing nothing, so
|
|
463
|
+
// the viewer can start one instead of waiting on a watch that ended.
|
|
464
|
+
send({ subtype: 'agent.turn.watch.gone', sessionName: req.sessionName });
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
if (watchers.size >= exports.MAX_TURN_WATCHERS) {
|
|
468
|
+
send({ subtype: 'agent.turn.watch.error', sessionName: req.sessionName, error: 'watch_limit' });
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
const { source, reason } = resolveForViewer(req.sessionName);
|
|
472
|
+
if (!source) {
|
|
473
|
+
refuse(req.sessionName, 'no transcript to watch', reason, send);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
const watcher = {
|
|
477
|
+
sessionName: req.sessionName,
|
|
478
|
+
transcriptPath: source.path,
|
|
479
|
+
project: source.project,
|
|
480
|
+
read: source.read,
|
|
481
|
+
subscriberPublicKey: req.subscriberPublicKey,
|
|
482
|
+
tail: (0, transcript_tail_js_1.initialTailState)(),
|
|
483
|
+
timer: undefined,
|
|
484
|
+
expiresAt: now() + exports.TURN_LEASE_MS,
|
|
485
|
+
seq: 0,
|
|
486
|
+
epoch: 0,
|
|
487
|
+
sealFailures: 0,
|
|
488
|
+
checkedAt: 0,
|
|
489
|
+
send,
|
|
490
|
+
backfilling: true,
|
|
491
|
+
events: 0,
|
|
492
|
+
startedAt: now(),
|
|
493
|
+
};
|
|
494
|
+
watchers.set(req.sessionName, watcher);
|
|
495
|
+
// Once per new watch, not per tick: the sweep reads the directory and
|
|
496
|
+
// each ring, and a machine that has run agents for months is the
|
|
497
|
+
// only one it matters for.
|
|
498
|
+
deps.ring.sweep();
|
|
499
|
+
beginWatch(watcher, source, req.subscriberPublicKey, send);
|
|
500
|
+
return true;
|
|
501
|
+
};
|
|
502
|
+
/**
|
|
503
|
+
* Turn a viewer away, and say so in the log.
|
|
504
|
+
*
|
|
505
|
+
* Not an error on the wire: an agent with no resolver genuinely has no
|
|
506
|
+
* timeline, and every case sends the same `no_transcript` so the viewer can
|
|
507
|
+
* say "no live timeline here" rather than show an empty screen that reads as
|
|
508
|
+
* a hang. The log is where the cases are told apart — `what` says which
|
|
509
|
+
* branch refused (a watch that never started vs. one whose file disappeared)
|
|
510
|
+
* and `reason` is the resolver's own account of the null. Without both, a
|
|
511
|
+
* broken resolver and an unsupported agent produce identical evidence, and
|
|
512
|
+
* the only symptom of the bug is a message that is also correct behaviour.
|
|
513
|
+
*
|
|
514
|
+
* Called only from `handle`, so it is once per watch attempt. The recheck in
|
|
515
|
+
* `readAndEmit` resolves on its own cadence and stays silent — a rotation
|
|
516
|
+
* that finds nothing keeps the watcher on the file it has.
|
|
517
|
+
*/
|
|
518
|
+
const refuse = (sessionName, what, reason, send) => {
|
|
519
|
+
deps.log(`⧉ turn-watch ${sessionName}: ${what} — ${reason || 'no reason reported'}`);
|
|
520
|
+
send({ subtype: 'agent.turn.watch.error', sessionName, error: 'no_transcript' });
|
|
521
|
+
};
|
|
522
|
+
/** Resolve for a viewer that is waiting, collecting the reason for a miss. */
|
|
523
|
+
const resolveForViewer = (sessionName) => {
|
|
524
|
+
let reason = '';
|
|
525
|
+
const source = deps.resolveTranscript(sessionName, (r) => {
|
|
526
|
+
reason = r;
|
|
527
|
+
});
|
|
528
|
+
return { source, reason };
|
|
529
|
+
};
|
|
530
|
+
/**
|
|
531
|
+
* Everything a `start` means, in one place.
|
|
532
|
+
*
|
|
533
|
+
* Both branches of `handle` — a watcher that already exists and one just
|
|
534
|
+
* built — end here, because a start means the same thing either way: point
|
|
535
|
+
* at the transcript, tell the viewer, begin reading. Two copies of that
|
|
536
|
+
* sequence is how the next step added to it ends up in only one of them.
|
|
537
|
+
*/
|
|
538
|
+
const beginWatch = (watcher, source, subscriberPublicKey, send) => {
|
|
539
|
+
reseed(watcher, source, subscriberPublicKey, send);
|
|
540
|
+
send({ subtype: 'agent.turn.watch.ok', sessionName: watcher.sessionName });
|
|
541
|
+
void sealThenRead(watcher, subscriberPublicKey);
|
|
542
|
+
};
|
|
543
|
+
/**
|
|
544
|
+
* Point a watcher at a transcript and hand it to a viewer, from scratch.
|
|
545
|
+
*
|
|
546
|
+
* Shared by "new watch" and "a start on an existing one" so the two can never
|
|
547
|
+
* disagree about what a start means — the second case is where the whole
|
|
548
|
+
* backfill contract used to fall through.
|
|
549
|
+
*/
|
|
550
|
+
const reseed = (watcher, source, subscriberPublicKey, send) => {
|
|
551
|
+
// Disarm first. `startTicking` below arms a fresh timer into
|
|
552
|
+
// `watcher.timer`, and whatever was already pending there would be
|
|
553
|
+
// overwritten while still scheduled — it fires, re-arms its own chain,
|
|
554
|
+
// and now the session is polled twice per interval, once more for every
|
|
555
|
+
// re-start, with only the newest handle reachable by `stop`.
|
|
556
|
+
clearTimeout(watcher.timer);
|
|
557
|
+
watcher.timer = undefined;
|
|
558
|
+
watcher.transcriptPath = source.path;
|
|
559
|
+
// The projector moves with the path. A rotation can land on a file this
|
|
560
|
+
// agent writes in another format — or, when a tmux name is reused, on a
|
|
561
|
+
// different agent's file entirely — and a projector left behind would
|
|
562
|
+
// parse the new bytes with the old format's rules.
|
|
563
|
+
watcher.project = source.project;
|
|
564
|
+
watcher.read = source.read;
|
|
565
|
+
// Backfill again: the point of a start is that someone is looking now.
|
|
566
|
+
watcher.tail = (0, transcript_tail_js_1.initialTailState)();
|
|
567
|
+
watcher.backfilling = true;
|
|
568
|
+
// The backfill folds the list again from the window it reads.
|
|
569
|
+
watcher.todo = undefined;
|
|
570
|
+
// A watch is keyed by session, not by viewer, so a keyless device that
|
|
571
|
+
// arrived first must not pin the session to plaintext for one that can
|
|
572
|
+
// read it sealed. A start always re-states who is watching and how.
|
|
573
|
+
watcher.subscriberPublicKey = subscriberPublicKey;
|
|
574
|
+
watcher.send = send;
|
|
575
|
+
watcher.sealFailures = 0;
|
|
576
|
+
// New counter incarnation, so the viewer resets rather than discarding
|
|
577
|
+
// everything below its old high-water mark.
|
|
578
|
+
watcher.epoch += 1;
|
|
579
|
+
watcher.seq = 0;
|
|
580
|
+
watcher.ringWriteFailed = false;
|
|
581
|
+
watcher.checkedAt = now();
|
|
582
|
+
deps.log(`⧉ turn-watch ${watcher.sessionName} started (${subscriberPublicKey ? 'sealed' : 'plaintext'})`);
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Settle the encryption question, then read once.
|
|
586
|
+
*
|
|
587
|
+
* Mostly this is the handshake: a viewer that asked for a seal gets the
|
|
588
|
+
* keypair loaded before any batch is built, and a failure ends the watch
|
|
589
|
+
* rather than letting the first batches die one at a time — three silent
|
|
590
|
+
* drops read as an idle session, which is the one thing this surface must
|
|
591
|
+
* never look like when it is broken.
|
|
592
|
+
*
|
|
593
|
+
* The read that follows is immediate so the in-flight turn is on screen when
|
|
594
|
+
* the chat opens, not one poll interval later.
|
|
595
|
+
*/
|
|
596
|
+
const sealThenRead = async (watcher, subscriberPublicKey) => {
|
|
597
|
+
if (subscriberPublicKey) {
|
|
598
|
+
try {
|
|
599
|
+
await deps.initCrypto();
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
deps.log(`⧉ turn-watch ${watcher.sessionName}: device crypto init failed (${err instanceof Error ? err.message : err}) — refusing to watch (fail-closed)`);
|
|
603
|
+
watcher.send({
|
|
604
|
+
subtype: 'agent.turn.watch.error',
|
|
605
|
+
sessionName: watcher.sessionName,
|
|
606
|
+
error: 'e2ee_unavailable',
|
|
607
|
+
});
|
|
608
|
+
stop(watcher.sessionName, 'e2ee unavailable');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (watchers.get(watcher.sessionName) !== watcher)
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
// Before the read, so the chat draws its history and then its live turn
|
|
615
|
+
// in the order they happened.
|
|
616
|
+
try {
|
|
617
|
+
await replayRing(watcher);
|
|
618
|
+
if (watchers.get(watcher.sessionName) !== watcher)
|
|
619
|
+
return;
|
|
620
|
+
await tick(watcher.sessionName);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
// sealThenRead is also fired floating from a start: same rule as tick.
|
|
624
|
+
deps.log(`⧉ turn-watch ${watcher.sessionName}: start failed (${err instanceof Error ? err.message : err}) — stopping the watch`);
|
|
625
|
+
stop(watcher.sessionName, 'start failed');
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
return { handle, stop, stopAll, tick, size: () => watchers.size };
|
|
629
|
+
};
|
|
630
|
+
exports.createTurnWatchers = createTurnWatchers;
|