@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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. 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;