Skip to content

feat(session-relay): cross-session/cross-project + cross-tool (Codex↔Claude) agent message bus#3

Merged
DocksDocks merged 9 commits into
mainfrom
feat/session-relay-cross-session-bus
Jul 1, 2026
Merged

feat(session-relay): cross-session/cross-project + cross-tool (Codex↔Claude) agent message bus#3
DocksDocks merged 9 commits into
mainfrom
feat/session-relay-cross-session-bus

Conversation

@DocksDocks

Copy link
Copy Markdown
Owner

What

Adds session-relay, a second plugin in the docks marketplace: a cross-session, cross-project, cross-tool agent message bus. One agent can hand a message to — and get a reply from — an agent in another session, another project, or another tool (Claude Code ⇄ Codex), over a single shared on-disk store.

This PR folds two phases:

  • v1 — the bus (Claude Code). A stdio MCP server (bus) exposing whoami/register/roster/send/inbox over a shared store, a SessionStart hook that auto-registers each session and drains its inbox into context, and a relay.mjs doorbell that wakes an idle recipient via headless claude -p --resume.
  • v2 — cross-tool (Codex). The same store/registry/hook now serve Codex too: registry entries carry a tool field, the doorbell dispatches per-tool (claude -p --resume or codex exec resume), the SessionStart hook is shared (Codex's contract is byte-identical to Claude's), and a .codex-plugin/ manifest + Codex marketplace entry ship it to Codex.

Why

Native Claude Code has no general live session-to-session channel across projects (Agent Teams can't span sessions; Channels are single-session, research-preview). The session id is the routing key; the transport is built from three confirmed primitives — an MCP server over a shared store, a SessionStart hook for auto-register + inbox-drain, and a headless-resume doorbell to wake idle recipients. Codex exposes the same three primitives (codex exec resume, a matching SessionStart hook, codex mcp), so the bus spans both tools.

How it works

 project A (Claude)            shared store (~/.agent-relay/)         project B (Codex)
  SessionStart hook ──register──▶ registry.json {id→dir,name,tool} ◀──register── SessionStart hook
  bus.send(to=B) ───────────────▶ mailbox/<id>.jsonl ◀──────────────── relay.mjs send
        └────── doorbell: codex exec resume <id> (wake idle B) ──────────┘

Delivery is pull + event, never a live push: a recipient sees mail when it calls inbox or at its next SessionStart; send reaches an idle session only after the doorbell wakes it.

Verification

  • node scripts/ci.mjs — green (adds a self-contained session-relay section with 5 Codex-parity checks; the rest of CI stays scoped to plugins/docks).
  • node plugins/session-relay/test/selftest.mjs — 15 checks (store round-trip, MCP handshake, hook inject/drain, tool-field, doorbell dispatch selection, AGENT_RELAY_HOME precedence).
  • Live bidirectional round-trip (codex 0.142.2 + claude): a real Codex session and a real Claude session registered on one store and exchanged message+reply both ways — Claude→Codex delivered an external message into the Codex session via its hook (agent replied MANGO); Codex→Claude had the Codex agent relay.mjs send a message that was delivered into the Claude session via its hook (echoed PAPAYA-FROM-CODEX).

Notes for reviewers

  • The Claude-only path is behavior-preserving; v2 only adds a tool tag and a second doorbell branch.
  • The bus is a privilege boundary: any local process that can write the store can inject a turn into a registered session. Inbound messages are treated as untrusted input; the skill says so explicitly.
  • release.mjs is intentionally not extended — it stays scoped to plugins/docks; session-relay self-versions (its manifest triple is gated in ci.mjs).
  • Full cold-handoff plan + the verified-research trail live in docs/plans/active/session-relay-cross-tool-bus.md.

Plan & completion review

Tracked end-to-end in docs/plans/active/session-relay-cross-tool-bus.md (11/11 steps done). An independent plan-review completion pass diffed planned_at_commit..HEAD against the goal and set review_status: passed — Goal met, no regressions to the Claude-only path, CI green. Its one follow-up (Claude-side manifests still said "Claude Code only") is resolved in this PR.

🤖 Generated with Claude Code

DocksDocks and others added 9 commits June 30, 2026 00:32
…ugin

Add session-relay, a second (Claude-only) marketplace plugin that lets an agent
in one Claude Code session message — and get a reply from — an agent in another
session or project, keyed by session id.

- lib/store.mjs: shared on-disk registry + per-recipient JSONL mailboxes + cwd→id
  markers (mkdir-lock + atomic writes, zero deps)
- mcp/bus.mjs: zero-dep MCP stdio server (whoami/register/roster/send/inbox)
- hooks/session-start.mjs: auto-register + inbox drain via additionalContext
- skills/productivity/session-relay: skill (scores 15/16) + relay.mjs doorbell CLI
- test/selftest.mjs: claude-free MCP-handshake + store + hook test (11 checks)
- ci.mjs: self-contained session-relay validation block
- marketplace.json + AGENTS.md: register the 2nd plugin

Transport is headless `claude -p --resume`, scoped to the recipient's project
dir. Claude-only by design (uses the claude CLI + a plugin MCP server, which
Codex does not consume); self-versioned (0.1.0). docks' release.mjs is unchanged
— release separately via `claude plugin tag ./plugins/session-relay`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ool-aware doorbell

- store home defaults to ~/.agent-relay (AGENT_RELAY_HOME, SESSION_RELAY_HOME alias)
- registry entries carry a tool field (claude|codex; default claude)
- relay.mjs wake dispatches per target.tool; --dry prints the command
- selftest covers tool field, home precedence, and doorbell dispatch (15 checks)

Claude path behavior-identical; prepares the bus for a Codex peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- shared SessionStart hook (session-start.mjs takes a tool arg) — Codex's
  SessionStart contract is identical to Claude's, so one script serves both
- hooks/codex-hooks.json invokes it with codex; tags register({tool:'codex'})
- bus.mjs ignores an unsubstituted ${...} project-dir env (Codex static config)
  and falls back to process.cwd()
- .codex-plugin/plugin.json + .codex-plugin/bus.mcp.json (skills + hooks + MCP);
  session-relay added to the Codex marketplace catalog
- SKILL.md documents the cross-tool model, two doorbells, Codex install
- ci.mjs gains 5 Codex-parity checks (codex manifest/version/marketplace/
  hooks.json/bus.mcp.json JSON-valid)

Verified live (codex 0.142.2 + claude): bidirectional round-trip — a real Codex
session and a real Claude session register on one store and exchange
message+reply both ways. selftest 15 checks; ci.mjs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tool manifest descriptions

- plan-review completion verdict: review_status=passed, Goal met, no regressions
- resolve the one follow-up inline: Claude-side .claude-plugin/plugin.json +
  .claude-plugin/marketplace.json descriptions/keywords/tags now read cross-tool
  (Claude Code + Codex), matching the Codex manifests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…istration)

Closes the gap where the bus could only reach pre-registered sessions and
roster had no liveness signal. New lib/discover.mjs scans the RAW on-disk
session stores so an agent can auto-resolve "my other running session":
- Claude: <root>/<enc-cwd>/<id>.jsonl — id=filename, cwd read from file
  CONTENT (the dir name is a lossy '-' encoding), mtime=liveness.
- Codex: rollout-*.jsonl session_meta line -> payload.id + cwd.
Surfaced as an MCP "discover" tool + "relay.mjs discover"; works even for
sessions that never joined the bus. Reach an unregistered one with
"relay.mjs wake --id <uuid> --dir <cwd> --tool <t> -- '<msg>'" (the message
rides the resume prompt inline). Decisions: full-scan (not registry-only) +
auto-pick most-recent/cwd-relevant, confirm only when ambiguous.

Hardened after a multi-lens adversarial-review workflow (14/21 findings
confirmed, each independently verified):
- UUID-validate ids — drop planted/flag-shaped ids and reject a non-UUID
  --id so they can't reach the spawned doorbell argv as injected options.
- stat-gate the content read by the liveness window before opening files
  (cost proportional to live sessions, not total history).
- isFile guard on the Claude scan; "--"-separator inline-message parsing;
  Number.isFinite --within guard; head-read pop guard; refreshed MCP
  initialize instructions string.
Documented limit: same-cwd self-pick (needs a host->bus session id MCP
doesn't expose) — the skill says to verify a candidate isn't self.

Verified: selftest 28 checks; node scripts/ci.mjs green; live — a brand-new
plugin-less claude session was discovered from disk and answered with its
own context via the inline doorbell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make ci.mjs and release.mjs host multiple plugins from one repo instead of
hardcoding docks. scripts/lib/plugins.mjs is the single source of truth: a
PLUGINS array of descriptors (paths + capabilities). Adding a plugin = adding
one descriptor; no edits to ci.mjs/release.mjs.

- ci.mjs: registry-driven. Repo-wide checks run once (workflow YAML, both
  marketplace catalogs, tree/guard, idempotency, shellcheck, scaffold), then a
  capability-driven per-plugin gate (gatePlugin) runs for each present plugin --
  a check fires only when its capability is declared (skills/agents/codex/
  selftest/extraJson/transformGuard). Flags: -q, --list, --plugin name.
  Replaces the old docks-hardcoded sections + bolted-on session-relay block.
- release.mjs: registry-driven, single-plugin. --plugin name (default docks)
  bumps only that plugin's manifests + its marketplace entry (matched by name);
  tag = name--vVER; commit = chore(release): name vVER. --dry-run previews.
  Versions are per-plugin and independent.
- no-author-scripts.mjs: takes skills-dir [agents-dir] so gatePlugin scopes it
  per-plugin (agents scanned only when given) -- closes the gap where
  session-relay skills were never author-script-checked.
- session-relay plugin.json: normalize to canonical 2-space JSON so release
  bumps produce version-only diffs (no semantic change).
- scripts/AGENTS.md: document the multi-plugin model, flags, per-plugin
  versioning.

session-relay now gets MORE coverage than the old bolted-on path (category
layout, no-author-scripts, trigger-collision). CI green: 2 plugins + repo-wide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + mail hardening, tests

A prior-art survey (14 verified projects: mcp_agent_mail, claude-peers-mcp,
AMQ, cc-to-cc, Anthropic Agent Teams, openai/codex resume issues, et al.)
confirmed all six load-bearing behaviors and surfaced concrete gaps. Fixes:

- discover.mjs: honor the tools' own relocation env vars. Roots were hardcoded
  to ~/.claude/projects and ~/.codex/sessions, ignoring CLAUDE_CONFIG_DIR and
  CODEX_HOME — a relocated user got a silent "no sessions found". Now
  CLAUDE_CONFIG_DIR -> <dir>/projects and CODEX_HOME -> <dir>/sessions, with
  RELAY_* still overriding outright. (real correctness bug)
- relay.mjs wake: refuse to resume into a target dir that no longer exists. A
  stale/moved registration would otherwise resume from an unexpected cwd, and
  Codex widens its sandbox writable roots to the caller cwd. Guard before spawn.
- session-start.mjs: structurally fence injected mail. Bodies come from other
  (untrusted) writers; wrap them in a labelled <session-relay-mail> UNTRUSTED
  block instead of relying on prose guidance alone. SKILL.md updated to match +
  state the single-user trust boundary (no store auth); content_hash rebumped.
- selftest.mjs: +10 checks (28 -> 38) for the coverage gaps prior art proved
  matter: concurrent multi-writer stress (no lost/torn JSONL, intact registry),
  stale-lock reclaim + fresh-lock fail-fast deadline, discovery format-fragility
  canary (malformed/cwd-less/empty), path-traversal sanitize, doorbell argv
  safety, CLAUDE_CONFIG_DIR/CODEX_HOME honoring, and the untrusted-mail fence.

ci.mjs green: 2 plugins + repo-wide. selftest: 38 checks pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on, stale-lock

Merge-review of PR #3 surfaced two confirmed injection-hardening gaps plus two
stale-lock robustness bugs; fix all four and correct a doc mislabel.

- session-start.mjs: defuse the </session-relay-mail> sentinel in mail body AND
  sender name before fencing, so a peer message can't close the block early and
  smuggle text out as trusted prose (reproduced breakout).
- relay.mjs: put the untrusted message after a `--` end-of-options marker in both
  child argvs, and UUID-validate target.id on the resolved-name path, so a
  dash-leading body/id can't become a flag on the spawned claude/codex.
- store.mjs: bound stale-lock reclaim by the deadline (no busy-spin hang) and
  reclaim atomically via rename so two racers can't both enter the mutex; fix the
  header comment that overstated mailbox atomicity.
- AGENTS.md: session-relay is cross-tool (Claude + Codex), not Claude-only.
- selftest.mjs: +2 checks (fence closing-tag payload; doorbell `--` fencing);
  38 -> 40, all green. node scripts/ci.mjs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UyDDEDsq6yGeJKzNAwW3ZQ
@DocksDocks DocksDocks merged commit 7ee6a0d into main Jul 1, 2026
1 check passed
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.

1 participant