Repository navigation
Let an agent send a message to another live opencode-skein session #82
Description
Proposal
Why
session-peer-awareness shipped discovery (peers — who else is working here) but deliberately
did not ship messaging: "Enforcement... should stay [elsewhere]. Awareness, not enforcement." That
was the right scope for that change. But nothing has since filled the messaging half, and the gap
is not hypothetical — it's the single piece of claude-peer-protocol's eventual scope that has no
dependency on the unproven Claude-compatibility hypothesis at all. Two opencode-skein sessions on
one machine sending each other a short message is a same-app problem: both speak the same HTTP
API already.
There is prior art, and it is informative about the gap rather than a solution: an untracked,
unregistered file, packages/opencode/src/plugin/skein-peers.ts, sketches exactly this — a
list_agents/send_message tool pair. It should not be built on directly (see What's wrong with
it, below), but it correctly identifies that send_message is the missing half, and its
prompt-injection approach (POST to the target's /session/{id}/prompt_async with a provenance
prefix) is the right shape for the same-instance case.
What's wrong with the prior art
list_agentsduplicatespeersunder a different tool name instead of extending it — exactly
the "parallel concept instead of extending an existing tool" anti-pattern the original request
warned against by name.- Its registry read (
fetch(${ctx.worktree}/../.skein/peers.json)) is not a validfetchcall —
no scheme, so it fails outright — and it invents a.skein/peers.jsonfile nothing writes. send_message's target discovery hardcodeshttp://localhost:4096, assuming exactly one
opencode instance on a fixed port. Real deployments run multiple instances on different ports
across different directories (fleet-instance-presence's whole reason for existing).- No exclusion of the caller's own session/descendants (the bug class
session-peer-awareness
1.2 specifically fixed forpeers). - No delivery semantics: no distinction between "the HTTP POST returned 200" and "the message was
actually injected and will be seen," no handling of a target that disappears mid-send, no
provenance beyond a bare text prefix.
What Changes
1. Extend peers, don't parallel it
The existing peers tool gains a sibling send_peer_message tool (or an optional parameter on
peers itself — implementation detail to resolve in tasks, but the model stays one thing: a
PeerRef/PeerMessage pair, not a second roster). No new list_agents tool. Resolve a target by
session id or an unambiguous prefix of its title; an ambiguous match is refused, never guessed.
2. Two delivery paths, one tool surface
- Same-directory (same store): the target is already visible via
session/peers.ts's
resolvePeers— no HTTP hop needed; inject directly through the local session/prompt API,
exactly likesession.synthetic/session.promptalready do internally. - Cross-instance (different directory/port), once
fleet-instance-presencePhase 4's
remote-instance control API exists: resolve the target instance's realbaseURLfrom presence
data (never a hardcoded port) and deliver through that instance's own HTTP API, the same pattern
Phase 4 already establishes for cancel/pause.
A caller does not need to know which path applies — the tool resolves it from where the target
actually is.
3. Explicit provenance, explicit delivery status
An injected message carries structured provenance (sending session id/title, not just a text
prefix) so the receiving agent can distinguish it from a human prompt, matching the same
"metadata not content, provenance always explicit" posture session-peer-awareness and
fleet-instance-presence already hold. Delivery is fire-and-forget (same pattern as the
background-subagent result injection in task.ts — awaiting a full reply would block the sender
on the peer's whole turn), so the tool result reports "accepted for delivery" honestly rather than
claiming a stronger guarantee it cannot back up; a mid-turn target is refused outright rather than
risked, and a disappeared target is reported as unreachable rather than accepted.
4. Retire the prior art
Once this ships, packages/opencode/src/plugin/skein-peers.ts is superseded. Delete it as part of
this change rather than leaving it as an unregistered, confusing duplicate.
Non-Goals
- Not the Claude-compatible cross-session protocol — that's
claude-peer-protocol, which is
expected to extend this samePeer/PeerMessagemodel with a Claude-Code-reachable harness
adapter once its spike proves the mechanism, not reinvent a tool surface. - Not solving the roster-accuracy problem for cross-instance targets — that's
fleet-instance-presencePhase 6; this change consumes that data, it doesn't fix it. - No broadcast, no channels, no persistent message history — a single short message to a single
named peer, same restraint the original request asked for.
Dependencies
- Same-directory delivery: none beyond what
session-peer-awarenessalready shipped. - Cross-instance delivery: depends on
fleet-instance-presencePhase 4 (remote-instance control
API) for the target-instance HTTP pattern. Can ship same-directory-only first and add
cross-instance delivery once Phase 4 lands, rather than blocking entirely. claude-peer-protocoldepends on this change'sPeer/PeerMessagemodel existing first, so it
has something to extend rather than something to duplicate.
Impact
- New:
send_peer_messagetool (orpeersextension) inpackages/opencode/src/tool/, sharing
packages/opencode/src/session/peers.ts's data model. - Deleted:
packages/opencode/src/plugin/skein-peers.ts. - Modified:
packages/opencode/src/session/peers.ts(delivery, not just resolution), instance HTTP
API surface for the cross-instance path (co-designed withfleet-instance-presencePhase 4).
Disposition (2026-09-18)
Archived — superseded/obsolete. Shipped as send_peer_message under claude-peer-messaging; 3.1/3.2 (unreachable/disappeared targets) handled there and by skein-pool's deadline.
Tasks
Slice 1: Domain model
- 1.1 Added
resolveTarget(id-or-unambiguous-title-prefix resolution over the existing
Peerroster) andformatPeerMessage(structured provenance envelope) to
session/peers.ts, rather than a separatePeerMessagestruct — the roster this reuses
(resolvePeers) is already harness-implicit (opencode-skeinis the only source today), so
claude-peer-protocoladds aharnessfield toPeerwhen it has a second harness to
represent, instead of this slice speculatively adding one now with only one value.- File:
packages/opencode/src/session/peers.ts - Validation:
bun run typecheck
- File:
Slice 2: Same-directory delivery
- 2.1 Added
send_peer_messagetool: resolves by exact session id or unambiguous
case-insensitive title prefix (ambiguous → refused with the candidate list; exact id always
short-circuits title matching). Targets are drawn from the sameresolvePeersroster the
peerstool uses, so caller/descendant exclusion is inherited, not reimplemented.- File:
packages/opencode/src/tool/send-peer-message.ts, registered in
packages/opencode/src/tool/registry.ts - Validation:
bun test test/session/peers.test.ts(resolveTargetcases: exact id, unambiguous
prefix, ambiguous refusal, not-found, id-shadows-title-prefix)
- File:
- 2.2 Delivery via
TaskPromptOps.prompt(the samectx.extra.promptOpsprimitive
task.tsuses for background-subagent result injection — see
subagent-notification-reliabilityfor the parent-wake-up reliability question this
shares), withformatPeerMessage's structured provenance envelope rather than a bare
prefix.- File:
packages/opencode/src/tool/send-peer-message.ts - Validation:
bun test test/session/peers.test.ts(formatPeerMessage— provenance separate
from message text)
- File:
- 2.3 Scope-adjusted from the original wording: delivery is fire-and-forget (forked, matching
task.ts's own background-result-injection pattern) rather than awaited, because awaiting
the fullprompt()call would block the sender on however long the peer's entire reply
takes to generate — wrong for a lightweight coordination message. The honest claim available
without that block is "accepted for delivery" (target existed and was not mid-turn at send
time), not "confirmed injected". A literally busy (mid-generation) target is refused
up front rather than risking joining/racing its turn, the same foreign-turn hazard
loop.tsguards against —awaiting-permission/stalled/cancellingare not mid-generation
and are still valid targets.- File:
packages/opencode/src/tool/send-peer-message.ts - Validation: covered by the tool's own not-found/ambiguous/busy/unreachable result branches;
seespecs/peer-messaging/spec.mdfor the corresponding requirement update
- File:
Slice 3: Cross-instance delivery (depends on fleet-instance-presence Phase 4)
- 3.1 Once
fleet-instance-presencePhase 4's remote-instance control API exists, resolve a
cross-instance target's realbaseURLfrom presence data and deliver through that
instance's HTTP API.- File:
packages/opencode/src/session/peers.ts - Validation: integration test with two instances on different ports
- File:
- 3.2 Handle target-instance-unreachable and target-session-disappeared distinctly from a
same-directory failure.- Validation: unit test for each failure mode
Slice 4: Retire prior art
- 4.1 Deleted
packages/opencode/src/plugin/skein-peers.ts— unregistered, unwired to any
plugin loader, and fully superseded by Slices 1-2.- File:
packages/opencode/src/plugin/skein-peers.ts - Validation:
git statusshows it removed;grep -rn skein-peers packages/opencode/srcempty
- File:
Slice 5: Verification
- 5.1
bun run typecheck(opencode, sdk, tui all clean) andbun test test/tool/ test/session/
— 762 pass (2 pre-existing, unrelated failures confirmed present on a clean checkout before
this change: a stale snapshot fromsubagent-background-default, fixed opportunistically
since it was a one-line, safe--update-snapshots; and a known-flaky
test/session/prompt.test.tstiming test, left as-is — see prior session notes). - 5.2 Live check: two same-directory sessions exchange a message end to end.
- Done 2026-09-05 — and it caught a real bug on the first attempt: the tool was constructed
intool/registry.ts's custom-tool assembly (with a node-graph layer) but never added to
the separate, hand-maintainedbuiltinarray that determines what the model actually sees.
The LLM's own error made this unambiguous: "Model tried to call unavailable tool
'send_peer_message'." Same bug, same fix, as the already-shippedpeerstool — see
session-peer-awarenesstask 4.2. Fixed inpackages/opencode/src/tool/registry.ts
(both added tobuiltin). - Also fixed along the way: target resolution was scoped to
resolvePeers's "actively
working" roster, which — correctly for the awareness tool, wrongly here — excludes idle
sessions. Messaging an idle session ("when you get back to this, X changed") is the normal
case, not an edge case, soresolveMessageTargets(new, insession/peers.ts) is a
sibling projection that includes idle sessions while keeping the same
caller/descendant/directory exclusions. - Validation:
packages/opencode/test/session/prompt.test.ts— "send_peer_message delivers
into a second, idle session in the same directory" — a real model turn calls the tool
through the realToolRegistry/SessionTools.resolvepath targeting a deliberately idle
session, and the test polls the receiving session's actual message history for the
delivered, provenance-tagged text. Not a mock — the real registered tool, the real forked
delivery path. - Cross-instance delivery (Slice 3) remains not live-checked — still blocked on
fleet-instance-presencePhase 4.
- Done 2026-09-05 — and it caught a real bug on the first attempt: the tool was constructed
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Plan changes
7 done
Activity
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.