feat(cli): wss push → mcp channel injection + status hooks in install
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 <channel source="claudemesh">
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 <status>` (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) <noreply@anthropic.com>
This commit is contained in:
123
apps/cli/src/commands/hook.ts
Normal file
123
apps/cli/src/commands/hook.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* `claudemesh hook <status>` — 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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
brokerWsUrl: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<Record<string, unknown>>((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);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
interface HookCommand {
|
||||
type: "command";
|
||||
command: string;
|
||||
}
|
||||
interface HookMatcher {
|
||||
matcher?: string;
|
||||
hooks: HookCommand[];
|
||||
}
|
||||
type HooksConfig = Record<string, HookMatcher[]>;
|
||||
|
||||
function readClaudeConfig(): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return {};
|
||||
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeSettings(obj: Record<string, unknown>): 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<string, McpEntry>
|
||||
| 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<string, McpEntry>
|
||||
| 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.");
|
||||
}
|
||||
|
||||
@@ -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 <command> [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 <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
@@ -45,11 +47,14 @@ async function main(): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -82,14 +82,29 @@ export async function startMcpServer(): Promise<void> {
|
||||
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 <channel source="claudemesh" ...> 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 \`<mesh-slug>:\` 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 <channel source="claudemesh">
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user