diff --git a/apps/cli/package.json b/apps/cli/package.json index 9186d9f..7b9f58d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.5.0", + "version": "0.5.1", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index 78f1efc..da05301 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -26,6 +26,7 @@ interface LaunchArgs { groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member" joinLink: string | null; meshSlug: string | null; + messageMode: "push" | "inbox" | "off" | null; quiet: boolean; skipPermConfirm: boolean; claudeArgs: string[]; @@ -38,6 +39,7 @@ function parseArgs(argv: string[]): LaunchArgs { groups: null, joinLink: null, meshSlug: null, + messageMode: null, quiet: false, skipPermConfirm: false, claudeArgs: [], @@ -66,6 +68,10 @@ function parseArgs(argv: string[]): LaunchArgs { result.meshSlug = argv[++i]!; } else if (arg.startsWith("--mesh=")) { result.meshSlug = arg.slice("--mesh=".length); + } else if (arg === "--inbox") { + result.messageMode = "inbox"; + } else if (arg === "--no-messages") { + result.messageMode = "off"; } else if (arg === "--quiet") { result.quiet = true; } else if (arg === "-y" || arg === "--yes") { @@ -171,7 +177,7 @@ async function confirmPermissions(): Promise { // --- Banner --- -function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[]): void { +function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void { const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); @@ -183,9 +189,15 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups : ""; const rule = "─".repeat(60); - console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags}`)); + console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`)); console.log(rule); - console.log("Peer messages arrive as reminders in real-time."); + if (messageMode === "push") { + console.log("Peer messages arrive as reminders in real-time."); + } else if (messageMode === "inbox") { + console.log("Peer messages held in inbox. Use check_messages to read."); + } else { + console.log("Messages off. Use check_messages to poll manually."); + } console.log("Peers send text only — they cannot call tools or read files."); console.log(dim(`Config: ${getConfigPath()}`)); console.log(rule); @@ -263,6 +275,8 @@ export async function runLaunch(extraArgs: string[]): Promise { let role: string | null = args.role; let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : []; + let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push"; + if (!args.quiet) { if (role === null) { const answer = await askLine(" Role (optional): "); @@ -272,6 +286,18 @@ export async function runLaunch(extraArgs: string[]): Promise { const answer = await askLine(" Groups (comma-separated, optional): "); if (answer) parsedGroups = parseGroupsString(answer); } + if (args.messageMode === null) { + console.log("\n Message mode:"); + console.log(" 1) Push (real-time, peers can interrupt your work)"); + console.log(" 2) Inbox (held until you check, notification only)"); + console.log(" 3) Off (tools only, no messages)"); + console.log(""); + const answer = await askLine(" Choice [1]: "); + const choice = parseInt(answer || "1", 10); + if (choice === 2) messageMode = "inbox"; + else if (choice === 3) messageMode = "off"; + else messageMode = "push"; + } if (role || parsedGroups.length) console.log(""); } @@ -293,6 +319,7 @@ export async function runLaunch(extraArgs: string[]): Promise { meshes: [mesh], displayName, ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), + messageMode, }; writeFileSync( join(tmpDir, "config.json"), @@ -302,7 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise { // 5. Banner + permission confirmation. if (!args.quiet) { - printBanner(displayName, mesh.slug, role, parsedGroups); + printBanner(displayName, mesh.slug, role, parsedGroups, messageMode); // Auto-permissions confirmation — needed for autonomous peer messaging. if (!args.skipPermConfirm) { await confirmPermissions(); diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index aab12ab..1e5690e 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -131,6 +131,7 @@ export async function startMcpServer(): Promise { const myName = config.displayName ?? "unnamed"; const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none"; + const messageMode = config.messageMode ?? "push"; const server = new Server( { name: "claudemesh", version: "0.3.0" }, @@ -236,7 +237,13 @@ Create and claim work items. create_task to propose work, claim_task to take own - "low": pull-only via check_messages (FYI, non-blocking context) ## Coordination -Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus.`, +Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus. + +## Message Mode +Your message mode is "${messageMode}". +- push: messages arrive in real-time as channel notifications. Respond immediately. +- inbox: messages are held. You'll see "[inbox] New message from X" notifications. Call check_messages to read them. +- off: no message notifications. Use check_messages manually to poll.`, }, ); @@ -716,11 +723,31 @@ Call list_peers at session start to understand who is online, their roles, and w // system reminder injected into Claude Code's context. for (const client of allClients()) { client.onPush(async (msg) => { + // In "off" mode, silently skip notification — messages are still + // buffered in pushBuffer and accessible via check_messages. + if (messageMode === "off") return; + const fromPubkey = msg.senderPubkey || ""; // Resolve sender's display name from the cached peer list. const fromName = fromPubkey ? await resolvePeerName(client, fromPubkey) : "unknown"; + + if (messageMode === "inbox") { + // Count-only notification, no content + 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 notification const content = msg.plaintext ?? decryptFailedWarning(fromPubkey); try { await server.notification({ diff --git a/apps/cli/src/state/config.ts b/apps/cli/src/state/config.ts index d7f270a..1c35511 100644 --- a/apps/cli/src/state/config.ts +++ b/apps/cli/src/state/config.ts @@ -38,6 +38,7 @@ export interface Config { meshes: JoinedMesh[]; displayName?: string; // per-session override, written by `claudemesh launch --name` groups?: GroupEntry[]; + messageMode?: "push" | "inbox" | "off"; } const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh"); @@ -53,7 +54,7 @@ export function loadConfig(): Config { if (!parsed || !Array.isArray(parsed.meshes)) { return { version: 1, meshes: [] }; } - return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups }; + return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode }; } catch (e) { throw new Error( `Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,