@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,349 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.webSocketProxyOptions = exports.useProxyFromEnv = exports.proxyForUrl = exports.AUTH_HINT = exports.httpFailureMessage = exports.readJsonBody = exports.networkFailureMessage = exports.isUnsendableHeader = exports.isConfigFailure = exports.isInvalidUrl = exports.neverConnected = exports.isRedirectRefusal = void 0;
4
+ const redact_js_1 = require("./redact.js");
5
+ const ws_url_js_1 = require("./ws-url.js");
6
+ /**
7
+ * What a failed call to the account API says to a person (C13). `fetch`
8
+ * reports every network problem as "fetch failed" with the real cause one
9
+ * level down, and an outage or proxy page is HTML, which `response.json()`
10
+ * turns into "Unexpected token <" — both said nothing about what to do.
11
+ */
12
+ /** Is any error of `err`'s cause chain one that `says` so? `fetch` wraps the reason one or two levels down, and its callers once more. */
13
+ const somewhereInCauses = (err, says) => {
14
+ for (let e = err, depth = 0; e instanceof Error && depth < 4; e = e.cause, depth++) {
15
+ if (says(e))
16
+ return true;
17
+ }
18
+ return false;
19
+ };
20
+ /**
21
+ * Did `fetch` reject because the answer was a redirect (every call that carries
22
+ * the API key passes `redirect: 'error'`)? The reason sits in the error's cause
23
+ * chain, one or two levels down depending on who wrapped it.
24
+ */
25
+ const isRedirectRefusal = (err) => somewhereInCauses(err, (e) => /unexpected redirect/i.test(e.message));
26
+ exports.isRedirectRefusal = isRedirectRefusal;
27
+ /**
28
+ * The errnos of a connection that could not be made, each with the call that says so. Under any other
29
+ * call the socket had been connected, and the request may have been written to it: a reset is reported
30
+ * under `read` (seen on Node 24.21), and "no route" is taken to come the same way (not reproduced).
31
+ */
32
+ const BEFORE_CONNECTING = new Map([
33
+ ['ENOTFOUND', 'getaddrinfo'],
34
+ ['EAI_AGAIN', 'getaddrinfo'],
35
+ ['ECONNREFUSED', 'connect'],
36
+ ['ENETUNREACH', 'connect'],
37
+ ['EHOSTUNREACH', 'connect'],
38
+ ['ETIMEDOUT', 'connect'],
39
+ ]);
40
+ /** undici's own "the connect got no answer", and `fetch` handed an address it could not parse: neither has a call to go by, or needs one. */
41
+ const NEVER_STARTED = new Set(['UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL']);
42
+ const codeOf = (err) => 'code' in err && typeof err.code === 'string' ? err.code : undefined;
43
+ /** One error of the chain: a connect or a lookup that failed, or — a host with several addresses — every one of them failing so. */
44
+ const failedBeforeConnecting = (err) => {
45
+ const code = codeOf(err);
46
+ if (code === undefined)
47
+ return false;
48
+ if (NEVER_STARTED.has(code))
49
+ return true;
50
+ if (err instanceof AggregateError)
51
+ return err.errors.length > 0 && err.errors.every((e) => e instanceof Error && failedBeforeConnecting(e));
52
+ const call = BEFORE_CONNECTING.get(code);
53
+ return call !== undefined && 'syscall' in err && err.syscall === call;
54
+ };
55
+ /**
56
+ * Did `fetch` reject before the request could be sent? Any other rejection — a socket reset, a body
57
+ * that stops halfway — is also a `TypeError`, and may be of a request the server took. The code sits
58
+ * in the cause chain, as for a refused redirect. The shapes are Node 24.21's (undici 7.29), read
59
+ * 2026-10-14: `{ code, syscall }` on the cause, and an `AggregateError` of them for a host with two addresses.
60
+ */
61
+ const neverConnected = (err) => somewhereInCauses(err, failedBeforeConnecting);
62
+ exports.neverConnected = neverConnected;
63
+ /** Did `fetch` reject because the address is not one? It will say so again however often it is asked. */
64
+ const isInvalidUrl = (err) => somewhereInCauses(err, (e) => codeOf(e) === 'ERR_INVALID_URL' || /not a valid address/.test(e.message));
65
+ exports.isInvalidUrl = isInvalidUrl;
66
+ /**
67
+ * Is this failure a permanent configuration problem (bad base URL, unsendable API key) rather than a
68
+ * network blip? Such errors say so in fixed words — `apiFetch` throws them before any `fetch`, and
69
+ * `resolveBaseUrl` throws them before any request — so swallowing them as "could not fetch" or
70
+ * retrying them with backoff only repeats them. Matched by message, as the throwers sanitize it — so
71
+ * never on an error that carries an HTTP status (`HttpError`, a `HwipError` from a reply): its message
72
+ * is the server's own text, and a 5xx that happens to say "is unknown" is still worth another try.
73
+ */
74
+ const isConfigFailure = (err) => somewhereInCauses(err, (e) => !isHttpReply(e)
75
+ && /not a valid address|user name or password|header could not be sent|stray characters|is not text|is unknown|belongs to/i.test(e.message));
76
+ exports.isConfigFailure = isConfigFailure;
77
+ const isHttpReply = (e) => {
78
+ const status = e.status;
79
+ return typeof status === 'number' && status >= 100;
80
+ };
81
+ /**
82
+ * Did `fetch` reject for a header value it cannot send? It does so before it opens a connection, and the
83
+ * same way however often it is asked. Two shapes (Node 24.21, read 2026-10-14 — held to a real `fetch`
84
+ * in http-failure.test.ts): a `TypeError` with no cause that says which value — whole for a line break
85
+ * or a NUL, by place for a character past U+00FF — and, for any other control character, undici's
86
+ * `UND_ERR_INVALID_ARG` ("invalid X-API-Key header") under "fetch failed". Looked for down the cause
87
+ * chain, as the callers hand on what `fetch` threw inside an error in their own words.
88
+ */
89
+ const isUnsendableHeader = (err) => somewhereInCauses(err, (e) => {
90
+ const refusedByHeaders = e instanceof TypeError && e.cause === undefined && /is an invalid header value|to a ByteString/.test(e.message);
91
+ const refusedByUndici = codeOf(e) === 'UND_ERR_INVALID_ARG' && /^invalid .+ header$/.test(e.message);
92
+ // The sanitized rethrow (`apiFetch`, `callApi`): no cause is kept, as the original quotes the key.
93
+ const sanitized = /a header could not be sent/.test(e.message);
94
+ return refusedByHeaders || refusedByUndici || sanitized;
95
+ });
96
+ exports.isUnsendableHeader = isUnsendableHeader;
97
+ /** "could not reach api.hwip.app (ENOTFOUND)" from a rejected `fetch`. */
98
+ const networkFailureMessage = (err, url) => {
99
+ // Nothing was tried, and the address is the thing to fix. Not quoted: it may carry a password.
100
+ if ((0, exports.isInvalidUrl)(err))
101
+ return 'the base URL is not a valid address — check --base-url, HWIP_BASE_URL or "baseUrl" in ~/.hwip/config.json';
102
+ // The one header whose value is not ours is the API key, and the error's own words quote it.
103
+ if ((0, exports.isUnsendableHeader)(err))
104
+ return 'a header could not be sent — check the API key for stray characters';
105
+ // `fetch` refuses such an address before it tries anything, in words that are the address as it was given: a
106
+ // password with a quote or a space in it is not one `redactSecrets` can find the end of (C152).
107
+ if ((0, ws_url_js_1.carriesCredentials)(url)) {
108
+ return 'the base URL has a user name or password in it, and no request can be made to such an address — take it out of --base-url, HWIP_BASE_URL or "baseUrl" in ~/.hwip/config.json';
109
+ }
110
+ let host;
111
+ try {
112
+ host = new URL(url).host;
113
+ }
114
+ catch {
115
+ // The raw url, less what it may carry: `https://user:password@host`.
116
+ host = (0, redact_js_1.redactSecrets)(url);
117
+ }
118
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause : null;
119
+ const code = cause && 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
120
+ // `redirect: 'error'` on every call that carries the key: say so, not "unexpected redirect".
121
+ if ((0, exports.isRedirectRefusal)(err)) {
122
+ return `${host} answered with a redirect, and hwip does not follow one with your API key — check the base URL (${host})`;
123
+ }
124
+ // The cause can quote the whole URL: `https://user:password@host` when the base URL carries credentials.
125
+ const why = (0, redact_js_1.redactSecrets)(code ?? cause?.message ?? (err instanceof Error ? err.message : String(err)));
126
+ return `could not reach ${host} (${why}) — check the network${process.env.HTTPS_PROXY || process.env.https_proxy ? ' and HTTPS_PROXY' : ''}`;
127
+ };
128
+ exports.networkFailureMessage = networkFailureMessage;
129
+ /** The body as JSON when it is JSON, else null. A read that fails (an abort
130
+ * mid-body) still throws: that is a timeout or a dropped connection, not a
131
+ * page that is not ours. */
132
+ const readJsonBody = async (response) => {
133
+ try {
134
+ return (await response.json());
135
+ }
136
+ catch (err) {
137
+ if (err instanceof SyntaxError)
138
+ return null;
139
+ throw err;
140
+ }
141
+ };
142
+ exports.readJsonBody = readJsonBody;
143
+ /**
144
+ * What one line of a message is not made of: controls, line and paragraph breaks, the invisible format
145
+ * characters (Cf — bidi marks and overrides, zero-width space, the BOM, and the Unicode tag block, which can
146
+ * hide text from a reader but not from an agent reading `hwip ask`), the grapheme-join blocker U+034F, and
147
+ * the Hangul fillers (U+115F, U+1160, U+3164, U+FFA0 — blank letters an attacker would use as an invisible
148
+ * base character). U+200C ZWNJ and U+200D ZWJ are writing, not hiding (Persian, Hindi, emoji sequences), so
149
+ * a lone one stays — but a run of them is a bit channel, so a run collapses to its first character (C153).
150
+ * Selectors go by allowlist, not by run: U+FE0E/U+FE0F stay only after an emoji base (text/emoji
151
+ * presentation: ❤️ ☺︎ 1️⃣), U+E0100–E01EF only after a unified ideograph (IVS); every other selector —
152
+ * including the Mongolian FVS (U+180B–U+180D, U+180F) — goes, so spacing a run out with an invisible
153
+ * character no longer smuggles it past the rule. The same rule as mcp-server's `UNPRINTABLE` block
154
+ * (src/api-client.ts): the lines below from `const UNPRINTABLE` through `sanitizeSmuggled` are
155
+ * character-identical there.
156
+ */
157
+ const UNPRINTABLE = /(?:[\p{Cc}\p{Zl}\p{Zp}]|(?![\u200C\u200D])\p{Cf}|[\u034F\u115F\u1160\u3164\uFFA0])+/gu;
158
+ // Written with \u{...}: \uE0100 would read as U+E010 plus "0", turning 0-\uE01E into a range over ASCII (C153).
159
+ const SELECTOR_ALLOW = /(\p{Emoji}|[0-9#*])([\uFE0E\uFE0F])|(\p{Unified_Ideograph})([\u{E0100}-\u{E01EF}])|[\uFE00-\uFE0F\u180B-\u180D\u180F\u{E0100}-\u{E01EF}]/gu;
160
+ const JOINER_RUN = /([\u200C\u200D])[\u200C\u200D]+/gu;
161
+ // '$1$2$3$4' rebuilds an allowed base + selector and erases anything else (groups that did not match are empty).
162
+ const sanitizeSmuggled = (text) => text.replace(SELECTOR_ALLOW, '$1$2$3$4').replace(JOINER_RUN, '$1');
163
+ const MAX_SERVER_MESSAGE_CHARS = 300;
164
+ /**
165
+ * A server's own words as one line of bounded length: they go to a terminal and, through `hwip ask`, into
166
+ * an agent's context, and a host that is not ours could otherwise put a paragraph of instructions there.
167
+ * The rule is mcp-server's `serverMessage` (src/api-client.ts) — keep them in step.
168
+ */
169
+ const serverWords = (sent) => {
170
+ const clean = sanitizeSmuggled(sent.replace(UNPRINTABLE, ' ')).trim();
171
+ // By code point: a cut through a surrogate pair would leave half an emoji.
172
+ const points = Array.from(clean);
173
+ return points.length > MAX_SERVER_MESSAGE_CHARS ? `${points.slice(0, MAX_SERVER_MESSAGE_CHARS - 1).join('')}…` : clean;
174
+ };
175
+ /** The line for a non-2xx answer whose body may not be ours at all. */
176
+ const ERROR_CODE_SHAPE = /^[A-Za-z0-9_.:-]{1,64}$/;
177
+ const httpFailureMessage = (status, body) => {
178
+ const sent = body && typeof body === 'object' ? body.error?.message : undefined; // a JSON body of any shape: read by the one path the API's errors have
179
+ const code = body && typeof body === 'object' ? body.error?.code : undefined;
180
+ const tag = typeof code === 'string' && ERROR_CODE_SHAPE.test(code) ? ` (${code})` : '';
181
+ const message = typeof sent === 'string' ? serverWords(sent) : '';
182
+ if (message)
183
+ return message;
184
+ return body === null
185
+ ? `the server answered ${status} with a page that is not the API's (an outage, or a proxy in the way)${tag}`
186
+ : `request failed with status ${status}${tag}`;
187
+ };
188
+ exports.httpFailureMessage = httpFailureMessage;
189
+ /** What to do about a rejected key, appended to its message. */
190
+ exports.AUTH_HINT = 'run `hwip login`, or check --key / HWIP_API_KEY';
191
+ const DEFAULT_PORTS = { 'http:': 80, 'https:': 443, 'ws:': 80, 'wss:': 443 };
192
+ const noProxyEntries = (value) => value.split(/[,\s]/).filter(Boolean).map((entry) => {
193
+ const parsed = entry.match(/^(.+):(\d+)$/);
194
+ return { hostname: (parsed ? parsed[1] : entry).toLowerCase(), port: parsed ? Number.parseInt(parsed[2], 10) : 0 };
195
+ });
196
+ const bypassesProxy = (url, noProxy) => {
197
+ const entries = noProxyEntries(noProxy);
198
+ if (entries.length === 0)
199
+ return false;
200
+ if (noProxy === '*')
201
+ return true;
202
+ // Not `url.hostname`: it drops the brackets round an IPv6 address.
203
+ const hostname = url.host.replace(/:\d*$/, '').toLowerCase();
204
+ const port = Number.parseInt(url.port, 10) || DEFAULT_PORTS[url.protocol] || 0;
205
+ return entries.some((entry) => {
206
+ if (entry.port && entry.port !== port)
207
+ return false;
208
+ return /^[.*]/.test(entry.hostname)
209
+ ? hostname.endsWith(entry.hostname.replace(/^\*/, ''))
210
+ : hostname === entry.hostname;
211
+ });
212
+ };
213
+ /** The proxy a request to `rawUrl` goes through, or null for a direct connection. `ws` / `wss` follow `http` / `https`. */
214
+ const proxyForUrl = (rawUrl, env = process.env) => {
215
+ const url = new URL(rawUrl);
216
+ const secure = url.protocol === 'https:' || url.protocol === 'wss:';
217
+ const http = env.http_proxy ?? env.HTTP_PROXY;
218
+ const https = env.https_proxy ?? env.HTTPS_PROXY;
219
+ const proxy = (secure ? https || http : http) || null;
220
+ if (!proxy || bypassesProxy(url, env.no_proxy ?? env.NO_PROXY ?? ''))
221
+ return null;
222
+ return proxy;
223
+ };
224
+ exports.proxyForUrl = proxyForUrl;
225
+ let active = null;
226
+ /** undici says "experimental" once, on stderr, the first time it builds the env-proxy agent. */
227
+ const withoutExperimentalWarning = (build) => {
228
+ const emit = process.emitWarning;
229
+ process.emitWarning = ((warning, ...rest) => {
230
+ const options = rest[0];
231
+ if (typeof options === 'object' && options !== null && options.code === 'UNDICI-EHPA')
232
+ return;
233
+ return emit.call(process, warning, ...rest);
234
+ });
235
+ try {
236
+ return build();
237
+ }
238
+ finally {
239
+ process.emitWarning = emit;
240
+ }
241
+ };
242
+ const checkProxyUrl = (name, value) => {
243
+ if (!value)
244
+ return;
245
+ let ok = false;
246
+ try {
247
+ const url = new URL(value);
248
+ ok = (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== '';
249
+ }
250
+ catch { /* not a URL */ }
251
+ // The name only: the value may carry the proxy's password.
252
+ if (!ok)
253
+ throw new Error(`${name} is not a usable proxy address — use http://host:port or https://host:port`);
254
+ };
255
+ /** `config` is a parsed JSON file: a value that is not text is a mistake to name, not a setting to coerce. */
256
+ const configProxyValue = (key, config) => {
257
+ const value = config[key];
258
+ if (value === undefined || value === null)
259
+ return undefined;
260
+ if (typeof value !== 'string')
261
+ throw new Error(`"${key}" in config.json is not text — use "${key}": "http://host:port"`);
262
+ return value;
263
+ };
264
+ /**
265
+ * Route `fetch` and the listener's socket through HTTPS_PROXY / HTTP_PROXY,
266
+ * honouring NO_PROXY, when the variables are set; a no-op otherwise.
267
+ *
268
+ * `config` (`httpProxy`, `httpsProxy`, `noProxy` of ~/.hwip/config.json) fills
269
+ * in what the environment leaves unset, one setting at a time: a launchd
270
+ * service starts with its own environment and the plist must not hold a proxy
271
+ * password, so the file is where it finds them. The environment wins when it
272
+ * sets a name at all, empty included.
273
+ *
274
+ * Not through Node's own `http.setGlobalProxyFromEnv`: it exists only from 24.14
275
+ * and 25.4, and the CLI runs on older Nodes. `fetch` gets undici's
276
+ * `EnvHttpProxyAgent` as the global dispatcher (undici 6, Node >= 18.17); `ws`
277
+ * opens its own connection, so it gets a CONNECT-tunnelling agent from
278
+ * `https-proxy-agent` (`webSocketProxyOptions`). Both libraries load only here,
279
+ * so a run with no proxy pays nothing for them.
280
+ *
281
+ * A proxy that cannot be set up is an error, not a direct connection: going
282
+ * around a proxy someone configured is the wrong way to fail. The error names
283
+ * the setting and never quotes it: the value may carry the proxy's password.
284
+ */
285
+ const useProxyFromEnv = async (env = process.env, config = {}) => {
286
+ // Only a value that will be used is read: the environment's setting makes the file's moot, wrong or not.
287
+ const envHttp = env.http_proxy ?? env.HTTP_PROXY;
288
+ const envHttps = env.https_proxy ?? env.HTTPS_PROXY;
289
+ const httpProxy = envHttp ?? configProxyValue('httpProxy', config);
290
+ const httpsProxy = envHttps ?? configProxyValue('httpsProxy', config);
291
+ // An empty lower-case variable counts as set, and wins over the upper-case
292
+ // one, because that is how fetch (undici) reads them — the socket has to
293
+ // agree with it. It is also the way to switch the proxy off for one run:
294
+ // `http_proxy= hwip …`.
295
+ if (!httpProxy && !httpsProxy) {
296
+ active = null;
297
+ return;
298
+ }
299
+ checkProxyUrl(envHttp === undefined ? '"httpProxy" in config.json' : env.http_proxy !== undefined ? 'http_proxy' : 'HTTP_PROXY', httpProxy);
300
+ checkProxyUrl(envHttps === undefined ? '"httpsProxy" in config.json' : env.https_proxy !== undefined ? 'https_proxy' : 'HTTPS_PROXY', httpsProxy);
301
+ const noProxy = env.no_proxy ?? env.NO_PROXY ?? configProxyValue('noProxy', config) ?? '';
302
+ const [{ EnvHttpProxyAgent, setGlobalDispatcher }, { HttpsProxyAgent }] = await Promise.all([
303
+ import('undici'),
304
+ import('https-proxy-agent'),
305
+ ]);
306
+ // Always strings, so undici never falls back to reading `process.env` itself.
307
+ setGlobalDispatcher(withoutExperimentalWarning(() => new EnvHttpProxyAgent({
308
+ httpProxy: httpProxy ?? '',
309
+ httpsProxy: httpsProxy ?? '',
310
+ noProxy,
311
+ })));
312
+ const agents = new Map();
313
+ active = {
314
+ env: { http_proxy: httpProxy, https_proxy: httpsProxy, no_proxy: noProxy },
315
+ agentFor: (proxy) => {
316
+ let agent = agents.get(proxy);
317
+ if (!agent) {
318
+ // The credentials go in a header, not in the URL the agent holds:
319
+ // `https-proxy-agent` logs that URL under `DEBUG`. Sent only with
320
+ // both parts, as undici's proxy agent does, so a username alone
321
+ // gets the same (no) header on fetch and on the socket.
322
+ const url = new URL(proxy);
323
+ const headers = {};
324
+ if (url.username && url.password) {
325
+ const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
326
+ headers['Proxy-Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`;
327
+ }
328
+ url.username = '';
329
+ url.password = '';
330
+ agents.set(proxy, (agent = new HttpsProxyAgent(url, { headers })));
331
+ }
332
+ return agent;
333
+ },
334
+ };
335
+ };
336
+ exports.useProxyFromEnv = useProxyFromEnv;
337
+ /**
338
+ * Options for the listener's `new WebSocket`: `ws` opens its own connection
339
+ * and never reaches fetch's dispatcher, so with a proxy set it is handed an
340
+ * agent that tunnels through it (CONNECT, for `ws:` as well as `wss:`) — unless
341
+ * NO_PROXY says that host goes direct.
342
+ */
343
+ const webSocketProxyOptions = (url) => {
344
+ if (!active)
345
+ return {};
346
+ const proxy = (0, exports.proxyForUrl)(url, active.env);
347
+ return proxy ? { agent: active.agentFor(proxy) } : {};
348
+ };
349
+ exports.webSocketProxyOptions = webSocketProxyOptions;
@@ -0,0 +1,7 @@
1
+ /** sha256 over the plugin manifest + extracted rule text at generation time. */
2
+ export declare const HWIP_CORE_SOURCE_HASH = "b937f41d66d1337e5cdc187c6fe40017ba70a14f0d3493e67aba44b018c18f82";
3
+ /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot, Pi, OpenCode). */
4
+ export declare const HWIP_CORE_HOOK_DRIVEN = "### When hwip_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `hwip_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `hwip_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When hwip_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `hwip_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `hwip_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `hwip_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, use `hwip_ask` \u2014 buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n hwip_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `hwip_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `hwip_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `hwip_ask` until the user exits. The state lives in a file (it survives compaction and long sessions); you are told what it is, you do not derive it.\n\n#### State Detection\n\n- **`hwip_ask` results carry it** as `hwipState: \"REMOTE\" | \"NORMAL\"`: a Done-like id, a timeout that fell back to one, or text sent with the phone's \"send and exit\" button exits; any other answer enters REMOTE. A send-and-exit result carries a `value` alongside `NORMAL`: it is the user's final instruction \u2014 carry it out, and end without `hwip_ask`. No `hwipState` = a timed-out ask that changed nothing.\n- **Prompt-submit hooks say it**, where installed: a remote-origin note on the turn a phone message arrives, and a LEFT-REMOTE note on the first prompt typed at the terminal. A phone answer to a `hwip_ask` returns as a `tool_result`, never through a prompt hook \u2014 so a prompt with no phone marker is the user's own keyboard, and staying in REMOTE would answer the terminal with a phone loop.\n- **Neither present \u2192 NORMAL.**\n- **Free text is the one call left to you** \u2014 no hook can tell \"run the tests\" from \"thanks, that's it\". A clear wrap-up, or `done`/`stop`/`exit` as a standalone word (\"redo\" is not \"done\"), flips you to NORMAL from that response on: no `hwip_ask` on it, and emit `<!-- hwip: exit -->` once so the hooks agree. That marker is separate from any push-volume marker; it says nothing about push volume, and push markers say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, hwip_ask MANDATORY)\n\nEnd EVERY response with `hwip_ask` \u2014 2\u20134 `actions` (the next-step candidates) plus a Done-like `fallback` (never a destructive one: an unanswered ask resolves to it), `timeout` 300\u2013600 s so silence exits quietly instead of chaining pushes at a user who stepped away. A text-only ask is only for inherently free-form answers. Non-negotiable in REMOTE, substantial work or not.\n\nFive things leave REMOTE: a Done-like button (or a timeout onto one), the phone's \"send and exit\" (text plus exit in one answer), your own read of a free-text wrap-up, a prompt typed at the terminal, and the state expiring after a crash nobody exited.\n\n#### Behavior in NORMAL (no hwip_ask is owed)\n\nThe user is at the terminal. Questions go to `AskUserQuestion` or prose (the Ask hook still pushes them to the device); completion is the Stop hook's push; `hwip_ask` is available when you want an answer from their device \u2014 never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs hwip_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `hwip_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs hwip_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `hwip_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `hwip_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
5
+ /** Shared rule core for rule-only agents without a Stop hook (Cline, Aider). */
6
+ export declare const HWIP_CORE_RULE_ONLY = "### When hwip_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `hwip_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `hwip_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When hwip_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `hwip_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `hwip_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `hwip_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, use `hwip_ask` \u2014 buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n hwip_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `hwip_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `hwip_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `hwip_ask` until the user exits. The state lives in a file (it survives compaction and long sessions); you are told what it is, you do not derive it.\n\n#### State Detection\n\n- **`hwip_ask` results carry it** as `hwipState: \"REMOTE\" | \"NORMAL\"`: a Done-like id, a timeout that fell back to one, or text sent with the phone's \"send and exit\" button exits; any other answer enters REMOTE. A send-and-exit result carries a `value` alongside `NORMAL`: it is the user's final instruction \u2014 carry it out, and end without `hwip_ask`. No `hwipState` = a timed-out ask that changed nothing.\n- **Prompt-submit hooks say it**, where installed: a remote-origin note on the turn a phone message arrives, and a LEFT-REMOTE note on the first prompt typed at the terminal. A phone answer to a `hwip_ask` returns as a `tool_result`, never through a prompt hook \u2014 so a prompt with no phone marker is the user's own keyboard, and staying in REMOTE would answer the terminal with a phone loop.\n- **Neither present \u2192 NORMAL.**\n- **Free text is the one call left to you** \u2014 no hook can tell \"run the tests\" from \"thanks, that's it\". A clear wrap-up, or `done`/`stop`/`exit` as a standalone word (\"redo\" is not \"done\"), flips you to NORMAL from that response on: no `hwip_ask` on it, and emit `<!-- hwip: exit -->` once so the hooks agree. That marker is separate from any push-volume marker; it says nothing about push volume, and push markers say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, hwip_ask MANDATORY)\n\nEnd EVERY response with `hwip_ask` \u2014 2\u20134 `actions` (the next-step candidates) plus a Done-like `fallback` (never a destructive one: an unanswered ask resolves to it), `timeout` 300\u2013600 s so silence exits quietly instead of chaining pushes at a user who stepped away. A text-only ask is only for inherently free-form answers. Non-negotiable in REMOTE, substantial work or not.\n\nFive things leave REMOTE: a Done-like button (or a timeout onto one), the phone's \"send and exit\" (text plus exit in one answer), your own read of a free-text wrap-up, a prompt typed at the terminal, and the state expiring after a crash nobody exited.\n\n#### Behavior in NORMAL (no hwip_ask is owed)\n\nThe user is at the terminal. Questions go to `AskUserQuestion` or prose (the Ask hook still pushes them to the device); completion is the Stop hook's push; `hwip_ask` is available when you want an answer from their device \u2014 never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs hwip_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `hwip_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs hwip_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `hwip_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `hwip_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
7
+ //# sourceMappingURL=hwip-core.generated.d.ts.map
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ // GENERATED by scripts/sync-from-plugin.mjs — DO NOT EDIT.
3
+ // Source of truth: hwipit/plugin docs/CORE_RULES.md, sliced per
4
+ // scripts/core-rules.manifest.json. Regenerate with `npm run sync:plugin`
5
+ // (requires a hwipit/plugin checkout, sibling ../plugin by default).
6
+ // CI cross-checks this file against plugin main and fails on drift.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.HWIP_CORE_RULE_ONLY = exports.HWIP_CORE_HOOK_DRIVEN = exports.HWIP_CORE_SOURCE_HASH = void 0;
9
+ /** sha256 over the plugin manifest + extracted rule text at generation time. */
10
+ exports.HWIP_CORE_SOURCE_HASH = "b937f41d66d1337e5cdc187c6fe40017ba70a14f0d3493e67aba44b018c18f82";
11
+ /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot, Pi, OpenCode). */
12
+ exports.HWIP_CORE_HOOK_DRIVEN = "### When hwip_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `hwip_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `hwip_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When hwip_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `hwip_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `hwip_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `hwip_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, use `hwip_ask` — buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n hwip_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `hwip_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `hwip_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `hwip_ask` until the user exits. The state lives in a file (it survives compaction and long sessions); you are told what it is, you do not derive it.\n\n#### State Detection\n\n- **`hwip_ask` results carry it** as `hwipState: \"REMOTE\" | \"NORMAL\"`: a Done-like id, a timeout that fell back to one, or text sent with the phone's \"send and exit\" button exits; any other answer enters REMOTE. A send-and-exit result carries a `value` alongside `NORMAL`: it is the user's final instruction — carry it out, and end without `hwip_ask`. No `hwipState` = a timed-out ask that changed nothing.\n- **Prompt-submit hooks say it**, where installed: a remote-origin note on the turn a phone message arrives, and a LEFT-REMOTE note on the first prompt typed at the terminal. A phone answer to a `hwip_ask` returns as a `tool_result`, never through a prompt hook — so a prompt with no phone marker is the user's own keyboard, and staying in REMOTE would answer the terminal with a phone loop.\n- **Neither present → NORMAL.**\n- **Free text is the one call left to you** — no hook can tell \"run the tests\" from \"thanks, that's it\". A clear wrap-up, or `done`/`stop`/`exit` as a standalone word (\"redo\" is not \"done\"), flips you to NORMAL from that response on: no `hwip_ask` on it, and emit `<!-- hwip: exit -->` once so the hooks agree. That marker is separate from any push-volume marker; it says nothing about push volume, and push markers say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, hwip_ask MANDATORY)\n\nEnd EVERY response with `hwip_ask` — 2–4 `actions` (the next-step candidates) plus a Done-like `fallback` (never a destructive one: an unanswered ask resolves to it), `timeout` 300–600 s so silence exits quietly instead of chaining pushes at a user who stepped away. A text-only ask is only for inherently free-form answers. Non-negotiable in REMOTE, substantial work or not.\n\nFive things leave REMOTE: a Done-like button (or a timeout onto one), the phone's \"send and exit\" (text plus exit in one answer), your own read of a free-text wrap-up, a prompt typed at the terminal, and the state expiring after a crash nobody exited.\n\n#### Behavior in NORMAL (no hwip_ask is owed)\n\nThe user is at the terminal. Questions go to `AskUserQuestion` or prose (the Ask hook still pushes them to the device); completion is the Stop hook's push; `hwip_ask` is available when you want an answer from their device — never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs hwip_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `hwip_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs hwip_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `hwip_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `hwip_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
13
+ /** Shared rule core for rule-only agents without a Stop hook (Cline, Aider). */
14
+ exports.HWIP_CORE_RULE_ONLY = "### When hwip_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `hwip_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `hwip_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When hwip_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `hwip_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `hwip_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `hwip_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, use `hwip_ask` — buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n hwip_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `hwip_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `hwip_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `hwip_ask` until the user exits. The state lives in a file (it survives compaction and long sessions); you are told what it is, you do not derive it.\n\n#### State Detection\n\n- **`hwip_ask` results carry it** as `hwipState: \"REMOTE\" | \"NORMAL\"`: a Done-like id, a timeout that fell back to one, or text sent with the phone's \"send and exit\" button exits; any other answer enters REMOTE. A send-and-exit result carries a `value` alongside `NORMAL`: it is the user's final instruction — carry it out, and end without `hwip_ask`. No `hwipState` = a timed-out ask that changed nothing.\n- **Prompt-submit hooks say it**, where installed: a remote-origin note on the turn a phone message arrives, and a LEFT-REMOTE note on the first prompt typed at the terminal. A phone answer to a `hwip_ask` returns as a `tool_result`, never through a prompt hook — so a prompt with no phone marker is the user's own keyboard, and staying in REMOTE would answer the terminal with a phone loop.\n- **Neither present → NORMAL.**\n- **Free text is the one call left to you** — no hook can tell \"run the tests\" from \"thanks, that's it\". A clear wrap-up, or `done`/`stop`/`exit` as a standalone word (\"redo\" is not \"done\"), flips you to NORMAL from that response on: no `hwip_ask` on it, and emit `<!-- hwip: exit -->` once so the hooks agree. That marker is separate from any push-volume marker; it says nothing about push volume, and push markers say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, hwip_ask MANDATORY)\n\nEnd EVERY response with `hwip_ask` — 2–4 `actions` (the next-step candidates) plus a Done-like `fallback` (never a destructive one: an unanswered ask resolves to it), `timeout` 300–600 s so silence exits quietly instead of chaining pushes at a user who stepped away. A text-only ask is only for inherently free-form answers. Non-negotiable in REMOTE, substantial work or not.\n\nFive things leave REMOTE: a Done-like button (or a timeout onto one), the phone's \"send and exit\" (text plus exit in one answer), your own read of a free-text wrap-up, a prompt typed at the terminal, and the state expiring after a crash nobody exited.\n\n#### Behavior in NORMAL (no hwip_ask is owed)\n\nThe user is at the terminal. Questions go to `AskUserQuestion` or prose (the Ask hook still pushes them to the device); completion is the Stop hook's push; `hwip_ask` is available when you want an answer from their device — never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs hwip_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `hwip_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs hwip_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `hwip_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `hwip_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
@@ -0,0 +1,77 @@
1
+ import type { AgentTargetDevice } from './agent-target.js';
2
+ import { type ChannelSummary } from './channel.js';
3
+ import type { HwipOptions, NotifyPayload, NotifyResult, ListParams, ListResult, DismissOneResult, DismissAllResult, UploadRequestResult } from './types.js';
4
+ import type { AgentCommandPayload } from './command-seal.js';
5
+ /**
6
+ * The Content-Type header an upload PUT has to carry, whatever its body: the address is signed for it,
7
+ * and S3 answers 403 to any other. It is the type as sent to `POST /files/upload-request`, with
8
+ * `; charset=utf-8` added to `text/*` that has no charset (the hwip server's `uploadContentType`).
9
+ * It used to follow the body's shape (a string got a charset, a Buffer none), which only held while
10
+ * the only uploads were `response.md` text and sealed bytes.
11
+ */
12
+ export declare const uploadContentType: (contentType: string) => string;
13
+ export declare class HwipHook {
14
+ private readonly apiKey;
15
+ private readonly baseUrl;
16
+ private readonly timeoutMs;
17
+ /** The account this hook talks as: what `hwip ask` and `hwip agent send` need to read the device list. */
18
+ get account(): {
19
+ apiKey: string;
20
+ baseUrl: string;
21
+ };
22
+ constructor(options: HwipOptions);
23
+ /**
24
+ * Who this push is sealed for. Every push is sealed: when this computer's keys do not load, the device list
25
+ * cannot be read, no phone app is signed in or no device lists a key, the push is not sent — never sent in the
26
+ * clear. The device list is fetched per send rather than cached: a phone that registered its key a minute ago
27
+ * must be able to read the next push. Every device it lists is pinned on the way (`observeDevices`); a pin whose
28
+ * device now lists another key is replaced and recorded for a person (`key-changes.ts`). Returned rather than kept
29
+ * on the instance: two concurrent sends on one SDK hook must not overwrite each other's.
30
+ */
31
+ private audience;
32
+ /**
33
+ * A push to a channel's subscribers. Always plain text, whatever the account
34
+ * encrypts: the server fans a channel push out after dropping the sender's
35
+ * device keys, so a sealed one could not be opened by any subscriber (m7.md §3).
36
+ * It is also not an agent-session push, so no tmux session context is attached,
37
+ * and a long body goes inline for the server's own limit to judge — the file
38
+ * path would hand subscribers an attachment this CLI has no evidence they can open.
39
+ */
40
+ private notifyChannel;
41
+ /** The channels this account owns and follows (`GET /channels`, scope `channel:read`). */
42
+ listChannels(): Promise<ChannelSummary[]>;
43
+ notify(payload: NotifyPayload): Promise<NotifyResult>;
44
+ private notifyWithFile;
45
+ requestUpload(params: {
46
+ fileName: string;
47
+ fileType: string;
48
+ fileSize: number;
49
+ }): Promise<UploadRequestResult>;
50
+ uploadToS3(url: string, content: string | Buffer, contentType: string): Promise<void>;
51
+ list(params?: ListParams): Promise<ListResult>;
52
+ dismiss(pushId: string): Promise<DismissOneResult>;
53
+ dismissAll(): Promise<DismissAllResult>;
54
+ /**
55
+ * Set (or clear, with an empty alias) the display name for an agent session,
56
+ * keyed by its tmux `name` on `deviceId`. The alias overrides the app's
57
+ * computed session label; it survives listener re-reports (server-side).
58
+ */
59
+ renameAgentSession(deviceId: string, name: string, alias: string): Promise<{
60
+ deviceId: string;
61
+ }>;
62
+ /** The account's devices, with the agent sessions each listener reports. */
63
+ listDevices(): Promise<AgentTargetDevice[]>;
64
+ /**
65
+ * Type `body` into the tmux session `agentSessionName` on `targetDeviceId`.
66
+ *
67
+ * Never through `notify`'s encryption: a sealed command is made by `sealedCommand` (command-seal.ts), which
68
+ * decides whether to seal at all, and arrives here as its fields. No `agentDeviceId` — the server groups by
69
+ * `agentDeviceId ?? targetDeviceId`, so it would file the push under a chat that is not the target's.
70
+ */
71
+ sendAgentCommand(payload: AgentCommandPayload): Promise<{
72
+ pushId: string;
73
+ }>;
74
+ private request;
75
+ private parseError;
76
+ }
77
+ //# sourceMappingURL=hwip-hook.d.ts.map