-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
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:
- Publishes registry entries to a shared directory
- Runs a local inbox listener (UDS on macOS/Linux, loopback TCP on Windows)
- Injects received messages into its own sessions via
promptAsync
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.statussnapshot plus any session with spool records (restart recovery). Never bulk-adoptssession.list()output — it contains every historical session the directory ever had. -
Runtime: sessions are adopted by real activity (
session.created/updated/statusevents,chat.message, commands, tool calls). -
Removal:
session.deletedevents 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.
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.
| 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
|
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.
| 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.
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 │
| 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.
- Receipt: HTTP acceptance is only a transport receipt (message reached the inbox).
-
Final ACK:
delivered,refused,expired,dropped, orduplicate— durably retried to the sender and stored inoutbox/<sender-endpoint>. -
peer_message_statustool and/peers-outboxcommand let the sender track both stages.
Messages carry a via hop list. Chains longer than 4 hops are rejected, preventing infinite relay loops.
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.
| 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 |