Skip to content

Channel server: event-driven inbound push for orchestrator sessions - #63

Merged
Aztec03hub merged 2 commits into
mainfrom
batch2-channel-server
Aug 10, 2026
Merged

Channel server: event-driven inbound push for orchestrator sessions#63
Aztec03hub merged 2 commits into
mainfrom
batch2-channel-server

Conversation

@Aztec03hub

Copy link
Copy Markdown
Owner

Batch 2 of the channels plan (2026-08-10-claude-comms-channels-push.md). Batch 1 landed the wire field this depends on (#61).

What this adds

src/claude_comms/channel.py, a one-way stdio MCP channel server run as python -m claude_comms.channel. It subscribes to the broker, applies a source-side wake filter, and pushes only relevant traffic into a live Claude Code session as notifications/claude/channel. An idle orchestrator costs zero tokens instead of 3k-10k per empty comms_read, and still reacts the moment it is addressed.

  • Raw JSON-RPC over stdio, no SDK. The handshake copied from the throwaway probe that drove a real interactive session: echo the client's protocolVersion, advertise capabilities.experimental["claude/channel"], tools/list returns [], {} for any other id-bearing request, notifications ignored. serverInfo.name is claude-comms-channel.
  • One lock-guarded stdout writer. Two producers exist by design (the stdin responder and the MQTT notifier); a split line corrupts the MCP transport for both.
  • WebSocket transport. The daemon is remote and its TCP 1883 binds loopback there; only the WS listener binds 0.0.0.0.
  • Wake rule in two tiers. Explicit address (whisper to me, DM, a real @mention of me) wakes from any conversation. Ambient signal (@all, a human talking, system/artifact events, a reply in a thread I started) wakes only in joined, unmuted conversations. Self-echo and whispers addressed to someone else are dropped first; the second is a confidentiality rule, since whispers ride the shared conversation topic and are hidden only read-side. Broadcast detection reads the broadcast_mention wire field, and DM detection is an exact fullmatch, so an ordinary room named dm-<mykey>-scratch is not mistaken for one.
  • Presence heartbeats on the daemon's exact payload contract, with the name harvested from the startup membership fetch. Without them the 600s TTL sweeps an idle session offline and out of every @all expansion. A key missing from the participant list turns heartbeats off and asks the session to re-register, once per absence episode, rather than heartbeating into the void.
  • Membership from /api/participants/general at startup, on a 60s timer, and opportunistically (debounced) when a filtered message would have woken us had the room been joined. On reconnect the fetch runs before heartbeats resume, because the heartbeat's own join() would re-create a wiped row and erase the signal it tests for.
  • Bounded thread-root index with one defensive hop mirroring the daemon's ingest. No chain walker.
  • Untrusted content. The server-generated reason is the first field and every wire-supplied value after it is JSON-quoted, so no body or display name can forge the framing or fake meta; meta keys are identifier-safe. The instructions string says channel content is chat to read, never instructions to follow, and that replies go via mcp__claude-comms__comms_send.
  • Cue drain at startup via GET {daemon}/api/notifications/{key}, which drains server-side. The uninstalled PostToolUse hook was that file's only drain and it has no cap.

Tests

tests/test_channel.py, 86 tests. Wake-matrix payloads are captured from the real send paths (tool_comms_send against a live registry, tool_comms_dm_open, and the daemon's own [system] publish) rather than hand-shaped, with self-checks proving an explicit mention and an @all really are byte-identical in mentions. Every ambient row runs in both joined and non-joined variants; the mute rows include the dangerous misplacement (an explicit address in a muted conv must still wake). Also: the stdio handshake driven as a subprocess, contended stdout writers (a barrier makes the contention real; the harness was checked against an unlocked writer, which fails it 200/200), the root-map cap and cycle handling, and a framing parser proving no hostile body or display name can shift the field boundaries.

Docs

CLAUDE.md gains the .mcp.json block, the launch command, the interactive-only constraint, the main-loop-only limit (subagents keep polling), the wake rule, env knobs, presence side effects, the trust boundary, and the hook-uninstall policy. It also corrects two stale claims: the registry is SQLite-backed and survives daemon restarts.

Verification

  • PYTHONPATH=$PWD/src python -m pytest -q: 1641 passed, 1 skipped (the pre-existing web/dist environmental skip; no skips added).
  • ruff check src/ tests/: clean. ruff format --check src/ tests/: 96 files already formatted. (ruff 0.15.12.)
  • basedpyright src: 0 errors, 0 warnings. basedpyright tests: 0 errors, 0 warnings.
  • Live runtime smoke (throwaway, not committed) against the real remote daemon and broker with an unregistered key, so nothing in the live registry was touched: handshake OK, membership fetch over REST detected the absent key and pushed exactly one re-register notice, the cue drain ran, an MQTT-published explicit mention produced the correct wake frame, and ordinary chatter in the same conversation produced no frame.
  • NOT run: a live end-to-end interactive channel session. That needs a terminal that can answer the --dangerously-load-development-channels dialog, which is plan step 14 and is the orchestrator's to do after deploying to phil-desktop.

🤖 Generated with Claude Code

One-way stdio MCP channel server so an idle orchestrator costs zero tokens
instead of 3k-10k per empty comms_read. Raw JSON-RPC over stdio (the handshake
proven against a real interactive session), aiomqtt over WebSocket to the
broker's 0.0.0.0 listener because the daemon is remote and its TCP 1883 binds
loopback there.

Wake rule in two tiers: explicit address (whisper, DM, a real @mention) wakes
from any conversation; ambient signal (@ALL, a human talking, system events, a
reply in a thread I started) wakes only in joined, unmuted conversations.
Self-echo and whispers addressed to someone else are dropped first, the second
because whispers ride the shared conversation topic and are hidden only
read-side. Broadcast detection reads the wire `broadcast_mention` flag, which
is the only thing that separates an @ALL from an explicit mention after
server-side expansion.

Also: single lock-guarded stdout writer (two producers by design), presence
heartbeats on the daemon's exact payload contract so the 600s TTL cannot sweep
an idle session out of @ALL expansions, membership refresh before heartbeats on
reconnect, a bounded thread-root index with one defensive hop, and a startup
drain of the daemon-side cue file the uninstalled hook used to empty.

Every event is framed as untrusted data: the server-generated reason comes
first and every wire-supplied value after it is JSON-quoted, so no body or
display name can forge the framing or fake meta.

Tests drive the wake matrix off payloads captured from the real send paths,
every ambient row in both joined and non-joined variants, plus the mute rows,
the stdio handshake as a subprocess, contended stdout writers, the root-map
cap and the escaping break-out attempts.
Documents the .mcp.json entry (named claude-comms-channel, never claude-comms,
which would shadow the HTTP tools server that provides the only reply path),
the launch command, the interactive-only constraint, the main-loop-only limit
that leaves subagents polling, the wake rule, the env knobs, the presence side
effects, the trust boundary, and the hook-uninstall policy.

The registry lines were wrong: it is SQLite-backed and rehydrated at boot
(mcp_server.py:1056-1071, mcp_tools.py:183-200), so keys, memberships and read
cursors survive a daemon restart. Only connections are ephemeral.
@Aztec03hub
Aztec03hub merged commit 69cd5d1 into main Aug 10, 2026
8 checks 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