feat(opencode): session-to-session messaging — communicate between two running sessions#32693
Draft
iceteaSA wants to merge 39 commits into
Draft
feat(opencode): session-to-session messaging — communicate between two running sessions#32693iceteaSA wants to merge 39 commits into
iceteaSA wants to merge 39 commits into
Conversation
750bb22 to
22230dc
Compare
Experimental capability for a parent agent or human operator to steer, gracefully cancel, or hard-abort a specific running Task subagent mid-run, without affecting the parent or sibling subagents. Core: - Interrupt service (session/interrupt.ts): process-local registry holding one pending interrupt per child plus a terminal record; steer/cancel frame renderers and a visible-marker renderer, both with origin attribution (user vs parent); reason length-capped and XML-escaped at every sink (frames AND the visible marker). - The child consumes pending interrupts at the runLoop turn boundary: steer injects a <steer> frame and a visible "Steered by ..." marker and continues; cancel injects <cancel> + a visible marker, records a terminal, and force-breaks within a grace window. abortChild writes a visible "Aborted by ..." marker (model/agent derived from the child's latest user message), records a terminal, and cancels the BackgroundJob. Agent tools (gated by permission.interrupt): - task_steer / task_cancel / task_abort (origin=parent). Human paths: - POST /session/:id/interrupt (intent steer|cancel|abort, origin=user), restricted to subagent sessions, gated by the experimental flag, and rejecting non-running children. - TUI: esc on a subagent opens a Steer/Cancel/Abort menu, then a reason prompt; markers render as "... by user". Bound at the session route via a uniquely-named gather bucket (the keymap gather() caches by name). Visible interrupt markers render as a distinct "Interrupt" line (tagged via part.metadata.interrupt), not as user prose. Whole feature gated by OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT (off by default): agent tools, HTTP endpoint, and TUI affordance. Limitations: agent-driven steer/cancel applies to background children only (a foreground child blocks the parent turn); cancel is boundary-soft (use task_abort / Abort for a child stuck in a long tool call).
…g, delivery errors)
The sender-echo markers duplicated information already shown by the message tool call itself (✉ Sent to parent / ✉ Replied to subagent sat right under the visible tool call), and the subagent's "Reply from parent" marker was written twice — once by the parent's reply branch and again by the subagent's own send path. Keep only the incoming markers: the parent sees "✉ Message from subagent", the recipient subagent sees "✉ Reply from parent", each once. Drop the now-unused marker direction field.
…r before adding inbox markers
…dation, fire-and-forget only
…ow + gating verification
experimentalS2S runtime flag; s2s_inbox/s2s_token/s2s_allow tables (hand-written migration + Drizzle s2s.sql.ts mirror so fresh-DB CREATE and upgrade paths agree); session_slug_unique migration neutralized to DROP INDEX (slugs are not unique). Store is one statement per method: atomic single-winner claims via UPDATE…RETURNING with drained_at IS NULL / accepted_by IS NULL guards, TTL enforced in the claimToken WHERE clause, and deleteInbox so a delivered row is hard-deleted (distinct from a merely-claimed crashed row). S2SCapsule v1 envelope with forward/back-compat serde and optional sender_name. UUIDv7 generator.
…up wiring Per-instance wake poller (C′) lazily forked from SessionPrompt.loop via attach so it captures the live fiber's InstanceRef; runLoop turn-boundary drain (D) of s2s_inbox in-context; 60s reaper that reopens ONLY crashed claims (delivered rows are deleted). LayerNode.group exposes only DIRECT children, so S2SStore/Messaging/SessionStatus are spliced as direct members of every prompt-serving group (app httpapi + control-plane workspace) — this is what made cross-process delivery actually work. marker.ts: shared Marker.render + escapeAttr (escapes " ' for untrusted attribute values so a peer cannot break out of the <external-context> name=/session= attributes); escape() for element content/visible markers. Slug-decoupled: Messaging.enqueue lazily inits the inbox queue and registerSlug is dropped from the loop, so s2s rides session_id only and the slug registry stays coordinator-messaging-owned. s2s frames carry the sender session name + addressable session_id.
s2s tool (invite/accept/msg/leave/relay) gated behind experimentalS2S: single-use 10-min invite tokens, durable bidirectional s2s_allow consent, peers addressed by globally-unique session_id (accept reports the inviter's id). Same-process sends hit the in-process inbox; cross-process persist to s2s_inbox for the recipient's poller. Outbound 50/hr is a SOFT per-process throttle (documented as such in code + s2s.txt); the durable cross-process bound is the recipient INBOX_CAP (exact now that delivered rows are deleted). Registry wires S2SStore into ToolRegistry; message tool gains peer-slug send (message_allow). TUI renders the ✉ inbox marker (session name + id) and the session-list surface.
…s on session deletion
…eted events for comms dashboard (cherry picked from commit 939ffdd61748fed5ae41429be9d0b80e9ea3992a)
- interrupt.ts: remove defaultLayer (deleted at dev), fix node to object form - interrupt.test.ts: migrate from EventV2Bridge.defaultLayer to LayerNode.compile - task-interrupt.test.ts: migrate from Layer.mergeAll(defaultLayer) to LayerNode group/compile - task.test.ts: add Interrupt.node to test group (registry gained the dep)
- Rewrite LayerNode.make positional→object form in poller/store - Add defaultLayer re-exports (raw layer) to 37 modules - Export layer variable for modules used via .layer access - Rewrite coordinator-messaging + s2s tests to LayerNode.group pattern - Switch tests to testEffectShared for Database memoMap sharing - Add NodePath to runLoopInfra for CrossSpawnSpawner deps - Create task-event.ts schema + manifest registration - Regenerate SDK types (task.completed, messaging.peer_sent, s2s.delivered) - Fix topology-repro.test.ts positional LayerNode.make + buildLayer→compile
…ator suites SessionProjector.node added to the s2s/coordinator runLoop harnesses (session readback needs projection at current dev), the reaper harness gets an explicit EventV2 layer, the poller runLoop shares the file-level :memory: database via node replacement, and the fork-fiber-sensitive suites (poller, coordinator runLoop) build per-test layers (testEffect) instead of a shared memoMap build so forked poller/drain fibers see the same instances as the test assertions.
Drops the defaultLayer = layer re-exports from 28 modules nothing consumes; the 8 that remain (Agent, Config, Session, Truncate, Messaging, S2SStore, EventV2Bridge, CrossSpawnSpawner) back the three s2s test harnesses that still compose with Layer.mergeAll. Follow-up: convert those harnesses to LayerNode and delete the bridge entirely.
be605f6 to
e2459bb
Compare
Add optional slug, agent, model, variant, elapsedMs, tokens, and cost fields to the task.completed event. A shared completedPayload helper reads session metadata and sums tokens/cost from child assistant messages, used at all 9 publish sites instead of duplicated logic.
Wrap completedPayload assembly in Effect.exit so any defect during session/message reads falls back to the base payload instead of killing the publish path. Use optional chaining and nullish defaults for token/cost field access to tolerate missing or malformed assistant rows. Rewrite test to observe the actual published event via Deferred + listen instead of relying on message persistence alone.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Related to #19215 (agent-to-agent communication primitives). Not "Closes" — this is an experimental, flag-gated primitive.
Type of change
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
s2stool drives it:invitemints a one-time 10-minute token; the peer runsacceptwith that token (shared out-of-band), which writes a durable bidirectional consent record; then either side runsmsgto send. Peers are addressed by their globally-unique session_id (ses_…), never by slug — slugs are not unique.leavetears the pair down.How a message actually gets from one process to the other:
s2s_inboxtable in the sharedopencode.db(tables:s2s_inboxdurable mailbox,s2s_tokenone-time invites,s2s_allowdirectional consent).external-contextframe taggedsource="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 theS2SStoreservice a direct member of the prompt-serving layer groups so aserviceOptionlookup in the forked fiber actually finds it. No service was made process-global.Addressing by session_id (not slug) is deliberate:
session.slughas 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?
invite/accepthandshake, 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 asource="sibling-session"frame. Both halves exercised, including waking a fully idle peer.test/s2sdirectory (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 typecheckclean on the s2s surface (the repo's pre-existingmcp/catalog.tserrors on dev are unrelated).leavenot revoking cross-process consent), all fixed and re-reviewed clean.Screenshots / recordings
Minor TUI surface (an inbox marker line in the transcript + a session-list tweak). Screenshots to be added.
Checklist