Harness orchestrator in Rust. Spawn ACP-adapter coding harnesses (Claude Code, Gemini, OpenCode, Codex, pi) as long-lived sessions, persist every turn as JSONL, attach multiple observers, and drive everything from a CLI, WebSocket, or MCP-aware LLM — all through one daemon, one journal, one control protocol.
Vocabulary (post-rename):
- harness — one of the ACP-adapter binaries roy spawns
(
claude-code-acp,gemini,opencode,codex-acp,pi-acp). Configured in~/.config/roy/harnesses.toml. - agent — a persona, defined in a
.roy/agents/<slug>.mdfile with YAML frontmatter (name,description,harness, optionalmodel) and a body that becomes the session's system prompt. - session — one live conversation between roy and a harness, optionally backed by an agent persona.
This is a monorepo. Three formerly-separate trees now live here:
crates/+Cargo.toml— the Rust workspace (the daemon and every adapter).workspace/— the Svelte SPA front-end (formerly theroy-webrepo).docker/— the container bundle: Dockerfiles,docker-compose*.yml, nginx config (formerly theroy-dockerrepo). The docker build context is this repo root; seedocker/README.md.
roy started as a Rust library that wraps coding-harness CLIs as a single
Session::send(prompt) -> Stream<TurnEvent> API. It now ships as a small
workspace with two crates:
crates/roy— the library.SessionEngineruns an agent in an actor task that pipes every event into a per-session JSONL journal and a bounded broadcast channel;SessionManagerkeeps the registry of live sessions; theDaemonexposes the registry over Unix-socket and WebSocket triggers; the underlying transport speaks ACP via the officialagent-client-protocolSDK.crates/roy-cli— theroybinary. Eight subcommands plus an MCP server mode. Each subcommand is a thin trigger client over the daemon's Unix socket.
┌──────────────────────────────────────────────────────────┐
│ roy serve (single-instance daemon, ~/.roy/daemon.sock) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SessionManager │ │
│ │ ├ SessionEngine { id, journal, broadcast, … } │ │
│ │ ├ SessionEngine { … } │ │
│ │ └ … │ │
│ └──────────────────────────────────────────────────┘ │
│ ▲ Unix socket ▲ WebSocket ▲ stdio MCP │
└───┼────────────────┼───────────────┼─────────────────────┘
│ │ │
roy run / fire WS client LLM via roy mcp
roy wait (browser/IDE)
roy attach
roy list / list-archived
roy resume / close
roy set-tags
Each trigger speaks the same JSON control protocol (ClientCommand /
ServerEvent enums); only the framing differs. The roy-side normalised
event shape (event_to_json) is identical on CLI stdout, in the JSONL
journal, and in WS/MCP frames.
cargo build --release
# the binary lands at target/release/roy
# put it on $PATH or alias itThe agents themselves are NOT bundled. Install whichever ones you intend to use:
| agent | how |
|---|---|
gemini |
the Google Gemini CLI (npm i -g @google/gemini-cli), logged in |
opencode |
the OpenCode CLI on $PATH, logged in |
codex |
npm i -g @zed-industries/codex-acp |
claude |
npm i -g @zed-industries/claude-code-acp + API auth |
Start the daemon in one terminal:
roy serve # listens on ~/.roy/daemon.sock
# optional knobs:
roy serve --port 7777 # also expose WebSocket on :7777
roy serve --idle-timeout 600 # auto-close sessions idle > 10 min
roy serve --resume-all # resurrect every archived session on startup
roy serve --socket /tmp/roy.sock # custom socket path
roy serve --journal-dir /var/lib/roy/log # custom journal locationDrive it from another terminal:
# one-shot: spawn opencode, send a task, stream events, exit on Result.
roy run opencode "explain this repo's architecture"
# fire-and-forget: same as above but exit right after sending; the session
# keeps running on the daemon.
roy run --detach opencode "rewrite the README and open a PR"
# list live + archived sessions.
roy list
roy list-archived
# tail a session's journal (live broadcast).
roy attach <session-id>
roy attach <session-id> --from-seq 42 # replay from this seq onward
# bring a closed session back as a live engine.
roy resume <session-id>
# close a live session.
roy close <session-id>stdout is always one JSON object per line (the event_to_json shape; see
docs/wire-protocol.md). stderr carries
structured logs from tracing — RUST_LOG=roy=debug roy serve for verbose
output.
Exit codes: 0 on a clean terminal Result, 1 if the agent stopped with
an error stop reason, 2 for CLI-level failures (no daemon, bad flag, etc.).
crates/roy can be driven in-process instead of over the socket. See
crates/roy/examples/engine_two_attach.rs for a runnable demo (spawn a
session, attach two observers, stream TurnEvents to a terminal Result).
roy mcp is a stdio MCP server. Spawn it from any MCP-aware host (Claude
Desktop config, IDE plugin, etc.):
{
"mcpServers": {
"roy": {
"command": "roy",
"args": ["mcp"]
}
}
}roy mcp is a thin bridge — it requires roy serve to be running. Tools
exposed:
| tool | what |
|---|---|
roy_list_sessions |
live sessions |
roy_list_archived |
sessions whose journals exist on disk but aren't live |
roy_run |
spawn + send + wait for Result, return text + stop reason |
roy_run_detached |
spawn + send, return session id (LLM polls with roy_read_session) |
roy_read_session |
paginated journal snapshot (live or archived) |
roy_close |
close a live session |
roy_set_tags |
replace the tag map on a live session (pass {} to clear all) |
roy_wait_for_result |
long-poll for the next terminal Result on a session |
roy_fire |
one-shot Spawn-or-Resume + Send + WaitForResult |
Every session writes a JSONL journal (<session_id>.jsonl) under the journal
dir, and a boot-kit row in ~/.local/state/roy/sessions.db. After the daemon
restarts:
roy list-archivedshows surviving session ids;roy attach <id>returns a read-only replay of the journal;roy resume <id>(orroy serve --resume-all) brings the session back to life. The roy-side journal continues from its last seq; the agent-side cursor (ACPsessionId) is replayed intoTransport::open, so agents that persist their own session (Gemini, OpenCode, ...) continue where they left off.
roy serve holds a PID lock at <socket>.pid. A second roy serve on the
same socket exits with protocol error: daemon already running (pid N). If
the daemon died unclean (e.g. kill -9), the next start detects the dead
PID and takes over.
The WebSocket listener (when enabled via --port) currently has no
auth — bind only on 127.0.0.1 and trust the local user, or front it
with something that does auth.
crates/
roy/ library: engine, journal, manager, daemon, control, transport
roy-cli/ binary `roy`: run/attach/list/list-archived/resume/close/serve/mcp
docs/
superpowers/specs/ design docs for the major iterations
CLAUDE.md project memory for code-assistant sessions
README.md this file
cargo test --workspace # ~45 tests; uses hermetic fake agents
cargo test --workspace -- --ignored # additionally runs smoke tests against the real claude/gemini/opencode/codex CLIs (need them installed + logged in)TBD.