pi-hail

A pi coding-agent extension that connects a pi session to the local hail daemon so a paired phone can watch it and drive it.

Packages

Package details

extension

Install pi-hail from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-hail
Package
pi-hail
Version
0.7.0
Published
Sep 29, 2026
Downloads
1,279/mo · 964/wk
Author
schuettc
License
MIT
Types
extension
Size
191.8 KB
Dependencies
0 dependencies · 0 peers
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-hail

A pi coding-agent extension that connects each interactive pi session to the local hail daemon — so a pi you start on your Mac appears on your paired phone, streams its events there, and can take a prompt typed from the phone as if you'd typed it yourself. The extension owns only the pane it starts on, stays silent when no daemon is listening, and soft-locks each side against the other so the person and the phone never fight over the same turn.

Install

Add the extension to ~/.pi/agent/settings.json:

{
  "extensions": ["npm:pi-hail"]
}

The extension loads on every pi session but is inert unless it owns an interactive TUI pane and the hail daemon is running. A missing or slow daemon never blocks pi.

Required: answer approvals from your phone (0.6.0)

Phone approvals ride the permission system's own prompt: the permission system draws the single dialog, and pi-hail mirrors that same prompt to your phone and answers it through the fork's prompt-answerer seam. pi-hail draws no dialog of its own and raises no alerts — the tmux bell and 🔐 fire exactly as they do with pi-hail absent, and pi-auto-review's auto-confirm is untouched.

This needs two things.

1. Install the fork of the permission system under its original name, via npm's alias spec, in ~/.pi/agent/settings.json. Use the alias pin instead of plain @gotgenes/pi-permission-system — never both (they resolve to the same path on disk, so two gates can never load together):

{
  "extensions": [
    "npm:@gotgenes/pi-permission-system@npm:@schuettc/pi-permission-system@<version>"
  ]
}

The fork carries the prompt-answerer seam (registerPromptAnswerer) pi-hail needs. The requirement is enforced at runtime, visibly. On startup pi-hail looks up the permission service:

  • fork present (the seam is there) → phone approvals work;
  • the plain @gotgenes package, or an older fork build without the seam → pi-hail warns once (hail: phone approvals need @schuettc/pi-permission-system) through pi's UI and the log, and keeps phone approvals disabled; everything else in pi-hail keeps working;
  • no permission system installed at all → pi-hail runs quietly and approvals are simply absent, as before.

2. Opt pi-hail in as a prompt answerer in ~/.pi/agent/extensions/pi-permission-system/config.json. Registration alone grants nothing; authority comes from this config key (mirroring authorizerChain):

{
  "authorizerChain": ["pi-auto-review"],
  "promptAnswerers": ["pi-hail"]
}

With promptAnswerers empty (or missing), a phone answer does nothing and the Mac dialog stays the only way to answer.

Connect / Disconnect (0.3.0)

Sessions running inside tmux under proj's project/work naming stream to your phone automatically and stay openable even with the Mac asleep. The status line shows hail: disconnected · /hail connect while disconnected.

  • /hail disconnect — stop streaming this session to your phone (pi keeps running).
  • /hail connect — resume streaming; the phone catches up on what it missed.

Wire protocol (C4 summary)

One long-lived newline-delimited JSON (NDJSON) connection per pi process, to the daemon's Unix control socket at $XDG_RUNTIME_DIR/hail/daemon.sock (falling back to $TMPDIR/hail/daemon.sock on macOS). One JSON object per line.

Register (first line, extension → daemon):

{ "cmd": "session.register", "args": { "sessionId": "...", "project": "...", "work": "...", "dir": "/abs/dir", "piVersion": "...", "extensionVersion": "0.1.0" } }

Daemon replies with { "ok": true, "data": { "hostId", "daemonVersion", "accepted", "have" } } on success, or { "ok": false, "error": "version_mismatch: ..." } on a version mismatch (C6) — in which case the extension shows one notice and goes inert.

Then, one object per line, both directions:

  • extension → daemon: { "event": <pi rpc event verbatim> } · { "turn": "start" | "end" } · { "lock": "held" | "released" } · { "exit": { "code": n } } · { "refused": { "requestId", "reason": "turn_running" } } · { "ask": { "requestId", "title", "message", "toolName"?, "surface"?, "value"? } } · { "askDone": { "requestId", "outcome": "allowed" | "denied", "by": "mac" | "phone" } }
  • daemon → extension: { "prompt": { "text", "from", "requestId" } } · { "presence": { "phones": [ ... ] } } · { "answer": { "requestId", "value": "allow" | "deny" } } · { "ctl": "stop" }

An ask/askDone pair brackets a permission approval: ask mirrors the permission system's own showing prompt to the phone (rendered as a confirm card); the daemon relays the phone's answer back as { answer }, which pi-hail feeds to the permission system's prompt-answerer seam; and askDone closes the card once the prompt resolves, carrying by: "phone" when the phone answered and by: "mac" otherwise (the Mac dialog, auto-confirm, a rule, or yolo). pi-hail never draws a dialog of its own.

An ask is mirrored to the phone only while the daemon has affirmed the session is connected, and only once per requestId; the announced set is dropped on session end and on re-register (the daemon stale-acks any still-open ask).

The extension refuses a phone prompt while the local turn runs; while a phone-originated turn runs, it holds local terminal input behind a visible notice and replays it when the phone's turn ends.

Tests

node --test src/*.test.ts

Integration test (real daemon)

src/integration.test.ts runs pi-hail's socket client against the real hail daemon binary over the C4 control socket. It is opt-in and skipped by the default npm test, because it boots the actual hail binary and writes a host identity into the macOS login Keychain.

It stays off the live identity by using hail's disposable-service override (HAIL_KEYCHAIN_SERVICE, default tools.hail.itest — never the production tools.hail, which it refuses), deletes those items in teardown, and isolates the socket/state/config under a temp XDG_* (it does not override HOME, which on darwin breaks the security CLI). Run it on a clean host or a scratch account.

# 1. Build the daemon from a hail checkout on main:
(cd /path/to/hail && go build -o /tmp/hail ./cmd/hail)
# 2. Run the opt-in integration test (HAIL_KEYCHAIN_SERVICE defaults to a scratch service):
HAIL_INTEGRATION=1 HAIL_BIN=/tmp/hail npm --workspace packages/pi-hail run test:integration

It asserts the extension→daemon direction: the session.register handshake and the turn/event/exit stream. The phone→extension {prompt} round-trip is covered by the unit tests against a fake socket (a real one needs a sealed inbound relay frame from a paired peer).