Architecture (established from relay source + CLI capabilities)
Relay supports two spawn transports. The default for Claude/Codex is PTY, but transport: 'headless' (piped stdin/stdout) works for any CLI — relay just forwards the chunks:
| Runtime |
CLIs |
How structured output works |
pty |
Any CLI (default for Claude, Codex, Grok) |
ANSI escape sequences → xterm renders raw |
headless |
Any CLI (default for OpenCode) |
piped stdin/stdout → relay forwards chunks via worker_stream |
Claude — no relay changes needed
Pear passes --output-format stream-json --include-partial-messages as spawn args. Relay spawns Claude as a piped subprocess. Claude outputs NDJSON to stdout. Relay forwards chunks via worker_stream → broker:pty-chunk IPC. Pear renderer parses NDJSON and formats for xterm (V1) or custom UI (V2).
NDJSON shape to parse:
{ "event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } } }
Codex — relay has native app-server support
codex_session.rs in the relay broker uses .stdin(Stdio::piped()).stdout(Stdio::piped()) — Codex's app-server protocol is already understood by relay. Spawn with transport: 'headless' and relay handles the rest.
OpenCode — headless via HTTP SSE (different mechanism)
OpenCode runs opencode serve --port N and exposes /event SSE. Events: message.delta, file.changed, permission.requested.
Grok — PTY only (for now)
No structured output format available from xAI yet. Tracked in #141.
What "no PTY" means for user-facing features
| Feature |
PTY (current) |
Headless (Claude/Codex/OpenCode) |
Slash commands (/clear, /compact) |
✓ typed into PTY stdin |
✓ written to piped stdin — Claude/Codex honor them |
| Image/screenshot paste |
✓ xterm clipboard → PTY |
✗ no clipboard path — needs explicit upload UI |
| Trust/permission dialogs |
✓ relay auto-handles |
Claude stream-json: no interactive prompts; --dangerously-skip-permissions or MCP trust needed |
Relay agent-relay view attach |
✓ PTY snapshot |
✗ headless has no view |
Image paste is the main regression risk when moving off PTY. For V1, this is acceptable if documented. V2 can add an explicit image attach button.
Work streams
Stream 1 — Merge immediately (safe, no dependencies) ✅
Merge order: #142 and #143 first, then #138 (resolve trivial 3-way conflict in SpawnAgentDialog, TerminalPane, SpawnAgentCli).
Stream 2 — Claude headless + NDJSON rendering (V1)
Goal: spawn Claude with --output-format stream-json, parse NDJSON chunks in the renderer, display clean formatted text in xterm. No raw JSON visible.
Steps:
broker.ts: route Claude spawns through spawnCli({ transport: 'headless', args: ['--output-format', 'stream-json', '--include-partial-messages'] }).
- Track
runtime per agent session in BrokerManager.
- Renderer: for headless Claude agents, parse
broker:pty-chunk NDJSON before passing to xterm — extract content_block_delta.text_delta.text, emit clean text only.
- Handle trust/permissions: either
--dangerously-skip-permissions flag or confirm MCP trust approach in relay.
Note: PR #139 has the right shape for this work but has two blockers — relay 8.3.0 lockfile not updated, and the NDJSON parsing (step 3) is absent so raw JSON hits xterm. Fix both before merging.
Stream 3 — Codex headless + app-server rendering (V1)
Relay's broker already understands Codex's app-server protocol (codex_session.rs). Steps:
broker.ts: route Codex spawns through spawnCli({ transport: 'headless' }).
- Parse Codex app-server event chunks in renderer:
AgentMessageDelta, FileChangePatchUpdated, ApplyPatchApprovalParams.
- Emit formatted text to xterm.
Can be done in same PR as Stream 2 or separately.
Stream 4 — OpenCode headless routing + SSE rendering (V1)
PR #140 has the right idea but depends on PR #139. Once #139 is fixed and merged:
- Route OpenCode through
spawnCli({ transport: 'headless' }).
- Parse
message.delta SSE chunks → formatted xterm text.
Stream 5 — V2 native Pear UI (all headless CLIs)
No xterm for headless agents. Replace terminal pane with React components:
message.delta → message bubbles
file.changed → diff cards
permission.requested → native approval dialogs
- Claude tool use events → collapsible tool call cards
Separate PR track, not blocking V1.
Stream 6 — Grok headless (blocked on xAI, tracked in #141)
Nothing to build until xAI ships structured output. When it arrives, follows Stream 2 pattern.
PRs at a glance
| PR |
Title |
Status |
Action |
| #138 |
Grok harness |
Open, ready |
Merge after #142/#143 |
| #139 |
relay 8.3 + headless Claude/Codex routing |
⛔ DO NOT MERGE yet |
Fix two blockers: (1) run npm install and commit lockfile, (2) add NDJSON parsing in renderer before merging |
| #140 |
OpenCode headless routing |
⛔ DO NOT MERGE yet |
Depends on #139 being fixed first |
| #141 |
Grok headless |
Open issue |
Blocked on xAI |
| #142 |
Terminal visual tweaks |
Open, ready |
Merge first |
| #143 |
OpenCode in spawn UI |
Open, ready |
Merge first |
| #146 |
Cloud workspace key |
Draft |
Separate track (#125) |
| #132 |
Slack integration fixes |
Open |
Separate track |
Agent team pickup checklist
Architecture (established from relay source + CLI capabilities)
Relay supports two spawn transports. The default for Claude/Codex is PTY, but
transport: 'headless'(piped stdin/stdout) works for any CLI — relay just forwards the chunks:ptyheadlessworker_streamClaude — no relay changes needed
Pear passes
--output-format stream-json --include-partial-messagesas spawn args. Relay spawns Claude as a piped subprocess. Claude outputs NDJSON to stdout. Relay forwards chunks viaworker_stream→broker:pty-chunkIPC. Pear renderer parses NDJSON and formats for xterm (V1) or custom UI (V2).NDJSON shape to parse:
{ "event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } } }Codex — relay has native app-server support
codex_session.rsin the relay broker uses.stdin(Stdio::piped()).stdout(Stdio::piped())— Codex's app-server protocol is already understood by relay. Spawn withtransport: 'headless'and relay handles the rest.OpenCode — headless via HTTP SSE (different mechanism)
OpenCode runs
opencode serve --port Nand exposes/eventSSE. Events:message.delta,file.changed,permission.requested.Grok — PTY only (for now)
No structured output format available from xAI yet. Tracked in #141.
What "no PTY" means for user-facing features
/clear,/compact)--dangerously-skip-permissionsor MCP trust neededagent-relay viewattachImage paste is the main regression risk when moving off PTY. For V1, this is acceptable if documented. V2 can add an explicit image attach button.
Work streams
Stream 1 — Merge immediately (safe, no dependencies) ✅
Merge order: #142 and #143 first, then #138 (resolve trivial 3-way conflict in
SpawnAgentDialog,TerminalPane,SpawnAgentCli).Stream 2 — Claude headless + NDJSON rendering (V1)
Goal: spawn Claude with
--output-format stream-json, parse NDJSON chunks in the renderer, display clean formatted text in xterm. No raw JSON visible.Steps:
broker.ts: route Claude spawns throughspawnCli({ transport: 'headless', args: ['--output-format', 'stream-json', '--include-partial-messages'] }).runtimeper agent session inBrokerManager.broker:pty-chunkNDJSON before passing to xterm — extractcontent_block_delta.text_delta.text, emit clean text only.--dangerously-skip-permissionsflag or confirm MCP trust approach in relay.Note: PR #139 has the right shape for this work but has two blockers — relay 8.3.0 lockfile not updated, and the NDJSON parsing (step 3) is absent so raw JSON hits xterm. Fix both before merging.
Stream 3 — Codex headless + app-server rendering (V1)
Relay's broker already understands Codex's app-server protocol (
codex_session.rs). Steps:broker.ts: route Codex spawns throughspawnCli({ transport: 'headless' }).AgentMessageDelta,FileChangePatchUpdated,ApplyPatchApprovalParams.Can be done in same PR as Stream 2 or separately.
Stream 4 — OpenCode headless routing + SSE rendering (V1)
PR #140 has the right idea but depends on PR #139. Once #139 is fixed and merged:
spawnCli({ transport: 'headless' }).message.deltaSSE chunks → formatted xterm text.Stream 5 — V2 native Pear UI (all headless CLIs)
No xterm for headless agents. Replace terminal pane with React components:
message.delta→ message bubblesfile.changed→ diff cardspermission.requested→ native approval dialogsSeparate PR track, not blocking V1.
Stream 6 — Grok headless (blocked on xAI, tracked in #141)
Nothing to build until xAI ships structured output. When it arrives, follows Stream 2 pattern.
PRs at a glance
npm installand commit lockfile, (2) add NDJSON parsing in renderer before mergingAgent team pickup checklist
npm install, commit lockfile; add NDJSON renderer parsing (Stream 2 step 3)