@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,93 @@
1
+ import type { TurnEvent } from './transcript-tail.js';
2
+ /**
3
+ * What the daemon has already sent a chat viewer, kept on disk so a
4
+ * re-subscribe can replay it.
5
+ *
6
+ * Why this exists at all: the live lane is ephemeral, and the transcript
7
+ * backfill deliberately replays only since the last prompt — finished turns are
8
+ * supposed to already exist in the chat as their completion pushes. Under the
9
+ * `quiet` push dial they do not exist, because that dial suppresses every
10
+ * auto-push without a `high` marker. A turn's closing words were therefore
11
+ * visible while it ran and gone the moment the viewer re-armed, with no record
12
+ * on the phone, on the server, or in the 256KB backfill window.
13
+ *
14
+ * Re-deriving those turns from the transcript is not an option: measured over
15
+ * this machine's transcripts larger than the window, the last 256KB holds a
16
+ * median of one human prompt and none at all in 8 of 19 files — a single
17
+ * `tool_result` line can be most of a megabyte. Projected events are a tool
18
+ * name, a short label and a verdict, so the same bytes hold turns rather than
19
+ * fragments of one.
20
+ *
21
+ * It lives beside `known-sessions.json` under `stateDir()` rather than in
22
+ * `~/.hwip`, for the same reason that file does: this is derived session state
23
+ * that expires, not configuration or keys.
24
+ *
25
+ * Everything here is best-effort. A ring that cannot be written costs the
26
+ * scrollback, never the live send — the daemon's job is to keep talking.
27
+ */
28
+ /**
29
+ * One ring file. Trimming is by bytes and not by event count on purpose: a
30
+ * prose event carries up to `MAX_EVENT_TEXT_CHARS` (5000) while a tool event's
31
+ * label is clamped at `MAX_EVENT_FIELD_CHARS` (512), so a count would bound two
32
+ * things an order of magnitude apart.
33
+ */
34
+ export declare const MAX_TURN_RING_BYTES: number;
35
+ /** Sessions with a ring. Past this the oldest by mtime are dropped. */
36
+ export declare const MAX_TURN_RING_FILES = 20;
37
+ /**
38
+ * How long anything stays on disk: a day, for the ring and for each event in it.
39
+ *
40
+ * The ring holds prompts and replies in the clear (the live lane is sealed, this
41
+ * is not), so the retention window is the exposure window. What it has to
42
+ * cover is a viewer re-arming after a quiet stretch — overnight at the longest;
43
+ * the transcript backfill carries the current turn from there. A week kept
44
+ * seven times the plaintext for a need nobody measured past that.
45
+ */
46
+ export declare const TURN_RING_TTL_MS: number;
47
+ export declare const turnRingDir: () => string;
48
+ /**
49
+ * The session name is chosen by whoever sent the watch request, so it never
50
+ * reaches the filesystem — a hash does. Same move as `hashListenerId`, at twice
51
+ * the width: 64 bits, because these names collide within one machine's
52
+ * directory rather than identifying one device.
53
+ */
54
+ export declare const turnRingPath: (sessionName: string) => string;
55
+ /**
56
+ * Record events this watch just sent. Call it with the same array that went out
57
+ * — the ring's whole meaning is "what the viewer already received", and a replay
58
+ * is that array sent again.
59
+ */
60
+ export declare const appendTurnRing: (sessionName: string, events: readonly TurnEvent[]) => boolean;
61
+ /**
62
+ * What this session's viewers have already been sent, oldest first.
63
+ *
64
+ * An expired ring is deleted here rather than merely ignored: returning an empty
65
+ * array and leaving the file would let the next append land on top of pre-expiry
66
+ * content, and the TTL would bound nothing.
67
+ */
68
+ export declare const readTurnRing: (sessionName: string, now?: number) => TurnEvent[];
69
+ /** Drop expired rings, then expired events, then the oldest rings past `MAX_TURN_RING_FILES`. */
70
+ export declare const sweepTurnRings: (now?: number) => void;
71
+ /**
72
+ * Forget one session's scrollback outright — the machine was told to forget the
73
+ * session. False when the file is still there, which the caller has to say out
74
+ * loud: answering "forgotten" while a day of prompts and tool targets sits on
75
+ * disk is the one wrong answer here.
76
+ */
77
+ export declare const removeTurnRing: (sessionName: string) => boolean;
78
+ /**
79
+ * The three operations a watcher needs, as one injectable surface.
80
+ *
81
+ * The watcher takes this rather than importing the module so its tests can hold
82
+ * the ring in memory — what the disk does is settled here, and re-proving it
83
+ * through a socket-and-transcript harness would only make those tests slower and
84
+ * no more truthful.
85
+ */
86
+ export interface TurnRing {
87
+ read: (sessionName: string) => TurnEvent[];
88
+ /** False when nothing was written — a full disk, a directory that is not ours. */
89
+ append: (sessionName: string, events: readonly TurnEvent[]) => boolean;
90
+ sweep: () => void;
91
+ }
92
+ export declare const diskTurnRing: TurnRing;
93
+ //# sourceMappingURL=turn-ring.d.ts.map
@@ -0,0 +1,418 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diskTurnRing = exports.removeTurnRing = exports.sweepTurnRings = exports.readTurnRing = exports.appendTurnRing = exports.turnRingPath = exports.turnRingDir = exports.TURN_RING_TTL_MS = exports.MAX_TURN_RING_FILES = exports.MAX_TURN_RING_BYTES = void 0;
4
+ const fs_1 = require("fs");
5
+ const crypto_1 = require("crypto");
6
+ const path_1 = require("path");
7
+ const gate_js_1 = require("./gate.js");
8
+ const private_files_js_1 = require("./private-files.js");
9
+ /**
10
+ * What the daemon has already sent a chat viewer, kept on disk so a
11
+ * re-subscribe can replay it.
12
+ *
13
+ * Why this exists at all: the live lane is ephemeral, and the transcript
14
+ * backfill deliberately replays only since the last prompt — finished turns are
15
+ * supposed to already exist in the chat as their completion pushes. Under the
16
+ * `quiet` push dial they do not exist, because that dial suppresses every
17
+ * auto-push without a `high` marker. A turn's closing words were therefore
18
+ * visible while it ran and gone the moment the viewer re-armed, with no record
19
+ * on the phone, on the server, or in the 256KB backfill window.
20
+ *
21
+ * Re-deriving those turns from the transcript is not an option: measured over
22
+ * this machine's transcripts larger than the window, the last 256KB holds a
23
+ * median of one human prompt and none at all in 8 of 19 files — a single
24
+ * `tool_result` line can be most of a megabyte. Projected events are a tool
25
+ * name, a short label and a verdict, so the same bytes hold turns rather than
26
+ * fragments of one.
27
+ *
28
+ * It lives beside `known-sessions.json` under `stateDir()` rather than in
29
+ * `~/.hwip`, for the same reason that file does: this is derived session state
30
+ * that expires, not configuration or keys.
31
+ *
32
+ * Everything here is best-effort. A ring that cannot be written costs the
33
+ * scrollback, never the live send — the daemon's job is to keep talking.
34
+ */
35
+ /**
36
+ * One ring file. Trimming is by bytes and not by event count on purpose: a
37
+ * prose event carries up to `MAX_EVENT_TEXT_CHARS` (5000) while a tool event's
38
+ * label is clamped at `MAX_EVENT_FIELD_CHARS` (512), so a count would bound two
39
+ * things an order of magnitude apart.
40
+ */
41
+ exports.MAX_TURN_RING_BYTES = 256 * 1024;
42
+ /** Sessions with a ring. Past this the oldest by mtime are dropped. */
43
+ exports.MAX_TURN_RING_FILES = 20;
44
+ /**
45
+ * How long anything stays on disk: a day, for the ring and for each event in it.
46
+ *
47
+ * The ring holds prompts and replies in the clear (the live lane is sealed, this
48
+ * is not), so the retention window is the exposure window. What it has to
49
+ * cover is a viewer re-arming after a quiet stretch — overnight at the longest;
50
+ * the transcript backfill carries the current turn from there. A week kept
51
+ * seven times the plaintext for a need nobody measured past that.
52
+ */
53
+ exports.TURN_RING_TTL_MS = 24 * 60 * 60 * 1000;
54
+ const turnRingDir = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'turns');
55
+ exports.turnRingDir = turnRingDir;
56
+ /**
57
+ * The session name is chosen by whoever sent the watch request, so it never
58
+ * reaches the filesystem — a hash does. Same move as `hashListenerId`, at twice
59
+ * the width: 64 bits, because these names collide within one machine's
60
+ * directory rather than identifying one device.
61
+ */
62
+ const turnRingPath = (sessionName) => (0, path_1.join)((0, exports.turnRingDir)(), `${(0, crypto_1.createHash)('sha256').update(sessionName).digest('hex').slice(0, 16)}.jsonl`);
63
+ exports.turnRingPath = turnRingPath;
64
+ const sizeOf = (path) => {
65
+ try {
66
+ return (0, fs_1.statSync)(path).size;
67
+ }
68
+ catch {
69
+ return 0;
70
+ }
71
+ };
72
+ /**
73
+ * Whether an event is still inside the TTL by its own `at`. An event with no
74
+ * stamp (or one that does not parse) is kept: its age is unknown, and the file's
75
+ * mtime already bounds the ring as a whole.
76
+ */
77
+ const eventInDate = (event, now) => {
78
+ const at = typeof event.at === 'string' ? Date.parse(event.at) : NaN;
79
+ return !Number.isFinite(at) || now - at <= exports.TURN_RING_TTL_MS;
80
+ };
81
+ const isExpired = (path, now) => {
82
+ try {
83
+ return now - (0, fs_1.statSync)(path).mtimeMs > exports.TURN_RING_TTL_MS;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ /**
90
+ * Paths whose owner-only mode this process has already set. `mode` on
91
+ * mkdir/append applies only when the entry is created, so a directory or file
92
+ * that already existed keeps whatever it had — a pre-created 0755 `turns/` would
93
+ * never become 0700 on its own. One chmod on first touch fixes that; every later
94
+ * append writes to a file this process has already made private.
95
+ */
96
+ const secured = new Set();
97
+ /**
98
+ * The size each ring is known to have, kept so a tick does not stat the file.
99
+ * It starts from a real stat, grows by what this process appends, and is dropped
100
+ * whenever a file is removed, trimmed, rewritten or fails to append, so the next
101
+ * append reads the truth again. It counts only this process's bytes: a second
102
+ * writer on the same file would not be seen. The listener is the only writer
103
+ * today, and `forget` only deletes — which makes the estimate too high, so a
104
+ * trim comes early and nothing breaks.
105
+ *
106
+ * `secured` below is likewise per process: a mode loosened by the same user
107
+ * while the daemon runs is not re-tightened until it restarts.
108
+ */
109
+ const sizes = new Map();
110
+ const removeQuietly = (path) => {
111
+ sizes.delete(path);
112
+ try {
113
+ (0, fs_1.unlinkSync)(path);
114
+ }
115
+ catch {
116
+ /* already gone, or not ours to delete */
117
+ }
118
+ };
119
+ /**
120
+ * How far under the cap a trim cuts.
121
+ *
122
+ * Trimming exactly to the cap would leave the file one event below it, so the
123
+ * next append crosses again and rewrites the whole thing — a full read, split
124
+ * and rename per event for the rest of the session. Cutting deeper buys roughly
125
+ * a quarter of the ring's worth of appends between rewrites.
126
+ */
127
+ const TRIM_TARGET_BYTES = Math.floor(exports.MAX_TURN_RING_BYTES * 0.75);
128
+ /** Keep the newest lines that fit under `MAX_TURN_RING_BYTES`. False when the cap still stands broken. */
129
+ const trimToCap = (path) => {
130
+ let raw;
131
+ try {
132
+ if ((0, fs_1.statSync)(path).size <= exports.MAX_TURN_RING_BYTES)
133
+ return true;
134
+ raw = (0, fs_1.readFileSync)(path, 'utf-8');
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ const lines = raw.split('\n').filter((line) => line.length > 0);
140
+ let bytes = 0;
141
+ let start = lines.length;
142
+ // Newest first, until the next line would not fit.
143
+ while (start > 0) {
144
+ const size = Buffer.byteLength(lines[start - 1], 'utf-8') + 1;
145
+ if (bytes + size > TRIM_TARGET_BYTES)
146
+ break;
147
+ bytes += size;
148
+ start--;
149
+ }
150
+ // A single line longer than the whole cap takes nothing, which would empty
151
+ // the ring — and empty it again on every later append. The newest line
152
+ // always survives; the one after it trims this one away in turn.
153
+ start = Math.min(start, lines.length - 1);
154
+ const kept = lines
155
+ .slice(start)
156
+ .map((line) => `${line}\n`)
157
+ .join('');
158
+ // Through a temp file, unlike the append: a kill mid-append costs the last
159
+ // line, a kill mid-rewrite would cost the whole ring.
160
+ // Random name, exclusive create: a predictable `${path}.tmp` is a file
161
+ // another process on this machine can pre-create as a symlink and have this
162
+ // write follow. `wx` fails instead of following one.
163
+ const tmp = `${path}.${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`;
164
+ try {
165
+ (0, fs_1.writeFileSync)(tmp, kept, { mode: 0o600, flag: 'wx' });
166
+ (0, private_files_js_1.handBackToSudoUser)(tmp);
167
+ (0, fs_1.renameSync)(tmp, path);
168
+ return true;
169
+ }
170
+ catch {
171
+ removeQuietly(tmp);
172
+ // The append landed but the cap did not. Saying the write succeeded
173
+ // would let the file grow past its bound with nothing ever noticing.
174
+ return false;
175
+ }
176
+ };
177
+ /**
178
+ * Record events this watch just sent. Call it with the same array that went out
179
+ * — the ring's whole meaning is "what the viewer already received", and a replay
180
+ * is that array sent again.
181
+ */
182
+ const appendTurnRing = (sessionName, events) => {
183
+ if (!events.length)
184
+ return true;
185
+ // Empty until the name hashes: a catch before that has no ring to forget.
186
+ let path = '';
187
+ try {
188
+ // Inside the guard: the name arrives off the wire, and a non-string one
189
+ // makes `createHash().update()` throw where "best-effort" is the whole
190
+ // contract — a ring that cannot be written must never take a tick down.
191
+ path = (0, exports.turnRingPath)(sessionName);
192
+ const data = events.map((event) => `${JSON.stringify(event)}\n`).join('');
193
+ const dir = (0, exports.turnRingDir)();
194
+ const firstTouch = !secured.has(path);
195
+ if (firstTouch) {
196
+ (0, private_files_js_1.mkdirHandedBack)(dir, 0o700);
197
+ (0, fs_1.chmodSync)(dir, 0o700);
198
+ }
199
+ // Read before the append so a first write counts the file as it was.
200
+ const known = sizes.get(path) ?? sizeOf(path);
201
+ try {
202
+ (0, fs_1.appendFileSync)(path, data, { mode: 0o600 });
203
+ }
204
+ catch (err) {
205
+ // The directory was removed under a long-running daemon (a manual
206
+ // cleanup, a forget that took the folder). Make it again and retry
207
+ // once — a ring that stayed dead for the rest of the process would
208
+ // lose every later turn.
209
+ if (err?.code !== 'ENOENT')
210
+ throw err;
211
+ (0, private_files_js_1.mkdirHandedBack)(dir, 0o700);
212
+ (0, fs_1.chmodSync)(dir, 0o700);
213
+ (0, fs_1.appendFileSync)(path, data, { mode: 0o600 });
214
+ }
215
+ if (firstTouch) {
216
+ (0, fs_1.chmodSync)(path, 0o600);
217
+ (0, private_files_js_1.handBackToSudoUser)(path);
218
+ secured.add(path);
219
+ }
220
+ const size = known + Buffer.byteLength(data, 'utf-8');
221
+ if (size <= exports.MAX_TURN_RING_BYTES) {
222
+ sizes.set(path, size);
223
+ return true;
224
+ }
225
+ }
226
+ catch {
227
+ // A failed append can still have landed some bytes, so the tracked size
228
+ // is no longer known.
229
+ sizes.delete(path);
230
+ // The caller says so once per session rather than per tick: a full disk
231
+ // costs the scrollback, and silence about it costs the diagnosis.
232
+ return false;
233
+ }
234
+ // Over the cap by the tracked size: let the trim read the real one.
235
+ sizes.delete(path);
236
+ return trimToCap(path);
237
+ };
238
+ exports.appendTurnRing = appendTurnRing;
239
+ /**
240
+ * What this session's viewers have already been sent, oldest first.
241
+ *
242
+ * An expired ring is deleted here rather than merely ignored: returning an empty
243
+ * array and leaving the file would let the next append land on top of pre-expiry
244
+ * content, and the TTL would bound nothing.
245
+ */
246
+ const readTurnRing = (sessionName, now = Date.now()) => {
247
+ let raw;
248
+ let path;
249
+ try {
250
+ // Same reason as the append: the name is wire input, and hashing a
251
+ // non-string throws.
252
+ path = (0, exports.turnRingPath)(sessionName);
253
+ if (isExpired(path, now)) {
254
+ removeQuietly(path);
255
+ return [];
256
+ }
257
+ raw = (0, fs_1.readFileSync)(path, 'utf-8');
258
+ }
259
+ catch {
260
+ return [];
261
+ }
262
+ const events = [];
263
+ for (const line of raw.split('\n')) {
264
+ if (!line)
265
+ continue;
266
+ try {
267
+ const parsed = JSON.parse(line);
268
+ // `null`, `5`, `[]` and `{}` are all valid JSON. Without a shape
269
+ // check a hand-edited or planted ring hands slice 02 a non-event to
270
+ // seal and send. `kind` is what every branch downstream switches on,
271
+ // so that is what makes a line an event — the same row-level filter
272
+ // `session-registry.ts:52-62` applies to its own file.
273
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
274
+ continue;
275
+ if (typeof parsed.kind !== 'string')
276
+ continue;
277
+ // The file's mtime moves on every append, so a ring that is still
278
+ // being written never expires as a whole — its old events do.
279
+ if (!eventInDate(parsed, now))
280
+ continue;
281
+ events.push(parsed);
282
+ }
283
+ catch {
284
+ // A line the daemon was killed halfway through: skip it, keep the
285
+ // rest — again the transcript reader's rule.
286
+ }
287
+ }
288
+ return events;
289
+ };
290
+ exports.readTurnRing = readTurnRing;
291
+ /**
292
+ * Take events past the TTL off the disk, and with them any line `readTurnRing`
293
+ * would skip anyway (a torn write, a non-event). Every line is judged rather
294
+ * than a leading run: one line that cannot be judged at the front of the file
295
+ * would otherwise stop the prune for the rest of that ring's life, and the
296
+ * file's mtime — moved by every append — never expires it either. Reads and
297
+ * parses the whole ring, at most `MAX_TURN_RING_BYTES` of it. False when nothing
298
+ * is left of the ring — the file is gone.
299
+ */
300
+ const pruneExpiredEvents = (path, now) => {
301
+ let raw;
302
+ try {
303
+ raw = (0, fs_1.readFileSync)(path, 'utf-8');
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ const lines = raw.split('\n').filter((line) => line.length > 0);
309
+ const kept = lines.filter((line) => {
310
+ try {
311
+ const event = JSON.parse(line);
312
+ return (!!event &&
313
+ typeof event === 'object' &&
314
+ !Array.isArray(event) &&
315
+ typeof event.kind === 'string' &&
316
+ eventInDate(event, now));
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ });
322
+ if (kept.length === lines.length)
323
+ return true;
324
+ if (kept.length === 0) {
325
+ removeQuietly(path);
326
+ return false;
327
+ }
328
+ // Same temp-and-rename as the trim, for the same reason: a kill mid-rewrite
329
+ // must not cost the whole ring.
330
+ const tmp = `${path}.${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`;
331
+ try {
332
+ (0, fs_1.writeFileSync)(tmp, kept.map((line) => `${line}\n`).join(''), { mode: 0o600, flag: 'wx' });
333
+ (0, private_files_js_1.handBackToSudoUser)(tmp);
334
+ (0, fs_1.renameSync)(tmp, path);
335
+ sizes.delete(path);
336
+ return true;
337
+ }
338
+ catch {
339
+ // Not rewritten, so what was there is still there — and still counts.
340
+ removeQuietly(tmp);
341
+ return true;
342
+ }
343
+ };
344
+ /** Drop expired rings, then expired events, then the oldest rings past `MAX_TURN_RING_FILES`. */
345
+ const sweepTurnRings = (now = Date.now()) => {
346
+ const dir = (0, exports.turnRingDir)();
347
+ let names;
348
+ try {
349
+ names = (0, fs_1.readdirSync)(dir);
350
+ }
351
+ catch {
352
+ return;
353
+ }
354
+ const live = [];
355
+ for (const name of names) {
356
+ const path = (0, path_1.join)(dir, name);
357
+ // A kill between the trim's write and its rename strands a temp file.
358
+ // Nothing else would ever remove it, and left in place it counts
359
+ // against MAX_TURN_RING_FILES and evicts a live ring early. Matching the
360
+ // ring's own name shape rather than just the extension also means a
361
+ // stray file dropped in here is cleaned up rather than counted.
362
+ if (!/^[0-9a-f]{16}\.jsonl$/.test(name)) {
363
+ removeQuietly(path);
364
+ continue;
365
+ }
366
+ let mtimeMs;
367
+ try {
368
+ mtimeMs = (0, fs_1.statSync)(path).mtimeMs;
369
+ }
370
+ catch {
371
+ continue;
372
+ }
373
+ if (now - mtimeMs > exports.TURN_RING_TTL_MS) {
374
+ removeQuietly(path);
375
+ continue;
376
+ }
377
+ if (pruneExpiredEvents(path, now))
378
+ live.push({ path, mtimeMs });
379
+ }
380
+ if (live.length <= exports.MAX_TURN_RING_FILES)
381
+ return;
382
+ live.sort((a, b) => a.mtimeMs - b.mtimeMs);
383
+ for (const stale of live.slice(0, live.length - exports.MAX_TURN_RING_FILES))
384
+ removeQuietly(stale.path);
385
+ };
386
+ exports.sweepTurnRings = sweepTurnRings;
387
+ /**
388
+ * Forget one session's scrollback outright — the machine was told to forget the
389
+ * session. False when the file is still there, which the caller has to say out
390
+ * loud: answering "forgotten" while a day of prompts and tool targets sits on
391
+ * disk is the one wrong answer here.
392
+ */
393
+ const removeTurnRing = (sessionName) => {
394
+ let path;
395
+ try {
396
+ path = (0, exports.turnRingPath)(sessionName);
397
+ }
398
+ catch {
399
+ return true; // a name that cannot even be hashed has no ring
400
+ }
401
+ sizes.delete(path);
402
+ try {
403
+ (0, fs_1.unlinkSync)(path);
404
+ return true;
405
+ }
406
+ catch (err) {
407
+ // Already gone is the outcome the caller asked for. Checking existence
408
+ // first and then unlinking would call a file that vanished in between a
409
+ // failure, and answer "your scrollback is still there" about nothing.
410
+ return err?.code === 'ENOENT';
411
+ }
412
+ };
413
+ exports.removeTurnRing = removeTurnRing;
414
+ exports.diskTurnRing = {
415
+ read: (sessionName) => (0, exports.readTurnRing)(sessionName),
416
+ append: (sessionName, events) => (0, exports.appendTurnRing)(sessionName, events),
417
+ sweep: () => (0, exports.sweepTurnRings)(),
418
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Live agent-chat timeline: tail this machine's Claude Code transcripts and push
3
+ * the small events off them over the relay the daemon already holds open.
4
+ *
5
+ * Sibling of the tmux mirror in `listener.ts`, deliberately not the same thing.
6
+ * The mirror sends a picture of a terminal; this sends what the agent *did*, so
7
+ * a phone can read a session the way the Claude app reads one — without the
8
+ * viewer having to parse ANSI, and without any of it becoming a push (no quota,
9
+ * no notification).
10
+ *
11
+ * Registry, lifecycle and framing mirror `activeStreams`/`stopStream`, because
12
+ * a second shape for the same job is a second set of leaks to find. What differs
13
+ * is the source: a file that grows, not a pane that repaints. `transcript-tail`
14
+ * owns that difference and stays pure; this file owns the socket and the clock.
15
+ *
16
+ * Every dependency that touches the world arrives through `TurnWatchDeps`, so
17
+ * the tests drive real ticks with a fake transcript and no WebSocket at all.
18
+ */
19
+ import { type TranscriptSource, type TurnEvent } from './transcript-tail.js';
20
+ import type { TurnRing } from './turn-ring.js';
21
+ /**
22
+ * Watchers this daemon will run at once — the same ceiling, for the same reason,
23
+ * as `MAX_CONCURRENT_STREAMS`. One machine can host a dozen tmux sessions, and
24
+ * a viewer that opened them all would otherwise have every one polling forever.
25
+ */
26
+ export declare const MAX_TURN_WATCHERS = 3;
27
+ /**
28
+ * Poll cadence. The plan's user-visible bar is "a tool call shows up within 2s";
29
+ * at 500 ms the read itself is never the reason it misses. Slower than the
30
+ * mirror's 400 ms on purpose — this loop answers "what happened", not "what does
31
+ * the screen look like", and nothing here is animated.
32
+ */
33
+ export declare const TURN_POLL_INTERVAL_MS = 500;
34
+ /**
35
+ * How long a watch survives without a renew.
36
+ *
37
+ * `watch.stop` is best-effort by construction: a swiped-away native sheet, a
38
+ * killed tab, or a dropped socket destroys the viewer before it can say
39
+ * anything. The lease — not the stop message — is what guarantees this daemon
40
+ * stops reading. Matches the terminal stream's `STREAM_SUB_TTL_SECONDS`.
41
+ */
42
+ export declare const TURN_LEASE_MS = 60000;
43
+ /**
44
+ * How often a live watch re-asks which transcript its session is writing.
45
+ *
46
+ * `/clear` and a compaction start a new session file under a new name; the old
47
+ * one simply stops growing, so a watcher pinned to the path it resolved at
48
+ * `start` goes quiet forever and looks exactly like an idle agent. Nothing in
49
+ * the file itself can signal this — the answer lives in the session registry —
50
+ * so it is asked for on a cadence rather than discovered.
51
+ *
52
+ * This is not a cheap question, and the number is chosen against its real cost:
53
+ * `resolveTranscript` runs `readPaneInfo`, an uncached blocking
54
+ * `spawnSync('tmux', …)` (`listener.ts`), and the pid-record memo behind it
55
+ * expires every 4s (`remote-agents.ts` SNAPSHOT_TTL_MS), so a recheck is one
56
+ * tmux spawn plus a real directory read — not a memo hit. At 10s per watcher and
57
+ * at most three watchers that stays under what the session report already spends
58
+ * on its own (`SESSION_REPORT_INTERVAL_MS` = 5s, one sweep for the whole
59
+ * machine), while keeping how long a cleared session stays dark to one interval.
60
+ */
61
+ export declare const TRANSCRIPT_RECHECK_MS = 10000;
62
+ /**
63
+ * Consecutive seal failures before the watch gives up and says so.
64
+ *
65
+ * Dropping a batch that will not seal is right; dropping every batch forever is
66
+ * an empty timeline the viewer cannot tell from an idle session. The mirror
67
+ * draws the same line with `STREAM_MAX_ENCRYPT_FAILURES`: fail closed, then stop
68
+ * and send an error the other side can render.
69
+ *
70
+ * A live tick stops at its first unsealable page (see `readAndEmit`), so one
71
+ * busy tick spends one failure; a replay's pages each spend one, as they did
72
+ * before live ticks were paged.
73
+ */
74
+ export declare const MAX_TURN_SEAL_FAILURES = 3;
75
+ /**
76
+ * Ceiling on one replay, in events.
77
+ *
78
+ * The viewer keeps a bounded window of live turns, and a replay that filled it
79
+ * on its own would push the turn actually in flight out of the very screen the
80
+ * replay exists to fill. Below the web cap on purpose, so the live tail still
81
+ * has room after a full replay.
82
+ */
83
+ export declare const MAX_REPLAY_EVENTS = 400;
84
+ /**
85
+ * Plaintext bytes per frame. Ephemeral frames ride API Gateway's 32KB WebSocket
86
+ * limit, and sealing base64-expands what goes in it — so this is deliberately
87
+ * below `SCREEN_PEEK_MAX_BYTES` (24KB, `listener.ts`), which bounds frames that
88
+ * are never sealed.
89
+ */
90
+ export declare const MAX_TURN_FRAME_BYTES: number;
91
+ /** Wire shape of one batch of timeline events. */
92
+ export type TurnDeltaFrame = {
93
+ subtype: 'agent.turn.delta';
94
+ sessionName: string;
95
+ /** Incarnation of this watch. See `AgentTurnDeltaFrame` (hwip `libs/feed-ui`)
96
+ * for why the pair exists; the mirror stamps frames the same way
97
+ * (`StreamFramePayload.epoch`). */
98
+ epoch: number;
99
+ seq: number;
100
+ /** Plaintext batch — present only when the subscriber supplied no key. */
101
+ events?: TurnEvent[];
102
+ /** Sealed batch — the same array, encrypted for the subscriber. */
103
+ encrypted?: unknown;
104
+ };
105
+ /** What a viewer sends to start, keep, or end a watch. */
106
+ export interface TurnWatchControl {
107
+ subtype?: string;
108
+ targetDeviceId?: string;
109
+ sessionName?: string;
110
+ /**
111
+ * The viewer's device public key. Present when that device can open what it
112
+ * asks to be sealed; absent on a device with no keypair, which then reads
113
+ * the same events in the clear — the convention `useAgentDiff` and
114
+ * `TerminalStreamView` already follow on the web side. Encryption is a paid
115
+ * feature, so refusing to serve an unsealed viewer would quietly make the
116
+ * whole timeline paid too.
117
+ */
118
+ subscriberPublicKey?: string;
119
+ }
120
+ export type SendTurnFrame = (data: Record<string, unknown>) => void;
121
+ export interface TurnWatchDeps {
122
+ /** This machine's listener device id — control messages that name another are not ours. */
123
+ deviceId: () => string;
124
+ /**
125
+ * tmux session name → that session's transcript and the projector that reads
126
+ * it, or null when this agent has no transcript this daemon knows how to
127
+ * follow. Path and projector arrive together because they are one answer;
128
+ * see `TranscriptSource`.
129
+ *
130
+ * `onMiss` is the reason channel for the null. The resolver is the only side
131
+ * that knows *why* — whether the row carries no resolver at all, or carries
132
+ * one whose path rule came back empty-handed — and those two look identical
133
+ * from here and identical on the phone. The caller decides how often to ask
134
+ * for a reason: `handle` passes one because a viewer is waiting on the
135
+ * answer, the recheck does not because it runs every TRANSCRIPT_RECHECK_MS
136
+ * and would turn one unsupported agent into a line every ten seconds.
137
+ */
138
+ resolveTranscript: (sessionName: string, onMiss?: (reason: string) => void) => TranscriptSource | null;
139
+ /** Whether tmux still holds this session. A watch outlives its pane otherwise. */
140
+ sessionExists: (sessionName: string) => boolean;
141
+ /**
142
+ * Load-or-create this device's keypair. Rejecting means this daemon cannot
143
+ * seal at all, which a viewer that asked for a seal must be told about
144
+ * rather than discovering three dropped batches later.
145
+ */
146
+ initCrypto: () => Promise<void>;
147
+ /** Seal one batch for the watcher, bound to its session (envelope v2 `turn.batch`).
148
+ * Rejecting drops the batch; it never falls back to plaintext. */
149
+ seal: (plaintext: string, subscriberPublicKey: string, sessionName: string) => Promise<unknown>;
150
+ log: (message: string) => void;
151
+ /** Where a watch's events are kept so a later watch can replay them. */
152
+ ring: TurnRing;
153
+ now?: () => number;
154
+ }
155
+ /**
156
+ * Cut a batch into frames of at most MAX_TURN_FRAME_BYTES of plaintext. One
157
+ * event bigger than the whole budget still goes, alone: prose is clamped at
158
+ * MAX_EVENT_TEXT_CHARS characters, not bytes, so a Korean paragraph can reach
159
+ * ~15KB — the budget keeps a *batch* inside the transport, it is not a promise
160
+ * about every single event.
161
+ */
162
+ export declare const pagesOf: (events: readonly TurnEvent[]) => TurnEvent[][];
163
+ /**
164
+ * A registry of transcript watchers plus the control handler that drives it.
165
+ *
166
+ * A factory rather than module state so a test can hold its own, and so two of
167
+ * them never share a clock or a socket by accident.
168
+ */
169
+ export declare const createTurnWatchers: (deps: TurnWatchDeps) => {
170
+ handle: (req: TurnWatchControl, send: SendTurnFrame) => boolean;
171
+ stop: (sessionName: string, reason: string) => void;
172
+ stopAll: (reason: string) => void;
173
+ tick: (sessionName: string) => Promise<void>;
174
+ size: () => number;
175
+ };
176
+ export type TurnWatchers = ReturnType<typeof createTurnWatchers>;
177
+ //# sourceMappingURL=turn-watch.d.ts.map