@hwip/cli 0.0.1 → 0.1.1

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 +1983 -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 +305 -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 +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -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 +278 -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 +5681 -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,695 @@
1
+ "use strict";
2
+ /**
3
+ * Always-on conversation recorder (I28, hwip `docs/session-history.md` §3).
4
+ *
5
+ * `turn-watch.ts` follows a transcript for a viewer who is looking. This reads
6
+ * the transcript of every agent session this listener knows, with nobody
7
+ * looking, and uploads what was said to the account so the phone can show the
8
+ * conversation later: the same projection (`TranscriptSource.project`), the
9
+ * same privacy line (a tool is a name, a label and counts — never its
10
+ * arguments or output), a different lifetime. The live watcher and its ring are
11
+ * left alone.
12
+ *
13
+ * What is uploaded, and how it stays correct:
14
+ *
15
+ * - A chunk is a page of projected events, at most 12 KB of plaintext, sealed
16
+ * with push envelope v1 for the account's pinned devices plus this machine
17
+ * (the relay is not a recipient; there is no plaintext upload at all). The
18
+ * sealed body carries `{sessionKey, seq, events}` so the reader can check the
19
+ * chunk is the one it asked for.
20
+ * - `seq` is per session and only goes up. It starts from the clock when a
21
+ * session has no state (the server allows gaps), so a wiped state directory
22
+ * cannot collide with chunks the server already holds.
23
+ * - Reading and uploading are decoupled by an outbox on disk. A read becomes
24
+ * chunks, and the chunks, the new byte offset and `seq` are written in one
25
+ * atomic replace; only then is anything uploaded, and an upload retries the
26
+ * stored bytes. A crash therefore loses nothing and, because a retry sends
27
+ * the same bytes under the same `seq`, duplicates nothing (the server answers
28
+ * a repeated chunk 200).
29
+ * - The offset is the end of the last complete line that went out (inside a line
30
+ * too long to read, where that line began), so the state file holds numbers and
31
+ * ciphertext, never conversation text. It also names the agent kind, so a
32
+ * session that is gone after a restart is still read to the end of its transcript.
33
+ * - A transcript that is not a file (opencode's SQLite store) is read by its
34
+ * agent's own `TranscriptReader`, and where it was read to is that reader's
35
+ * cursor, kept in the state file beside the offset. The cursor is the reader's
36
+ * to keep free of conversation text (opencode's holds row numbers and keys).
37
+ *
38
+ * Flush rules: events wait for 2 s of quiet, or 12 KB, or a prompt in the read
39
+ * (a person typed — that goes out now). A transcript that changes name
40
+ * (`/clear`, compaction, resume) keeps the session's key; events not newer than
41
+ * the last one sent are dropped on every read of a new file until the first
42
+ * newer one, because a new file may start with a copy of the old history.
43
+ *
44
+ * Every dependency that touches the world is injected, so tests run real ticks
45
+ * on a temporary directory with a fake clock and no network.
46
+ */
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.chooseRecipients = exports.createTurnRecorder = exports.sweepRecorderState = exports.readSessionState = exports.RECORDER_SEQ_PER_SECOND = exports.RECORDER_RECIPIENTS_TIMEOUT_MS = exports.RECORDER_PENDING_MAX_BYTES = exports.RECORDER_STATE_TTL_MS = exports.RECORDER_LINGER_MS = exports.RECORDER_READER_SETTLE_MS = exports.RECORDER_PAUSE_MS = exports.RECORDER_BACKOFF_MAX_MS = exports.RECORDER_BACKOFF_BASE_MS = exports.RECORDER_MAX_RECIPIENTS = exports.RECORDER_OUTBOX_MAX = exports.RECORDER_BACKFILL_EVENTS = exports.RECORDER_FLUSH_BYTES = exports.RECORDER_QUIET_MS = exports.RECORDER_TICK_MS = void 0;
49
+ const node_crypto_1 = require("node:crypto");
50
+ const node_fs_1 = require("node:fs");
51
+ const node_path_1 = require("node:path");
52
+ const transcript_tail_js_1 = require("./transcript-tail.js");
53
+ const turn_watch_js_1 = require("./turn-watch.js");
54
+ const private_files_js_1 = require("./private-files.js");
55
+ /** Poll cadence for every observed session; an idle transcript costs one `stat`. */
56
+ exports.RECORDER_TICK_MS = 1_000;
57
+ /** Quiet time after the last append before pending events go out. */
58
+ exports.RECORDER_QUIET_MS = 2_000;
59
+ /** Plaintext bytes that flush on their own (the live frame's budget, `MAX_TURN_FRAME_BYTES`). */
60
+ exports.RECORDER_FLUSH_BYTES = 12 * 1024;
61
+ /** First run on a session: only the last this-many events of the transcript. */
62
+ exports.RECORDER_BACKFILL_EVENTS = 400;
63
+ /** Chunks waiting to upload, per session; past it nothing more is read until some go out. */
64
+ exports.RECORDER_OUTBOX_MAX = 20;
65
+ /** Recipient slots on one chunk: the server's `deviceKeyMap` limit. */
66
+ exports.RECORDER_MAX_RECIPIENTS = 50;
67
+ /** Retry backoff for a failing upload: doubles from the base up to the cap. */
68
+ exports.RECORDER_BACKOFF_BASE_MS = 2_000;
69
+ exports.RECORDER_BACKOFF_MAX_MS = 5 * 60_000;
70
+ /** How long a session is left alone after the server refused it (a daily cap, a bad key). */
71
+ exports.RECORDER_PAUSE_MS = 60 * 60_000;
72
+ /** A reader's transcript a session has left is over once it has read as nothing new this long: a reader
73
+ * answers nothing also when it did not look (opencode asks its store at most once a second, and not
74
+ * again at once after a failed query), so one empty read says nothing. */
75
+ exports.RECORDER_READER_SETTLE_MS = 3_000;
76
+ /** A session no longer in the inventory is followed this long, so its last words still go out. */
77
+ exports.RECORDER_LINGER_MS = 60_000;
78
+ /** State files untouched this long, with nothing waiting, are deleted at start. */
79
+ exports.RECORDER_STATE_TTL_MS = 30 * 24 * 60 * 60_000;
80
+ /** Read-but-unsealed events held in memory; past this a read waits (the transcript keeps them). */
81
+ exports.RECORDER_PENDING_MAX_BYTES = 256 * 1024;
82
+ /** How long a tick waits to learn who a chunk is sealed for; past it nothing is read this round. */
83
+ exports.RECORDER_RECIPIENTS_TIMEOUT_MS = 10_000;
84
+ /** `seq` of a session with no state: seconds since the epoch × this (the server takes up to 10^12 - 1). */
85
+ exports.RECORDER_SEQ_PER_SECOND = 100;
86
+ const stateFileOf = (dir, sessionKey) => (0, node_path_1.join)(dir, `${(0, node_crypto_1.createHash)('sha256').update(sessionKey).digest('hex').slice(0, 24)}.json`);
87
+ const isOutboxChunk = (c) => {
88
+ if (!c || typeof c !== 'object')
89
+ return false;
90
+ const o = c;
91
+ const s = o.sealed;
92
+ return (Number.isSafeInteger(o.seq) && typeof o.firstAt === 'string' && typeof o.lastAt === 'string' &&
93
+ !!s && typeof s.body === 'string' && typeof s.senderPublicKey === 'string' &&
94
+ !!s.deviceKeyMap && typeof s.deviceKeyMap === 'object');
95
+ };
96
+ /** What is on disk, or null when it is missing or not ours (a bad file is a fresh start, never a crash). */
97
+ const readSessionState = (dir, sessionKey) => {
98
+ try {
99
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(stateFileOf(dir, sessionKey), 'utf-8'));
100
+ if (raw.v !== 1 || raw.sessionKey !== sessionKey)
101
+ return null;
102
+ if (typeof raw.path !== 'string' || !Number.isSafeInteger(raw.offset) || !Number.isSafeInteger(raw.seq))
103
+ return null;
104
+ const outbox = Array.isArray(raw.outbox) ? raw.outbox.filter(isOutboxChunk) : [];
105
+ return {
106
+ v: 1,
107
+ sessionKey,
108
+ path: raw.path,
109
+ ...(typeof raw.kind === 'string' ? { kind: raw.kind } : {}),
110
+ ino: typeof raw.ino === 'number' ? raw.ino : 0,
111
+ offset: raw.offset,
112
+ ...(raw.cursor !== undefined ? { cursor: raw.cursor } : {}),
113
+ seq: raw.seq,
114
+ ...(typeof raw.lastAt === 'string' ? { lastAt: raw.lastAt } : {}),
115
+ outbox,
116
+ };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ };
122
+ exports.readSessionState = readSessionState;
123
+ /** One atomic replace: a reader (or the next run) sees the old state or the new one, never half. */
124
+ const writeSessionState = (dir, state) => {
125
+ (0, private_files_js_1.mkdirHandedBack)(dir, 0o700);
126
+ (0, node_fs_1.chmodSync)(dir, 0o700);
127
+ const file = stateFileOf(dir, state.sessionKey);
128
+ const tmp = `${file}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
129
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
130
+ (0, private_files_js_1.handBackToSudoUser)(tmp);
131
+ (0, node_fs_1.renameSync)(tmp, file);
132
+ };
133
+ /**
134
+ * Delete state files nothing has touched for a long while, nothing is waiting in, and whose
135
+ * transcript is gone. A session that is quiet but alive keeps its state: without it the next
136
+ * read takes the last 400 events of the transcript again and uploads them under new `seq`s.
137
+ */
138
+ const sweepRecorderState = (dir, now = Date.now()) => {
139
+ let names;
140
+ try {
141
+ names = (0, node_fs_1.readdirSync)(dir);
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ for (const name of names) {
147
+ const file = (0, node_path_1.join)(dir, name);
148
+ try {
149
+ if (name.endsWith('.tmp')) {
150
+ if (now - (0, node_fs_1.statSync)(file).mtimeMs > 60_000)
151
+ (0, node_fs_1.rmSync)(file, { force: true });
152
+ continue;
153
+ }
154
+ if (now - (0, node_fs_1.statSync)(file).mtimeMs <= exports.RECORDER_STATE_TTL_MS)
155
+ continue;
156
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf-8'));
157
+ if (parsed.outbox?.length)
158
+ continue;
159
+ if (typeof parsed.path === 'string' && (0, node_fs_1.existsSync)(parsed.path))
160
+ continue;
161
+ (0, node_fs_1.rmSync)(file, { force: true });
162
+ }
163
+ catch { /* unreadable: leave it */ }
164
+ }
165
+ };
166
+ exports.sweepRecorderState = sweepRecorderState;
167
+ const TIMED_OUT = Symbol('timed out');
168
+ /** `promise`'s value, `TIMED_OUT` when it has not settled in `ms`, and `undefined` when it rejects. */
169
+ const withTimeout = (promise, ms) => new Promise((resolve) => {
170
+ const timer = setTimeout(() => resolve(TIMED_OUT), ms);
171
+ timer.unref?.();
172
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, () => { clearTimeout(timer); resolve(undefined); });
173
+ });
174
+ const eventAt = (events, pick, fallback) => {
175
+ const stamps = events.map((e) => e.at).filter((a) => typeof a === 'string').sort();
176
+ return (pick === 'first' ? stamps[0] : stamps[stamps.length - 1]) ?? fallback;
177
+ };
178
+ const eventBytes = (event) => Buffer.byteLength(JSON.stringify(event), 'utf-8') + 1;
179
+ const createTurnRecorder = (deps) => {
180
+ const now = deps.now ?? Date.now;
181
+ const sessions = new Map();
182
+ let timer;
183
+ let noRecipientsLogged = false;
184
+ let notReadyLogged = false;
185
+ let projectorWarned = false;
186
+ const sessionKeyOf = (name) => `${deps.deviceId()}:${name}`;
187
+ const loadState = (live) => {
188
+ if (live.stateLoaded)
189
+ return;
190
+ live.stateLoaded = true;
191
+ live.state = (0, exports.readSessionState)(deps.dir, live.sessionKey);
192
+ };
193
+ const persist = (live, state) => {
194
+ try {
195
+ writeSessionState(deps.dir, state);
196
+ live.state = state;
197
+ return true;
198
+ }
199
+ catch (err) {
200
+ deps.log(`⧉ turn-recorder ${live.name}: could not write state (${err instanceof Error ? err.message : err}) — keeping the events in memory`);
201
+ return false;
202
+ }
203
+ };
204
+ const statIno = (path) => {
205
+ try {
206
+ return (0, node_fs_1.statSync)(path).ino;
207
+ }
208
+ catch {
209
+ return 0;
210
+ } // unreadable: the first read says so
211
+ };
212
+ /** The kind that wrote `path`: the file a session has left was written by the kind its state names,
213
+ * which is not always the kind of the file it writes now. */
214
+ const kindOf = (live, path) => path !== live.source.path ? live.state?.kind ?? live.source.kind : live.source.kind;
215
+ const readerOf = (live, path) => deps.readerFor?.(kindOf(live, path)) ?? null;
216
+ const startTail = (live) => {
217
+ const state = live.state;
218
+ const target = live.source.path;
219
+ // The transcript changed name while this was not following (the listener was down):
220
+ // what the old file gained since the last chunk is read first, so it is not lost.
221
+ // A reader's path names no file; a session gone from its store reads as nothing, which ends it.
222
+ const finishOld = !!state && state.path !== target && !live.finished.has(state.path)
223
+ && (!!readerOf(live, state.path) || (0, node_fs_1.existsSync)(state.path));
224
+ const path = finishOld ? state.path : target;
225
+ // Another file is another session file: its list is folded from what it says.
226
+ if (live.tailPath && live.tailPath !== path)
227
+ live.todo = undefined;
228
+ live.tailPath = path;
229
+ // The same name can be another file (replaced, restored): its offset means nothing.
230
+ // A reader's path is no file, and its cursor says for itself whether it still fits what it reads.
231
+ if (state && state.path === path && (!!readerOf(live, path) || state.ino === statIno(path))) {
232
+ // Resume where the last chunk ended. `size`/`mtimeMs` of 0 make the
233
+ // first read look at the file whatever it holds.
234
+ live.tail = {
235
+ offset: state.offset, size: 0, mtimeMs: 0, ino: state.ino, carry: '', carryBytes: 0, resyncing: false, salvage: [],
236
+ ...(state.cursor !== undefined ? { cursor: state.cursor } : {}),
237
+ };
238
+ live.newFile = false;
239
+ }
240
+ else {
241
+ live.tail = null;
242
+ live.newFile = true;
243
+ // A file this session has not been recorded from may open with a copy of the history already sent.
244
+ live.dedupeAfter = state?.lastAt;
245
+ }
246
+ };
247
+ /** Turn what was read into chunks, seal them, and write them to the outbox together with the new offset. */
248
+ const flush = async (live, recipients) => {
249
+ if (!live.pending.length || !live.tail || !live.tailPath)
250
+ return;
251
+ // Inside a line too long to read the offset is the middle of it, and a restart would parse its
252
+ // tail as a record: resume from where that line began instead. The events read before it still go.
253
+ const committed = live.tail.resyncing
254
+ ? live.tail.resyncFrom
255
+ : live.tail.offset - (live.tail.carryBytes ?? Buffer.byteLength(live.tail.carry, 'utf-8'));
256
+ if (committed === undefined)
257
+ return;
258
+ const pages = (0, turn_watch_js_1.pagesOf)(live.pending);
259
+ const base = live.state ?? {
260
+ v: 1,
261
+ sessionKey: live.sessionKey,
262
+ path: live.tailPath,
263
+ ino: live.tail.ino,
264
+ offset: 0,
265
+ seq: Math.floor(now() / 1000) * exports.RECORDER_SEQ_PER_SECOND,
266
+ outbox: [],
267
+ };
268
+ const fallbackAt = new Date(now()).toISOString();
269
+ const chunks = [];
270
+ let seq = base.seq;
271
+ try {
272
+ for (const page of pages) {
273
+ seq += 1;
274
+ const firstAt = eventAt(page, 'first', fallbackAt);
275
+ const lastAt = eventAt(page, 'last', fallbackAt);
276
+ const sealed = await deps.seal(JSON.stringify({ sessionKey: live.sessionKey, seq, events: page }), recipients);
277
+ chunks.push({ seq, firstAt, lastAt, sealed });
278
+ }
279
+ }
280
+ catch (err) {
281
+ if (!live.sealWarned) {
282
+ live.sealWarned = true;
283
+ deps.log(`⧉ turn-recorder ${live.name}: could not seal (${err instanceof Error ? err.message : err}) — will retry`);
284
+ }
285
+ return;
286
+ }
287
+ live.sealWarned = false;
288
+ // From the events' own stamps: a chunk with none is stamped with the clock, which says nothing about the file.
289
+ const newestAt = live.pending.map((e) => e.at).filter((a) => typeof a === 'string').sort().pop();
290
+ const next = {
291
+ ...base,
292
+ path: live.tailPath,
293
+ kind: kindOf(live, live.tailPath),
294
+ ino: live.tail.ino,
295
+ offset: committed,
296
+ cursor: live.tail.cursor,
297
+ seq,
298
+ ...(newestAt && (!base.lastAt || newestAt > base.lastAt) ? { lastAt: newestAt } : base.lastAt ? { lastAt: base.lastAt } : {}),
299
+ outbox: [...base.outbox, ...chunks],
300
+ };
301
+ if (!persist(live, next))
302
+ return;
303
+ live.pending = [];
304
+ live.pendingBytes = 0;
305
+ live.hasPrompt = false;
306
+ };
307
+ /** The newest event time already sent or waiting: nothing at or before it is new. */
308
+ const newestSeen = (live) => {
309
+ let newest = live.state?.lastAt;
310
+ for (const e of live.pending)
311
+ if (e.at && (!newest || e.at > newest))
312
+ newest = e.at;
313
+ return newest;
314
+ };
315
+ /** One read of `live.tailPath` into `pending`. False when there was nothing to read. */
316
+ const readInto = (live, project) => {
317
+ const path = live.tailPath;
318
+ if (!path)
319
+ return false;
320
+ const before = live.tail;
321
+ const firstOfFile = live.newFile;
322
+ const reader = readerOf(live, path);
323
+ // A file this session has sent from is read from its start whenever its offset means nothing
324
+ // (a new first read, a replaced file, a truncated one): `dedupeAfter` drops what was sent. A window off
325
+ // the end would lose the head of a replacement longer than the window.
326
+ const read = reader ? reader(path, before) : (0, transcript_tail_js_1.readTranscriptDelta)(path, before, { fromStart: !!live.state });
327
+ if (!read)
328
+ return false;
329
+ // A new inode or a shorter file restarts the read from a window: what that window
330
+ // holds may already have been read. A reader's offset and size are not bytes, and it keeps its own place.
331
+ const restarted = !reader && !!before && (read.state.ino !== before.ino || read.state.size < before.offset);
332
+ // Set before anything can return: a restart that yields bytes but no complete line (a file truncated in
333
+ // place, its history then written again) still has to drop that history when it comes.
334
+ if (restarted)
335
+ live.dedupeAfter = newestSeen(live);
336
+ live.tail = read.state;
337
+ live.newFile = false;
338
+ // Nothing came of it — the file ends inside a multi-byte character, or only its mtime moved:
339
+ // that is no growth and no reason to read again at once, or the quiet flush never comes.
340
+ if (!read.bytesRead && !read.lines.length)
341
+ return false;
342
+ live.lastGrowthAt = now();
343
+ if (!read.lines.length)
344
+ return true;
345
+ let events = [];
346
+ try {
347
+ events = project(read.lines, { includeSidechain: false, todo: live.todo, onTodo: (list) => (live.todo = list) });
348
+ }
349
+ catch (err) {
350
+ // A line this version cannot read costs that read, never the recorder.
351
+ if (!projectorWarned) {
352
+ projectorWarned = true;
353
+ deps.log(`⧉ turn-recorder ${live.name}: could not read part of the transcript (${err instanceof Error ? err.message : err})`);
354
+ }
355
+ }
356
+ const after = live.dedupeAfter;
357
+ if (after) {
358
+ // Up to the first event newer than the last one sent it is a copy — an event with no time
359
+ // (a salvaged oversized tool result) is part of the copy too.
360
+ let fresh = false;
361
+ events = events.filter((e) => {
362
+ if (!fresh && e.at && e.at > after)
363
+ fresh = true;
364
+ return fresh;
365
+ });
366
+ if (fresh)
367
+ live.dedupeAfter = undefined;
368
+ }
369
+ if (firstOfFile && !live.state)
370
+ events = events.slice(-exports.RECORDER_BACKFILL_EVENTS);
371
+ for (const event of events) {
372
+ live.pending.push(event);
373
+ live.pendingBytes += eventBytes(event);
374
+ if (event.kind === 'prompt')
375
+ live.hasPrompt = true;
376
+ }
377
+ return true;
378
+ };
379
+ /** Read what the transcript gained, and flush when a rule says so. */
380
+ const produce = async (live, recipients) => {
381
+ if ((live.state?.outbox.length ?? 0) >= exports.RECORDER_OUTBOX_MAX)
382
+ return;
383
+ const target = live.source.path;
384
+ // Up to a few reads per tick, so a long old file is caught up in a few ticks, not one.
385
+ for (let reads = 0; reads < 8; reads++) {
386
+ if (!live.tail)
387
+ startTail(live);
388
+ const project = deps.projectorFor(kindOf(live, live.tailPath));
389
+ if (!project)
390
+ return;
391
+ if (live.pendingBytes >= exports.RECORDER_PENDING_MAX_BYTES)
392
+ break;
393
+ const more = readInto(live, project);
394
+ if (live.tailPath === target) {
395
+ live.leftSince = undefined;
396
+ break;
397
+ }
398
+ // Still on a file the session has left. Once it is read to its end, what it held goes
399
+ // out against its own offset, and the session moves on to the file it writes now.
400
+ if (more)
401
+ continue;
402
+ if (readerOf(live, live.tailPath)) {
403
+ live.leftSince ??= now();
404
+ if (now() - Math.max(live.leftSince, live.lastGrowthAt) < exports.RECORDER_READER_SETTLE_MS)
405
+ break;
406
+ }
407
+ // A line left unfinished at the end of a file that is no longer written is that file's end.
408
+ if (live.tail?.resyncing)
409
+ live.tail = { ...live.tail, resyncing: false, resyncFrom: undefined, carry: '', carryBytes: 0, salvage: [] };
410
+ await flush(live, recipients);
411
+ if (live.pending.length)
412
+ return;
413
+ live.finished.add(live.tailPath);
414
+ live.tail = null;
415
+ live.leftSince = undefined;
416
+ }
417
+ if (!live.pending.length)
418
+ return;
419
+ const quiet = now() - live.lastGrowthAt >= exports.RECORDER_QUIET_MS;
420
+ if (live.hasPrompt || live.pendingBytes >= exports.RECORDER_FLUSH_BYTES || quiet)
421
+ await flush(live, recipients);
422
+ };
423
+ /** The head chunk is done with (sent, or never to be taken): forget it in the state file. False when that could not be written. */
424
+ const removeHead = (live, state) => {
425
+ live.attempts = 0;
426
+ live.nextAttemptAt = 0;
427
+ live.refusals = null;
428
+ if (persist(live, { ...state, outbox: state.outbox.slice(1) }))
429
+ return true;
430
+ // Not recorded: do not try the next one in a tight loop.
431
+ live.nextAttemptAt = now() + exports.RECORDER_BACKOFF_BASE_MS;
432
+ return false;
433
+ };
434
+ /** After a 409 the numbering is out of step with the server: the next chunk goes after what it holds. */
435
+ const moveSeqPast = async (live) => {
436
+ const state = live.state;
437
+ if (!state)
438
+ return;
439
+ let latest = null;
440
+ try {
441
+ latest = (await deps.latestSeq?.(live.sessionKey)) ?? null;
442
+ }
443
+ catch { /* the clock below */ }
444
+ // Without an answer the clock steps over it: a state lost and rebuilt restarts there anyway.
445
+ const past = latest ?? Math.floor(now() / 1000) * exports.RECORDER_SEQ_PER_SECOND + exports.RECORDER_SEQ_PER_SECOND;
446
+ if (past > state.seq)
447
+ persist(live, { ...state, seq: past });
448
+ };
449
+ /** Upload from the front of the outbox until it is empty, a retry is due, or the server says stop. */
450
+ const drain = async (live) => {
451
+ for (;;) {
452
+ const state = live.state;
453
+ const head = state?.outbox[0];
454
+ if (!state || !head)
455
+ return;
456
+ if (now() < live.pausedUntil || now() < live.nextAttemptAt)
457
+ return;
458
+ if (deps.uploadReady && !deps.uploadReady()) {
459
+ if (!notReadyLogged) {
460
+ notReadyLogged = true;
461
+ deps.log('⧉ turn-recorder: this machine\'s device key is not registered yet — chunks wait until it is');
462
+ }
463
+ return;
464
+ }
465
+ notReadyLogged = false;
466
+ const key = deps.devicePublicKey?.();
467
+ if (key && head.sealed.senderPublicKey !== key) {
468
+ deps.log(`⧉ turn-recorder ${live.name}: dropped chunk ${head.seq} — it was sealed with a device key this machine no longer has`);
469
+ if (!removeHead(live, state))
470
+ return;
471
+ continue;
472
+ }
473
+ let outcome;
474
+ try {
475
+ outcome = await deps.upload(live.sessionKey, head);
476
+ }
477
+ catch (err) {
478
+ outcome = { kind: 'retry', why: err instanceof Error ? err.message : String(err) };
479
+ }
480
+ if (outcome.kind === 'ok') {
481
+ if (!removeHead(live, state))
482
+ return;
483
+ const day = new Date(now()).toISOString().slice(0, 10);
484
+ if (live.today.day !== day)
485
+ live.today = { day, count: 0 };
486
+ live.today.count += 1;
487
+ if (live.today.count % 100 === 0)
488
+ deps.log(`⧉ turn-recorder ${live.name}: ${live.today.count} chunks sent today (UTC)`);
489
+ continue;
490
+ }
491
+ if (outcome.kind === 'drop') {
492
+ deps.log(`⧉ turn-recorder ${live.name}: dropped chunk ${head.seq} — the server will not take it (${outcome.why})`);
493
+ if (!removeHead(live, state))
494
+ return;
495
+ if (outcome.conflict)
496
+ await moveSeqPast(live);
497
+ continue;
498
+ }
499
+ if (outcome.kind === 'pause') {
500
+ // Only a refusal that may be for ever counts, and only back to back.
501
+ const count = outcome.giveUpAfter ? (live.refusals?.seq === head.seq ? live.refusals.count + 1 : 1) : 0;
502
+ live.refusals = outcome.giveUpAfter ? { seq: head.seq, count } : null;
503
+ if (outcome.giveUpAfter && count >= outcome.giveUpAfter) {
504
+ deps.log(`⧉ turn-recorder ${live.name}: dropped chunk ${head.seq} — the server refused it ${count} times (${outcome.why})`);
505
+ if (!removeHead(live, state))
506
+ return;
507
+ continue;
508
+ }
509
+ live.pausedUntil = outcome.untilMs ?? now() + exports.RECORDER_PAUSE_MS;
510
+ deps.log(`⧉ turn-recorder ${live.name}: the server refused chunk ${head.seq} (${outcome.why}) — paused ${Math.round((live.pausedUntil - now()) / 60_000)} min`);
511
+ return;
512
+ }
513
+ live.refusals = null;
514
+ live.attempts += 1;
515
+ const wait = outcome.afterMs ?? Math.min(exports.RECORDER_BACKOFF_BASE_MS * 2 ** (live.attempts - 1), exports.RECORDER_BACKOFF_MAX_MS);
516
+ live.nextAttemptAt = now() + wait;
517
+ if (live.attempts === 1 || live.attempts % 10 === 0) {
518
+ deps.log(`⧉ turn-recorder ${live.name}: upload of chunk ${head.seq} failed (${outcome.why}) — retry in ${Math.round(wait / 1000)}s`);
519
+ }
520
+ return;
521
+ }
522
+ };
523
+ const step = async (live, recipients) => {
524
+ if (live.busy)
525
+ return;
526
+ live.busy = true;
527
+ try {
528
+ loadState(live);
529
+ await drain(live);
530
+ if (now() < live.pausedUntil)
531
+ return;
532
+ // Nobody could open a chunk, so nothing is read: the transcript is the
533
+ // record, and a device that pins later is sealed for from then on.
534
+ if (!recipients.length)
535
+ return;
536
+ await produce(live, recipients);
537
+ await drain(live);
538
+ }
539
+ catch (err) {
540
+ deps.log(`⧉ turn-recorder ${live.name}: ${err instanceof Error ? err.message : err}`);
541
+ }
542
+ finally {
543
+ live.busy = false;
544
+ }
545
+ };
546
+ const newLive = (name, source, at) => ({
547
+ name,
548
+ sessionKey: sessionKeyOf(name),
549
+ source,
550
+ lastSeenAt: at,
551
+ state: null,
552
+ stateLoaded: false,
553
+ tail: null,
554
+ tailPath: null,
555
+ finished: new Set(),
556
+ refusals: null,
557
+ today: { day: '', count: 0 },
558
+ pending: [],
559
+ pendingBytes: 0,
560
+ hasPrompt: false,
561
+ lastGrowthAt: at,
562
+ newFile: false,
563
+ busy: false,
564
+ attempts: 0,
565
+ nextAttemptAt: 0,
566
+ pausedUntil: 0,
567
+ sealWarned: false,
568
+ });
569
+ /** The sessions to follow now: whatever the latest inventory sweep could resolve a transcript for. */
570
+ const observe = (found) => {
571
+ const at = now();
572
+ for (const [name, source] of Object.entries(found)) {
573
+ const existing = sessions.get(name);
574
+ if (existing) {
575
+ existing.source = source;
576
+ existing.lastSeenAt = at;
577
+ continue;
578
+ }
579
+ sessions.set(name, newLive(name, source, at));
580
+ }
581
+ };
582
+ /**
583
+ * Sessions of an earlier run that may hold more than what went out: the listener restarted (a reboot
584
+ * ends every tmux session) and they are not in the inventory, so nothing would ever look at them
585
+ * again. They are followed like any session — waiting chunks go out, what their transcript gained
586
+ * since goes out too — and forgotten a minute after they are empty. Sessions with nothing
587
+ * waiting are taken up as well: the follow resumes at the stored offset, so an unchanged
588
+ * transcript costs one stat and no upload before the linger forgets it.
589
+ */
590
+ const adoptStored = () => {
591
+ let names;
592
+ try {
593
+ names = (0, node_fs_1.readdirSync)(deps.dir);
594
+ }
595
+ catch {
596
+ return;
597
+ }
598
+ const prefix = `${deps.deviceId()}:`;
599
+ for (const file of names) {
600
+ if (!file.endsWith('.json'))
601
+ continue;
602
+ let sessionKey;
603
+ try {
604
+ sessionKey = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(deps.dir, file), 'utf-8')).sessionKey;
605
+ }
606
+ catch {
607
+ continue;
608
+ }
609
+ // Another machine's key (the device id changed) could not be uploaded against this record anyway.
610
+ if (typeof sessionKey !== 'string' || !sessionKey.startsWith(prefix))
611
+ continue;
612
+ const name = sessionKey.slice(prefix.length);
613
+ if (!name || sessions.has(name))
614
+ continue;
615
+ const state = (0, exports.readSessionState)(deps.dir, sessionKey);
616
+ if (!state)
617
+ continue;
618
+ sessions.set(name, newLive(name, { path: state.path, kind: state.kind ?? '' }, now()));
619
+ }
620
+ };
621
+ /**
622
+ * Who a chunk is sealed for. One lookup at a time, shared by every round that asks while it
623
+ * is out: a lookup that hangs must not pile up a call a second, and gives up after a while.
624
+ * Rounds themselves still overlap, since a session's own `busy` keeps its steps apart and one
625
+ * slow upload must not hold the others.
626
+ */
627
+ let lookup = null;
628
+ let lookupTimeoutLogged = false;
629
+ const lookUpRecipients = () => {
630
+ lookup ??= withTimeout(deps.recipients(), exports.RECORDER_RECIPIENTS_TIMEOUT_MS).finally(() => { lookup = null; });
631
+ return lookup;
632
+ };
633
+ /** One round over every followed session; sessions run side by side, one step at a time each. */
634
+ const tick = async () => {
635
+ const at = now();
636
+ for (const [name, live] of sessions) {
637
+ const gone = at - live.lastSeenAt > exports.RECORDER_LINGER_MS;
638
+ if (gone && !live.busy && !live.pending.length && !(live.state?.outbox.length))
639
+ sessions.delete(name);
640
+ }
641
+ if (!sessions.size)
642
+ return;
643
+ let recipients = [];
644
+ try {
645
+ const found = await lookUpRecipients();
646
+ if (found === TIMED_OUT) {
647
+ if (!lookupTimeoutLogged) {
648
+ lookupTimeoutLogged = true;
649
+ deps.log(`⧉ turn-recorder: could not learn who to seal for within ${exports.RECORDER_RECIPIENTS_TIMEOUT_MS / 1000} s — nothing is read until it answers`);
650
+ }
651
+ }
652
+ else {
653
+ lookupTimeoutLogged = false;
654
+ recipients = found ?? [];
655
+ }
656
+ }
657
+ catch { /* none: nothing is read */ }
658
+ if (!recipients.length && !noRecipientsLogged) {
659
+ noRecipientsLogged = true;
660
+ deps.log('⧉ turn-recorder: no device to seal the conversation for yet — waiting for the first one this machine trusts');
661
+ }
662
+ if (recipients.length)
663
+ noRecipientsLogged = false;
664
+ await Promise.all([...sessions.values()].map((live) => step(live, recipients)));
665
+ };
666
+ const start = () => {
667
+ (0, exports.sweepRecorderState)(deps.dir, now());
668
+ adoptStored();
669
+ if (timer)
670
+ return;
671
+ timer = setInterval(() => { void tick(); }, exports.RECORDER_TICK_MS);
672
+ // Never what keeps the daemon alive.
673
+ timer.unref?.();
674
+ };
675
+ const stop = () => {
676
+ if (timer)
677
+ clearInterval(timer);
678
+ timer = undefined;
679
+ };
680
+ return { observe, tick, start, stop, size: () => sessions.size };
681
+ };
682
+ exports.createTurnRecorder = createTurnRecorder;
683
+ /** The recipients of a chunk: this machine, then the newest pinned devices, within the server's slot limit. */
684
+ const chooseRecipients = (self, pinned) => {
685
+ // Nobody but this machine = nobody who could read it on a phone.
686
+ if (!pinned.length)
687
+ return [];
688
+ const others = [...pinned]
689
+ .filter((d) => d.deviceId !== self?.deviceId)
690
+ .sort((a, b) => (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? ''))
691
+ .slice(0, exports.RECORDER_MAX_RECIPIENTS - (self ? 1 : 0))
692
+ .map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
693
+ return self ? [self, ...others] : others;
694
+ };
695
+ exports.chooseRecipients = chooseRecipients;