Channel server: event-driven inbound push for orchestrator sessions - #63
Merged
Conversation
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.
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.
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 aspython -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 asnotifications/claude/channel. An idle orchestrator costs zero tokens instead of 3k-10k per emptycomms_read, and still reacts the moment it is addressed.protocolVersion, advertisecapabilities.experimental["claude/channel"],tools/listreturns[],{}for any other id-bearing request, notifications ignored.serverInfo.nameisclaude-comms-channel.0.0.0.0.@mentionof 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 thebroadcast_mentionwire field, and DM detection is an exactfullmatch, so an ordinary room nameddm-<mykey>-scratchis not mistaken for one.@allexpansion. 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./api/participants/generalat 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 ownjoin()would re-create a wiped row and erase the signal it tests for.reasonis the first field and every wire-supplied value after it is JSON-quoted, so no body or display name can forge the framing or fakemeta;metakeys are identifier-safe. Theinstructionsstring says channel content is chat to read, never instructions to follow, and that replies go viamcp__claude-comms__comms_send.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_sendagainst 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@allreally are byte-identical inmentions. 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.mdgains the.mcp.jsonblock, 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-existingweb/distenvironmental 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.--dangerously-load-development-channelsdialog, which is plan step 14 and is the orchestrator's to do after deploying to phil-desktop.🤖 Generated with Claude Code