@paigy/harness
v0.2.4
Published
Run Claude Code / Codex on this machine, bridged to Paigy — launchable from your phone.
Readme
paigy-harness
Runs Claude Code and Codex on your machine, mirrors every turn to your Paigy inbox, and — when you ask it to — rings your phone when an agent is blocked (#804).
Why this exists
Hooks already cover turn capture — .claude/settings.json, .codex/hooks.json, and
paigy-listen + PAIGY_ON_WAKE (#486) between them can observe and wake any harness
without a desktop app. Two things need a process that actually owns the agent:
- Permission prompts reach you. When Claude Code wants to run something destructive or edit outside the workspace, today it blocks on a terminal nobody is watching. Here it becomes an approve/deny on your phone, and the agent unblocks with your answer.
- Capture is structural, not advisory.
contactfires because a model chose to call it. A turn boundary in a harness is a fact of the runtime. - The conversation goes both ways. Your words reach a running agent's stdin — so you can steer it from a walk, not just approve what it already decided to do.
The round trip
The point isn't notifications, it's a conversation with an agent that happens to be
running on your laptop. Both directions close (paigy/conversation.ts):
- Answering. The agent's turn ends on a question → it banners → your reply goes straight back into stdin as the next turn. Without this the loop is one-directional and dies at the first idle.
- Initiating. You say something unprompted from your phone → it arrives as a
requeston the thread → same stdin. You never have to wait to be asked.
Both ride the same thread, so the phone shows one conversation. endsWithQuestion is
what decides "the agent is waiting on you" versus "here's your history", and it is
deliberately conservative: a false positive buzzes someone who was never asked anything.
Answers are read out of whatever shape they arrive in (spokenText) — a tapped option,
a spoken sentence, a whole call log — because the words are what the agent needs, not
the envelope. A silence is never turned into a prompt; the agent must not end up talking
to itself on your behalf.
It is not a chat client. The terminal stays where you work; this window is config plus a log of what's being mirrored. A second place to read agent output would defeat the point — the second place is your phone.
Approvals: bypass by default, ask when told
Buzz taught us the default (block/buzz auto-approves every session/request_permission
with allow_once): an agent that stalls on every tool call is an agent nobody runs.
So bypass is the default mode — the driver pins the adapter's permission mode to
bypassPermissions and answers whatever still asks — but unlike Buzz, every
auto-approval still lands on the rail as history. Bypass means "don't stall the
agent", never "don't tell the user".
ask mode is the original #804 behavior, one toggle away: blocked actions banner (or
ring, when destructive) and your answer unblocks the process. Ask pins the adapter to
default (manual prompts) explicitly — claude-agent-acp otherwise opens in auto, a
classifier that answers permission prompts in the user's place, which is precisely the
job this mode reserves for the user.
One rail, level per notification
Mirrored turns and blocking asks ride the same notifications rail. What separates
silent history from "answer me now" is urgency, not a second store:
| Event | Level | Why |
| --- | --- | --- |
| a turn (agent or user) | inbox | lands, never buzzes; searchable, handoff-able history |
| a blocked action | banner | claims attention; escalates on its own if unanswered |
| a blocked destructive action | call | rm -rf doesn't get to wait for the escalation ladder |
| a turn that asked you something | banner | a question filed silently is a conversation that dies |
| a turn that failed | push | worth knowing soon, not worth a sound |
| our own parse failure | — | not on the rail at all; it's our problem, not yours |
paigy/level.ts decides; apps/api/src/notify/levels.ts still arbitrates against your
session mode and account permissions and can only lower it.
The catalog and the doctor
The other thing Buzz got right: connecting a harness should be a status line and a
button, not a wiki page. harness/catalog.ts is a compiled-in table per runtime — the
binaries to probe (PATH plus the dirs a GUI app can't see; Finder-launched apps
don't get your shell PATH), an auth probe (codex login status), a login hint, and an
install one-liner. The window renders it as a doctor: ✓ ready, ◐ needs login (with the
command to run), ✗ missing (with an Install button — the command comes from the
catalog, never the renderer). Starting a half-configured harness doesn't fail
silently: the hint lands on your Paigy rail as a push (nudgeSetup).
One transport: ACP
Both harnesses speak ACP — the Agent Client Protocol, JSON-RPC over stdio — through
their adapters: @agentclientprotocol/claude-agent-acp and …/codex-acp. One driver
(acp.ts), no parser museum; ACP gave Codex the permission channel codex exec never
had, and it's the door to every other harness that speaks it (Goose, Cursor, Devin…)
as a catalog entry. The stream-json Claude adapter this replaced lives in git history.
Layout
src/harness/ events.ts the neutral HarnessEvent the driver produces
acp.ts the ACP driver — both harnesses, any ACP agent tomorrow
catalog.ts which harnesses exist, detection, auth probes, installs
session.ts spawn, line-split, lifecycle — the only impure file here
src/paigy/ level.ts event → NotifyLevel
bridge.ts mirror turns, ask permissions/questions, setup nudges
conversation.ts the round trip — your words into a running agent's stdin
src/run.ts the whole bridge, Electron-free — what main.ts and cli.ts both drive
src/main.ts Electron main; renderer/ is a doctor, a form and a log
src/cli.ts `paigy-harness` — the same bridge from a terminal or a service unitParsing lives in the drivers so the whole protocol surface is testable without spawning anything.
Pair this Mac (the beacon)
Unpaired (or still borrowing a legacy ~/.paigy slot), the window shows a QR — the
device flow's verification URL. Scan it with the phone camera, approve on the phone,
and the token lands in the app's own Desktop slot: the machine becomes its own
identity ("Mauricio's Mac"), sessions and hatched agents mint underneath it, and the
default slot goes back to belonging to whatever terminal agent paired it. Legacy
setups keep working until scanned — the beacon is an upgrade, never a wall.
Allowed workspaces
An allow-list of folders at ~/.paigy/workspaces.json — device config, shared by the
window and the headless host, so the phone's offer never depends on which entry point
is running. A session may only start inside a granted folder (the desktop equivalent
of a permission scope), curated through the window's native picker or
paigy-harness host --grant DIR. This list is exactly what the phone offers when you
launch or assign a session. The CLI's positional-run path deliberately does NOT
enforce it: a path typed into your own shell is its own grant.
Launch from the phone
While a host is running (window open, or paigy-harness host), its heartbeat
advertises ready harnesses + granted workspaces. On the phone: Agents → "+ New
session" picks from those options and gives the session the pairing flow's identity
gestures — a name and a voice — because a session IS a minted pairing: its turns and
everything you say back ride its own conversation. The notes assign picker offers
the same as "New session · " rows, naming the session after the
note and delivering the brief as its opening request.
Hatch — pair one device, mint many agents
paigy-harness hatch "Name" mints a pre-paired sibling identity under your account —
your existing pairing is the ceremony; no code, no phone round-trip. It lands in its
own ~/.paigy slot:
paigy-harness hatch "Voice bug hunter"
PAIGY_AGENT="Voice bug hunter" claude # any tool speaks as it
paigy-harness --identity "Voice bug hunter" --harness codex "fix the flaky test"Hatched agents appear on the phone's Agents screen like any pairing — renameable, re-voiceable, revocable.
Install anywhere (the curl path)
curl -fsSL https://paigy.ai/install | shInstalls the npm CLI (one self-contained file — the workspace libs are bundled) and
runs paigy-harness setup: QR-pairs the machine, hatches each detected agent's
identity (no codes), registers the MCP + statusline, grants a workspace, installs
the host service. paigy-harness service installs a
macOS launchd agent so hosting survives reboots — the host loop IS the service,
launchd just keeps it alive (the old "no daemon" note was about the WAKE channel,
which still belongs to paigy-listen).
Run it
pnpm --filter @paigy/harness dev # the window (status + host; sessions start from the phone)
pnpm --filter @paigy/harness build
node apps/desktop/dist/cli.js --doctor # or headless: paigy-harness
node apps/desktop/dist/cli.js --harness codex --cwd ~/repo "fix the flaky test"
node apps/desktop/dist/cli.js host --grant ~/projects # standby, launchable from the phoneThe window deliberately has NO run form — launching moved to the phone (companion.md). It shows pairing, the doctor, the workspace allow-list, a Stop button, and the log.
Headless flags: --harness claude|codex, --mode bypass|ask, --cwd DIR,
--identity NAME, --grace SECONDS, --doctor; subcommands host [--grant DIR]…
and hatch NAME. In ask mode the CLI is TERMINAL-FIRST: a blocked action or open
question prints in the terminal and waits --grace (default 90s); a person at the
keyboard answers in place, an unattended terminal escalates to your phone (0 = phone
immediately; non-TTY runs always go straight to the phone).
The CLI is the proof the harness doesn't need the app: run.ts/host.ts never
import Electron — a machine with only the CLI is exactly as launchable as one with
the window.
Pairing is one-time and lives in ~/.paigy — the app reads it, never mints it. If
you've never paired: paigy-harness setup.
Uninstall
curl -fsSL https://paigy.ai/uninstall.sh | sh removes the harness surface (service,
global package, workspace list, the machine's Desktop slot — other agents' slots
survive). Then revoke the machine on the phone's Agents screen: server-side tokens
outlive local files.
The FULL Paigy strip (tested 2026-08-03) additionally removes, in order: every
~/.paigy slot (unpairs ALL local agents — re-pairing resets voices), the ACP
adapters (npm rm -g @agentclientprotocol/{claude-agent-acp,codex-acp}), Electron
userData, the statusline block + paigy@paigy plugin enablement + marketplace +
mcp__paigy__* permission entries in ~/.claude/settings.json, the project
.mcp.json paigy server, the plugin cache/registry entries, and ~/.npm/_npx (so
@paigy/mcp@latest can't serve stale). Hooks can recreate ~/.paigy — delete it
LAST, after the hooks are gone.
Known gaps (#804)
- Protocol churn is the standing risk. Neither
stream-jsonnor ACP adapter behavior is a stability contract. Every driver fails soft by design: an unrecognized frame is skipped, never fatal. If mirroring goes quiet after a CLI update, re-verify the frame names in the driver doc comments first. - Auth probes are vendor commands (
claude auth status,codex login status) and can drift with CLI releases; a probe failure reads as "needs login", so drift shows up as a nagging hint, not a broken start. - No session resume, no packaging. Signing, notarization and auto-update are unstarted (they block on Apple credentials, not code).
- Concurrent sessions (2026-08-03): every claimed spec runs under its OWN minted
token — per-run identity is threaded through every network touch (
run.tsbinds submit/check/ack once per session), so parallel sessions never share a conversation. "Stop sessions" stops them all; per-session stop is UI away. - The host service IS the daemon (
paigy-harness service, launchd, KeepAlive) — it claims phone launches and runs sessions. The WAKE channel for terminal-agent replies still belongs topaigy-listen+PAIGY_ON_WAKE(#486); spawn-on-wake needs the slot-lease design in companion.md before it auto-wires.
