@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,143 @@
1
+ /**
2
+ * What a session WAS, kept so it can be started again.
3
+ *
4
+ * tmux is the only record of a live session, and it forgets one the moment it
5
+ * ends — which is exactly when the phone wants it back. So the listener writes
6
+ * down what it saw while the session was alive: where it ran and which agent it
7
+ * ran, keyed by the tmux name the phone already addresses it by **and** the
8
+ * agent that ran under it — tmux hands the same name out again, and one row per
9
+ * name meant the next occupant erased the last one's record (`runKey`).
10
+ *
11
+ * This file is the whitelist that makes remote resume safe. A resume request
12
+ * carries a session NAME and nothing else; the directory and the binary come
13
+ * from here, from what this machine observed itself. Nothing a phone (or a
14
+ * relay posing as one) sends can point the daemon at another directory or
15
+ * another program.
16
+ */
17
+ export interface KnownSession {
18
+ /** tmux session name — the key the phone addresses. */
19
+ name: string;
20
+ /** Pane cwd while it was alive. Where a resume starts the agent again. */
21
+ cwd: string;
22
+ /** RemoteAgent `kind` (claude/codex/…), resolved to a binary at resume time. */
23
+ agentKind: string;
24
+ project?: string;
25
+ label?: string;
26
+ /** Last time this machine saw the session running. */
27
+ lastSeenAt: string;
28
+ }
29
+ /**
30
+ * Ceiling on remembered sessions. tmux names come from a small reused pool
31
+ * (`hwip-<project>`, `-2`, …), so this holds far more distinct projects than it
32
+ * looks; the cap only stops the file from growing without bound on a machine
33
+ * that has run agents for years.
34
+ */
35
+ export declare const MAX_KNOWN_SESSIONS = 100;
36
+ /** Forgotten after this long unseen — a directory from months ago is more
37
+ * likely to have moved than to be what the user meant. */
38
+ export declare const KNOWN_SESSION_TTL_MS: number;
39
+ /**
40
+ * What makes two sightings the same remembered run: the tmux name AND the
41
+ * agent that was running under it.
42
+ *
43
+ * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
44
+ * the same slot — the ended run left no row to resume and no row to read, and
45
+ * the past list silently answered with the newest occupant instead. tmux hands
46
+ * a name back out; the record of what ran under it is not the name's to lose.
47
+ *
48
+ * NUL rather than a printable separator: a project directory can be called
49
+ * almost anything, and a name holding the separator must not be able to forge
50
+ * another run's key.
51
+ */
52
+ export declare const runKey: (name: string, agentKind: string) => string;
53
+ /** Sessions this machine has seen, newest first, expired ones dropped. */
54
+ export declare const knownSessions: (now?: number) => KnownSession[];
55
+ /** One remembered session, or null when this machine never saw that name.
56
+ * The newest run under that name — which is the one a bare resume means. */
57
+ export declare const recallSession: (name: string, now?: number) => KnownSession | null;
58
+ /**
59
+ * One specific run: the newest under this name that ran THIS agent.
60
+ *
61
+ * A tmux name is a slot several agents pass through, so "resume hwip-web" is
62
+ * ambiguous the moment the slot has held both pi and claude. The phone knows
63
+ * which row was tapped and says so; this is how that answer is honoured.
64
+ * Null when this machine never saw that agent under that name — the caller
65
+ * decides whether to fall back to the newest run or refuse.
66
+ */
67
+ export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
68
+ /**
69
+ * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
70
+ * CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
71
+ * whitelist, written in place (not renamed over), so a second writer can leave the listener's reader a
72
+ * half-written file. Never called in the listener's own process, which stays the one writer.
73
+ */
74
+ export declare const keepRegistryUntouched: () => void;
75
+ /**
76
+ * Write down the sessions running right now, replacing what was known about
77
+ * each. Called from the inventory sweep, so the record follows a session that
78
+ * moves directory or changes agent rather than pinning its first sighting.
79
+ *
80
+ * A session with no readable cwd is skipped rather than remembered without
81
+ * one: an entry that cannot say where to start the agent is not a resume
82
+ * target, only a row that would fail when tapped.
83
+ */
84
+ export declare const rememberSessions: (live: Array<{
85
+ name: string;
86
+ cwd: string | null;
87
+ agentKind: string;
88
+ project?: string | null;
89
+ label?: string | null;
90
+ }>, now?: number) => void;
91
+ /**
92
+ * Forget one session, by name. Returns false when this machine never knew it.
93
+ *
94
+ * This is what deleting a past session means on the machine that ran it: the
95
+ * entry leaves the file, so the phone stops being offered the session AND —
96
+ * since this file is the resume whitelist — stops being able to start it.
97
+ * That is the intended pair, not a side effect.
98
+ *
99
+ * It stays forgotten: `rememberSessions` only writes down sessions that are
100
+ * running, so nothing re-adds an entry for a session that has ended. Running
101
+ * that name again is what brings it back, which is also the only way back.
102
+ */
103
+ export type ForgetOutcome =
104
+ /** This machine never had a record under that name. */
105
+ 'unknown'
106
+ /** Registry entry and chat scrollback both gone. */
107
+ | 'forgotten'
108
+ /** Registry entry gone, but the scrollback file would not delete. */
109
+ | 'scrollback_kept';
110
+ export declare const forgetSession: (name: string, agentKind?: string) => ForgetOutcome;
111
+ /**
112
+ * What the caller was able to find out about a name. `unknown` is not a
113
+ * synonym for ended: it is the terminal failing to answer at all — no tmux to
114
+ * run, or no server behind the socket it resolved — and a sweep that deletes on
115
+ * `ended` must not delete on that. Deciding this is the caller's job; this
116
+ * module owns the file, not the terminal.
117
+ */
118
+ export type SessionLiveness = 'running' | 'ended' | 'unknown';
119
+ /** What one sweep did. `scrollbackKept` is a subset of `forgotten`: the row is
120
+ * gone either way, and these are the ones whose chat history stayed behind. */
121
+ export type EndedSweep = {
122
+ forgotten: string[];
123
+ scrollbackKept: string[];
124
+ kept: string[];
125
+ unreachable: string[];
126
+ };
127
+ /**
128
+ * Forget every remembered name that no longer runs, and report each side.
129
+ *
130
+ * The unit is the NAME, not the run: `forgetSession(name)` takes every run of
131
+ * the name, and a name tmux still holds is one the next inventory sweep writes
132
+ * straight back — so a name with a live run is left alone, its ended runs
133
+ * included. Each name is judged, deleted and reported once, however many runs
134
+ * sit under it.
135
+ */
136
+ export declare const forgetEnded: (liveness: (name: string) => SessionLiveness, now?: number) => EndedSweep;
137
+ /** Whether the registry knows this name — the resume whitelist check. */
138
+ export declare const isKnownSession: (name: string, now?: number) => boolean;
139
+ /** Test seam: the file this module reads and writes. */
140
+ export declare const knownSessionsPath: () => string;
141
+ /** True when the recorded directory still exists to start an agent in. */
142
+ export declare const sessionDirectoryExists: (entry: KnownSession) => boolean;
143
+ //# sourceMappingURL=session-registry.d.ts.map
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ /**
3
+ * What a session WAS, kept so it can be started again.
4
+ *
5
+ * tmux is the only record of a live session, and it forgets one the moment it
6
+ * ends — which is exactly when the phone wants it back. So the listener writes
7
+ * down what it saw while the session was alive: where it ran and which agent it
8
+ * ran, keyed by the tmux name the phone already addresses it by **and** the
9
+ * agent that ran under it — tmux hands the same name out again, and one row per
10
+ * name meant the next occupant erased the last one's record (`runKey`).
11
+ *
12
+ * This file is the whitelist that makes remote resume safe. A resume request
13
+ * carries a session NAME and nothing else; the directory and the binary come
14
+ * from here, from what this machine observed itself. Nothing a phone (or a
15
+ * relay posing as one) sends can point the daemon at another directory or
16
+ * another program.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_KNOWN_SESSIONS = void 0;
20
+ const fs_1 = require("fs");
21
+ const path_1 = require("path");
22
+ const turn_ring_js_1 = require("./turn-ring.js");
23
+ const gate_js_1 = require("./gate.js");
24
+ const private_files_js_1 = require("./private-files.js");
25
+ /**
26
+ * Ceiling on remembered sessions. tmux names come from a small reused pool
27
+ * (`hwip-<project>`, `-2`, …), so this holds far more distinct projects than it
28
+ * looks; the cap only stops the file from growing without bound on a machine
29
+ * that has run agents for years.
30
+ */
31
+ exports.MAX_KNOWN_SESSIONS = 100;
32
+ /** Forgotten after this long unseen — a directory from months ago is more
33
+ * likely to have moved than to be what the user meant. */
34
+ exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
35
+ const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
36
+ const readAll = () => {
37
+ try {
38
+ const raw = (0, fs_1.readFileSync)(registryPath(), 'utf-8');
39
+ const parsed = JSON.parse(raw);
40
+ if (!Array.isArray(parsed))
41
+ return [];
42
+ // Written by this process, but a half-written or hand-edited file must
43
+ // not take the listener down — keep the rows that still make sense.
44
+ return parsed.filter((e) => !!e &&
45
+ typeof e === 'object' &&
46
+ typeof e.name === 'string' &&
47
+ typeof e.cwd === 'string' &&
48
+ typeof e.agentKind === 'string' &&
49
+ typeof e.lastSeenAt === 'string');
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ };
55
+ const writeAll = (entries) => {
56
+ const path = registryPath();
57
+ try {
58
+ (0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(path), 0o700);
59
+ // 0600: it names the user's project directories, which is not something
60
+ // every account on the machine needs to read.
61
+ (0, fs_1.writeFileSync)(path, JSON.stringify(entries, null, 2), { mode: 0o600 });
62
+ (0, private_files_js_1.handBackToSudoUser)(path);
63
+ }
64
+ catch {
65
+ // A registry that cannot be written costs the resume affordance, not
66
+ // the daemon — every other path keeps working.
67
+ }
68
+ };
69
+ /**
70
+ * What makes two sightings the same remembered run: the tmux name AND the
71
+ * agent that was running under it.
72
+ *
73
+ * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
74
+ * the same slot — the ended run left no row to resume and no row to read, and
75
+ * the past list silently answered with the newest occupant instead. tmux hands
76
+ * a name back out; the record of what ran under it is not the name's to lose.
77
+ *
78
+ * NUL rather than a printable separator: a project directory can be called
79
+ * almost anything, and a name holding the separator must not be able to forge
80
+ * another run's key.
81
+ */
82
+ const runKey = (name, agentKind) => `${name}\u0000${agentKind}`;
83
+ exports.runKey = runKey;
84
+ /** Sessions this machine has seen, newest first, expired ones dropped. */
85
+ const knownSessions = (now = Date.now()) => readAll()
86
+ .filter((e) => now - Date.parse(e.lastSeenAt) < exports.KNOWN_SESSION_TTL_MS)
87
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
88
+ exports.knownSessions = knownSessions;
89
+ /** One remembered session, or null when this machine never saw that name.
90
+ * The newest run under that name — which is the one a bare resume means. */
91
+ const recallSession = (name, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name) ?? null;
92
+ exports.recallSession = recallSession;
93
+ /**
94
+ * One specific run: the newest under this name that ran THIS agent.
95
+ *
96
+ * A tmux name is a slot several agents pass through, so "resume hwip-web" is
97
+ * ambiguous the moment the slot has held both pi and claude. The phone knows
98
+ * which row was tapped and says so; this is how that answer is honoured.
99
+ * Null when this machine never saw that agent under that name — the caller
100
+ * decides whether to fall back to the newest run or refuse.
101
+ */
102
+ const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
103
+ exports.recallRun = recallRun;
104
+ let readOnly = false;
105
+ /**
106
+ * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
107
+ * CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
108
+ * whitelist, written in place (not renamed over), so a second writer can leave the listener's reader a
109
+ * half-written file. Never called in the listener's own process, which stays the one writer.
110
+ */
111
+ const keepRegistryUntouched = () => {
112
+ readOnly = true;
113
+ };
114
+ exports.keepRegistryUntouched = keepRegistryUntouched;
115
+ /**
116
+ * Write down the sessions running right now, replacing what was known about
117
+ * each. Called from the inventory sweep, so the record follows a session that
118
+ * moves directory or changes agent rather than pinning its first sighting.
119
+ *
120
+ * A session with no readable cwd is skipped rather than remembered without
121
+ * one: an entry that cannot say where to start the agent is not a resume
122
+ * target, only a row that would fail when tapped.
123
+ */
124
+ const rememberSessions = (live, now = Date.now()) => {
125
+ if (readOnly)
126
+ return;
127
+ const usable = live.filter((s) => !!s.name && !!s.cwd);
128
+ if (usable.length === 0)
129
+ return;
130
+ const seenAt = new Date(now).toISOString();
131
+ const byRun = new Map((0, exports.knownSessions)(now).map((e) => [(0, exports.runKey)(e.name, e.agentKind), e]));
132
+ for (const s of usable) {
133
+ byRun.set((0, exports.runKey)(s.name, s.agentKind), {
134
+ name: s.name,
135
+ cwd: s.cwd,
136
+ agentKind: s.agentKind,
137
+ ...(s.project ? { project: s.project } : {}),
138
+ ...(s.label ? { label: s.label } : {}),
139
+ lastSeenAt: seenAt,
140
+ });
141
+ }
142
+ const entries = [...byRun.values()]
143
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
144
+ .slice(0, exports.MAX_KNOWN_SESSIONS);
145
+ writeAll(entries);
146
+ };
147
+ exports.rememberSessions = rememberSessions;
148
+ const forgetSession = (name, agentKind) => {
149
+ const entries = readAll();
150
+ // With a kind, only that run goes — the phone deletes the row it is looking
151
+ // at, and a name that held both pi and claude shows two rows. Without one
152
+ // (an older phone, which sends no kind), the name goes entirely: that is
153
+ // what the request meant when a name could only mean one run.
154
+ const kept = entries.filter((e) => e.name !== name || (agentKind ? e.agentKind !== agentKind : false));
155
+ if (kept.length === entries.length)
156
+ return 'unknown';
157
+ // The chat's scrollback for that session goes with it. Forgetting a session
158
+ // everywhere except the one file that holds a day of its prompts and tool
159
+ // targets is not forgetting it — and a failure to delete that file has to
160
+ // reach the person who asked, not stay in a swallowed catch.
161
+ //
162
+ // Except while another run still answers to the name: the scrollback is per
163
+ // NAME, so deleting the pi row would take the claude row's history with it.
164
+ // The record is gone either way; the file waits for the last run to go.
165
+ const nameStillHeld = kept.some((e) => e.name === name);
166
+ writeAll(kept);
167
+ if (nameStillHeld)
168
+ return 'forgotten';
169
+ return (0, turn_ring_js_1.removeTurnRing)(name) ? 'forgotten' : 'scrollback_kept';
170
+ };
171
+ exports.forgetSession = forgetSession;
172
+ /**
173
+ * Forget every remembered name that no longer runs, and report each side.
174
+ *
175
+ * The unit is the NAME, not the run: `forgetSession(name)` takes every run of
176
+ * the name, and a name tmux still holds is one the next inventory sweep writes
177
+ * straight back — so a name with a live run is left alone, its ended runs
178
+ * included. Each name is judged, deleted and reported once, however many runs
179
+ * sit under it.
180
+ */
181
+ const forgetEnded = (liveness, now = Date.now()) => {
182
+ const names = [...new Set((0, exports.knownSessions)(now).map((e) => e.name))];
183
+ const sweep = { forgotten: [], scrollbackKept: [], kept: [], unreachable: [] };
184
+ for (const name of names) {
185
+ const state = liveness(name);
186
+ if (state === 'running')
187
+ sweep.kept.push(name);
188
+ else if (state === 'unknown')
189
+ sweep.unreachable.push(name);
190
+ else
191
+ sweep.forgotten.push(name);
192
+ }
193
+ if (sweep.forgotten.length === 0)
194
+ return sweep;
195
+ // One read and one write for the whole sweep. Calling `forgetSession` per
196
+ // name would rewrite the file once per name — N chances for the daemon's
197
+ // inventory sweep to write in between and put a deleted row back.
198
+ const doomed = new Set(sweep.forgotten);
199
+ writeAll(readAll().filter((e) => !doomed.has(e.name)));
200
+ // Every run of these names has gone, so — unlike `forgetSession`, which
201
+ // spares a ring another run still answers to — each ring goes with them.
202
+ for (const name of sweep.forgotten) {
203
+ if (!(0, turn_ring_js_1.removeTurnRing)(name))
204
+ sweep.scrollbackKept.push(name);
205
+ }
206
+ return sweep;
207
+ };
208
+ exports.forgetEnded = forgetEnded;
209
+ /** Whether the registry knows this name — the resume whitelist check. */
210
+ const isKnownSession = (name, now = Date.now()) => (0, exports.recallSession)(name, now) !== null;
211
+ exports.isKnownSession = isKnownSession;
212
+ /** Test seam: the file this module reads and writes. */
213
+ exports.knownSessionsPath = registryPath;
214
+ /** True when the recorded directory still exists to start an agent in. */
215
+ const sessionDirectoryExists = (entry) => (0, fs_1.existsSync)(entry.cwd);
216
+ exports.sessionDirectoryExists = sessionDirectoryExists;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The `listener.sessions` report, cut to fit the one message size this socket
3
+ * can carry.
4
+ *
5
+ * API Gateway closes a WebSocket whose frame is over 32 KB (close code 1009;
6
+ * "WebSocket frame size", a quota that cannot be raised). `ws` sends a message
7
+ * as one frame, and the report grows by about 290 bytes a session, so a machine
8
+ * with around a hundred sessions would see its connection dropped on every
9
+ * report and the inventory would never arrive.
10
+ *
11
+ * A report that fits goes out exactly as it always did. A larger one goes as
12
+ * (the server's contract, `server/SERVER-API.md`):
13
+ *
14
+ * - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
15
+ * with `partial: true` and some of the sessions — the server upserts what
16
+ * it is given, by name, and acks each part. The first part also carries
17
+ * `knownSessions` (once is enough);
18
+ * - then `listener.sessions.names` with every session name in the report, so
19
+ * the server can drop the sessions that are no longer there — what a
20
+ * whole-list report does by replacing the list.
21
+ *
22
+ * The next message waits for the previous part's ack (`sendSessionsReport`):
23
+ * the server merges overlapping parts safely, but overlapping ones can fire a
24
+ * blocked-session push twice. Parts also say their place (`part` of `parts`,
25
+ * one `reportId`); the server ignores those, they are for the log.
26
+ *
27
+ * A server that predates parts reads each part as a whole report (the last
28
+ * part would win) and does not know `listener.sessions.names`: the inventory is
29
+ * wrong there, but the connection stays up, which one oversized message did not.
30
+ */
31
+ /** Under API Gateway's 32 KB frame, with room for the envelope and a long session or two. */
32
+ export declare const SESSIONS_REPORT_MAX_BYTES: number;
33
+ export interface SessionsReportPlan {
34
+ /** The JSON messages to send, in order: one when the report fits, else the parts and then `names`. */
35
+ messages: string[];
36
+ /** Sessions in the report. */
37
+ sessionCount: number;
38
+ /** Bytes of the report sent as one message — what it would have been uncut. */
39
+ wholeBytes: number;
40
+ /** Sessions whose own JSON is bigger than a part can hold: sent alone, over the limit. */
41
+ oversized: string[];
42
+ /** The `names` message is bigger than a message can hold (about 900 sessions): sent anyway. */
43
+ namesOversized: boolean;
44
+ /** Identifies a report in parts; the server echoes it (and `part`) in each ack. Absent for one message. */
45
+ reportId?: string;
46
+ }
47
+ export declare const planSessionsReport: (sessions: readonly {
48
+ name: string;
49
+ }[], knownSessions: readonly unknown[], maxBytes?: number, reportId?: () => string) => SessionsReportPlan;
50
+ /** How long a part waits for its ack before the report is given up (and tried again on the next poll). */
51
+ export declare const SESSIONS_ACK_TIMEOUT_MS = 10000;
52
+ /**
53
+ * Send a plan's messages in order. With parts, every message but the last (`names`) waits for its
54
+ * ack before the next goes — `awaitAck(part)` is asked for before the send, so a quick answer is not
55
+ * missed, and `names` (which removes whatever it does not list) only goes once every part landed.
56
+ * False when an ack did not come: the rest is not sent, and the caller treats the report as unsent.
57
+ */
58
+ export declare const sendSessionsReport: (messages: readonly string[], send: (message: string) => void, awaitAck: (part: number) => Promise<boolean>) => Promise<boolean>;
59
+ //# sourceMappingURL=sessions-report.d.ts.map
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * The `listener.sessions` report, cut to fit the one message size this socket
4
+ * can carry.
5
+ *
6
+ * API Gateway closes a WebSocket whose frame is over 32 KB (close code 1009;
7
+ * "WebSocket frame size", a quota that cannot be raised). `ws` sends a message
8
+ * as one frame, and the report grows by about 290 bytes a session, so a machine
9
+ * with around a hundred sessions would see its connection dropped on every
10
+ * report and the inventory would never arrive.
11
+ *
12
+ * A report that fits goes out exactly as it always did. A larger one goes as
13
+ * (the server's contract, `server/SERVER-API.md`):
14
+ *
15
+ * - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
16
+ * with `partial: true` and some of the sessions — the server upserts what
17
+ * it is given, by name, and acks each part. The first part also carries
18
+ * `knownSessions` (once is enough);
19
+ * - then `listener.sessions.names` with every session name in the report, so
20
+ * the server can drop the sessions that are no longer there — what a
21
+ * whole-list report does by replacing the list.
22
+ *
23
+ * The next message waits for the previous part's ack (`sendSessionsReport`):
24
+ * the server merges overlapping parts safely, but overlapping ones can fire a
25
+ * blocked-session push twice. Parts also say their place (`part` of `parts`,
26
+ * one `reportId`); the server ignores those, they are for the log.
27
+ *
28
+ * A server that predates parts reads each part as a whole report (the last
29
+ * part would win) and does not know `listener.sessions.names`: the inventory is
30
+ * wrong there, but the connection stays up, which one oversized message did not.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.sendSessionsReport = exports.SESSIONS_ACK_TIMEOUT_MS = exports.planSessionsReport = exports.SESSIONS_REPORT_MAX_BYTES = void 0;
34
+ const node_crypto_1 = require("node:crypto");
35
+ /** Under API Gateway's 32 KB frame, with room for the envelope and a long session or two. */
36
+ exports.SESSIONS_REPORT_MAX_BYTES = 24 * 1024;
37
+ const bytes = (text) => Buffer.byteLength(text, 'utf-8');
38
+ const planSessionsReport = (sessions, knownSessions, maxBytes = exports.SESSIONS_REPORT_MAX_BYTES, reportId = node_crypto_1.randomUUID) => {
39
+ const whole = JSON.stringify({ type: 'listener.sessions', data: { sessions, knownSessions } });
40
+ const wholeBytes = bytes(whole);
41
+ if (wholeBytes <= maxBytes) {
42
+ return { messages: [whole], sessionCount: sessions.length, wholeBytes, oversized: [], namesOversized: false };
43
+ }
44
+ const id = reportId();
45
+ const knownBytes = bytes(JSON.stringify({ knownSessions }));
46
+ const envelopeBytes = bytes(JSON.stringify({
47
+ type: 'listener.sessions',
48
+ data: { sessions: [], partial: true, reportId: id, part: 999, parts: 999 },
49
+ }));
50
+ // Greedy: a session goes in the current part while it fits. The first part gives up room for
51
+ // `knownSessions`; no part gives up less than one session.
52
+ const groups = [];
53
+ const oversized = [];
54
+ let current = [];
55
+ let budget = Math.max(maxBytes - envelopeBytes - knownBytes, 0);
56
+ let used = 0;
57
+ for (const session of sessions) {
58
+ // +1 for the comma between sessions in the array.
59
+ const size = bytes(JSON.stringify(session)) + 1;
60
+ if (current.length > 0 && used + size > budget) {
61
+ groups.push(current);
62
+ current = [];
63
+ used = 0;
64
+ budget = Math.max(maxBytes - envelopeBytes, 0);
65
+ }
66
+ if (size > budget)
67
+ oversized.push(session.name);
68
+ current.push(session);
69
+ used += size;
70
+ }
71
+ groups.push(current);
72
+ const parts = groups.length;
73
+ const messages = groups.map((group, index) => JSON.stringify({
74
+ type: 'listener.sessions',
75
+ data: { sessions: group, partial: true, reportId: id, part: index + 1, parts, ...(index === 0 ? { knownSessions } : {}) },
76
+ }));
77
+ const names = JSON.stringify({ type: 'listener.sessions.names', data: { names: sessions.map((s) => s.name), reportId: id } });
78
+ messages.push(names);
79
+ return { messages, sessionCount: sessions.length, wholeBytes, oversized, namesOversized: bytes(names) > maxBytes, reportId: id };
80
+ };
81
+ exports.planSessionsReport = planSessionsReport;
82
+ /** How long a part waits for its ack before the report is given up (and tried again on the next poll). */
83
+ exports.SESSIONS_ACK_TIMEOUT_MS = 10_000;
84
+ /**
85
+ * Send a plan's messages in order. With parts, every message but the last (`names`) waits for its
86
+ * ack before the next goes — `awaitAck(part)` is asked for before the send, so a quick answer is not
87
+ * missed, and `names` (which removes whatever it does not list) only goes once every part landed.
88
+ * False when an ack did not come: the rest is not sent, and the caller treats the report as unsent.
89
+ */
90
+ const sendSessionsReport = async (messages, send, awaitAck) => {
91
+ for (let i = 0; i < messages.length; i++) {
92
+ const acked = messages.length > 1 && i < messages.length - 1 ? awaitAck(i + 1) : null;
93
+ send(messages[i]);
94
+ if (acked && !(await acked))
95
+ return false;
96
+ }
97
+ return true;
98
+ };
99
+ exports.sendSessionsReport = sendSessionsReport;
@@ -0,0 +1,34 @@
1
+ /** One phone → shell-session injection, as the listener admitted it. */
2
+ export interface ShellAuditEntry {
3
+ at: Date;
4
+ session: string;
5
+ /** The pane the guard probed — the one the input was typed into. */
6
+ target: string;
7
+ kind: 'body' | 'insert' | 'keys';
8
+ /** `pane_current_command` when the input landed. */
9
+ foreground: string;
10
+ /** The foreground is the shell itself, so the text is a command line. */
11
+ atShell: boolean;
12
+ /** The text, or the key names joined by spaces. */
13
+ text: string;
14
+ }
15
+ export declare const shellAuditPath: () => string;
16
+ /** Past this size the log moves to `shell-audit.log.1`, replacing the one before. */
17
+ export declare const SHELL_AUDIT_MAX_BYTES: number;
18
+ /**
19
+ * One tab-separated line. The text is kept only when it was typed at the shell
20
+ * prompt: anything else went to a program running in the pane — a `sudo`
21
+ * password prompt, an `ssh` login — and is recorded by length alone. Key names
22
+ * are never secret, so they are kept whatever the foreground is. The payload
23
+ * and every name are JSON-quoted, so a tab or newline in any of them cannot
24
+ * forge a field or a line.
25
+ */
26
+ export declare const formatShellAuditLine: (e: ShellAuditEntry) => string;
27
+ /**
28
+ * Append one entry to the audit log, a 0600 file (the state dir it sits in is
29
+ * shared, and whichever writer creates it first sets its mode). Returns false
30
+ * instead of throwing: the log answers "what did the phone run", and a full
31
+ * disk must not also stop the phone from running it.
32
+ */
33
+ export declare const appendShellAudit: (entry: ShellAuditEntry, path?: string) => boolean;
34
+ //# sourceMappingURL=shell-audit.d.ts.map
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.appendShellAudit = exports.formatShellAuditLine = exports.SHELL_AUDIT_MAX_BYTES = exports.shellAuditPath = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const gate_js_1 = require("./gate.js");
7
+ const private_files_js_1 = require("./private-files.js");
8
+ const shellAuditPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'shell-audit.log');
9
+ exports.shellAuditPath = shellAuditPath;
10
+ /** Past this size the log moves to `shell-audit.log.1`, replacing the one before. */
11
+ exports.SHELL_AUDIT_MAX_BYTES = 1024 * 1024;
12
+ /**
13
+ * One tab-separated line. The text is kept only when it was typed at the shell
14
+ * prompt: anything else went to a program running in the pane — a `sudo`
15
+ * password prompt, an `ssh` login — and is recorded by length alone. Key names
16
+ * are never secret, so they are kept whatever the foreground is. The payload
17
+ * and every name are JSON-quoted, so a tab or newline in any of them cannot
18
+ * forge a field or a line.
19
+ */
20
+ const formatShellAuditLine = (e) => {
21
+ const payload = e.atShell || e.kind === 'keys'
22
+ ? JSON.stringify(e.text)
23
+ : `[${[...e.text].length} chars → ${JSON.stringify(e.foreground)}]`;
24
+ const q = JSON.stringify;
25
+ return [e.at.toISOString(), q(e.session), q(e.target), e.kind, q(e.foreground), payload].join('\t') + '\n';
26
+ };
27
+ exports.formatShellAuditLine = formatShellAuditLine;
28
+ /**
29
+ * Append one entry to the audit log, a 0600 file (the state dir it sits in is
30
+ * shared, and whichever writer creates it first sets its mode). Returns false
31
+ * instead of throwing: the log answers "what did the phone run", and a full
32
+ * disk must not also stop the phone from running it.
33
+ */
34
+ const appendShellAudit = (entry, path = (0, exports.shellAuditPath)()) => {
35
+ try {
36
+ (0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
37
+ if (((0, fs_1.statSync)(path, { throwIfNoEntry: false })?.size ?? 0) >= exports.SHELL_AUDIT_MAX_BYTES)
38
+ (0, fs_1.renameSync)(path, `${path}.1`);
39
+ (0, fs_1.appendFileSync)(path, (0, exports.formatShellAuditLine)(entry), { mode: 0o600 });
40
+ (0, private_files_js_1.handBackToSudoUser)(path);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ };
47
+ exports.appendShellAudit = appendShellAudit;