paigy-harness
Phone launches persist their selected harness and Paigy session identity with the local slot.
Recovery uses that runtime, never a default Claude process. Older slots need a known named
runtime or a Claude transcript; otherwise the host logs a missing-runtime blocker.
The Antigravity adapter reconnects once after its specific subscriber fell behind updates
failure, using the conversation ID reported by agy. It asks the resumed agent to inspect
existing results before continuing, rather than replaying the original task. Missing IDs,
other errors, and a failed recovery remain visible failures. Diagnostics name the conversation,
recovery attempt, and outcome. This mitigates the vendor stream failure; it does not repair
Antigravity's internal broadcaster. Keep verbose command output in files and read bounded summaries.
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.
contact fires 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 asks through Paigy's
contact — its only channel to you; the
harness never relays its terminal output (docs/clients/desktop/companion.md Part 4, one channel) → your
reply lands on its Goal and 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 on the agent's
Goal → same stdin. You never have to wait to be asked. A Claude agent in the middle of a task
sees your words right after its next tool call, and gets them again as its next turn
(
docs/clients/desktop/companion.md Part 4, words reach a running turn).
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:
| 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 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/arbitration/arbitrate.ts still arbitrates against your
session mode and account permissions and can only lower it.
The working log, and the one thing it's allowed to do on your phone
A tool call is NOT on that table, and that is deliberate: the ⚙ line is the live texture of
a turn, and shipping it to the rail would be the working log arriving one tool at a time.
But "Working" is equally true of four minutes of pnpm test and four minutes of an agent
gently going nowhere, and the only place that told them apart was a log window on a machine
you'd walked away from.
So the tail takes a different road (paigy/activity.ts): the last two ⚙ lines ride the
presence beat, on the session's own token, and the agent's page on the phone renders
them under its standing sentence. Every property that makes it not-a-notification is load-bearing:
- Pulled, not pushed. It never rings, never queues, never waits to be answered. You see
it only if you are already looking at that one screen.
- A window, not a log. Two lines, replaced in place — the newest arrives on the bottom
row, the one before it dims. Nothing accumulates and there is no history to open.
- It expires. Overwritten in place by every beat, retired at the turn boundary, and drawn
only while the process is live (
isLive, the same window as the dot). A tail over a dead
run is the working-over-a-corpse state the party already refused.
- It stays off the rail.
entryFor still returns null for every work event, and
bridge.test.ts pins it — that assertion is now the guard on this feature too.
Only sessions the harness drives have one. A hatched identity used straight from a terminal
(PAIGY_AGENT="…" claude) emits no work events, so its page shows no strip rather than an
empty box. Note also that presence is not in the E2EE lane (e2ee/flow.md covers
notifications and responses) — which is why the publisher clips each line and shortens
absolute paths to their last two segments before it leaves the machine.
The broker has a space too
Owner, 2026-08-28: "i feel like the desktop app also needs a space for the broker, and
the queue of things to deal with after that."
Every agent on this Mac had a face on the perch and a page behind it. Paigy — the one
actually holding the work, since a note is a request addressed to the broker — had none,
even though this is the machine its sweep runs on (paigy-harness triage, #1255). You
could produce a proposal here and then had to pick up a phone to act on it.
So the home screen gains a broker card (the mark, and one standing sentence: how many
open notes, when it last swept, whether something is waiting on a click), and behind it a
page:
- What Paigy suggests —
GET /api/triage's open proposal, grouped exactly as the
phone groups it: close, stale, and one card per hatchling with the queue it would
receive underneath it. One button per group, POST /api/triage/:id/accept with no
noteIds — the same endpoint the phone's button and the CLI's --apply hit, so the
three doors cannot mean three different things by "accept". Nothing on the page has
happened; accepting is the user's, always.
- The queue — every open note, longest-ignored first, with its assignee and the same
deterministic
why a sweep would quote (no movement in 40 days). Opening a note and
answering it still belongs to the phone; this is the shape of the pile, which is what
the machine can say.
- Sweep the queue — calls
runTriage directly, not our own CLI: it is already a
function with injected network deps, and a shell-out would read the DEFAULT ~/.paigy
slot while this window is deliberately the Desktop one. A refusal (no judge on this
machine) stays on screen with the exact install line; a clean run's transcript goes,
because the cards it just produced say it better.
src/broker.ts is the whole read, and it computes nothing of its own: the queue is
noteSignals() — literally the dossier the sweep judges — so the card and the proposal
above it can never disagree about what "open" or "40 days idle" means.
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 permission asks, cannot-work notices, the turn's end as Goal progress
conversation.ts the round trip — your words into a running agent's stdin
src/triage/ evidence.ts the deterministic signals; judge.ts the one model boundary
verdict.ts the prompt + its defensive parser; triage.ts the sweep itself
src/broker.ts what Paigy is HOLDING — the open queue + the one open proposal, one read
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 unit
Parsing 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"
To start a HELPER session, use launch, not hatch: it picks the helper's own session id, mints
the identity bound to it, places the brief as the helper's first Goal (under --under GOAL
when given), and starts the session on it — so every Paigy call the helper makes is its own:
paigy-harness launch "Deploy check" --under <goalId> --harness claude "get CI green and merge"
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 | sh
Installs 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 Paigy MCP in every harness it runs (MCP_WIRES —
Claude Code, Codex, Antigravity: an agent's only channel to you) + the 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).
The window is not distributable (yet)
npm is the ONLY shipping channel: @paigy/harness installs the CLI and the whole
bridge. The host service updates itself once no session is running (src/update.ts), and
re-running curl -fsSL https://paigy.ai/install | sh updates it on demand and restarts it —
a bare npm i -g leaves the running host on the old code. The Electron window runs
from source (electron .) — there is no packaging, no Developer ID signing or
notarization, and therefore no auto-update (macOS auto-update requires the signing).
A downloaded unsigned app would hit Gatekeeper's "damaged" dialog, which is worse
than no download button. Steps for when it's time: #854.
Run it
pnpm --filter @paigy/harness dev
pnpm --filter @paigy/harness build
node apps/desktop/dist/cli.js --doctor
node apps/desktop/dist/cli.js --harness codex --cwd ~/repo "fix the flaky test"
node apps/desktop/dist/cli.js host --grant ~/projects
The window deliberately has NO run form — launching moved to the phone (docs/clients/desktop/companion.md).
It shows pairing, the party, the broker's card and queue, the doctor, the workspace
allow-list, a Stop button, and the log.
Headless flags: --harness claude|codex, --mode bypass|ask, --cwd DIR,
--grace SECONDS, --doctor; subcommands host [--grant DIR]…,
hatch NAME and launch NAME [--under GOAL] BRIEF…. 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-json nor 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. An adapter can also leave a prompt
unanswered for good (claude-agent-acp#1145); the driver cancels one after a long quiet
with no tool running (docs/clients/desktop/companion.md Part 4, one way in).
- 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.ts binds
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 to paigy-listen + PAIGY_ON_WAKE (#486); spawn-on-wake
needs the slot-lease design in docs/clients/desktop/companion.md before it auto-wires.