@hwip/cli 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
package/README.md
ADDED
|
@@ -0,0 +1,1983 @@
|
|
|
1
|
+
# @hwip/cli
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@hwip/cli)
|
|
4
|
+
[](https://www.npmjs.com/package/@hwip/cli)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](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, the listener also sends what it is waiting
|
|
650
|
+
for — the tool, the command or the diff, and the answers — so the app can show it
|
|
651
|
+
and answer. That covers Claude Code (a Bash command, a file edit or a new file),
|
|
652
|
+
Gemini CLI (its tool approvals), OpenCode (its permission request; the listener reads
|
|
653
|
+
the screen with its colours, since only a colour marks the chosen button) and pi
|
|
654
|
+
approval extensions that ask through `ctx.ui.select`. Codex's approval dialog is not
|
|
655
|
+
read yet. It is sealed the same
|
|
656
|
+
way as a chunk, for the same pinned devices, and turned off by the same
|
|
657
|
+
`--no-turn-history`; the server stores ciphertext only. Other dialogs (a plan, a
|
|
658
|
+
question, a trust prompt) send nothing beyond the state. An answer the app sends is
|
|
659
|
+
typed only while that same prompt is still on screen: if you have moved the
|
|
660
|
+
highlight or answered at the keyboard, the command is refused (`prompt_changed`)
|
|
661
|
+
instead of landing on whatever is there now.
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
### Diagnostics
|
|
665
|
+
|
|
666
|
+
The auto-spawned listener writes to three files under `~/.hwip/`:
|
|
667
|
+
|
|
668
|
+
- `listener.pid` — the running daemon's PID. `cat ~/.hwip/listener.pid`
|
|
669
|
+
+ `ps -p <pid>` to confirm it's alive.
|
|
670
|
+
- `listener.version` — the CLI version the daemon booted from. This is
|
|
671
|
+
what `hwip cc` compares against the installed package to spot a stale
|
|
672
|
+
daemon (see below).
|
|
673
|
+
- `listener.log` — stdout + stderr from the daemon. `tail -f` to watch.
|
|
674
|
+
|
|
675
|
+
The daemon logs its version on the first line, which is the only
|
|
676
|
+
reliable way to tell which build a long-running process is on:
|
|
677
|
+
|
|
678
|
+
```
|
|
679
|
+
[xx:xx:xx] hwip listener starting — v1.26.0 — wss://ws.hwip.app
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
A healthy listener log shows one line per cycle:
|
|
683
|
+
|
|
684
|
+
```
|
|
685
|
+
[xx:xx:xx] reported 2 session(s): hwip-myapp, hwip-otherapp
|
|
686
|
+
[xx:xx:xx] ✓ server persisted 2 session(s)
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
If you see `! server rejected listener.sessions: ...` instead, the
|
|
690
|
+
message points at the failure (auth, missing device record, etc.) so
|
|
691
|
+
you can fix the actual problem instead of guessing.
|
|
692
|
+
|
|
693
|
+
To force a restart:
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
hwip listener --restart
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
It gives the new daemon 1.5 s (under launchd, after it has written its pid
|
|
700
|
+
file, up to 5 s in) and exits 1 when that daemon is already gone, with the
|
|
701
|
+
last line it logged — a rejected key, say:
|
|
702
|
+
|
|
703
|
+
```
|
|
704
|
+
hwip listener: the listener stopped right after restarting — hwip listener: API key required. (log: /Users/you/.hwip/listener.log)
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
With the service installed launchd restarts such a daemon on a loop, so
|
|
708
|
+
`hwip cc` makes the same check after asking launchd and prints `hwip: the
|
|
709
|
+
listener stopped right after starting — …` instead of reporting a start.
|
|
710
|
+
|
|
711
|
+
**After upgrading `@hwip/cli` you normally don't have to.** `npm i -g`
|
|
712
|
+
replaces the package on disk but never the daemon already running from
|
|
713
|
+
the old build — that daemon keeps answering pushes (so agent chat looks
|
|
714
|
+
fine) while silently ignoring every message subtype added since it
|
|
715
|
+
booted. `hwip cc` compares `listener.version` against the installed
|
|
716
|
+
version and restarts the daemon for you when the installed one is newer
|
|
717
|
+
(a daemon *newer* than the `hwip cc` you ran is left alone, so several
|
|
718
|
+
installs on one machine don't fight over it):
|
|
719
|
+
|
|
720
|
+
```
|
|
721
|
+
hwip: listener 1.25.0 is stale — restarting on 1.26.0
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
If the PID file is missing (a different account started the daemon, or
|
|
725
|
+
it was removed by hand) the singleton guard can't see it. Find the real
|
|
726
|
+
process instead:
|
|
727
|
+
|
|
728
|
+
```bash
|
|
729
|
+
ps aux | grep '[c]li.js listener'
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
To run it in the foreground (for development of the SDK itself):
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
hwip listener
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
You'll get the same logs you'd otherwise tail from `listener.log`.
|
|
739
|
+
|
|
740
|
+
### Custom tmux sockets
|
|
741
|
+
|
|
742
|
+
The listener auto-discovers the tmux socket — it probes the default
|
|
743
|
+
socket first and stops there when it answers. Otherwise it probes every
|
|
744
|
+
socket file in the per-user `tmux-<uid>/` dirs (`$TMUX_TMPDIR`/`$TMPDIR`,
|
|
745
|
+
macOS `/var/folders/.../T/`, `/tmp/`), which is where `tmux -L <name>`
|
|
746
|
+
puts its socket. Only when none answers does it ask `lsof` which socket
|
|
747
|
+
each running tmux process holds — the slow path, which also covers a
|
|
748
|
+
`-S` path outside those dirs. If your tmux
|
|
749
|
+
uses `tmux -L <name>` or a non-standard `-S <path>`, set the override
|
|
750
|
+
explicitly:
|
|
751
|
+
|
|
752
|
+
```bash
|
|
753
|
+
export HWIP_TMUX_SOCKET=/path/to/socket
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
(The wrapper passes the env to the auto-spawned listener, so setting
|
|
757
|
+
it in your shell rc is enough.)
|
|
758
|
+
|
|
759
|
+
### Wire format
|
|
760
|
+
|
|
761
|
+
The listener only acts on pushes with `type='agent.command'` carrying
|
|
762
|
+
the tmux session name in `agentSessionName` and the message in `body`.
|
|
763
|
+
Other pushes (Stop-hook auto-pushes, `hwip_ask` responses, channel
|
|
764
|
+
broadcasts, plain notes) are ignored. End-to-end:
|
|
765
|
+
|
|
766
|
+
```
|
|
767
|
+
tmux send-keys -l -t <agentSessionName> "<body>"
|
|
768
|
+
tmux send-keys -t <agentSessionName> Enter
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
If you need to send one from the command line (debugging, scripting),
|
|
772
|
+
build the structured push directly:
|
|
773
|
+
|
|
774
|
+
```bash
|
|
775
|
+
curl -X POST "$HWIP_BASE_URL/pushes/send" \
|
|
776
|
+
-H "X-API-Key: $HWIP_API_KEY" \
|
|
777
|
+
-H 'Content-Type: application/json' \
|
|
778
|
+
-d '{
|
|
779
|
+
"type": "agent.command",
|
|
780
|
+
"targetDeviceId": "dev_listener_<sha8(hostname)>",
|
|
781
|
+
"agentSessionName": "hwip-myapp",
|
|
782
|
+
"body": "테스트 통과시키고 PR 올려줘"
|
|
783
|
+
}'
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
The phone also reads this machine's schedules (see [Scheduled sessions](#scheduled-sessions)) over the ephemeral relay, with two more subtypes:
|
|
787
|
+
|
|
788
|
+
```
|
|
789
|
+
{ subtype: 'agent.schedule.list.request', targetDeviceId, requestId, subscriberPublicKey? }
|
|
790
|
+
{ subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`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.
|
|
794
|
+
|
|
795
|
+
### Your API key and redirects
|
|
796
|
+
|
|
797
|
+
Every request that carries your API key refuses to follow a redirect: a host
|
|
798
|
+
that answers with one (to another host, or from https to http) would otherwise
|
|
799
|
+
be handed the key. The call fails with "answered with a redirect … check the
|
|
800
|
+
base URL", so a base URL that redirects has to be corrected rather than
|
|
801
|
+
followed. The key is also taken out of the socket error line and the CLI's final
|
|
802
|
+
error line (`apiKey=***`), and a base URL with credentials written into it
|
|
803
|
+
(`https://user:pass@host`) is refused without being repeated (below).
|
|
804
|
+
|
|
805
|
+
`~/.hwip` is kept at `0700` and `listener.log` at `0600` (it can quote what the
|
|
806
|
+
listener was handed). `~/.hwip/config.json` says where your key is sent, so it is
|
|
807
|
+
checked when read: a file that another user owns or that anyone can write is
|
|
808
|
+
ignored with a warning (`HWIP_API_KEY` and `--key` still work, and nothing is
|
|
809
|
+
saved over it), and one that your group or others can reach is restricted to
|
|
810
|
+
`0600` and used (a symlink, as dotfiles use, is judged by what it points at). A
|
|
811
|
+
`config.json` that cannot be read as a JSON object (a stray comma, half a file, bytes that are not
|
|
812
|
+
UTF-8) is ignored with a warning too, and `hwip install` and `hwip login` stop before they ask for
|
|
813
|
+
anything and name the file: they would otherwise replace it with the new key alone and drop your
|
|
814
|
+
`baseUrl`, `wsUrl` and your other settings. Fix it, or move it aside, and run the command again. Saving is a
|
|
815
|
+
write-then-rename, so a crash leaves the old file or the new one, and a write by another process in
|
|
816
|
+
between is an error, not a lost update.
|
|
817
|
+
|
|
818
|
+
Run with `sudo` where your home folder is kept (macOS), a first `hwip login` or `hwip install` would make
|
|
819
|
+
`~/.hwip`, `config.json` and the device keypair root-owned, and the next plain `hwip` could neither read the key nor save to
|
|
820
|
+
it. What root creates there now goes to the user who ran `sudo` (`SUDO_UID`/`SUDO_GID`) — only inside
|
|
821
|
+
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
|
|
822
|
+
manifest is fetched from the same base URL as everything else: with a `config.json` that cannot be
|
|
823
|
+
read, `hwip` does not fetch it from the default stage but keeps the manifest it has.
|
|
824
|
+
|
|
825
|
+
While a `config.json` is ignored (it does not parse, or anyone can write it) the listener keeps
|
|
826
|
+
running when it has a key from `--key` or `HWIP_API_KEY` (and, for a file that does not parse, a base
|
|
827
|
+
URL from `--base-url` or `HWIP_BASE_URL`), but with every on/off option **off**: `"turnHistory"` and
|
|
828
|
+
`"keepAwake"` are opt-outs, and reading an unreadable file as "nothing set" would turn them back on
|
|
829
|
+
and start uploading the conversations you had switched off. The listener log says so, `hwip verify`
|
|
830
|
+
shows a failed row naming the file, and a restart after fixing the file picks the options up again.
|
|
831
|
+
The login service has no key in its environment, so with such a file it cannot start (exit 3, retried
|
|
832
|
+
every 30 s) and the log names the file. A command that needs the API base URL and cannot read the
|
|
833
|
+
file it may be in (`notify`, `ask`, `trust <deviceId>`, `verify --ping`, `listener`) stops and says so
|
|
834
|
+
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
|
|
835
|
+
key over plain `http://` or `ws://` to a host that is not on this machine draws a
|
|
836
|
+
warning (`hwip verify` shows it as a warning row). A base URL with a user name or password in it
|
|
837
|
+
(`https://user:password@host`) stops those commands as well: no request can be made to such an address, and the
|
|
838
|
+
message says where it came from (`--base-url`, `HWIP_BASE_URL` or the file) without repeating it. Where a command shows which
|
|
839
|
+
key is in use it prints the prefix only (`ak_…`).
|
|
840
|
+
|
|
841
|
+
The key in the file belongs to the addresses the file says (`baseUrl`, else the default API), so it does not
|
|
842
|
+
follow an address from the environment: with that key, an `HWIP_BASE_URL` on another origin stops the command
|
|
843
|
+
(`install` too, before it checks or saves the key against it), naming both origins and no more. Set `HWIP_API_KEY`
|
|
844
|
+
or pass `--key` as well (a key given with the address goes with it), pass `--base-url` (your word on that
|
|
845
|
+
command), or unset the variable; the same origin is always fine. The deprecated `HWIP_WS_URL` is held to the
|
|
846
|
+
same rule: with the file's key it is used only for the default socket's origin, otherwise it is skipped with a line
|
|
847
|
+
saying so — put `wsUrl` in the file to name another socket.
|
|
848
|
+
|
|
849
|
+
### Defense
|
|
850
|
+
|
|
851
|
+
The listener is a remote-code-execution surface by design (it types
|
|
852
|
+
into a shell-adjacent pane). The defense is layered:
|
|
853
|
+
|
|
854
|
+
1. **Pane guard** — before injecting, the listener checks the pinned
|
|
855
|
+
pane's `#{pane_current_command}`. If the pane is at an interactive shell
|
|
856
|
+
(`bash`/`zsh`/`fish`/`sh`/`dash`/`ksh`/`tcsh`/`csh`/`pwsh`), the inject
|
|
857
|
+
is refused. CC/Codex/Gemini exited ≠ phone gets free shell access. The
|
|
858
|
+
one exception is a `hwip sh` session: its `@hwip_shell` session option
|
|
859
|
+
names the session itself. Only a process on this machine can set a tmux
|
|
860
|
+
option, and a value inherited from `set -g` names no session. The marker
|
|
861
|
+
says you opened that shell; it is not a boundary — see what it does not
|
|
862
|
+
stop, below.
|
|
863
|
+
2. **Addressed shells only** — a marked shell takes a REST
|
|
864
|
+
`agent.command` only when its `targetDeviceId` is this machine. An
|
|
865
|
+
agent session still takes an unaddressed one, which `push.new` fans out
|
|
866
|
+
to every listener of the account; for a shell, two PCs with `hwip sh`
|
|
867
|
+
open in the same project would both run it. Stream input already
|
|
868
|
+
arrives on one listener's own subscription.
|
|
869
|
+
3. **Nothing a shell would misread** — into a marked shell the listener
|
|
870
|
+
refuses attachments (their paths would arrive as lines, each a command),
|
|
871
|
+
control bytes such as ESC (which can leave a bracketed paste), and a
|
|
872
|
+
newline in an insert (a shell without bracketed paste, such as macOS
|
|
873
|
+
`/bin/bash` 3.2, would run it). A multi-line body is allowed: each line
|
|
874
|
+
is a command you meant.
|
|
875
|
+
4. **Audit** — every input admitted into a shell session is one line in
|
|
876
|
+
`$XDG_STATE_HOME/hwip/shell-audit.log` (default
|
|
877
|
+
`~/.local/state/hwip/shell-audit.log`, mode 0600; past 1 MiB it moves to
|
|
878
|
+
`shell-audit.log.1`, replacing the one before): time, session, pane,
|
|
879
|
+
kind, foreground program, and the text only when the shell itself was in
|
|
880
|
+
front. Input to anything else — a `sudo` or `ssh` password prompt — is
|
|
881
|
+
recorded as `[N chars → "sudo"]`. `listener.log`, which is not private,
|
|
882
|
+
shows only the length. The foreground is judged when the input lands, so
|
|
883
|
+
a secret read by the shell itself (`read -s`) is logged in full, and a
|
|
884
|
+
command typed inside `sudo -s` or `ssh` is logged by length only.
|
|
885
|
+
5. **Literal injection** — text is delivered as a paste of data, not typed
|
|
886
|
+
key names; tmux escape sequences inside a message can't drive other tmux
|
|
887
|
+
commands.
|
|
888
|
+
6. **Session-name allowlist** — only `[A-Za-z0-9._-]+` is accepted as
|
|
889
|
+
a session target, so shell metacharacters never reach the tmux argv.
|
|
890
|
+
Agents skip a shell: `hwip agent send` (and `hwip send`, its older name) and `hwip_agent_send` (from
|
|
891
|
+
`@hwip/mcp-server` 3.3.0) drop `agentKind: "shell"` sessions from
|
|
892
|
+
their candidates. The check runs on the sending machine only, so an
|
|
893
|
+
older `hwip` on any machine of your account can still type into your
|
|
894
|
+
shell — update every machine that runs agents before you open one.
|
|
895
|
+
7. **Per-session rate limit** — a token bucket caps a runaway or
|
|
896
|
+
compromised sender, shell sessions included.
|
|
897
|
+
8. **Agent permission gate — with one hole.** Your CC/Codex/Gemini
|
|
898
|
+
permission prompt still stands in front of every tool call the agent
|
|
899
|
+
makes. But Claude Code's `!` prefix runs a shell command directly, as
|
|
900
|
+
typed input, with no permission prompt, and text the phone injects is
|
|
901
|
+
typed input: `!<command>` sent to a Claude Code session runs on this
|
|
902
|
+
machine. This is accepted, and it is how `!hwip sh --detach` opens a
|
|
903
|
+
shell from the phone.
|
|
904
|
+
|
|
905
|
+
What the marker does not stop: anything running as your user on this
|
|
906
|
+
machine can set `@hwip_shell` on a session, or run `hwip sh` itself — and
|
|
907
|
+
so can the phone, while a Claude Code session (`!tmux set-option …`) or a
|
|
908
|
+
marked shell is open to it. It keeps the phone from choosing panes only
|
|
909
|
+
when neither is.
|
|
910
|
+
And no layer authenticates the sender: whoever holds your API key, or
|
|
911
|
+
controls the backend, can type into every marked shell and, through `!`,
|
|
912
|
+
every Claude Code session.
|
|
913
|
+
|
|
914
|
+
The transport (WS) is authenticated by API key + `push:read` scope.
|
|
915
|
+
Whether your backend also reads what crosses it depends on the phone
|
|
916
|
+
handing the listener a key:
|
|
917
|
+
|
|
918
|
+
- **No key — an older app.** It has no device keypair to hand the
|
|
919
|
+
listener, so pane frames and the messages you type both cross the relay
|
|
920
|
+
in plaintext. This listener answers that only until it has pinned a
|
|
921
|
+
device (below); after that it refuses a request that asks for plaintext.
|
|
922
|
+
- **A key — the app.** The phone sends its device public key and a
|
|
923
|
+
fresh nonce when it subscribes, and every pane frame comes back
|
|
924
|
+
inside envelope v2: ECDH P-256 between the two device keys, HKDF-SHA256
|
|
925
|
+
with a fresh salt and the message's purpose (`stream.frame`,
|
|
926
|
+
`stream.input`, `stream.alive`, `stream.started`, `screen.snapshot`,
|
|
927
|
+
`screen.history`, `session.diff`, `schedule.list`, `turn.batch`) as the key's info, and
|
|
928
|
+
AES-256-GCM over data that names the purpose, both keys in send →
|
|
929
|
+
receive order and the session. The sealed frame also holds the
|
|
930
|
+
session name, its `seq`/`epoch` and that nonce, so a relay that cannot
|
|
931
|
+
swap in a key of its own (below) can't move one to another stream,
|
|
932
|
+
replay one from an earlier subscription, or hand one back as input —
|
|
933
|
+
or as anything else this listener sealed for the phone. Your
|
|
934
|
+
keystrokes are sealed for this listener as `stream.input` with their
|
|
935
|
+
`seq`/`epoch` stamps, the stream's start epoch and the phone's device
|
|
936
|
+
id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
|
|
937
|
+
says the listener seals v2 (`sealVersion: 2`), sealed for the phone on
|
|
938
|
+
a sealed stream; a phone that has seen it never takes the older
|
|
939
|
+
envelope from this machine again. A frame that fails to encrypt is
|
|
940
|
+
dropped, never downgraded.
|
|
941
|
+
|
|
942
|
+
Two gaps. A message sent while no live stream is open falls
|
|
943
|
+
back to REST, which is plaintext to the server unless the phone has
|
|
944
|
+
sealed with this computer before (then it is sealed, below) — that
|
|
945
|
+
includes every `@<session>` command from the agent chat, and every
|
|
946
|
+
command typed into a `hwip sh` shell. Until this listener has pinned a
|
|
947
|
+
device, a shell's screen crosses the relay in plaintext too, so keep
|
|
948
|
+
secrets off both.
|
|
949
|
+
|
|
950
|
+
**Whose key it seals for.** A viewer asks for sealed content by sending
|
|
951
|
+
its public key, and the relay could put its own there. So the listener
|
|
952
|
+
takes a key — on a stream subscription, a chat timeline watch, a diff or
|
|
953
|
+
a screen snapshot request — only when it is the key the account's device
|
|
954
|
+
list holds for the device the relay says is asking. Any other key gets
|
|
955
|
+
an error (`subscriber_unverified`; `subscriber_key_in_use` for another
|
|
956
|
+
id asking with a key pinned for a different device) and no pane: nothing is read off
|
|
957
|
+
the screen until the key has checked out. The device is pinned in
|
|
958
|
+
`~/.hwip/trusted-devices.json` the first time it is seen — by the listener
|
|
959
|
+
or by a send of this computer, whichever comes first — and a key that
|
|
960
|
+
changed (the app was reinstalled) replaces the pin with one log line,
|
|
961
|
+
`trust: <deviceId> changed its key — now pinned to the new one`, and is
|
|
962
|
+
not refused. The change is also recorded for a person (see Encryption →
|
|
963
|
+
key changes), since nobody reads the daemon log. The app pins the listener's key the same way and stops when
|
|
964
|
+
the device list shows another one.
|
|
965
|
+
|
|
966
|
+
Pins are a record, not a gate: the account's device list decides which
|
|
967
|
+
key to believe, and the pins tell this listener when plaintext is over.
|
|
968
|
+
Nothing needs `hwip trust`; it is there to look at the record:
|
|
969
|
+
|
|
970
|
+
```bash
|
|
971
|
+
hwip trust # this computer's fingerprint, and the pinned devices
|
|
972
|
+
hwip trust <deviceId> # pin the key the account now lists for that device
|
|
973
|
+
hwip trust --reset # forget every pin; each comes back as it is seen again
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
A fingerprint is the first 16 bytes of SHA-256 over the key, as eight
|
|
977
|
+
groups of four hex digits. The app shows the same string for the same
|
|
978
|
+
key on the device's page, and the listener prints its own at startup:
|
|
979
|
+
comparing the two by eye is the check that no key was swapped in
|
|
980
|
+
between. It is the only check for what pinning no longer catches — a
|
|
981
|
+
backend that registers a device of its own, or lists another key for a
|
|
982
|
+
device you know, is followed like a new phone or a reinstalled one.
|
|
983
|
+
|
|
984
|
+
A screen snapshot — the answer to `agent.screen.request` — is sealed
|
|
985
|
+
for the asking device under the same rule.
|
|
986
|
+
|
|
987
|
+
The schedule list — the answer to `agent.schedule.list.request` — follows
|
|
988
|
+
the same rule: sealed for a key that checked out, in the clear only for a
|
|
989
|
+
request without a key while this listener pins no device, and otherwise an
|
|
990
|
+
error code (`e2ee_required`, `subscriber_unverified`, …) and no schedule. Prompts are in
|
|
991
|
+
it, so a request that asked for a seal is never answered in the clear.
|
|
992
|
+
|
|
993
|
+
**Reading a session's changes** (`agent.diff.files.request` and
|
|
994
|
+
`agent.diff.file.request`) is sealed both ways and never answered in the
|
|
995
|
+
clear: a request without a key gets `e2ee_required` whether or not this
|
|
996
|
+
listener pins a device. What
|
|
997
|
+
the request asks — which comparison (the uncommitted changes, or the point
|
|
998
|
+
this branch left the default branch), which file, which page — is inside
|
|
999
|
+
a sealed question (`session.diff`), so the relay does not see the
|
|
1000
|
+
comparison, the path, the page or any content. A request that fails is
|
|
1001
|
+
answered with an error code in the clear (`bad_path`, `no_default_branch`
|
|
1002
|
+
and so on), which the relay can read and nobody has vouched for. The answer
|
|
1003
|
+
is sealed back for the same key and carries the request id, the comparison, the path and the offset
|
|
1004
|
+
inside the seal, which the app checks against what it asked. The file
|
|
1005
|
+
list also carries, inside the seal, the branch the session's repository is on, the name of the ref it
|
|
1006
|
+
follows (`origin/main`, not the remote's URL) and how many commits it is ahead of and behind that ref;
|
|
1007
|
+
a repository with no commit yet leaves that field out, and a `rev-list` that outlasts the request's
|
|
1008
|
+
time budget leaves out the counts; the list is unchanged either way. The
|
|
1009
|
+
listener runs `git` only after the key has checked out and the question
|
|
1010
|
+
opened; the session name picks a directory, the comparison is one of two
|
|
1011
|
+
fixed choices, and a path is resolved inside the repository (an untracked
|
|
1012
|
+
file that `.gitignore` covers is neither listed nor shown; a file git tracks
|
|
1013
|
+
is shown whatever `.gitignore` says). The repository's own configuration is
|
|
1014
|
+
not trusted, and git can run commands it names. Switched off: `core.fsmonitor`,
|
|
1015
|
+
external diff drivers and textconv filters, a repository nested as a submodule
|
|
1016
|
+
(its content is not shown, its config does not run, and a changed submodule
|
|
1017
|
+
pointer is missing from the list), the lazy fetch of a partial clone
|
|
1018
|
+
(`remote.<name>.uploadpack`), and the listener's own `GIT_DIR`, `GIT_WORK_TREE`,
|
|
1019
|
+
`GIT_EXTERNAL_DIFF`, `GIT_ATTR_SOURCE` and `GIT_CONFIG_*`, which are not passed
|
|
1020
|
+
to git. `GIT_NO_LAZY_FETCH` is heard only by a git that knows it (the release
|
|
1021
|
+
notes of git 2.45 introduce `--no-lazy-fetch`; the variable's own first release
|
|
1022
|
+
is unchecked). The `clean` and `process` commands of the filters the config
|
|
1023
|
+
defines are blanked as git is asked to compare the working tree; a driver whose
|
|
1024
|
+
name cannot be blanked, or is named `set`, `unset` or `unspecified`, fails the
|
|
1025
|
+
request. Not closed: the filters are listed by one git run and used by the next,
|
|
1026
|
+
so a config changed in between can still run a filter once.
|
|
1027
|
+
|
|
1028
|
+
A file that a `filter=` attribute names (git-crypt, transcrypt, LFS, global
|
|
1029
|
+
git-lfs included) is listed without a patch, marked like a binary file with no
|
|
1030
|
+
counts: without its filter the working-tree file would read as all added lines
|
|
1031
|
+
in plaintext. The attributes are read from the working tree and from the base
|
|
1032
|
+
revision, and a file counts as filtered if either names a filter; a git without
|
|
1033
|
+
`check-attr --source` (before 2.40) fails the request, and so does an
|
|
1034
|
+
`info/attributes` that unsets `filter` (it outranks both). A patch is asked for
|
|
1035
|
+
one file at a time: a path that is a directory, or that git would diff as more
|
|
1036
|
+
than that one file, is `bad_path`. What stays: a filtered file renamed or copied
|
|
1037
|
+
to a name no filter covers shows that name's plaintext (git does the same); a
|
|
1038
|
+
filtered file that was only touched may be listed as changed, without a patch.
|
|
1039
|
+
Other config that names commands is not reviewed one by one — the listener reads
|
|
1040
|
+
repositories a session already works in. Strings that look like
|
|
1041
|
+
secrets (well-known token formats, `password = …`-style assignments,
|
|
1042
|
+
private key blocks) are masked before sealing, and the answer says how
|
|
1043
|
+
many places were; that is best effort, not a promise.
|
|
1044
|
+
|
|
1045
|
+
**Reading a session's files** (`agent.diff.tree.request` and
|
|
1046
|
+
`agent.diff.blob.request`) rides the same channel and the same seal as the
|
|
1047
|
+
changes above (`session.diff`): sealed both ways, never answered in the clear,
|
|
1048
|
+
the relay sees neither the path nor the listing nor any content, and the
|
|
1049
|
+
answer carries the request id and path (a file page also its offset) inside
|
|
1050
|
+
the seal. The two kinds share a label, so what tells a file answer from a
|
|
1051
|
+
diff answer is the `kind` inside the seal, which both ends check; a
|
|
1052
|
+
`tree` or `blob` request that carries a comparison is refused as
|
|
1053
|
+
`bad_request`. What the phone may see is what git lists: the tracked files and
|
|
1054
|
+
the untracked ones `.gitignore` does not cover. An ignored file, a path
|
|
1055
|
+
that is not there and one outside the repository are all answered `absent`
|
|
1056
|
+
inside the seal, so asking for a name does not tell whether it exists. The
|
|
1057
|
+
content is the working tree, read by the listener itself, one file at a time:
|
|
1058
|
+
a symlink is shown as the text it points to and never followed (a symlink
|
|
1059
|
+
among the directories on the way makes the path `absent`); a submodule or a
|
|
1060
|
+
nested repository is shown as one and never entered; a file that is not a
|
|
1061
|
+
regular file is `absent`; a file with a hard link, one named like a secret
|
|
1062
|
+
(`.env` and `.env.*` except `.env.example` and `.env.sample`, `*.pem`, `*.key`,
|
|
1063
|
+
`*.p12`, `*.pfx`, `*.keystore`, `id_rsa*`, `id_ed25519*`, `credentials*` —
|
|
1064
|
+
fixed in the code, not a setting), and one a `filter=` attribute names are listed but
|
|
1065
|
+
not shown. A file over 1 MiB is `too_large`; one with a NUL in its first 8,000
|
|
1066
|
+
bytes is shown as binary and its size. Strings that look like secrets are
|
|
1067
|
+
masked in the whole file before it is cut into 16 KiB pages, and each page says
|
|
1068
|
+
how many places were masked in the file; that is best effort, not a promise. The
|
|
1069
|
+
only git commands this adds are `ls-files` (twice, for the visible set) and the
|
|
1070
|
+
`check-attr` that finds `filter=`; `git status` is not run (the app lays the
|
|
1071
|
+
changed files of the changes view over the listing). A request that fails is
|
|
1072
|
+
answered with an error code in the clear (`bad_path`, `file_view_off` and so on),
|
|
1073
|
+
which the relay can read and nobody has vouched for.
|
|
1074
|
+
|
|
1075
|
+
A session may read 32 MiB of file text in 10 minutes; past that, file page requests
|
|
1076
|
+
are `rate_limited` until the window ends (listings keep working). Every file view request that opened
|
|
1077
|
+
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,
|
|
1078
|
+
the session, listing or file, the path, the bytes read, the places masked and
|
|
1079
|
+
how it ended, never any content (names are written so a control character in
|
|
1080
|
+
one cannot start or redraw a line). It stays on this computer, nothing is
|
|
1081
|
+
pushed anywhere, and `hwip file-view log` prints the last 50 (the log rotates at 5 MB and keeps one
|
|
1082
|
+
older copy, which `log` reads when the current one has fewer).
|
|
1083
|
+
`hwip file-view off` stops it for the project (the repository root) and
|
|
1084
|
+
`hwip file-view off --global` for every project; the listener answers
|
|
1085
|
+
`file_view_off`. A switch file that cannot be read counts as off, and
|
|
1086
|
+
`HWIP_FILE_VIEW=0` in the listener's environment switches it off for that run. It
|
|
1087
|
+
is on until turned off. Not closed: between the checks and the open a directory
|
|
1088
|
+
on the path could be swapped for a symlink, which the listener looks for again
|
|
1089
|
+
after the open but cannot make atomic on macOS; and two paths that reach the
|
|
1090
|
+
same file without a hard link (a bind mount) are not told apart.
|
|
1091
|
+
|
|
1092
|
+
Once the listener has pinned
|
|
1093
|
+
a device, it stops answering
|
|
1094
|
+
in the clear until the pins are forgotten: a stream start, snapshot request or chat
|
|
1095
|
+
timeline watch without a key gets `e2ee_required` (a keyless stream start
|
|
1096
|
+
is refused before it would restart the sealed stream of that session; a
|
|
1097
|
+
stop is not checked and still ends it), a plain stream already running
|
|
1098
|
+
ends at its next capture with the same error, and the unsolicited
|
|
1099
|
+
snapshots after a REST key press are no longer sent, since nobody asked
|
|
1100
|
+
and there is no key to seal them for. The app, for its part, never subscribes in the clear to a computer
|
|
1101
|
+
it has sealed with.
|
|
1102
|
+
|
|
1103
|
+
What you type in the app's conversation for such a computer is sealed
|
|
1104
|
+
too: the stored command's body is sealed for this computer and the
|
|
1105
|
+
phone, and inside it a label names the session, this machine and a
|
|
1106
|
+
number that only grows per phone. The listener types it only when the
|
|
1107
|
+
phone's key checks out like a stream subscriber's, the label is for this
|
|
1108
|
+
session on this machine, and the number is above the last one it took
|
|
1109
|
+
from that phone — kept in `~/.hwip/trusted-devices.json`, so a command
|
|
1110
|
+
sent again is refused after a restart too, and `hwip trust --reset`
|
|
1111
|
+
keeps the numbers. Beside the per-id numbers the file holds, per sealing
|
|
1112
|
+
key, the largest number taken with it (`commandKeys`, optional; an older
|
|
1113
|
+
hwip that saves the file drops it): a number must be above both, so a
|
|
1114
|
+
command already taken is refused under a new id, after `--reset`, and
|
|
1115
|
+
after the phone changed its key. The key column covers the numbers this
|
|
1116
|
+
version took, plus the id numbers of the devices pinned when `--reset` or
|
|
1117
|
+
`hwip trust <deviceId>` drops or replaces a pin (those are written beside
|
|
1118
|
+
the old key first); a number of an id that was not pinned at that point has
|
|
1119
|
+
no key to go with. A key is told by the x coordinate of its
|
|
1120
|
+
point alone, because a sealed push opens from that coordinate and a relay
|
|
1121
|
+
can make the mirrored key (x, p − y) without any secret. A key already
|
|
1122
|
+
pinned for one device is not taken under a second id
|
|
1123
|
+
(`subscriber_key_in_use`): the id is stamped by the relay, so it could
|
|
1124
|
+
otherwise hand an old command a number of its own. Nothing is forgotten to
|
|
1125
|
+
make room: once 64 senders or 64 keys have numbers, a command from a new
|
|
1126
|
+
one is dropped (the log names the field in `trusted-devices.json`), because
|
|
1127
|
+
a number forgotten is a command that can be typed again. `--reset` does not
|
|
1128
|
+
free those slots, and a device that changes its key under one id fills the
|
|
1129
|
+
key column too (64 changes). The only way out is editing
|
|
1130
|
+
`trusted-devices.json` by hand, and a number deleted there can be typed
|
|
1131
|
+
again. If the
|
|
1132
|
+
same phone really came back under a new id, `hwip trust <deviceId>`
|
|
1133
|
+
pins it after you compare fingerprints. A file sent with it is sealed for this computer and
|
|
1134
|
+
the phone too, and the sealed body names each attachment (by its IV, in
|
|
1135
|
+
order), so the relay can neither drop one nor swap in another. The
|
|
1136
|
+
command is typed only once every named attachment has come down and
|
|
1137
|
+
opened with this machine's key — never half of it. Keys or an insert
|
|
1138
|
+
beside a sealed body, or attachments other than the named ones, refuse it.
|
|
1139
|
+
|
|
1140
|
+
A listener that only seals (it pins a device: see above) does not type a plaintext `agent.command` either: a plain
|
|
1141
|
+
`body`, `insert`, attachment or `keys` is refused whole, and the phone is told
|
|
1142
|
+
`e2ee_required` (a code, never the text). The approve button on the phone
|
|
1143
|
+
seals its keys instead, so it keeps working. A listener with no device list to check against, and one that pins no
|
|
1144
|
+
device, takes plain commands as before; an unreadable
|
|
1145
|
+
`~/.hwip/trusted-devices.json` counts as sealed-only. `hwip trust --reset`
|
|
1146
|
+
forgets the pins, and with them goes back to taking plain commands until a
|
|
1147
|
+
device is pinned again.
|
|
1148
|
+
|
|
1149
|
+
**A schedule request** — the phone making or removing a scheduled session — rides
|
|
1150
|
+
the same `agent.command` push, with a `schedule` field and no `body`. It carries
|
|
1151
|
+
a time, a prompt and, if the phone says so, an agent and a run time — and nothing
|
|
1152
|
+
else. The directory and the agent come from what this machine recorded for the
|
|
1153
|
+
session the push names (the way a resume does), so the phone cannot point a
|
|
1154
|
+
schedule at another directory; it cannot send agent arguments, a worktree or a
|
|
1155
|
+
label either, and a request that carries one is refused rather than ignored
|
|
1156
|
+
(`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
|
|
1157
|
+
session this machine never recorded; `schedule_failed` when this machine's own
|
|
1158
|
+
files would not read or write). A remove names the schedule's own
|
|
1159
|
+
session, so it cannot reach another. Applying a request never types into a
|
|
1160
|
+
session. The request is sealed like any
|
|
1161
|
+
command — the whole of it inside the body, only `op` beside it, and the sender,
|
|
1162
|
+
label and counter checks above apply, so a replay or a swapped `op` changes
|
|
1163
|
+
nothing — and a listener that only seals refuses a plain one (`e2ee_required`).
|
|
1164
|
+
A sealed request refused for `schedule_invalid` or `schedule_limit` is told to
|
|
1165
|
+
the phone as `sealed_rejected`, because those were read from text the relay
|
|
1166
|
+
cannot see; `unknown_session`, `rate_limited` and `schedule_failed` go out as
|
|
1167
|
+
themselves.
|
|
1168
|
+
|
|
1169
|
+
## CLI Usage
|
|
1170
|
+
|
|
1171
|
+
```bash
|
|
1172
|
+
# Send a notification
|
|
1173
|
+
hwip notify --title "Deploy done" --body "v2.1.0 shipped"
|
|
1174
|
+
|
|
1175
|
+
# Send with priority
|
|
1176
|
+
hwip notify --title "Build failed" --priority high --url https://ci.example.com/123
|
|
1177
|
+
|
|
1178
|
+
# List recent pushes
|
|
1179
|
+
hwip list
|
|
1180
|
+
hwip list --limit 10 --type note
|
|
1181
|
+
|
|
1182
|
+
# Dismiss a push
|
|
1183
|
+
hwip dismiss push_01JXY...
|
|
1184
|
+
hwip dismiss --all
|
|
1185
|
+
|
|
1186
|
+
# Rename the current agent session (shows in the app's Agents list)
|
|
1187
|
+
hwip rename "Prod deploy"
|
|
1188
|
+
hwip rename --clear # reset to the default name
|
|
1189
|
+
|
|
1190
|
+
# Type a message into another agent session, here or on another PC
|
|
1191
|
+
hwip agent send dev_listener_ab12cd34:hwip-api "Build is green — rerun the e2e suite"
|
|
1192
|
+
hwip agent send hwip-api "…" # a name/alias works when it names one session
|
|
1193
|
+
hwip agent send hwip-api - <<'EOF' # a long message on stdin; the quoted heredoc expands nothing
|
|
1194
|
+
Build is green. `npm test` passed.
|
|
1195
|
+
EOF
|
|
1196
|
+
|
|
1197
|
+
# See which agent sessions run on THIS PC, and what one did last (this machine only)
|
|
1198
|
+
hwip agent list # KEY AGENT STATE NAME; --json for scripts
|
|
1199
|
+
hwip agent read hwip-api # its last 3 turns: prompt, the agent's text, tool names
|
|
1200
|
+
hwip agent read dev_listener_ab12cd34:hwip-api --turns 5 --json
|
|
1201
|
+
|
|
1202
|
+
# Drop an ended session from this machine's record (it leaves the app's past list)
|
|
1203
|
+
hwip forget hwip-myapp-20260914-PLAN-11-32-44-pi
|
|
1204
|
+
hwip forget --ended # every session tmux no longer has
|
|
1205
|
+
|
|
1206
|
+
# Test connection
|
|
1207
|
+
hwip test
|
|
1208
|
+
|
|
1209
|
+
# Run an agent in a named tmux session (so the listener can reach it)
|
|
1210
|
+
hwip cc # claude
|
|
1211
|
+
hwip codex # codex
|
|
1212
|
+
hwip cursor # cursor-agent (Cursor CLI, not the IDE)
|
|
1213
|
+
hwip gemini # gemini
|
|
1214
|
+
hwip hermes # hermes
|
|
1215
|
+
hwip kimi # kimi (Kimi Code)
|
|
1216
|
+
hwip grok # grok (Grok Build)
|
|
1217
|
+
hwip agy # agy (Antigravity; also `hwip antigravity`)
|
|
1218
|
+
hwip qwen # qwen (Qwen Code)
|
|
1219
|
+
hwip auggie # auggie
|
|
1220
|
+
hwip kilo # kilo
|
|
1221
|
+
hwip copilot # copilot (Copilot CLI)
|
|
1222
|
+
hwip kiro-cli # kiro-cli (Kiro; also `hwip kiro`)
|
|
1223
|
+
hwip droid # droid (Factory Droid)
|
|
1224
|
+
hwip cc --wt feat/x # claude in a new git worktree + branch (../<repo>-wt-feat-x)
|
|
1225
|
+
hwip worktree # the worktrees --wt made, and what keeps each
|
|
1226
|
+
hwip worktree clean --dry-run # what clean would remove (no --dry-run: removes it)
|
|
1227
|
+
|
|
1228
|
+
# Start an agent session on a timer (the listener starts it, so it has to be running)
|
|
1229
|
+
hwip schedule add cc --at 09:00 --days weekdays --prompt "Triage the open issues"
|
|
1230
|
+
hwip schedule add codex --every 2h --max-runtime 30m
|
|
1231
|
+
hwip schedule # the schedules, with the next run and the last result
|
|
1232
|
+
hwip schedule rm k3x9aq # remove one; a session it already started keeps running
|
|
1233
|
+
|
|
1234
|
+
# Run the resident listener (foreground; background it as you like)
|
|
1235
|
+
hwip listener
|
|
1236
|
+
hwip listener --ws-url wss://... # override config
|
|
1237
|
+
|
|
1238
|
+
# JSON output
|
|
1239
|
+
hwip notify --title "Hello" --json
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
### Commands
|
|
1243
|
+
|
|
1244
|
+
| Command | Description |
|
|
1245
|
+
|---------|-------------|
|
|
1246
|
+
| `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 |
|
|
1247
|
+
| `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 |
|
|
1248
|
+
| `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 |
|
|
1249
|
+
| `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) |
|
|
1250
|
+
| `check-update` | Check whether a newer Hwip version is on npm |
|
|
1251
|
+
| `notify` | Send a push notification |
|
|
1252
|
+
| `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 |
|
|
1253
|
+
| `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) |
|
|
1254
|
+
| `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) |
|
|
1255
|
+
| `list` | List recent push notifications. An end-to-end encrypted push reads `[encrypted]` — this host cannot open it |
|
|
1256
|
+
| `dismiss <id>` | Dismiss a push (or `--all`) |
|
|
1257
|
+
| `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 |
|
|
1258
|
+
| `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 |
|
|
1259
|
+
| `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`. |
|
|
1260
|
+
| `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`. |
|
|
1261
|
+
| `send <target> <message…>` | The older name of `agent send`: the same command with the same `[from …]` header; its global flags go before `send`. |
|
|
1262
|
+
| `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 |
|
|
1263
|
+
| `test` | Verify connection and API key |
|
|
1264
|
+
| `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 |
|
|
1265
|
+
| `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. |
|
|
1266
|
+
| `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 |
|
|
1267
|
+
| `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* |
|
|
1268
|
+
| `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) |
|
|
1269
|
+
| `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 |
|
|
1270
|
+
| `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 |
|
|
1271
|
+
|
|
1272
|
+
### Notify Options
|
|
1273
|
+
|
|
1274
|
+
| Flag | Description |
|
|
1275
|
+
|------|-------------|
|
|
1276
|
+
| `--title <text>` | Push title (default: `"Task done"`) |
|
|
1277
|
+
| `--body <text>` | Push body (default: `"<project> · <branch>"` if cwd is a git repo, else `"<project>"`) |
|
|
1278
|
+
| `--url <url>` | URL to include |
|
|
1279
|
+
| `--type <type>` | Push type: `note`, `link`, `file`, `hook` |
|
|
1280
|
+
| `--priority <p>` | Priority: `low`, `normal`, `high`, `urgent` |
|
|
1281
|
+
| `--device <id>` | Target device ID |
|
|
1282
|
+
| `--session <id>` | AI session ID so the push threads into that session's chat (or `HWIP_SESSION_ID` env) |
|
|
1283
|
+
| `--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 |
|
|
1284
|
+
| `--pushmode-default <m>` | Mode `--auto` assumes when the project has no dial: `quiet` (built-in), `normal`, `loud`. A dial the user set always wins |
|
|
1285
|
+
| `--marker <m>` | Push Signal marker for `--auto`: `skip`, `push`, `high` |
|
|
1286
|
+
| `--tools <n>`, `--nonreadonly <n>` | Turn tool counts feeding `--auto`'s heuristic (defaults assume real work) |
|
|
1287
|
+
|
|
1288
|
+
The defaults are tuned for hook-driven invocations (e.g. Stop hooks
|
|
1289
|
+
calling `hwip notify --title "Task done"` without a body) — you'll see
|
|
1290
|
+
which project + branch finished without writing per-IDE wrappers. Pass
|
|
1291
|
+
`--body ""` explicitly to suppress.
|
|
1292
|
+
|
|
1293
|
+
### Listener Options
|
|
1294
|
+
|
|
1295
|
+
| Flag | Description |
|
|
1296
|
+
|------|-------------|
|
|
1297
|
+
| `--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 |
|
|
1298
|
+
| `--key <api-key>` | API key (or set `HWIP_API_KEY` env) |
|
|
1299
|
+
| `--base-url <url>` | REST API base URL (or set `HWIP_BASE_URL` env, or `baseUrl` in `~/.hwip/config.json`) |
|
|
1300
|
+
| `--stop` | Stop the running daemon and clear its PID/version stamps |
|
|
1301
|
+
| `--restart` | Stop it and relaunch detached (logs to `~/.hwip/listener.log`) |
|
|
1302
|
+
| `--no-keep-awake` | Don't hold the Mac awake on AC power (macOS). Lasting form: `"keepAwake": false` in `~/.hwip/config.json` |
|
|
1303
|
+
| `--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` |
|
|
1304
|
+
|
|
1305
|
+
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.
|
|
1306
|
+
|
|
1307
|
+
The listener reconnects with exponential backoff + jitter (1 s → 30 s
|
|
1308
|
+
cap). Keeping the link alive costs the backend a message each way, so it
|
|
1309
|
+
is quiet on purpose: the report heartbeat (every 2 minutes) is itself the
|
|
1310
|
+
proof of life, and a `ping` goes out only when nothing was sent for 4
|
|
1311
|
+
minutes (API Gateway closes a WebSocket idle for 10 minutes). Two things
|
|
1312
|
+
tell a dead socket from a quiet one. A report the server does not answer
|
|
1313
|
+
(ack or error) within 30 s drops the socket and the new connection
|
|
1314
|
+
reports again; and 300 s of total silence — any inbound frame counts, ack
|
|
1315
|
+
or pong — drops it too, which catches a link with nothing to send. The
|
|
1316
|
+
silence limit is waived while the daemon's own event loop is blocked,
|
|
1317
|
+
because a thread that is not listening cannot tell a dead socket from its
|
|
1318
|
+
own deafness; 360 s of wall-clock silence terminates the socket either
|
|
1319
|
+
way. On an
|
|
1320
|
+
authentication failure close (4001/4002/4003) the listener exits with
|
|
1321
|
+
code 3 instead of looping forever — fix the key and restart.
|
|
1322
|
+
|
|
1323
|
+
**Pattern watches and detection rules** run on the daemon's one thread, so
|
|
1324
|
+
they use RE2 ([re2js](https://github.com/le0pard/re2js)) rather than the
|
|
1325
|
+
JavaScript engine: matching time is linear in the pane text (the last
|
|
1326
|
+
8 KiB), where a backtracking engine freezes every stream on `(a+)+$` or
|
|
1327
|
+
`.*.*.*x`. RE2's time also grows with the compiled pattern, so a pattern
|
|
1328
|
+
that compiles too large — big counted repeats like `\w{1,1000}` — is
|
|
1329
|
+
refused. RE2 has no lookaround and no backreferences, its `\s`, `\w`, `\d`
|
|
1330
|
+
are ASCII-only (a non-breaking space is not `\s`), and `$` does not match
|
|
1331
|
+
before `\r`. A pattern it cannot compile never matches; for a watch the log
|
|
1332
|
+
says `! watch on <session> will never fire: its pattern does not compile (…)`
|
|
1333
|
+
once.
|
|
1334
|
+
|
|
1335
|
+
**Local transfer.** Once its device keys are loaded the listener also opens a
|
|
1336
|
+
small HTTP server on an OS-assigned port and publishes `host:port` (private
|
|
1337
|
+
IPv4 only) on its device record, so a sender on the same Wi-Fi can hand a
|
|
1338
|
+
file straight to this machine instead of going through the cloud. The log
|
|
1339
|
+
says `local transfer: listening on 0.0.0.0:<port>`. Each remote address may hold at most 8
|
|
1340
|
+
open connections to it, so one device on the network cannot use them all up. If the macOS firewall is
|
|
1341
|
+
on, the first bind may ask *"Do you want the application node to accept
|
|
1342
|
+
incoming network connections?"* — declining is safe; files simply keep
|
|
1343
|
+
taking the relay. A file sent this way may be up to 16 GiB — no plan limit
|
|
1344
|
+
applies, the bytes never reach the cloud — as long as the disk keeps 1 GiB
|
|
1345
|
+
free after it. With several addresses it prefers a physical interface
|
|
1346
|
+
(`en*`, `eth*`, `wlan*`) over a VPN or virtual one (`utun*`, `tailscale*`,
|
|
1347
|
+
`docker*`, `bridge*`, …), and within one kind an RFC 1918 address over a
|
|
1348
|
+
link-local or CGNAT (`100.64/10`) one.
|
|
1349
|
+
|
|
1350
|
+
**Receiving files.** A file push addressed to this machine (the MCP `hwip_file`
|
|
1351
|
+
tool, the phone's share sheet) is saved to `~/Downloads/Hwip/`
|
|
1352
|
+
under its own name — `name (2).ext` when that name is taken, never
|
|
1353
|
+
overwritten, never cleaned up — and a desktop banner (`Hwip · <file>` over
|
|
1354
|
+
the push's title and the folder, e.g. `[myproject] shot.png · ~/Downloads/Hwip`)
|
|
1355
|
+
says where it landed. The name in the banner is the one it was saved under, so
|
|
1356
|
+
a `(2)` shows up there. With [`terminal-notifier`](https://github.com/julienXX/terminal-notifier)
|
|
1357
|
+
installed (`brew install terminal-notifier`), clicking the banner reveals the
|
|
1358
|
+
file in Finder; the plain macOS banner has no click action. Encrypted
|
|
1359
|
+
files are opened with this machine's own device key on the way down; nothing
|
|
1360
|
+
is buffered, so a 1 GB recording streams straight to disk. A sealed-only
|
|
1361
|
+
listener saves only files sealed for this machine: a plain one is logged and
|
|
1362
|
+
skipped, since anyone who can write a push could have chosen its bytes and
|
|
1363
|
+
name. Bytes are fetched only from an `https` S3 address (a loopback one when
|
|
1364
|
+
the base URL is loopback, for the local server), with redirects refused, and
|
|
1365
|
+
a download stops past 1.25 GiB — the 1 GiB plan limit plus the padding and
|
|
1366
|
+
tag a sealed file carries. `agent.command`
|
|
1367
|
+
attachments are unaffected: they still go to `~/.hwip/attachments/<pushId>/`
|
|
1368
|
+
and are swept after 24 h. A download is written to a hidden
|
|
1369
|
+
`.hwip-<id>.part` beside its final name and moved into place once
|
|
1370
|
+
complete; one a crash left behind is removed by the hourly sweep once it
|
|
1371
|
+
is an hour old.
|
|
1372
|
+
A download that sends no response, or whose link request hangs, is given
|
|
1373
|
+
up on instead of holding the push open.
|
|
1374
|
+
|
|
1375
|
+
A sender on the same network can skip the cloud entirely: it pings this
|
|
1376
|
+
machine's endpoint, then uploads the encrypted file straight to it. Every
|
|
1377
|
+
request is authenticated with a key only a device registered on your account
|
|
1378
|
+
can derive (ECDH with this machine's device key), so an unknown sender — or a
|
|
1379
|
+
replayed or tampered upload — gets an empty 401 and nothing is written. File
|
|
1380
|
+
names and the recipient list travel sealed too; someone sniffing the Wi-Fi
|
|
1381
|
+
sees a device id and a transfer id, nothing else. The sender counts a file
|
|
1382
|
+
delivered only on a receipt this machine alone can compute, so a host that
|
|
1383
|
+
merely answers on this machine's address cannot swallow it — the file goes via
|
|
1384
|
+
the cloud instead. A device removed from the
|
|
1385
|
+
account is refused within a minute. The file waits in `~/.hwip/attachments/lan/`
|
|
1386
|
+
until its push record arrives, then moves to `~/Downloads/Hwip/` like any
|
|
1387
|
+
other; a transfer whose record never comes is swept after 24 h. Two uploads at
|
|
1388
|
+
a time, 1 GB each at most — whatever the plan, since these bytes never reach
|
|
1389
|
+
the cloud.
|
|
1390
|
+
|
|
1391
|
+
### List Options
|
|
1392
|
+
|
|
1393
|
+
| Flag | Description |
|
|
1394
|
+
|------|-------------|
|
|
1395
|
+
| `--limit <n>` | Number of pushes (1-20, default 5) |
|
|
1396
|
+
| `--type <type>` | Filter by push type |
|
|
1397
|
+
|
|
1398
|
+
### Global Options
|
|
1399
|
+
|
|
1400
|
+
A flag that takes a value reads it from the next word or after `=`
|
|
1401
|
+
(`--title=Foo`). A flag that takes a value takes the next word whatever it
|
|
1402
|
+
looks like — `--body "--base-url=http://x"` is a body, not a base URL — so text
|
|
1403
|
+
nobody typed (a command to approve, a model's message) cannot set a flag of
|
|
1404
|
+
hwip's (only an exact `--help`, `-h` or `--` keeps its meaning there); the
|
|
1405
|
+
`--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"`)
|
|
1406
|
+
stays a value, and `--` ends hwip's flags: every word after it is read as a
|
|
1407
|
+
word, not a flag. After an agent subcommand (`hwip cc …`) the words go to
|
|
1408
|
+
the agent untouched. A flag the command does not read (`--titel`) is
|
|
1409
|
+
ignored with a one-line warning on stderr.
|
|
1410
|
+
|
|
1411
|
+
| Flag | Description |
|
|
1412
|
+
|------|-------------|
|
|
1413
|
+
| `--key <api-key>` | API key (or set `HWIP_API_KEY` env) |
|
|
1414
|
+
| `--base-url <url>` | API base URL (or set `HWIP_BASE_URL` env) |
|
|
1415
|
+
| `--json` | Output JSON format |
|
|
1416
|
+
| `--version` | Print version |
|
|
1417
|
+
| `--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 |
|
|
1418
|
+
|
|
1419
|
+
### Mute & push mode
|
|
1420
|
+
|
|
1421
|
+
Both live as state files under `${XDG_STATE_HOME:-~/.local/state}/hwip`,
|
|
1422
|
+
keyed by a `cksum` hash of the project directory. Claude Code's
|
|
1423
|
+
`/hwip-mode mute` / `/hwip-mode quiet` / `/hwip-mode loud` / `/hwip-mode normal` write them;
|
|
1424
|
+
the CLI reads them (mute on every `notify`, push mode on `--auto`).
|
|
1425
|
+
|
|
1426
|
+
Notifications are silently skipped when a mute file exists for the
|
|
1427
|
+
current project:
|
|
1428
|
+
|
|
1429
|
+
```bash
|
|
1430
|
+
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hwip"
|
|
1431
|
+
HASH=$(printf '%s' "$PROJECT_DIR" | cksum | cut -d' ' -f1)
|
|
1432
|
+
|
|
1433
|
+
# Mute (created by /hwip-mode mute in the Claude Code plugin)
|
|
1434
|
+
mkdir -p "$STATE_DIR" && touch "$STATE_DIR/muted-$HASH"
|
|
1435
|
+
|
|
1436
|
+
# Unmute
|
|
1437
|
+
rm -f "$STATE_DIR/muted-$HASH"
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
Push mode is a one-word file (`quiet` / `loud` / `normal`) resolved in
|
|
1441
|
+
this order — first hit wins:
|
|
1442
|
+
|
|
1443
|
+
| Order | File | Set by |
|
|
1444
|
+
|-------|------|--------|
|
|
1445
|
+
| 1 | `$STATE_DIR/pushmode-<hash>` | `/hwip-mode quiet` · `/hwip-mode loud` · `/hwip-mode normal` |
|
|
1446
|
+
| 2 | `/tmp/hwip-pushmode-<hash>` | older versions (honored only when you own the file) |
|
|
1447
|
+
| 3 | `$STATE_DIR/pushmode-default` | the `--global` form of any dial — the machine-wide default |
|
|
1448
|
+
| 4 | `--pushmode-default <mode>` | the calling hook (the installed ones pass `normal`) |
|
|
1449
|
+
| 5 | *(nothing above)* | `quiet` |
|
|
1450
|
+
|
|
1451
|
+
**Row 5 changed**: an install with no dial anywhere used to be `normal`.
|
|
1452
|
+
It is now `quiet`, so upgrading turns the routine per-turn push off until
|
|
1453
|
+
you run `/hwip-mode normal`. Row 4 is why the hooks this CLI installs are
|
|
1454
|
+
unaffected: they name `normal` themselves, since a hook that supplies no
|
|
1455
|
+
`high` marker would be silent under `quiet` whenever you're at the terminal,
|
|
1456
|
+
rather than merely quieter. Row 4 sits *below* the state files on purpose — the flag
|
|
1457
|
+
names a default, it does not override a dial the user set.
|
|
1458
|
+
|
|
1459
|
+
Under `normal` the JSON hook configs push on every turn, because they see
|
|
1460
|
+
no per-tool events and so pass no counts — the gate then assumes real
|
|
1461
|
+
work. The Pi extension and the OpenCode plugin do see those events, so
|
|
1462
|
+
they pass real `--tools` / `--nonreadonly` counts and go quiet where the
|
|
1463
|
+
others cannot: a turn with no tool calls, a turn with exactly one (a
|
|
1464
|
+
lone edit included — the gate wants two), and a turn whose calls were
|
|
1465
|
+
all reads. `/hwip-mode loud` still pushes on all of them.
|
|
1466
|
+
|
|
1467
|
+
The Pi extension also pushes when pi is **waiting on you**: any blocking
|
|
1468
|
+
extension dialog — a bash guard's Run/Abort, an ask-user tool, a
|
|
1469
|
+
`ctx.ui.confirm` — that is still open 10 seconds after it appeared sends a
|
|
1470
|
+
`high` push ("pi asks: <project>", body = the dialog title or the guarded
|
|
1471
|
+
bash command). `high` gets through `quiet`; only `/hwip-mode mute` stops it.
|
|
1472
|
+
Answering inside the 10 seconds sends nothing. It is the pi twin of the
|
|
1473
|
+
Claude Code plugin's AskUserQuestion push, driven by pi's
|
|
1474
|
+
`ui_prompt_start` / `ui_prompt_end` events. Only dialogs that open while a
|
|
1475
|
+
turn is running count: a settings picker you open yourself between turns
|
|
1476
|
+
(`/caveman config`) never pushes.
|
|
1477
|
+
|
|
1478
|
+
Two more end-of-turn cases differ from Claude Code:
|
|
1479
|
+
|
|
1480
|
+
- **Push Signal markers** — pi's rules teach the same `<!-- hwip: skip|push|high -->`
|
|
1481
|
+
markers, and the extension reads them from the final assistant message
|
|
1482
|
+
(then strips them, since pi's terminal would otherwise print the comment).
|
|
1483
|
+
`high` is how pi gets a "the turn ended with a question for you" push
|
|
1484
|
+
through `quiet`.
|
|
1485
|
+
- **Errors and aborts** — a turn that ended on a provider error sends a
|
|
1486
|
+
`high` "pi stopped: <project>" push with the error text instead of
|
|
1487
|
+
"Task done"; a turn you aborted with Esc sends nothing.
|
|
1488
|
+
|
|
1489
|
+
#### Quiet while away
|
|
1490
|
+
|
|
1491
|
+
`quiet` silences routine pushes because you are watching the pane. When
|
|
1492
|
+
you've walked away, nobody is, so `notify --auto` under `quiet` still sends
|
|
1493
|
+
the push (normal priority) once it decides you're away. It reads the machine
|
|
1494
|
+
with three signals (the first that answers wins), then asks about this
|
|
1495
|
+
session's own pane:
|
|
1496
|
+
|
|
1497
|
+
1. Inside tmux and **no client attached to the tmux server** → away.
|
|
1498
|
+
2. Not over SSH and macOS `ioreg` reports **`HIDIdleTime`** (system-wide
|
|
1499
|
+
input idle) → that alone decides. Inside tmux, "over SSH" follows the
|
|
1500
|
+
session environment tmux refreshes on each attach, not the value frozen
|
|
1501
|
+
at server start.
|
|
1502
|
+
3. Inside tmux → the newest tmux **`client_activity`** (SSH and non-macOS).
|
|
1503
|
+
4. Beyond the machine: input and a tmux client say you are at the desk, not
|
|
1504
|
+
which tmux session you are looking at. Inside tmux the hook also asks about
|
|
1505
|
+
its own pane (`$TMUX_PANE`): a session with no client attached, in a window
|
|
1506
|
+
that is not showing, or under another pane's zoom counts as away even while
|
|
1507
|
+
you type elsewhere. A tmux that will not answer, a missing `$TMUX_PANE` and
|
|
1508
|
+
any control-mode client (iTerm2 `tmux -CC`) leave the answer above alone.
|
|
1509
|
+
|
|
1510
|
+
Anything unreadable counts as present. The threshold is `HWIP_AWAY_SEC`
|
|
1511
|
+
seconds in the hook's environment (default `300`, `0` turns detection off —
|
|
1512
|
+
the pane check included — and a non-number falls back to the default). The probe runs only on a
|
|
1513
|
+
quiet turn with no `high` marker. The Claude Code plugin's Stop hook
|
|
1514
|
+
applies the same rule (`hwip_is_away` in its `hooks/gate.sh`).
|
|
1515
|
+
|
|
1516
|
+
A dial file that exists but reads empty resolves to `normal`, not to row
|
|
1517
|
+
5: an empty file is a failed write, and resolving breakage to silence
|
|
1518
|
+
leaves no symptom to debug.
|
|
1519
|
+
|
|
1520
|
+
Mute has no `-default` form on purpose: it is keyed on presence, not
|
|
1521
|
+
content, so a global mute could never be lifted for a single project.
|
|
1522
|
+
|
|
1523
|
+
Legacy `/tmp/hwip-muted-<hash>` files are still honored when owned by the
|
|
1524
|
+
current user (the state dir moved out of world-writable `/tmp`).
|
|
1525
|
+
|
|
1526
|
+
The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`,
|
|
1527
|
+
`WINDSURF_PROJECT_DIR`, and falls back to `cwd`.
|
|
1528
|
+
|
|
1529
|
+
### Exit Codes
|
|
1530
|
+
|
|
1531
|
+
| Code | Meaning |
|
|
1532
|
+
|------|---------|
|
|
1533
|
+
| 0 | Success |
|
|
1534
|
+
| 1 | General error — also: `ask` got no answer, `verify` has a failed check, `install` has a step marked `-`, `uninstall` answered no |
|
|
1535
|
+
| 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 |
|
|
1536
|
+
| 3 | Authentication failed, or no API key (also: listener auth close 4001/4002/4003, `install` with a key the API rejects) |
|
|
1537
|
+
| 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 |
|
|
1538
|
+
|
|
1539
|
+
### Environment Variables
|
|
1540
|
+
|
|
1541
|
+
| Variable | Description |
|
|
1542
|
+
|----------|-------------|
|
|
1543
|
+
| `HWIP_API_KEY` | API key (fallback when `--key` not provided) |
|
|
1544
|
+
| `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) |
|
|
1545
|
+
| `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 |
|
|
1546
|
+
| `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>`) |
|
|
1547
|
+
| `HWIP_SESSION_ID` | AI session ID (fallback when `--session` not provided) |
|
|
1548
|
+
| `HWIP_ASK_CONTEXT` | `0`, `false`, `off` or `no` switches off, for that run, the context a sealed question may carry (see `ask-context`) |
|
|
1549
|
+
| `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`) |
|
|
1550
|
+
| `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) |
|
|
1551
|
+
| `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 |
|
|
1552
|
+
|
|
1553
|
+
## SDK Usage
|
|
1554
|
+
|
|
1555
|
+
```typescript
|
|
1556
|
+
import { HwipHook } from '@hwip/cli';
|
|
1557
|
+
|
|
1558
|
+
const hook = new HwipHook({ apiKey: 'ak_...' });
|
|
1559
|
+
|
|
1560
|
+
// Notify
|
|
1561
|
+
const result = await hook.notify({
|
|
1562
|
+
title: 'Build Complete',
|
|
1563
|
+
body: 'Deploy succeeded',
|
|
1564
|
+
url: 'https://example.com/deploy/123',
|
|
1565
|
+
priority: 'high',
|
|
1566
|
+
});
|
|
1567
|
+
console.log(result.pushId); // 'push_01JXY...'
|
|
1568
|
+
|
|
1569
|
+
// List
|
|
1570
|
+
const list = await hook.list({ limit: 5 });
|
|
1571
|
+
console.log(list.pushes);
|
|
1572
|
+
|
|
1573
|
+
// Dismiss
|
|
1574
|
+
await hook.dismiss('push_01JXY...');
|
|
1575
|
+
await hook.dismissAll();
|
|
1576
|
+
```
|
|
1577
|
+
|
|
1578
|
+
### Constructor Options
|
|
1579
|
+
|
|
1580
|
+
| Field | Type | Description |
|
|
1581
|
+
|-------|------|-------------|
|
|
1582
|
+
| `apiKey` | `string` | Required — API key from Hwip settings |
|
|
1583
|
+
| `baseUrl` | `string?` | API base URL (default: `https://api.hwip.app/v1`) |
|
|
1584
|
+
| `timeout` | `number?` | Request timeout in ms (default: 30000) |
|
|
1585
|
+
|
|
1586
|
+
### Last push failure
|
|
1587
|
+
|
|
1588
|
+
Every installed hook runs `hwip notify … 2>/dev/null || true`, so a failed
|
|
1589
|
+
push leaves nothing on screen. `hwip notify` (and `hwip test`, and the test
|
|
1590
|
+
push `hwip install` sends) therefore records the last failure
|
|
1591
|
+
itself and deletes the record once a push goes through — the last attempt to
|
|
1592
|
+
finish wins when hooks race:
|
|
1593
|
+
|
|
1594
|
+
```
|
|
1595
|
+
${XDG_STATE_HOME:-~/.local/state}/hwip/last-notify-failure.json
|
|
1596
|
+
{"v":1,"at":"2026-10-02T00:00:00.000Z","kind":"auth","status":401,"code":"AUTHENTICATION_ERROR"}
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
`kind` is one of `no-key`, `auth`, `quota`, `rate-limit`, `client`,
|
|
1600
|
+
`server`, `timeout`, `network`, `no-app`, `unsealed`; `status` is the HTTP
|
|
1601
|
+
status and `code` the server's error code, both `null` when there was no HTTP
|
|
1602
|
+
answer. A push is never sent in the clear (see Encryption): `no-app`
|
|
1603
|
+
(`NO_APP_DEVICE`, no app is signed in to the account) and `unsealed`
|
|
1604
|
+
(`E2E_REQUIRED`, the push could not be sealed) are refusals made on this
|
|
1605
|
+
computer before anything was sent, so their `status` is `null` and `code`
|
|
1606
|
+
names them. A device whose key changed is not a failure and
|
|
1607
|
+
is not recorded here (see Encryption → key changes). No message
|
|
1608
|
+
text, key or push content is stored. `hwip verify` shows it under
|
|
1609
|
+
**Push delivery**, and the Claude Code plugin shows it in `/hwip-mode` and
|
|
1610
|
+
at session start. The format is a contract with the plugin —
|
|
1611
|
+
`src/notify-failure.ts` is the source of truth and its test pins the line
|
|
1612
|
+
above.
|
|
1613
|
+
|
|
1614
|
+
### Notify Payload
|
|
1615
|
+
|
|
1616
|
+
| Field | Type | Description |
|
|
1617
|
+
|-------|------|-------------|
|
|
1618
|
+
| `title` | `string?` | Push title |
|
|
1619
|
+
| `body` | `string?` | Push body |
|
|
1620
|
+
| `url` | `string?` | URL to include |
|
|
1621
|
+
| `type` | `'note' \| 'link' \| 'file' \| 'hook'?` | Push type (default: `hook`) |
|
|
1622
|
+
| `priority` | `'low' \| 'normal' \| 'high' \| 'urgent'?` | Priority (default: `normal`) |
|
|
1623
|
+
| `targetDeviceId` | `string?` | Send to specific device |
|
|
1624
|
+
|
|
1625
|
+
### Error Handling
|
|
1626
|
+
|
|
1627
|
+
```typescript
|
|
1628
|
+
import { HwipHook, AuthenticationError, QuotaExceededError, HwipError } from '@hwip/cli';
|
|
1629
|
+
|
|
1630
|
+
try {
|
|
1631
|
+
await hook.notify({ title: 'Hello' });
|
|
1632
|
+
} catch (err) {
|
|
1633
|
+
if (err instanceof AuthenticationError) { /* Invalid API key */ }
|
|
1634
|
+
if (err instanceof QuotaExceededError) { /* Monthly limit reached */ }
|
|
1635
|
+
if (err instanceof HwipError) { /* Other API error */ }
|
|
1636
|
+
}
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
## Supported Agents
|
|
1640
|
+
|
|
1641
|
+
`hwip install` detects and configures these agents automatically:
|
|
1642
|
+
|
|
1643
|
+
| Agent | What gets installed |
|
|
1644
|
+
|-------|-------------------|
|
|
1645
|
+
| Claude Code | Plugin (hooks + MCP server) |
|
|
1646
|
+
| Cursor | MCP server + stop hook + rules |
|
|
1647
|
+
| Windsurf | MCP server + response hook + rules |
|
|
1648
|
+
| Gemini CLI | MCP server + AfterAgent hook |
|
|
1649
|
+
| Codex CLI | MCP server + prompt/stop hooks + rules |
|
|
1650
|
+
| Copilot CLI | Session end hook + rules |
|
|
1651
|
+
| Cline | Rules file (`~/.cline/rules/hwip.md`) |
|
|
1652
|
+
| Aider | Conventions file + `read:` directive in `~/.aider.conf.yml` |
|
|
1653
|
+
| Pi | Extension (settle hook + waiting-on-you push + remote detection) + rules — no MCP (pi has none; rules map hwip tools to the CLI) |
|
|
1654
|
+
| 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 |
|
|
1655
|
+
|
|
1656
|
+
Settings files the agents share with you — Cursor `mcp.json` and `hooks.json`,
|
|
1657
|
+
Windsurf `mcp_config.json` and `hooks.json`, Gemini `settings.json`, Codex
|
|
1658
|
+
`hooks.json`, OpenCode `opencode.json` — are merged: your own entries stay, and
|
|
1659
|
+
only hwip's are added or replaced. Comments and trailing commas (JSONC) are
|
|
1660
|
+
read like the agents read them and are kept: hwip's entry is added after yours,
|
|
1661
|
+
on its own lines, and the rest of the file is left as it was (when your file
|
|
1662
|
+
is written compactly, on one line, the part around hwip's entry is laid out
|
|
1663
|
+
again). A comment sitting right next to one of hwip's own entries goes when that
|
|
1664
|
+
entry does, and `hwip uninstall` keeps a hooks file that holds a comment even
|
|
1665
|
+
when only hwip's entries were in it. `hwip uninstall` takes out only hwip's entries, and deletes a hooks file
|
|
1666
|
+
only when nothing else was in it. A file with a syntax error, or whose `hooks`
|
|
1667
|
+
is not an object of lists, or whose comments an edit could not keep (an
|
|
1668
|
+
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
|
|
1669
|
+
to add by hand. Uninstall marks such a hooks file, and any step it could not
|
|
1670
|
+
finish, with `!` and names it. Every write but one goes through a temp file
|
|
1671
|
+
and a rename, so a full disk or a killed install leaves the old file whole; a
|
|
1672
|
+
symlinked settings file stays a link. A file you made read-only is left as it is and
|
|
1673
|
+
named, not replaced (unless hwip runs as root, which can write any file); a writable
|
|
1674
|
+
file in a directory that takes no new files is written in place instead — not atomic,
|
|
1675
|
+
so a killed install can cut it off; its old bytes are put back if the write fails —
|
|
1676
|
+
and a file that is not there yet in such a directory is named and not created.
|
|
1677
|
+
A file that is not valid UTF-8 text is left as it is and named (reading it as
|
|
1678
|
+
text would turn its bytes into U+FFFD). A file someone else writes while hwip is
|
|
1679
|
+
editing it is left as that writer made it: the step stops, names the file and says
|
|
1680
|
+
to run the command again, instead of replacing their change. Uninstall of a hooks
|
|
1681
|
+
file or rule file that is a symlink (a dotfiles repo) takes hwip's part out of what
|
|
1682
|
+
the link points at and keeps the link, so nothing of hwip's comes back with it.
|
|
1683
|
+
|
|
1684
|
+
For remote-control via `hwip listener` the per-agent setup is the same
|
|
1685
|
+
across CC/Codex/Gemini — the wrapper just spawns them in a named tmux
|
|
1686
|
+
session.
|
|
1687
|
+
|
|
1688
|
+
## Encryption
|
|
1689
|
+
|
|
1690
|
+
End-to-end encryption is **always on**: there is no switch and nothing to
|
|
1691
|
+
configure. A push's title, body and URL, a question, an answer and a message to
|
|
1692
|
+
another computer leave this host as ciphertext, on every plan.
|
|
1693
|
+
The one exception is a push to a channel's subscribers (`--channel`), which the
|
|
1694
|
+
server fans out as plain text.
|
|
1695
|
+
|
|
1696
|
+
A push needs an app to read it. When no iPhone or Android app is signed in to
|
|
1697
|
+
the account, `hwip notify` and `hwip ask` send nothing and say so —
|
|
1698
|
+
`no app is signed in to this account: sign in to the Hwip app on an iPhone or
|
|
1699
|
+
Android phone, then send again` (`NO_APP_DEVICE`). When an app is signed in but
|
|
1700
|
+
this send cannot be sealed — the device list does not load, this computer's
|
|
1701
|
+
keys do not, no device lists a key, encryption throws — the push is refused with
|
|
1702
|
+
`E2E_REQUIRED` and the reason. The push-delivery file below records the two as
|
|
1703
|
+
`no-app` and `unsealed`. There is no plaintext fallback, no line on stderr about one, and
|
|
1704
|
+
nothing to turn off.
|
|
1705
|
+
|
|
1706
|
+
Push bodies and long-body attachments are encrypted with
|
|
1707
|
+
AES-256-GCM. This host holds its own ECDH P-256 keypair in
|
|
1708
|
+
`~/.hwip/device-keys.json` — generated on first use, and the private
|
|
1709
|
+
half never leaves the machine; the backend stores public keys only and
|
|
1710
|
+
rejects a private-key upload. Each push is encrypted once, and its AES
|
|
1711
|
+
key is wrapped separately for every device the account lists with a key
|
|
1712
|
+
(`GET /devices`), using ECDH against that device's public key. The title and
|
|
1713
|
+
URL move inside the ciphertext with the body. Every listed device is pinned in
|
|
1714
|
+
`~/.hwip/trusted-devices.json` the first time it is seen; a device that lists
|
|
1715
|
+
another key than the one pinned gets the new key — the pin is replaced, one line
|
|
1716
|
+
on stderr says `hwip: trust: <deviceId> changed its key — now pinned to the new
|
|
1717
|
+
one` (`… could not record the new pin` when the trust file could not be
|
|
1718
|
+
written), and the change is recorded for a person. Nothing is refused for it.
|
|
1719
|
+
|
|
1720
|
+
**Key changes are shown once, wherever they happened.** That stderr line often
|
|
1721
|
+
reaches nobody — a hook throws it away, the listener writes it to its log, the
|
|
1722
|
+
MCP server's stderr is not on any screen — and once the pin holds the new key
|
|
1723
|
+
no one sees the change again. So whoever replaces a pin (`hwip notify`,
|
|
1724
|
+
`hwip ask`, `hwip agent send`, the listener, the MCP server) also writes it to
|
|
1725
|
+
`~/.hwip/key-changes.json`, under the trust file's lock:
|
|
1726
|
+
|
|
1727
|
+
```
|
|
1728
|
+
{"v":1,"changes":[{"deviceId":"dev_ios_1","at":"2026-10-13T09:00:00.000Z","name":"iPhone"}]}
|
|
1729
|
+
```
|
|
1730
|
+
|
|
1731
|
+
One entry per device, at most 64. The next surface a person reads shows each one
|
|
1732
|
+
once and deletes the file: `hwip verify`, `hwip trust`, `hwip test`, and
|
|
1733
|
+
`hwip notify` / `hwip ask` when stderr is a terminal print
|
|
1734
|
+
`hwip: <name> changed its encryption key — the new key is trusted. Not expecting
|
|
1735
|
+
that (app reinstalled?) Check it: hwip trust`; the Claude Code plugin shows it at
|
|
1736
|
+
session start and in `/hwip-mode`, the MCP server at the end of a tool result.
|
|
1737
|
+
A hook's `hwip notify … 2>/dev/null` neither reads nor deletes it. The format is
|
|
1738
|
+
a contract with the MCP server and the plugin — `src/key-changes.ts` is the
|
|
1739
|
+
source of truth.
|
|
1740
|
+
|
|
1741
|
+
**Threat model:** against a passive backend — a leaked snapshot, an
|
|
1742
|
+
operator reading the table — the stored ciphertext and wrapped keys are
|
|
1743
|
+
useless, so push contents stay private. Four limits worth knowing:
|
|
1744
|
+
|
|
1745
|
+
- **No protection from an active malicious operator.** Recipient public keys
|
|
1746
|
+
come from `GET /devices` on that same server, unsigned. A backend that
|
|
1747
|
+
injects a device record carrying its own key, or lists another key for a
|
|
1748
|
+
device you know, gets the message key wrapped for it and reads what is sent
|
|
1749
|
+
after that — the pin is replaced and one line says so, nothing stops it.
|
|
1750
|
+
Comparing a device's fingerprint (`hwip trust`) with the one the app shows
|
|
1751
|
+
is the check, by eye, and a changed-key line on stderr is the signal to do
|
|
1752
|
+
it. The Hwip app's verified-device strict mode is per browser profile and is
|
|
1753
|
+
not consulted here.
|
|
1754
|
+
- **No forward secrecy.** The ECDH secret for a given sender/device pair
|
|
1755
|
+
is static, so compromising either private key opens every past push
|
|
1756
|
+
wrapped for that pair.
|
|
1757
|
+
- **`senderPublicKey` is unsigned**, so a swapped one makes a push
|
|
1758
|
+
undecryptable — that direction fails closed rather than leaking.
|
|
1759
|
+
- **Two OS users on one computer share its device id** (it comes from the
|
|
1760
|
+
machine; the key is each user's `~/.hwip`). With a `push:read` API key each
|
|
1761
|
+
user's notify or ask puts its own key back on the list, and the app warns of
|
|
1762
|
+
a changed key each time.
|
|
1763
|
+
|
|
1764
|
+
A device that has not registered a per-device public key cannot be sent
|
|
1765
|
+
to; it is skipped, and if no device qualifies nothing is sent.
|
|
1766
|
+
|
|
1767
|
+
**`hwip ask` seals both ways.** The question's title,
|
|
1768
|
+
body and button wording go to the phone inside the envelope, with a random
|
|
1769
|
+
128-bit id the answer has to echo; the server keeps only each button's id,
|
|
1770
|
+
the timeout, the `hookType` and `acceptsExit` flags, the session fields, the
|
|
1771
|
+
envelope's `senderPublicKey` and `deviceKeyMap` (so, the ids of the devices
|
|
1772
|
+
it is sealed for), the push priority (the hook's setting) and, once
|
|
1773
|
+
answered, which device answered. The phone seals its answer for this
|
|
1774
|
+
computer, and `hwip ask` opens it only if the answering device's key is
|
|
1775
|
+
the one the account's device list registers for it, the envelope was sealed
|
|
1776
|
+
with that key, and it holds an `answer` for this question's id. An answer
|
|
1777
|
+
from a device whose key changed since it was pinned opens too (the pin is
|
|
1778
|
+
replaced and the line above is printed); an answer from a device the list
|
|
1779
|
+
does not hold with a key is refused. This path never falls back to plaintext:
|
|
1780
|
+
if the question cannot be sealed (no app signed in, no device to seal for, keys
|
|
1781
|
+
that do not load, a device list that does not load, this computer's key that
|
|
1782
|
+
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
|
|
1783
|
+
buttons is refused with its own message. The device list is read before the send
|
|
1784
|
+
and counted against the `--timeout` window: if it uses it all up, nothing is
|
|
1785
|
+
sent and the ask ends as a timeout. Otherwise the server is told what is left
|
|
1786
|
+
of the window, rounded up to a whole second and never less than one. Plaintext
|
|
1787
|
+
fields the server returns for a sealed question are ignored.
|
|
1788
|
+
|
|
1789
|
+
**A sealed question carries its context.** What the asking session has
|
|
1790
|
+
changed (`git diff` against HEAD with new files, never a `.gitignore`d one)
|
|
1791
|
+
and the last 60 lines of its terminal (the tmux pane `hwip ask` runs in;
|
|
1792
|
+
none outside tmux) go with the question, masked for secret-looking text
|
|
1793
|
+
first — best effort: a secret in a form the masking does not know stays. A
|
|
1794
|
+
summary (files, lines, at most 20 paths; 4 KiB at most) rides inside the
|
|
1795
|
+
sealed body; the text itself (64 KiB at most, cut at a line with a mark) is
|
|
1796
|
+
one attachment sealed for the same devices, which the phone fetches when the
|
|
1797
|
+
summary is opened. The server sees that attachment the same way on every
|
|
1798
|
+
sealed question: named `file`, `application/octet-stream`, 64 KiB — also when
|
|
1799
|
+
there is nothing to carry or `hwip ask-context off` is set, where it is sent
|
|
1800
|
+
with nothing in it — so neither whether a question has context nor how much
|
|
1801
|
+
changed shows. It is kept as long as any attachment (30 days). The
|
|
1802
|
+
context never holds a question back: if it cannot be gathered or stored the
|
|
1803
|
+
question goes without the attachment (which the server can tell), a
|
|
1804
|
+
summary that would take the envelope over the server's 48 KiB limit is left
|
|
1805
|
+
out — and the attachment too if that is still not enough — and with less than 20 seconds of `--timeout` left nothing is gathered.
|
|
1806
|
+
|
|
1807
|
+
**A question goes up once.** The send carries an `Idempotency-Key`, a new
|
|
1808
|
+
random one per question, so sending it again cannot open a second question:
|
|
1809
|
+
the server answers a repeat with the first one's ids. A send that fails in a
|
|
1810
|
+
way that may pass — no connection, no answer in 10 seconds, a 5xx, 408 or
|
|
1811
|
+
429 — is sent again with the same key and the same bytes, after 1, 2 and 4
|
|
1812
|
+
seconds (four tries). While the server says the first request is still
|
|
1813
|
+
running under the key (409 `IDEMPOTENCY_IN_PROGRESS`), the send is repeated
|
|
1814
|
+
every 5 seconds instead, for up to 35 seconds from the first such answer: a
|
|
1815
|
+
request the server lost to its own timeout holds the key for 30. No try is
|
|
1816
|
+
made that would start after the `--timeout` window has ended, and none is
|
|
1817
|
+
waited on more than a second past its end (a try gets what is left of the
|
|
1818
|
+
window when that is under 10 seconds, and a second at the least). Any other
|
|
1819
|
+
refusal (a bad key, a key that cannot be sent as a header, a hook that is
|
|
1820
|
+
gone, a redirect, a base URL that is not an address) ends the ask at once. The
|
|
1821
|
+
server counts the question's timeout from the try it took, so a question that
|
|
1822
|
+
went up on a late try stays on the phone that much longer than this command
|
|
1823
|
+
waits, and an answer given in that tail is not read. A repeat the server had
|
|
1824
|
+
already taken is answered with the question's ids alone, which do not say
|
|
1825
|
+
whether the phone was notified — that was the first request's to say. So
|
|
1826
|
+
when a question went up on a retry and the answer does not say the phone was
|
|
1827
|
+
notified, `hwip ask` says so on stderr, and again as `error` in its JSON if
|
|
1828
|
+
the ask ends as a timeout: the question is in the app either way, but nobody
|
|
1829
|
+
may have been pinged. When the tries run out and one of them may have
|
|
1830
|
+
reached the server all the same — it was slow to answer, answered
|
|
1831
|
+
unreadably or with a 5xx, still held the first send as running, or the
|
|
1832
|
+
connection dropped once made — the
|
|
1833
|
+
`error` ends with "the question may have gone up all the same", whatever the
|
|
1834
|
+
last try said. A try that never connected (nothing listening, no route, a
|
|
1835
|
+
name that does not resolve) or was answered 429 does not count: when every
|
|
1836
|
+
try ended so, the error is plain. One
|
|
1837
|
+
case still opens a second
|
|
1838
|
+
question: a first request that wrote the question and died before it marked
|
|
1839
|
+
the key is taken over after those 30 seconds.
|
|
1840
|
+
|
|
1841
|
+
**A question cut short is taken up again.** `hwip ask` blocks, and what runs
|
|
1842
|
+
it does not always wait: an agent's tool call times out, a shell is closed.
|
|
1843
|
+
The question is still on the phone then, with nobody reading its answer. So
|
|
1844
|
+
each question that went up leaves a record under `~/.local/state/hwip/`
|
|
1845
|
+
(`ask-pending-<hash>.<pid>.<nonce>`, mode 0600), and the same command run again — the same
|
|
1846
|
+
title, body and actions, from the same directory, agent session and machine
|
|
1847
|
+
— waits on that question instead of sending a second one. Its JSON then
|
|
1848
|
+
carries `"resumed": true`. The record holds the event id, the id the answer
|
|
1849
|
+
has to echo and the question's deadline, and nothing of the wording, which
|
|
1850
|
+
is matched by a hash. It is taken up only once the process that asked has
|
|
1851
|
+
exited (two that run side by side each ask their own), by one process only,
|
|
1852
|
+
and within the first question's own window: `--timeout` of the second run
|
|
1853
|
+
bounds its wait but does not extend the question. When the first question
|
|
1854
|
+
timed out with nobody having answered and the second run has window left, it
|
|
1855
|
+
asks anew with what is left; one the user cancelled stays cancelled. A read
|
|
1856
|
+
that fails or is refused (a key that was rotated), or an answer that cannot
|
|
1857
|
+
be opened because the device list did not load, ends the run with the error
|
|
1858
|
+
and keeps the record: the question is still there for the next run. The
|
|
1859
|
+
record is removed before an answer is handed over, so an answer is used
|
|
1860
|
+
once — if it cannot be removed, stderr says that the next run would be given
|
|
1861
|
+
the answer again — and one past its deadline is cleared by the next
|
|
1862
|
+
`hwip ask`. An approval gate goes the same
|
|
1863
|
+
way: a command blocked while its question was open and tried again within
|
|
1864
|
+
that window gets the answer given to the first, for that same command in
|
|
1865
|
+
that same place only.
|
|
1866
|
+
|
|
1867
|
+
This host becomes a device others can encrypt for once its public key is on
|
|
1868
|
+
the account's device list. `hwip listener` puts it there each time it connects
|
|
1869
|
+
to Hwip; without a listener, the first `hwip ask`, `hwip agent send` or
|
|
1870
|
+
`hwip notify` that finds this computer missing from the list, or listed under
|
|
1871
|
+
another key, puts it there itself (one `PUT /devices/{id}/public-key`, no
|
|
1872
|
+
second read of the list) — so `hwip ask` works on a computer that never ran
|
|
1873
|
+
the listener. Only an API key with `push:read` may replace a key the list
|
|
1874
|
+
already holds: with another, the old key stays, and `hwip ask` and
|
|
1875
|
+
`hwip agent send` refuse, saying so on stderr (`hwip ask` in its JSON too). A notify whose key cannot be registered
|
|
1876
|
+
still goes, with one line on stderr — the app reads it either way.
|
|
1877
|
+
|
|
1878
|
+
The `hwip listener` opens encrypted file pushes addressed to it and
|
|
1879
|
+
sealed `agent.command` pushes from a device whose key checks out (see
|
|
1880
|
+
Defense above); it ignores every other `isEncrypted` push — it does not
|
|
1881
|
+
try to decrypt them. A sealed command names its sender inside the seal
|
|
1882
|
+
(`cmd.from`), because a send made with an API key is stamped `api_<keyId>`
|
|
1883
|
+
by the server, which says nothing about the machine. The key must be the
|
|
1884
|
+
one the account's device list registers for that device. The first such
|
|
1885
|
+
command from a device pins it, and — like any first sealed request — makes
|
|
1886
|
+
this listener sealed-only from then on; a changed key replaces the pin.
|
|
1887
|
+
Command numbers are counted per sender (`cmd.from`, else the stamped
|
|
1888
|
+
device). A command sealed by another session of this same computer is
|
|
1889
|
+
checked against this computer's own key, not the account's list. Stop-hook auto-pushes and `hwip_ask` responses are
|
|
1890
|
+
not part of the `@<session>` injection path, so this doesn't affect
|
|
1891
|
+
normal use.
|
|
1892
|
+
|
|
1893
|
+
### Sealed `hwip agent send`
|
|
1894
|
+
|
|
1895
|
+
`hwip agent send` (and `hwip send`) always seals the
|
|
1896
|
+
message for the target computer instead of posting its text: the push carries only ciphertext, the label inside names the
|
|
1897
|
+
session, the target, this computer as the sender and a number, and the
|
|
1898
|
+
target's listener types it by the same checks as a command from the phone
|
|
1899
|
+
(above). The senders on one computer draw theirs from one counter,
|
|
1900
|
+
`~/.hwip/command-seq`, under a lock (`~/.hwip/command-seq.lock`); the number
|
|
1901
|
+
is the larger of the last one plus one and the clock in milliseconds, so a
|
|
1902
|
+
sender that starts later is never behind.
|
|
1903
|
+
|
|
1904
|
+
**No pairing.** Two computers on one account need nothing set up beyond
|
|
1905
|
+
`hwip listener` running on the target (or `hwip cc`), which types the
|
|
1906
|
+
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
|
|
1907
|
+
way, the target included; the target pins the sender on the first sealed
|
|
1908
|
+
command it opens, like any first sealed request. To compare fingerprints
|
|
1909
|
+
before that, `hwip trust` prints this computer's and lists the account's
|
|
1910
|
+
other computers that are not pinned yet, and `hwip trust <deviceId>` pins one
|
|
1911
|
+
on purpose — nothing requires it.
|
|
1912
|
+
|
|
1913
|
+
**What it refuses.** Sealing is not a preference: a message
|
|
1914
|
+
that cannot be sealed is not sent, and there is no plaintext branch.
|
|
1915
|
+
Each refusal says what to do — this computer's key could not be put on the
|
|
1916
|
+
account's device list (and why), or the target has no key listed (run `hwip listener` on it
|
|
1917
|
+
once), the device list could not be fetched, the keys did not load, the trust
|
|
1918
|
+
file or the counter cannot be read, or another process holds the counter lock
|
|
1919
|
+
(try again). A target whose key changed since it was pinned is not a refusal:
|
|
1920
|
+
the pin is replaced, one line on stderr says so, and the message goes.
|
|
1921
|
+
|
|
1922
|
+
**Who can read it.** The target computer, and every other device the account
|
|
1923
|
+
lists with a key (a send to another session of this same computer reaches
|
|
1924
|
+
it with this computer's own key). The phone's chat bubble for the message opens by the phone's own slot in
|
|
1925
|
+
`deviceKeyMap`; it does not need the sender pinned.
|
|
1926
|
+
|
|
1927
|
+
**What stays in the clear and what repeats.** The relay needs
|
|
1928
|
+
`agentSessionName` and `targetDeviceId` to route the push, so they are
|
|
1929
|
+
plaintext; so are `senderPublicKey` and the ids of the devices the message
|
|
1930
|
+
is sealed for (the keys of `deviceKeyMap`) — the relay sees which devices
|
|
1931
|
+
can read it, not what it says. A send that failed on
|
|
1932
|
+
the way and is typed again takes a new number; if the first request had in
|
|
1933
|
+
fact arrived, the session receives the message twice.
|
|
1934
|
+
|
|
1935
|
+
The receiving listener, once it only seals, refuses a plaintext `agent.command`
|
|
1936
|
+
(see Defense above); a sender that still posts plaintext gets nothing typed.
|
|
1937
|
+
|
|
1938
|
+
**App version.** An iOS build from before hwip #318 sends the conversation
|
|
1939
|
+
input in plaintext to a computer it has no pin for, and one from before
|
|
1940
|
+
hwip #322 shows `e2ee_required` as a refusal without a reason. Against a
|
|
1941
|
+
sealed-only listener of this version, the first gets nothing typed. The
|
|
1942
|
+
buttons of a blocked prompt (in the app and on its notification) seal named
|
|
1943
|
+
`keys` with `ifPrompt` since the sealed-answer-keys iOS build; an older
|
|
1944
|
+
build's approve button sends plain `keys`, which this version refuses with
|
|
1945
|
+
`e2ee_required`. This version is to be published only after an iOS build
|
|
1946
|
+
that includes #318, #322 and the sealed answer keys is out.
|
|
1947
|
+
|
|
1948
|
+
A file the app seals goes up padded to its Padmé length, under a record
|
|
1949
|
+
that says only `file`, `application/octet-stream` and the padded size; the
|
|
1950
|
+
sealed message names it (by IV) with its real name, type and size, and the
|
|
1951
|
+
listener saves it under that name, cut back to that size. Files from
|
|
1952
|
+
senders that do not do this (this cli, the MCP server) keep their names
|
|
1953
|
+
in the record as before.
|
|
1954
|
+
|
|
1955
|
+
A file too large to seal in one piece is sealed in 1 MiB chunks instead
|
|
1956
|
+
(`file-chunk-v1`): each chunk under the file key with its own nonce, which
|
|
1957
|
+
carries the chunk's number and whether it is the last, so chunks that are
|
|
1958
|
+
reordered, repeated, missing or cut short do not open and nothing is saved.
|
|
1959
|
+
The sealed message says which format a file is in. The listener tells the
|
|
1960
|
+
account's devices that it opens this format, and a sender uses it only for a
|
|
1961
|
+
machine that says so; a listener from before it keeps getting files sealed
|
|
1962
|
+
whole.
|
|
1963
|
+
|
|
1964
|
+
Pushes and files use the envelope above. The live path — stream frames
|
|
1965
|
+
and input, snapshots, history pages, diffs and chat timeline batches —
|
|
1966
|
+
uses envelope v2 instead, sealed for one device key at a time
|
|
1967
|
+
(Defense above).
|
|
1968
|
+
|
|
1969
|
+
## Requirements
|
|
1970
|
+
|
|
1971
|
+
- **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).
|
|
1972
|
+
- **tmux** — required for `hwip cc` / `codex` / `gemini` and `hwip listener`.
|
|
1973
|
+
- The `HwipHook` SDK has no runtime dependencies. The CLI depends on
|
|
1974
|
+
`@inquirer/prompts` for the interactive `hwip install` picker, on
|
|
1975
|
+
`ws` for the listener's WebSocket subscription, and on `jsonc-parser`
|
|
1976
|
+
(Microsoft's, the parser VS Code reads its settings with; MIT, no
|
|
1977
|
+
dependencies of its own) to edit agent settings that hold comments.
|
|
1978
|
+
|
|
1979
|
+
## License
|
|
1980
|
+
|
|
1981
|
+
Apache-2.0
|
|
1982
|
+
|
|
1983
|
+
Contributions are welcome: see [CONTRIBUTING.md](./CONTRIBUTING.md). Report security issues as described in [SECURITY.md](./SECURITY.md).
|