@hwip/cli 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
package/dist/ask.js ADDED
@@ -0,0 +1,551 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleAsk = exports.liveDeps = exports.liveSealDeps = exports.remoteTransition = exports.requestApproval = exports.parseActions = exports.pollIntervalMs = void 0;
4
+ /**
5
+ * `hwip ask` — put a question on the user's phone and block until they answer.
6
+ *
7
+ * Why this lives in the CLI at all: hooks are shell scripts, and a shell script
8
+ * cannot call an MCP tool. `hwip_ask` (mcp-server) already does this for the
9
+ * model; a hook that needs to wait for a person — an approval gate in front of
10
+ * a dangerous command — has no way to reach it. This is that path.
11
+ *
12
+ * It returns ONE shape for every outcome, including failure. A hook standing in
13
+ * front of `rm -rf` cannot handle an exception, and an approval gate that
14
+ * throws has stopped being a gate: unreachable server, bad key, and silence all
15
+ * come back as `answered: false` so the caller makes the same decision it would
16
+ * have made on a refusal. `reason` says which (`cancelled`, `timeout`, `error`)
17
+ * for a caller that wants to tell them apart.
18
+ *
19
+ * No WebSocket fast path, unlike mcp-server's poll. A hook run is seconds long
20
+ * and dies with the tool call, so a socket would cost a handshake it cannot
21
+ * amortise; plain polling is the whole protocol here.
22
+ */
23
+ const node_crypto_1 = require("node:crypto");
24
+ const config_js_1 = require("./config.js");
25
+ const agent_session_js_1 = require("./agent-session.js");
26
+ const gate_js_1 = require("./gate.js");
27
+ const http_failure_js_1 = require("./http-failure.js");
28
+ const insecure_url_js_1 = require("./insecure-url.js");
29
+ const args_js_1 = require("./args.js");
30
+ const api_js_1 = require("./api.js");
31
+ const crypto_js_1 = require("./crypto.js");
32
+ const listener_device_id_js_1 = require("./listener-device-id.js");
33
+ const config_js_2 = require("./config.js");
34
+ const self_register_js_1 = require("./self-register.js");
35
+ const ask_seal_js_1 = require("./ask-seal.js");
36
+ const ask_pending_js_1 = require("./ask-pending.js");
37
+ /** Server-side hook trigger + event read. Kept narrow on purpose — this
38
+ * module needs two routes, not an API client. */
39
+ const TRIGGER_PATH = (hookId) => `/hooks/${encodeURIComponent(hookId)}/trigger`;
40
+ const EVENT_PATH = (hookId, eventId) => `/hooks/${encodeURIComponent(hookId)}/events/${encodeURIComponent(eventId)}`;
41
+ /** Poll cadence: every second for the first 30 s, when a tap is most likely
42
+ * and should feel immediate, then every 2.5 s — a 10-minute wait is about
43
+ * 250 requests instead of 600. Exported for tests. */
44
+ const pollIntervalMs = (waitedMs) => (waitedMs < 30_000 ? 1_000 : 2_500);
45
+ exports.pollIntervalMs = pollIntervalMs;
46
+ /** Per-request bound, well under any sane overall deadline. */
47
+ const REQUEST_TIMEOUT_MS = 10_000;
48
+ /** The least a try at sending is waited on, however little of the window is left: one with no time to be answered is not a try. */
49
+ const MIN_REQUEST_MS = 1_000;
50
+ /** Failed polls in a row before the wait ends early. At the poll cadence this
51
+ * is 5–13 s of outage — long enough to ride out a blip, short enough that a
52
+ * gate does not sit out its whole timeout against a dead server. */
53
+ const MAX_CONSECUTIVE_FAILURES = 5;
54
+ /** Pauses before the tries that follow a trigger failure that may pass: four tries in all. */
55
+ const TRIGGER_BACKOFF_MS = [1_000, 2_000, 4_000];
56
+ /**
57
+ * A first request still running under the key answers a retry with 409 until it ends. One the server
58
+ * lost to its own timeout never ends: the key stays taken for 30 s (hwip `server/src/entities/idempotency.ts`
59
+ * STALE_CLAIM_MS, read 2026-10-14) and a retry after that is taken as a new request. So these tries are
60
+ * counted apart from the ones above, and go on until that hold has passed: seven more, 35 s of
61
+ * waiting from the first 409. The key was taken before that answer, so the last try comes after the
62
+ * hold whatever the first request took to fail.
63
+ */
64
+ const IN_PROGRESS_RETRY_MS = 5_000;
65
+ const IN_PROGRESS_RETRIES = 7;
66
+ /**
67
+ * Said of a question a retry put up, unless the answer says the phone was notified. A repeat the
68
+ * server had already taken is answered with the question's ids alone (hwip `server/src/functions/hooks.ts`,
69
+ * `once.replayed`, read 2026-10-14): the push went out with the first request or not at all, and only
70
+ * that request's answer said which.
71
+ */
72
+ const UNCONFIRMED_NOTIFY = 'the question went up on a retry, and the server did not say the phone was notified: it is in the app either way';
73
+ /**
74
+ * How a 2xx to the trigger ends when it holds no event id to read by — none, or one not written as an id
75
+ * (`ID`), which would go into the path of every read and into the record the next run reads by. Nothing is
76
+ * read and nothing kept, and the caller is told not to take it for a question that never went up: asked
77
+ * again, it is a second card. "May": a 2xx with such a body can be a proxy's as well as the server's.
78
+ */
79
+ const NOT_READ_HERE = 'the question may be on the phone, and no answer to it can be read here \u2014 check the phone first: asking again puts a second card next to it';
80
+ /**
81
+ * `id:Label` pairs, comma separated. The label may contain colons — only the
82
+ * first one separates, so `go:Deploy: prod` keeps its punctuation. A bare
83
+ * segment becomes its own label, which is what someone typing `--actions ok`
84
+ * means.
85
+ */
86
+ const parseActions = (spec) => {
87
+ if (!spec)
88
+ return [];
89
+ return spec
90
+ .split(',')
91
+ .map((seg) => seg.trim())
92
+ .filter((seg) => seg.length > 0)
93
+ .map((seg) => {
94
+ const idx = seg.indexOf(':');
95
+ if (idx === -1)
96
+ return { id: seg, label: seg };
97
+ const id = seg.slice(0, idx).trim();
98
+ const label = seg.slice(idx + 1).trim();
99
+ return { id, label: label || id };
100
+ })
101
+ .filter((a) => a.id.length > 0);
102
+ };
103
+ exports.parseActions = parseActions;
104
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
105
+ /** A non-2xx reply, kept apart from a network failure so the caller can tell
106
+ * a refusal that will repeat from a blip that may not. */
107
+ class HttpError extends Error {
108
+ status;
109
+ code;
110
+ constructor(status, message, code) {
111
+ super(message);
112
+ this.status = status;
113
+ this.code = code;
114
+ }
115
+ }
116
+ /** A 4xx other than 408 / 429 is an answer about the request itself — bad key,
117
+ * hook gone — and asking again gets the same answer. */
118
+ const isFatal = (err) => err instanceof HttpError && err.status >= 400 && err.status < 500 && err.status !== 408 && err.status !== 429;
119
+ /**
120
+ * Whether a failed trigger is sent again, and after which pause. The trigger goes under an
121
+ * Idempotency-Key, so a second copy of one that landed opens nothing: the server answers it with the
122
+ * first question's ids. That leaves only a refusal not worth repeating — a 4xx about the request
123
+ * itself, or a redirect, which the server will answer the same way again — and an address `fetch`
124
+ * cannot parse or a header it will not send, which no try will send.
125
+ */
126
+ const resendAfter = (err) => {
127
+ if (err instanceof HttpError && err.status === 409 && err.code === 'IDEMPOTENCY_IN_PROGRESS')
128
+ return 'in-progress';
129
+ if (isFatal(err) || (err instanceof TypeError && ((0, http_failure_js_1.isRedirectRefusal)(err) || (0, http_failure_js_1.isInvalidUrl)(err) || (0, http_failure_js_1.isUnsendableHeader)(err))) || (0, http_failure_js_1.isConfigFailure)(err))
130
+ return 'never';
131
+ return 'backoff';
132
+ };
133
+ const callApi = async (deps, opts, method, path, request = {}) => {
134
+ const { payload, until } = request;
135
+ const controller = new AbortController();
136
+ const patience = until === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, Math.max(MIN_REQUEST_MS, until - deps.now()));
137
+ const timer = setTimeout(() => controller.abort(new DOMException(`the request timed out after ${Math.round(patience / 1000)} s`, 'TimeoutError')), patience);
138
+ try {
139
+ const headers = { 'X-API-Key': opts.apiKey };
140
+ if (payload)
141
+ Object.assign(headers, { 'Content-Type': 'application/json' }, payload.headers);
142
+ const url = `${opts.baseUrl}${path}`;
143
+ let res;
144
+ try {
145
+ res = await deps.fetchFn(url, {
146
+ method,
147
+ headers,
148
+ body: payload?.body,
149
+ signal: controller.signal,
150
+ // A redirect would carry the X-API-Key header to another host.
151
+ redirect: 'error',
152
+ });
153
+ }
154
+ catch (err) {
155
+ // Still a TypeError, so resendAfter reads it as before — with
156
+ // the cause a person can act on instead of "fetch failed". A refused redirect keeps its
157
+ // original; an unsendable header's words quote the key whole, so its cause is dropped.
158
+ if (err instanceof DOMException && err.name === 'TimeoutError')
159
+ throw new Error(err.message, { cause: err });
160
+ // The cause is what `neverConnected` reads the connection from, so it is kept — except an
161
+ // unsendable header's, whose words quote the key whole (the sanitized message still classifies).
162
+ if (err instanceof TypeError && (0, http_failure_js_1.isUnsendableHeader)(err))
163
+ throw new TypeError((0, http_failure_js_1.networkFailureMessage)(err, url));
164
+ if (err instanceof TypeError)
165
+ throw new TypeError((0, http_failure_js_1.networkFailureMessage)(err, url), { cause: err });
166
+ throw err;
167
+ }
168
+ const json = (await (0, http_failure_js_1.readJsonBody)(res));
169
+ if (!res.ok) {
170
+ const message = (0, http_failure_js_1.httpFailureMessage)(res.status, json);
171
+ throw new HttpError(res.status, res.status === 401 ? `${message} — ${http_failure_js_1.AUTH_HINT}` : message, json?.error?.code);
172
+ }
173
+ // A 2xx that is not our JSON (a captive portal, a cut-off body) is a
174
+ // failed call, not an empty answer.
175
+ if (!json)
176
+ throw new Error(`unreadable response (HTTP ${res.status})`);
177
+ return json;
178
+ }
179
+ finally {
180
+ clearTimeout(timer);
181
+ }
182
+ };
183
+ /**
184
+ * A failed trigger the server may have taken all the same: a slow answer, one that could not be read, a
185
+ * 5xx (a gateway that gave up on a request already written), a first send still running. Not its own
186
+ * "too many", and not a connection that was never made or a header `fetch` would not send — which is
187
+ * not every `TypeError` of `fetch`: a socket reset after the request, or an answer that stops mid-body,
188
+ * is one too.
189
+ */
190
+ const mayHaveLanded = (err) => err instanceof HttpError ? err.status !== 429 : !(0, http_failure_js_1.neverConnected)(err) && !(0, http_failure_js_1.isUnsendableHeader)(err);
191
+ /**
192
+ * Put the question on the phone, once. Every try carries the same key and the same bytes: the server
193
+ * replays a retry only when its body is the first one's, and takes another body under the key as a
194
+ * different request (422).
195
+ *
196
+ * What the key does not cover (hwip `server/SERVER-API.md`, the trigger's `Idempotency-Key`, read
197
+ * 2026-10-14): a first request that wrote the question and died before it marked the key is taken
198
+ * over after its 30 s hold, and that try opens a second question.
199
+ */
200
+ const trigger = async (deps, opts, deadline, prepared) => {
201
+ // The question carries its wording inside the envelope: the server gets each choice's id and nothing else of it.
202
+ const body = JSON.stringify({
203
+ isEncrypted: true,
204
+ ...prepared.fields,
205
+ actions: opts.actions?.map(({ id }) => ({ id })),
206
+ ...(prepared.files ? { files: prepared.files } : {}),
207
+ // What is left of the window, so the question ends on the phone when this wait does: sealing took
208
+ // some of it, and so did a question taken up first that ended unanswered.
209
+ timeout: Math.max(1, Math.ceil((deadline - deps.now()) / 1000)),
210
+ hookType: 'combo',
211
+ agentDeviceId: opts.agentDeviceId,
212
+ agentSessionName: opts.agentSessionName,
213
+ ...(opts.acceptsExit ? { acceptsExit: true } : {}),
214
+ });
215
+ const payload = { body, headers: { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() } };
216
+ let backoffs = 0;
217
+ let inProgress = 0;
218
+ /** Some try so far may have reached the server, whatever the last one says. */
219
+ let unsure = false;
220
+ for (;;) {
221
+ try {
222
+ return { answer: await callApi(deps, opts, 'POST', TRIGGER_PATH(opts.hookId), { payload, until: deadline }), retried: backoffs + inProgress > 0 };
223
+ }
224
+ catch (err) {
225
+ const resend = resendAfter(err);
226
+ if (resend !== 'never')
227
+ unsure ||= mayHaveLanded(err);
228
+ const wait = resend === 'never'
229
+ ? undefined
230
+ : resend === 'in-progress'
231
+ ? (inProgress++ < IN_PROGRESS_RETRIES ? IN_PROGRESS_RETRY_MS : undefined)
232
+ : TRIGGER_BACKOFF_MS[backoffs++];
233
+ if (wait === undefined || deps.now() + wait >= deadline) {
234
+ // The last failure alone would have its reader ask again, for a second card next to the first.
235
+ throw unsure ? new Error(`${errorMessage(err)} — ${TRIGGER_MAY_BE_UP}`) : err;
236
+ }
237
+ await deps.sleep(wait);
238
+ }
239
+ }
240
+ };
241
+ /** What an opened answer means to the caller: the choice, or the text (with the exit when this ask offers it). */
242
+ const sealedOutcome = (opened, acceptsExit) => {
243
+ if (!opened.ok)
244
+ return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)(opened.cause) };
245
+ if (opened.actionId !== undefined)
246
+ return { answered: true, actionId: opened.actionId };
247
+ if (opened.exit === true && acceptsExit)
248
+ return { answered: true, value: opened.value ?? '', exitRemote: true };
249
+ if (opened.value)
250
+ return { answered: true, value: opened.value };
251
+ return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)('empty-answer') };
252
+ };
253
+ /**
254
+ * Read the question's answer until there is one, the question ends without, or `until` passes — once
255
+ * at least, whatever the clock says: the answer may be there already.
256
+ */
257
+ const awaitAnswer = async (deps, opts, asked, until, unconfirmed) => {
258
+ const polling = deps.now();
259
+ let failures = 0;
260
+ let unusable = 0;
261
+ let lastError;
262
+ let seen = false;
263
+ do {
264
+ try {
265
+ const event = await callApi(deps, opts, 'GET', EVENT_PATH(opts.hookId, asked.eventId));
266
+ failures = 0;
267
+ seen = true;
268
+ // The plaintext fields of a response are never read: the server refuses them on a sealed
269
+ // question, so any that are there were put there by something else.
270
+ if (event.data?.status === 'responded') {
271
+ const opened = await (0, ask_seal_js_1.openAnswer)(event.data.response ?? undefined, asked.askId, asked.offered, deps.seal);
272
+ // The device list or the trust file failing to load may pass: read again, up to the
273
+ // same run of failures as a failed poll (this read itself succeeded, so `failures` was reset).
274
+ // When it keeps failing the answer is still there, to be opened by a later run.
275
+ const mayPass = !opened.ok && opened.cause === 'peer-unusable';
276
+ if (mayPass && (unusable += 1) < MAX_CONSECUTIVE_FAILURES) {
277
+ lastError = new Error((0, ask_seal_js_1.refusalMessage)(opened.cause));
278
+ }
279
+ else {
280
+ return { outcome: sealedOutcome(opened, opts.acceptsExit === true), end: mayPass ? 'open' : 'over' };
281
+ }
282
+ }
283
+ if (event.data?.status === 'cancelled')
284
+ return { outcome: { answered: false, reason: 'cancelled' }, end: 'over' };
285
+ if (event.data?.status === 'timed_out')
286
+ return { outcome: { answered: false, reason: 'timeout', ...unconfirmed }, end: 'over' };
287
+ }
288
+ catch (err) {
289
+ // A refusal will repeat, so waiting on it only stalls the gate. A
290
+ // blip may pass — one failed poll is not an answer and not a
291
+ // refusal — but a run of them means the server is gone for now.
292
+ // A 404 before the first good read is the server's eventually
293
+ // consistent read missing the event it just wrote, not a refusal.
294
+ // A refusal ends the wait and not the question: a key that was
295
+ // rotated or mistyped is refused while the question sits on the phone.
296
+ const unseen404 = !seen && err instanceof HttpError && err.status === 404;
297
+ if (isFatal(err) && !unseen404)
298
+ return { outcome: { answered: false, reason: 'error', error: errorMessage(err) }, end: 'open' };
299
+ failures += 1;
300
+ lastError = err;
301
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
302
+ return { outcome: { answered: false, reason: 'error', error: errorMessage(err) }, end: 'open' };
303
+ }
304
+ }
305
+ const now = deps.now();
306
+ await deps.sleep(Math.min((0, exports.pollIntervalMs)(now - polling), Math.max(0, until - now)));
307
+ } while (deps.now() < until);
308
+ return {
309
+ outcome: failures > 0 || unusable > 0
310
+ ? { answered: false, reason: 'error', error: errorMessage(lastError) }
311
+ : { answered: false, reason: 'timeout', ...unconfirmed },
312
+ end: 'open',
313
+ };
314
+ };
315
+ /** Ends the error of a trigger that failed after a try the server may have taken. */
316
+ const TRIGGER_MAY_BE_UP = 'the question may have gone up all the same';
317
+ /** Said when a question that is over stays on record: the next run would take it up, and its answer with it. */
318
+ const NOT_FORGOTTEN = 'this question is over but its record under the state directory could not be removed: '
319
+ + 'the same command run again before its timeout would be given this answer again';
320
+ const forget = (slot, deps) => {
321
+ if (!slot.settle()) {
322
+ deps.seal.warn?.(NOT_FORGOTTEN);
323
+ return false;
324
+ }
325
+ return true;
326
+ };
327
+ /**
328
+ * Wait on the question an earlier run of the same command left. Null when it ended with nobody having
329
+ * answered and some of this run's window is left: that is not this run's outcome, and it asks anew.
330
+ */
331
+ const takeUp = async (deps, opts, slot, kept, deadline) => {
332
+ // A new question loads the keys as it is sealed; this one was sealed by the run that put it up.
333
+ try {
334
+ await deps.seal.loadKeys();
335
+ }
336
+ catch {
337
+ // Kept: the question is still on the phone, and a later run may open its answer.
338
+ return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)('keystore'), resumed: true };
339
+ }
340
+ // The buttons are this run's own: the match says they are the ones the question went up with.
341
+ const asked = { eventId: kept.eventId, askId: kept.askId, offered: opts.actions?.map((a) => a.id) };
342
+ const waited = await awaitAnswer(deps, opts, asked, Math.min(deadline, kept.deadline));
343
+ const ended = waited.end === 'over' || deps.now() >= kept.deadline;
344
+ // Before the outcome is handed over: an answer is used once.
345
+ const forgotten = ended ? forget(slot, deps) : true;
346
+ // A question the user cancelled stays cancelled.
347
+ const unanswered = !waited.outcome.answered && waited.outcome.reason === 'timeout';
348
+ if (ended && unanswered && deps.now() < deadline)
349
+ return null;
350
+ return { ...waited.outcome, resumed: true, ...(!forgotten ? { warn: NOT_FORGOTTEN } : {}) };
351
+ };
352
+ /**
353
+ * Ask, then wait. Resolves when the user answers, the question settles
354
+ * without an answer, or the deadline passes — never rejects. An unanswered
355
+ * outcome says why in `reason`, and carries the last error when there was one.
356
+ *
357
+ * A question an earlier run of the same command put up and did not see through is waited on first, and
358
+ * nothing is sent: the user may have answered it already, and a second card would sit next to the first.
359
+ */
360
+ const requestApproval = async (opts, deps) => {
361
+ const deadline = deps.now() + opts.timeoutSeconds * 1000;
362
+ const slot = deps.pending?.of(opts);
363
+ const kept = slot?.claim();
364
+ if (slot && kept) {
365
+ const outcome = await takeUp(deps, opts, slot, kept, deadline);
366
+ if (outcome)
367
+ return outcome;
368
+ }
369
+ const prepared = await (0, ask_seal_js_1.prepareAsk)({ title: opts.title, body: opts.body, actions: opts.actions }, deps.seal);
370
+ // No plaintext fallback: a question that cannot be sealed is not sent.
371
+ if (prepared.kind === 'refused') {
372
+ const error = (0, ask_seal_js_1.refusalMessage)(prepared.cause, prepared.why, prepared.detail);
373
+ // On stderr too, as `hwip notify` and `hwip agent send` say it: the JSON goes to whatever parses it, not to a person.
374
+ if (prepared.cause === 'self-unlisted')
375
+ deps.seal.warn?.(error);
376
+ return { answered: false, reason: 'error', error };
377
+ }
378
+ // The lookups behind `prepared` count against the window but each has only its own limit: when they used it up,
379
+ // a question sent now would sit on the phone after this ask has ended.
380
+ if (deps.now() >= deadline)
381
+ return { answered: false, reason: 'timeout' };
382
+ let eventId;
383
+ /** Set when nobody may have been told of the question: said at once, and again if it ends unanswered. */
384
+ let unconfirmed;
385
+ try {
386
+ const { answer, retried } = await trigger(deps, opts, deadline, prepared);
387
+ const { eventId: id, pushId: push } = answer.data ?? {};
388
+ if (typeof id !== 'string' || !ask_pending_js_1.ID.test(id)) {
389
+ const missing = id === undefined || id === null || id === '';
390
+ // The push id is the server's string as well: quoted only when it is written as an id.
391
+ const named = typeof push === 'string' && ask_pending_js_1.ID.test(push) ? ` (push ${push})` : '';
392
+ const what = missing ? `named no event for it${named}` : 'the id it gave for it cannot be used';
393
+ return { answered: false, reason: 'error', error: `the server took the question, but ${what}: ${NOT_READ_HERE}` };
394
+ }
395
+ eventId = id;
396
+ if (retried && answer.data?.notified !== true) {
397
+ unconfirmed = { error: UNCONFIRMED_NOTIFY };
398
+ deps.seal.warn?.(UNCONFIRMED_NOTIFY);
399
+ }
400
+ }
401
+ catch (err) {
402
+ // No event id to wait on. When a try may have landed, the error says so.
403
+ return { answered: false, reason: 'error', error: errorMessage(err) };
404
+ }
405
+ const asked = { eventId, askId: prepared.askId, offered: prepared.offered };
406
+ slot?.record({ eventId, askId: prepared.askId, deadline });
407
+ const waited = await awaitAnswer(deps, opts, asked, deadline, unconfirmed);
408
+ // Kept while the question may still be answered — the wait gave up on a server it could not read, or
409
+ // this process dies — so that the same command run again takes it up.
410
+ if (slot && waited.end === 'over') {
411
+ const forgotten = forget(slot, deps);
412
+ if (!forgotten)
413
+ return { ...waited.outcome, warn: NOT_FORGOTTEN };
414
+ }
415
+ return waited.outcome;
416
+ };
417
+ exports.requestApproval = requestApproval;
418
+ /** Action ids that end a remote session, case-insensitive — mcp-server remote-state.ts SESSION_EXIT_IDS. */
419
+ const SESSION_EXIT_IDS = ['done', 'stop', 'exit'];
420
+ /**
421
+ * What an ask outcome does to sticky REMOTE — mcp-server's
422
+ * `remoteTransitionFor`, for the outcomes this command can produce. A
423
+ * Done-like button or a send-and-exit answer ends it; any other answer enters
424
+ * or refreshes it. An unanswered ask changes nothing: there is no fallback id
425
+ * here to resolve to, and silence is not a user action.
426
+ */
427
+ const remoteTransition = (outcome) => {
428
+ if (!outcome.answered)
429
+ return 'keep';
430
+ if ('actionId' in outcome) {
431
+ return SESSION_EXIT_IDS.includes(outcome.actionId.trim().toLowerCase()) ? 'exit' : 'enter';
432
+ }
433
+ return outcome.exitRemote ? 'exit' : 'enter';
434
+ };
435
+ exports.remoteTransition = remoteTransition;
436
+ const settleRemote = (outcome, dir) => {
437
+ switch ((0, exports.remoteTransition)(outcome)) {
438
+ case 'exit':
439
+ (0, gate_js_1.clearRemoteActive)(dir);
440
+ return { hwipState: 'NORMAL' };
441
+ case 'enter':
442
+ (0, gate_js_1.touchRemoteActive)(dir);
443
+ return { hwipState: 'REMOTE' };
444
+ case 'keep':
445
+ return {};
446
+ }
447
+ };
448
+ /** The seal's world for real use: the account API, this machine's keys, the listener's trust file. */
449
+ const liveSealDeps = (apiKey, baseUrl, deadline) => ({
450
+ loadKeys: async () => { await (0, crypto_js_1.initDeviceCrypto)(); },
451
+ self: () => {
452
+ const publicKey = (0, crypto_js_1.getDevicePublicKey)();
453
+ return publicKey ? { deviceId: (0, listener_device_id_js_1.listenerDeviceId)(), publicKey } : null;
454
+ },
455
+ listDevices: async () => {
456
+ const res = await (0, api_js_1.apiFetch)(fetch, (0, api_js_1.apiUrl)(baseUrl, '/devices'), apiKey, REQUEST_TIMEOUT_MS);
457
+ if (!res.ok)
458
+ throw new Error(`devices ${res.status}`);
459
+ const json = (await res.json());
460
+ if (!Array.isArray(json.data))
461
+ throw new Error('devices: unexpected response');
462
+ return json.data;
463
+ },
464
+ register: (0, self_register_js_1.registerOverApi)({ apiKey, baseUrl }),
465
+ trustPath: config_js_2.TRUST_FILE,
466
+ warn: (line) => console.error(`hwip: ${line}`),
467
+ // Loaded only for a question with a window to carry context for.
468
+ ...(deadline === undefined ? {} : {
469
+ attach: async (recipients) => (await import('./ask-context-attach.js'))
470
+ .liveAttach({ apiKey, baseUrl }, deadline, (line) => process.stderr.write(`hwip ask: ${line}\n`))(recipients),
471
+ }),
472
+ });
473
+ exports.liveSealDeps = liveSealDeps;
474
+ /** Wall-clock deps for real use. `deadline` is when the question's window ends: with one, a sealed question carries its context. */
475
+ const liveDeps = (account) => ({
476
+ fetchFn: fetch,
477
+ now: () => Date.now(),
478
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
479
+ seal: (0, exports.liveSealDeps)(account.apiKey, account.baseUrl, account.deadline),
480
+ pending: (0, ask_pending_js_1.pendingAsks)((0, gate_js_1.stateDir)()),
481
+ });
482
+ exports.liveDeps = liveDeps;
483
+ /**
484
+ * `hwip ask --title … [--body …] [--actions id:Label,…] [--timeout 60] [--accepts-exit]`
485
+ *
486
+ * Prints one JSON object and nothing else, so a hook can pipe it straight into
487
+ * `jq`. Exit code 0 means answered, 1 means not — a shell caller that only
488
+ * wants approve/deny can read `$?` and skip the JSON entirely.
489
+ */
490
+ const handleAsk = async (args) => {
491
+ const config = (0, config_js_1.loadConfig)();
492
+ // `resolvedEnv`, not `process.env`: a hook environment can hand us a literal
493
+ // unexpanded `${HWIP_API_KEY}`, which is truthy and would beat the config
494
+ // file. Auth then fails, this returns `answered: false`, and the approval
495
+ // hook maps that to DENY — a command blocked for a reason that has nothing
496
+ // to do with the user. Every other command in cli.ts resolves env this way.
497
+ // Key and base URL resolve as cli.ts createHook does for every other
498
+ // command; `--api-key` is this command's older spelling of `--key`. A
499
+ // bare flag parses as `true`, which is no key and no URL.
500
+ const flag = (name) => (typeof args[name] === 'string' ? args[name] : undefined);
501
+ const apiKey = flag('key') || flag('api-key') || (0, config_js_1.resolvedEnv)('HWIP_API_KEY') || config.apiKey;
502
+ let baseUrl;
503
+ try {
504
+ baseUrl = (0, config_js_1.resolveBaseUrl)(flag('base-url'), config, (0, config_js_1.keyIsFromFile)(flag('key') || flag('api-key'), config));
505
+ }
506
+ catch (err) {
507
+ process.stdout.write(JSON.stringify({ answered: false, reason: 'error', error: err instanceof Error ? err.message : String(err) }) + '\n');
508
+ return 1;
509
+ }
510
+ (0, insecure_url_js_1.warnInsecureUrl)(baseUrl);
511
+ const hookId = args.hook || (0, config_js_1.resolveHookId)();
512
+ const title = args.title || '';
513
+ if (!apiKey || !hookId || !title) {
514
+ const missing = [!title && '--title', !apiKey && 'an API key', !hookId && 'a hook id']
515
+ .filter((m) => !!m);
516
+ process.stdout.write(JSON.stringify({ answered: false, reason: 'error', error: `missing ${new Intl.ListFormat('en').format(missing)}` }) + '\n');
517
+ return 1;
518
+ }
519
+ // A key `fetch` cannot send is said before anything is tried: the trigger would fail the same
520
+ // way, and its error would quote the key. The message names no secret.
521
+ if (!(0, api_js_1.fitsInHeader)(apiKey)) {
522
+ process.stdout.write(JSON.stringify({ answered: false, reason: 'error', error: 'a header could not be sent — check the API key for stray characters' }) + '\n');
523
+ return 1;
524
+ }
525
+ // A bare `--timeout` parses as `true`, which Number() reads as 1 second.
526
+ const timeoutSeconds = typeof args.timeout === 'string' ? Number(args.timeout) : 60;
527
+ const acceptsExit = (0, args_js_1.flagOn)(args, 'accepts-exit');
528
+ const windowSeconds = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 60;
529
+ const outcome = await (0, exports.requestApproval)({
530
+ apiKey,
531
+ baseUrl: baseUrl.replace(/\/+$/, ''),
532
+ hookId,
533
+ title,
534
+ body: args.body,
535
+ actions: (0, exports.parseActions)(args.actions),
536
+ timeoutSeconds: windowSeconds,
537
+ ...((0, agent_session_js_1.agentSessionContext)() ?? {}),
538
+ acceptsExit,
539
+ }, (0, exports.liveDeps)({ apiKey, baseUrl: baseUrl.replace(/\/+$/, ''), deadline: Date.now() + windowSeconds * 1000 }));
540
+ // The agent's own ask settles REMOTE as mcp-server's hwip_ask does and
541
+ // reports it in the same `hwipState` field; an approval gate leaves the
542
+ // mode alone. Keyed by the cwd, the twin of the `ctx.cwd` the pi
543
+ // extension hands `remote-hook pi` — pi's bash tool runs every command
544
+ // there. Not detectProjectDir(): a CLAUDE_PROJECT_DIR leaked into a pi
545
+ // launched from Claude Code would key another project's file.
546
+ const settled = acceptsExit ? settleRemote(outcome, process.cwd()) : {};
547
+ // `exitRemote` is the server's flag; the rules read `hwipState`.
548
+ process.stdout.write(JSON.stringify({ ...outcome, exitRemote: undefined, ...settled }) + '\n');
549
+ return outcome.answered ? 0 : 1;
550
+ };
551
+ exports.handleAsk = handleAsk;
@@ -0,0 +1,39 @@
1
+ import type { AgentState } from './agent-state.js';
2
+ import type { AgentKind } from './remote-agents.js';
3
+ import type { RecorderRecipient, SealedChunk } from './turn-recorder.js';
4
+ /** What the session report carries: the server stores it and never opens it. */
5
+ export interface BlockedPromptWire {
6
+ id: string;
7
+ /**
8
+ * When the listener first saw THIS prompt id, ISO 8601. Not the session's `stateChangedAt`: approvals that follow
9
+ * one another inside one blocked run, or a highlight that moves, make a new id with a later `at` while
10
+ * `stateChangedAt` stays. It never precedes the session's `stateChangedAt` (the state is confirmed before a prompt
11
+ * is read); the app compares it with the session record's `blockedPrompt.at`.
12
+ */
13
+ at: string;
14
+ sealed: SealedChunk;
15
+ }
16
+ /** After a failed seal or an empty recipient list, how long before the same prompt is tried again. */
17
+ export declare const PROMPT_SEAL_RETRY_MS = 30000;
18
+ export interface PromptSealerDeps {
19
+ deviceId: () => string;
20
+ recipients: () => Promise<RecorderRecipient[]>;
21
+ seal: (plaintext: string, recipients: RecorderRecipient[]) => Promise<SealedChunk>;
22
+ log?: (message: string) => void;
23
+ now?: () => number;
24
+ }
25
+ export interface PromptSealer {
26
+ /** The sealed prompt for this session's screen, or undefined (not blocked, not a known dialog, not sealed yet). */
27
+ observe: (name: string, kind: AgentKind, state: AgentState | undefined, paneText: string | null) => BlockedPromptWire | undefined;
28
+ /** Forget sessions that are gone. */
29
+ prune: (live: ReadonlySet<string>) => void;
30
+ /** Settles when the seals running now are done, or undefined when none is. */
31
+ sealing: () => Promise<void> | undefined;
32
+ }
33
+ export declare const createPromptSealer: (deps: PromptSealerDeps) => PromptSealer;
34
+ /**
35
+ * True when the pane shows the prompt `id` right now (a fresh capture, not the one a report was made from). Capture it
36
+ * the way `observe` was fed (`-e`): OpenCode's highlight is in the escapes, and the id with it.
37
+ */
38
+ export declare const promptStillShown: (id: string, paneText: string | null) => boolean;
39
+ //# sourceMappingURL=blocked-prompt-seal.d.ts.map