@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,272 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.saveStream = exports.DOWNLOAD_MAX_BYTES = exports.withIdleTimeout = exports.createChunkedFileDecryptStream = exports.chunkOf = exports.FILE_CHUNK_CAPABILITY = exports.FILE_CHUNK_PREFIX_BYTES = exports.FILE_CHUNK_BYTES = exports.createFileDecryptStream = exports.GCM_IV_BYTES = exports.GCM_TAG_BYTES = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_fs_1 = require("node:fs");
6
+ const promises_1 = require("node:fs/promises");
7
+ const node_path_1 = require("node:path");
8
+ const node_stream_1 = require("node:stream");
9
+ const promises_2 = require("node:stream/promises");
10
+ const private_files_js_1 = require("./private-files.js");
11
+ /**
12
+ * Streaming decrypt of a file the web / MCP side produced with
13
+ * `libs/crypto` `encryptFile` (WebCrypto AES-256-GCM, 12-byte IV). That
14
+ * API is one-shot; a 1 GiB attachment would sit in memory here, so this
15
+ * is the same wire format on `node:crypto`'s incremental cipher:
16
+ * WebCrypto appends the 16-byte GCM tag to the ciphertext, and the stream
17
+ * holds back the last 16 bytes of whatever it is fed as that tag.
18
+ *
19
+ * The key is the raw per-file AES key, already unwrapped from the
20
+ * attachment's `deviceKeyMap` slot (see `unwrapDeviceKey` in crypto.ts).
21
+ */
22
+ exports.GCM_TAG_BYTES = 16;
23
+ exports.GCM_IV_BYTES = 12;
24
+ const createFileDecryptStream = (rawKey, iv) => {
25
+ if (rawKey.length !== 32)
26
+ throw new Error(`file key must be 32 bytes, got ${rawKey.length}`);
27
+ if (iv.length !== exports.GCM_IV_BYTES)
28
+ throw new Error(`file iv must be ${exports.GCM_IV_BYTES} bytes, got ${iv.length}`);
29
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', rawKey, iv);
30
+ let tail = Buffer.alloc(0);
31
+ return new node_stream_1.Transform({
32
+ transform(chunk, _enc, done) {
33
+ const joined = tail.length ? Buffer.concat([tail, chunk]) : chunk;
34
+ if (joined.length <= exports.GCM_TAG_BYTES) {
35
+ tail = joined;
36
+ done();
37
+ return;
38
+ }
39
+ const body = joined.subarray(0, joined.length - exports.GCM_TAG_BYTES);
40
+ tail = Buffer.from(joined.subarray(joined.length - exports.GCM_TAG_BYTES));
41
+ done(null, decipher.update(body));
42
+ },
43
+ flush(done) {
44
+ if (tail.length < exports.GCM_TAG_BYTES) {
45
+ done(new Error('ciphertext shorter than the GCM tag'));
46
+ return;
47
+ }
48
+ try {
49
+ decipher.setAuthTag(tail);
50
+ done(null, decipher.final()); // throws on a bad tag: tampered, truncated, wrong key
51
+ }
52
+ catch (err) {
53
+ done(err instanceof Error ? err : new Error(String(err)));
54
+ }
55
+ },
56
+ });
57
+ };
58
+ exports.createFileDecryptStream = createFileDecryptStream;
59
+ /**
60
+ * A file sealed in chunks (`file-chunk-v1`), the format a phone seals a file
61
+ * in that is too large to hold in memory. The padded plaintext is cut into
62
+ * `FILE_CHUNK_BYTES` pieces — the last one shorter or equal, never empty —
63
+ * and each is sealed alone with AES-256-GCM under the file key, no AAD:
64
+ *
65
+ * nonce = prefix[7] ‖ piece index (u32 big-endian) ‖ 0x01 on the last piece, else 0x00
66
+ * file = every `ciphertext ‖ tag[16]`, in order
67
+ *
68
+ * The index in the nonce refuses a piece moved or repeated; the last-piece
69
+ * flag refuses a file cut at a piece's end, and bytes added after it. The
70
+ * prefix travels where a whole-sealed file's IV does (`iv`), and the sealed
71
+ * message says `chunk` next to it — see `chunkOf`. Vectors:
72
+ * `file-chunk.vectors.json` (scripts/gen-file-chunk-vectors.py).
73
+ */
74
+ exports.FILE_CHUNK_BYTES = 1_048_576;
75
+ exports.FILE_CHUNK_PREFIX_BYTES = 7;
76
+ /** What a listener tells senders it opens (`capabilities` on its device record). */
77
+ exports.FILE_CHUNK_CAPABILITY = 'file-chunk-v1';
78
+ /** Feeds the reader keeps apart before it joins them. */
79
+ const MAX_HELD_FEEDS = 1024;
80
+ /**
81
+ * The `chunk` a sender wrote beside a file's `iv`: absent for a file sealed
82
+ * whole, `FILE_CHUNK_BYTES` for one sealed in chunks. Any other value is a
83
+ * format this build does not open — `ok: false`, and the file is left alone.
84
+ */
85
+ const chunkOf = (said) => {
86
+ if (said === undefined)
87
+ return { ok: true };
88
+ return said === exports.FILE_CHUNK_BYTES ? { ok: true, chunk: exports.FILE_CHUNK_BYTES } : { ok: false };
89
+ };
90
+ exports.chunkOf = chunkOf;
91
+ /**
92
+ * Streaming decrypt of a file sealed in chunks. A piece is opened only once
93
+ * a byte past it has arrived, because whether it is the last one is in its
94
+ * nonce: what is left when the stream ends is the last piece, and it has to
95
+ * open as one. Pieces already opened have reached the `.part` file by then;
96
+ * `saveStream` removes it on any failure, so nothing a later piece disowns
97
+ * is ever linked into place.
98
+ */
99
+ const createChunkedFileDecryptStream = (rawKey, prefix, chunk = exports.FILE_CHUNK_BYTES) => {
100
+ if (rawKey.length !== 32)
101
+ throw new Error(`file key must be 32 bytes, got ${rawKey.length}`);
102
+ if (prefix.length !== exports.FILE_CHUNK_PREFIX_BYTES)
103
+ throw new Error(`chunked file prefix must be ${exports.FILE_CHUNK_PREFIX_BYTES} bytes, got ${prefix.length}`);
104
+ if (!Number.isSafeInteger(chunk) || chunk <= 0)
105
+ throw new Error(`chunk size must be a whole number of bytes above zero, got ${chunk}`);
106
+ const sealedPiece = chunk + exports.GCM_TAG_BYTES;
107
+ // What has arrived and is not opened yet, joined only once it holds a whole
108
+ // piece: joining at every feed would copy up to a chunk each time.
109
+ let held = [];
110
+ let heldBytes = 0;
111
+ let index = 0;
112
+ const open = (piece, last) => {
113
+ const nonce = Buffer.alloc(exports.GCM_IV_BYTES);
114
+ prefix.copy(nonce);
115
+ nonce.writeUInt32BE(index++, exports.FILE_CHUNK_PREFIX_BYTES);
116
+ nonce[exports.GCM_IV_BYTES - 1] = last ? 1 : 0;
117
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', rawKey, nonce);
118
+ decipher.setAuthTag(piece.subarray(piece.length - exports.GCM_TAG_BYTES));
119
+ const plain = decipher.update(piece.subarray(0, piece.length - exports.GCM_TAG_BYTES));
120
+ decipher.final(); // checks the tag; GCM has no bytes left to give here
121
+ return plain;
122
+ };
123
+ return new node_stream_1.Transform({
124
+ transform(fed, _enc, done) {
125
+ held.push(fed);
126
+ heldBytes += fed.length;
127
+ // A peer that sends a byte at a time does not get to pile up a million buffers.
128
+ if (held.length > MAX_HELD_FEEDS)
129
+ held = [Buffer.concat(held)];
130
+ if (heldBytes <= sealedPiece) {
131
+ done();
132
+ return;
133
+ }
134
+ try {
135
+ let rest = Buffer.concat(held);
136
+ while (rest.length > sealedPiece) {
137
+ this.push(open(rest.subarray(0, sealedPiece), false));
138
+ rest = rest.subarray(sealedPiece);
139
+ }
140
+ held = [rest];
141
+ heldBytes = rest.length;
142
+ done();
143
+ }
144
+ catch (err) {
145
+ done(err instanceof Error ? err : new Error(String(err)));
146
+ }
147
+ },
148
+ flush(done) {
149
+ if (heldBytes <= exports.GCM_TAG_BYTES) {
150
+ done(new Error('chunked file ends without a last piece'));
151
+ return;
152
+ }
153
+ try {
154
+ done(null, open(Buffer.concat(held), true)); // throws on a piece that is not the last: cut short, or more was sealed after it
155
+ }
156
+ catch (err) {
157
+ done(err instanceof Error ? err : new Error(String(err)));
158
+ }
159
+ },
160
+ });
161
+ };
162
+ exports.createChunkedFileDecryptStream = createChunkedFileDecryptStream;
163
+ /**
164
+ * Abort `controller` when `source` goes quiet for `idleMs`. A stalled S3
165
+ * connection would otherwise park a download — and its part file — for
166
+ * the life of the daemon with nothing in the log. Bytes still flowing,
167
+ * however slowly, never trip it: a 1 GiB file on a bad link is not a stall.
168
+ * More than `maxBytes` fails the stream: a body that never ends is not a
169
+ * stall either, and would fill the disk.
170
+ */
171
+ const withIdleTimeout = (source, idleMs, controller, maxBytes = Infinity) => {
172
+ let timer = null;
173
+ let seen = 0;
174
+ const arm = () => {
175
+ if (timer)
176
+ clearTimeout(timer);
177
+ timer = setTimeout(() => controller.abort(new Error(`no bytes for ${idleMs} ms`)), idleMs);
178
+ timer.unref();
179
+ };
180
+ const guard = new node_stream_1.Transform({
181
+ transform(chunk, _enc, done) {
182
+ arm();
183
+ seen += chunk.length;
184
+ if (seen <= maxBytes)
185
+ return done(null, chunk);
186
+ if (timer)
187
+ clearTimeout(timer);
188
+ done(new Error(`download passed the ${maxBytes}-byte limit`));
189
+ },
190
+ flush(done) { if (timer)
191
+ clearTimeout(timer); done(); },
192
+ });
193
+ arm();
194
+ // `pipeline`, not `pipe`: a failure downstream (bad tag, disk, name cap)
195
+ // must destroy the source too, or the response body sits open until the
196
+ // idle timer fires. Errors surface through the returned stream.
197
+ void (0, promises_2.pipeline)(source, guard).catch(() => undefined);
198
+ return guard;
199
+ };
200
+ exports.withIdleTimeout = withIdleTimeout;
201
+ /**
202
+ * The most bytes one download may carry: the largest file any tier may send
203
+ * (1 GiB, `fileMaxBytes`), plus the Padmé padding (at most about 12%) and
204
+ * the GCM tag a sealed file adds. The record's `fileSize` is the relay's
205
+ * word, so it cannot be the limit; without one, a URL that streams forever
206
+ * fills the disk.
207
+ */
208
+ exports.DOWNLOAD_MAX_BYTES = 1.25 * 1024 ** 3;
209
+ /** How many `nextDest()` candidates `saveStream` tries before giving up. */
210
+ const MAX_LINK_ATTEMPTS = 100;
211
+ const isErrno = (err, code) => typeof err === 'object' && err !== null && 'code' in err && err.code === code;
212
+ /**
213
+ * Stream `source` to the path `nextDest()` names, optionally decrypting
214
+ * on the way — a file sealed whole, or with `chunk` one sealed in chunks
215
+ * (`iv` is then its 7-byte prefix). The bytes go to a private `.part` file first (a per-call
216
+ * random name, so two saves of the same file cannot touch each other's
217
+ * temp) and are fsynced before being linked into place — a reader never
218
+ * sees a half file, and an authentication failure (which GCM only reports
219
+ * at the very end) leaves nothing behind. `link` refuses an existing dest
220
+ * where `rename` would replace it; on that collision `nextDest()` is asked
221
+ * again, so a name taken between the caller's check and here costs a
222
+ * ` (n)` suffix, never the download. Resolves with the path written and
223
+ * the plaintext byte count.
224
+ */
225
+ const saveStream = async (source, nextDest, decrypt) => {
226
+ const first = nextDest();
227
+ const dir = (0, node_path_1.dirname)(first);
228
+ (0, private_files_js_1.mkdirHandedBack)(dir);
229
+ const part = (0, node_path_1.join)(dir, `.hwip-${(0, node_crypto_1.randomBytes)(6).toString('hex')}.part`);
230
+ let bytes = 0;
231
+ const counter = new node_stream_1.Transform({
232
+ transform(chunk, _enc, done) { bytes += chunk.length; done(null, chunk); },
233
+ });
234
+ try {
235
+ // Before the part file exists: a key or an iv the format refuses throws here.
236
+ const opened = !decrypt ? null : decrypt.chunk === undefined
237
+ ? (0, exports.createFileDecryptStream)(decrypt.rawKey, decrypt.iv)
238
+ : (0, exports.createChunkedFileDecryptStream)(decrypt.rawKey, decrypt.iv, decrypt.chunk);
239
+ const sink = (0, node_fs_1.createWriteStream)(part, { flags: 'wx' });
240
+ if (opened)
241
+ await (0, promises_2.pipeline)(source, opened, counter, sink);
242
+ else
243
+ await (0, promises_2.pipeline)(source, counter, sink);
244
+ const fh = await (0, promises_1.open)(part, 'r+');
245
+ try {
246
+ await fh.sync();
247
+ }
248
+ finally {
249
+ await fh.close();
250
+ }
251
+ let dest = first;
252
+ for (let attempt = 1;; attempt++) {
253
+ try {
254
+ await (0, promises_1.link)(part, dest);
255
+ break;
256
+ }
257
+ catch (err) {
258
+ if (!isErrno(err, 'EEXIST') || attempt >= MAX_LINK_ATTEMPTS)
259
+ throw err;
260
+ dest = nextDest();
261
+ }
262
+ }
263
+ // The file is in place; a temp that will not go is not a failed save.
264
+ await (0, promises_1.unlink)(part).catch(() => undefined);
265
+ return { path: dest, bytes };
266
+ }
267
+ catch (err) {
268
+ (0, node_fs_1.rmSync)(part, { force: true });
269
+ throw err;
270
+ }
271
+ };
272
+ exports.saveStream = saveStream;
@@ -0,0 +1,15 @@
1
+ /** D6: the most file content one session may read in a window. Only a blob's text pages count; a listing carries no content. */
2
+ export declare const FILE_VIEW_WINDOW_BYTES: number;
3
+ export declare const FILE_VIEW_WINDOW_MS: number;
4
+ export interface ByteBudget {
5
+ /** Has `key` already read its share in the current window? A request that finds it spent is refused before it reads. */
6
+ spent: (key: string, now?: number) => boolean;
7
+ /** Count `bytes` against `key`. The window opens at the first byte counted after the last one closed. */
8
+ charge: (key: string, bytes: number, now?: number) => void;
9
+ }
10
+ /**
11
+ * A fixed window per key. Keys are session names a relay can make up, so a window that has run out is
12
+ * dropped whenever any key is looked at — the map holds only sessions that read something recently.
13
+ */
14
+ export declare const createByteBudget: (limitBytes: number, windowMs: number) => ByteBudget;
15
+ //# sourceMappingURL=file-view-budget.d.ts.map
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createByteBudget = exports.FILE_VIEW_WINDOW_MS = exports.FILE_VIEW_WINDOW_BYTES = void 0;
4
+ /** D6: the most file content one session may read in a window. Only a blob's text pages count; a listing carries no content. */
5
+ exports.FILE_VIEW_WINDOW_BYTES = 32 * 1024 * 1024;
6
+ exports.FILE_VIEW_WINDOW_MS = 10 * 60_000;
7
+ /**
8
+ * A fixed window per key. Keys are session names a relay can make up, so a window that has run out is
9
+ * dropped whenever any key is looked at — the map holds only sessions that read something recently.
10
+ */
11
+ const createByteBudget = (limitBytes, windowMs) => {
12
+ const windows = new Map();
13
+ const prune = (now) => {
14
+ for (const [key, w] of windows)
15
+ if (now - w.opened >= windowMs)
16
+ windows.delete(key);
17
+ };
18
+ return {
19
+ spent: (key, now = Date.now()) => {
20
+ prune(now);
21
+ return (windows.get(key)?.bytes ?? 0) >= limitBytes;
22
+ },
23
+ charge: (key, bytes, now = Date.now()) => {
24
+ prune(now);
25
+ // Nothing counted opens no window.
26
+ if (bytes <= 0)
27
+ return;
28
+ const w = windows.get(key) ?? { opened: now, bytes: 0 };
29
+ w.bytes += bytes;
30
+ windows.set(key, w);
31
+ },
32
+ };
33
+ };
34
+ exports.createByteBudget = createByteBudget;
@@ -0,0 +1,28 @@
1
+ import { type SwitchOffBy } from './project-switch.js';
2
+ /** Why the file view is off for the project at `dir`, or `null` when it is on. A switch file that cannot be read counts as off. */
3
+ export declare const fileViewOffBy: (dir: string, env?: NodeJS.ProcessEnv) => SwitchOffBy | null;
4
+ export declare const setFileView: (dir: string, on: boolean, scope: import("./project-switch.js").SwitchScope) => void;
5
+ export declare const describeFileView: (offBy: SwitchOffBy | null) => string;
6
+ /** How many reads `log` shows: the most recent. */
7
+ export declare const FILE_VIEW_LOG_LINES = 50;
8
+ export declare const escapeLogUnsafe: (text: string) => string;
9
+ /**
10
+ * The most recent reads in a listener log, oldest first, without the stamp (the line carries its own date).
11
+ * `older` is the log the listener keeps when it rotates; it is read only when the current one has too few.
12
+ */
13
+ export declare const recentFileViewReads: (current: string, older: string, limit?: number) => string[];
14
+ export interface FileViewCommandDeps {
15
+ cwd: () => string;
16
+ out: (line: string) => void;
17
+ err: (line: string) => void;
18
+ env: NodeJS.ProcessEnv;
19
+ repoRoot: (cwd: string) => string | null;
20
+ /** The listener log and the one it rotated out, as text; '' for a log that is not there. Throws for one that cannot be read. */
21
+ readLogs: () => {
22
+ current: string;
23
+ older: string;
24
+ };
25
+ }
26
+ export declare const runFileView: (sub: string | undefined, global: boolean, deps: FileViewCommandDeps) => number;
27
+ export declare const handleFileView: (args: Record<string, string | boolean>) => Promise<number>;
28
+ //# sourceMappingURL=file-view-switch.d.ts.map
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleFileView = exports.runFileView = exports.recentFileViewReads = exports.escapeLogUnsafe = exports.FILE_VIEW_LOG_LINES = exports.describeFileView = exports.setFileView = exports.fileViewOffBy = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const args_js_1 = require("./args.js");
6
+ const listener_process_js_1 = require("./listener-process.js");
7
+ const project_switch_js_1 = require("./project-switch.js");
8
+ const session_diff_js_1 = require("./session-diff.js");
9
+ /**
10
+ * The switch for the phone's file view (V13): one state file per project (`file-view-off-<hash>`) and one for
11
+ * every project (`file-view-off-default`), local to this machine. On unless turned off — the default is on (D10).
12
+ * `HWIP_FILE_VIEW=0` in the listener's environment turns it off for that run. The listener asks with the
13
+ * repository root of the session the request names, which is the directory `hwip file-view off` writes under.
14
+ */
15
+ const fileView = (0, project_switch_js_1.projectSwitch)('file-view', 'HWIP_FILE_VIEW');
16
+ /** Why the file view is off for the project at `dir`, or `null` when it is on. A switch file that cannot be read counts as off. */
17
+ exports.fileViewOffBy = fileView.offBy;
18
+ exports.setFileView = fileView.set;
19
+ const REASON = {
20
+ env: 'HWIP_FILE_VIEW is set to off here; a running listener reads its own environment',
21
+ project: 'turned off for this project',
22
+ global: 'turned off for every project',
23
+ unreadable: 'the switch file cannot be read, so it is treated as off',
24
+ };
25
+ const describeFileView = (offBy) => offBy === null ? 'file view: on' : `file view: off (${REASON[offBy]})`;
26
+ exports.describeFileView = describeFileView;
27
+ const USAGE = 'Usage: hwip file-view [status | on | off | log] [--global]';
28
+ /** How many reads `log` shows: the most recent. */
29
+ exports.FILE_VIEW_LOG_LINES = 50;
30
+ /**
31
+ * What a terminal or a reader obeys that must not reach a log line as itself: control characters (ESC, CR), DEL, C1
32
+ * controls (U+009B is CSI), zero-width and direction marks, line and paragraph separators, bidi overrides and isolates,
33
+ * the byte-order mark, interlinear annotation marks and the tag characters. Written by the listener after JSON quoting
34
+ * and applied again when the line is read back: a log is a file other lines also land in.
35
+ */
36
+ const LOG_UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\ufff9-\ufffb\u{e0000}-\u{e007f}]/gu;
37
+ const escapeLogUnsafe = (text) => text.replace(LOG_UNSAFE, (c) => {
38
+ const code = c.codePointAt(0);
39
+ return code > 0xffff ? `\\u{${code.toString(16)}}` : `\\u${code.toString(16).padStart(4, '0')}`;
40
+ });
41
+ exports.escapeLogUnsafe = escapeLogUnsafe;
42
+ /**
43
+ * The log line a read leaves (`listener.ts`, `logFileView`): the stamp, then `file-view: ` and the fields, in that
44
+ * shape. A line that only starts like one is not a read — the log also holds lines that carry names a relay chose.
45
+ */
46
+ const READ_LINE = /^\[[^\]]*\] file-view: (at=\S+ device=".*" session=".*" op=(?:tree|blob) path=".*" bytes=\d+ masked=\d+ result=[a-z_]+)$/;
47
+ /**
48
+ * The most recent reads in a listener log, oldest first, without the stamp (the line carries its own date).
49
+ * `older` is the log the listener keeps when it rotates; it is read only when the current one has too few.
50
+ */
51
+ const recentFileViewReads = (current, older, limit = exports.FILE_VIEW_LOG_LINES) => {
52
+ const reads = (text) => text.split('\n').flatMap((line) => {
53
+ const match = READ_LINE.exec(line);
54
+ return match ? [(0, exports.escapeLogUnsafe)(match[1])] : [];
55
+ });
56
+ const now = reads(current);
57
+ return (now.length >= limit ? now : [...reads(older), ...now]).slice(-limit);
58
+ };
59
+ exports.recentFileViewReads = recentFileViewReads;
60
+ /** A log that is not there is empty; one that is there and cannot be read is an error, not "no reads". */
61
+ const readIfThere = (path) => {
62
+ try {
63
+ return (0, node_fs_1.readFileSync)(path, 'utf8');
64
+ }
65
+ catch (err) {
66
+ if (err.code === 'ENOENT')
67
+ return '';
68
+ throw err;
69
+ }
70
+ };
71
+ const runFileView = (sub, global, deps) => {
72
+ const word = sub ?? 'status';
73
+ if (word !== 'status' && word !== 'on' && word !== 'off' && word !== 'log') {
74
+ deps.err(`hwip file-view: unknown subcommand '${word}'. ${USAGE}`);
75
+ return 2;
76
+ }
77
+ if (global && (word === 'status' || word === 'log')) {
78
+ deps.err(`hwip file-view: --global goes with on or off. ${USAGE}`);
79
+ return 2;
80
+ }
81
+ if (word === 'log') {
82
+ let reads;
83
+ try {
84
+ const { current, older } = deps.readLogs();
85
+ reads = (0, exports.recentFileViewReads)(current, older);
86
+ }
87
+ catch (err) {
88
+ deps.err(`hwip file-view: could not read the listener log: ${err.message}`);
89
+ return 1;
90
+ }
91
+ if (reads.length === 0) {
92
+ deps.out('file view: no reads on record');
93
+ // A listener run in the foreground writes to its terminal, so an empty log proves nothing about it.
94
+ deps.out('(only a listener started by hwip cc or as a service writes ~/.hwip/listener.log; one run in the foreground prints to its own terminal)');
95
+ }
96
+ for (const read of reads)
97
+ deps.out(read);
98
+ return 0;
99
+ }
100
+ const dir = (0, project_switch_js_1.projectDirOf)(deps.cwd(), deps.repoRoot);
101
+ if (word !== 'status') {
102
+ try {
103
+ (0, exports.setFileView)(dir, word === 'on', global ? 'global' : 'project');
104
+ }
105
+ catch (err) {
106
+ deps.err(`hwip file-view: could not change the switch: ${err.message}`);
107
+ return 1;
108
+ }
109
+ }
110
+ deps.out((0, exports.describeFileView)((0, exports.fileViewOffBy)(dir, deps.env)));
111
+ return 0;
112
+ };
113
+ exports.runFileView = runFileView;
114
+ const handleFileView = async (args) => (0, exports.runFileView)(typeof args._arg1 === 'string' && args._arg1 ? args._arg1 : undefined, (0, args_js_1.flagOn)(args, 'global'), {
115
+ cwd: () => process.cwd(),
116
+ out: (line) => console.log(line),
117
+ err: (line) => console.error(line),
118
+ env: process.env,
119
+ repoRoot: session_diff_js_1.repoRootOf,
120
+ readLogs: () => ({ current: readIfThere(listener_process_js_1.LISTENER_LOG_FILE), older: readIfThere(`${listener_process_js_1.LISTENER_LOG_FILE}.old`) }),
121
+ });
122
+ exports.handleFileView = handleFileView;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Reading a session's repository for the phone's file view (F5): the one place
3
+ * the working tree is opened for it. Design: hwip `docs/file-view.md` (V1~V14,
4
+ * D1~D13); this module is V6, V7, V9, V10, V11, D4 and D5.
5
+ *
6
+ * What a path may be and what the phone may see of the tree is decided here and
7
+ * nowhere else. Nothing here knows about sealing, the wire or who asked — the
8
+ * listener decides that; this decides what is read and what is never opened.
9
+ *
10
+ * git is run only through `gitIn`, with constant arguments. What this module adds
11
+ * is `ls-files` (twice) for the visible set and `check-attr` (through `filteredPaths`)
12
+ * for `filter=`; `resolveBase` and `filteredPaths` bring their own `rev-parse` and
13
+ * `hash-object`. None of them runs a filter. One caller path goes to git, after `--`.
14
+ */
15
+ import { type DiffResult } from './session-diff.js';
16
+ /** Most entries in one page of a directory listing. */
17
+ export declare const TREE_PAGE_MAX_ENTRIES = 500;
18
+ /** A file past this is not read. Same value as an untracked file's line count limit in the diff. */
19
+ export declare const FILE_VIEW_MAX_BYTES: number;
20
+ /**
21
+ * V7 ①, the string rules. A path is repo-relative and names one thing: not empty
22
+ * (a directory request's `""` for the root is the tree handler's to allow), no NUL
23
+ * or other control character, not absolute, at most 1,024 UTF-8 bytes, and no
24
+ * segment that is empty, `.`, `..` or — at any depth, in any case — `.git` (a
25
+ * case-insensitive file system opens `.GIT` as `.git`, D11). A leading `-` is fine:
26
+ * the path only ever follows `--`.
27
+ */
28
+ export declare const isViewablePath: (path: string) => boolean;
29
+ /** What kind of thing git lists at a path. `nested` is an untracked repository (`ls-files --others` prints it as `dir/`). */
30
+ export interface VisibleEntry {
31
+ /** The tracked mode (`100644`, `120000`, `160000`, …); absent for an untracked path. */
32
+ mode?: string;
33
+ /** The tracked object id (a gitlink's commit); absent for an untracked path. */
34
+ oid?: string;
35
+ nested?: true;
36
+ }
37
+ /**
38
+ * V6, the visible set: what git lists as tracked (`ls-files --stage`) plus what it lists as
39
+ * untracked and not ignored (`ls-files --others --exclude-standard`), under `scope` (the
40
+ * whole repository when absent). An ignored path is simply not there, so asking for it by
41
+ * name answers like a path that does not exist. The caller matches a path against the keys
42
+ * exactly; the pathspec only narrows what git has to print. A path the phone could never
43
+ * ask for (`isViewablePath`) or whose bytes are not UTF-8 is left out, so a listing never
44
+ * shows a name that cannot be opened.
45
+ */
46
+ export declare const visibleSet: (root: string, scope: string | undefined, deadline?: number) => DiffResult<{
47
+ entries: Map<string, VisibleEntry>;
48
+ }>;
49
+ export type TreeEntryType = 'file' | 'dir' | 'symlink' | 'submodule';
50
+ export interface TreeEntry {
51
+ name: string;
52
+ type: TreeEntryType;
53
+ }
54
+ /** `absent` is a path outside the visible set, ignored, or not a directory — the three look the same (V6). */
55
+ export type TreeResult = DiffResult<{
56
+ body: 'entries';
57
+ entries: TreeEntry[];
58
+ truncated: boolean;
59
+ cursor?: string;
60
+ } | {
61
+ body: 'absent';
62
+ }>;
63
+ /**
64
+ * One page of the entries directly under `dir` (`""` is the repository root), by
65
+ * name in UTF-8 byte order. `cursor` is the last name of the page before; the page is
66
+ * at most `TREE_PAGE_MAX_ENTRIES` and `truncated` says there is more. A directory is
67
+ * only a name some visible path continues under, so an empty one, an ignored one and
68
+ * one that is not there are all `absent`.
69
+ */
70
+ export declare const listTree: (root: string, dir: string, cursor?: string, deadline?: number) => TreeResult;
71
+ export type BlobBody = {
72
+ body: 'text';
73
+ text: string;
74
+ masked: number;
75
+ } | {
76
+ body: 'binary';
77
+ size: number;
78
+ } | {
79
+ body: 'symlink';
80
+ target: string;
81
+ } | {
82
+ body: 'filtered';
83
+ } | {
84
+ body: 'submodule';
85
+ commit?: string;
86
+ } | {
87
+ body: 'absent';
88
+ };
89
+ export type BlobResult = DiffResult<BlobBody>;
90
+ /** Test seams: where an agent could swap the file — after the symlink walk (V7 ③) and before the `lstat` that judges it, and between that `lstat` and the `open` that reads it. */
91
+ export interface ReadBlobOptions {
92
+ deadline?: number;
93
+ beforeLstat?: () => void;
94
+ beforeOpen?: () => void;
95
+ }
96
+ /**
97
+ * What a file looks like to the phone. The order is the design's V7: ① the string rules
98
+ * (`bad_path`) · ② the visible set, exact match · ③ no symlink on the way · ④ `lstat`: a
99
+ * symlink is shown as its target string and never followed, a gitlink or nested repository
100
+ * is shown as a submodule and never entered, anything but a regular file is `absent` ·
101
+ * D4 and V9 (`filter=`) say `filtered` · ⑤ open with `O_NOFOLLOW | O_NONBLOCK`, `fstat`
102
+ * the descriptor and compare it with ④ — a different file is `absent`, a hard link is
103
+ * `filtered` (D5) · V11: past 1 MiB is `too_large`, a NUL in the first 8,000 bytes is
104
+ * `binary` · V10: the whole text is masked before the caller pages it. A path outside the
105
+ * set, ignored, deleted or swapped is `absent`, never an error: the phone cannot tell them apart.
106
+ */
107
+ export declare const readBlob: (root: string, path: string, options?: ReadBlobOptions) => BlobResult;
108
+ //# sourceMappingURL=file-view.d.ts.map