Skip to content

feat(opencode): session-to-session messaging — communicate between two running sessions - #38944

Draft
iceteaSA wants to merge 5 commits into
anomalyco:devfrom
iceteaSA:session-to-session
Draft

feat(opencode): session-to-session messaging — communicate between two running sessions#38944
iceteaSA wants to merge 5 commits into
anomalyco:devfrom
iceteaSA:session-to-session

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 26, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38965

Related to #19215 (agent-to-agent communication primitives). Not "Closes" — this is an experimental, flag-gated primitive.

Re-submission of #32693, closed by the automated cleanup bot on 2026-07-17 while still active. The branch has been force-pushed since (rebased onto current dev), so GitHub refuses an author-side reopen — same situation as #35195. No maintainer decision was involved in the closure.

Stacked PR. This branch sits on top of three PRs: #38942 (message tool) → #32425 (subagent interrupt) → #38943 (coordinator-messaging) → this. The diff therefore shows the whole stack, but only the top commits (packages/opencode/src/s2s/*, the s2s tool, the DB tables, the poller/drain wiring) are new here. Review most easily against #38943, or wait until the base PRs land. The four together are a family of messaging primitives: subagent↔parent (#38942), interrupt (#32425), sibling↔sibling (#38943), and session↔session (this one).

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Lets two separate top-level sessions — different windows / OS processes on the same machine — send each other messages. This is distinct from the subagent primitives in the base PRs: there is no parent/child relationship, so the two sides opt in with an explicit mutual-consent handshake first. Gated behind OPENCODE_EXPERIMENTAL_S2S (off by default).

A new s2s tool drives it: invite mints a one-time 10-minute token; the peer runs accept with that token (shared out-of-band), which writes a durable bidirectional consent record; then either side runs msg to send. Peers are addressed by their globally-unique session_id (ses_…), never by slug — slugs are not unique. leave tears the pair down.

How a message actually gets from one process to the other:

  • Send persists a versioned capsule row to a new s2s_inbox table in the shared opencode.db (tables: s2s_inbox durable mailbox, s2s_token one-time invites, s2s_allow directional consent).
  • Receive has two halves. A session actively taking a turn drains its inbox at the runLoop turn boundary (in-context). An idle session is woken by a small per-instance poller (~2s) that claims its rows and starts a loop to drain them. Delivered rows are hard-deleted; a 60s reaper reopens any row claimed by a process that crashed before draining. Incoming messages render in the recipient transcript as an external-context frame tagged source="sibling-session", and are treated as untrusted input (attributes escaped at the sink).

Why the receive path is built the way it is (the non-obvious part): the messaging/poller services are per-instance — they resolve through a request-scoped context reference that is only present inside a live request/run fiber. An earlier version forked the poller at layer-build time, where that reference is absent, so it died on its first tick and the entire recipient path was silently dead in a real process while every unit test passed (the tests inject the context). The fix is to (a) drain in-context at the runLoop boundary, and (b) lazily fork the per-instance wake-poller from inside SessionPrompt.loop, capturing the live fiber's context — plus making the S2SStore service a direct member of the prompt-serving layer groups so a serviceOption lookup in the forked fiber actually finds it. No service was made process-global.

Addressing by session_id (not slug) is deliberate: session.slug has no uniqueness guarantee — an earlier draft added a UNIQUE index on it and that broke new-session creation on a real DB once the small random-slug space saturated. Slugs stay as parent-owned handles for the subagent primitives only.

How did you verify your code works?

  • Live two-process test (the real gate — unit tests structurally can't cover cross-process delivery): two opencode windows, shared DB, invite/accept handshake, then messages both directions. Confirmed at the DB level — consent rows written in both directions, capsule persisted, and the row hard-deleted on delivery on each side; the reply surfaced in the recipient's context as a source="sibling-session" frame. Both halves exercised, including waking a fully idle peer.
  • Automated suites: the test/s2s directory (store, poller, capsule, lifecycle, frame-escaping, the cross-process topology repro, and an in-process fork repro pinning the per-instance-context behavior), plus the messaging/coordinator/interrupt suites it shares files with — green locally. bun typecheck clean on the s2s surface (the repo's pre-existing mcp/catalog.ts errors on dev are unrelated).
  • Pre-merge review council (independent, cross-family): found 4 must-fix items incl. 2 security (an unescaped attribute sink, and leave not revoking cross-process consent), all fixed and re-reviewed clean.
  • Since the original submission this branch has been rebased onto current dev three more times, each with the full suite re-run. One fix landed in that window: several test/s2s files shared Effect's process-global memoMap across files, so a router registered by one suite leaked into another and produced order-dependent failures. A testEffectIsolatedShared helper now allocates a file-local MemoMap, keeping intra-file service identity without the cross-file leak.

Screenshots / recordings

Minor TUI surface (an inbox marker line in the transcript + a session-list tweak), reusing the marker render path from #38942.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR (the only "extra" content is the three base PRs this is stacked on — see the note at the top)

Lets a parent agent (via tools) or the human (via TUI esc) steer, gracefully
cancel, or hard-abort a single running Task subagent mid-run, without
affecting the parent or sibling subagents.

A per-instance Interrupt service holds one pending interrupt per child plus a
terminal record; the child consumes it at its own runLoop turn boundary.
Steer injects a frame and the child adapts and continues; cancel injects a
frame, records a terminal, and force-breaks within a grace window; abort
cancels the BackgroundJob immediately. Interrupt tools reach any descendant
through a bounded ancestry walk that fails closed off the caller's subtree.

Off by default behind OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT.
Lets a subagent message the agent that spawned it — ask a question and block
for the answer, or send a fire-and-forget update — instead of only being able
to ask the human. The parent replies with the same tool.

A send wakes the parent's parked task wait through a background-job channel
and parks on a reply Deferred held by a per-instance Messaging service, so no
new session state is needed. Replies are bounded by a timeout, the channel is
separate from the promotion channel, and a missing parent fails fast.

Off by default behind OPENCODE_EXPERIMENTAL_AGENT_MESSAGING.


Injected parent messages carry the parent session model and variant
explicitly, so an injected message cannot fall through to the agent
default and switch the session model.
…communication

Lets the sibling subagents one parent spawns message each other directly
instead of routing everything through the parent. The surface is one
composable primitive — a per-child allow-list (`message_allow`) — so the
parent builds whatever graph it wants: hub, mesh, or chain.

Peer sends are fire-and-forget; the synchronous round-trip stays parent-only,
which is what keeps siblings deadlock-free. Slugs are parent-owned handles
resolved through a registry, authorized at send time against both the
allow-list and true sibling-hood. Recipients drain a FIFO inbox at their own
turn boundary, batched so an M-member graph costs O(1) turns per drain.

The interrupt tools also accept a slug task_id, resolved through the same
registry and still subject to the full ancestry check.

Off by default behind OPENCODE_EXPERIMENTAL_AGENT_MESSAGING.
…ions

Lets two separate top-level sessions — different windows or OS processes on
the same machine — send each other messages. There is no parent/child
relationship, so both sides opt in with an explicit mutual-consent handshake:
invite mints a one-time token, accept consumes it and writes durable
bidirectional consent, then either side can msg. Peers are addressed by
session_id, never by slug, because slugs carry no uniqueness guarantee.

Send persists a capsule row to a shared table; receive has two halves — a
session taking a turn drains its inbox at the turn boundary, and an idle
session is woken by a per-instance poller that claims its rows. Delivered
rows are hard-deleted and a reaper reopens rows claimed by a crashed process.
Incoming messages render as an external-context frame and are treated as
untrusted input.

The poller forks lazily from inside the run loop so it captures the live
fiber's instance context; forking at layer-build time leaves that context
absent and the receive path dies silently while unit tests still pass.

Off by default behind OPENCODE_EXPERIMENTAL_S2S.
@iceteaSA
iceteaSA force-pushed the session-to-session branch from 89868d4 to 7316ade Compare August 2, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Two running opencode sessions have no way to communicate

1 participant