From b1f428c44b3455eccb9214df690c6acfae5c792a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:17:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli):=20wss=20push=20=E2=86=92=20mcp=20cha?= =?UTF-8?q?nnel=20injection=20+=20status=20hooks=20in=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full parity with claude-peers: 1. Push-injection (the "tap on shoulder" UX) - MCP server now declares experimental.claude/channel capability - BrokerClient onPush handlers emit server.notification({ method: "notifications/claude/channel", params: { content, meta: {from_id, from_name, mesh_slug, mesh_id, priority, sent_at, delivered_at, kind}} }) - Claude Code injects each push as system reminder, so the receiver session sees inbound messages WITHOUT calling check_messages manually - Updated MCP instructions with the "RESPOND IMMEDIATELY" framing (adapted from claude-peers) 2. Status hooks in install (default-on, --no-hooks to opt out) - new apps/cli/src/commands/hook.ts: reads stdin JSON (Claude Code hook payload), extracts cwd+session_id, POSTs /hook/set-status to every joined mesh's broker in parallel with process.ppid + 1s timeout per POST. Silent fail, fire-and-forget. - install.ts: writes to ~/.claude/settings.json registering `claudemesh hook idle` on Stop + `claudemesh hook working` on UserPromptSubmit. Idempotent, preserves other hook entries. - uninstall.ts: removes both hook entries + MCP entry; leaves unrelated hook/MCP entries alone. - dedupes by brokerUrl (multiple meshes on same broker → one POST) 3. CLI surface - new subcommand: `claudemesh hook ` (internal, but exposed so Claude Code can invoke it via the hook shell command) - `install --no-hooks` for users who want bare MCP registration - --help updated Coexistence with claude-peers: both tools register Stop and UserPromptSubmit hooks, each POSTs to its own broker. Claude Code fires multiple hooks per event without conflict. npm version 0.1.0 → 0.1.1 (patch). Verified: - install with hooks → 2 entries added to settings.json ✓ - install --no-hooks → "Hooks skipped" ✓ - uninstall → both MCP entry + 2 hook entries removed ✓ - `echo '{...}' | claudemesh hook idle` with no joined meshes → silent no-op ("no joined meshes, nothing to do") ✓ - MCP initialize response includes experimental.claude/channel ✓ Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/package.json | 2 +- apps/cli/src/commands/hook.ts | 123 ++++++++++++++++++++++ apps/cli/src/commands/install.ts | 169 ++++++++++++++++++++++++++++--- apps/cli/src/index.ts | 11 +- apps/cli/src/mcp/server.ts | 58 ++++++++++- 5 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 apps/cli/src/commands/hook.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index cb9ceaf..881eddf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.1.0", + "version": "0.1.1", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/hook.ts b/apps/cli/src/commands/hook.ts new file mode 100644 index 0000000..1256016 --- /dev/null +++ b/apps/cli/src/commands/hook.ts @@ -0,0 +1,123 @@ +/** + * `claudemesh hook ` — Claude Code hook handler. + * + * Registered as a Stop + UserPromptSubmit hook by `claudemesh install`. + * On each turn boundary, Claude Code invokes: + * + * Stop → `claudemesh hook idle` + * UserPromptSubmit → `claudemesh hook working` + * + * We read the Claude Code hook JSON payload from stdin (contains cwd + + * session_id), then POST `/hook/set-status` to EVERY joined mesh's + * broker with {cwd, pid, status, session_id}. Each broker looks up + * its local presence row by (pid, cwd) and updates status. + * + * Fire-and-forget, silent. Hooks must NEVER block Claude Code or + * surface errors to the user. Debug logging available via + * CLAUDEMESH_HOOK_DEBUG=1. + * + * Why send to every broker? A user joined to multiple meshes has + * one presence row per mesh, each on its own broker. A turn boundary + * updates the status on every broker where this session is active. + * Brokers that don't have a matching presence just queue the signal + * in pending_status (harmless, TTL-swept). + */ + +import { loadConfig } from "../state/config"; + +const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1"; + +function debug(msg: string): void { + if (DEBUG) console.error(`[claudemesh-hook] ${msg}`); +} + +/** WS URL → HTTP URL (same host, swap scheme). */ +function wsToHttp(wsUrl: string): string { + try { + const u = new URL(wsUrl); + const httpScheme = u.protocol === "wss:" ? "https:" : "http:"; + return `${httpScheme}//${u.host}`; + } catch { + return wsUrl; + } +} + +async function readStdinJson(): Promise> { + if (process.stdin.isTTY) return {}; + const chunks: Uint8Array[] = []; + const reader = process.stdin; + try { + for await (const chunk of reader) { + chunks.push(chunk as Uint8Array); + if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break; + } + const raw = Buffer.concat(chunks).toString("utf-8").trim(); + if (!raw) return {}; + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +async function postHook( + brokerWsUrl: string, + body: Record, +): Promise { + const base = wsToHttp(brokerWsUrl); + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 1000); + await fetch(`${base}/hook/set-status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }).finally(() => clearTimeout(t)); + } catch (e) { + debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`); + } +} + +export async function runHook(args: string[]): Promise { + const status = args[0]; + if (!status || !["idle", "working", "dnd"].includes(status)) { + // Silent no-op — we never want a hook to surface an error. + process.exit(0); + } + + // Read Claude Code's stdin payload for cwd + session_id. + const stdinTimeout = new Promise>((r) => + setTimeout(() => r({}), 500), + ); + const payload = await Promise.race([readStdinJson(), stdinTimeout]); + const cwd = + (typeof payload.cwd === "string" && payload.cwd) || + process.env.CLAUDE_PROJECT_DIR || + process.cwd(); + const sessionId = + (typeof payload.session_id === "string" && payload.session_id) || ""; + + // Fan out to EVERY joined mesh's broker in parallel. + let config; + try { + config = loadConfig(); + } catch (e) { + debug(`config load failed: ${e instanceof Error ? e.message : e}`); + process.exit(0); + } + if (config.meshes.length === 0) { + debug("no joined meshes, nothing to do"); + process.exit(0); + } + + const body = { cwd, pid: process.ppid, status, session_id: sessionId }; + debug( + `status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`, + ); + + // Dedupe by brokerUrl — if multiple meshes share a broker, one POST + // covers them (broker resolves presence by cwd+pid regardless). + const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))]; + await Promise.all(brokerUrls.map((url) => postHook(url, body))); + process.exit(0); +} diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 3974594..f0ad089 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -31,6 +31,10 @@ import { spawnSync } from "node:child_process"; const MCP_NAME = "claudemesh"; const CLAUDE_CONFIG = join(homedir(), ".claude.json"); +const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json"); +const HOOK_COMMAND_STOP = "claudemesh hook idle"; +const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working"; +const HOOK_MARKER = "claudemesh hook "; type McpEntry = { command: string; @@ -38,6 +42,16 @@ type McpEntry = { env?: Record; }; +interface HookCommand { + type: "command"; + command: string; +} +interface HookMatcher { + matcher?: string; + hooks: HookCommand[]; +} +type HooksConfig = Record; + function readClaudeConfig(): Record { if (!existsSync(CLAUDE_CONFIG)) return {}; const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim(); @@ -116,7 +130,87 @@ function entriesEqual(a: McpEntry, b: McpEntry): boolean { ); } -export function runInstall(): void { +function readClaudeSettings(): Record { + if (!existsSync(CLAUDE_SETTINGS)) return {}; + const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } catch (e) { + throw new Error( + `failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`, + ); + } +} + +function writeClaudeSettings(obj: Record): void { + mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true }); + writeFileSync( + CLAUDE_SETTINGS, + JSON.stringify(obj, null, 2) + "\n", + "utf-8", + ); +} + +/** + * Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json, + * idempotent on the command string. Returns counts for reporting. + */ +function installHooks(): { added: number; unchanged: number } { + const settings = readClaudeSettings(); + const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {}; + let added = 0; + let unchanged = 0; + + const ensure = (event: string, command: string): void => { + const list = (hooks[event] ??= []); + const alreadyPresent = list.some((entry) => + (entry.hooks ?? []).some((h) => h.command === command), + ); + if (alreadyPresent) { + unchanged += 1; + return; + } + list.push({ hooks: [{ type: "command", command }] }); + added += 1; + }; + ensure("Stop", HOOK_COMMAND_STOP); + ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT); + + settings.hooks = hooks; + writeClaudeSettings(settings); + return { added, unchanged }; +} + +/** + * Remove every hook entry whose command contains "claudemesh hook " + * from ~/.claude/settings.json. Idempotent. Returns removed count. + */ +function uninstallHooks(): number { + if (!existsSync(CLAUDE_SETTINGS)) return 0; + const settings = readClaudeSettings(); + const hooks = settings.hooks as HooksConfig | undefined; + if (!hooks) return 0; + let removed = 0; + for (const event of Object.keys(hooks)) { + const kept: HookMatcher[] = []; + for (const entry of hooks[event] ?? []) { + const filtered = (entry.hooks ?? []).filter( + (h) => !(h.command ?? "").includes(HOOK_MARKER), + ); + removed += (entry.hooks ?? []).length - filtered.length; + if (filtered.length > 0) kept.push({ ...entry, hooks: filtered }); + } + if (kept.length === 0) delete hooks[event]; + else hooks[event] = kept; + } + settings.hooks = hooks; + writeClaudeSettings(settings); + return removed; +} + +export function runInstall(args: string[] = []): void { + const skipHooks = args.includes("--no-hooks"); console.log("claudemesh install"); console.log("------------------"); @@ -182,6 +276,31 @@ export function runInstall(): void { ` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`, ), ); + + // Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status). + if (!skipHooks) { + try { + const { added, unchanged } = installHooks(); + if (added > 0) { + console.log( + `✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`, + ); + } else { + console.log(`✓ Hooks already registered (${unchanged} present)`); + } + console.log(dim(` config: ${CLAUDE_SETTINGS}`)); + } catch (e) { + console.error( + `⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`, + ); + console.error( + " (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)", + ); + } + } else { + console.log(dim("· Hooks skipped (--no-hooks)")); + } + console.log(""); console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear.")); console.log(""); @@ -193,21 +312,39 @@ export function runInstall(): void { export function runUninstall(): void { console.log("claudemesh uninstall"); console.log("--------------------"); - if (!existsSync(CLAUDE_CONFIG)) { - console.log(`· no ${CLAUDE_CONFIG} — nothing to remove`); - return; + + // MCP entry + if (existsSync(CLAUDE_CONFIG)) { + const cfg = readClaudeConfig(); + const servers = cfg.mcpServers as + | Record + | undefined; + if (servers && MCP_NAME in servers) { + delete servers[MCP_NAME]; + cfg.mcpServers = servers; + writeClaudeConfig(cfg); + console.log(`✓ MCP server "${MCP_NAME}" removed`); + } else { + console.log(`· MCP server "${MCP_NAME}" not present`); + } + } else { + console.log(`· no ${CLAUDE_CONFIG} — MCP entry skipped`); } - const cfg = readClaudeConfig(); - const servers = cfg.mcpServers as - | Record - | undefined; - if (!servers || !(MCP_NAME in servers)) { - console.log(`· MCP server "${MCP_NAME}" not present — nothing to remove`); - return; + + // Hooks + try { + const removed = uninstallHooks(); + if (removed > 0) { + console.log(`✓ Hooks removed (${removed} entries)`); + } else { + console.log("· No claudemesh hooks to remove"); + } + } catch (e) { + console.error( + `⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`, + ); } - delete servers[MCP_NAME]; - cfg.mcpServers = servers; - writeClaudeConfig(cfg); - console.log(`✓ MCP server "${MCP_NAME}" removed`); - console.log("Restart Claude Code to drop the MCP connection."); + + console.log(""); + console.log("Restart Claude Code to drop the MCP connection + hooks."); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5cd8a81..8cf5d83 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -14,6 +14,7 @@ import { runJoin } from "./commands/join"; import { runList } from "./commands/list"; import { runLeave } from "./commands/leave"; import { runSeedTestMesh } from "./commands/seed-test-mesh"; +import { runHook } from "./commands/hook"; const HELP = `claudemesh — peer mesh for Claude Code sessions @@ -21,8 +22,9 @@ Usage: claudemesh [args] Commands: - install Register claudemesh as a Claude Code MCP server - uninstall Remove claudemesh MCP server registration + install Register MCP + Stop/UserPromptSubmit status hooks + (add --no-hooks for bare MCP registration) + uninstall Remove MCP server + hooks join Join a mesh via https://claudemesh.com/join/... URL list Show all joined meshes leave Leave a joined mesh @@ -45,11 +47,14 @@ async function main(): Promise { await startMcpServer(); return; case "install": - runInstall(); + runInstall(args); return; case "uninstall": runUninstall(); return; + case "hook": + await runHook(args); + return; case "join": await runJoin(args); return; diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 40f164a..ab1469a 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -82,14 +82,29 @@ export async function startMcpServer(): Promise { const config = loadConfig(); const server = new Server( - { name: "claudemesh", version: "0.1.0" }, + { name: "claudemesh", version: "0.1.1" }, { - capabilities: { tools: {} }, - instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions. + capabilities: { + experimental: { "claude/channel": {} }, + tools: {}, + }, + instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions on this machine and elsewhere. -Use these tools to coordinate with peers on demand. Respond promptly when you receive messages (they're like someone tapping your shoulder). +IMPORTANT: When you receive a message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something. -Tools: send_message, list_peers, check_messages, set_summary, set_status. +Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with the same target (for direct messages the from_id is the sender's pubkey). + +Available tools: +- list_peers: see joined meshes + their connection status +- send_message: send to a peer pubkey, channel, or broadcast (priority: now/next/low) +- check_messages: drain buffered inbound messages (usually auto-pushed) +- set_summary: 1-2 sentence summary of what you're working on +- set_status: manually override your status (idle/working/dnd) + +Message priority: +- "now": delivered immediately regardless of recipient status (use sparingly) +- "next" (default): delivered when recipient is idle +- "low": pull-only (check_messages) If you have multiple joined meshes, prefix the \`to\` argument of send_message with \`:\` to disambiguate. Otherwise claudemesh picks the single joined mesh.`, }, @@ -191,6 +206,39 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w const transport = new StdioServerTransport(); await server.connect(transport); + // Wire WSS pushes → MCP channel notifications. Each inbound push on + // any mesh's broker connection becomes a + // system reminder injected into Claude Code's context. + for (const client of allClients()) { + client.onPush(async (msg) => { + const fromPubkey = msg.senderPubkey || ""; + const fromName = fromPubkey + ? `peer-${fromPubkey.slice(0, 8)}` + : "unknown"; + const content = msg.plaintext ?? "(decryption failed)"; + 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, + }, + }, + }); + } catch { + /* channel push is best-effort; check_messages is the fallback */ + } + }); + } + const shutdown = (): void => { stopAll(); process.exit(0);