diff --git a/SPEC.md b/SPEC.md index 7478f1e..8c28e4c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -855,7 +855,63 @@ The broker: --- -## 13. Encryption +## 13. Claude Code Integration — How Push Delivery Works + +Understanding how Claude Code processes channel notifications is critical for claudemesh reliability. + +### The notification pipeline + +``` +MCP server (claudemesh-cli) + └─ server.notification("notifications/claude/channel", { content, meta }) + └─ writes JSON-RPC to stdout + └─ Claude Code reads from MCP process stdout + └─ setNotificationHandler fires + └─ enqueue({ mode: "prompt", value: wrappedContent, origin: { kind: "channel" } }) + └─ React useSyncExternalStore triggers re-render + └─ useQueueProcessor effect fires + └─ processQueueIfReady() → executeInput() + └─ Claude sees ← claudemesh: ... +``` + +### Key requirements (from Claude Code source) + +1. **Feature gate**: `feature('KAIROS') || feature('KAIROS_CHANNELS')` must be true. `KAIROS_CHANNELS` is external (GrowthBook). `--dangerously-load-development-channels` sets `entry.dev = true` which bypasses the allowlist check but still requires the feature gate. + +2. **OAuth auth required**: Channel notifications require `claude.ai` authentication (OAuth tokens). API key users are blocked. This means `claude login --for-claude-ai` must have been run. + +3. **Server name must match**: The MCP server's declared name (`new Server({ name: "claudemesh" })`) must match the channel entry from `--dangerously-load-development-channels server:claudemesh`. + +4. **Meta keys**: Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. No hyphens. All values must be strings. + +5. **Capability declaration**: Server must declare `experimental: { "claude/channel": {} }` in capabilities. + +6. **Queue processing is event-driven**: `enqueue()` triggers a React store update → `useEffect` fires → processes immediately. No polling needed on the Claude Code side. The 1s poll timer in claudemesh is for draining the WS push buffer into notifications — Claude Code handles the rest instantly. + +### Priority gating on the broker + +The broker holds `"next"` and `"low"` priority messages when the peer's status is `"working"`. Only `"now"` messages deliver immediately regardless of status. This is by design — but can cause perceived "push not working" when the hook reports `working` status. + +``` +Status: idle → delivers: now, next, low +Status: working → delivers: now only +Status: dnd → delivers: now only +``` + +If a peer appears to not receive messages, check their status in `list_peers`. A peer stuck in `"working"` (e.g., stale hook) will only receive `"now"` priority messages. + +### Common issues + +| Symptom | Likely cause | +|---------|-------------| +| Messages never arrive | Session started before CLI update — restart with `claudemesh launch` | +| Messages arrive with 5+ minute delay | Peer status stuck on `"working"` — `next` messages held until idle | +| `← claudemesh:` never appears in idle session | Feature gate `KAIROS_CHANNELS` not enabled, or not OAuth-authenticated | +| Messages arrive only on `check_messages` | Channel handler not registered — check `--dangerously-load-development-channels` flag | + +--- + +## 14. Encryption ### Direct messages diff --git a/apps/cli/package.json b/apps/cli/package.json index 5fcd494..4e947c6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.5.3", + "version": "0.5.4", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index ca36eca..8db5c38 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -722,62 +722,56 @@ Your message mode is "${messageMode}". // any mesh's broker connection becomes a // system reminder injected into Claude Code's context. for (const client of allClients()) { - // Poll-based push: drain pushBuffer every 1s and emit channel notifications. - // This is the proven approach from claude-intercom. The WS onPush handler - // fires instantly but server.notification() may not flush stdio reliably - // from an async WS callback. Polling on a timer ensures consistent delivery. - if (messageMode !== "off") { - const pushPollTimer = setInterval(async () => { - const buffered = client.drainPushBuffer(); - if (buffered.length > 0) { - process.stderr.write(`[claudemesh] poll: ${buffered.length} message(s) to push\n`); - } - for (const msg of buffered) { - const fromPubkey = msg.senderPubkey || ""; - const fromName = fromPubkey - ? await resolvePeerName(client, fromPubkey) - : "unknown"; + // Event-driven push: WS onPush fires immediately when a message arrives. + // Claude Code's setNotificationHandler → enqueue → React useEffect pipeline + // processes notifications instantly (no polling needed on Claude's side). + // The old poll-based approach was an overcorrection — Claude Code source + // confirms event-driven notification processing. + client.onPush(async (msg) => { + if (messageMode === "off") return; - if (messageMode === "inbox") { - try { - await server.notification({ - method: "notifications/claude/channel", - params: { - content: `[inbox] New message from ${fromName}. Use check_messages to read.`, - meta: { kind: "inbox_notification", from_name: fromName }, - }, - }); - } catch { /* best effort */ } - continue; - } + const fromPubkey = msg.senderPubkey || ""; + const fromName = fromPubkey + ? await resolvePeerName(client, fromPubkey) + : "unknown"; - // push mode — full content - const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); - try { - await server.notification({ - method: "notifications/claude/channel", - params: { - content, - meta: { - from_id: fromPubkey, - from_name: fromName, - mesh_slug: client.meshSlug, - mesh_id: client.meshId, - priority: msg.priority, - sent_at: msg.createdAt, - delivered_at: msg.receivedAt, - kind: msg.kind, - }, - }, - }); - process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`); - } catch (pushErr) { - process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`); - } - } - }, 1_000); - pushPollTimer.unref(); - } + if (messageMode === "inbox") { + try { + await server.notification({ + method: "notifications/claude/channel", + params: { + content: `[inbox] New message from ${fromName}. Use check_messages to read.`, + meta: { kind: "inbox_notification", from_name: fromName }, + }, + }); + } catch { /* best effort */ } + return; + } + + // push mode — full content + const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); + try { + await server.notification({ + method: "notifications/claude/channel", + params: { + content, + meta: { + from_id: fromPubkey, + from_name: fromName, + mesh_slug: client.meshSlug, + mesh_id: client.meshId, + priority: msg.priority, + sent_at: msg.createdAt, + delivered_at: msg.receivedAt, + kind: msg.kind, + }, + }, + }); + process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`); + } catch (pushErr) { + process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`); + } + }); client.onStreamData(async (evt) => { try {