Repository navigation
Send messages to Claude Code peers over its local socket protocol #85
Description
Proposal
Depends on claude-code-peer-source. Seeing peers comes first and is independently
useful; this change is the part that carries risk, and it is separated so that risk cannot
contaminate the roster.
Status: outbound shipped; inbound (Phase 3) explicitly greenlit and in progress (2026-09-17),
superseding this section's earlier "deferred"/"not sanctioned" language — see tasks.md's
Phase 3 for the current, accurate state. Phase 0's spike passed (GO). Outbound messaging
(Phase 1) is implemented and live-tested against this machine's real Claude Code sessions —
packages/opencode/src/peer/claude/{registry,codec,client,resolve}.ts, wired into
send_peer_message, on by default (OPENCODE_DISABLE_CLAUDE_CODE_PEER_MESSAGING opts out).
Inbound was originally deferred pending its own go-ahead, per the reasoning below (still the
right reasoning — it's why this is called out explicitly rather than silently proceeding).
The user gave that explicit go-ahead on 2026-09-17 ("It is not bigger, let us add it now"),
so Phase 3 is now underway: a per-session sidecar process, real registration/socket/protocol
handling, all tagged managedBy: "opencode-skein" in ~/.claude/sessions/ so opencode-skein's
own writes are always distinguishable from and never overwrite a real Claude Code session
(see sidecar-registry.ts and Phase 3.0 in tasks.md). This is still "a write into another
tool's state directory," now done deliberately and with an explicit safety contract rather
than being avoided. The original reasoning for deferring — a persistent extra process per session, a bigger
separately-reviewable commitment — is exactly why it's tracked here in detail rather than
assumed safe by default. Phase 3 now works end-to-end, verified against a real running
opencode serve process (see tasks.md 3.1/3.2): a real session gets a real sidecar, a real
Claude-shaped socket message reaches it, and lands as a synthetic prompt on the real session.
On by default as of 2026-09-17 (OPENCODE_DISABLE_CLAUDE_CODE_PEER_MESSAGING opts out).
The sidecar is settled, not provisional (2026-09-18). A proposal to replace it with
in-process registration was tested and rejected on evidence: Claude keys discovery on the
<pid>.json filename, so one process has exactly one reachable identity, and the user's
requirement is that every session be reachable. A dedicated real process per top-level
session is therefore what the protocol permits, not a workaround. The full protocol — with
that rule first and the spike as its evidence — is now written down as the
local-agent-peer-protocol capability in this change, so a third harness can implement it.
The socket-path risk flagged by review was real, and is now fixed and verified. The
sidecar originally registered under /tmp/opencode-cc-socks/, not one of the root prefixes
findings.md recorded Claude actually validating peer addresses against. Confirmed live: that
registration never appeared in claude agents --json at all — silent exclusion, not a
refusal. Fixed by moving to /tmp/cc-socks (the darwin root; Linux's
$XDG_RUNTIME_DIR/cc-socks equivalent is not yet handled). Then verified against real,
unmodified Claude Code tools — not the hand-rolled test client used everywhere above — this
session's own ListAgents found the sidecar within 4 seconds, and SendMessage to it
delivered successfully and landed as a real prompt on the real opencode session, with correct
sender attribution. Phase 3's core mechanism is no longer just internally consistent; it works
against the real thing.
A shutdown gap found in the same pass, now fixed at the source. Neither a graceful stop
(stopAllSidecars()'s finalizer never fires on a real SIGTERM — serve.ts has no signal
handler wired to interrupt the Effect scope) nor the next-boot orphan sweep (which explicitly
skips any registration whose pid is still alive) reclaimed a sidecar whose parent died without
signalling it — Process.spawn children outlive their parent by default. That made a killed
server leave a live "ghost peer": still discoverable, still able to authenticate a connection,
silently dropping whatever it received. Fixed by having the sidecar detect its own orphaning
(a changed ppid, checked every 2s) and self-terminate/self-unregister — this covers a
graceful stop, SIGTERM, SIGKILL, and a hard crash with one mechanism, independent of
anything the parent does. Verified live (a real SIGKILL to a real running server, confirmed
self-cleanup within ~1s) and covered by an automated regression test.
Why
With the roster in place, the human still routes every cross-agent question by hand: read
one terminal, retype the answer into another. The agents can see each other and cannot
speak. The asymmetry is the point of friction — a skein session that knows
fieldforms-be exists and is idle still cannot ask it what shape the endpoint landed in.
What is actually there
Observed on this machine, 2026-09-11, Claude Code v2.1.268. All of it is undocumented
and private. It is recorded here as findings, not as a contract:
- Each session listens on a unix socket,
messagingSocketPathin
~/.claude/sessions/<pid>.json, typically/tmp/cc-socks/<pid>.sock, mode 0600.
Other permitted roots are/private/tmp/cc-socks*,/run/user/<uid>/cc-socksand a
Termux path. - Auth is a bearer token in
~/.claude/sessions/<pid>.<sha256-of-canonical-socket-path>.key,
as{"peerToken":"<32 hex>","procStart":…,"pidDomain":…}. The first frame written to
the socket is{"type":"auth","token":"<peerToken>"}. - The registry advertises
peerProtocol: 1and apeerFeaturesstring array
(observed:notify_idle,reply_across_default_dirs,artifact_yield). - Addresses are validated against
^(?:uds|bridge|did):….bridge:is a first-class
scheme, which suggests non-Claude peers reached through a bridge are an anticipated
case. Whether it is usable from outside is exactly what Phase 0 must answer.
What Changes
Phase 0 first: a spike on bridge:
Before any implementation, establish what a non-Claude process may actually do:
- Whether a skein instance can be addressed as a
bridge:peer, and what registration
that requires. - Whether Claude Code sanctions third-party writes into
~/.claude/sessions/.
Assume not until shown otherwise. - The message framing and its reply semantics.
Three outcomes, decided before code is written:
- Bridge works → skein registers as a bridge peer; inbound and outbound both follow,
and Claude sessions see skein sessions as ordinary peers. This is the goal. - Outbound only → implement sending, and say plainly that inbound is not available.
- Neither → archive this change. Do not ship a half-working inbound path; an agent
that believes it can be reached and cannot is worse than one that knows it cannot.
Outbound messaging, if the spike allows it
A client that resolves a peer to its socket, reads the token for that exact socket path,
sends the auth frame, then the message. Gated by experimental.claude_peer_messaging,
default off, following the agent_worktree_isolation precedent for changes that reach
outside the process.
Hard-gated on peerProtocol === 1. Any other value refuses with a diagnostic naming the
observed value and the version this was written against. The failure mode must be a
clear refusal, never a best-effort guess at a changed wire format.
When messaging is available for a peer, canPrompt is published true for that peer;
otherwise it stays false. The roster must not advertise a capability it cannot perform.
What may be sent
The constraint from agent-coordination-bus applies in full, and is the reason this
change is narrow: structured, verifiable facts with a stated source. "endpoint
POST /x merged, response shape attached", "suite S failed, exit 1, output attached",
"I am working slug Y in worktree Z". Not "I think we should refactor the parser".
That proposal's analysis is the governing one here: when agents exchange free-form
reasoning, one agent's speculation becomes another's premise, the group converges on a
confident conclusion nobody had evidence for, and the agreement itself reads as
corroboration. Enforce the constraint in the tool's own description and message shape,
not only in prose — the description is what the model actually reads.
Capabilities
MODIFIED Capabilities
agent-presence: control capabilities may be publishedtruefor reachable peers.
New Capabilities
agent-messaging: outbound structured messages to peer agent sessions on this host.
Non-Goals
- No broadcast and no bus. Point-to-point to a named peer only. A bus is
agent-coordination-bus, still gated. - No claiming, locking or work assignment. Messaging is not consensus; see
provider-slot-leases. - No free-form agent conversation, and no relaying of transcripts.
- No cross-host messaging. Local sockets only.
- No modification of Claude Code, and no plugin or hook installed into it.
- No writes into
~/.claude/unless Phase 0 establishes that as sanctioned.
Risks
- The protocol is private and can change in any release. This is the defining risk and
it cannot be mitigated away, only contained: experimental flag, default off, hard
version gate, and a roster that is unaffected when messaging breaks. - Peer tokens are credentials. They must be read at the moment of use, never logged,
never written to disk by skein, never placed in a model's context, and never included in
an error message or a spec. - Sending to the wrong session. Pids are reused. Validate
procStart/pidDomainfrom
the key file against the live process before sending.
Impact
- New: a peer messaging client and a tool exposing it, both behind one flag.
- Changed: capability publication in the presence source.
- If Phase 0 says no, the impact is one archived proposal and a documented finding.
Disposition (2026-09-18)
Shipped. Shipped both directions via the sidecar (on by default). 2.2 done 2026-09-18 (canPrompt = alive + messaging enabled); 3.3 moot (inbound exists); 4.1 verified live 2026-09-17; 4.2 covered by the protocol-version unit test.
Tasks
Phase 0: Spike — decide before building
- 0.1 Determine whether a non-Claude process can be addressed as a
bridge:peer,
and what registering as one requires- Validation: a written finding with evidence, and a go/no-go recommendation
- Done in
claude-peer-protocol-spike(2026-09-05): confirmed via a disposable test
peer. See that change'sfindings.md.
- 0.2 Determine whether third-party writes into
~/.claude/sessions/are sanctioned- Default answer is no; record what changed the answer if it changes
- Validation: written finding
- Decision (2026-09-17): treated as not sanctioned for a persistent, always-on
registration. This change ships outbound-only and writes nothing under
~/.claude/— see the Phase 3 note below.
- 0.3 Establish framing and reply semantics after the auth frame
- Validation: a documented request/response transcript against a throwaway session
- Done in the spike; reply mechanism confirmed but not exercised live (see
findings.md, "Return address / reply mechanism").
- 0.4 Decision gate. Record the outcome — bridge / outbound-only / archive — and
stop here if it is archive- Validation: the decision is written into this proposal before Phase 1 starts
- Decision: outbound-only, this pass. See
proposal.md's status line.
Phase 1: Outbound client
- 1.1 Resolve peer → socket path → matching
<pid>.<sha256>.key- Validation: unit test over a fixture registry, including a socket with no key
- Done:
src/peer/claude/registry.ts,test/peer/claude/registry.test.ts.
- 1.2 Validate pid identity via
procStart/pidDomainbefore connecting- Validation: test that a reused pid with a mismatched start time is refused
- Done:
verifyProcessIdentityinregistry.ts, tested against a real live pid
(this process) and a deliberately wrongprocStart.
- 1.3 Connect, send the auth frame, send the message, handle the reply
- Validation: integration test against a live local Claude session
- Outbound half done and tested against a fake listening socket
(test/peer/claude/client.test.ts) with real frame bytes asserted. "Handle the
reply" is N/A without a sidecar (Phase 3, deferred) — there is no inbox to
receive one on yet; the tool description states this plainly.
- 1.4 Hard-gate on
peerProtocol === 1; refuse anything else with a diagnostic
naming the observed value- Validation: test with a registry entry advertising protocol 2
- 1.5 Never log, persist, or surface a peer token
- Validation: test asserts no token appears in logs, errors, or serialized state
- Done: the token is read in
registry.ts, used once inclient.ts's frame
construction, and never placed in a returned result, error message, or log line.
Phase 2: Gating and exposure
- 2.1
disableClaudeCodePeerMessagingineffect/runtime-flags.ts
(OPENCODE_DISABLE_CLAUDE_CODE_PEER_MESSAGING), on by default as of
2026-09-17 (was default-off behind an experimental flag)- Validation: with the flag set, no socket is opened and
canPromptstays false
- Validation: with the flag set, no socket is opened and
- 2.2 Publish
canPrompt: trueonly for peers actually reachable now- Validation: a peer whose socket is gone publishes false
- Deliberately not done:
canPromptstill means "can be prompted and can reply",
which this outbound-only channel cannot honor — flipping it totruewould
advertise a capability that isn't real. Revisit once/if Phase 3 lands.
- 2.3 Expose a tool whose description constrains content to structured facts with a
stated source, and rejects free-form reasoning- Validation: the description states the constraint; review confirms the message shape
carries a source field - Done:
send-peer-message.txtstates the outbound-only limitation and the
structured-fact framing is inherited from the existing peer-messaging tool
description (short, point-to-files coordination, not free-form chat).
- Validation: the description states the constraint; review confirms the message shape
Phase 3: Inbound — now in progress (2026-09-17), per-session sidecar
- 3.0 Cleanup/attribution safety, built first since it's the load-bearing
correctness requirement for everything else in this phase- Every file opencode-skein ever writes into
~/.claude/sessions/is tagged
managedBy: "opencode-skein"— the entire safety contract: nothing here
ever touches a file lacking that marker, and a stale sweep only removes a
marked entry whose pid is confirmed dead. - Done:
src/peer/claude/sidecar-registry.ts
(writeSidecarRegistration/removeSidecarRegistration/sweepStaleSidecars/
listManagedPids),test/peer/claude/sidecar-registry.test.ts. - Also done:
presence-claude.tsexcludes our own managed pids from the
Claude-peer roster, cross-referencing the raw registry file directly
(claude agents --jsonsilently drops unknown fields likemanagedBy,
so filtering can't rely on the CLI path alone).
- Every file opencode-skein ever writes into
- 3.1 Register the skein instance as a bridge peer
- Validation: a skein session appears in a Claude session's peer list
- Done:
src/peer/claude/sidecar-server.ts(registration + real UDS server- protocol handling),
sidecar-entry.ts(the actual spawned script — a
genuinely separate process, satisfying Claude's real-pid/real-procStart
requirement),sidecar-manager.ts(spawns/tracks/stops sidecars from the
main server process, only whenwhich("claude")finds a real install).
End-to-end tested by spawning the real entry script and connecting to it
exactly as a real Claude peer would (test/peer/claude/sidecar-e2e.test.ts).
- protocol handling),
- Design departure from "HTTP callback to the owning server": the sidecar
talks to its parent over stdout only (one NDJSON event per line), not
HTTP. This avoids inventing any new auth surface (no server port/password
to hand the child) — the parent already has full access to the real
session via the Effect services that spawned it.
- 3.2 Accept, authenticate and route inbound messages to the right session
- Validation: a message sent from Claude reaches the addressed skein session
- Done for accept/authenticate/decode: real auth-token check (wrong token
⇒ connection refused, tested), envelope stripped via
codec.ts#parseEnvelope, forwarded tosidecar-manager.ts's injected
deliver(sessionID, text)callback — tested end-to-end
(test/peer/claude/sidecar-manager.test.ts) with a real spawned sidecar
and a real socket message. - Done:
src/peer/claude/lifecycle.ts— a new Effect service
(ClaudeSidecarLifecycle, registered ineffect/app-runtime.ts's
LayerNode graph alongside the other fork services) that subscribes to
Session.Event.Created/Deleted, callsensureSidecar/stopSidecar
accordingly, and wiresdelivertoSessionPrompt.Service.prompt(..., synthetic: true)— the same injection primitivesend_peer_message
uses. Inert only whendisableClaudeCodePeerMessagingis set. - Verified:
bun typecheckclean; the broad session/effect test suites
pass unchanged with this new node in the boot graph — a circular or
broken layer dependency would have failed layer construction for all of
them, not just this feature's own tests. - Real bug found by a live smoke test (2026-09-17), root-caused and
fixed.server/routes/instance/httpapi/server.tsbuilds its own,
separateLayerNode.group([...])— the actual root the HTTP server
boots from — distinct fromeffect/app-runtime.ts'sAppLayer.
ClaudeSidecarLifecycle.nodehad only been added to the latter, so a
session created over the real HTTP API never reached it. Fixed by adding
the node toserver.ts's own graph too (same placeAutoMode.node/
Loop.node/SideQuestion.nodeare already joined for the same reason). - A second real bug surfaced once the event reached the listener:
SessionPrompt.Service.prompt(...)requiresInstanceRef(the resolved
project instance), which a tool call normally inherits from the request
that invoked it — a background event listener has no such context.
Fixed by resolving it explicitly viaInstanceStore.Service.load({ directory: info.directory })and providing it with
Effect.provideService(InstanceRef, instance)around thepromptcall. - Verified end-to-end against a live
opencode serveprocess, for
real: booted the server with the flag on, created a real session over
HTTP, connected to the resulting sidecar's real socket exactly as a real
Claude peer would (raw auth + message frames), and confirmed via the
HTTP API that the real message part
("hello from a real smoke test round 2", wrapped in the peer-message
provenance envelope) landed on the real session. This is no longer a
hypothesis — inbound genuinely works end to end with the flag on. - Also added while fixing: subagent/child sessions (
info.parentIDset)
no longer get their own sidecar — one per top-level session only; the
sidecar's self-reported sender name now reaches the injected message
(previously hardcoded to "Claude Code")
so a multi-peer setup can tell which Claude session actually sent it;
sidecar-manager.tsnow pipes and logs the child's stderr instead of
discarding it, since a sidecar that fails silently at startup was
exactly the kind of thing that made this bug hard to see. - A third real bug, found and fixed (2026-09-17): the socket directory
was invisible to real discovery. The sidecar registered under
/tmp/opencode-cc-socks/, not one of the roots findings.md recorded
Claude actually validating peer addresses against. Confirmed live: a
perfectly well-formed registration there never appeared in
claude agents --jsonat all — not a refusal, silent exclusion. Fixed
by moving the default to/tmp/cc-socks(the darwin root, shared safely
since every socket is pid-named and pids are unique). Linux's
equivalent ($XDG_RUNTIME_DIR/cc-socks) is not yet handled. - Verified against real, unmodified Claude Code tools, not a hand-rolled
client: after the fix, this session's ownListAgentslisted the
sidecar within 4 seconds of session creation, and this session's own
SendMessageto it returnedsuccess: trueand the message landed on
the real opencode session with the correct sender identity attached.
This is the test the proposal's "one real risk" section called for —
it now passes. - A fourth gap, found during this same verification pass and now fixed
properly rather than patched around. ThestopAllSidecars()
graceful-shutdown finalizer does not fire on a realSIGTERMto the
server (serve.ts'sEffect.neverhas no signal handler wired to
interrupt the Effect scope) — confirmed live. Worse, a peer review
caught thatsweepOrphanedSidecars()on next boot does not cover
this case either: it only reclaims a registration whose pid is also
dead (sweepStaleSidecarsexplicitly skips a live pid), and
Process.spawnchildren don't die with their parent. So a killed
server left a live "ghost peer" sidecar — still discoverable, still
able to authenticate a connection, with no parent left to turn what it
receives into a real prompt — which fails as silent message loss, not a
visible error, and would sit there indefinitely (a SIGTERM handler in
serve.tsalone couldn't have fixed the SIGKILL/hard-crash case
either). Fixed at the source instead: the sidecar detects its own
orphaning (itsppidchanging from what it was at spawn — the OS
reparents an orphan to pid 1 or the nearest subreaper) via a 2s poll,
and self-terminates and self-unregisters the moment it notices,
covering graceful stop,SIGTERM,SIGKILLand a hard crash with one
mechanism, insidecar-entry.tsitself — no dependency on the parent
signalling it at all. Verified live: hard-SIGKILLed a real running
server with a real sidecar registered, confirmed the sidecar'sppid
became1within 1s and the process and its registration were both
gone within that same window — and covered by a new automated
regression test (sidecar-e2e.test.ts, orphans a real sidecar via an
intermediary process that exits immediately, confirms self-cleanup). - A fifth bug, the real cause of a red error on every actual TUI launch
(2026-09-17), root-caused and fixed. Turning peer messaging on by
default meant the sidecar started spawning on ordinary use for the
first time — and it crashed immediately every time, because
sidecar-manager.tsspawned it asbun run <path-to-sidecar-entry.ts>,
a real file on disk in dev but not shipped at all in a compiled
single-file binary (confirmed:find dist -iname '*sidecar*'— nothing).
Fixed by running the sidecar as a hiddendebug claude-sidecar-entry
subcommand of the same executable instead (cli/cmd/debug/ claude-sidecar-entry.ts), resolved correctly in both dev (bun run <src/index.ts's own path>, computed from this file's own location, not
Bun.main— which reflects whatever launched the current process,
e.g. the test runner underbun test, not the real entry point) and
compiled (the executable itself, no wrapping needed). - A second, related bug surfaced immediately once the subcommand ran at
all:index.tshas an unconditionalfinally { process.exit() }after
cli.parse()resolves — fine for one-shot commands, fatal for
something meant to run forever. The sidecar's handler used to return
once setup finished, so the whole process was force-killed moments
after registering (registry file present, socket already gone — the
exact symptom observed). Fixed the same wayserve.tsdoes for the
same reason:runSidecarEntrynow never resolves on its own. - Verified against the actual compiled binary, not source: rebuilt with
bun run build:local, ran it as a real server, created a real session,
confirmed a real sidecar registered with no error, messaged it with a
realSendMessage, confirmed the message landed on the real session,
and confirmed clean teardown.sidecar-manager.test.tsnow exercises
this exact subcommand path too (previously it inadvertently tested a
different, source-only code path). - Two more bugs caught live by the user (2026-09-17), fixed. First: the
peerstool never showed Claude Code peers at all — the cross-owner
merge only ever existed in the/agentsHTTP endpoint and the CLI, not
in the tool an agent actually reaches for when asked "who else is
around?". Fixed:tool/peers.tsnow merges infetchClaudeAgentRecords
results, states whether each is currently messageable, and
peers.txt's description (which still said "in this directory") is
rewritten to match the cross-directory reality. - Second:
send_peer_message's "not found" message still said "No
session in this directory matches" — stale text from before the
cross-directory widening — and, worse, when
disableClaudeCodePeerMessagingwas off it never even looked up
whether a matching Claude session existed, so "doesn't exist anywhere"
and "exists but messaging is disabled" were indistinguishable. Fixed:
the Claude lookup now always runs (gated only by the presence flag),
and a found-but-disabled Claude peer gets its own clear message and
metadata reason (messaging-disabled) instead of a generic
"not-found".
- 3.4 Architecture settled by evidence (2026-09-18): the sidecar stays,
and is the only mechanism that can satisfy "every session reachable."- A rewrite was proposed to drop the sidecar: register the TUI/
run
process itself (one PID, one focused session — the TUI's server is a
Bun Worker thread, same PID, so one window really is one process). It
would have removed every process-lifecycle failure this phase hit. It
would also have made exactly one session per window reachable, and the
user's requirement is that all sessions are. - So the load-bearing question was tested rather than argued: can one
process register several peer entries (same live pid, one socket each)?
Spike: two registrations from one process,<pid>.jsonand
<pid>-b.json, both with live sockets and correct per-socket key files.
RealListAgentslisted the first and not the second. Claude keys
discovery on the<pid>.jsonfilename; one process has at most one
identity. Recorded as the first requirement of the new
local-agent-peer-protocolspec. - Consequence: a reachable conversation needs its own real pid — a
dedicated process per session is not an artifact of earlier assumptions,
it is what the protocol permits. In-process rewrite cancelled. The
(now working, compiled-binary-verified) sidecar remains the single
uniform mechanism; eager spawning per top-level session is correct for
the stated requirement (subagents still excluded). - Also rejected, with the reason on paper: an MCP-over-HTTP inbound path.
The default TUI serves athttp://opencode.internalover worker RPC
with no TCP port, so that path would need a port per window plus port
discovery — the registry problem relocated — and loses native
ListAgents/SendMessagesymmetry. - Follow-up, not done: sidecar startup evaluates the whole CLI's static
imports (~200ms, ~40MB per session). A first-position fork import in
index.tsusing top-level await could branch to the sidecar before the
rest evaluates. Deferred — a memory-leak investigation the user raised
takes priority over startup shaving.
- A rewrite was proposed to drop the sidecar: register the TUI/
- 3.3 If Phase 0 said outbound-only, document the absence here and close the phase
- N/A — proceeding with inbound, not stopping at outbound-only.
Phase 4: Verify
- 4.1 End-to-end with the real working set: a skein session messages a Claude peer
working a sibling worktree of the same change- Validation: the message arrives, the reply is received, the roster stays correct
- 4.2 Confirm a Claude Code upgrade that changes the protocol degrades to a refusal
and leaves the roster working- Validation: simulated by forcing the version gate to fail
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Plan changes
15 done
Activity
feat(peers): message Claude Code sessions both ways over its local pe…
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.
feat(peers): message Claude Code sessions both ways over its local pe…
feat(peers): message Claude Code sessions both ways over its local pe…