Skip to content

Architecture

jkrandom edited this page Aug 12, 2026 · 1 revision

Architecture

Overview

OpenCode process A                         OpenCode process B
┌──────────────────────────────┐           ┌──────────────────────────────┐
│ session A1 → endpoint/spool  │           │ session B1 → endpoint/spool  │
│ session A2 → endpoint/spool  │           │ session B2 → endpoint/spool  │
│ durable outbox ◄── final ACK ├───────────┤ local UDS/TCP listener       │
│ registry v1 + v2 ────────────┼──────────►│ promptAsync(exact session)   │
└──────────────────────────────┘           └──────────────────────────────┘
                     │                                      │
                     └──────── peers.d/ registry ──────────┘

Each opencode process loads the plugin independently. The plugin:

  1. Publishes registry entries to a shared directory
  2. Runs a local inbox listener (UDS on macOS/Linux, loopback TCP on Windows)
  3. Injects received messages into its own sessions via promptAsync

Protocol v2

Registry

Each process writes one 0600 JSON file per session endpoint plus a v1 compatibility entry in $XDG_DATA_HOME/opencode-plugin-peers/peers.d/.

Lifecycle-based adoption — only sessions alive in the publishing process are advertised:

  • Startup: adopts non-idle sessions from the session.status snapshot plus any session with spool records (restart recovery). Never bulk-adopts session.list() output — it contains every historical session the directory ever had.
  • Runtime: sessions are adopted by real activity (session.created/updated/status events, chat.message, commands, tool calls).
  • Removal: session.deleted events cascade-remove child sessions; process exit removes all entries within one stale window (30s default).

Heartbeat: every 10s, each process atomically rewrites its registry files (tmp + rename) to prove liveness. A peer is stale if its heartbeat is older than staleMs (default 30s).

Display collapse: /peers and list_agents collapse multiple session endpoints of the same process into one row (the most recently started session). Routing (send_message) still uses the full un-collapsed registry. This matches Claude Code's one-row-per-instance display.

Deterministic sort: entries are sorted by (startedAt, endpointId) to eliminate row-order jitter caused by heartbeat atomic rewrites shuffling readdir order.

Default naming

The default peer name is <dir>-<hex4> (e.g. my-app-a3f2), where the hex suffix is the last 4 chars of the per-process instanceId (random 8-char hex). This makes same-directory instances distinguishable, matching Claude Code's my-app-3f pattern. An explicit name config option or /peers-name replaces the default entirely.

Registry entry fields (v2)

Field Description
endpointId Stable hash of session ID; routing target
processId Per-process random hex; groups sessions for display
sessionId OpenCode session ID
parentSessionId Parent session (for subagent sessions)
title Session title (LLM-generated summary)
name Peer display name
directory Working directory
status idle, busy, or retry
transport UDS path or TCP URL
inboxUrl / inboxToken Legacy v1 loopback listener
timestamps startedAt, updatedAt, heartbeatAt
policy inboundPolicy, peerPermissions

v1 compatibility

Each process also writes a v1 entry for its most recently active root session. Legacy v1 peers can discover and message the process via the loopback HTTP listener. The v1 entry shares the same name and inbox.

Transport

Platform Transport
macOS / Linux Unix-domain socket (UDS)
Windows Loopback TCP

A loopback HTTP listener remains available for v1 protocol compatibility. Peers never call another process's OpenCode server directly — all communication goes through the inbox listener.

Delivery pipeline

Sender                          Receiver
  │                               │
  ├─ send_message / Sender.send   │
  │   ├─ resolve endpoint         │
  │   ├─ POST to inbox listener ──┤
  │   │                    ├─ gate (accept/auto/hold/refuse)
  │   │                    ├─ durable spool: queued/held/inflight/done
  │   │                    ├─ promptAsync (immediate, one per message)
  │   │                    └─ receipt ACK ◄─────────────────────┤
  │   │                                                          │
  │   ◄── final ACK (delivered/refused/expired/dropped) ────────┤
  │       durably retried into outbox                            │
  └─ outbox record                                               │

Spool states

State Meaning
queued Accepted, awaiting delivery attempt
held Parked for user review (hold policy)
inflight Delivery attempt in progress
done Terminal state (delivered/refused/expired/dropped)

Each message is a 0600 JSON record under spool/<endpoint>/{queued,held,inflight,done}. Atomic state transitions, process locks, deterministic message IDs, and durable deduplication make retries and restarts safe.

ACK semantics

  • Receipt: HTTP acceptance is only a transport receipt (message reached the inbox).
  • Final ACK: delivered, refused, expired, dropped, or duplicate — durably retried to the sender and stored in outbox/<sender-endpoint>.
  • peer_message_status tool and /peers-outbox command let the sender track both stages.

Loop protection

Messages carry a via hop list. Chains longer than 4 hops are rejected, preventing infinite relay loops.

Permission model

See Security-Model for the full breakdown.

Summary: opencode 1.18 does not invoke the plugin SDK's permission.ask hook. Instead the plugin listens for permission.asked / permission.v2.asked bus events and answers via client.postSessionIdPermissionsPermissionId. A turn is peer-triggered when walking from the event's tool.messageID (v2: source.messageID) up parentID reaches an injected user message. Auto-reply is "once" (allow) or "reject" (deny) per the peerPermissions option.

Key design decisions

Decision Rationale
Factory functions, not class + new opencode's loader can break new
Config hook for command registration opencode 1.18 doesn't scan plugin packages for commands/*.md
Display collapse (not adoption filtering) opencode replays historical session events at restart
import type only in src/tui.ts TUI process can't resolve runtime imports
Heartbeat timer NOT unref'd When SDK call hangs, timer must fire to unblock delivery

Clone this wiki locally