npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hwip/cli

v0.1.11

Published

Hwip CLI + push notification SDK for AI agents

Readme

@hwip/cli

npm downloads node license docs

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.

@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.

  • HwipHook SDK — native fetch, no runtime deps. Send / list / dismiss pushes.
  • hwip CLI — one-command setup for 10 agents, push sending, and the resident listener for phone-driven remote control of 16 agents.

Part of the Hwip toolchain: @hwip/mcp-server (the MCP tools your agent calls, e.g. hwip_ask) · hwipit/plugin (Claude Code plugin bundling hooks + MCP + rules) · the Hwip app on your phone.

New here? 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.

Quick Start

Install the CLI globally, then run setup:

npm install -g @hwip/cli
hwip install

hwip install opens a browser sign-in on a fresh machine — the web app issues an API key + hook as a matched pair and the CLI writes them to ~/.hwip/config.json (no copy-paste). Then it installs rules + hooks + MCP for every detected agent (Claude Code / Cursor / Windsurf / Gemini / Codex / …). Safe to re-run: a saved login is reused untouched (no prompts), so a re-run just refreshes the agent integrations. To switch account, hwip install --relogin forces a fresh sign-in.

Auto session names. Agents without a title of their own — Codex, Cursor, Windsurf, Gemini and pi — name their sessions after what they were asked, so the phone shows that instead of a tmux name. The registrations hwip install writes turn it on (HWIP_AUTONAME=1 in the MCP entry, the hwip_session_title tool in the pi extension), so after updating @hwip/cli, run hwip install once more to get it on a machine installed before; hwip verify says which MCP agents still lack it. Claude Code and opencode title sessions themselves and are left as they are. To hide the names agents choose, turn off Name sessions automatically in the app's settings — names you set yourself always show.

Why global, not npx? hwip cc (drive a session from your phone) needs hwip on your PATH, and the agent hooks this installs are $(command -v hwip || npx …) — a global binary skips an npx cold-start on every notification. For notifications only, with no phone control, npx @hwip/cli install is a lighter alternative that skips the global binary — and with it hwip cc, which is also where the update notice (npm i -g @hwip/cli) appears.

Once installed, the hooks fire in every session of each configured agent — hwip cc is the phone-control bridge, not the notification switch. In Claude Code the routine per-turn push starts off (quiet is the default) — while you're at the terminal; once you've stepped away, a finished turn still pushes (Quiet while away). /hwip-mode normal turns it on for a project, /hwip-mode loud pushes on every turn, /hwip-mode mute silences a project entirely, and /hwip-mode shows what's in effect. See Mute & push mode.

~/.hwip/config.json is the single source of truth — the CLI, the MCP server, the plugin hooks, and the listener all read it. You never need HWIP_API_KEY-style env vars for a normal setup; they exist as overrides (second account, CI).

Variants:

# Headless box (no browser): paste credentials from another machine's
# ~/.hwip/config.json
hwip install --key ak_... --hook hook_...

# Skip the interactive agent picker
hwip install --only claude,cursor

# Switch account — force a fresh browser sign-in over a saved login
hwip install --relogin

# Refresh credentials only (no agent re-install) — assumes you already
# ran `hwip install` once; on its own it does NOT wire MCP into agents
hwip login

To send notifications:

hwip notify --title "Deploy done" --body "v2.1.0 shipped"

To send to a channel you own (everyone subscribed to it gets the push):

hwip channel list                                     # channels you own and follow
hwip notify --channel Releases --title "v2.1.0" --body "shipped"
hwip notify --channel ch_abc123 --title "v2.1.0"      # a channelId works too

--channel takes a channelId or an exact channel name. A name resolves to the channel you own with that name — a channel you only follow under the same name is not a candidate — and two owned channels with one name are refused: use the id. channel list marks each channel yours or following; only an owner can send, and the CLI refuses a channel you follow without sending anything. (Against a server that predates the isOwner field, the list shows each channel's owner instead, a name shared with a followed channel is refused as ambiguous, and the server refuses the push for a channel you do not own.)

A channel push is plain text. Unlike every other push, it is not sealed — the server can read it, because a sealed push could not be opened by the subscribers. The CLI says so on stderr each time. It needs --title or --body (there is no default text), and does not combine with --auto, --device or --session, which mean "this agent session". hwip send / hwip agent send types into an agent session and has no channel option. The API key needs the channel:read and push:write scopes.

To drive an agent session from your phone, see Remote Control below.

Remote Control

Send messages from your phone into a live agent session — even after a hwip_ask polling window has expired.

The MCP tool hwip_ask waits on a fixed timeout (120–600 s). Once that window closes the session becomes unaddressable from the phone, even though it's still running. The hwip listener daemon fixes this by keeping a persistent WebSocket open to Hwip and injecting matching messages into a named tmux session via tmux send-keys.

Architecture

[phone — "Active Agents" picker on Hwip app]
   │  selects session, types message
   ▼  POST /pushes/send  { type: 'agent.command',
   │                       agentSessionName: 'hwip-myapp',
   │                       body: '리팩토링 마무리해줘' }
[Hwip backend]
   │  WebSocket fan-out (push.new)
   ▼
[hwip listener — resident daemon, started by `hwip cc` automatically]
   │  tmux send-keys -l -t hwip-myapp "리팩토링 마무리해줘" + Enter
   ▼
[tmux session "hwip-myapp" running claude / codex / cursor-agent / gemini / hermes]

The listener polls its tmux session inventory every 5 seconds and reports it to the server whenever something changed (new session, agent state transition, activity) — an unchanged inventory is re-sent only as a two-minute idle heartbeat. The phone picker stays in sync with no manual configuration, and an idle listener costs the backend a fraction of what a fixed 5-second report cycle would.

Live timeline

While a session is working, the phone can draw what the agent is doing right now — the prompt that started the turn, each tool call and its outcome, the agent's own replies, and per-message token counts. It is read by tailing the agent's transcript on disk; nothing a tool read or wrote leaves the machine, only names, short labels and numbers.

The one list it carries in words is the agent's own to-do list (Claude Code's TaskCreate · TaskUpdate · TaskList), sent whole as a todo event each time it changes — the tasks as the agent wrote them, at most 30 of 100 characters each, sealed like the replies are.

Each agent stores its transcript differently, so a row in REMOTE_AGENT_TABLE (src/remote-agents.ts) names two things: where the file is, and which projector reads it. An agent with neither answers no_transcript and the phone says it has no live timeline.

| Agent | Transcript | Projector | |-------|-----------|-----------| | Claude Code | ~/.claude/projects/<encoded cwd>/<uuid>.jsonl | src/transcript-tail.ts | | pi | ~/.pi/agent/sessions/<encoded cwd>/<ts>_<id>.jsonl (PI_CODING_AGENT_DIR) | src/pi-transcript.ts | | Codex CLI | ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl (CODEX_HOME) | src/codex-transcript.ts | | Gemini CLI | ~/.gemini/tmp/<project>/chats/session-*.jsonl (GEMINI_CLI_HOME) | src/gemini-transcript.ts | | 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 | | Cursor, Hermes, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — |

Adding an agent means a projector file of its own, not another branch in an existing one: the formats share no vocabulary. Each projector is pinned to the vendor layout it was read against, and an entry type it has never seen draws no row rather than breaking the watch.

Remote-origin detection (sticky REMOTE mode)

A message injected via send-keys is indistinguishable from typing — so the listener also records each injection as a one-shot marker (epoch + sha256 of the text, keyed by the pane's project dir). A prompt-submit hook on the agent side matches the submitted prompt against that marker and, on an exact match, tells the model the user is driving the session from their phone — entering sticky REMOTE mode (every response ends with an answerable hwip_ask).

| Agent | Hook | Installed by | |-------|------|--------------| | Claude Code | UserPromptSubmit → plugin's hwip-remote.sh | Hwip plugin | | Gemini CLI | BeforeAgent → hwip remote-hook gemini | hwip setup | | Codex CLI | UserPromptSubmit → hwip remote-hook codex | hwip setup | | Pi | before_agent_start → hwip remote-hook pi (via extension) | hwip setup | | Cursor CLI | — none yet | — |

Subagents (view-only). A subagent shows up on your phone as a child of its parent card, named <session>.<n> — it streams live, but input, keys, exit, resume and forget are all refused: you watch from the phone and answer at the terminal, where the parent owns the conversation. A prompt push from a subagent names it and opens its stream directly.

They reach the roster two ways. A pi subagent spawned into an extra pane of the same tmux window is found as a pane, and labelled from @hwip_pane_label (the pi extension sets it from PI_SUBAGENT_NAME). A Claude Code subagent — the Agent tool — has no pane at all; it is found in the parent's transcript directory instead (src/subagent-transcripts.ts), labelled from the description the parent gave it, and carries the number of tools it has run so far. Both kinds are numbered in one sequence, so the names never collide.

A Claude Code subagent is marked inProcess, because the phone cannot mirror a pane that does not exist — its terminal view draws the transcript instead. It leaves the roster the moment the parent's transcript records its end (a background subagent's task notification, or a foreground one's Agent call returning), rather than after five quiet minutes; one that is resumed comes back when it writes again.

Detection is exact-match: a terminal keystroke racing a phone message can never false-flag. Muted projects are never flagged.

Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one other way in: a hwip_ask answer reporting hwipState: "REMOTE". Their rule files therefore keep the after-real-work hwip_ask — the "Entering REMOTE without a prompt hook" preamble in src/templates.ts — so the phone always has a button to tap that starts the loop. The one exception is an answer sent with the phone's "send and exit": that turn carries out the instruction and ends without an ask. Agents with the hook drop that obligation while at the terminal — and their rule file carries only the NORMAL branch of the core (about a quarter of it), since it is read on every turn; the hook injects the REMOTE sections in full on the turn a phone message enters REMOTE, the way the Claude Code plugin's hooks do.

hwip cursor has no remote-origin hook, so it never enters sticky REMOTE mode by itself. Just ask for it — one line, once per session:

I'm driving this session from my phone. End every response with hwip_ask so I can answer with a button.

Nothing else is missing: injection works, and the MCP tools are all there, hwip_ask included. Check them with cursor-agent mcp list-tools hwip — mcp list prints only the approved list, not what's configured, so it reads as empty even when the server is wired up.

Why it isn't automatic: hooks.json is honored by the Cursor IDE but not by cursor-agent, and beforeSubmitPrompt's output schema is {continue, user_message} — no context channel to deliver a marker match through. Same reason the stop-hook auto-push hwip setup installs covers the Cursor IDE but not hwip cursor panes; ask for a hwip_notify when you want one.

hwip hermes is in the same position, and asking for it works the same way. hwip install does not wire Hermes either — add the MCP server to ~/.hermes/config.yaml by hand, as one entry under mcp_servers:

mcp_servers:
  hwip:
    command: "hwip"
    args: ["mcp"]

hwip mcp runs the MCP server inside the CLI process. The older command: "npx" / args: ["-y", "@hwip/mcp-server"] form still works, but leaves an npm exec launcher resident beside the server for as long as the session lives — hwip verify flags a config still on it.

Add the entry; do not replace the file. mcp_servers is shared with every other server you have configured, and nothing under ~/.hermes is in version control. Then /reload-mcp inside Hermes.

hwip_ask needs a hook id, which the MCP server reads from HWIP_HOOK_ID or from hookId in ~/.hwip/config.json — hwip install writes the latter, so a machine that has run it once needs no env block here.

Setup

  1. Install tmux. The listener uses send-keys; the wrapper spawns named sessions. hwip install checks for it and, on macOS with Homebrew, offers to run brew install tmux for you. Elsewhere it prints the line to run — sudo apt install tmux on Debian/Ubuntu, and so on — because installing with sudo is not something an installer should do on your behalf. tmux has no Windows build, so hwip cc needs WSL there.

  2. Add wsUrl to ~/.hwip/config.json (the WebSocket endpoint of your Hwip backend — CDK output WsApiUrl). Keep it on the same stage as baseUrl: the REST stage is the path prefix (/v1 prod, /d1 dev) and the socket stage is the hostname, so they can disagree without either looking wrong. hwip verify says which stage each one is on, and the listener only reads this at startup — hwip listener --restart after editing:

    {
      "apiKey": "ak_...",
      "hookId": "hook_...",
      "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
    }

    This is where it belongs. --ws-url overrides it for a single run. HWIP_WS_URL is deprecated: it used to outrank this file, which meant an export in a shell profile silently chose the stage and left no trace of having done so. It now loses to the file and is read only when the file has no wsUrl, so nothing that worked stops working — but it will go, and hwip verify says so while it is still set.

  3. Run agents through the wrapper. That's it.

    hwip cc        # claude       → tmux session "hwip-<project>"
    hwip codex     # codex        → tmux session "hwip-<project>"
    hwip cursor    # cursor-agent → tmux session "hwip-<project>"
    hwip gemini    # gemini       → tmux session "hwip-<project>"
    hwip hermes    # hermes       → tmux session "hwip-<project>"
    hwip pi        # pi           → tmux session "hwip-<project>"
    hwip opencode  # opencode     → tmux session "hwip-<project>"
    hwip kimi      # kimi         → tmux session "hwip-<project>"
    hwip grok      # grok         → tmux session "hwip-<project>"
    hwip agy       # agy          → tmux session "hwip-<project>"
    hwip qwen      # qwen         → tmux session "hwip-<project>"
    hwip auggie    # auggie       → tmux session "hwip-<project>"
    hwip kilo      # kilo         → tmux session "hwip-<project>"
    hwip copilot   # copilot      → tmux session "hwip-<project>"
    hwip kiro-cli  # kiro-cli     → tmux session "hwip-<project>"
    hwip droid     # droid        → tmux session "hwip-<project>"

    hwip install does not wire MCP, rules or hooks for kimi · grok · agy · qwen · auggie · kilo · copilot · kiro-cli · droid — the wrapper makes the session reachable from the phone and nothing more.

    Two wrapper flags, read off the front of the args: --detach creates the session without attaching (no TTY needed — a script or another agent's pane can launch it) and prints its name; --label <x> names it hwip-<project>-<x> instead of the -2/-3 family. Example — a Claude Code planner starting a pi implementer for one plan:

    hwip pi --detach --label plan-auth-pi -n plan-auth '/skill:implement plans/auth.md'

    A session in its own git worktree — --wt <name>. hwip cc --wt feat/x makes the worktree for branch feat/x next to the repo's main checkout (<repo>-wt-feat-x, with the branch started from the HEAD you typed it at, or the branch as it is when it already exists) and starts the session in it, under the name hwip-<repo>-wt-feat-x; the phone groups it under <repo> as wt-feat-x. Typing the same command again reuses the worktree (without --detach it reattaches the detached session). Typed inside a worktree, it still makes the new one beside the main checkout. It refuses, with one line and a non-zero exit, outside git, in a bare or --separate-git-dir layout, for a name git does not take as a branch, and when something that is not this worktree is already at the path — nothing there is touched. Like --detach and --label, it goes at the front of the args. Claude Code's own --worktree is not taken by the wrapper: it passes through and uses .claude/worktrees/. hwip sh has no --wt.

    When the session is over. The listener takes the worktree back, once, after a session that ran in it has ended and no pane is left in it — and only when nothing in it would be lost: no uncommitted or untracked file, no ignored file (a hand-written .env is ignored, and git worktree remove would delete it without a word), and no commit made since the worktree was created that is not on a remote. It removes with plain git worktree remove (never --force), then deletes the branch with git branch -d only if hwip made it and git says it is merged. When anything holds the worktree it stays: the listener log gets one line and macOS gets one banner naming what holds it (2 uncommitted or untracked, 1 unpushed commit), and it is not judged again until a session uses it again. Only worktrees hwip made with --wt are ever removed — it keeps them in worktrees.json under the state directory, and a worktree you made by hand has no row. git worktree list is the full list; hwip worktree shows the ones hwip made and what keeps each, and hwip worktree clean removes the ones nothing keeps now (--dry-run only says; it judges every worktree not in use, including ones the listener left or never saw end). The listener looks when the set of hwip-* sessions changes (and once when it starts), the last session ending with tmux itself included; a run started inside a tmux pane of your own ($TMUX set, no --detach) is not a session it sees end, so hwip worktree clean is how that one goes. The chat history of an ended session stays, so resuming it from the phone after its worktree is gone answers with the app's missing-directory error.

    A labelled session, and any session started in a linked git worktree, still shows on the phone under its project — the main checkout's name — with the label (or the worktree's session name) beside it. The wrapper records both as tmux session options (@hwip_project, @hwip_session_label) because the session name alone cannot say where the project ends. A session started without them (before this, or by an older CLI) is grouped by the listener from its pane's checkout instead, so upgrading the listener is enough — no session restart.

    Inside herdr. herdr finds agents by a pane's foreground process, which for a wrapped session is the tmux client, so the agent would not show in its sidebar. Every hwip attach records the herdr pane it runs in as session options (@hwip_herdr_pane, @hwip_herdr_socket, @hwip_herdr_bin), and clears them when attached from outside herdr; a bare tmux attach changes neither, so the agent keeps reporting to the last herdr pane hwip saw. Reporters read them on every report and send the agent's working / idle / blocked state to that pane: Claude Code through the hwip plugin's hwip-herdr.sh hook (plugin 0.20.0+) as hwip cc, pi through the hwip pi extension as hwip pi. herdr's own pi extension reads the pane from its env, which tmux does not pass, so it stays silent in a wrapped session. Headless runs (claude -p, pi -p, RPC) started from a shell in a wrapped pane inherit its TMUX_PANE and never report.

    A shell the phone may type into — hwip sh. Every other shell pane is refused (see Defense); hwip sh opens the one exception, hwip-<project>-sh running your login shell, for the jobs an agent's permission prompt blocks — a restart, a kill, a sudo. It is a real terminal: cd and export stick, sudo/ssh prompts work, and a long command does not tie up an agent. --label <x> names it hwip-<project>-<x>; --detach creates it without attaching. Away from the desk, open one from the phone through any running Claude Code session by sending !hwip sh --detach (Claude Code's ! bash mode — see Defense item 7). Inside tmux it never attaches; it prints a tmux switch-client -t <session> line instead. Running it again for a session it opened reattaches, and a session of that name it did not open is refused.

    hwip sh              # → tmux session "hwip-<project>-sh"
    hwip sh --detach     # create it, print its name, exit

    The mirror fits a session to the phone. A pane is as wide as the terminal that last drew it, so a desktop's prompt rules and right-aligned clocks fold in two on a phone. Whoever used the session last decides its width, the way tmux's window-size latest does: typing from the phone makes the phone the latest user, and the listener resizes the window to the phone's width, and coming back to the mirror later (a locked screen, a resubscribe) takes it again unless the desktop was used since. Opening the mirror without typing does the same once the desktop terminal has been idle for a minute, so a phone reconnecting on its own never shrinks a terminal you are typing in. Pressing a key in (or attaching) a desktop terminal makes it the latest user, and within a renew (5 s) the window is handed back to tmux, which sizes it to that terminal. Moving the mouse over a desktop pane or clicking another pane is not using it: on macOS the listener reads when a key was last pressed (the HID system clock, no permission needed) and counts a desktop terminal as used no later than that key or its attach. Elsewhere any tmux client activity counts. While the phone holds a session, a desktop terminal still attached to it shows it narrower. When the phone leaves, a session no terminal is attached to gets its old width back and an attached one is sized to its terminal. A window split into panes is never resized. A listener that died while a window was fitted restores it the next time it starts.

    hwip cursor runs cursor-agent, Cursor's terminal agent — a separate install from the Cursor IDE (the bare cursor on your PATH is the editor launcher, which exits immediately and can't be driven).

    hwip hermes runs Hermes in its classic REPL, which is the default. Launching it in TUI mode instead (hwip hermes --tui, or HERMES_TUI=1 in the environment) puts the pane on the terminal's alternate screen, and the alternate screen has no scrollback — the live mirror on your phone still draws, but scrolling up in it comes back empty.

    If a Hermes session disappears from the phone's picker, the cause is tmux having dropped the pane's start_command: all the listener sees then is the interpreter Hermes execs (python3.11 at time of writing), which names no agent. hwip listener run in the foreground prints the reason (no agent in pane (start=…, current=python3.11)). Kill the session and start it again with hwip hermes to recover. claude sits on the same edge for the same reason — it execs node.

    The first hwip cc on a machine auto-spawns a background listener (singleton, PID file at ~/.hwip/listener.pid, stdout/stderr at ~/.hwip/listener.log). You never run hwip listener by hand — every hwip cc checks the PID file and skips the spawn when one is already alive, so opening a dozen terminals doesn't create a dozen daemons. The daemon survives between hwip cc invocations — but not a reboot, which is what the login-time service below is for.

    Project name resolves from CLAUDE_PROJECT_DIR / CURSOR_PROJECT_DIR / WINDSURF_PROJECT_DIR if set, else the git repo root, else the cwd basename. Any extra args after the command pass through to the agent verbatim:

    hwip cc --resume "abc123"
    hwip cc --dangerously-skip-permissions
    hwip codex --model gpt-5-high "fix the failing test"

    Multiple sessions in one project. Open another terminal in the same folder, run hwip cc again, and the wrapper auto-suffixes: first session is hwip-encl, the next attached one becomes hwip-encl-2, then hwip-encl-3, etc. The phone picker shows them as encl · Claude, encl · Claude #2, encl · Claude #3. Once the agent has named the session itself — Claude Code, Codex, Hermes and OpenCode 2.x do — the picker shows <that name> · <Agent> instead.

    Reattaching wins over spawning. If any session of that project is detached (no one attached), the wrapper reattaches instead of spawning a new one — close the terminal, come back later, pick up where you left off. When several are detached it takes the highest-numbered one first, then works down on later runs, so no session can end up out of reach. That matters because a session you cannot reach still holds a live agent: an agent CLI plus its MCP servers is a few hundred MB you can neither see nor reclaim. A detached session is never killed for you — nothing here can tell "done with this" from "closed the laptop".

    Only a slot running the same agent is reattached. hwip pi will not reattach a detached hwip cc session: tmux attaches to a session that already exists and drops the command it was given, so you would land in claude with pi never started. A slot running another agent is skipped and the next suffix is used instead.

    If you're already inside a tmux session ($TMUX set) the wrapper skips the outer tmux and runs the agent in the current pane — the listener can't target an unnamed session that way, but you keep your existing multiplexer setup. Unless that session is itself a hwip-* one, the wrapper says so in one line and points at --detach or running outside tmux.

Starting the listener at login

A reboot ends the daemon, and nothing starts it again until you open a terminal and run hwip cc. Until you do, the phone shows no agents — the machine is on, the past sessions are on disk, and the app has no one to hear from.

hwip listener --install-service

On macOS that registers a launchd LaunchAgent (app.hwip.listener), and on Linux a systemd user unit (app.hwip.listener.service, see On Linux below); either starts the listener at every login. --uninstall-service removes it and --service-status shows what the installed one points at; hwip install offers it during setup and hwip uninstall takes it away again.

What you get back after a reboot is your past sessions, ready to resume from the phone — not live agents. tmux does not survive a reboot either, and this doesn't try to bring those sessions back to life. A resume starts the agent in the directory it last ran in; for Claude Code, OpenCode, Codex, Pi and Hermes it also picks up the conversation that session last had (claude --resume <id>, opencode --session <id>, codex resume <thread>, pi --session <file>, hermes --resume <id> — the id this machine read off the live pane's own process; an id two panes both answered to is not kept). Claude Code, Codex and Pi start fresh when that conversation is no longer on disk, and the other agents always start a new one. Each ended run of a name is its own row on the phone, and starting one again picks up that run's conversation, not the newest one. If the name is live again by then, that run starts beside it under the next free name of the family (<name>-2, <name>-3, …) instead of being refused.

Two details worth knowing:

  • It fires at user login, not at boot. Without automatic login, the machine stays silent while it sits on the login screen.
  • The plist bakes in absolute paths for node and the CLI, a PATH that can reach tmux, and a UTF-8 LANG. launchd gives a job /usr/bin:/bin:/usr/sbin:/sbin and no locale at all: without the PATH the daemon exits 127 at every login, and without the locale tmux escapes the separator the session list is split on, so every session is silently dropped while everything else looks healthy. If any of that drifts — a version-manager node upgrade, say — hwip verify names it, and re-running --install-service repoints the plist.
  • The plist never holds a proxy. A proxy password in EnvironmentVariables would sit in a file other tools back up and read, so the service takes the proxy from ~/.hwip/config.json ("httpsProxy": "http://user:[email protected]:3128", optionally "httpProxy" and "noProxy"), which hwip keeps at mode 0600 and ignores when another user owns it or anyone can write it. It covers both the API calls and the listener's socket; the rules (the environment's HTTPS_PROXY wins over the file's value, NO_PROXY syntax, the error for a bad address) are in Environment Variables. Restart the service after editing the file: hwip listener --restart. While config.json is ignored (see below) its proxy settings are ignored too, and the service connects directly.

With the service installed, launchd owns the process: hwip cc and hwip listener --stop|--restart ask launchd rather than signalling the PID, so there is never a second daemon racing the first.

On Linux

Where a systemd user manager is running (systemctl --user answers), --install-service writes ~/.config/systemd/user/app.hwip.listener.service ($XDG_CONFIG_HOME/systemd/user/ when that is set), then runs systemctl --user daemon-reload, enable and restart. The unit bakes in the same absolute node and CLI paths, PATH and UTF-8 LANG as the plist — the user manager's own PATH is whatever the distribution configured — and appends both output streams to ~/.hwip/listener.log (append: needs systemd 240 or later; behavior on older releases is unverified). It restarts the listener 30 s after a crash and leaves a clean exit down, as launchd does. --uninstall-service runs systemctl --user disable --now, deletes the file and reloads; hwip listener --stop|--restart become systemctl --user stop|restart, and hwip verify checks the unit the way it checks the plist. This Linux path has not been verified against a real machine yet — the unit and the systemctl calls follow the freedesktop.org manual pages.

The unit hangs off the user manager's default.target, so it runs while you are logged in. Without lingering, systemd starts your user manager at login and stops it after your last logout — on a server nobody logs in to, the listener would not start after a reboot. hwip never changes that setting; turn it on yourself if you want the listener up from boot:

sudo loginctl enable-linger $USER

Keeping the Mac awake

The listener holds an outbound socket, and macOS "Wake for network access" does not protect it — that setting only wakes the machine for inbound requests to services it advertises. When the Mac idle-sleeps, the socket dies and the phone shows "Waiting for the live stream…" until someone opens the lid. A Mac running Claude Code hides this (the agent runs caffeinate during every turn), so one machine looks fine while another, idling at a prompt, keeps dropping.

So on macOS the listener runs caffeinate -s -w <its pid> for as long as it lives. -s is honoured on AC power only — on battery macOS ignores it, so a laptop still sleeps and nothing drains. The display still turns off; a closed lid still sleeps. Activity Monitor shows the daemon under "Preventing Sleep". It holds even when no agent session is open: resuming a past session from the phone needs this Mac awake to answer. Opt out with --no-keep-awake or "keepAwake": false in ~/.hwip/config.json; the log's keep-awake: line says which way it went.

Scheduled sessions

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) is what keeps schedules running across restarts.

  • 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. --on YYYY-MM-DD --at HH:MM runs once, at that local date and time (stored as an instant, so it does not move with the time zone); a time that has passed, or one the clocks skip that day, is refused. A once schedule removes itself after it has run — when its session has ended and the phone has been told what became of it — or after it was missed.
  • A missed run is never started late. A run is started only if the listener sees it within two minutes of its time (thirty minutes for a once schedule: there is no next time to wait for, and "at 3 pm" still means it at 3:20 after the laptop woke up). 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • Editing: hwip schedule edit <id> changes the time (--at, --days, --every), --prompt and --max-runtime of a schedule in place; the id, the session, the directory and the agent stay. What a flag does not name is kept — --at keeps the days (or a once schedule's date), --days the time, --on a once schedule's time of day. A schedule into a session has no --max-runtime to change, and its target stays what it was made with. A changed time counts from the edit: the times before it are neither started nor counted as missed, and an --every schedule runs one period after the edit; changing only the prompt or the run time keeps the schedule on its times. A session already running keeps going. A listener of an older build is restarted first, because it would skip the edited file as unreadable; when it cannot be replaced, a new time is not written (exit 1) and the schedule keeps running as it was.
  • 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. agent-dirs.json holds the directories hwip <agent> typed in a shell found its agent in: a listener run as a login service has only the PATH its service file baked, so a scheduled launch adds these to it — run an agent from a shell once (hwip cc) before scheduling it, or the run is skipped with 'claude' not found on PATH in ~/.hwip/listener.log.
  • Into a running session: hwip schedule add --session <name> (--at … | --on … --at … | --every …) --prompt <text> starts nothing. It types the prompt into a session already running — the run that session holds when the schedule is made (a resume that carries it on, even under another tmux name, is followed; a new run that takes over the name is not). At its time the listener waits up to 30 minutes for that run to be idle (its detection rules, as for a scheduled prompt) and for no terminal attached to the session to have been used in the last 3 minutes, so the prompt does not land after words someone is typing. If the wait runs out it is skipped (session_busy; prompt_undelivered when the prompt would not go in); if the run has ended it fails (session_ended, told once while it stays ended — remove or remake the schedule). A tmux that does not answer is waited out, never taken for an ended run. These never count toward the three running sessions, have no --max-runtime and are never asked to end: the session is yours, not the schedule's. Agent arguments, --wt, --label and an agent word are refused with --session, and so is an agent without an idle rule. A draft left in the agent's input box for more than 3 minutes is not detected: the prompt is typed after it and both are submitted together — clear the box, or don't leave one, before a scheduled time.
  • From the phone: the app lists the schedules of a computer, makes one for a session this computer has run, edits one, 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. From a session's screen the app makes a schedule into that session by default (the computer picks the run the session holds), and offers a one-time date and time; it offers both only to a listener that advertises schedule-session-v1 (an older one refuses them).

Session history

So the app can show a conversation after the fact, the listener reads each agent session's transcript (Claude Code, Codex, pi, Gemini CLI, and OpenCode 2.x from its database) with nobody watching and uploads what was said as small sealed chunks (POST /sessions/<key>/turns). A chunk holds your prompts, the agent's replies, and one line per tool — its name, what it acted on, and how many lines changed — never a tool's arguments, output or file contents. Each chunk is sealed for this machine and every device the account lists with a key (the newest pins first, within the server's slot limit), so the server stores ciphertext only, on every plan; a device that signs in later cannot open earlier chunks. While the account lists no other device with a key, or its device list has never been fetched, nothing is read or sent. Uploads also wait until this machine's own device key is registered with the server. Opt out with --no-turn-history or "turnHistory": false in ~/.hwip/config.json; the log says which way it went. The chunks wait in a small outbox under the state directory (ciphertext and read positions only — a byte offset, or for OpenCode row numbers and their keys) when the network is down, and go out in order once it is back — also after the listener restarts, even when the session they belong to did not survive (a reboot ends every tmux session): such a session is followed to the end of its transcript, then forgotten. A session that has been quiet for a long time keeps its state while its transcript exists, so nothing already sent is sent again (an OpenCode session, which has no file to check, for 30 days).

A tmux name is used again when an agent starts in the same folder, so each chunk also names its run — the tmux session it was recorded in (runId, tmux's session_created in epoch seconds). The app uses it to keep one run's conversation apart from the one before it under the same name; a chunk read after a restart keeps the run it was recorded in. The listener's record of past sessions keeps runs apart the same way: closing hwip cc and starting it again in the same folder leaves the first run as a past session of its own (the newest ten runs of a name are kept, 100 in all, and up to 50 are reported to the phone). Each run is reported with the block of chunk numbers it took — the first and, once it has ended, the last — kept in run-seqs.json next to the registry, so the server reads one run's conversation from that range instead of every chunk the name ever had. Deleting a past run from the phone forgets that run on this machine too, even while a later run of the same name is going; the run that is going is refused (still_running).

When a session waits for your approval, the listener also sends what it is waiting for — the tool, the command or the diff, and the answers — so the app can show it and answer. That covers Claude Code (a Bash command, a file edit or a new file), Gemini CLI (its tool approvals), OpenCode (its permission request; the listener reads the screen with its colours, since only a colour marks the chosen button) and pi approval extensions that ask through ctx.ui.select. Codex's approval dialog is not read yet. It is sealed the same way as a chunk, for the same pinned devices, and turned off by the same --no-turn-history; the server stores ciphertext only. Other dialogs (a plan, a question, a trust prompt) send nothing beyond the state. An answer the app sends is typed only while that same prompt is still on screen: if you have moved the highlight or answered at the keyboard, the command is refused (prompt_changed) instead of landing on whatever is there now.

Diagnostics

The auto-spawned listener writes to three files under ~/.hwip/:

  • listener.pid — the running daemon's PID. cat ~/.hwip/listener.pid
    • ps -p <pid> to confirm it's alive.
  • listener.version — the CLI version the daemon booted from. This is what hwip cc compares against the installed package to spot a stale daemon (see below).
  • listener.log — stdout + stderr from the daemon. tail -f to watch.

The daemon logs its version on the first line, which is the only reliable way to tell which build a long-running process is on:

[xx:xx:xx] hwip listener starting — v1.26.0 — wss://ws.hwip.app

A healthy listener log shows one line per cycle:

[xx:xx:xx] reported 2 session(s): hwip-myapp, hwip-otherapp
[xx:xx:xx] ✓ server persisted 2 session(s)

If you see ! server rejected listener.sessions: ... instead, the message points at the failure (auth, missing device record, etc.) so you can fix the actual problem instead of guessing.

To force a restart:

hwip listener --restart

It gives the new daemon 1.5 s (under launchd, after it has written its pid file, up to 5 s in) and exits 1 when that daemon is already gone, with the last line it logged — a rejected key, say:

hwip listener: the listener stopped right after restarting — hwip listener: API key required. (log: /Users/you/.hwip/listener.log)

With the service installed launchd restarts such a daemon on a loop, so hwip cc makes the same check after asking launchd and prints hwip: the listener stopped right after starting — … instead of reporting a start.

After upgrading @hwip/cli you normally don't have to. npm i -g replaces the package on disk but never the daemon already running from the old build — that daemon keeps answering pushes (so agent chat looks fine) while silently ignoring every message subtype added since it booted. hwip cc compares listener.version against the installed version and restarts the daemon for you when the installed one is newer (a daemon newer than the hwip cc you ran is left alone, so several installs on one machine don't fight over it):

hwip: listener 1.25.0 is stale — restarting on 1.26.0

If the PID file is missing (a different account started the daemon, or it was removed by hand) the singleton guard can't see it. Find the real process instead:

ps aux | grep '[c]li.js listener'

To run it in the foreground (for development of the SDK itself):

hwip listener

You'll get the same logs you'd otherwise tail from listener.log.

Custom tmux sockets

The listener auto-discovers the tmux socket — it probes the default socket first and stops there when it answers. Otherwise it probes every socket file in the per-user tmux-<uid>/ dirs ($TMUX_TMPDIR/$TMPDIR, macOS /var/folders/.../T/, /tmp/), which is where tmux -L <name> puts its socket. Only when none answers does it ask lsof which socket each running tmux process holds — the slow path, which also covers a -S path outside those dirs. If your tmux uses tmux -L <name> or a non-standard -S <path>, set the override explicitly:

export HWIP_TMUX_SOCKET=/path/to/socket

(The wrapper passes the env to the auto-spawned listener, so setting it in your shell rc is enough.)

Wire format

The listener only acts on pushes with type='agent.command' carrying the tmux session name in agentSessionName and the message in body. Other pushes (Stop-hook auto-pushes, hwip_ask responses, channel broadcasts, plain notes) are ignored. End-to-end:

tmux send-keys -l -t <agentSessionName> "<body>"
tmux send-keys    -t <agentSessionName> Enter

If you need to send one from the command line (debugging, scripting), build the structured push directly:

curl -X POST "$HWIP_BASE_URL/pushes/send" \
  -H "X-API-Key: $HWIP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "agent.command",
    "targetDeviceId": "dev_listener_<sha8(hostname)>",
    "agentSessionName": "hwip-myapp",
    "body": "테스트 통과시키고 PR 올려줘"
  }'

The phone also reads this machine's schedules (see Scheduled sessions) over the ephemeral relay, with two more subtypes:

{ subtype: 'agent.schedule.list.request', targetDeviceId, requestId, subscriberPublicKey? }
{ subtype: 'agent.schedule.list.result',  requestId, at, schedules | encrypted | error }

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; promptLocked: true says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to hwip schedule edit), maxRuntimeMin, nextAt and the last result — never a directory path or the agent's arguments. when is { kind: 'daily', at, days }, { kind: 'every', minutes } or { kind: 'once', at } (an ISO 8601 instant). A schedule into a session also holds target: 'session' and the runId it types into, and sessionEnded: true when that run is over (absent while it runs, or when tmux could not say). last.outcome may be session_busy or session_ended for those.

Your API key and redirects

Every request that carries your API key refuses to follow a redirect: a host that answers with one (to another host, or from https to http) would otherwise be handed the key. The call fails with "answered with a redirect … check the base URL", so a base URL that redirects has to be corrected rather than followed. The key is also taken out of the socket error line and the CLI's final error line (apiKey=***), and a base URL with credentials written into it (https://user:pass@host) is refused without being repeated (below).

~/.hwip is kept at 0700 and listener.log at 0600 (it can quote what the listener was handed). ~/.hwip/config.json says where your key is sent, so it is checked when read: a file that another user owns or that anyone can write is ignored with a warning (HWIP_API_KEY and --key still work, and nothing is saved over it), and one that your group or others can reach is restricted to 0600 and used (a symlink, as dotfiles use, is judged by what it points at). A config.json that cannot be read as a JSON object (a stray comma, half a file, bytes that are not UTF-8) is ignored with a warning too, and hwip install and hwip login stop before they ask for anything and name the file: they would otherwise replace it with the new key alone and drop your baseUrl, wsUrl and your other settings. Fix it, or move it aside, and run the command again. Saving is a write-then-rename, so a crash leaves the old file or the new one, and a write by another process in between is an error, not a lost update.

Run with sudo where your home folder is kept (macOS), a first hwip login or hwip install would make ~/.hwip, config.json and the device keypair root-owned, and the next plain hwip could neither read the key nor save to it. What root creates there now goes to the user who ran sudo (SUDO_UID/SUDO_GID) — only inside 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 manifest is fetched from the same base URL as everything else: with a config.json that cannot be read, hwip does not fetch it from the default stage but keeps the manifest it has.

While a config.json is ignored (it does not parse, or anyone can write it) the listener keeps running when it has a key from --key or HWIP_API_KEY (and, for a file that does not parse, a base URL from --base-url or HWIP_BASE_URL), but with every on/off option off: "turnHistory" and "keepAwake" are opt-outs, and reading an unreadable file as "nothing set" would turn them back on and start uploading the conversations you had switched off. The listener log says so, hwip verify shows a failed row naming the file, and a restart after fixing the file picks the options up again. The login service has no key in its environment, so with such a file it cannot start (exit 3, retried every 30 s) and the log names the file. A command that needs the API base URL and cannot read the file it may be in (notify, ask, trust <deviceId>, verify --ping, listener) stops and says so 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 key over plain http:// or ws:// to a host that is not on this machine draws a warning (hwip verify shows it as a warning row). A base URL with a user name or password in it (https://user:password@host) stops those commands as well: no request can be made to such an address, and the message says where it came from (--base-url, HWIP_BASE_URL or the file) without repeating it. Where a command shows which key is in use it prints the prefix only (ak_…).

The key in the file belongs to the addresses the file says (baseUrl, else the default API), so it does not follow an address from the environment: with that key, an HWIP_BASE_URL on another origin stops the command (install too, before it checks or saves the key against it), naming both origins and no more. Set HWIP_API_KEY or pass --key as well (a key given with the address goes with it), pass --base-url (your word on that command), or unset the variable; the same origin is always fine. The deprecated HWIP_WS_URL is held to the same rule: with the file's key it is used only for the default socket's origin, otherwise it is skipped with a line saying so — put wsUrl in the file to name another socket.

Defense

The listener is a remote-code-execution surface by design (it types into a shell-adjacent pane). The defense is layered:

  1. Pane guard — before injecting, the listener checks the pinned pane's #{pane_current_command}. If the pane is at an interactive shell (bash/zsh/fish/sh/dash/ksh/tcsh/csh/pwsh), the inject is refused. CC/Codex/Gemini exited ≠ phone gets free shell access. The one exception is a hwip sh session: its @hwip_shell session option names the session itself. Only a process on this machine can set a tmux option, and a value inherited from set -g names no session. The marker says you opened that shell; it is not a boundary — see what it does not stop, below.
  2. Addressed shells only — a marked shell takes a REST agent.command only when its targetDeviceId is this machine. An agent session still takes an unaddressed one, which push.new fans out to every listener of the account; for a shell, two PCs with hwip sh open in the same project would both run it. Stream input already arrives on one listener's own subscription.
  3. Nothing a shell would misread — into a marked shell the listener refuses attachments (their paths would arrive as lines, each a command), control bytes such as ESC (which can leave a bracketed paste), and a newline in an insert (a shell without bracketed paste, such as macOS /bin/bash 3.2, would run it). A multi-line body is allowed: each line is a command you meant.
  4. Audit — every input admitted into a shell session is one line in $XDG_STATE_HOME/hwip/shell-audit.log (default ~/.local/state/hwip/shell-audit.log, mode 0600; past 1 MiB it moves to shell-audit.log.1, replacing the one before): time, session, pane, kind, foreground program, and the text only when the shell itself was in front. Input to anything else — a sudo or ssh password prompt — is recorded as [N chars → "sudo"]. listener.log, which is not private, shows only the length. The foreground is judged when the input lands, so a secret read by the shell itself (read -s) is logged in full, and a command typed inside sudo -s or ssh is logged by length only.
  5. Literal injection — text is delivered as a paste of data, not typed key names; tmux escape sequences inside a message can't drive other tmux commands.
  6. Session-name allowlist — only [A-Za-z0-9._-]+ is accepted as a session target, so shell metacharacters never reach the tmux argv. Agents skip a shell: hwip agent send (and hwip send, its older name) and hwip_agent_send (from @hwip/mcp-server 3.3.0) drop agentKind: "shell" sessions from their candidates. The check runs on the sending machine only, so an older hwip on any machine of your account can still type into your shell — update every machine that runs agents before you open one.
  7. Per-session rate limit — a token bucket caps a runaway or compromised sender, shell sessions included.
  8. Agent permission gate — with one hole. Your CC/Codex/Gemini permission prompt still stands in front of every tool call the agent makes. But Claude Code's ! prefix runs a shell command directly, as typed input, with no permission prompt, and text the phone injects is typed input: !<command> sent to a Claude Code session runs on this machine. This is accepted, and it is how !hwip sh --detach opens a shell from the phone.

What the marker does not stop: anything running as your user on this machine can set @hwip_shell on a session, or run hwip sh itself — and so can the phone, while a Claude Code session (!tmux set-option …) or a marked shell is open to it. It keeps the phone from choosing panes only when neither is. And no layer authenticates the sender: whoever holds your API key, or controls the backend, can type into every marked shell and, through !, every Claude Code session.

The transport (WS) is authenticated by API key + push:read scope. Whether your backend also reads what crosses it depends on the phone handing the listener a key:

  • No key — an older app. It has no device keypair to hand the listener, so pane frames and the messages you type both cross the relay in plaintext. This listener answers that only until it has pinned a device (below); after that it refuses a request that asks for plaintext.
  • A key — the app. The phone sends its device public key and a fresh nonce when it subscribes, and every pane frame comes back inside envelope v2: ECDH P-256 between the two device keys, HKDF-SHA256 with a fresh salt and the message's purpose (stream.frame, stream.input, stream.alive, stream.started, screen.snapshot, screen.history, session.diff, schedule.list, turn.batch) as the key's info, and AES-256-GCM over data that names the purpose, both keys in send → receive order and the session. The sealed frame also holds the session name, its seq/epoch and that nonce, so a relay that cannot swap in a key of its own (below) can't move one to another stream, replay one from an earlier subscription, or hand one back as input — or as anything else this listener sealed for the phone. A history page (scrolling up past the live window) holds the same nonce and epoch, the request id it answers and where it sits in the scrollback, so a relay can't hand an earlier page back under a new request or move where the next one is asked for; only the request id rides beside it in the clear. Your keystrokes are sealed for this listener as stream.input with their seq/epoch stamps, the stream's start epoch and the phone's device id inside, so a forged restart can't replay them. The start answer (agent.stream.start.ok) says the listener seals v2 (sealVersion: 2), sealed for the phone on a sealed stream; a phone that has seen it never takes the older envelope from this machine again. A frame that fails to encrypt is dropped, never downgraded.

Two gaps. A message sent while no live stream is open falls back to REST, which is plaintext to the server unless the phone has sealed with this computer before (then it is sealed, below) — that includes every @<session> command from the agent chat, and every command typed into a hwip sh shell. Until this listener has pinned a device, a shell's screen crosses the relay in plaintext too, so keep secrets off both.

Whose key it seals for. A viewer asks for sealed content by sending its public key, and the relay could put its own there. So the listener takes a key — on a stream subscription, a chat timeline watch, a diff or a screen snapshot request — only when it is the key the account's device list holds for the device the relay says is asking. Any other key gets an error (subscriber_unverified; subscriber_key_in_use for another id asking with a key pinned for a different device) and no pane: nothing is read off the screen until the key has checked out. The device is pinned in ~/.hwip/trusted-devices.json the first time it is seen — by the listener or by a send of this computer, whichever comes first — and a key that changed (the app was reinstalled) replaces the pin with one log line, trust: <deviceId> changed its key — now pinned to the new one, and is not refused. The change is also recorded for a person (see Encryption → key changes), since nobody reads the daemon log. The app pins the listener's key the same way and stops when the device list shows another one.

Pins are a record, not a gate: the account's device list decides which key to believe, and the pins tell this listener when plaintext is over. Nothing needs hwip trust; it is there to look at the record:

hwip trust                 # this computer's fingerprint, and the pinned devices
hwip trust <deviceId>      # pin the key the account now lists for that device
hwip trust --reset         # forget every pin; each comes back as it is seen again

A fingerprint is the first 16 bytes of SHA-256 over the key, as eight groups of four hex digits. The app shows the same string for the same key on the device's page, and the listener prints its own at startup: comparing the two by eye is the check that no key was swapped in between. It is the only check for what pinning no longer catches — a backend that registers a device of its own, or lists another key for a device you know, is followed like a new phone or a reinstalled one.

A screen snapshot — the answer to agent.screen.request — is sealed for the asking device under th