@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,128 @@
1
+ /**
2
+ * Who this listener hands pane content to, and whether it still answers in
3
+ * the clear.
4
+ *
5
+ * The relay is not trusted with either question. A viewer asks for sealed
6
+ * content by sending a public key, and the relay can swap that key for its own
7
+ * or strip it: the first would have this listener seal the pane for the relay,
8
+ * the second would have it send the pane in the clear. So a key is taken only
9
+ * when
10
+ *
11
+ * • the account's device list holds it for the device the relay says is
12
+ * asking, and
13
+ * • it is pinned for that device: the key this listener saw for it the first
14
+ * time (trust on first use). A different key later (a reinstalled app) is
15
+ * pinned in place of the old one, with one line in the log and a record a
16
+ * person is shown later (`key-changes.ts`); nothing is refused for it.
17
+ *
18
+ * and once this listener pins any device it answers nobody in the clear
19
+ * again. The pin is local and sticky on purpose: the relay can say anything.
20
+ *
21
+ * What this does not stop: a server that registers a brand-new device with its
22
+ * own key is trusted on first use like any new phone. Comparing fingerprints
23
+ * (`hwip trust`) is the check for that.
24
+ *
25
+ * State is one small file, read again whenever it changed, so `hwip trust` can
26
+ * edit it beside a running daemon. Every write holds the lock in `trust-lock.ts`.
27
+ */
28
+ export type RefusalCode = 'e2ee_required' | 'subscriber_unverified' | 'subscriber_key_in_use';
29
+ /** What a requester gets: content sealed for a key, content in the clear, or nothing. */
30
+ export type Admission = {
31
+ kind: 'seal';
32
+ publicKey: string;
33
+ } | {
34
+ kind: 'plain';
35
+ } | {
36
+ kind: 'refuse';
37
+ reason: RefusalCode;
38
+ };
39
+ export interface SubscriberTrustDeps {
40
+ /** The state file (`~/.hwip/trusted-devices.json`). */
41
+ path: string;
42
+ /** The key the account's device list holds for a device; null when the
43
+ * device is not listed, has no key, or the list could not be read. */
44
+ registeredKey: (deviceId: string) => Promise<string | null>;
45
+ /** The name the account lists a device under, for the key-change record; undefined when the list does not say. */
46
+ nameOf?: (deviceId: string) => Promise<string | undefined>;
47
+ log: (msg: string) => void;
48
+ now?: () => Date;
49
+ }
50
+ export interface SubscriberTrust {
51
+ /** Decide how a request for pane content is answered. `publicKey` absent =
52
+ * the request asked for plaintext. Never rejects. */
53
+ admit: (requester: {
54
+ deviceId?: string;
55
+ publicKey?: string;
56
+ }) => Promise<Admission>;
57
+ /** Whether plaintext is refused from here on: this listener pins a device (or its file cannot be read). */
58
+ sealedOnly: () => boolean;
59
+ /** Take sealed command number `n` from `deviceId`: only above the last one
60
+ * taken from it, and only once it is on disk — a command the relay sends
61
+ * again, before or after a restart, is refused. False refuses. The
62
+ * numbers taken from the sender's `publicKey` under any id count too
63
+ * (`publicKeySender`): the id is the relay's stamp, the key is not. The
64
+ * key must be a device key. */
65
+ acceptCommand: (deviceId: string, n: number, publicKey: string) => boolean;
66
+ }
67
+ export interface PinnedDevice {
68
+ publicKey: string;
69
+ pinnedAt: string;
70
+ }
71
+ export interface TrustState {
72
+ /** deviceId → the key pinned for it. */
73
+ devices: Map<string, PinnedDevice>;
74
+ /** deviceId → the last sealed command number taken from it (M5 D8). Kept
75
+ * through `hwip trust` and `--reset`: forgetting it would let an old
76
+ * command be typed again. */
77
+ commands: Map<string, number>;
78
+ /** sender → the largest command number taken from it, where the sender is `publicKeySender` of the key it sealed
79
+ * with (x coordinate in hex). Beside `commands`, never overwritten by it: the id is the relay's stamp and a
80
+ * device can change key, but a number taken with a key stays taken, through `--reset` and a key change. A
81
+ * command is taken only above the larger of both columns. Absent from files written before it, and an older
82
+ * cli that saves the file drops it — the numbers then count by id and by pin only, as they did before. */
83
+ commandKeys: Map<string, number>;
84
+ /** Top-level fields of the file this version does not know (another writer's, or a later version's), kept as they were on every save. */
85
+ extra?: Record<string, unknown>;
86
+ }
87
+ /**
88
+ * The key column with the id-column numbers of the pinned devices in `ids` folded in, each under its pinned key. Files
89
+ * from before `commandKeys` hold a number only by id; a pin dropped or changed without this takes the one trace of
90
+ * that key's numbers with it, and an old command comes back under a new id.
91
+ */
92
+ export declare const foldCommandNumbers: (state: TrustState, ids: Iterable<string>) => Map<string, number>;
93
+ /** The file as read: its state, or why it cannot be used. */
94
+ export type LoadedTrust = {
95
+ state: TrustState;
96
+ } | {
97
+ broken: string;
98
+ };
99
+ /** A device id as the app, this cli and the server mint them (`dev_ios_0a1b2c3d`,
100
+ * `dev_listener_…`, `dev_<nanoid>`).
101
+ * The one on a request is relay JSON, and it ends up in a log line and in the
102
+ * `hwip trust <id>` this module tells a person to run: anything else is no id. */
103
+ export declare const DEVICE_ID: RegExp;
104
+ /** Devices one listener pins. A person's phones and tablets are a handful; the
105
+ * cap is what stops a device list padded with made-up devices from growing
106
+ * the file one rewrite at a time. */
107
+ export declare const MAX_PINNED_DEVICES = 64;
108
+ /** Read the state file. A missing file is the empty state; one that will not
109
+ * read or parse is `broken`, never a guess. */
110
+ export declare const readTrustState: (path: string) => LoadedTrust;
111
+ /** Replace the state file, whole and private. */
112
+ export declare const writeTrustState: (path: string, state: TrustState) => void;
113
+ /** What `planPin` finds for a device and key against the pins held. */
114
+ export type PinPlan = 'same' | 'new' | 'replace' | 'in-use' | 'cap';
115
+ /**
116
+ * How a device's key stands against the pins. `replace` is a device that comes back with another key
117
+ * (a reinstalled app); `in-use` is a key already pinned for another id (one key is one device — the id is
118
+ * the relay's stamp, a second id for a pinned key would have an empty record of the commands taken from
119
+ * it); `cap` is a new device past `MAX_PINNED_DEVICES`.
120
+ */
121
+ export declare const planPin: (state: TrustState, deviceId: string, publicKey: string) => PinPlan;
122
+ /**
123
+ * The state with `deviceId` pinned to `publicKey` (a new pin, or the old one replaced). The numbers taken with the key a
124
+ * replaced pin held move to the key column first — see `foldCommandNumbers`.
125
+ */
126
+ export declare const withPin: (state: TrustState, deviceId: string, publicKey: string, at: Date) => TrustState;
127
+ export declare const createSubscriberTrust: (deps: SubscriberTrustDeps) => SubscriberTrust;
128
+ //# sourceMappingURL=subscriber-trust.d.ts.map
@@ -0,0 +1,397 @@
1
+ "use strict";
2
+ /**
3
+ * Who this listener hands pane content to, and whether it still answers in
4
+ * the clear.
5
+ *
6
+ * The relay is not trusted with either question. A viewer asks for sealed
7
+ * content by sending a public key, and the relay can swap that key for its own
8
+ * or strip it: the first would have this listener seal the pane for the relay,
9
+ * the second would have it send the pane in the clear. So a key is taken only
10
+ * when
11
+ *
12
+ * • the account's device list holds it for the device the relay says is
13
+ * asking, and
14
+ * • it is pinned for that device: the key this listener saw for it the first
15
+ * time (trust on first use). A different key later (a reinstalled app) is
16
+ * pinned in place of the old one, with one line in the log and a record a
17
+ * person is shown later (`key-changes.ts`); nothing is refused for it.
18
+ *
19
+ * and once this listener pins any device it answers nobody in the clear
20
+ * again. The pin is local and sticky on purpose: the relay can say anything.
21
+ *
22
+ * What this does not stop: a server that registers a brand-new device with its
23
+ * own key is trusted on first use like any new phone. Comparing fingerprints
24
+ * (`hwip trust`) is the check for that.
25
+ *
26
+ * State is one small file, read again whenever it changed, so `hwip trust` can
27
+ * edit it beside a running daemon. Every write holds the lock in `trust-lock.ts`.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.createSubscriberTrust = exports.withPin = exports.planPin = exports.writeTrustState = exports.readTrustState = exports.MAX_PINNED_DEVICES = exports.DEVICE_ID = exports.foldCommandNumbers = void 0;
31
+ const fs_1 = require("fs");
32
+ const node_crypto_1 = require("node:crypto");
33
+ const path_1 = require("path");
34
+ const crypto_js_1 = require("./crypto.js");
35
+ const key_changes_js_1 = require("./key-changes.js");
36
+ const private_files_js_1 = require("./private-files.js");
37
+ const trust_lock_js_1 = require("./trust-lock.js");
38
+ /**
39
+ * The key column with the id-column numbers of the pinned devices in `ids` folded in, each under its pinned key. Files
40
+ * from before `commandKeys` hold a number only by id; a pin dropped or changed without this takes the one trace of
41
+ * that key's numbers with it, and an old command comes back under a new id.
42
+ */
43
+ const foldCommandNumbers = (state, ids) => {
44
+ const folded = new Map(state.commandKeys);
45
+ for (const id of ids) {
46
+ const pin = state.devices.get(id);
47
+ const sender = pin ? (0, crypto_js_1.publicKeySender)(pin.publicKey) : null;
48
+ const number = state.commands.get(id);
49
+ if (sender !== null && number !== undefined)
50
+ folded.set(sender, Math.max(folded.get(sender) ?? number, number));
51
+ }
52
+ return folded;
53
+ };
54
+ exports.foldCommandNumbers = foldCommandNumbers;
55
+ const STORE_VERSION = 1;
56
+ /** The fields this version reads or retired (`sealedOnce`); any other is kept as it is. */
57
+ const KNOWN_FIELDS = new Set(['version', 'sealedOnce', 'devices', 'commands', 'commandKeys']);
58
+ /** A device id as the app, this cli and the server mint them (`dev_ios_0a1b2c3d`,
59
+ * `dev_listener_…`, `dev_<nanoid>`).
60
+ * The one on a request is relay JSON, and it ends up in a log line and in the
61
+ * `hwip trust <id>` this module tells a person to run: anything else is no id. */
62
+ exports.DEVICE_ID = /^[A-Za-z0-9_-]{1,64}$/;
63
+ /** Devices one listener pins. A person's phones and tablets are a handful; the
64
+ * cap is what stops a device list padded with made-up devices from growing
65
+ * the file one rewrite at a time. */
66
+ exports.MAX_PINNED_DEVICES = 64;
67
+ /** `publicKeySender` as it is written in the file. */
68
+ const SENDER = /^[0-9a-f]{64}$/;
69
+ const isCommandNumber = (n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
70
+ const parseState = (text) => {
71
+ const raw = JSON.parse(text);
72
+ if (typeof raw !== 'object' || raw === null)
73
+ throw new Error('not an object');
74
+ if (!('version' in raw) || raw.version !== STORE_VERSION)
75
+ throw new Error('unknown version');
76
+ if (!('devices' in raw) || typeof raw.devices !== 'object' || raw.devices === null)
77
+ throw new Error('devices is not an object');
78
+ const devices = new Map();
79
+ for (const [deviceId, pin] of Object.entries(raw.devices)) {
80
+ const isPin = typeof pin === 'object' && pin !== null
81
+ && 'publicKey' in pin && typeof pin.publicKey === 'string'
82
+ && 'pinnedAt' in pin && typeof pin.pinnedAt === 'string';
83
+ if (!isPin)
84
+ throw new Error(`device ${deviceId} is not { publicKey, pinnedAt }`);
85
+ devices.set(deviceId, { publicKey: pin.publicKey, pinnedAt: pin.pinnedAt });
86
+ }
87
+ // Absent from files written before sealed commands.
88
+ const commands = new Map();
89
+ if ('commands' in raw) {
90
+ if (typeof raw.commands !== 'object' || raw.commands === null)
91
+ throw new Error('commands is not an object');
92
+ for (const [deviceId, n] of Object.entries(raw.commands)) {
93
+ if (!isCommandNumber(n))
94
+ throw new Error(`command number of ${deviceId} is not a whole number`);
95
+ commands.set(deviceId, n);
96
+ }
97
+ }
98
+ // Optional and only ever a help: an entry that is not a sender and a number is left out, not an error. Every entry
99
+ // is read, however many: a file past the cap (by hand) must not forget the numbers it holds; the cap only stops new ones.
100
+ const commandKeys = new Map();
101
+ if ('commandKeys' in raw && typeof raw.commandKeys === 'object' && raw.commandKeys !== null && !Array.isArray(raw.commandKeys)) {
102
+ for (const [sender, n] of Object.entries(raw.commandKeys)) {
103
+ if (SENDER.test(sender) && isCommandNumber(n))
104
+ commandKeys.set(sender, n);
105
+ }
106
+ }
107
+ // `sealedOnce` was this file's before every send was sealed: it is read past and not written back.
108
+ const extra = Object.fromEntries(Object.entries(raw).filter(([key]) => !KNOWN_FIELDS.has(key)));
109
+ return { devices, commands, commandKeys, ...(Object.keys(extra).length > 0 ? { extra } : {}) };
110
+ };
111
+ /** Read the state file. A missing file is the empty state; one that will not
112
+ * read or parse is `broken`, never a guess. */
113
+ const readTrustState = (path) => {
114
+ let text;
115
+ try {
116
+ text = (0, fs_1.readFileSync)(path, 'utf-8');
117
+ }
118
+ catch (err) {
119
+ if (err.code === 'ENOENT')
120
+ return { state: { devices: new Map(), commands: new Map(), commandKeys: new Map() } }; // err.code is how node names a missing file
121
+ return { broken: err instanceof Error ? err.message : String(err) };
122
+ }
123
+ try {
124
+ return { state: parseState(text) };
125
+ }
126
+ catch (err) {
127
+ return { broken: err instanceof Error ? err.message : String(err) };
128
+ }
129
+ };
130
+ exports.readTrustState = readTrustState;
131
+ /** Replace the state file, whole and private. */
132
+ const writeTrustState = (path, state) => {
133
+ (0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(path), 0o700);
134
+ const temp = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
135
+ const body = {
136
+ ...state.extra,
137
+ version: STORE_VERSION,
138
+ devices: Object.fromEntries(state.devices),
139
+ ...(state.commands.size > 0 ? { commands: Object.fromEntries(state.commands) } : {}),
140
+ ...(state.commandKeys.size > 0 ? { commandKeys: Object.fromEntries(state.commandKeys) } : {}),
141
+ };
142
+ // Synced before the rename: a crash must leave the old file or the new
143
+ // one, not an empty one that reads as "unusable".
144
+ const fd = (0, fs_1.openSync)(temp, 'wx', 0o600);
145
+ try {
146
+ try {
147
+ (0, fs_1.writeSync)(fd, `${JSON.stringify(body, null, 2)}\n`);
148
+ (0, fs_1.fsyncSync)(fd);
149
+ }
150
+ finally {
151
+ (0, fs_1.closeSync)(fd);
152
+ }
153
+ (0, private_files_js_1.handBackToSudoUser)(temp);
154
+ (0, fs_1.renameSync)(temp, path);
155
+ }
156
+ catch (err) {
157
+ // A listener that keeps failing to save would otherwise leave one of these per request.
158
+ (0, fs_1.rmSync)(temp, { force: true });
159
+ throw err;
160
+ }
161
+ };
162
+ exports.writeTrustState = writeTrustState;
163
+ /**
164
+ * How a device's key stands against the pins. `replace` is a device that comes back with another key
165
+ * (a reinstalled app); `in-use` is a key already pinned for another id (one key is one device — the id is
166
+ * the relay's stamp, a second id for a pinned key would have an empty record of the commands taken from
167
+ * it); `cap` is a new device past `MAX_PINNED_DEVICES`.
168
+ */
169
+ const planPin = (state, deviceId, publicKey) => {
170
+ const pin = state.devices.get(deviceId);
171
+ if (pin && (0, crypto_js_1.samePublicKey)(pin.publicKey, publicKey))
172
+ return 'same';
173
+ const sender = (0, crypto_js_1.publicKeySender)(publicKey);
174
+ for (const [otherId, other] of state.devices) {
175
+ if (otherId !== deviceId && (0, crypto_js_1.publicKeySender)(other.publicKey) === sender)
176
+ return 'in-use';
177
+ }
178
+ if (pin)
179
+ return 'replace';
180
+ return state.devices.size >= exports.MAX_PINNED_DEVICES ? 'cap' : 'new';
181
+ };
182
+ exports.planPin = planPin;
183
+ /**
184
+ * The state with `deviceId` pinned to `publicKey` (a new pin, or the old one replaced). The numbers taken with the key a
185
+ * replaced pin held move to the key column first — see `foldCommandNumbers`.
186
+ */
187
+ const withPin = (state, deviceId, publicKey, at) => ({
188
+ ...state,
189
+ devices: new Map(state.devices).set(deviceId, { publicKey, pinnedAt: at.toISOString() }),
190
+ commandKeys: (0, exports.foldCommandNumbers)(state, [deviceId]),
191
+ });
192
+ exports.withPin = withPin;
193
+ const createSubscriberTrust = (deps) => {
194
+ const now = deps.now ?? (() => new Date());
195
+ let cached = null;
196
+ let brokenLogged = false;
197
+ /** This process has read a pinned device from the file. */
198
+ let sawPins = false;
199
+ /** inode, mtime and size: enough to see an edit without reading the file on
200
+ * every request. The inode is what catches a rewrite of the same length
201
+ * within the clock's resolution — every save here is a rename. */
202
+ const stampOf = () => {
203
+ try {
204
+ const s = (0, fs_1.statSync)(deps.path);
205
+ return `${s.ino}:${s.mtimeMs}:${s.size}`;
206
+ }
207
+ catch (err) {
208
+ if (err.code === 'ENOENT')
209
+ return null; // err.code is how node names a missing file
210
+ throw err;
211
+ }
212
+ };
213
+ /** The file as it is now, re-read only when it changed. */
214
+ const read = () => {
215
+ let stamp;
216
+ try {
217
+ stamp = stampOf();
218
+ }
219
+ catch (err) {
220
+ return { broken: err instanceof Error ? err.message : String(err) };
221
+ }
222
+ if (stamp === null) {
223
+ // Gone after it pinned a device: whoever removed it, this
224
+ // process does not take that as leave to answer in the clear.
225
+ // (`hwip trust --reset` rewrites the file; it does not remove it.)
226
+ return sawPins ? { broken: 'removed while this listener was running' } : (0, exports.readTrustState)(deps.path);
227
+ }
228
+ if (cached?.stamp === stamp)
229
+ return cached.loaded;
230
+ const loaded = (0, exports.readTrustState)(deps.path);
231
+ if ('state' in loaded) {
232
+ cached = { stamp, loaded };
233
+ sawPins = loaded.state.devices.size > 0;
234
+ }
235
+ return loaded;
236
+ };
237
+ const load = () => {
238
+ const loaded = read();
239
+ if ('state' in loaded) {
240
+ brokenLogged = false;
241
+ return loaded;
242
+ }
243
+ // A file that will not read could have said anything, including
244
+ // "has pinned a device": answering in the clear on that is the downgrade.
245
+ if (!brokenLogged) {
246
+ brokenLogged = true;
247
+ deps.log(`! trust: ${deps.path} is unusable (${loaded.broken}) — refusing every request for pane content until it is fixed or reset with \`hwip trust --reset\``);
248
+ }
249
+ return loaded;
250
+ };
251
+ /**
252
+ * Change the file under its lock, on the file as it is once the lock is held. `change` returns the
253
+ * state to write, or null to write nothing, and the key changes that write makes; its other result goes
254
+ * back to the caller. Throws when the lock cannot be had or the write fails.
255
+ */
256
+ const update = (change) => {
257
+ const done = (0, trust_lock_js_1.withTrustLock)(deps.path, () => {
258
+ const loaded = (0, exports.readTrustState)(deps.path);
259
+ if ('broken' in loaded)
260
+ throw new Error(`the trust file is unusable (${loaded.broken})`);
261
+ const out = change(loaded.state);
262
+ if (out.state) {
263
+ (0, exports.writeTrustState)(deps.path, out.state);
264
+ sawPins = out.state.devices.size > 0;
265
+ if (!(0, key_changes_js_1.recordKeyChanges)(deps.path, out.keyChanges ?? [])) {
266
+ deps.log('! trust: could not save that key change for later (key-changes.json) — this line is the only notice');
267
+ }
268
+ }
269
+ return out.result;
270
+ });
271
+ if (!done.locked)
272
+ throw new Error('the trust file is locked by another writer');
273
+ return done.value;
274
+ };
275
+ const refuse = (reason, why) => {
276
+ deps.log(`! trust: refused (${reason}) — ${why}`);
277
+ return { kind: 'refuse', reason };
278
+ };
279
+ const admit = async ({ deviceId, publicKey }) => {
280
+ if (publicKey === undefined) {
281
+ const loaded = load();
282
+ const sealedOnly = 'broken' in loaded || loaded.state.devices.size > 0;
283
+ return sealedOnly ? refuse('e2ee_required', 'a plaintext request, and this listener only seals') : { kind: 'plain' };
284
+ }
285
+ // The relay stamps the sending device; without it there is no device
286
+ // whose registered key this one could be checked against.
287
+ if (!deviceId)
288
+ return refuse('subscriber_unverified', 'the request names no device');
289
+ if (!exports.DEVICE_ID.test(deviceId))
290
+ return refuse('subscriber_unverified', 'the request names something that is not a device id');
291
+ let registered;
292
+ try {
293
+ registered = await deps.registeredKey(deviceId);
294
+ }
295
+ catch (err) {
296
+ return refuse('subscriber_unverified', `the device list could not be read (${err instanceof Error ? err.message : String(err)})`);
297
+ }
298
+ if (registered === null)
299
+ return refuse('subscriber_unverified', `${deviceId} has no registered key (or the device list is unavailable)`);
300
+ if (!(0, crypto_js_1.samePublicKey)(registered, publicKey))
301
+ return refuse('subscriber_unverified', `the key is not the one registered for ${deviceId}`);
302
+ // Read after the lookup: `hwip trust` may have changed the pin meanwhile.
303
+ const loaded = load();
304
+ if ('broken' in loaded)
305
+ return refuse('subscriber_unverified', 'the trust file is unusable');
306
+ const plan = (0, exports.planPin)(loaded.state, deviceId, publicKey);
307
+ if (plan === 'in-use') {
308
+ return refuse('subscriber_key_in_use', `${deviceId} asks with the key pinned for another id — if it is the same device, compare fingerprints, then \`hwip trust ${deviceId}\``);
309
+ }
310
+ if (plan === 'cap') {
311
+ return refuse('subscriber_unverified', `${exports.MAX_PINNED_DEVICES} devices are pinned already — \`hwip trust --reset\` starts over`);
312
+ }
313
+ if (plan === 'same')
314
+ return { kind: 'seal', publicKey };
315
+ // Looked up outside the lock, which is held only for file I/O.
316
+ const name = plan === 'replace' ? await deps.nameOf?.(deviceId).catch(() => undefined) : undefined;
317
+ try {
318
+ // Sealing without the record would forget, at the next start, that plaintext is over.
319
+ const settled = update((state) => {
320
+ const again = (0, exports.planPin)(state, deviceId, publicKey);
321
+ if (again !== 'new' && again !== 'replace')
322
+ return { state: null, result: again };
323
+ const at = now();
324
+ return {
325
+ state: (0, exports.withPin)(state, deviceId, publicKey, at),
326
+ result: again,
327
+ keyChanges: again === 'replace' ? [{ deviceId, at: at.toISOString(), ...(name ? { name } : {}) }] : [],
328
+ };
329
+ });
330
+ if (settled === 'in-use' || settled === 'cap')
331
+ return refuse('subscriber_unverified', 'the pins changed while this request was checked');
332
+ // `same`: another writer pinned it first, and that writer logged it.
333
+ if (settled === 'replace')
334
+ deps.log(`trust: ${deviceId} changed its key — now pinned to the new one`);
335
+ else if (settled === 'new')
336
+ deps.log(`trust: pinned ${deviceId} on first use`);
337
+ }
338
+ catch (err) {
339
+ return refuse('subscriber_unverified', `the trust file could not be written (${err instanceof Error ? err.message : String(err)})`);
340
+ }
341
+ return { kind: 'seal', publicKey };
342
+ };
343
+ return {
344
+ admit,
345
+ sealedOnly: () => {
346
+ const loaded = load();
347
+ return 'broken' in loaded || loaded.state.devices.size > 0;
348
+ },
349
+ acceptCommand: (deviceId, n, publicKey) => {
350
+ const sender = (0, crypto_js_1.publicKeySender)(publicKey);
351
+ if (!exports.DEVICE_ID.test(deviceId) || !isCommandNumber(n) || sender === null) {
352
+ deps.log('! trust: a sealed command with no usable sender, key or number — dropped');
353
+ return false;
354
+ }
355
+ const loaded = load();
356
+ if ('broken' in loaded)
357
+ return false;
358
+ try {
359
+ return update((state) => {
360
+ const { commands, commandKeys, devices } = state;
361
+ // The number must clear every number taken under this id, with this key (whatever id it came under), and under
362
+ // any id pinned to this key (files from before `commandKeys` have no other trace of it).
363
+ let last = commands.get(deviceId);
364
+ const raise = (theirs) => {
365
+ if (theirs !== undefined)
366
+ last = Math.max(last ?? theirs, theirs);
367
+ };
368
+ raise(commandKeys.get(sender));
369
+ for (const [otherId, pin] of devices) {
370
+ if ((0, crypto_js_1.publicKeySender)(pin.publicKey) === sender)
371
+ raise(commands.get(otherId));
372
+ }
373
+ if (last !== undefined && n <= last) {
374
+ deps.log(`! trust: sealed command ${n} from ${deviceId} is not after ${last} — a replay, dropped`);
375
+ return { state: null, result: false };
376
+ }
377
+ // Nothing is forgotten to make room: a number dropped is a command that can be typed again.
378
+ if (!commands.has(deviceId) && commands.size >= exports.MAX_PINNED_DEVICES) {
379
+ deps.log(`! trust: ${exports.MAX_PINNED_DEVICES} senders have numbers already — sealed command from ${deviceId} dropped (\`commands\` in ${deps.path} holds them)`);
380
+ return { state: null, result: false };
381
+ }
382
+ if (!commandKeys.has(sender) && commandKeys.size >= exports.MAX_PINNED_DEVICES) {
383
+ deps.log(`! trust: ${exports.MAX_PINNED_DEVICES} keys have numbers already — sealed command from ${deviceId} dropped (\`commandKeys\` in ${deps.path} holds them)`);
384
+ return { state: null, result: false };
385
+ }
386
+ return { state: { ...state, commands: new Map(commands).set(deviceId, n), commandKeys: new Map(commandKeys).set(sender, n) }, result: true };
387
+ });
388
+ }
389
+ catch (err) {
390
+ // Typed without the record, it could be typed again after a restart.
391
+ deps.log(`! trust: sealed command number not written (${err instanceof Error ? err.message : String(err)}) — dropped`);
392
+ return false;
393
+ }
394
+ },
395
+ };
396
+ };
397
+ exports.createSubscriberTrust = createSubscriberTrust;
@@ -0,0 +1,105 @@
1
+ /** Grace before the pi extension turns an unanswered prompt into a push. Exported for the tests. */
2
+ export declare const PI_PROMPT_GRACE_MS = 10000;
3
+ /** Cursor — written to ~/.cursor/rules/hwip.mdc (needs .mdc frontmatter). No prompt hook. */
4
+ export declare const CURSOR_RULE: string;
5
+ /** Windsurf — appended into ~/.codeium/windsurf/memories/global_rules.md. No prompt hook. */
6
+ export declare const WINDSURF_RULE: string;
7
+ /** Gemini CLI — appended into ~/.gemini/GEMINI.md. Has the prompt hook (GEMINI_HOOKS), so NORMAL-only core. */
8
+ export declare const GEMINI_RULE: string;
9
+ /** Codex CLI — appended into ~/.codex/AGENTS.md. Has the prompt hook (CODEX_HOOKS), so NORMAL-only core. */
10
+ export declare const CODEX_RULE: string;
11
+ /** GitHub Copilot CLI — written to ~/.copilot/instructions/hwip.instructions.md. No prompt hook. */
12
+ export declare const COPILOT_RULE: string;
13
+ /** Cline — written to ~/.cline/rules/hwip.md (no Stop hook, no prompt hook). */
14
+ export declare const CLINE_RULE: string;
15
+ /** Aider — standalone conventions file via .aider.conf.yml `read:` (no Stop hook, no prompt hook). */
16
+ export declare const AIDER_RULE: string;
17
+ /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook + waiting-on-you push (PI_EXTENSION), so NORMAL-only core. */
18
+ export declare const PI_RULE: string;
19
+ /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
20
+ export declare const OPENCODE_RULE: string;
21
+ export declare const CURSOR_HOOKS: {
22
+ version: number;
23
+ hooks: {
24
+ stop: {
25
+ command: string;
26
+ }[];
27
+ };
28
+ };
29
+ export declare const WINDSURF_HOOKS: {
30
+ hooks: {
31
+ post_cascade_response: {
32
+ command: string;
33
+ show_output: boolean;
34
+ }[];
35
+ };
36
+ };
37
+ export declare const GEMINI_HOOKS: {
38
+ hooks: {
39
+ BeforeAgent: {
40
+ matcher: string;
41
+ hooks: {
42
+ name: string;
43
+ type: string;
44
+ command: string;
45
+ timeout: number;
46
+ }[];
47
+ }[];
48
+ AfterAgent: {
49
+ matcher: string;
50
+ hooks: {
51
+ name: string;
52
+ type: string;
53
+ command: string;
54
+ timeout: number;
55
+ }[];
56
+ }[];
57
+ };
58
+ hooksConfig: {
59
+ enabled: boolean;
60
+ };
61
+ };
62
+ export declare const CODEX_HOOKS: {
63
+ hooks: {
64
+ UserPromptSubmit: {
65
+ hooks: {
66
+ type: string;
67
+ command: string;
68
+ timeout: number;
69
+ }[];
70
+ }[];
71
+ Stop: {
72
+ hooks: {
73
+ type: string;
74
+ command: string;
75
+ timeout: number;
76
+ }[];
77
+ }[];
78
+ };
79
+ };
80
+ /**
81
+ * True when a hook entry was written by hwip — by handler `name` (`hwip-*`,
82
+ * Gemini) or by the `@hwip/cli` command substring (Codex, Cursor, Windsurf,
83
+ * whose handlers have no name field; every hwip command carries the substring
84
+ * via the `command -v hwip || npx -y @hwip/cli` fallback). The entry is a
85
+ * matcher group holding `hooks` (Gemini, Codex) or a flat handler (Cursor,
86
+ * Windsurf). Shared by the installer (replace ours, keep the user's on
87
+ * re-install) and the uninstaller (remove exactly ours).
88
+ */
89
+ export declare const isHwipHookGroup: (group: unknown) => boolean;
90
+ export declare const COPILOT_HOOKS: string;
91
+ /** Pi extension source — written to ~/.pi/agent/extensions/hwip.ts (drop-in auto-load). */
92
+ export declare const PI_EXTENSION: string;
93
+ /** OpenCode plugin source — written to ~/.config/opencode/plugins/hwip.ts (drop-in auto-load). */
94
+ export declare const OPENCODE_PLUGIN: string;
95
+ export declare const HWIP_MARK_START = "<!-- HWIP:START \u2014 managed by @hwip/cli, do not edit between markers -->";
96
+ export declare const HWIP_MARK_END = "<!-- HWIP:END -->";
97
+ /**
98
+ * Return `existing` with the Hwip-managed block inserted or replaced.
99
+ * If the markers are already present, the content between them is
100
+ * swapped; otherwise the block is appended.
101
+ */
102
+ export declare const upsertManagedBlock: (existing: string, rule: string) => string;
103
+ /** Strip the Hwip-managed block from a shared file (for uninstall). */
104
+ export declare const removeManagedBlock: (existing: string) => string;
105
+ //# sourceMappingURL=templates.d.ts.map