New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@paigy/harness

Package Overview
Dependencies
Maintainers
1
Versions
32
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@paigy/harness

Run Claude Code / Codex on this machine, bridged to Paigy — launchable from your phone.

latest
npmnpm
Version
0.3.28
Version published
Weekly downloads
790
1.02%
Maintainers
1
Weekly downloads
 
Created
Source

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. 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 (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 (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:

EventLevelWhy
a blocked actionbannerclaims attention; escalates on its own if unanswered
a blocked destructive actioncallrm -rf doesn't get to wait for the escalation ladder
a turn that failedpushworth 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"
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 | 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          # 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 phone

The window deliberately has NO run form — launching moved to the phone (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, --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-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 (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 companion.md before it auto-wires.

FAQs

Package last updated on 28 Sep 2026

Related posts