@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
package/README.md ADDED
@@ -0,0 +1,1979 @@
1
+ # @hwip/cli
2
+
3
+ [![npm](https://img.shields.io/npm/v/@hwip/cli.svg)](https://www.npmjs.com/package/@hwip/cli)
4
+ [![downloads](https://img.shields.io/npm/dm/@hwip/cli.svg)](https://www.npmjs.com/package/@hwip/cli)
5
+ [![node](https://img.shields.io/node/v/@hwip/cli.svg)](https://nodejs.org)
6
+ [![license](https://img.shields.io/npm/l/@hwip/cli.svg)](./LICENSE)
7
+ [![docs](https://img.shields.io/badge/docs-docs.hwip.app-1f6feb)](https://docs.hwip.app)
8
+
9
+ **Your agent works, hits a decision, and asks your phone. You tap a button (or type a reply), and the answer lands back in the live session — so the agent keeps going.**
10
+
11
+ `@hwip/cli` is the terminal side of that round trip: a push SDK, a `hwip` CLI that wires up 10 AI agents in one command, and a resident listener that lets your phone **drive any agent session it can start** (16 agents supported) by typing straight into named tmux sessions.
12
+
13
+ <p align="center">
14
+ <img src="https://hwip.app/readme/demo.gif" alt="Agent asks 'Deploy to prod?' on your phone; you tap Deploy; the session ships" width="560"><br>
15
+ <sub><em>Your agent asks on your phone → you tap <b>Deploy</b> → the session ships. No terminal.</em></sub>
16
+ </p>
17
+
18
+ - **`HwipHook` SDK** — native `fetch`, no runtime deps. Send / list / dismiss pushes.
19
+ - **`hwip` CLI** — one-command setup for 10 agents, push sending, and the resident listener for phone-driven remote control of 16 agents.
20
+
21
+ Part of the Hwip toolchain: [`@hwip/mcp-server`](https://github.com/hwipit/mcp-server) (the MCP tools your agent calls, e.g. `hwip_ask`) · [`hwipit/plugin`](https://github.com/hwipit/plugin) (Claude Code plugin bundling hooks + MCP + rules) · the [Hwip app](https://hwip.app) on your phone.
22
+
23
+ > **New here?** [docs.hwip.app](https://docs.hwip.app) walks the whole setup — one command on this machine, the app on your phone, and a restart. The reference below assumes that is already done.
24
+
25
+ ## Quick Start
26
+
27
+ Install the CLI globally, then run setup:
28
+
29
+ ```bash
30
+ npm install -g @hwip/cli
31
+ hwip install
32
+ ```
33
+
34
+ `hwip install` opens a browser sign-in on a fresh machine — the web app
35
+ issues an API key + hook as a matched pair and the CLI writes them to
36
+ `~/.hwip/config.json` (no copy-paste). Then it installs rules + hooks +
37
+ MCP for every detected agent (Claude Code / Cursor / Windsurf / Gemini /
38
+ Codex / …). Safe to re-run: a saved login is reused untouched (no
39
+ prompts), so a re-run just refreshes the agent integrations. To switch
40
+ account, `hwip install --relogin` forces a fresh sign-in.
41
+
42
+ **Why global, not `npx`?** `hwip cc` (drive a session from your phone)
43
+ needs `hwip` on your `PATH`, and the agent hooks this installs are
44
+ `$(command -v hwip || npx …)` — a global binary skips an npx cold-start
45
+ on *every* notification. For notifications only, with no phone control,
46
+ `npx @hwip/cli install` is a lighter alternative that skips the global
47
+ binary — and with it `hwip cc`, which is also where the update notice
48
+ (`npm i -g @hwip/cli`) appears.
49
+
50
+ Once installed, the hooks fire in **every** session of each configured
51
+ agent — `hwip cc` is the phone-control bridge, not the notification
52
+ switch. In Claude Code the routine per-turn push starts off (`quiet` is
53
+ the default) — while you're at the terminal; once you've stepped away, a
54
+ finished turn still pushes ([Quiet while away](#quiet-while-away)).
55
+ `/hwip-mode normal` turns it on for a project, `/hwip-mode loud`
56
+ pushes on every turn, `/hwip-mode mute` silences a project entirely, and
57
+ `/hwip-mode` shows what's in effect. See
58
+ [Mute & push mode](#mute--push-mode).
59
+
60
+ `~/.hwip/config.json` is the single source of truth — the CLI, the MCP
61
+ server, the plugin hooks, and the listener all read it. You never need
62
+ `HWIP_API_KEY`-style env vars for a normal setup; they exist as overrides
63
+ (second account, CI).
64
+
65
+ Variants:
66
+
67
+ ```bash
68
+ # Headless box (no browser): paste credentials from another machine's
69
+ # ~/.hwip/config.json
70
+ hwip install --key ak_... --hook hook_...
71
+
72
+ # Skip the interactive agent picker
73
+ hwip install --only claude,cursor
74
+
75
+ # Switch account — force a fresh browser sign-in over a saved login
76
+ hwip install --relogin
77
+
78
+ # Refresh credentials only (no agent re-install) — assumes you already
79
+ # ran `hwip install` once; on its own it does NOT wire MCP into agents
80
+ hwip login
81
+ ```
82
+
83
+ To **send** notifications:
84
+
85
+ ```bash
86
+ hwip notify --title "Deploy done" --body "v2.1.0 shipped"
87
+ ```
88
+
89
+ To **send to a channel you own** (everyone subscribed to it gets the push):
90
+
91
+ ```bash
92
+ hwip channel list # channels you own and follow
93
+ hwip notify --channel Releases --title "v2.1.0" --body "shipped"
94
+ hwip notify --channel ch_abc123 --title "v2.1.0" # a channelId works too
95
+ ```
96
+
97
+ `--channel` takes a channelId or an exact channel name. A name resolves to the
98
+ channel *you own* with that name — a channel you only follow under the same name
99
+ is not a candidate — and two owned channels with one name are refused: use the
100
+ id. `channel list` marks each channel `yours` or `following`; only an owner can
101
+ send, and the CLI refuses a channel you follow without sending anything. (Against
102
+ a server that predates the `isOwner` field, the list shows each channel's owner
103
+ instead, a name shared with a followed channel is refused as ambiguous, and the
104
+ server refuses the push for a channel you do not own.)
105
+
106
+ **A channel push is plain text.** Unlike every other push, it is not sealed — the server can read it, because a sealed push
107
+ could not be opened by the subscribers. The CLI says so on stderr each time.
108
+ It needs `--title` or `--body` (there is no default text), and does not
109
+ combine with `--auto`, `--device` or `--session`, which mean "this agent
110
+ session". `hwip send` / `hwip agent send` types into an agent session and has
111
+ no channel option. The API key needs the `channel:read` and `push:write`
112
+ scopes.
113
+
114
+ To **drive an agent session from your phone**, see
115
+ [Remote Control](#remote-control) below.
116
+
117
+ ## Remote Control
118
+
119
+ > Send messages from your phone *into* a live agent session — even after
120
+ > a `hwip_ask` polling window has expired.
121
+
122
+ <p align="center">
123
+ <img src="https://hwip.app/readme/ask-phone.png" alt="A Hwip hook on the phone: a question with tappable answer buttons and a text field" width="300">
124
+ </p>
125
+
126
+ The MCP tool `hwip_ask` waits on a fixed
127
+ timeout (120–600 s). Once that window closes the
128
+ session becomes unaddressable from the phone, even though it's still
129
+ running. The `hwip listener` daemon fixes this by keeping a persistent
130
+ WebSocket open to Hwip and injecting matching messages into a *named*
131
+ tmux session via `tmux send-keys`.
132
+
133
+ ### Architecture
134
+
135
+ ```
136
+ [phone — "Active Agents" picker on Hwip app]
137
+ │ selects session, types message
138
+ ▼ POST /pushes/send { type: 'agent.command',
139
+ │ agentSessionName: 'hwip-myapp',
140
+ │ body: '리팩토링 마무리해줘' }
141
+ [Hwip backend]
142
+ │ WebSocket fan-out (push.new)
143
+ ▼
144
+ [hwip listener — resident daemon, started by `hwip cc` automatically]
145
+ │ tmux send-keys -l -t hwip-myapp "리팩토링 마무리해줘" + Enter
146
+ ▼
147
+ [tmux session "hwip-myapp" running claude / codex / cursor-agent / gemini / hermes]
148
+ ```
149
+
150
+ The listener polls its tmux session inventory every 5 seconds and
151
+ reports it to the server whenever something changed (new session, agent
152
+ state transition, activity) — an unchanged inventory is re-sent only as
153
+ a two-minute idle heartbeat. The phone picker stays in sync with no
154
+ manual configuration, and an idle listener costs the backend a fraction
155
+ of what a fixed 5-second report cycle would.
156
+
157
+ ### Live timeline
158
+
159
+ While a session is working, the phone can draw what the agent is doing right
160
+ now — the prompt that started the turn, each tool call and its outcome, the
161
+ agent's own replies, and per-message token counts. It is read by tailing the
162
+ agent's transcript on disk; nothing a tool read or wrote leaves the machine,
163
+ only names, short labels and numbers.
164
+
165
+ The one list it carries in words is the agent's own to-do list (Claude Code's
166
+ TaskCreate · TaskUpdate · TaskList), sent whole as a `todo` event each time it
167
+ changes — the tasks as the agent wrote them, at most 30 of 100 characters each,
168
+ sealed like the replies are.
169
+
170
+ Each agent stores its transcript differently, so a row in `REMOTE_AGENT_TABLE`
171
+ (`src/remote-agents.ts`) names two things: where the file is, and which
172
+ projector reads it. An agent with neither answers `no_transcript` and the phone
173
+ says it has no live timeline.
174
+
175
+ | Agent | Transcript | Projector |
176
+ |-------|-----------|-----------|
177
+ | Claude Code | `~/.claude/projects/<encoded cwd>/<uuid>.jsonl` | `src/transcript-tail.ts` |
178
+ | pi | `~/.pi/agent/sessions/<encoded cwd>/<ts>_<id>.jsonl` (`PI_CODING_AGENT_DIR`) | `src/pi-transcript.ts` |
179
+ | Codex CLI | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`CODEX_HOME`) | `src/codex-transcript.ts` |
180
+ | Gemini CLI | `~/.gemini/tmp/<project>/chats/session-*.jsonl` (`GEMINI_CLI_HOME`) | `src/gemini-transcript.ts` |
181
+ | OpenCode 2.x | no file — the `session_message` table of `~/.local/share/opencode/opencode.db` (`OPENCODE_DB`, `XDG_DATA_HOME`), asked again when the store changes, at most once a second per session. The pane's session is the newest one in its directory written to since the pane started | `src/opencode-transcript.ts` |
182
+ | Cursor, Hermes, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — |
183
+
184
+ Adding an agent means a projector file of its own, not another branch in an
185
+ existing one: the formats share no vocabulary. Each projector is pinned to the
186
+ vendor layout it was read against, and an entry type it has never seen draws no
187
+ row rather than breaking the watch.
188
+
189
+ ### Remote-origin detection (sticky REMOTE mode)
190
+
191
+ A message injected via `send-keys` is indistinguishable from typing — so
192
+ the listener also records each injection as a one-shot marker (epoch +
193
+ sha256 of the text, keyed by the pane's project dir). A prompt-submit
194
+ hook on the agent side matches the submitted prompt against that marker
195
+ and, on an exact match, tells the model the user is driving the session
196
+ from their phone — entering sticky REMOTE mode (every response ends with
197
+ an answerable `hwip_ask`).
198
+
199
+ | Agent | Hook | Installed by |
200
+ |-------|------|--------------|
201
+ | Claude Code | `UserPromptSubmit` → plugin's `hwip-remote.sh` | Hwip plugin |
202
+ | Gemini CLI | `BeforeAgent` → `hwip remote-hook gemini` | `hwip setup` |
203
+ | Codex CLI | `UserPromptSubmit` → `hwip remote-hook codex` | `hwip setup` |
204
+ | Pi | `before_agent_start` → `hwip remote-hook pi` (via extension) | `hwip setup` |
205
+ | Cursor CLI | — none yet | — |
206
+
207
+ **Subagents (view-only).** A subagent shows up on your phone as a child of its
208
+ parent card, named `<session>.<n>` — it streams live, but input, keys, exit,
209
+ resume and forget are all refused: you watch from the phone and answer at the
210
+ terminal, where the parent owns the conversation. A prompt push from a subagent
211
+ names it and opens its stream directly.
212
+
213
+ They reach the roster two ways. A pi subagent spawned into an extra pane of the
214
+ same tmux window is found as a pane, and labelled from `@hwip_pane_label` (the
215
+ pi extension sets it from `PI_SUBAGENT_NAME`). A Claude Code subagent — the
216
+ `Agent` tool — has no pane at all; it is found in the parent's transcript
217
+ directory instead (`src/subagent-transcripts.ts`), labelled from the
218
+ description the parent gave it, and carries the number of tools it has run so
219
+ far. Both kinds are numbered in one sequence, so the names never collide.
220
+
221
+ A Claude Code subagent is marked `inProcess`, because the phone cannot mirror a
222
+ pane that does not exist — its terminal view draws the transcript instead. It
223
+ leaves the roster the moment the parent's transcript records its end (a
224
+ background subagent's task notification, or a foreground one's `Agent` call
225
+ returning), rather than after five quiet minutes; one that is resumed comes
226
+ back when it writes again.
227
+
228
+ Detection is exact-match: a terminal keystroke racing a phone message
229
+ can never false-flag. Muted projects are never flagged.
230
+
231
+ Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one
232
+ other way in: a `hwip_ask` answer reporting `hwipState: "REMOTE"`. Their rule
233
+ files therefore keep the after-real-work `hwip_ask` — the "Entering REMOTE
234
+ without a prompt hook" preamble in `src/templates.ts` — so the phone always
235
+ has a button to tap that starts the loop. The one exception is an answer sent
236
+ with the phone's "send and exit": that turn carries out the instruction and
237
+ ends without an ask. Agents with the hook drop that
238
+ obligation while at the terminal — and their rule file carries only the
239
+ NORMAL branch of the core (about a quarter of it), since it is read on every
240
+ turn; the hook injects the REMOTE sections in full on the turn a phone
241
+ message enters REMOTE, the way the Claude Code plugin's hooks do.
242
+
243
+ `hwip cursor` has no remote-origin hook, so it never enters sticky REMOTE
244
+ mode by itself. **Just ask for it** — one line, once per session:
245
+
246
+ > I'm driving this session from my phone. End every response with
247
+ > `hwip_ask` so I can answer with a button.
248
+
249
+ Nothing else is missing: injection works, and the MCP tools are all
250
+ there, `hwip_ask` included. Check them with `cursor-agent mcp list-tools
251
+ hwip` — `mcp list` prints only the *approved* list, not what's
252
+ configured, so it reads as empty even when the server is wired up.
253
+
254
+ Why it isn't automatic: `hooks.json` is honored by the Cursor IDE but not
255
+ by `cursor-agent`, and `beforeSubmitPrompt`'s output schema is
256
+ `{continue, user_message}` — no context channel to deliver a marker match
257
+ through. Same reason the `stop`-hook auto-push `hwip setup` installs
258
+ covers the Cursor **IDE** but not `hwip cursor` panes; ask for a
259
+ `hwip_notify` when you want one.
260
+
261
+ `hwip hermes` is in the same position, and asking for it works the same
262
+ way. `hwip install` does not wire Hermes either — add the MCP server to
263
+ `~/.hermes/config.yaml` by hand, as one entry under `mcp_servers`:
264
+
265
+ ```yaml
266
+ mcp_servers:
267
+ hwip:
268
+ command: "hwip"
269
+ args: ["mcp"]
270
+ ```
271
+
272
+ `hwip mcp` runs the MCP server inside the CLI process. The older
273
+ `command: "npx"` / `args: ["-y", "@hwip/mcp-server"]` form still works,
274
+ but leaves an `npm exec` launcher resident beside the server for as long as
275
+ the session lives — `hwip verify` flags a config still on it.
276
+
277
+ Add the entry; do not replace the file. `mcp_servers` is shared with
278
+ every other server you have configured, and nothing under `~/.hermes` is
279
+ in version control. Then `/reload-mcp` inside Hermes.
280
+
281
+ `hwip_ask` needs a hook id, which the MCP server reads from
282
+ `HWIP_HOOK_ID` or from `hookId` in `~/.hwip/config.json` — `hwip install`
283
+ writes the latter, so a machine that has run it once needs no `env`
284
+ block here.
285
+
286
+ ### Setup
287
+
288
+ 1. **Install tmux.** The listener uses `send-keys`; the wrapper spawns
289
+ named sessions. `hwip install` checks for it and, on macOS with Homebrew,
290
+ offers to run `brew install tmux` for you. Elsewhere it prints the line to
291
+ run — `sudo apt install tmux` on Debian/Ubuntu, and so on — because
292
+ installing with `sudo` is not something an installer should do on your
293
+ behalf. tmux has no Windows build, so `hwip cc` needs WSL there.
294
+
295
+ 2. **Add `wsUrl` to `~/.hwip/config.json`** (the WebSocket endpoint of
296
+ your Hwip backend — CDK output `WsApiUrl`). Keep it on the same stage as
297
+ `baseUrl`: the REST stage is the path prefix (`/v1` prod, `/d1` dev) and the
298
+ socket stage is the hostname, so they can disagree without either looking
299
+ wrong. `hwip verify` says which stage each one is on, and the listener only
300
+ reads this at startup — `hwip listener --restart` after editing:
301
+
302
+ ```json
303
+ {
304
+ "apiKey": "ak_...",
305
+ "hookId": "hook_...",
306
+ "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
307
+ }
308
+ ```
309
+
310
+ This is where it belongs. `--ws-url` overrides it for a single run.
311
+ `HWIP_WS_URL` is deprecated: it used to outrank this file, which meant an
312
+ `export` in a shell profile silently chose the stage and left no trace of
313
+ having done so. It now loses to the file and is read only when the file has
314
+ no `wsUrl`, so nothing that worked stops working — but it will go, and
315
+ `hwip verify` says so while it is still set.
316
+
317
+ 3. **Run agents through the wrapper.** That's it.
318
+
319
+ ```bash
320
+ hwip cc # claude → tmux session "hwip-<project>"
321
+ hwip codex # codex → tmux session "hwip-<project>"
322
+ hwip cursor # cursor-agent → tmux session "hwip-<project>"
323
+ hwip gemini # gemini → tmux session "hwip-<project>"
324
+ hwip hermes # hermes → tmux session "hwip-<project>"
325
+ hwip pi # pi → tmux session "hwip-<project>"
326
+ hwip opencode # opencode → tmux session "hwip-<project>"
327
+ hwip kimi # kimi → tmux session "hwip-<project>"
328
+ hwip grok # grok → tmux session "hwip-<project>"
329
+ hwip agy # agy → tmux session "hwip-<project>"
330
+ hwip qwen # qwen → tmux session "hwip-<project>"
331
+ hwip auggie # auggie → tmux session "hwip-<project>"
332
+ hwip kilo # kilo → tmux session "hwip-<project>"
333
+ hwip copilot # copilot → tmux session "hwip-<project>"
334
+ hwip kiro-cli # kiro-cli → tmux session "hwip-<project>"
335
+ hwip droid # droid → tmux session "hwip-<project>"
336
+ ```
337
+
338
+ `hwip install` does not wire MCP, rules or hooks for `kimi` · `grok` · `agy` ·
339
+ `qwen` · `auggie` · `kilo` · `copilot` · `kiro-cli` · `droid` — the wrapper
340
+ makes the session reachable from the phone and nothing more.
341
+
342
+ Two wrapper flags, read off the front of the args: `--detach` creates
343
+ the session without attaching (no TTY needed — a script or another
344
+ agent's pane can launch it) and prints its name; `--label <x>` names
345
+ it `hwip-<project>-<x>` instead of the `-2/-3` family. Example — a
346
+ Claude Code planner starting a pi implementer for one plan:
347
+
348
+ ```bash
349
+ hwip pi --detach --label plan-auth-pi -n plan-auth '/skill:implement plans/auth.md'
350
+ ```
351
+
352
+ **A session in its own git worktree — `--wt <name>`.** `hwip cc --wt feat/x`
353
+ makes the worktree for branch `feat/x` next to the repo's main checkout
354
+ (`<repo>-wt-feat-x`, with the branch started from the HEAD you typed it
355
+ at, or the branch as it is when it already exists) and starts the session
356
+ in it, under the name `hwip-<repo>-wt-feat-x`; the phone groups it under
357
+ `<repo>` as `wt-feat-x`. Typing the same command again reuses the
358
+ worktree (without `--detach` it reattaches the detached session). Typed
359
+ inside a worktree, it still makes the new one beside the main checkout. It
360
+ refuses, with one line and a non-zero exit, outside git, in a bare or
361
+ `--separate-git-dir` layout, for a name git does not take as a branch, and
362
+ when something that is not this worktree is already at the path — nothing
363
+ there is touched. Like `--detach` and `--label`, it goes at the front of
364
+ the args. Claude Code's own `--worktree` is not taken by the wrapper: it
365
+ passes through and uses `.claude/worktrees/`. `hwip sh` has no `--wt`.
366
+
367
+ **When the session is over.** The listener takes the worktree back, once,
368
+ after a session that ran in it has ended and no pane is left in it — and
369
+ only when nothing in it would be lost: no uncommitted or untracked file,
370
+ no **ignored** file (a hand-written `.env` is ignored, and `git worktree
371
+ remove` would delete it without a word), and no commit made since the
372
+ worktree was created that is not on a remote. It removes with plain
373
+ `git worktree remove` (never `--force`), then deletes the branch with
374
+ `git branch -d` only if hwip made it and git says it is merged. When
375
+ anything holds the worktree it stays: the listener log gets one line and
376
+ macOS gets one banner naming what holds it (`2 uncommitted or untracked, 1
377
+ unpushed commit`), and it is not judged again until a session uses it
378
+ again. Only worktrees hwip made with `--wt` are ever removed — it keeps
379
+ them in `worktrees.json` under the state directory, and a worktree you
380
+ made by hand has no row. `git worktree list` is the full list; `hwip
381
+ worktree` shows the ones hwip made and what keeps each, and `hwip worktree
382
+ clean` removes the ones nothing keeps now (`--dry-run` only says; it
383
+ judges every worktree not in use, including ones the listener left or
384
+ never saw end). The listener looks when the set of
385
+ `hwip-*` sessions changes (and once when it starts), the last session
386
+ ending with tmux itself included; a run started inside a tmux pane of your
387
+ own (`$TMUX` set, no `--detach`) is not a session it sees end, so
388
+ `hwip worktree clean` is how that one goes. The chat history of an ended
389
+ session stays, so resuming it from the phone after its worktree is gone
390
+ answers with the app's missing-directory error.
391
+
392
+ A labelled session, and any session started in a linked git worktree,
393
+ still shows on the phone under its project — the main checkout's name —
394
+ with the label (or the worktree's session name) beside it. The wrapper
395
+ records both as tmux session options (`@hwip_project`,
396
+ `@hwip_session_label`) because the session name alone cannot say where
397
+ the project ends. A session started without them (before this, or by an
398
+ older CLI) is grouped by the listener from its pane's checkout instead,
399
+ so upgrading the listener is enough — no session restart.
400
+
401
+ **Inside [herdr](https://herdr.dev).** herdr finds agents by a pane's
402
+ foreground process, which for a wrapped session is the tmux client, so
403
+ the agent would not show in its sidebar. Every `hwip` attach records the
404
+ herdr pane it runs in as session options (`@hwip_herdr_pane`,
405
+ `@hwip_herdr_socket`, `@hwip_herdr_bin`), and clears them when attached
406
+ from outside herdr; a bare `tmux attach` changes neither, so the agent
407
+ keeps reporting to the last herdr pane `hwip` saw. Reporters read them on
408
+ every report and send the agent's `working` / `idle` / `blocked` state to
409
+ that pane: Claude Code through the hwip plugin's `hwip-herdr.sh` hook
410
+ (plugin 0.20.0+) as `hwip cc`, pi through the hwip pi extension as
411
+ `hwip pi`. herdr's own pi extension reads the pane from its env, which
412
+ tmux does not pass, so it stays silent in a wrapped session. Headless runs (`claude -p`, `pi -p`,
413
+ RPC) started from a shell in a wrapped pane inherit its `TMUX_PANE` and
414
+ never report.
415
+
416
+ **A shell the phone may type into — `hwip sh`.** Every other shell
417
+ pane is refused (see [Defense](#defense)); `hwip sh` opens the one
418
+ exception, `hwip-<project>-sh` running your login shell, for the jobs an
419
+ agent's permission prompt blocks — a restart, a `kill`, a `sudo`. It is a
420
+ real terminal: `cd` and `export` stick, `sudo`/`ssh` prompts work, and a
421
+ long command does not tie up an agent. `--label <x>` names it
422
+ `hwip-<project>-<x>`; `--detach` creates it without attaching. Away from
423
+ the desk, open one from the phone through any running Claude Code
424
+ session by sending `!hwip sh --detach` (Claude Code's `!` bash mode —
425
+ see Defense item 7). Inside tmux it never attaches; it prints a
426
+ `tmux switch-client -t <session>` line instead. Running it again for a
427
+ session it opened reattaches, and a session of that name it did not open
428
+ is refused.
429
+
430
+ ```bash
431
+ hwip sh # → tmux session "hwip-<project>-sh"
432
+ hwip sh --detach # create it, print its name, exit
433
+ ```
434
+
435
+ **The mirror fits a detached session to the phone.** A pane is as wide
436
+ as the terminal that last drew it, so a desktop's prompt rules and
437
+ right-aligned clocks fold in two on a phone. While the phone watches a
438
+ session no terminal is attached to (a `hwip sh --detach` shell, or an
439
+ agent whose terminal you closed), the listener resizes its window to the
440
+ phone's width and gives the old width back when the phone leaves. An
441
+ attached session keeps its size: shrinking it would shrink your desktop
442
+ terminal, so the phone wraps those rows instead. A window split into
443
+ panes is never resized, and one a terminal attaches to while the phone
444
+ watches is handed back within a renew (5 s). A listener that died while
445
+ a window was fitted restores it the next time it starts.
446
+
447
+ `hwip cursor` runs **`cursor-agent`**, Cursor's terminal agent — a
448
+ separate install from the Cursor IDE (the bare `cursor` on your PATH
449
+ is the editor launcher, which exits immediately and can't be driven).
450
+
451
+ `hwip hermes` runs Hermes in its classic REPL, which is the default.
452
+ Launching it in TUI mode instead (`hwip hermes --tui`, or `HERMES_TUI=1`
453
+ in the environment) puts the pane on the terminal's alternate screen,
454
+ and the alternate screen has no scrollback — the live mirror on your
455
+ phone still draws, but scrolling up in it comes back empty.
456
+
457
+ If a Hermes session disappears from the phone's picker, the cause is
458
+ tmux having dropped the pane's `start_command`: all the listener sees
459
+ then is the interpreter Hermes execs (`python3.11` at time of writing),
460
+ which names no agent. `hwip listener` run in the foreground prints the
461
+ reason (`no agent in pane (start=…, current=python3.11)`). Kill the
462
+ session and start it again with `hwip hermes` to recover. `claude` sits
463
+ on the same edge for the same reason — it execs node.
464
+
465
+ The first `hwip cc` on a machine **auto-spawns a background
466
+ listener** (singleton, PID file at `~/.hwip/listener.pid`,
467
+ stdout/stderr at `~/.hwip/listener.log`). You never run
468
+ `hwip listener` by hand — every `hwip cc` checks the PID file and
469
+ skips the spawn when one is already alive, so opening a dozen
470
+ terminals doesn't create a dozen daemons. The daemon survives
471
+ between `hwip cc` invocations — but not a reboot, which is what the
472
+ login-time service below is for.
473
+
474
+ Project name resolves from `CLAUDE_PROJECT_DIR` /
475
+ `CURSOR_PROJECT_DIR` / `WINDSURF_PROJECT_DIR` if set, else the git
476
+ repo root, else the cwd basename. Any extra args after the command
477
+ pass through to the agent verbatim:
478
+
479
+ ```bash
480
+ hwip cc --resume "abc123"
481
+ hwip cc --dangerously-skip-permissions
482
+ hwip codex --model gpt-5-high "fix the failing test"
483
+ ```
484
+
485
+ **Multiple sessions in one project.** Open another terminal in the
486
+ same folder, run `hwip cc` again, and the wrapper auto-suffixes:
487
+ first session is `hwip-encl`, the next attached one becomes
488
+ `hwip-encl-2`, then `hwip-encl-3`, etc. The phone picker shows them
489
+ as `encl · Claude`, `encl · Claude #2`, `encl · Claude #3`.
490
+ Once the agent has named the session itself — Claude Code, Codex,
491
+ Hermes and OpenCode 2.x do — the picker shows `<that name> · <Agent>`
492
+ instead.
493
+
494
+ **Reattaching wins over spawning.** If any session of that project is
495
+ **detached** (no one attached), the wrapper reattaches instead of
496
+ spawning a new one — close the terminal, come back later, pick up
497
+ where you left off. When several are detached it takes the
498
+ highest-numbered one first, then works down on later runs, so no
499
+ session can end up out of reach. That matters because a session you
500
+ cannot reach still holds a live agent: an agent CLI plus its MCP
501
+ servers is a few hundred MB you can neither see nor reclaim. A
502
+ detached session is never killed for you — nothing here can tell
503
+ "done with this" from "closed the laptop".
504
+
505
+ **Only a slot running the same agent is reattached.** `hwip pi` will
506
+ not reattach a detached `hwip cc` session: tmux attaches to a session
507
+ that already exists and drops the command it was given, so you would
508
+ land in claude with pi never started. A slot running another agent is
509
+ skipped and the next suffix is used instead.
510
+
511
+ If you're already inside a tmux session (`$TMUX` set) the wrapper
512
+ skips the outer tmux and runs the agent in the current pane — the
513
+ listener can't target an unnamed session that way, but you keep your
514
+ existing multiplexer setup. Unless that session is itself a `hwip-*`
515
+ one, the wrapper says so in one line and points at `--detach` or
516
+ running outside tmux.
517
+
518
+ ### Starting the listener at login
519
+
520
+ A reboot ends the daemon, and nothing starts it again until you open a
521
+ terminal and run `hwip cc`. Until you do, the phone shows **no agents** —
522
+ the machine is on, the past sessions are on disk, and the app has no one
523
+ to hear from.
524
+
525
+ ```bash
526
+ hwip listener --install-service
527
+ ```
528
+
529
+ On macOS that registers a launchd LaunchAgent (`app.hwip.listener`), and on
530
+ Linux a systemd user unit (`app.hwip.listener.service`, see
531
+ [On Linux](#on-linux) below); either starts the listener at every login. `--uninstall-service` removes it and
532
+ `--service-status` shows what the installed one points at; `hwip install`
533
+ offers it during setup and `hwip uninstall` takes it away again.
534
+
535
+ What you get back after a reboot is your **past sessions**, ready to
536
+ resume from the phone — not live agents. tmux does not survive a reboot
537
+ either, and this doesn't try to bring those sessions back to life.
538
+
539
+ Two details worth knowing:
540
+
541
+ - It fires at **user login**, not at boot. Without automatic login, the
542
+ machine stays silent while it sits on the login screen.
543
+ - The plist bakes in absolute paths for node and the CLI, a `PATH` that
544
+ can reach tmux, and a UTF-8 `LANG`. launchd gives a job
545
+ `/usr/bin:/bin:/usr/sbin:/sbin` and no locale at all: without the PATH
546
+ the daemon exits 127 at every login, and without the locale tmux
547
+ escapes the separator the session list is split on, so every session is
548
+ silently dropped while everything else looks healthy. If any of that
549
+ drifts — a version-manager node upgrade, say — `hwip verify` names it,
550
+ and re-running `--install-service` repoints the plist.
551
+ - The plist never holds a proxy. A proxy password in `EnvironmentVariables` would sit in a
552
+ file other tools back up and read, so the service takes the proxy from `~/.hwip/config.json`
553
+ (`"httpsProxy": "http://user:pass@proxy.example:3128"`, optionally `"httpProxy"` and
554
+ `"noProxy"`), which hwip keeps at mode 0600 and ignores when another user owns it or anyone can write it. It
555
+ covers both the API calls and the listener's socket; the rules (the environment's `HTTPS_PROXY`
556
+ wins over the file's value, `NO_PROXY` syntax, the error for a bad address) are in
557
+ [Environment Variables](#environment-variables). Restart the service after editing the file:
558
+ `hwip listener --restart`. While `config.json` is ignored (see below) its proxy settings are
559
+ ignored too, and the service connects directly.
560
+
561
+ With the service installed, launchd owns the process: `hwip cc` and
562
+ `hwip listener --stop|--restart` ask launchd rather than signalling the
563
+ PID, so there is never a second daemon racing the first.
564
+
565
+ #### On Linux
566
+
567
+ Where a systemd user manager is running (`systemctl --user` answers),
568
+ `--install-service` writes `~/.config/systemd/user/app.hwip.listener.service`
569
+ (`$XDG_CONFIG_HOME/systemd/user/` when that is set), then runs
570
+ `systemctl --user daemon-reload`, `enable` and `restart`. The unit bakes in
571
+ the same absolute node and CLI paths, `PATH` and UTF-8 `LANG` as the plist —
572
+ the user manager's own `PATH` is whatever the distribution configured — and
573
+ appends both output streams to `~/.hwip/listener.log` (`append:` needs systemd 240 or
574
+ later; behavior on older releases is unverified).
575
+ It restarts the listener 30 s after a crash and leaves a clean exit down, as
576
+ launchd does. `--uninstall-service` runs `systemctl --user disable --now`,
577
+ deletes the file and reloads; `hwip listener --stop|--restart` become
578
+ `systemctl --user stop|restart`, and `hwip verify` checks the unit the way it
579
+ checks the plist. This Linux path has not been verified against a real machine yet —
580
+ the unit and the `systemctl` calls follow the freedesktop.org manual pages.
581
+
582
+ The unit hangs off the user manager's `default.target`, so it runs while you
583
+ are logged in. Without *lingering*, systemd starts your user manager at login
584
+ and stops it after your last logout — on a server nobody logs in to, the
585
+ listener would not start after a reboot. hwip never changes that setting;
586
+ turn it on yourself if you want the listener up from boot:
587
+
588
+ ```bash
589
+ sudo loginctl enable-linger $USER
590
+ ```
591
+
592
+ ### Keeping the Mac awake
593
+
594
+ The listener holds an *outbound* socket, and macOS **"Wake for network
595
+ access" does not protect it** — that setting only wakes the machine for
596
+ inbound requests to services it advertises. When the Mac idle-sleeps, the
597
+ socket dies and the phone shows "Waiting for the live stream…" until
598
+ someone opens the lid. A Mac running Claude Code hides this (the agent
599
+ runs `caffeinate` during every turn), so one machine looks fine while
600
+ another, idling at a prompt, keeps dropping.
601
+
602
+ So on macOS the listener runs `caffeinate -s -w <its pid>` for as long as
603
+ it lives. `-s` is honoured **on AC power only** — on battery macOS ignores
604
+ it, so a laptop still sleeps and nothing drains. The display still turns
605
+ off; a closed lid still sleeps. Activity Monitor shows the daemon under
606
+ "Preventing Sleep". It holds even when no agent session is open: resuming a
607
+ past session from the phone needs this Mac awake to answer. Opt out with `--no-keep-awake` or `"keepAwake": false`
608
+ in `~/.hwip/config.json`; the log's `keep-awake:` line says which way it
609
+ went.
610
+
611
+ ### Scheduled sessions
612
+
613
+ `hwip schedule add` makes the listener start an agent session by itself — every morning, every two hours — so the phone has something to look at without anyone opening a terminal first. The listener ticks every 30 seconds for as long as it lives, with or without the socket up, so installing it as a login service ([above](#starting-the-listener-at-login)) is what keeps schedules running across restarts.
614
+
615
+ - **Times** are local. `--at HH:MM` runs on `--days` (daily by default); `--every <N>m|<N>h` counts from the moment the schedule was made, no more often than every 15 minutes. A time that does not exist on the day the clocks go forward runs once at the moved time.
616
+ - **A missed run is never started late.** A run is started only if the listener sees it within two minutes of its time. After the computer slept or the listener was down, every time that passed is counted as missed and skipped; the newest one still starts if it is within those two minutes.
617
+ - **One session per schedule, three at once.** A run whose session from the last one is still there is skipped (`overlap`), and so is a fourth scheduled session while three run (`busy`). A directory that is gone, an agent command that does not exist, and a launch that fails are skipped too.
618
+ - **The prompt** (`--prompt`) is typed once, and only when the agent is the foreground program of the pane and its detection rules read the screen as `idle` — not working, not a dialog. If that does not happen within 90 seconds the prompt is not typed, the session stays, and the phone is told. **Only agents that have an `idle` rule can take a prompt** (today Claude Code, Codex, Gemini CLI and pi): for any other agent nothing separates "ready" from a trust or permission dialog, and the Enter that submits a prompt would answer the dialog's default with nobody there to read it. `hwip schedule add … --prompt` refuses those agents and says which ones work; a schedule without `--prompt` starts any agent. The rules that decide this are the ones shipped in the `hwip` package, not the detection rules the listener updates over the air: a phone's input is typed by someone who has seen the screen, a schedule by nobody, and a rule set that arrives over the air is not signed — it must not be able to call a dialog `idle` and have your prompt and Enter typed into it.
619
+ - **The cap**: a session that runs longer than `--max-runtime` is asked to end the way the phone's *end session* does — the agent's own quit command, then signals. It is never killed; one that stays keeps the schedule from starting its next run (`overlap`).
620
+ - **The phone is told** with one push per listener tick, listing each schedule with how many times and why (`<label> · 놓침 31회` — the push is in Korean). A run skipped because the last one is still going counts once until the result changes; a push that does not go out is tried again after 30 seconds, then 1, 2, 4 … minutes up to 30 (only a refusal by the server, such as the daily limit, is not tried again; a timeout, a proxy or a captive portal in the way is), and is dropped after 24 hours. The push has one title per kind of news — skipped or missed, over its runtime, or a prompt not typed — and a general one when a push holds several kinds. The prompt is never in it. A free account sends at most 100 pushes a day (`pushPerDay`), which is why these are batched.
621
+ - **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to.
622
+ - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
623
+
624
+ ### Session history
625
+
626
+ So the app can show a conversation after the fact, the listener reads each
627
+ agent session's transcript (Claude Code, Codex, pi, Gemini CLI, and OpenCode 2.x from its
628
+ database) with nobody watching and
629
+ uploads what was said as small **sealed** chunks (`POST /sessions/<key>/turns`).
630
+ A chunk holds your prompts, the agent's replies, and one line per tool — its
631
+ name, what it acted on, and how many lines changed — never a tool's arguments,
632
+ output or file contents. Each chunk is sealed for this machine and every device
633
+ the account lists with a key (the newest pins first, within the server's
634
+ slot limit), so the
635
+ server stores ciphertext only, on every plan; a device that signs in later
636
+ cannot open earlier chunks. While the account lists no other device with a key,
637
+ or its device list has never been fetched, nothing is read or sent. Uploads also wait until this machine's own device key is
638
+ registered with the server. Opt out with `--no-turn-history` or `"turnHistory": false` in
639
+ `~/.hwip/config.json`; the log says which way it went. The chunks wait in a
640
+ small outbox under the state directory (ciphertext and read positions only — a byte
641
+ offset, or for OpenCode row numbers and their keys) when
642
+ the network is down, and go out in order once it is back — also after the
643
+ listener restarts, even when the session they belong to did not survive (a reboot
644
+ ends every tmux session): such a session is followed to the end of its
645
+ transcript, then forgotten. A session that has been quiet for a long time keeps its
646
+ state while its transcript exists, so nothing already sent is sent again (an OpenCode
647
+ session, which has no file to check, for 30 days).
648
+
649
+ When a session waits for your approval (a Bash command, a file edit or a new file),
650
+ the listener also sends what it is waiting for — the tool, the command or the diff,
651
+ and the numbered answers — so the app can show it and answer. It is sealed the same
652
+ way as a chunk, for the same pinned devices, and turned off by the same
653
+ `--no-turn-history`; the server stores ciphertext only. Other dialogs (a plan, a
654
+ question, a trust prompt) send nothing beyond the state. An answer the app sends is
655
+ typed only while that same prompt is still on screen: if you have moved the
656
+ highlight or answered at the keyboard, the command is refused (`prompt_changed`)
657
+ instead of landing on whatever is there now.
658
+
659
+
660
+ ### Diagnostics
661
+
662
+ The auto-spawned listener writes to three files under `~/.hwip/`:
663
+
664
+ - `listener.pid` — the running daemon's PID. `cat ~/.hwip/listener.pid`
665
+ + `ps -p <pid>` to confirm it's alive.
666
+ - `listener.version` — the CLI version the daemon booted from. This is
667
+ what `hwip cc` compares against the installed package to spot a stale
668
+ daemon (see below).
669
+ - `listener.log` — stdout + stderr from the daemon. `tail -f` to watch.
670
+
671
+ The daemon logs its version on the first line, which is the only
672
+ reliable way to tell which build a long-running process is on:
673
+
674
+ ```
675
+ [xx:xx:xx] hwip listener starting — v1.26.0 — wss://ws.hwip.app
676
+ ```
677
+
678
+ A healthy listener log shows one line per cycle:
679
+
680
+ ```
681
+ [xx:xx:xx] reported 2 session(s): hwip-myapp, hwip-otherapp
682
+ [xx:xx:xx] ✓ server persisted 2 session(s)
683
+ ```
684
+
685
+ If you see `! server rejected listener.sessions: ...` instead, the
686
+ message points at the failure (auth, missing device record, etc.) so
687
+ you can fix the actual problem instead of guessing.
688
+
689
+ To force a restart:
690
+
691
+ ```bash
692
+ hwip listener --restart
693
+ ```
694
+
695
+ It gives the new daemon 1.5 s (under launchd, after it has written its pid
696
+ file, up to 5 s in) and exits 1 when that daemon is already gone, with the
697
+ last line it logged — a rejected key, say:
698
+
699
+ ```
700
+ hwip listener: the listener stopped right after restarting — hwip listener: API key required. (log: /Users/you/.hwip/listener.log)
701
+ ```
702
+
703
+ With the service installed launchd restarts such a daemon on a loop, so
704
+ `hwip cc` makes the same check after asking launchd and prints `hwip: the
705
+ listener stopped right after starting — …` instead of reporting a start.
706
+
707
+ **After upgrading `@hwip/cli` you normally don't have to.** `npm i -g`
708
+ replaces the package on disk but never the daemon already running from
709
+ the old build — that daemon keeps answering pushes (so agent chat looks
710
+ fine) while silently ignoring every message subtype added since it
711
+ booted. `hwip cc` compares `listener.version` against the installed
712
+ version and restarts the daemon for you when the installed one is newer
713
+ (a daemon *newer* than the `hwip cc` you ran is left alone, so several
714
+ installs on one machine don't fight over it):
715
+
716
+ ```
717
+ hwip: listener 1.25.0 is stale — restarting on 1.26.0
718
+ ```
719
+
720
+ If the PID file is missing (a different account started the daemon, or
721
+ it was removed by hand) the singleton guard can't see it. Find the real
722
+ process instead:
723
+
724
+ ```bash
725
+ ps aux | grep '[c]li.js listener'
726
+ ```
727
+
728
+ To run it in the foreground (for development of the SDK itself):
729
+
730
+ ```bash
731
+ hwip listener
732
+ ```
733
+
734
+ You'll get the same logs you'd otherwise tail from `listener.log`.
735
+
736
+ ### Custom tmux sockets
737
+
738
+ The listener auto-discovers the tmux socket — it probes the default
739
+ socket first and stops there when it answers. Otherwise it probes every
740
+ socket file in the per-user `tmux-<uid>/` dirs (`$TMUX_TMPDIR`/`$TMPDIR`,
741
+ macOS `/var/folders/.../T/`, `/tmp/`), which is where `tmux -L <name>`
742
+ puts its socket. Only when none answers does it ask `lsof` which socket
743
+ each running tmux process holds — the slow path, which also covers a
744
+ `-S` path outside those dirs. If your tmux
745
+ uses `tmux -L <name>` or a non-standard `-S <path>`, set the override
746
+ explicitly:
747
+
748
+ ```bash
749
+ export HWIP_TMUX_SOCKET=/path/to/socket
750
+ ```
751
+
752
+ (The wrapper passes the env to the auto-spawned listener, so setting
753
+ it in your shell rc is enough.)
754
+
755
+ ### Wire format
756
+
757
+ The listener only acts on pushes with `type='agent.command'` carrying
758
+ the tmux session name in `agentSessionName` and the message in `body`.
759
+ Other pushes (Stop-hook auto-pushes, `hwip_ask` responses, channel
760
+ broadcasts, plain notes) are ignored. End-to-end:
761
+
762
+ ```
763
+ tmux send-keys -l -t <agentSessionName> "<body>"
764
+ tmux send-keys -t <agentSessionName> Enter
765
+ ```
766
+
767
+ If you need to send one from the command line (debugging, scripting),
768
+ build the structured push directly:
769
+
770
+ ```bash
771
+ curl -X POST "$HWIP_BASE_URL/pushes/send" \
772
+ -H "X-API-Key: $HWIP_API_KEY" \
773
+ -H 'Content-Type: application/json' \
774
+ -d '{
775
+ "type": "agent.command",
776
+ "targetDeviceId": "dev_listener_<sha8(hostname)>",
777
+ "agentSessionName": "hwip-myapp",
778
+ "body": "테스트 통과시키고 PR 올려줘"
779
+ }'
780
+ ```
781
+
782
+ The phone also reads this machine's schedules (see [Scheduled sessions](#scheduled-sessions)) over the ephemeral relay, with two more subtypes:
783
+
784
+ ```
785
+ { subtype: 'agent.schedule.list.request', targetDeviceId, requestId, subscriberPublicKey? }
786
+ { subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
787
+ ```
788
+
789
+ `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
790
+
791
+ ### Your API key and redirects
792
+
793
+ Every request that carries your API key refuses to follow a redirect: a host
794
+ that answers with one (to another host, or from https to http) would otherwise
795
+ be handed the key. The call fails with "answered with a redirect … check the
796
+ base URL", so a base URL that redirects has to be corrected rather than
797
+ followed. The key is also taken out of the socket error line and the CLI's final
798
+ error line (`apiKey=***`), and a base URL with credentials written into it
799
+ (`https://user:pass@host`) is refused without being repeated (below).
800
+
801
+ `~/.hwip` is kept at `0700` and `listener.log` at `0600` (it can quote what the
802
+ listener was handed). `~/.hwip/config.json` says where your key is sent, so it is
803
+ checked when read: a file that another user owns or that anyone can write is
804
+ ignored with a warning (`HWIP_API_KEY` and `--key` still work, and nothing is
805
+ saved over it), and one that your group or others can reach is restricted to
806
+ `0600` and used (a symlink, as dotfiles use, is judged by what it points at). A
807
+ `config.json` that cannot be read as a JSON object (a stray comma, half a file, bytes that are not
808
+ UTF-8) is ignored with a warning too, and `hwip install` and `hwip login` stop before they ask for
809
+ anything and name the file: they would otherwise replace it with the new key alone and drop your
810
+ `baseUrl`, `wsUrl` and your other settings. Fix it, or move it aside, and run the command again. Saving is a
811
+ write-then-rename, so a crash leaves the old file or the new one, and a write by another process in
812
+ between is an error, not a lost update.
813
+
814
+ Run with `sudo` where your home folder is kept (macOS), a first `hwip login` or `hwip install` would make
815
+ `~/.hwip`, `config.json` and the device keypair root-owned, and the next plain `hwip` could neither read the key nor save to
816
+ it. What root creates there now goes to the user who ran `sudo` (`SUDO_UID`/`SUDO_GID`) — only inside
817
+ a home folder that user owns, so a root whose `HOME` is `/root` keeps its own files. The same goes for the state folders and files hwip keeps (hook state, the shell audit log, the turn ring, the trust state, the listener's runtime files). The agent-rules
818
+ manifest is fetched from the same base URL as everything else: with a `config.json` that cannot be
819
+ read, `hwip` does not fetch it from the default stage but keeps the manifest it has.
820
+
821
+ While a `config.json` is ignored (it does not parse, or anyone can write it) the listener keeps
822
+ running when it has a key from `--key` or `HWIP_API_KEY` (and, for a file that does not parse, a base
823
+ URL from `--base-url` or `HWIP_BASE_URL`), but with every on/off option **off**: `"turnHistory"` and
824
+ `"keepAwake"` are opt-outs, and reading an unreadable file as "nothing set" would turn them back on
825
+ and start uploading the conversations you had switched off. The listener log says so, `hwip verify`
826
+ shows a failed row naming the file, and a restart after fixing the file picks the options up again.
827
+ The login service has no key in its environment, so with such a file it cannot start (exit 3, retried
828
+ every 30 s) and the log names the file. A command that needs the API base URL and cannot read the
829
+ file it may be in (`notify`, `ask`, `trust <deviceId>`, `verify --ping`, `listener`) stops and says so
830
+ instead of sending the key to the default API; `--base-url` or `HWIP_BASE_URL` says where. A base URL or socket URL that sends the
831
+ key over plain `http://` or `ws://` to a host that is not on this machine draws a
832
+ warning (`hwip verify` shows it as a warning row). A base URL with a user name or password in it
833
+ (`https://user:password@host`) stops those commands as well: no request can be made to such an address, and the
834
+ message says where it came from (`--base-url`, `HWIP_BASE_URL` or the file) without repeating it. Where a command shows which
835
+ key is in use it prints the prefix only (`ak_…`).
836
+
837
+ The key in the file belongs to the addresses the file says (`baseUrl`, else the default API), so it does not
838
+ follow an address from the environment: with that key, an `HWIP_BASE_URL` on another origin stops the command
839
+ (`install` too, before it checks or saves the key against it), naming both origins and no more. Set `HWIP_API_KEY`
840
+ or pass `--key` as well (a key given with the address goes with it), pass `--base-url` (your word on that
841
+ command), or unset the variable; the same origin is always fine. The deprecated `HWIP_WS_URL` is held to the
842
+ same rule: with the file's key it is used only for the default socket's origin, otherwise it is skipped with a line
843
+ saying so — put `wsUrl` in the file to name another socket.
844
+
845
+ ### Defense
846
+
847
+ The listener is a remote-code-execution surface by design (it types
848
+ into a shell-adjacent pane). The defense is layered:
849
+
850
+ 1. **Pane guard** — before injecting, the listener checks the pinned
851
+ pane's `#{pane_current_command}`. If the pane is at an interactive shell
852
+ (`bash`/`zsh`/`fish`/`sh`/`dash`/`ksh`/`tcsh`/`csh`/`pwsh`), the inject
853
+ is refused. CC/Codex/Gemini exited ≠ phone gets free shell access. The
854
+ one exception is a `hwip sh` session: its `@hwip_shell` session option
855
+ names the session itself. Only a process on this machine can set a tmux
856
+ option, and a value inherited from `set -g` names no session. The marker
857
+ says you opened that shell; it is not a boundary — see what it does not
858
+ stop, below.
859
+ 2. **Addressed shells only** — a marked shell takes a REST
860
+ `agent.command` only when its `targetDeviceId` is this machine. An
861
+ agent session still takes an unaddressed one, which `push.new` fans out
862
+ to every listener of the account; for a shell, two PCs with `hwip sh`
863
+ open in the same project would both run it. Stream input already
864
+ arrives on one listener's own subscription.
865
+ 3. **Nothing a shell would misread** — into a marked shell the listener
866
+ refuses attachments (their paths would arrive as lines, each a command),
867
+ control bytes such as ESC (which can leave a bracketed paste), and a
868
+ newline in an insert (a shell without bracketed paste, such as macOS
869
+ `/bin/bash` 3.2, would run it). A multi-line body is allowed: each line
870
+ is a command you meant.
871
+ 4. **Audit** — every input admitted into a shell session is one line in
872
+ `$XDG_STATE_HOME/hwip/shell-audit.log` (default
873
+ `~/.local/state/hwip/shell-audit.log`, mode 0600; past 1 MiB it moves to
874
+ `shell-audit.log.1`, replacing the one before): time, session, pane,
875
+ kind, foreground program, and the text only when the shell itself was in
876
+ front. Input to anything else — a `sudo` or `ssh` password prompt — is
877
+ recorded as `[N chars → "sudo"]`. `listener.log`, which is not private,
878
+ shows only the length. The foreground is judged when the input lands, so
879
+ a secret read by the shell itself (`read -s`) is logged in full, and a
880
+ command typed inside `sudo -s` or `ssh` is logged by length only.
881
+ 5. **Literal injection** — text is delivered as a paste of data, not typed
882
+ key names; tmux escape sequences inside a message can't drive other tmux
883
+ commands.
884
+ 6. **Session-name allowlist** — only `[A-Za-z0-9._-]+` is accepted as
885
+ a session target, so shell metacharacters never reach the tmux argv.
886
+ Agents skip a shell: `hwip agent send` (and `hwip send`, its older name) and `hwip_agent_send` (from
887
+ `@hwip/mcp-server` 3.3.0) drop `agentKind: "shell"` sessions from
888
+ their candidates. The check runs on the sending machine only, so an
889
+ older `hwip` on any machine of your account can still type into your
890
+ shell — update every machine that runs agents before you open one.
891
+ 7. **Per-session rate limit** — a token bucket caps a runaway or
892
+ compromised sender, shell sessions included.
893
+ 8. **Agent permission gate — with one hole.** Your CC/Codex/Gemini
894
+ permission prompt still stands in front of every tool call the agent
895
+ makes. But Claude Code's `!` prefix runs a shell command directly, as
896
+ typed input, with no permission prompt, and text the phone injects is
897
+ typed input: `!<command>` sent to a Claude Code session runs on this
898
+ machine. This is accepted, and it is how `!hwip sh --detach` opens a
899
+ shell from the phone.
900
+
901
+ What the marker does not stop: anything running as your user on this
902
+ machine can set `@hwip_shell` on a session, or run `hwip sh` itself — and
903
+ so can the phone, while a Claude Code session (`!tmux set-option …`) or a
904
+ marked shell is open to it. It keeps the phone from choosing panes only
905
+ when neither is.
906
+ And no layer authenticates the sender: whoever holds your API key, or
907
+ controls the backend, can type into every marked shell and, through `!`,
908
+ every Claude Code session.
909
+
910
+ The transport (WS) is authenticated by API key + `push:read` scope.
911
+ Whether your backend also reads what crosses it depends on the phone
912
+ handing the listener a key:
913
+
914
+ - **No key — an older app.** It has no device keypair to hand the
915
+ listener, so pane frames and the messages you type both cross the relay
916
+ in plaintext. This listener answers that only until it has pinned a
917
+ device (below); after that it refuses a request that asks for plaintext.
918
+ - **A key — the app.** The phone sends its device public key and a
919
+ fresh nonce when it subscribes, and every pane frame comes back
920
+ inside envelope v2: ECDH P-256 between the two device keys, HKDF-SHA256
921
+ with a fresh salt and the message's purpose (`stream.frame`,
922
+ `stream.input`, `stream.alive`, `stream.started`, `screen.snapshot`,
923
+ `screen.history`, `session.diff`, `schedule.list`, `turn.batch`) as the key's info, and
924
+ AES-256-GCM over data that names the purpose, both keys in send →
925
+ receive order and the session. The sealed frame also holds the
926
+ session name, its `seq`/`epoch` and that nonce, so a relay that cannot
927
+ swap in a key of its own (below) can't move one to another stream,
928
+ replay one from an earlier subscription, or hand one back as input —
929
+ or as anything else this listener sealed for the phone. Your
930
+ keystrokes are sealed for this listener as `stream.input` with their
931
+ `seq`/`epoch` stamps, the stream's start epoch and the phone's device
932
+ id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
933
+ says the listener seals v2 (`sealVersion: 2`), sealed for the phone on
934
+ a sealed stream; a phone that has seen it never takes the older
935
+ envelope from this machine again. A frame that fails to encrypt is
936
+ dropped, never downgraded.
937
+
938
+ Two gaps. A message sent while no live stream is open falls
939
+ back to REST, which is plaintext to the server unless the phone has
940
+ sealed with this computer before (then it is sealed, below) — that
941
+ includes every `@<session>` command from the agent chat, and every
942
+ command typed into a `hwip sh` shell. Until this listener has pinned a
943
+ device, a shell's screen crosses the relay in plaintext too, so keep
944
+ secrets off both.
945
+
946
+ **Whose key it seals for.** A viewer asks for sealed content by sending
947
+ its public key, and the relay could put its own there. So the listener
948
+ takes a key — on a stream subscription, a chat timeline watch, a diff or
949
+ a screen snapshot request — only when it is the key the account's device
950
+ list holds for the device the relay says is asking. Any other key gets
951
+ an error (`subscriber_unverified`; `subscriber_key_in_use` for another
952
+ id asking with a key pinned for a different device) and no pane: nothing is read off
953
+ the screen until the key has checked out. The device is pinned in
954
+ `~/.hwip/trusted-devices.json` the first time it is seen — by the listener
955
+ or by a send of this computer, whichever comes first — and a key that
956
+ changed (the app was reinstalled) replaces the pin with one log line,
957
+ `trust: <deviceId> changed its key — now pinned to the new one`, and is
958
+ not refused. The change is also recorded for a person (see Encryption →
959
+ key changes), since nobody reads the daemon log. The app pins the listener's key the same way and stops when
960
+ the device list shows another one.
961
+
962
+ Pins are a record, not a gate: the account's device list decides which
963
+ key to believe, and the pins tell this listener when plaintext is over.
964
+ Nothing needs `hwip trust`; it is there to look at the record:
965
+
966
+ ```bash
967
+ hwip trust # this computer's fingerprint, and the pinned devices
968
+ hwip trust <deviceId> # pin the key the account now lists for that device
969
+ hwip trust --reset # forget every pin; each comes back as it is seen again
970
+ ```
971
+
972
+ A fingerprint is the first 16 bytes of SHA-256 over the key, as eight
973
+ groups of four hex digits. The app shows the same string for the same
974
+ key on the device's page, and the listener prints its own at startup:
975
+ comparing the two by eye is the check that no key was swapped in
976
+ between. It is the only check for what pinning no longer catches — a
977
+ backend that registers a device of its own, or lists another key for a
978
+ device you know, is followed like a new phone or a reinstalled one.
979
+
980
+ A screen snapshot — the answer to `agent.screen.request` — is sealed
981
+ for the asking device under the same rule.
982
+
983
+ The schedule list — the answer to `agent.schedule.list.request` — follows
984
+ the same rule: sealed for a key that checked out, in the clear only for a
985
+ request without a key while this listener pins no device, and otherwise an
986
+ error code (`e2ee_required`, `subscriber_unverified`, …) and no schedule. Prompts are in
987
+ it, so a request that asked for a seal is never answered in the clear.
988
+
989
+ **Reading a session's changes** (`agent.diff.files.request` and
990
+ `agent.diff.file.request`) is sealed both ways and never answered in the
991
+ clear: a request without a key gets `e2ee_required` whether or not this
992
+ listener pins a device. What
993
+ the request asks — which comparison (the uncommitted changes, or the point
994
+ this branch left the default branch), which file, which page — is inside
995
+ a sealed question (`session.diff`), so the relay does not see the
996
+ comparison, the path, the page or any content. A request that fails is
997
+ answered with an error code in the clear (`bad_path`, `no_default_branch`
998
+ and so on), which the relay can read and nobody has vouched for. The answer
999
+ is sealed back for the same key and carries the request id, the comparison, the path and the offset
1000
+ inside the seal, which the app checks against what it asked. The file
1001
+ list also carries, inside the seal, the branch the session's repository is on, the name of the ref it
1002
+ follows (`origin/main`, not the remote's URL) and how many commits it is ahead of and behind that ref;
1003
+ a repository with no commit yet leaves that field out, and a `rev-list` that outlasts the request's
1004
+ time budget leaves out the counts; the list is unchanged either way. The
1005
+ listener runs `git` only after the key has checked out and the question
1006
+ opened; the session name picks a directory, the comparison is one of two
1007
+ fixed choices, and a path is resolved inside the repository (an untracked
1008
+ file that `.gitignore` covers is neither listed nor shown; a file git tracks
1009
+ is shown whatever `.gitignore` says). The repository's own configuration is
1010
+ not trusted, and git can run commands it names. Switched off: `core.fsmonitor`,
1011
+ external diff drivers and textconv filters, a repository nested as a submodule
1012
+ (its content is not shown, its config does not run, and a changed submodule
1013
+ pointer is missing from the list), the lazy fetch of a partial clone
1014
+ (`remote.<name>.uploadpack`), and the listener's own `GIT_DIR`, `GIT_WORK_TREE`,
1015
+ `GIT_EXTERNAL_DIFF`, `GIT_ATTR_SOURCE` and `GIT_CONFIG_*`, which are not passed
1016
+ to git. `GIT_NO_LAZY_FETCH` is heard only by a git that knows it (the release
1017
+ notes of git 2.45 introduce `--no-lazy-fetch`; the variable's own first release
1018
+ is unchecked). The `clean` and `process` commands of the filters the config
1019
+ defines are blanked as git is asked to compare the working tree; a driver whose
1020
+ name cannot be blanked, or is named `set`, `unset` or `unspecified`, fails the
1021
+ request. Not closed: the filters are listed by one git run and used by the next,
1022
+ so a config changed in between can still run a filter once.
1023
+
1024
+ A file that a `filter=` attribute names (git-crypt, transcrypt, LFS, global
1025
+ git-lfs included) is listed without a patch, marked like a binary file with no
1026
+ counts: without its filter the working-tree file would read as all added lines
1027
+ in plaintext. The attributes are read from the working tree and from the base
1028
+ revision, and a file counts as filtered if either names a filter; a git without
1029
+ `check-attr --source` (before 2.40) fails the request, and so does an
1030
+ `info/attributes` that unsets `filter` (it outranks both). A patch is asked for
1031
+ one file at a time: a path that is a directory, or that git would diff as more
1032
+ than that one file, is `bad_path`. What stays: a filtered file renamed or copied
1033
+ to a name no filter covers shows that name's plaintext (git does the same); a
1034
+ filtered file that was only touched may be listed as changed, without a patch.
1035
+ Other config that names commands is not reviewed one by one — the listener reads
1036
+ repositories a session already works in. Strings that look like
1037
+ secrets (well-known token formats, `password = …`-style assignments,
1038
+ private key blocks) are masked before sealing, and the answer says how
1039
+ many places were; that is best effort, not a promise.
1040
+
1041
+ **Reading a session's files** (`agent.diff.tree.request` and
1042
+ `agent.diff.blob.request`) rides the same channel and the same seal as the
1043
+ changes above (`session.diff`): sealed both ways, never answered in the clear,
1044
+ the relay sees neither the path nor the listing nor any content, and the
1045
+ answer carries the request id and path (a file page also its offset) inside
1046
+ the seal. The two kinds share a label, so what tells a file answer from a
1047
+ diff answer is the `kind` inside the seal, which both ends check; a
1048
+ `tree` or `blob` request that carries a comparison is refused as
1049
+ `bad_request`. What the phone may see is what git lists: the tracked files and
1050
+ the untracked ones `.gitignore` does not cover. An ignored file, a path
1051
+ that is not there and one outside the repository are all answered `absent`
1052
+ inside the seal, so asking for a name does not tell whether it exists. The
1053
+ content is the working tree, read by the listener itself, one file at a time:
1054
+ a symlink is shown as the text it points to and never followed (a symlink
1055
+ among the directories on the way makes the path `absent`); a submodule or a
1056
+ nested repository is shown as one and never entered; a file that is not a
1057
+ regular file is `absent`; a file with a hard link, one named like a secret
1058
+ (`.env` and `.env.*` except `.env.example` and `.env.sample`, `*.pem`, `*.key`,
1059
+ `*.p12`, `*.pfx`, `*.keystore`, `id_rsa*`, `id_ed25519*`, `credentials*` —
1060
+ fixed in the code, not a setting), and one a `filter=` attribute names are listed but
1061
+ not shown. A file over 1 MiB is `too_large`; one with a NUL in its first 8,000
1062
+ bytes is shown as binary and its size. Strings that look like secrets are
1063
+ masked in the whole file before it is cut into 16 KiB pages, and each page says
1064
+ how many places were masked in the file; that is best effort, not a promise. The
1065
+ only git commands this adds are `ls-files` (twice, for the visible set) and the
1066
+ `check-attr` that finds `filter=`; `git status` is not run (the app lays the
1067
+ changed files of the changes view over the listing). A request that fails is
1068
+ answered with an error code in the clear (`bad_path`, `file_view_off` and so on),
1069
+ which the relay can read and nobody has vouched for.
1070
+
1071
+ A session may read 32 MiB of file text in 10 minutes; past that, file page requests
1072
+ are `rate_limited` until the window ends (listings keep working). Every file view request that opened
1073
+ under a key the listener admitted adds one line to its log (`~/.hwip/listener.log` for a listener started by `hwip cc` or as a service; one run in the foreground prints to its own terminal, and `hwip file-view log` then has nothing to show): when, the device,
1074
+ the session, listing or file, the path, the bytes read, the places masked and
1075
+ how it ended, never any content (names are written so a control character in
1076
+ one cannot start or redraw a line). It stays on this computer, nothing is
1077
+ pushed anywhere, and `hwip file-view log` prints the last 50 (the log rotates at 5 MB and keeps one
1078
+ older copy, which `log` reads when the current one has fewer).
1079
+ `hwip file-view off` stops it for the project (the repository root) and
1080
+ `hwip file-view off --global` for every project; the listener answers
1081
+ `file_view_off`. A switch file that cannot be read counts as off, and
1082
+ `HWIP_FILE_VIEW=0` in the listener's environment switches it off for that run. It
1083
+ is on until turned off. Not closed: between the checks and the open a directory
1084
+ on the path could be swapped for a symlink, which the listener looks for again
1085
+ after the open but cannot make atomic on macOS; and two paths that reach the
1086
+ same file without a hard link (a bind mount) are not told apart.
1087
+
1088
+ Once the listener has pinned
1089
+ a device, it stops answering
1090
+ in the clear until the pins are forgotten: a stream start, snapshot request or chat
1091
+ timeline watch without a key gets `e2ee_required` (a keyless stream start
1092
+ is refused before it would restart the sealed stream of that session; a
1093
+ stop is not checked and still ends it), a plain stream already running
1094
+ ends at its next capture with the same error, and the unsolicited
1095
+ snapshots after a REST key press are no longer sent, since nobody asked
1096
+ and there is no key to seal them for. The app, for its part, never subscribes in the clear to a computer
1097
+ it has sealed with.
1098
+
1099
+ What you type in the app's conversation for such a computer is sealed
1100
+ too: the stored command's body is sealed for this computer and the
1101
+ phone, and inside it a label names the session, this machine and a
1102
+ number that only grows per phone. The listener types it only when the
1103
+ phone's key checks out like a stream subscriber's, the label is for this
1104
+ session on this machine, and the number is above the last one it took
1105
+ from that phone — kept in `~/.hwip/trusted-devices.json`, so a command
1106
+ sent again is refused after a restart too, and `hwip trust --reset`
1107
+ keeps the numbers. Beside the per-id numbers the file holds, per sealing
1108
+ key, the largest number taken with it (`commandKeys`, optional; an older
1109
+ hwip that saves the file drops it): a number must be above both, so a
1110
+ command already taken is refused under a new id, after `--reset`, and
1111
+ after the phone changed its key. The key column covers the numbers this
1112
+ version took, plus the id numbers of the devices pinned when `--reset` or
1113
+ `hwip trust <deviceId>` drops or replaces a pin (those are written beside
1114
+ the old key first); a number of an id that was not pinned at that point has
1115
+ no key to go with. A key is told by the x coordinate of its
1116
+ point alone, because a sealed push opens from that coordinate and a relay
1117
+ can make the mirrored key (x, p − y) without any secret. A key already
1118
+ pinned for one device is not taken under a second id
1119
+ (`subscriber_key_in_use`): the id is stamped by the relay, so it could
1120
+ otherwise hand an old command a number of its own. Nothing is forgotten to
1121
+ make room: once 64 senders or 64 keys have numbers, a command from a new
1122
+ one is dropped (the log names the field in `trusted-devices.json`), because
1123
+ a number forgotten is a command that can be typed again. `--reset` does not
1124
+ free those slots, and a device that changes its key under one id fills the
1125
+ key column too (64 changes). The only way out is editing
1126
+ `trusted-devices.json` by hand, and a number deleted there can be typed
1127
+ again. If the
1128
+ same phone really came back under a new id, `hwip trust <deviceId>`
1129
+ pins it after you compare fingerprints. A file sent with it is sealed for this computer and
1130
+ the phone too, and the sealed body names each attachment (by its IV, in
1131
+ order), so the relay can neither drop one nor swap in another. The
1132
+ command is typed only once every named attachment has come down and
1133
+ opened with this machine's key — never half of it. Keys or an insert
1134
+ beside a sealed body, or attachments other than the named ones, refuse it.
1135
+
1136
+ A listener that only seals (it pins a device: see above) does not type a plaintext `agent.command` either: a plain
1137
+ `body`, `insert`, attachment or `keys` is refused whole, and the phone is told
1138
+ `e2ee_required` (a code, never the text). The approve button on the phone
1139
+ seals its keys instead, so it keeps working. A listener with no device list to check against, and one that pins no
1140
+ device, takes plain commands as before; an unreadable
1141
+ `~/.hwip/trusted-devices.json` counts as sealed-only. `hwip trust --reset`
1142
+ forgets the pins, and with them goes back to taking plain commands until a
1143
+ device is pinned again.
1144
+
1145
+ **A schedule request** — the phone making or removing a scheduled session — rides
1146
+ the same `agent.command` push, with a `schedule` field and no `body`. It carries
1147
+ a time, a prompt and, if the phone says so, an agent and a run time — and nothing
1148
+ else. The directory and the agent come from what this machine recorded for the
1149
+ session the push names (the way a resume does), so the phone cannot point a
1150
+ schedule at another directory; it cannot send agent arguments, a worktree or a
1151
+ label either, and a request that carries one is refused rather than ignored
1152
+ (`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
1153
+ session this machine never recorded; `schedule_failed` when this machine's own
1154
+ files would not read or write). A remove names the schedule's own
1155
+ session, so it cannot reach another. Applying a request never types into a
1156
+ session. The request is sealed like any
1157
+ command — the whole of it inside the body, only `op` beside it, and the sender,
1158
+ label and counter checks above apply, so a replay or a swapped `op` changes
1159
+ nothing — and a listener that only seals refuses a plain one (`e2ee_required`).
1160
+ A sealed request refused for `schedule_invalid` or `schedule_limit` is told to
1161
+ the phone as `sealed_rejected`, because those were read from text the relay
1162
+ cannot see; `unknown_session`, `rate_limited` and `schedule_failed` go out as
1163
+ themselves.
1164
+
1165
+ ## CLI Usage
1166
+
1167
+ ```bash
1168
+ # Send a notification
1169
+ hwip notify --title "Deploy done" --body "v2.1.0 shipped"
1170
+
1171
+ # Send with priority
1172
+ hwip notify --title "Build failed" --priority high --url https://ci.example.com/123
1173
+
1174
+ # List recent pushes
1175
+ hwip list
1176
+ hwip list --limit 10 --type note
1177
+
1178
+ # Dismiss a push
1179
+ hwip dismiss push_01JXY...
1180
+ hwip dismiss --all
1181
+
1182
+ # Rename the current agent session (shows in the app's Agents list)
1183
+ hwip rename "Prod deploy"
1184
+ hwip rename --clear # reset to the default name
1185
+
1186
+ # Type a message into another agent session, here or on another PC
1187
+ hwip agent send dev_listener_ab12cd34:hwip-api "Build is green — rerun the e2e suite"
1188
+ hwip agent send hwip-api "…" # a name/alias works when it names one session
1189
+ hwip agent send hwip-api - <<'EOF' # a long message on stdin; the quoted heredoc expands nothing
1190
+ Build is green. `npm test` passed.
1191
+ EOF
1192
+
1193
+ # See which agent sessions run on THIS PC, and what one did last (this machine only)
1194
+ hwip agent list # KEY AGENT STATE NAME; --json for scripts
1195
+ hwip agent read hwip-api # its last 3 turns: prompt, the agent's text, tool names
1196
+ hwip agent read dev_listener_ab12cd34:hwip-api --turns 5 --json
1197
+
1198
+ # Drop an ended session from this machine's record (it leaves the app's past list)
1199
+ hwip forget hwip-myapp-20260914-PLAN-11-32-44-pi
1200
+ hwip forget --ended # every session tmux no longer has
1201
+
1202
+ # Test connection
1203
+ hwip test
1204
+
1205
+ # Run an agent in a named tmux session (so the listener can reach it)
1206
+ hwip cc # claude
1207
+ hwip codex # codex
1208
+ hwip cursor # cursor-agent (Cursor CLI, not the IDE)
1209
+ hwip gemini # gemini
1210
+ hwip hermes # hermes
1211
+ hwip kimi # kimi (Kimi Code)
1212
+ hwip grok # grok (Grok Build)
1213
+ hwip agy # agy (Antigravity; also `hwip antigravity`)
1214
+ hwip qwen # qwen (Qwen Code)
1215
+ hwip auggie # auggie
1216
+ hwip kilo # kilo
1217
+ hwip copilot # copilot (Copilot CLI)
1218
+ hwip kiro-cli # kiro-cli (Kiro; also `hwip kiro`)
1219
+ hwip droid # droid (Factory Droid)
1220
+ hwip cc --wt feat/x # claude in a new git worktree + branch (../<repo>-wt-feat-x)
1221
+ hwip worktree # the worktrees --wt made, and what keeps each
1222
+ hwip worktree clean --dry-run # what clean would remove (no --dry-run: removes it)
1223
+
1224
+ # Start an agent session on a timer (the listener starts it, so it has to be running)
1225
+ hwip schedule add cc --at 09:00 --days weekdays --prompt "Triage the open issues"
1226
+ hwip schedule add codex --every 2h --max-runtime 30m
1227
+ hwip schedule # the schedules, with the next run and the last result
1228
+ hwip schedule rm k3x9aq # remove one; a session it already started keeps running
1229
+
1230
+ # Run the resident listener (foreground; background it as you like)
1231
+ hwip listener
1232
+ hwip listener --ws-url wss://... # override config
1233
+
1234
+ # JSON output
1235
+ hwip notify --title "Hello" --json
1236
+ ```
1237
+
1238
+ ### Commands
1239
+
1240
+ | Command | Description |
1241
+ |---------|-------------|
1242
+ | `login` | Browser sign-in: auto-fetch API key + hook into `~/.hwip/config.json` over a localhost loopback (`--web-url`, `--timeout`, `--allow-host`); the key comes back in a POST body, not in a URL. No copy-paste. Over SSH it opens no browser here: it prints the URL and the `ssh -L` line that forwards its callback port, so a browser on your own machine can finish the login, and still waits for it. The callback's `baseUrl` / `wsUrl` must be `https` / `wss` on `hwip.app`, the `--web-url` host or a host already in `config.json`; `--allow-host a.example,b.example` adds hosts you name (and their subdomains) for a self-hosted API on an unrelated domain — bare host names only. `install` takes the same flag for its browser sign-in. On a Linux box with no display, or when the opener fails, it says why and points at `install --key` instead of waiting |
1243
+ | `install` (alias: `setup`) | One-command setup: detect agents, save config, install rules + hooks + MCP. No saved config → opens browser login automatically (inside it, Enter skips to pasting the key). `--only claude,cursor,…` skips the picker (`cc` = `claude`; an unknown id is a usage error, exit 2). A key the API rejects is not saved (exit 3); a step marked `-`, the test push included, ends it with exit 1 (with `--key`, a missing tmux is a note, not a failed step); with no terminal for the agent picker it is a usage error (exit 2) — pass `--key` for a scripted install |
1244
+ | `uninstall` | Remove Hwip from all detected agents and stop the listener (`--dry-run`, `--purge`). Asks first when stdin is a terminal — answering no exits 1; `--yes` skips the question |
1245
+ | `verify` | Check installation health across detected agents (`--ping` for a live API call, with the key and API base every command resolves: `--key`, `--base-url`, then env, then config; `--json` prints the checks as one JSON object). Reports whether tmux is on PATH and whether the listener is running — both warnings, since notifications alone need neither. A settings file it cannot parse is a `!` line, not "not registered". Shows the last failed push, if any (see *Last push failure*). Also reports the resolved WebSocket URL **and where it came from** — `--ws-url`, config, the deprecated `HWIP_WS_URL`, or the built-in default — warns when the socket and the API base are on different stages, and warns when the running listener is on a different URL than the one that resolves now (it reads the config once, at startup) |
1246
+ | `check-update` | Check whether a newer Hwip version is on npm |
1247
+ | `notify` | Send a push notification |
1248
+ | `ask` | Ask the phone a question and wait for the answer — for hooks, which cannot call the MCP `hwip_ask` tool. `--title` (required), `--body`, `--actions id:Label,…`, `--timeout <s>` (default 60), `--accepts-exit` for an agent's own ask (the phone may "send and exit", and the answer settles REMOTE like `hwip_ask`). Key and API base resolve like every command. Prints one JSON line — `{"answered":true,"actionId":…}` or `{"value":…}`, else `{"answered":false,"reason":"cancelled"\|"timeout"\|"error"}`; `"resumed":true` is added when the outcome is of a question an earlier run of the same command put up (see "A question cut short"). Exit 0 answered, 1 not. The question and the answer travel sealed, always (see Encryption); an answer that cannot be verified is an error, never an approval. With no app signed in to the account it prints `no app is signed in to this account` and exits 1 |
1249
+ | `ask-context [status\|on\|off\|show] [--global]` | The switch for the context a sealed question carries (a git diff against HEAD and the terminal's last lines, masked, sealed on every send — see Encryption). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. It is a state file under `$XDG_STATE_HOME/hwip` (`ask-context-off-<project hash>`, `ask-context-off-default`) and never leaves this machine; `HWIP_ASK_CONTEXT=0` switches it off for one run. A switch file that cannot be read counts as off. `show` prints one JSON line, `{"context": …}` — the summary and the masked original a question asked from the current directory would carry, `null` when there is nothing, with `"off"` naming why when the switch is off — and sends nothing (the MCP `hwip_ask` tool reads it) |
1250
+ | `file-view [status\|on\|off\|log] [--global]` | The switch for the phone browsing this computer's files (a session's repository — see Defense). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. A state file under `$XDG_STATE_HOME/hwip` (`file-view-off-<project hash>`, `file-view-off-default`) that never leaves this machine; a switch file that cannot be read counts as off. `log` prints the last 50 reads the phone made, from `~/.hwip/listener.log` (time, device, session, listing or file, path, bytes, secrets masked, result) |
1251
+ | `list` | List recent push notifications. An end-to-end encrypted push reads `[encrypted]` — this host cannot open it |
1252
+ | `dismiss <id>` | Dismiss a push (or `--all`) |
1253
+ | `rename <name>` | Set the current agent session's display name in the app — run inside a `hwip cc` session, or name one with `--session <name>` (`--clear` resets). Auto-detects the tmux session + this machine's listener device id (`--device <id>` picks another machine), so the alias lands on the right device |
1254
+ | `agent send <target> <message…>` | Type a message into another agent session — on this machine or another — as if the user sent it from the phone; it shows in that session's chat and puts it in remote mode. `target` is the `<deviceId>:<session>` key or a name/alias that names exactly one session (a name two machines share is refused, with both keys listed, and so is a session on a machine that is offline — its listener would never type it). The receiver reads `[from <your alias or tmux name>@<machine> · reply: <your key>]` first; outside tmux it is `[from cli@<host>]`, and from a tmux session the listener does not report, or from a subagent, it is `[from <session name>@<host>]` — neither carries a reply key. The CLI twin of the MCP `hwip_agent_send`, for agents without MCP (pi). Global flags (`--key`, `--base-url`) go before `agent` (or between `agent` and `send`); everything after the target is the message, verbatim, and a `--key` or `--base-url` placed there is refused (exit 2) rather than typed into the other session. A `-` as the message reads it from stdin: write it on a quoted heredoc (`<<'EOF'`), since inside double quotes the shell would run backticks or `$(…)` in it. The message is always sealed, for the target computer and the devices the account lists with a key (see [Sealed `hwip agent send`](#sealed-hwip-agent-send)); the target is pinned on the way, no pairing step. A target the account does not list with a key, or an unreadable command counter, is refused, never sent in the clear; an unreadable trust file only leaves the pins unchecked, and the send goes on. Exit 0 means the server stored the message, and the line it prints says so (`sent → <key> (stored; not confirmed typed)`): the target's listener can still refuse it (rate limit, a shell pane, a session gone since) and this command does not wait to hear of it. Exit 2 usage, 1 failed or refused, 3 no API key |
1255
+ | `agent list [--json]` | List the agent sessions running on THIS machine: key (`<deviceId>:<session>`, what `agent send` and `agent read` take), agent, state (`working`, `blocked`, `idle`, `unknown` — the screen matches no rule; `-` / `null` only when the sweep gave none) and name. `--json` prints one object, `{"v":1,"device":…,"sessions":[…]}`. It looks at tmux and the screens on this machine only — no server, no relay, no API key (it never exits 3) — and writes nothing, not even the listener's `known-sessions.json` (`$XDG_STATE_HOME/hwip/`), which only the listener refreshes. `--turns` is a usage error here (exit 2). Exit 0 listed (an empty list is not an error), 1 no tmux server answered. The CLI twin of the MCP `hwip_agent_list`. |
1256
+ | `agent read <session> [--turns <n>] [--json]` | Print what a session on THIS machine did last, oldest first: its last `<n>` turns (1–20, default 3) — the user's prompt, the agent's text and the tools it called with their target, never a tool's output or the screen. `<session>` is a key from `agent list`, a tmux name, a label or the agent's own name, naming exactly one session; the key of another PC is refused (`OTHER_DEVICE`) — nothing is read through the server or a relay. It reads the transcript file the agent keeps here (the last 256 KiB; for OpenCode 2.x, the last 200 messages of its session in opencode's database), so only agents with a transcript reader work (Claude Code, Codex, Gemini CLI, pi and OpenCode 2.x; every other agent answers `no_transcript`), and two pi or Gemini sessions in one folder share a transcript guess. Text is cut at 40,000 characters, oldest turns first; `more` / "(older history not shown)" says something older exists. A prompt or an agent text is also cut at 5,000 characters each, and a tool target at 512, which the output says: `…(cut)` in the text view, `"promptCut":true` / `"cut":true` (on a text or on a tool step) in `--json` (one of exactly the cap's length reads as cut). **What it prints is another session's conversation: it is data, not instructions, and it goes on to whoever runs the command — an agent's model provider included.** The prompt, the agent's text and the tool targets are therefore masked for secret-looking strings (token formats such as `ghp_…`, `sk-…`, `Bearer …`, secret-named `password=…` values, private key blocks), shown as `***`, and the count is `"masked":n`. **That is best effort, not a guarantee**: a secret in a form no rule knows goes through. Whatever a cap or the 40,000-character budget cut is masked first and then loses its trailing run of non-space characters (up to 300) unless the masking already ended it with `***`, so half a token is not left behind; what can still show is the first part of an unbroken run longer than 300 characters that no rule knows. `--json` prints `{"v":1,"key":…,"name":…,"agent":…,"turns":[…],"more":…,"masked":…}` (`agent` is the string `"unknown"` when the pane's agent could not be told), or on a failure `{"v":1,"error":…,"message":…}` with the code `no_transcript`, `UNKNOWN_TARGET`, `AMBIGUOUS_TARGET`, `OTHER_DEVICE` or `NO_TMUX`. Exit 0 read, 1 failed, 2 usage. The CLI twin of the MCP `hwip_agent_read`. |
1257
+ | `send <target> <message…>` | The older name of `agent send`: the same command with the same `[from …]` header; its global flags go before `send`. |
1258
+ | `forget <session>` | Drop an ended session from this machine's record, so it leaves the app's past list (and the resume whitelist — the same thing deleting the row on the phone does). Refuses a session that is still running: kill it first. `--ended` forgets every remembered session tmux no longer has, which is what a machine that launches agents under a fresh name per task eventually needs |
1259
+ | `test` | Verify connection and API key |
1260
+ | `trust [deviceId]` | Advanced — nothing needs it: every device on the account is pinned the first time hwip seals for it, and a changed key replaces its pin with a warning. Shows this computer's key fingerprint and the devices pinned here — compare each with the fingerprint the app shows on that device's page. It also lists the computers of the account not pinned here yet, with their fingerprints; the first send seals for each and pins it. With a `deviceId`, pin the key the account now lists for that device: prints the pinned and the listed fingerprint and asks first, `--yes` skips the question. `--reset` forgets every pin (each is pinned again as hwip sees it), and until then the listener answers a request for a screen in plaintext. Exit 0 done, 1 refused or declined, 2 not a device id |
1261
+ | `cc` · `codex` · `gemini` · `cursor` · `hermes` · `pi` · `opencode` · `kimi` · `grok` · `agy` · `qwen` · `auggie` · `kilo` · `copilot` · `kiro-cli` · `droid` | Run the agent in a `hwip-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works; when that daemon stops within a second and a half (no key, no tmux, a rejected key) it prints the daemon's last log line instead of "autostarted". Once a day it asks npm whether a newer hwip is out and prints the update line (cached in `~/.hwip/update-check.json`; skipped under `CI` or `HWIP_NO_UPDATE_CHECK`). Trailing args pass through to the agent (`hwip cc --resume "..."`). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no `hwip cc` of its own in `ps` — older runtimes and Windows keep the waiting wrapper `--detach` / `--label <x>` (front of the args) create without attaching / pin the name; `--wt <name>` runs it in a git worktree made for the session — see "Run agents through the wrapper" above. |
1262
+ | `worktree [clean]` | List the git worktrees `--wt` made (path, branch, and `in use` / `clean` / `never used` / what keeps it: uncommitted, untracked or ignored files, commits not pushed). `clean` removes the ones nothing keeps and prints why the others stay; `--dry-run` removes nothing and records nothing. A worktree you made by hand is never listed or removed. Exit 0, also when some stay; `clean` exits 1 when git refused a removal it had cleared or tmux could not be asked (the list still prints, without `in use`); 2 for an unknown subcommand. See "Run agents through the wrapper" for when the listener does the same on its own |
1263
+ | `schedule` | Start an agent session on a timer, in the background. The listener starts it, so it has to be running (installing the listener as a login service keeps it up). A run missed while the computer slept or the listener was down is skipped, never started late, and the phone is told. At most 20 schedules and 3 scheduled sessions at once; a session is asked to end after `--max-runtime` (never killed). Schedules live in `~/.local/state/hwip/schedules`. `schedule add <agent> (--at HH:MM \| --every <N>m\|<N>h) [-- <agent args…>]` adds one: `<agent>` is an agent command above (`cc`, `codex`, …) and the project is this one. `schedule [list]` shows them, with the next run and the last result (`--json` for scripts). `schedule rm <id>` removes one; a session it already started keeps running. Options: `--days <days>` (with `--at`: `daily`, `weekdays`, or day names like `mon,wed,fri`), `--prompt <text>` (typed into the session once the agent's status rules say it is idle: Claude Code, Codex, Gemini CLI and pi only), `--label <x>`, `--wt <branch>` (a git worktree for the branch, not with `--label`), `--max-runtime <N>m\|<N>h` (default 2h, at most the `--every` period; 5m to 24h). See *Scheduled sessions* |
1264
+ | `sh` | Open (or reattach) `hwip-<project>-sh`, a login shell the phone may type into — the one shell pane the listener does not refuse. `--label <x>` / `--detach` as for the agents; inside tmux it never attaches and prints a `switch-client` hint. Refuses a session of that name it did not open. Input must be addressed to this machine, is audited, and agents (`agent send`, `hwip_agent_send`) skip it — see [Defense](#defense) |
1265
+ | `mcp` | Run the MCP server on stdio, in this process. What agent MCP configs launch — `hwip install` registers it and you never type it. Replaces the old `npx -y @hwip/mcp-server` registration, which left an `npm exec` launcher resident alongside the server for the life of the session. `@hwip/mcp-server` ships as a dependency now, so it updates with the CLI rather than being re-fetched by `npx` on every launch — `hwip check-update` reports the version you actually have |
1266
+ | `listener` | (Usually unnecessary — `hwip cc` autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects `agent.command` pushes into the matching session. Run in the foreground for SDK development; otherwise let `hwip cc` manage it |
1267
+
1268
+ ### Notify Options
1269
+
1270
+ | Flag | Description |
1271
+ |------|-------------|
1272
+ | `--title <text>` | Push title (default: `"Task done"`) |
1273
+ | `--body <text>` | Push body (default: `"<project> · <branch>"` if cwd is a git repo, else `"<project>"`) |
1274
+ | `--url <url>` | URL to include |
1275
+ | `--type <type>` | Push type: `note`, `link`, `file`, `hook` |
1276
+ | `--priority <p>` | Priority: `low`, `normal`, `high`, `urgent` |
1277
+ | `--device <id>` | Target device ID |
1278
+ | `--session <id>` | AI session ID so the push threads into that session's chat (or `HWIP_SESSION_ID` env) |
1279
+ | `--auto` | Apply the push gate before sending — honors the `/hwip-mode quiet` / `/hwip-mode loud` push-mode dial, per project or machine-wide (`--global`); gated-out exits silently with code 0 |
1280
+ | `--pushmode-default <m>` | Mode `--auto` assumes when the project has no dial: `quiet` (built-in), `normal`, `loud`. A dial the user set always wins |
1281
+ | `--marker <m>` | Push Signal marker for `--auto`: `skip`, `push`, `high` |
1282
+ | `--tools <n>`, `--nonreadonly <n>` | Turn tool counts feeding `--auto`'s heuristic (defaults assume real work) |
1283
+
1284
+ The defaults are tuned for hook-driven invocations (e.g. Stop hooks
1285
+ calling `hwip notify --title "Task done"` without a body) — you'll see
1286
+ which project + branch finished without writing per-IDE wrappers. Pass
1287
+ `--body ""` explicitly to suppress.
1288
+
1289
+ ### Listener Options
1290
+
1291
+ | Flag | Description |
1292
+ |------|-------------|
1293
+ | `--ws-url <url>` | WebSocket endpoint for this run only; the lasting setting is `wsUrl` in `~/.hwip/config.json`. It has to be a `ws://` or `wss://` URL: the listener refuses to start on anything else (a bare `ws.hwip.app` included), and `hwip verify` fails the WebSocket row |
1294
+ | `--key <api-key>` | API key (or set `HWIP_API_KEY` env) |
1295
+ | `--base-url <url>` | REST API base URL (or set `HWIP_BASE_URL` env, or `baseUrl` in `~/.hwip/config.json`) |
1296
+ | `--stop` | Stop the running daemon and clear its PID/version stamps |
1297
+ | `--restart` | Stop it and relaunch detached (logs to `~/.hwip/listener.log`) |
1298
+ | `--no-keep-awake` | Don't hold the Mac awake on AC power (macOS). Lasting form: `"keepAwake": false` in `~/.hwip/config.json` |
1299
+ | `--no-turn-history` | Don't upload session conversations for the app's history (see [Session history](#session-history)). Lasting form: `"turnHistory": false` in `~/.hwip/config.json` |
1300
+
1301
+ A yes/no flag takes nothing (`--no-turn-history`), or `=true` / `=1` / `=yes` for on and `=false` / `=0` / `=no` for off; any other value stops the command with exit 2 (`--dry-run=maybe`), rather than being read as "not given". In `config.json` an option stays on only when it is absent or `true`: `"turnHistory": "false"`, `0` or `null` read as off.
1302
+
1303
+ The listener reconnects with exponential backoff + jitter (1 s → 30 s
1304
+ cap). Keeping the link alive costs the backend a message each way, so it
1305
+ is quiet on purpose: the report heartbeat (every 2 minutes) is itself the
1306
+ proof of life, and a `ping` goes out only when nothing was sent for 4
1307
+ minutes (API Gateway closes a WebSocket idle for 10 minutes). Two things
1308
+ tell a dead socket from a quiet one. A report the server does not answer
1309
+ (ack or error) within 30 s drops the socket and the new connection
1310
+ reports again; and 300 s of total silence — any inbound frame counts, ack
1311
+ or pong — drops it too, which catches a link with nothing to send. The
1312
+ silence limit is waived while the daemon's own event loop is blocked,
1313
+ because a thread that is not listening cannot tell a dead socket from its
1314
+ own deafness; 360 s of wall-clock silence terminates the socket either
1315
+ way. On an
1316
+ authentication failure close (4001/4002/4003) the listener exits with
1317
+ code 3 instead of looping forever — fix the key and restart.
1318
+
1319
+ **Pattern watches and detection rules** run on the daemon's one thread, so
1320
+ they use RE2 ([re2js](https://github.com/le0pard/re2js)) rather than the
1321
+ JavaScript engine: matching time is linear in the pane text (the last
1322
+ 8 KiB), where a backtracking engine freezes every stream on `(a+)+$` or
1323
+ `.*.*.*x`. RE2's time also grows with the compiled pattern, so a pattern
1324
+ that compiles too large — big counted repeats like `\w{1,1000}` — is
1325
+ refused. RE2 has no lookaround and no backreferences, its `\s`, `\w`, `\d`
1326
+ are ASCII-only (a non-breaking space is not `\s`), and `$` does not match
1327
+ before `\r`. A pattern it cannot compile never matches; for a watch the log
1328
+ says `! watch on <session> will never fire: its pattern does not compile (…)`
1329
+ once.
1330
+
1331
+ **Local transfer.** Once its device keys are loaded the listener also opens a
1332
+ small HTTP server on an OS-assigned port and publishes `host:port` (private
1333
+ IPv4 only) on its device record, so a sender on the same Wi-Fi can hand a
1334
+ file straight to this machine instead of going through the cloud. The log
1335
+ says `local transfer: listening on 0.0.0.0:<port>`. Each remote address may hold at most 8
1336
+ open connections to it, so one device on the network cannot use them all up. If the macOS firewall is
1337
+ on, the first bind may ask *"Do you want the application node to accept
1338
+ incoming network connections?"* — declining is safe; files simply keep
1339
+ taking the relay. A file sent this way may be up to 16 GiB — no plan limit
1340
+ applies, the bytes never reach the cloud — as long as the disk keeps 1 GiB
1341
+ free after it. With several addresses it prefers a physical interface
1342
+ (`en*`, `eth*`, `wlan*`) over a VPN or virtual one (`utun*`, `tailscale*`,
1343
+ `docker*`, `bridge*`, …), and within one kind an RFC 1918 address over a
1344
+ link-local or CGNAT (`100.64/10`) one.
1345
+
1346
+ **Receiving files.** A file push addressed to this machine (the MCP `hwip_file`
1347
+ tool, the phone's share sheet) is saved to `~/Downloads/Hwip/`
1348
+ under its own name — `name (2).ext` when that name is taken, never
1349
+ overwritten, never cleaned up — and a desktop banner (`Hwip · <file>` over
1350
+ the push's title and the folder, e.g. `[myproject] shot.png · ~/Downloads/Hwip`)
1351
+ says where it landed. The name in the banner is the one it was saved under, so
1352
+ a `(2)` shows up there. With [`terminal-notifier`](https://github.com/julienXX/terminal-notifier)
1353
+ installed (`brew install terminal-notifier`), clicking the banner reveals the
1354
+ file in Finder; the plain macOS banner has no click action. Encrypted
1355
+ files are opened with this machine's own device key on the way down; nothing
1356
+ is buffered, so a 1 GB recording streams straight to disk. A sealed-only
1357
+ listener saves only files sealed for this machine: a plain one is logged and
1358
+ skipped, since anyone who can write a push could have chosen its bytes and
1359
+ name. Bytes are fetched only from an `https` S3 address (a loopback one when
1360
+ the base URL is loopback, for the local server), with redirects refused, and
1361
+ a download stops past 1.25 GiB — the 1 GiB plan limit plus the padding and
1362
+ tag a sealed file carries. `agent.command`
1363
+ attachments are unaffected: they still go to `~/.hwip/attachments/<pushId>/`
1364
+ and are swept after 24 h. A download is written to a hidden
1365
+ `.hwip-<id>.part` beside its final name and moved into place once
1366
+ complete; one a crash left behind is removed by the hourly sweep once it
1367
+ is an hour old.
1368
+ A download that sends no response, or whose link request hangs, is given
1369
+ up on instead of holding the push open.
1370
+
1371
+ A sender on the same network can skip the cloud entirely: it pings this
1372
+ machine's endpoint, then uploads the encrypted file straight to it. Every
1373
+ request is authenticated with a key only a device registered on your account
1374
+ can derive (ECDH with this machine's device key), so an unknown sender — or a
1375
+ replayed or tampered upload — gets an empty 401 and nothing is written. File
1376
+ names and the recipient list travel sealed too; someone sniffing the Wi-Fi
1377
+ sees a device id and a transfer id, nothing else. The sender counts a file
1378
+ delivered only on a receipt this machine alone can compute, so a host that
1379
+ merely answers on this machine's address cannot swallow it — the file goes via
1380
+ the cloud instead. A device removed from the
1381
+ account is refused within a minute. The file waits in `~/.hwip/attachments/lan/`
1382
+ until its push record arrives, then moves to `~/Downloads/Hwip/` like any
1383
+ other; a transfer whose record never comes is swept after 24 h. Two uploads at
1384
+ a time, 1 GB each at most — whatever the plan, since these bytes never reach
1385
+ the cloud.
1386
+
1387
+ ### List Options
1388
+
1389
+ | Flag | Description |
1390
+ |------|-------------|
1391
+ | `--limit <n>` | Number of pushes (1-20, default 5) |
1392
+ | `--type <type>` | Filter by push type |
1393
+
1394
+ ### Global Options
1395
+
1396
+ A flag that takes a value reads it from the next word or after `=`
1397
+ (`--title=Foo`). A flag that takes a value takes the next word whatever it
1398
+ looks like — `--body "--base-url=http://x"` is a body, not a base URL — so text
1399
+ nobody typed (a command to approve, a model's message) cannot set a flag of
1400
+ hwip's (only an exact `--help`, `-h` or `--` keeps its meaning there); the
1401
+ `--title=Foo` form is one word, so no version can read it as a flag. A value that starts with a dash run (`--title "--- done"`)
1402
+ stays a value, and `--` ends hwip's flags: every word after it is read as a
1403
+ word, not a flag. After an agent subcommand (`hwip cc …`) the words go to
1404
+ the agent untouched. A flag the command does not read (`--titel`) is
1405
+ ignored with a one-line warning on stderr.
1406
+
1407
+ | Flag | Description |
1408
+ |------|-------------|
1409
+ | `--key <api-key>` | API key (or set `HWIP_API_KEY` env) |
1410
+ | `--base-url <url>` | API base URL (or set `HWIP_BASE_URL` env) |
1411
+ | `--json` | Output JSON format |
1412
+ | `--version` | Print version |
1413
+ | `--help`, `-h` | Print usage and exit without running the command. With a command (`hwip notify --help`, or `hwip help notify`) it prints that command's own short usage — its description, its options and the global ones; with none, or a word that is not a command, it prints every command. After an agent subcommand like `hwip cc` it goes to the agent (before it, hwip prints usage), and after `hwip agent send <target>` (or `hwip send <target>`) it is part of the message |
1414
+
1415
+ ### Mute & push mode
1416
+
1417
+ Both live as state files under `${XDG_STATE_HOME:-~/.local/state}/hwip`,
1418
+ keyed by a `cksum` hash of the project directory. Claude Code's
1419
+ `/hwip-mode mute` / `/hwip-mode quiet` / `/hwip-mode loud` / `/hwip-mode normal` write them;
1420
+ the CLI reads them (mute on every `notify`, push mode on `--auto`).
1421
+
1422
+ Notifications are silently skipped when a mute file exists for the
1423
+ current project:
1424
+
1425
+ ```bash
1426
+ STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hwip"
1427
+ HASH=$(printf '%s' "$PROJECT_DIR" | cksum | cut -d' ' -f1)
1428
+
1429
+ # Mute (created by /hwip-mode mute in the Claude Code plugin)
1430
+ mkdir -p "$STATE_DIR" && touch "$STATE_DIR/muted-$HASH"
1431
+
1432
+ # Unmute
1433
+ rm -f "$STATE_DIR/muted-$HASH"
1434
+ ```
1435
+
1436
+ Push mode is a one-word file (`quiet` / `loud` / `normal`) resolved in
1437
+ this order — first hit wins:
1438
+
1439
+ | Order | File | Set by |
1440
+ |-------|------|--------|
1441
+ | 1 | `$STATE_DIR/pushmode-<hash>` | `/hwip-mode quiet` · `/hwip-mode loud` · `/hwip-mode normal` |
1442
+ | 2 | `/tmp/hwip-pushmode-<hash>` | older versions (honored only when you own the file) |
1443
+ | 3 | `$STATE_DIR/pushmode-default` | the `--global` form of any dial — the machine-wide default |
1444
+ | 4 | `--pushmode-default <mode>` | the calling hook (the installed ones pass `normal`) |
1445
+ | 5 | *(nothing above)* | `quiet` |
1446
+
1447
+ **Row 5 changed**: an install with no dial anywhere used to be `normal`.
1448
+ It is now `quiet`, so upgrading turns the routine per-turn push off until
1449
+ you run `/hwip-mode normal`. Row 4 is why the hooks this CLI installs are
1450
+ unaffected: they name `normal` themselves, since a hook that supplies no
1451
+ `high` marker would be silent under `quiet` whenever you're at the terminal,
1452
+ rather than merely quieter. Row 4 sits *below* the state files on purpose — the flag
1453
+ names a default, it does not override a dial the user set.
1454
+
1455
+ Under `normal` the JSON hook configs push on every turn, because they see
1456
+ no per-tool events and so pass no counts — the gate then assumes real
1457
+ work. The Pi extension and the OpenCode plugin do see those events, so
1458
+ they pass real `--tools` / `--nonreadonly` counts and go quiet where the
1459
+ others cannot: a turn with no tool calls, a turn with exactly one (a
1460
+ lone edit included — the gate wants two), and a turn whose calls were
1461
+ all reads. `/hwip-mode loud` still pushes on all of them.
1462
+
1463
+ The Pi extension also pushes when pi is **waiting on you**: any blocking
1464
+ extension dialog — a bash guard's Run/Abort, an ask-user tool, a
1465
+ `ctx.ui.confirm` — that is still open 10 seconds after it appeared sends a
1466
+ `high` push ("pi asks: <project>", body = the dialog title or the guarded
1467
+ bash command). `high` gets through `quiet`; only `/hwip-mode mute` stops it.
1468
+ Answering inside the 10 seconds sends nothing. It is the pi twin of the
1469
+ Claude Code plugin's AskUserQuestion push, driven by pi's
1470
+ `ui_prompt_start` / `ui_prompt_end` events. Only dialogs that open while a
1471
+ turn is running count: a settings picker you open yourself between turns
1472
+ (`/caveman config`) never pushes.
1473
+
1474
+ Two more end-of-turn cases differ from Claude Code:
1475
+
1476
+ - **Push Signal markers** — pi's rules teach the same `<!-- hwip: skip|push|high -->`
1477
+ markers, and the extension reads them from the final assistant message
1478
+ (then strips them, since pi's terminal would otherwise print the comment).
1479
+ `high` is how pi gets a "the turn ended with a question for you" push
1480
+ through `quiet`.
1481
+ - **Errors and aborts** — a turn that ended on a provider error sends a
1482
+ `high` "pi stopped: <project>" push with the error text instead of
1483
+ "Task done"; a turn you aborted with Esc sends nothing.
1484
+
1485
+ #### Quiet while away
1486
+
1487
+ `quiet` silences routine pushes because you are watching the pane. When
1488
+ you've walked away, nobody is, so `notify --auto` under `quiet` still sends
1489
+ the push (normal priority) once it decides you're away. It reads the machine
1490
+ with three signals (the first that answers wins), then asks about this
1491
+ session's own pane:
1492
+
1493
+ 1. Inside tmux and **no client attached to the tmux server** → away.
1494
+ 2. Not over SSH and macOS `ioreg` reports **`HIDIdleTime`** (system-wide
1495
+ input idle) → that alone decides. Inside tmux, "over SSH" follows the
1496
+ session environment tmux refreshes on each attach, not the value frozen
1497
+ at server start.
1498
+ 3. Inside tmux → the newest tmux **`client_activity`** (SSH and non-macOS).
1499
+ 4. Beyond the machine: input and a tmux client say you are at the desk, not
1500
+ which tmux session you are looking at. Inside tmux the hook also asks about
1501
+ its own pane (`$TMUX_PANE`): a session with no client attached, in a window
1502
+ that is not showing, or under another pane's zoom counts as away even while
1503
+ you type elsewhere. A tmux that will not answer, a missing `$TMUX_PANE` and
1504
+ any control-mode client (iTerm2 `tmux -CC`) leave the answer above alone.
1505
+
1506
+ Anything unreadable counts as present. The threshold is `HWIP_AWAY_SEC`
1507
+ seconds in the hook's environment (default `300`, `0` turns detection off —
1508
+ the pane check included — and a non-number falls back to the default). The probe runs only on a
1509
+ quiet turn with no `high` marker. The Claude Code plugin's Stop hook
1510
+ applies the same rule (`hwip_is_away` in its `hooks/gate.sh`).
1511
+
1512
+ A dial file that exists but reads empty resolves to `normal`, not to row
1513
+ 5: an empty file is a failed write, and resolving breakage to silence
1514
+ leaves no symptom to debug.
1515
+
1516
+ Mute has no `-default` form on purpose: it is keyed on presence, not
1517
+ content, so a global mute could never be lifted for a single project.
1518
+
1519
+ Legacy `/tmp/hwip-muted-<hash>` files are still honored when owned by the
1520
+ current user (the state dir moved out of world-writable `/tmp`).
1521
+
1522
+ The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`,
1523
+ `WINDSURF_PROJECT_DIR`, and falls back to `cwd`.
1524
+
1525
+ ### Exit Codes
1526
+
1527
+ | Code | Meaning |
1528
+ |------|---------|
1529
+ | 0 | Success |
1530
+ | 1 | General error — also: `ask` got no answer, `verify` has a failed check, `install` has a step marked `-`, `uninstall` answered no |
1531
+ | 2 | Quota exceeded — also a usage error: `agent send` (and `send`), `agent list`, `agent read`, `rename`, `forget`, agent-subcommand flags, `install` with no terminal for the picker |
1532
+ | 3 | Authentication failed, or no API key (also: listener auth close 4001/4002/4003, `install` with a key the API rejects) |
1533
+ | 127 | A required external binary (e.g. `tmux`, `claude`) was not found on PATH — for `hwip cc` / `hwip sh` a missing tmux comes with its install hint |
1534
+
1535
+ ### Environment Variables
1536
+
1537
+ | Variable | Description |
1538
+ |----------|-------------|
1539
+ | `HWIP_API_KEY` | API key (fallback when `--key` not provided) |
1540
+ | `HWIP_BASE_URL` | API base URL (default: `https://api.hwip.app/v1`). With the key from `~/.hwip/config.json` it must be on the same origin as that file's `baseUrl` (else set `HWIP_API_KEY` too) |
1541
+ | `HWIP_WS_URL` | **Deprecated.** WebSocket endpoint for `hwip listener`. `wsUrl` in `~/.hwip/config.json` wins over it; read only when the file has none. It will stop being read |
1542
+ | `HWIP_TMUX_SOCKET` | Explicit tmux socket path for the listener (skips auto-discovery — use when your tmux runs with `-L <name>` or a custom `-S <path>`) |
1543
+ | `HWIP_SESSION_ID` | AI session ID (fallback when `--session` not provided) |
1544
+ | `HWIP_ASK_CONTEXT` | `0`, `false`, `off` or `no` switches off, for that run, the context a sealed question may carry (see `ask-context`) |
1545
+ | `HWIP_FILE_VIEW` | `0`, `false`, `off` or `no`, in the listener's environment, switches off for that run the phone's access to session files (see `file-view`) |
1546
+ | `HWIP_NO_UPDATE_CHECK` | Set to skip `hwip cc`'s once-a-day npm check for a newer version (also skipped when `CI` is set) |
1547
+ | `HTTPS_PROXY` · `HTTP_PROXY` · `NO_PROXY` | Route API calls and the listener's socket through a proxy (`http://` or `https://`, credentials allowed in the URL; lower-case names work too and win). `HTTPS_PROXY` falls back to `HTTP_PROXY` for https; `NO_PROXY` takes hosts, `.suffix`es and `host:port`s, or `*`. Works on every supported Node — `fetch` through undici's `EnvHttpProxyAgent`, the listener's websocket through a CONNECT tunnel — not only on the Nodes that have `http.setGlobalProxyFromEnv` (24.14+). An unusable value is an error naming the variable (`--version` and `--help` still answer), not a direct connection. Plain `http://` and `ws://` targets are tunnelled with CONNECT too, so a proxy that allows CONNECT only to port 443 refuses them. A lower-case variable that is set but empty wins over the upper-case one, which is how `http_proxy= hwip …` switches the proxy off for one run. A listener installed as a service (`hwip listener --install-service`) starts with launchd's own minimal environment and does not see these variables — it reads `"httpsProxy"` · `"httpProxy"` · `"noProxy"` from `~/.hwip/config.json` instead, which every `hwip` command also reads for whatever the environment leaves unset: **the environment wins, one setting at a time** (a variable that is set at all, empty included, replaces the file's value), so `HTTPS_PROXY` from the shell beats `"httpsProxy"`, while `NO_PROXY` can still come from the file. To switch the file's proxy off for one run, empty the variable of the same setting (`https_proxy= hwip …` for `"httpsProxy"`; `http_proxy=` does not reach it). An unusable `"httpsProxy"` / `"httpProxy"` (not an `http://` or `https://` address, or not text) is the same error naming the key, and a service with it exits and is retried every 30 s, not started direct |
1548
+
1549
+ ## SDK Usage
1550
+
1551
+ ```typescript
1552
+ import { HwipHook } from '@hwip/cli';
1553
+
1554
+ const hook = new HwipHook({ apiKey: 'ak_...' });
1555
+
1556
+ // Notify
1557
+ const result = await hook.notify({
1558
+ title: 'Build Complete',
1559
+ body: 'Deploy succeeded',
1560
+ url: 'https://example.com/deploy/123',
1561
+ priority: 'high',
1562
+ });
1563
+ console.log(result.pushId); // 'push_01JXY...'
1564
+
1565
+ // List
1566
+ const list = await hook.list({ limit: 5 });
1567
+ console.log(list.pushes);
1568
+
1569
+ // Dismiss
1570
+ await hook.dismiss('push_01JXY...');
1571
+ await hook.dismissAll();
1572
+ ```
1573
+
1574
+ ### Constructor Options
1575
+
1576
+ | Field | Type | Description |
1577
+ |-------|------|-------------|
1578
+ | `apiKey` | `string` | Required — API key from Hwip settings |
1579
+ | `baseUrl` | `string?` | API base URL (default: `https://api.hwip.app/v1`) |
1580
+ | `timeout` | `number?` | Request timeout in ms (default: 30000) |
1581
+
1582
+ ### Last push failure
1583
+
1584
+ Every installed hook runs `hwip notify … 2>/dev/null || true`, so a failed
1585
+ push leaves nothing on screen. `hwip notify` (and `hwip test`, and the test
1586
+ push `hwip install` sends) therefore records the last failure
1587
+ itself and deletes the record once a push goes through — the last attempt to
1588
+ finish wins when hooks race:
1589
+
1590
+ ```
1591
+ ${XDG_STATE_HOME:-~/.local/state}/hwip/last-notify-failure.json
1592
+ {"v":1,"at":"2026-10-02T00:00:00.000Z","kind":"auth","status":401,"code":"AUTHENTICATION_ERROR"}
1593
+ ```
1594
+
1595
+ `kind` is one of `no-key`, `auth`, `quota`, `rate-limit`, `client`,
1596
+ `server`, `timeout`, `network`, `no-app`, `unsealed`; `status` is the HTTP
1597
+ status and `code` the server's error code, both `null` when there was no HTTP
1598
+ answer. A push is never sent in the clear (see Encryption): `no-app`
1599
+ (`NO_APP_DEVICE`, no app is signed in to the account) and `unsealed`
1600
+ (`E2E_REQUIRED`, the push could not be sealed) are refusals made on this
1601
+ computer before anything was sent, so their `status` is `null` and `code`
1602
+ names them. A device whose key changed is not a failure and
1603
+ is not recorded here (see Encryption → key changes). No message
1604
+ text, key or push content is stored. `hwip verify` shows it under
1605
+ **Push delivery**, and the Claude Code plugin shows it in `/hwip-mode` and
1606
+ at session start. The format is a contract with the plugin —
1607
+ `src/notify-failure.ts` is the source of truth and its test pins the line
1608
+ above.
1609
+
1610
+ ### Notify Payload
1611
+
1612
+ | Field | Type | Description |
1613
+ |-------|------|-------------|
1614
+ | `title` | `string?` | Push title |
1615
+ | `body` | `string?` | Push body |
1616
+ | `url` | `string?` | URL to include |
1617
+ | `type` | `'note' \| 'link' \| 'file' \| 'hook'?` | Push type (default: `hook`) |
1618
+ | `priority` | `'low' \| 'normal' \| 'high' \| 'urgent'?` | Priority (default: `normal`) |
1619
+ | `targetDeviceId` | `string?` | Send to specific device |
1620
+
1621
+ ### Error Handling
1622
+
1623
+ ```typescript
1624
+ import { HwipHook, AuthenticationError, QuotaExceededError, HwipError } from '@hwip/cli';
1625
+
1626
+ try {
1627
+ await hook.notify({ title: 'Hello' });
1628
+ } catch (err) {
1629
+ if (err instanceof AuthenticationError) { /* Invalid API key */ }
1630
+ if (err instanceof QuotaExceededError) { /* Monthly limit reached */ }
1631
+ if (err instanceof HwipError) { /* Other API error */ }
1632
+ }
1633
+ ```
1634
+
1635
+ ## Supported Agents
1636
+
1637
+ `hwip install` detects and configures these agents automatically:
1638
+
1639
+ | Agent | What gets installed |
1640
+ |-------|-------------------|
1641
+ | Claude Code | Plugin (hooks + MCP server) |
1642
+ | Cursor | MCP server + stop hook + rules |
1643
+ | Windsurf | MCP server + response hook + rules |
1644
+ | Gemini CLI | MCP server + AfterAgent hook |
1645
+ | Codex CLI | MCP server + prompt/stop hooks + rules |
1646
+ | Copilot CLI | Session end hook + rules |
1647
+ | Cline | Rules file (`~/.cline/rules/hwip.md`) |
1648
+ | Aider | Conventions file + `read:` directive in `~/.aider.conf.yml` |
1649
+ | Pi | Extension (settle hook + waiting-on-you push + remote detection) + rules — no MCP (pi has none; rules map hwip tools to the CLI) |
1650
+ | OpenCode | MCP server + idle-notify plugin + rules — the plugin loads on opencode 1.18.35 and 2.0.24 (checked 2026-10-10). On 2.x run `opencode reload` after installing: the background server loads plugins when it starts |
1651
+
1652
+ Settings files the agents share with you — Cursor `mcp.json` and `hooks.json`,
1653
+ Windsurf `mcp_config.json` and `hooks.json`, Gemini `settings.json`, Codex
1654
+ `hooks.json`, OpenCode `opencode.json` — are merged: your own entries stay, and
1655
+ only hwip's are added or replaced. Comments and trailing commas (JSONC) are
1656
+ read like the agents read them and are kept: hwip's entry is added after yours,
1657
+ on its own lines, and the rest of the file is left as it was (when your file
1658
+ is written compactly, on one line, the part around hwip's entry is laid out
1659
+ again). A comment sitting right next to one of hwip's own entries goes when that
1660
+ entry does, and `hwip uninstall` keeps a hooks file that holds a comment even
1661
+ when only hwip's entries were in it. `hwip uninstall` takes out only hwip's entries, and deletes a hooks file
1662
+ only when nothing else was in it. A file with a syntax error, or whose `hooks`
1663
+ is not an object of lists, or whose comments an edit could not keep (an
1664
+ empty or one-line section with a comment at the end of its last line), is left as it is, and the step prints why and what
1665
+ to add by hand. Uninstall marks such a hooks file, and any step it could not
1666
+ finish, with `!` and names it. Every write but one goes through a temp file
1667
+ and a rename, so a full disk or a killed install leaves the old file whole; a
1668
+ symlinked settings file stays a link. A file you made read-only is left as it is and
1669
+ named, not replaced (unless hwip runs as root, which can write any file); a writable
1670
+ file in a directory that takes no new files is written in place instead — not atomic,
1671
+ so a killed install can cut it off; its old bytes are put back if the write fails —
1672
+ and a file that is not there yet in such a directory is named and not created.
1673
+ A file that is not valid UTF-8 text is left as it is and named (reading it as
1674
+ text would turn its bytes into U+FFFD). A file someone else writes while hwip is
1675
+ editing it is left as that writer made it: the step stops, names the file and says
1676
+ to run the command again, instead of replacing their change. Uninstall of a hooks
1677
+ file or rule file that is a symlink (a dotfiles repo) takes hwip's part out of what
1678
+ the link points at and keeps the link, so nothing of hwip's comes back with it.
1679
+
1680
+ For remote-control via `hwip listener` the per-agent setup is the same
1681
+ across CC/Codex/Gemini — the wrapper just spawns them in a named tmux
1682
+ session.
1683
+
1684
+ ## Encryption
1685
+
1686
+ End-to-end encryption is **always on**: there is no switch and nothing to
1687
+ configure. A push's title, body and URL, a question, an answer and a message to
1688
+ another computer leave this host as ciphertext, on every plan.
1689
+ The one exception is a push to a channel's subscribers (`--channel`), which the
1690
+ server fans out as plain text.
1691
+
1692
+ A push needs an app to read it. When no iPhone or Android app is signed in to
1693
+ the account, `hwip notify` and `hwip ask` send nothing and say so —
1694
+ `no app is signed in to this account: sign in to the Hwip app on an iPhone or
1695
+ Android phone, then send again` (`NO_APP_DEVICE`). When an app is signed in but
1696
+ this send cannot be sealed — the device list does not load, this computer's
1697
+ keys do not, no device lists a key, encryption throws — the push is refused with
1698
+ `E2E_REQUIRED` and the reason. The push-delivery file below records the two as
1699
+ `no-app` and `unsealed`. There is no plaintext fallback, no line on stderr about one, and
1700
+ nothing to turn off.
1701
+
1702
+ Push bodies and long-body attachments are encrypted with
1703
+ AES-256-GCM. This host holds its own ECDH P-256 keypair in
1704
+ `~/.hwip/device-keys.json` — generated on first use, and the private
1705
+ half never leaves the machine; the backend stores public keys only and
1706
+ rejects a private-key upload. Each push is encrypted once, and its AES
1707
+ key is wrapped separately for every device the account lists with a key
1708
+ (`GET /devices`), using ECDH against that device's public key. The title and
1709
+ URL move inside the ciphertext with the body. Every listed device is pinned in
1710
+ `~/.hwip/trusted-devices.json` the first time it is seen; a device that lists
1711
+ another key than the one pinned gets the new key — the pin is replaced, one line
1712
+ on stderr says `hwip: trust: <deviceId> changed its key — now pinned to the new
1713
+ one` (`… could not record the new pin` when the trust file could not be
1714
+ written), and the change is recorded for a person. Nothing is refused for it.
1715
+
1716
+ **Key changes are shown once, wherever they happened.** That stderr line often
1717
+ reaches nobody — a hook throws it away, the listener writes it to its log, the
1718
+ MCP server's stderr is not on any screen — and once the pin holds the new key
1719
+ no one sees the change again. So whoever replaces a pin (`hwip notify`,
1720
+ `hwip ask`, `hwip agent send`, the listener, the MCP server) also writes it to
1721
+ `~/.hwip/key-changes.json`, under the trust file's lock:
1722
+
1723
+ ```
1724
+ {"v":1,"changes":[{"deviceId":"dev_ios_1","at":"2026-10-13T09:00:00.000Z","name":"iPhone"}]}
1725
+ ```
1726
+
1727
+ One entry per device, at most 64. The next surface a person reads shows each one
1728
+ once and deletes the file: `hwip verify`, `hwip trust`, `hwip test`, and
1729
+ `hwip notify` / `hwip ask` when stderr is a terminal print
1730
+ `hwip: <name> changed its encryption key — the new key is trusted. Not expecting
1731
+ that (app reinstalled?) Check it: hwip trust`; the Claude Code plugin shows it at
1732
+ session start and in `/hwip-mode`, the MCP server at the end of a tool result.
1733
+ A hook's `hwip notify … 2>/dev/null` neither reads nor deletes it. The format is
1734
+ a contract with the MCP server and the plugin — `src/key-changes.ts` is the
1735
+ source of truth.
1736
+
1737
+ **Threat model:** against a passive backend — a leaked snapshot, an
1738
+ operator reading the table — the stored ciphertext and wrapped keys are
1739
+ useless, so push contents stay private. Four limits worth knowing:
1740
+
1741
+ - **No protection from an active malicious operator.** Recipient public keys
1742
+ come from `GET /devices` on that same server, unsigned. A backend that
1743
+ injects a device record carrying its own key, or lists another key for a
1744
+ device you know, gets the message key wrapped for it and reads what is sent
1745
+ after that — the pin is replaced and one line says so, nothing stops it.
1746
+ Comparing a device's fingerprint (`hwip trust`) with the one the app shows
1747
+ is the check, by eye, and a changed-key line on stderr is the signal to do
1748
+ it. The Hwip app's verified-device strict mode is per browser profile and is
1749
+ not consulted here.
1750
+ - **No forward secrecy.** The ECDH secret for a given sender/device pair
1751
+ is static, so compromising either private key opens every past push
1752
+ wrapped for that pair.
1753
+ - **`senderPublicKey` is unsigned**, so a swapped one makes a push
1754
+ undecryptable — that direction fails closed rather than leaking.
1755
+ - **Two OS users on one computer share its device id** (it comes from the
1756
+ machine; the key is each user's `~/.hwip`). With a `push:read` API key each
1757
+ user's notify or ask puts its own key back on the list, and the app warns of
1758
+ a changed key each time.
1759
+
1760
+ A device that has not registered a per-device public key cannot be sent
1761
+ to; it is skipped, and if no device qualifies nothing is sent.
1762
+
1763
+ **`hwip ask` seals both ways.** The question's title,
1764
+ body and button wording go to the phone inside the envelope, with a random
1765
+ 128-bit id the answer has to echo; the server keeps only each button's id,
1766
+ the timeout, the `hookType` and `acceptsExit` flags, the session fields, the
1767
+ envelope's `senderPublicKey` and `deviceKeyMap` (so, the ids of the devices
1768
+ it is sealed for), the push priority (the hook's setting) and, once
1769
+ answered, which device answered. The phone seals its answer for this
1770
+ computer, and `hwip ask` opens it only if the answering device's key is
1771
+ the one the account's device list registers for it, the envelope was sealed
1772
+ with that key, and it holds an `answer` for this question's id. An answer
1773
+ from a device whose key changed since it was pinned opens too (the pin is
1774
+ replaced and the line above is printed); an answer from a device the list
1775
+ does not hold with a key is refused. This path never falls back to plaintext:
1776
+ if the question cannot be sealed (no app signed in, no device to seal for, keys
1777
+ that do not load, a device list that does not load, this computer's key that
1778
+ cannot be put on the list), nothing is sent and the JSON says why. A button id over 64 characters, a label over 100 or more than 10
1779
+ buttons is refused with its own message. The device list is read before the send
1780
+ and counted against the `--timeout` window: if it uses it all up, nothing is
1781
+ sent and the ask ends as a timeout. Otherwise the server is told what is left
1782
+ of the window, rounded up to a whole second and never less than one. Plaintext
1783
+ fields the server returns for a sealed question are ignored.
1784
+
1785
+ **A sealed question carries its context.** What the asking session has
1786
+ changed (`git diff` against HEAD with new files, never a `.gitignore`d one)
1787
+ and the last 60 lines of its terminal (the tmux pane `hwip ask` runs in;
1788
+ none outside tmux) go with the question, masked for secret-looking text
1789
+ first — best effort: a secret in a form the masking does not know stays. A
1790
+ summary (files, lines, at most 20 paths; 4 KiB at most) rides inside the
1791
+ sealed body; the text itself (64 KiB at most, cut at a line with a mark) is
1792
+ one attachment sealed for the same devices, which the phone fetches when the
1793
+ summary is opened. The server sees that attachment the same way on every
1794
+ sealed question: named `file`, `application/octet-stream`, 64 KiB — also when
1795
+ there is nothing to carry or `hwip ask-context off` is set, where it is sent
1796
+ with nothing in it — so neither whether a question has context nor how much
1797
+ changed shows. It is kept as long as any attachment (30 days). The
1798
+ context never holds a question back: if it cannot be gathered or stored the
1799
+ question goes without the attachment (which the server can tell), a
1800
+ summary that would take the envelope over the server's 48 KiB limit is left
1801
+ out — and the attachment too if that is still not enough — and with less than 20 seconds of `--timeout` left nothing is gathered.
1802
+
1803
+ **A question goes up once.** The send carries an `Idempotency-Key`, a new
1804
+ random one per question, so sending it again cannot open a second question:
1805
+ the server answers a repeat with the first one's ids. A send that fails in a
1806
+ way that may pass — no connection, no answer in 10 seconds, a 5xx, 408 or
1807
+ 429 — is sent again with the same key and the same bytes, after 1, 2 and 4
1808
+ seconds (four tries). While the server says the first request is still
1809
+ running under the key (409 `IDEMPOTENCY_IN_PROGRESS`), the send is repeated
1810
+ every 5 seconds instead, for up to 35 seconds from the first such answer: a
1811
+ request the server lost to its own timeout holds the key for 30. No try is
1812
+ made that would start after the `--timeout` window has ended, and none is
1813
+ waited on more than a second past its end (a try gets what is left of the
1814
+ window when that is under 10 seconds, and a second at the least). Any other
1815
+ refusal (a bad key, a key that cannot be sent as a header, a hook that is
1816
+ gone, a redirect, a base URL that is not an address) ends the ask at once. The
1817
+ server counts the question's timeout from the try it took, so a question that
1818
+ went up on a late try stays on the phone that much longer than this command
1819
+ waits, and an answer given in that tail is not read. A repeat the server had
1820
+ already taken is answered with the question's ids alone, which do not say
1821
+ whether the phone was notified — that was the first request's to say. So
1822
+ when a question went up on a retry and the answer does not say the phone was
1823
+ notified, `hwip ask` says so on stderr, and again as `error` in its JSON if
1824
+ the ask ends as a timeout: the question is in the app either way, but nobody
1825
+ may have been pinged. When the tries run out and one of them may have
1826
+ reached the server all the same — it was slow to answer, answered
1827
+ unreadably or with a 5xx, still held the first send as running, or the
1828
+ connection dropped once made — the
1829
+ `error` ends with "the question may have gone up all the same", whatever the
1830
+ last try said. A try that never connected (nothing listening, no route, a
1831
+ name that does not resolve) or was answered 429 does not count: when every
1832
+ try ended so, the error is plain. One
1833
+ case still opens a second
1834
+ question: a first request that wrote the question and died before it marked
1835
+ the key is taken over after those 30 seconds.
1836
+
1837
+ **A question cut short is taken up again.** `hwip ask` blocks, and what runs
1838
+ it does not always wait: an agent's tool call times out, a shell is closed.
1839
+ The question is still on the phone then, with nobody reading its answer. So
1840
+ each question that went up leaves a record under `~/.local/state/hwip/`
1841
+ (`ask-pending-<hash>.<pid>.<nonce>`, mode 0600), and the same command run again — the same
1842
+ title, body and actions, from the same directory, agent session and machine
1843
+ — waits on that question instead of sending a second one. Its JSON then
1844
+ carries `"resumed": true`. The record holds the event id, the id the answer
1845
+ has to echo and the question's deadline, and nothing of the wording, which
1846
+ is matched by a hash. It is taken up only once the process that asked has
1847
+ exited (two that run side by side each ask their own), by one process only,
1848
+ and within the first question's own window: `--timeout` of the second run
1849
+ bounds its wait but does not extend the question. When the first question
1850
+ timed out with nobody having answered and the second run has window left, it
1851
+ asks anew with what is left; one the user cancelled stays cancelled. A read
1852
+ that fails or is refused (a key that was rotated), or an answer that cannot
1853
+ be opened because the device list did not load, ends the run with the error
1854
+ and keeps the record: the question is still there for the next run. The
1855
+ record is removed before an answer is handed over, so an answer is used
1856
+ once — if it cannot be removed, stderr says that the next run would be given
1857
+ the answer again — and one past its deadline is cleared by the next
1858
+ `hwip ask`. An approval gate goes the same
1859
+ way: a command blocked while its question was open and tried again within
1860
+ that window gets the answer given to the first, for that same command in
1861
+ that same place only.
1862
+
1863
+ This host becomes a device others can encrypt for once its public key is on
1864
+ the account's device list. `hwip listener` puts it there each time it connects
1865
+ to Hwip; without a listener, the first `hwip ask`, `hwip agent send` or
1866
+ `hwip notify` that finds this computer missing from the list, or listed under
1867
+ another key, puts it there itself (one `PUT /devices/{id}/public-key`, no
1868
+ second read of the list) — so `hwip ask` works on a computer that never ran
1869
+ the listener. Only an API key with `push:read` may replace a key the list
1870
+ already holds: with another, the old key stays, and `hwip ask` and
1871
+ `hwip agent send` refuse, saying so on stderr (`hwip ask` in its JSON too). A notify whose key cannot be registered
1872
+ still goes, with one line on stderr — the app reads it either way.
1873
+
1874
+ The `hwip listener` opens encrypted file pushes addressed to it and
1875
+ sealed `agent.command` pushes from a device whose key checks out (see
1876
+ Defense above); it ignores every other `isEncrypted` push — it does not
1877
+ try to decrypt them. A sealed command names its sender inside the seal
1878
+ (`cmd.from`), because a send made with an API key is stamped `api_<keyId>`
1879
+ by the server, which says nothing about the machine. The key must be the
1880
+ one the account's device list registers for that device. The first such
1881
+ command from a device pins it, and — like any first sealed request — makes
1882
+ this listener sealed-only from then on; a changed key replaces the pin.
1883
+ Command numbers are counted per sender (`cmd.from`, else the stamped
1884
+ device). A command sealed by another session of this same computer is
1885
+ checked against this computer's own key, not the account's list. Stop-hook auto-pushes and `hwip_ask` responses are
1886
+ not part of the `@<session>` injection path, so this doesn't affect
1887
+ normal use.
1888
+
1889
+ ### Sealed `hwip agent send`
1890
+
1891
+ `hwip agent send` (and `hwip send`) always seals the
1892
+ message for the target computer instead of posting its text: the push carries only ciphertext, the label inside names the
1893
+ session, the target, this computer as the sender and a number, and the
1894
+ target's listener types it by the same checks as a command from the phone
1895
+ (above). The senders on one computer draw theirs from one counter,
1896
+ `~/.hwip/command-seq`, under a lock (`~/.hwip/command-seq.lock`); the number
1897
+ is the larger of the last one plus one and the clock in milliseconds, so a
1898
+ sender that starts later is never behind.
1899
+
1900
+ **No pairing.** Two computers on one account need nothing set up beyond
1901
+ `hwip listener` running on the target (or `hwip cc`), which types the
1902
+ message; the sender puts its own key on the account's list when it sends. A send pins every device the account lists on the
1903
+ way, the target included; the target pins the sender on the first sealed
1904
+ command it opens, like any first sealed request. To compare fingerprints
1905
+ before that, `hwip trust` prints this computer's and lists the account's
1906
+ other computers that are not pinned yet, and `hwip trust <deviceId>` pins one
1907
+ on purpose — nothing requires it.
1908
+
1909
+ **What it refuses.** Sealing is not a preference: a message
1910
+ that cannot be sealed is not sent, and there is no plaintext branch.
1911
+ Each refusal says what to do — this computer's key could not be put on the
1912
+ account's device list (and why), or the target has no key listed (run `hwip listener` on it
1913
+ once), the device list could not be fetched, the keys did not load, the trust
1914
+ file or the counter cannot be read, or another process holds the counter lock
1915
+ (try again). A target whose key changed since it was pinned is not a refusal:
1916
+ the pin is replaced, one line on stderr says so, and the message goes.
1917
+
1918
+ **Who can read it.** The target computer, and every other device the account
1919
+ lists with a key (a send to another session of this same computer reaches
1920
+ it with this computer's own key). The phone's chat bubble for the message opens by the phone's own slot in
1921
+ `deviceKeyMap`; it does not need the sender pinned.
1922
+
1923
+ **What stays in the clear and what repeats.** The relay needs
1924
+ `agentSessionName` and `targetDeviceId` to route the push, so they are
1925
+ plaintext; so are `senderPublicKey` and the ids of the devices the message
1926
+ is sealed for (the keys of `deviceKeyMap`) — the relay sees which devices
1927
+ can read it, not what it says. A send that failed on
1928
+ the way and is typed again takes a new number; if the first request had in
1929
+ fact arrived, the session receives the message twice.
1930
+
1931
+ The receiving listener, once it only seals, refuses a plaintext `agent.command`
1932
+ (see Defense above); a sender that still posts plaintext gets nothing typed.
1933
+
1934
+ **App version.** An iOS build from before hwip #318 sends the conversation
1935
+ input in plaintext to a computer it has no pin for, and one from before
1936
+ hwip #322 shows `e2ee_required` as a refusal without a reason. Against a
1937
+ sealed-only listener of this version, the first gets nothing typed. The
1938
+ buttons of a blocked prompt (in the app and on its notification) seal named
1939
+ `keys` with `ifPrompt` since the sealed-answer-keys iOS build; an older
1940
+ build's approve button sends plain `keys`, which this version refuses with
1941
+ `e2ee_required`. This version is to be published only after an iOS build
1942
+ that includes #318, #322 and the sealed answer keys is out.
1943
+
1944
+ A file the app seals goes up padded to its Padmé length, under a record
1945
+ that says only `file`, `application/octet-stream` and the padded size; the
1946
+ sealed message names it (by IV) with its real name, type and size, and the
1947
+ listener saves it under that name, cut back to that size. Files from
1948
+ senders that do not do this (this cli, the MCP server) keep their names
1949
+ in the record as before.
1950
+
1951
+ A file too large to seal in one piece is sealed in 1 MiB chunks instead
1952
+ (`file-chunk-v1`): each chunk under the file key with its own nonce, which
1953
+ carries the chunk's number and whether it is the last, so chunks that are
1954
+ reordered, repeated, missing or cut short do not open and nothing is saved.
1955
+ The sealed message says which format a file is in. The listener tells the
1956
+ account's devices that it opens this format, and a sender uses it only for a
1957
+ machine that says so; a listener from before it keeps getting files sealed
1958
+ whole.
1959
+
1960
+ Pushes and files use the envelope above. The live path — stream frames
1961
+ and input, snapshots, history pages, diffs and chat timeline batches —
1962
+ uses envelope v2 instead, sealed for one device key at a time
1963
+ (Defense above).
1964
+
1965
+ ## Requirements
1966
+
1967
+ - **Node.js ^20.17, ^22.13 or >= 23.5** (uses native `fetch`). That is what the dependencies already required (the prompt library asks for it); `package.json` `engines` said `>= 18` until now. CI runs the suite on 20.19, 22.13 and 24 (the test tooling needs 20.19, so 20.17 itself is declared by the dependencies but not exercised).
1968
+ - **tmux** — required for `hwip cc` / `codex` / `gemini` and `hwip listener`.
1969
+ - The `HwipHook` SDK has no runtime dependencies. The CLI depends on
1970
+ `@inquirer/prompts` for the interactive `hwip install` picker, on
1971
+ `ws` for the listener's WebSocket subscription, and on `jsonc-parser`
1972
+ (Microsoft's, the parser VS Code reads its settings with; MIT, no
1973
+ dependencies of its own) to edit agent settings that hold comments.
1974
+
1975
+ ## License
1976
+
1977
+ Apache-2.0
1978
+
1979
+ Contributions are welcome: see [CONTRIBUTING.md](./CONTRIBUTING.md). Report security issues as described in [SECURITY.md](./SECURITY.md).