@hwip/cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tryLanDelivery = exports.lanUploadTimeoutMs = exports.LAN_PING_TIMEOUT_MS = exports.pickLanTarget = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const lan_auth_js_1 = require("./lan-auth.js");
6
+ /**
7
+ * The device a push can go to directly, or null for the relay: a push with
8
+ * no single target (a broadcast) never qualifies, nor does a target without
9
+ * a key, an endpoint, or a live connection.
10
+ */
11
+ const pickLanTarget = (devices, targetDeviceId) => {
12
+ if (!targetDeviceId)
13
+ return null;
14
+ const device = devices.find((d) => d.deviceId === targetDeviceId);
15
+ if (!device?.publicKey || device.isOnline !== true || !device.lan)
16
+ return null;
17
+ const { host, port } = device.lan;
18
+ if (typeof host !== 'string' || host.length === 0 || typeof port !== 'number' || !Number.isInteger(port))
19
+ return null;
20
+ return { deviceId: device.deviceId, publicKey: device.publicKey, host, port };
21
+ };
22
+ exports.pickLanTarget = pickLanTarget;
23
+ /** A receiver that is there answers a ping on the same network in milliseconds. */
24
+ exports.LAN_PING_TIMEOUT_MS = 1_500;
25
+ /** 30 s, plus one second per MiB — slower than any Wi-Fi a LAN transfer is worth trying on. */
26
+ const lanUploadTimeoutMs = (bytes) => 30_000 + Math.ceil(bytes / 1_048_576) * 1_000;
27
+ exports.lanUploadTimeoutMs = lanUploadTimeoutMs;
28
+ const newTransferId = () => `lt_${(0, node_crypto_1.randomBytes)(12).toString('hex')}`;
29
+ /** A ping answer is `{"deviceId": "..."}`; anything longer is not a receiver. */
30
+ const PING_BODY_MAX_BYTES = 1024;
31
+ const UPLOAD_CHUNK_BYTES = 1_048_576;
32
+ /** Fires on the timeout or the caller's signal, whichever comes first
33
+ * (`AbortSignal.any` is Node 20+; this package supports 20.17+). `release`
34
+ * detaches from the caller's signal, which may outlive this request. */
35
+ const deadline = (timeoutMs, external) => {
36
+ const timeout = AbortSignal.timeout(timeoutMs);
37
+ if (!external)
38
+ return { signal: timeout, release: () => undefined };
39
+ const controller = new AbortController();
40
+ const onExternal = () => { controller.abort(external.reason); };
41
+ const onTimeout = () => { controller.abort(timeout.reason); };
42
+ external.addEventListener('abort', onExternal, { once: true });
43
+ timeout.addEventListener('abort', onTimeout, { once: true });
44
+ if (external.aborted)
45
+ onExternal();
46
+ return {
47
+ signal: controller.signal,
48
+ release: () => {
49
+ external.removeEventListener('abort', onExternal);
50
+ timeout.removeEventListener('abort', onTimeout);
51
+ },
52
+ };
53
+ };
54
+ /**
55
+ * The ciphertext as a stream of views into it. Node's fetch copies a
56
+ * `Uint8Array` body whole — measured at +2× the file for a buffered one — and
57
+ * the file may be a gigabyte already held twice (plaintext and ciphertext).
58
+ * Pulled one chunk at a time, only a chunk is ever in flight.
59
+ */
60
+ const bodyStream = (buf) => {
61
+ let offset = 0;
62
+ return new ReadableStream({
63
+ pull(controller) {
64
+ if (offset >= buf.byteLength) {
65
+ controller.close();
66
+ return;
67
+ }
68
+ const end = Math.min(offset + UPLOAD_CHUNK_BYTES, buf.byteLength);
69
+ controller.enqueue(new Uint8Array(buf.buffer, buf.byteOffset + offset, end - offset));
70
+ offset = end;
71
+ },
72
+ });
73
+ };
74
+ /** Drop a body we will not read; a peer that never ends it must not hold us. */
75
+ const discard = (res) => (res.body ? res.body.cancel().catch(() => undefined) : Promise.resolve());
76
+ /** The body as text, or null past `max` bytes — read no further than that. */
77
+ const readCapped = async (res, max) => {
78
+ if (!res.body)
79
+ return '';
80
+ const reader = res.body.getReader();
81
+ const chunks = [];
82
+ let size = 0;
83
+ for (;;) {
84
+ const { done, value } = await reader.read();
85
+ if (done)
86
+ break;
87
+ size += value.byteLength;
88
+ if (size > max) {
89
+ await reader.cancel().catch(() => undefined);
90
+ return null;
91
+ }
92
+ chunks.push(value);
93
+ }
94
+ return Buffer.concat(chunks).toString('utf8');
95
+ };
96
+ const reasonOf = (err) => {
97
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'))
98
+ return 'timed out';
99
+ return err instanceof Error ? err.message : String(err);
100
+ };
101
+ const tryLanDelivery = async (input) => {
102
+ const { target, file } = input;
103
+ const fetchFn = input.fetchFn ?? fetch;
104
+ const base = `http://${target.host}:${target.port}`;
105
+ let keys;
106
+ try {
107
+ keys = (0, lan_auth_js_1.deriveLanKeys)(await input.deriveSharedSecret(target.publicKey));
108
+ }
109
+ catch (err) {
110
+ return { delivered: false, reason: `key derivation failed: ${reasonOf(err)}` };
111
+ }
112
+ // Ping first: an address that has gone stale, or now belongs to another
113
+ // machine, costs the ping timeout — not an upload of the whole file.
114
+ const pingDeadline = deadline(input.pingTimeoutMs ?? exports.LAN_PING_TIMEOUT_MS, input.signal);
115
+ try {
116
+ const fields = { senderDeviceId: input.senderDeviceId, transferId: lan_auth_js_1.LAN_PING_TRANSFER_ID, timestamp: Date.now(), nonce: (0, lan_auth_js_1.newNonce)() };
117
+ const mac = (0, lan_auth_js_1.signLanRequest)(keys.macKey, { ...fields, method: 'GET', path: lan_auth_js_1.LAN_PATHS.ping, bodySha256: lan_auth_js_1.EMPTY_BODY_SHA256 });
118
+ const res = await fetchFn(`${base}${lan_auth_js_1.LAN_PATHS.ping}`, {
119
+ headers: (0, lan_auth_js_1.toLanAuthHeaders)(fields, mac),
120
+ signal: pingDeadline.signal,
121
+ });
122
+ // Status and receipt are headers: decide on them before reading a
123
+ // body whose size and end the peer controls.
124
+ if (res.status !== 200) {
125
+ await discard(res);
126
+ return { delivered: false, reason: `ping answered ${res.status}` };
127
+ }
128
+ if (!(0, lan_auth_js_1.verifyLanReceipt)(keys.macKey, mac, res.headers.get(lan_auth_js_1.LAN_HEADERS.receipt))) {
129
+ await discard(res);
130
+ return { delivered: false, reason: 'ping answered without a valid receipt' };
131
+ }
132
+ const body = await readCapped(res, PING_BODY_MAX_BYTES);
133
+ let answeredBy;
134
+ try {
135
+ answeredBy = JSON.parse(body ?? '').deviceId;
136
+ }
137
+ catch {
138
+ answeredBy = undefined;
139
+ }
140
+ if (answeredBy !== target.deviceId)
141
+ return { delivered: false, reason: 'ping answered by another device' };
142
+ }
143
+ catch (err) {
144
+ return { delivered: false, reason: input.signal?.aborted ? 'cancelled' : `ping failed: ${reasonOf(err)}` };
145
+ }
146
+ finally {
147
+ pingDeadline.release();
148
+ }
149
+ const transferId = newTransferId();
150
+ const uploadDeadline = deadline(input.uploadTimeoutMs ?? (0, exports.lanUploadTimeoutMs)(file.ciphertext.byteLength), input.signal);
151
+ try {
152
+ const fields = { senderDeviceId: input.senderDeviceId, transferId, timestamp: Date.now(), nonce: (0, lan_auth_js_1.newNonce)() };
153
+ const mac = (0, lan_auth_js_1.signLanRequest)(keys.macKey, { ...fields, method: 'POST', path: lan_auth_js_1.LAN_PATHS.upload, bodySha256: (0, lan_auth_js_1.sha256Hex)(file.ciphertext) });
154
+ const meta = { fileName: file.fileName, fileType: file.fileType, fileSize: file.fileSize, iv: file.iv, deviceKeyMap: file.deviceKeyMap, transferId };
155
+ const init = {
156
+ method: 'POST',
157
+ headers: {
158
+ ...(0, lan_auth_js_1.toLanAuthHeaders)(fields, mac),
159
+ [lan_auth_js_1.LAN_HEADERS.meta]: (0, lan_auth_js_1.sealMeta)(keys.metaKey, meta),
160
+ 'content-type': 'application/octet-stream',
161
+ // Explicit, so a streamed body still goes with a length: the
162
+ // receiver refuses chunked uploads (411).
163
+ 'content-length': String(file.ciphertext.byteLength),
164
+ },
165
+ body: bodyStream(file.ciphertext),
166
+ duplex: 'half',
167
+ signal: uploadDeadline.signal,
168
+ };
169
+ const res = await fetchFn(`${base}${lan_auth_js_1.LAN_PATHS.upload}`, init);
170
+ await discard(res);
171
+ if (res.status !== 201)
172
+ return { delivered: false, reason: `upload answered ${res.status}` };
173
+ if (!(0, lan_auth_js_1.verifyLanReceipt)(keys.macKey, mac, res.headers.get(lan_auth_js_1.LAN_HEADERS.receipt)))
174
+ return { delivered: false, reason: 'upload answered without a valid receipt' };
175
+ return { delivered: true, transferId };
176
+ }
177
+ catch (err) {
178
+ return { delivered: false, reason: input.signal?.aborted ? 'cancelled' : `upload failed: ${reasonOf(err)}` };
179
+ }
180
+ finally {
181
+ uploadDeadline.release();
182
+ }
183
+ };
184
+ exports.tryLanDelivery = tryLanDelivery;
@@ -0,0 +1,36 @@
1
+ import type { LanPublisher } from './lan-endpoint.js';
2
+ import type { LanReceiver } from './lan-receiver.js';
3
+ /**
4
+ * Local transfer (ADR-0013) — the listener's lifecycle for it, in one place.
5
+ *
6
+ * Order of events the daemon cannot control: the WebSocket may open before or
7
+ * after the receiver is bound, and SIGTERM may land while the keys are still
8
+ * loading. Every branch here ends the same way — the device record never
9
+ * points at a port nobody is listening on:
10
+ *
11
+ * keys → bind → (socket already open? publish now) → watch the address
12
+ * socket open → force-publish (the record exists from $connect on)
13
+ * stop → stop the watch, `lan: null`, close the socket — or, if
14
+ * the bind has not finished yet, close it the moment it does
15
+ */
16
+ export interface LanTransferDeps {
17
+ log: (msg: string) => void;
18
+ initCrypto: () => Promise<void>;
19
+ /** Bind the receiver; `onError` fires if the bound socket later dies. */
20
+ startReceiver: (hooks: {
21
+ onError: (err: Error) => void;
22
+ }) => Promise<LanReceiver>;
23
+ createPublisher: (port: number) => LanPublisher;
24
+ /** Whether the WebSocket has reached `open` right now. */
25
+ isOpen: () => boolean;
26
+ }
27
+ export interface LanTransfer {
28
+ /** Call on every WebSocket `open`. No-op until the receiver is bound. */
29
+ onOpen: () => void;
30
+ /** Retract the endpoint and release the port. Idempotent. Resolves once
31
+ * the receiver's socket is closed; the retract itself is one frame on a
32
+ * socket that is already closing, so it is sent and not waited on. */
33
+ stop: () => Promise<void>;
34
+ }
35
+ export declare const createLanTransfer: (deps: LanTransferDeps) => LanTransfer;
36
+ //# sourceMappingURL=lan-transfer.d.ts.map
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLanTransfer = void 0;
4
+ const createLanTransfer = (deps) => {
5
+ let live = null;
6
+ let stopping = false;
7
+ // A bound socket that dies later must not keep being re-affirmed by the
8
+ // watch: retract, release, and turn the feature off with one log line.
9
+ const onReceiverError = (err) => {
10
+ deps.log(`local transfer: off — receiver failed after bind (${err.message})`);
11
+ void teardown();
12
+ };
13
+ const ready = deps.initCrypto()
14
+ .then(() => deps.startReceiver({ onError: onReceiverError }))
15
+ .then((receiver) => {
16
+ if (stopping) {
17
+ // Bound after stop() was already called: nothing was published,
18
+ // so there is nothing to retract — just let the port go.
19
+ void receiver.stop();
20
+ return;
21
+ }
22
+ const publisher = deps.createPublisher(receiver.port);
23
+ live = { publisher, receiver };
24
+ publisher.startWatch(receiver.port);
25
+ if (deps.isOpen())
26
+ publisher.publish(receiver.port, { force: true });
27
+ })
28
+ .catch((err) => {
29
+ deps.log(`local transfer: off — ${err instanceof Error ? err.message : String(err)}`);
30
+ });
31
+ const onOpen = () => {
32
+ if (live)
33
+ live.publisher.publish(live.receiver.port, { force: true });
34
+ };
35
+ const teardown = async () => {
36
+ if (!live)
37
+ return;
38
+ const current = live;
39
+ live = null;
40
+ current.publisher.stopWatch();
41
+ current.publisher.clear();
42
+ await current.receiver.stop();
43
+ };
44
+ let stopped = null;
45
+ const stop = () => {
46
+ if (stopped)
47
+ return stopped;
48
+ stopping = true;
49
+ stopped = ready.then(() => teardown());
50
+ return stopped;
51
+ };
52
+ return { onOpen, stop };
53
+ };
54
+ exports.createLanTransfer = createLanTransfer;
@@ -0,0 +1,20 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export declare const hashListenerId: (seed: string) => string;
3
+ /** The id the sticky file holds, or null when it is missing or not an id. */
4
+ export declare const readStickyListenerId: () => string | null;
5
+ /**
6
+ * Leave `id` in the sticky file (0600, in the 0700 `~/.hwip`) so a later process that cannot
7
+ * read the machine id — `ioreg` stalled past its timeout — still gets this one. The writer is
8
+ * `computeListenerDeviceId` (the listener, `hwip rename`). A file that already holds `id` is left
9
+ * alone; one that holds another is replaced (an id a stalled start wrote down earlier). The new
10
+ * content goes in by rename, so a hook reading in the middle sees the old id or the new one, never
11
+ * an empty file. Best-effort: the id in memory stands.
12
+ */
13
+ export declare const rememberListenerId: (id: string) => void;
14
+ /** Platform machine id (macOS IOPlatformUUID / Linux machine-id), or null. */
15
+ export declare const readMachineId: (deps?: {
16
+ platform?: NodeJS.Platform;
17
+ exec?: typeof execFileSync;
18
+ }) => string | null;
19
+ export declare const listenerDeviceId: () => string;
20
+ //# sourceMappingURL=listener-device-id.d.ts.map
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listenerDeviceId = exports.readMachineId = exports.rememberListenerId = exports.readStickyListenerId = exports.hashListenerId = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_os_1 = require("node:os");
7
+ const node_path_1 = require("node:path");
8
+ const node_crypto_1 = require("node:crypto");
9
+ const private_files_js_1 = require("./private-files.js");
10
+ // The listener daemon writes this file (see listener.ts computeListenerDeviceId; `hwip rename`
11
+ // reaches the same function, and writes the same value); the other processes in the CLI
12
+ // (hwip-hook's agent-session context) only READ it, so both resolve the same id.
13
+ const LISTENER_ID_FILE = (0, node_path_1.join)((0, node_os_1.homedir)(), '.hwip', 'listener-device-id');
14
+ const hashListenerId = (seed) => `dev_listener_${(0, node_crypto_1.createHash)('sha256').update(seed).digest('hex').slice(0, 8)}`;
15
+ exports.hashListenerId = hashListenerId;
16
+ /** The id the sticky file holds, or null when it is missing or not an id. */
17
+ const readStickyListenerId = () => {
18
+ try {
19
+ const saved = (0, node_fs_1.readFileSync)(LISTENER_ID_FILE, 'utf-8').trim();
20
+ return /^dev_listener_[0-9a-f]{8}$/.test(saved) ? saved : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ };
26
+ exports.readStickyListenerId = readStickyListenerId;
27
+ /**
28
+ * Leave `id` in the sticky file (0600, in the 0700 `~/.hwip`) so a later process that cannot
29
+ * read the machine id — `ioreg` stalled past its timeout — still gets this one. The writer is
30
+ * `computeListenerDeviceId` (the listener, `hwip rename`). A file that already holds `id` is left
31
+ * alone; one that holds another is replaced (an id a stalled start wrote down earlier). The new
32
+ * content goes in by rename, so a hook reading in the middle sees the old id or the new one, never
33
+ * an empty file. Best-effort: the id in memory stands.
34
+ */
35
+ const rememberListenerId = (id) => {
36
+ if ((0, exports.readStickyListenerId)() === id)
37
+ return;
38
+ // Random name, exclusive create: a predictable name is one another process could pre-create as a symlink.
39
+ const tmp = `${LISTENER_ID_FILE}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
40
+ try {
41
+ (0, private_files_js_1.ensurePrivateDir)((0, node_path_1.dirname)(LISTENER_ID_FILE));
42
+ (0, node_fs_1.writeFileSync)(tmp, `${id}\n`, { mode: 0o600, flag: 'wx' });
43
+ // After the write: the create mode is cut by the umask, and an older file's mode is not this one's.
44
+ (0, node_fs_1.chmodSync)(tmp, 0o600);
45
+ (0, private_files_js_1.handBackToSudoUser)(tmp);
46
+ (0, node_fs_1.renameSync)(tmp, LISTENER_ID_FILE);
47
+ }
48
+ catch {
49
+ try {
50
+ (0, node_fs_1.unlinkSync)(tmp);
51
+ }
52
+ catch { /* never created, or already renamed */ }
53
+ /* not writable: nothing is lost that was not already */
54
+ }
55
+ };
56
+ exports.rememberListenerId = rememberListenerId;
57
+ /**
58
+ * ioreg by its absolute path: it lives in /usr/sbin, which a PATH from an IDE or a
59
+ * hook environment may lack, and a failed lookup falls back to a hostname hash —
60
+ * the same Mac registered as another device. launchd's own PATH has it; nothing
61
+ * else is promised. Shared by the listener, which registers the id, and the
62
+ * read-only reader here, so both read the same machine id.
63
+ */
64
+ const IOREG = '/usr/sbin/ioreg';
65
+ /**
66
+ * A stalled ioreg (IOKit wedged) used to hold every hook, `hwip rename` and the
67
+ * listener's start for as long as it hung. A timed-out run throws, which the
68
+ * caller reads as "no machine id" and falls back to the sticky file, then the
69
+ * hostname hash. mcp-server (config.ts IOREG_TIMEOUT_MS) gives up at the same
70
+ * 5 s and falls back the same way. The two run ioreg at different moments, so
71
+ * agreeing on the id still rests on ioreg being fast; this only keeps a wedged
72
+ * one from deciding who waits longest. A listener whose FIRST start (no sticky
73
+ * file yet) stalls keeps the hostname id until it restarts; on macOS that id is
74
+ * not written down, so the restart recovers (C109).
75
+ */
76
+ const IOREG_TIMEOUT_MS = 5_000;
77
+ /** Platform machine id (macOS IOPlatformUUID / Linux machine-id), or null. */
78
+ const readMachineId = (deps = {}) => {
79
+ const platform = deps.platform ?? process.platform;
80
+ const exec = deps.exec ?? node_child_process_1.execFileSync;
81
+ try {
82
+ if (platform === 'darwin') {
83
+ // stdio: swallow ioreg's stderr so a one-off `hwip rename` / hook
84
+ // never leaks ioreg warnings to the terminal.
85
+ const out = exec(IOREG, ['-rd1', '-c', 'IOPlatformExpertDevice'], {
86
+ encoding: 'utf-8',
87
+ stdio: ['ignore', 'pipe', 'ignore'],
88
+ timeout: IOREG_TIMEOUT_MS,
89
+ });
90
+ const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
91
+ if (m)
92
+ return m[1];
93
+ }
94
+ if (platform === 'linux') {
95
+ for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
96
+ try {
97
+ const v = (0, node_fs_1.readFileSync)(p, 'utf-8').trim();
98
+ if (v)
99
+ return v;
100
+ }
101
+ catch { /* try next path */ }
102
+ }
103
+ }
104
+ }
105
+ catch { /* no machine id readable — fall through */ }
106
+ return null;
107
+ };
108
+ exports.readMachineId = readMachineId;
109
+ /**
110
+ * Listener device id, resolved READ-ONLY — MUST equal what `listener.ts`
111
+ * `computeListenerDeviceId()` registers, so pushes tagged with this id land
112
+ * under the listener's device (otherwise a hook/notify reaches the feed but
113
+ * never threads into the agent chat). Hash the platform machine id first, then
114
+ * the sticky file the listener persisted, then a hostname hash — the same order
115
+ * as the writer, but never writing the file here.
116
+ */
117
+ let cached;
118
+ const listenerDeviceId = () => {
119
+ // The machine id does not change under a running process, and on macOS
120
+ // reading it is an `ioreg` spawn — once per process is enough.
121
+ cached ??= resolveListenerDeviceId();
122
+ return cached;
123
+ };
124
+ exports.listenerDeviceId = listenerDeviceId;
125
+ const resolveListenerDeviceId = () => {
126
+ const machineId = (0, exports.readMachineId)();
127
+ if (machineId)
128
+ return (0, exports.hashListenerId)(machineId);
129
+ const saved = (0, exports.readStickyListenerId)();
130
+ if (saved)
131
+ return saved;
132
+ return (0, exports.hashListenerId)((0, node_os_1.hostname)());
133
+ };
@@ -0,0 +1,4 @@
1
+ /** The listener's timestamped log line, and the switch that silences it for a CLI command. */
2
+ export declare const silenceListenerLog: () => void;
3
+ export declare const log: (msg: string) => void;
4
+ //# sourceMappingURL=listener-log.d.ts.map
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ /** The listener's timestamped log line, and the switch that silences it for a CLI command. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.log = exports.silenceListenerLog = void 0;
5
+ const stamp = () => new Date().toISOString().slice(11, 19);
6
+ /**
7
+ * The daemon's log is stdout, which its supervisor redirects to a file. A CLI
8
+ * command that borrows these helpers has no such redirect — `hwip forget` calls
9
+ * `sessionExists`, and socket discovery logs from there — so the chatter would
10
+ * land in that command's own output, and inside a `--json` line it would break
11
+ * the parse.
12
+ */
13
+ let logSilenced = false;
14
+ const silenceListenerLog = () => {
15
+ logSilenced = true;
16
+ };
17
+ exports.silenceListenerLog = silenceListenerLog;
18
+ const log = (msg) => {
19
+ if (logSilenced)
20
+ return;
21
+ console.log(`[${stamp()}] ${msg}`);
22
+ };
23
+ exports.log = log;
@@ -0,0 +1,200 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare const LISTENER_PID_FILE: string;
3
+ /** CLI version the running daemon booted from. Absent ⇒ pre-stamp build. */
4
+ export declare const LISTENER_VERSION_FILE: string;
5
+ /**
6
+ * The socket the running daemon actually connected to. A stamp rather than a
7
+ * line in listener.log, because the log rotates at 5MB and a long-lived daemon
8
+ * — exactly the one whose config edit has been outlived — loses its own startup
9
+ * line to that rotation, which would silently switch the staleness check off.
10
+ */
11
+ export declare const LISTENER_WS_URL_FILE: string;
12
+ export declare const LISTENER_LOG_FILE: string;
13
+ export declare const sleep: (ms: number) => Promise<void>;
14
+ /**
15
+ * PID of the listener on record, or null when the file is missing or stale.
16
+ * Stale PID files (process gone) read as "no listener" so a crashed daemon
17
+ * never blocks the next autostart.
18
+ */
19
+ export declare const runningListenerPid: () => number | null;
20
+ /**
21
+ * CLI version the running daemon started from, or null when unknown.
22
+ * null is meaningful: every build before the stamp existed predates the
23
+ * live-terminal protocol, so callers treat "unknown" as "stale".
24
+ */
25
+ export declare const runningListenerVersion: () => string | null;
26
+ /** Claim the singleton slot for THIS process and stamp the version with it. */
27
+ export declare const writeListenerRuntime: (version: string, wsUrl?: string) => void;
28
+ /** The socket the running daemon connected to, or null when it is unknown. */
29
+ export declare const runningListenerWsUrl: () => string | null;
30
+ /**
31
+ * Drop the PID/version stamps, but only when they still name `pid` — a
32
+ * successor that already claimed the slot must not be trampled by a
33
+ * predecessor's exit handler.
34
+ */
35
+ export declare const clearListenerRuntime: (pid: number) => void;
36
+ /**
37
+ * Drop stamps left behind by a daemon that died without running its exit
38
+ * handler (crash, SIGKILL, reboot-era leftovers). No-op while one is alive.
39
+ */
40
+ export declare const clearStaleListenerRuntime: () => void;
41
+ /** What `ps` says about a pid. */
42
+ export type PidCommand = {
43
+ readonly kind: 'running';
44
+ readonly command: string;
45
+ } | {
46
+ readonly kind: 'gone';
47
+ } | {
48
+ readonly kind: 'unchecked';
49
+ readonly reason: string;
50
+ };
51
+ export declare const commandOfPid: (pid: number) => PidCommand;
52
+ /**
53
+ * True when a command line is hwip's listener daemon: the word before
54
+ * `listener` is hwip's own script — the bin shim (`…/hwip`), the published
55
+ * package's entry (`…/@hwip/cli/dist/cli.js`: global install, npx), or this
56
+ * CLI's own file (a dev checkout). Any other `…/cli.js listener` (Zeph's runs
57
+ * on the same machines), or a file that only sits under a `hwip*` directory,
58
+ * does not pass. Exported for tests, which pass `ownCliPath`.
59
+ */
60
+ export declare const isHwipListenerCommand: (command: string, ownCliPath?: string | null) => boolean;
61
+ /** Whether the pid in the pid file is still hwip's listener. */
62
+ export type ListenerPidCheck = {
63
+ readonly kind: 'ours';
64
+ } | {
65
+ readonly kind: 'gone';
66
+ }
67
+ /** Another process holds the pid — only its program name, since its
68
+ * arguments can hold secrets. */
69
+ | {
70
+ readonly kind: 'foreign';
71
+ readonly program: string;
72
+ }
73
+ /** hwip's listener from another checkout or worktree
74
+ * (`…/cli-wt-x/dist/cli.js listener`), which `isHwipListenerCommand`
75
+ * cannot vouch for. Left running with its stamps — clearing them would
76
+ * let a second daemon start and answer every push twice. */
77
+ | {
78
+ readonly kind: 'other-listener';
79
+ readonly script: string;
80
+ } | {
81
+ readonly kind: 'unchecked';
82
+ readonly reason: string;
83
+ };
84
+ /**
85
+ * The pid file can outlive a crashed daemon and its pid be reused by any
86
+ * process, so a pid is only ours while its command line says so.
87
+ */
88
+ export declare const checkListenerPid: (pid: number, commandOf?: (pid: number) => PidCommand, isHwipScript?: (script: string) => boolean) => ListenerPidCheck;
89
+ /** What every caller says about an `other-listener`. */
90
+ export declare const otherListenerNote: (pid: number, script: string) => string;
91
+ /**
92
+ * Whether the pid on record should be treated as a live listener: hwip's own
93
+ * or listener-shaped command line, or one `ps` could not check — two daemons
94
+ * answering the same pushes is the worse failure. `hwip verify` and the
95
+ * startup guard ask the same question.
96
+ */
97
+ export declare const isListenerPid: (pid: number, commandOf?: (pid: number) => PidCommand) => boolean;
98
+ /**
99
+ * The pid of another hwip listener holding the singleton slot, or null. A pid
100
+ * the OS handed to an unrelated process is not a listener: deferring to it
101
+ * would end launchd's or `hwip cc`'s daemon with exit 0 and leave no listener
102
+ * at all, so its stamps are cleared and the slot is free.
103
+ */
104
+ export declare const otherListenerPid: (selfPid?: number, commandOf?: (pid: number) => PidCommand) => number | null;
105
+ export type ListenerStop = {
106
+ readonly kind: 'stopped';
107
+ }
108
+ /** It outlived the grace window and took SIGKILL. */
109
+ | {
110
+ readonly kind: 'killed';
111
+ } | Exclude<ListenerPidCheck, {
112
+ kind: 'ours';
113
+ }>;
114
+ /**
115
+ * Stop the daemon and wait for the process to actually go away — but only a
116
+ * process that is still hwip's listener (`checkListenerPid`); anything else is
117
+ * left alone and reported, and a pid file pointing at a stranger is cleared.
118
+ * SIGTERM first so its exit handler clears the stamps, SIGKILL once the grace
119
+ * window expires.
120
+ *
121
+ * Waiting matters: the caller's next move is spawning a replacement, and two
122
+ * live daemons on one machine both answer the same pushes.
123
+ */
124
+ export declare const stopListener: (pid: number, commandOf?: (pid: number) => PidCommand, timeoutMs?: number) => Promise<ListenerStop>;
125
+ /**
126
+ * Set by the listener on the `hwip <agent> --detach` it runs for a schedule. That child is the listener's own: when its
127
+ * build is newer than the running listener's (after an upgrade) `ensureListenerRunning` would restart the listener, and
128
+ * the restart would end the launch's own parent. With this set it leaves the listener alone, and removes the variable from
129
+ * its environment so that nothing the launch starts after it (the tmux server, the agent) inherits it.
130
+ */
131
+ export declare const LISTENER_LAUNCH_ENV = "HWIP_LISTENER_LAUNCH";
132
+ /**
133
+ * Path to the running cli.js entry. wrapper.js/listener.js sit next to cli.js
134
+ * in dist/, so __dirname resolves it directly — independent of how the user
135
+ * invoked us.
136
+ *
137
+ * `process.argv[1]` is unreliable here: when `hwip` runs via the npm-installed
138
+ * bin shim (`/usr/local/bin/hwip` → cli.js via a wrapper script), argv[1] is
139
+ * the shim path (`.../bin/hwip`), NOT `cli.js`. That made the original
140
+ * `/cli\.(js|ts|mjs|cjs)$/` check silently reject the entry and the autospawn
141
+ * never fired — the bug only surfaced once the user switched to the global
142
+ * npm install.
143
+ *
144
+ * Fall back to argv[1] only when the __dirname-relative file doesn't exist
145
+ * (some packaging where dist layout differs).
146
+ */
147
+ export declare const resolveCliPath: () => string | null;
148
+ export declare const rotateListenerLogIfLarge: () => void;
149
+ /**
150
+ * How long a daemon `hwip cc` just spawned gets to fail. The fast deaths — no
151
+ * key, no tmux, no socket URL — come before the daemon writes its pid file;
152
+ * a rejected key closes the first socket within about a second.
153
+ */
154
+ export declare const LISTENER_START_GRACE_MS = 1500;
155
+ export type ListenerStart = {
156
+ readonly kind: 'running';
157
+ }
158
+ /** It exited 0: another daemon won the singleton slot, so one is running. */
159
+ | {
160
+ readonly kind: 'deferred';
161
+ }
162
+ /** It died inside the grace window; `line` is the last line it logged. */
163
+ | {
164
+ readonly kind: 'died';
165
+ readonly code: number | null;
166
+ readonly line: string | null;
167
+ }
168
+ /** launchd started it, and after the grace window it is not running. */
169
+ | {
170
+ readonly kind: 'not-up';
171
+ readonly line: string | null;
172
+ } | {
173
+ readonly kind: 'not-spawned';
174
+ };
175
+ /** Why a start failed, in one line for the user; null when a listener is up. */
176
+ export declare const startFailure: (started: ListenerStart) => string | null;
177
+ /** Where the log ends now: what a start writes after this is that start's. */
178
+ export declare const listenerLogSize: () => number;
179
+ /**
180
+ * Spawn the daemon and watch it through the grace window. A daemon that dies
181
+ * on a missing key, a missing tmux or a rejected key only says so in the log,
182
+ * so `hwip cc` used to print "listener autostarted" over a dead one and the
183
+ * phone silently lost control.
184
+ */
185
+ export declare const startListenerDetached: (spawnChild?: () => Pick<ChildProcess, "once" | "exitCode"> | null, graceMs?: number) => Promise<ListenerStart>;
186
+ /** How long launchd gets to bring a kickstarted daemon as far as its pid file. */
187
+ export declare const LISTENER_SERVICE_START_MS = 5000;
188
+ /**
189
+ * After `launchctl kickstart`: launchd starts the daemon, so there is no child
190
+ * to watch — only the pid file it writes once its key, tmux and socket URL
191
+ * check out, and the log it says why in. A daemon that dies there is restarted
192
+ * by launchd on a loop, and `hwip cc` used to report it "started". Waits for a
193
+ * pid other than `previousPid` (the daemon `kickstart -k` replaced may still
194
+ * be shutting down), then for the grace window a rejected key needs.
195
+ */
196
+ export declare const awaitServiceListener: (logOffset: number, previousPid: number | null, deps?: {
197
+ sleep: (ms: number) => Promise<void>;
198
+ runningPid: () => number | null;
199
+ }, graceMs?: number, startMs?: number) => Promise<ListenerStart>;
200
+ //# sourceMappingURL=listener-process.d.ts.map