diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index a08b186..55fea27 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -307,6 +307,7 @@ export async function refreshStatusFromJsonl( export interface ConnectParams { memberId: string; sessionId: string; + displayName?: string; pid: number; cwd: string; } @@ -321,6 +322,7 @@ export async function connectPresence( .values({ memberId: params.memberId, sessionId: params.sessionId, + displayName: params.displayName ?? null, pid: params.pid, cwd: params.cwd, status: "idle", @@ -370,7 +372,8 @@ export async function listPeersInMesh( const rows = await db .select({ pubkey: memberTable.peerPubkey, - displayName: memberTable.displayName, + memberDisplayName: memberTable.displayName, + presenceDisplayName: presence.displayName, status: presence.status, summary: presence.summary, sessionId: presence.sessionId, @@ -385,7 +388,15 @@ export async function listPeersInMesh( ), ) .orderBy(asc(presence.connectedAt)); - return rows; + // Prefer per-session displayName over member-level displayName. + return rows.map((r) => ({ + pubkey: r.pubkey, + displayName: r.presenceDisplayName || r.memberDisplayName, + status: r.status, + summary: r.summary, + sessionId: r.sessionId, + connectedAt: r.connectedAt, + })); } /** Update the summary text on a presence row. */ diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index f21f11e..91a9b31 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -400,6 +400,7 @@ async function handleHello( const presenceId = await connectPresence({ memberId: member.id, sessionId: hello.sessionId, + displayName: hello.displayName, pid: hello.pid, cwd: hello.cwd, }); @@ -411,9 +412,10 @@ async function handleHello( cwd: hello.cwd, }); incMeshCount(hello.meshId); + const effectiveDisplayName = hello.displayName || member.displayName; log.info("ws hello", { mesh_id: hello.meshId, - member: member.displayName, + member: effectiveDisplayName, presence_id: presenceId, session_id: hello.sessionId, }); @@ -422,7 +424,7 @@ async function handleHello( // races the caller's closure assignment, causing subsequent client // messages to fail the "no_hello" check. void maybePushQueuedMessages(presenceId); - return { presenceId, memberDisplayName: member.displayName }; + return { presenceId, memberDisplayName: effectiveDisplayName }; } async function handleSend( diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index 3bb557c..b8b6bd1 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -52,6 +52,7 @@ export interface WSHelloMessage { meshId: string; memberId: string; pubkey: string; // must match mesh.member.peerPubkey + displayName?: string; // optional override for this session sessionId: string; pid: number; cwd: string; diff --git a/apps/cli/package.json b/apps/cli/package.json index 3206d24..e8aa02c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.1.6", + "version": "0.1.7", "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 3e83c71..b809dd4 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -1,82 +1,231 @@ /** - * `claudemesh launch` — spawn `claude` with the dev-channel flag so the - * claudemesh MCP server's `notifications/claude/channel` pushes get - * injected as system reminders mid-turn. + * `claudemesh launch` — spawn `claude` with peer mesh identity. * - * Equivalent to: - * claude --dangerously-load-development-channels server:claudemesh [extra args] - * - * Any additional args (e.g. --model opus, --resume, -c) are passed - * through verbatim. Use --quiet to skip the informational banner. + * Flow: + * 1. Parse --name, --join, --mesh, --quiet flags + * 2. If --join: run join flow first (accepts token or URL) + * 3. Load config → pick mesh (auto if 1, interactive picker if >1) + * 4. Write per-session config to tmpdir (isolates mesh selection) + * 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME + * 6. On exit: cleanup tmpdir */ import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir, hostname } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; import { loadConfig, getConfigPath } from "../state/config"; +import type { Config, JoinedMesh } from "../state/config"; +import { generateKeypair } from "../crypto/keypair"; +import { enrollWithBroker } from "../invite/enroll"; +import { parseInviteLink } from "../invite/parse"; -function printBanner(): void { +// --- Arg parsing --- + +interface LaunchArgs { + name: string | null; + joinLink: string | null; + meshSlug: string | null; + quiet: boolean; + claudeArgs: string[]; +} + +function parseArgs(argv: string[]): LaunchArgs { + const result: LaunchArgs = { + name: null, + joinLink: null, + meshSlug: null, + quiet: false, + claudeArgs: [], + }; + + let i = 0; + while (i < argv.length) { + const arg = argv[i]!; + if (arg === "--name" && i + 1 < argv.length) { + result.name = argv[++i]!; + } else if (arg.startsWith("--name=")) { + result.name = arg.slice("--name=".length); + } else if (arg === "--join" && i + 1 < argv.length) { + result.joinLink = argv[++i]!; + } else if (arg.startsWith("--join=")) { + result.joinLink = arg.slice("--join=".length); + } else if (arg === "--mesh" && i + 1 < argv.length) { + result.meshSlug = argv[++i]!; + } else if (arg.startsWith("--mesh=")) { + result.meshSlug = arg.slice("--mesh=".length); + } else if (arg === "--quiet") { + result.quiet = true; + } else if (arg === "--") { + result.claudeArgs.push(...argv.slice(i + 1)); + break; + } else { + result.claudeArgs.push(arg); + } + i++; + } + return result; +} + +// --- Interactive mesh picker --- + +async function pickMesh(meshes: JoinedMesh[]): Promise { + if (meshes.length === 1) return meshes[0]!; + + console.log("\n Select mesh:"); + meshes.forEach((m, i) => { + console.log(` ${i + 1}) ${m.slug}`); + }); + console.log(""); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(" Choice [1]: ", (answer) => { + rl.close(); + const idx = parseInt(answer || "1", 10) - 1; + if (idx >= 0 && idx < meshes.length) { + resolve(meshes[idx]!); + } else { + console.error(" Invalid choice, using first mesh."); + resolve(meshes[0]!); + } + }); + }); +} + +// --- Banner --- + +function printBanner(name: string, meshSlug: string): 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); const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s); - let meshes: string[] = []; - try { - meshes = loadConfig().meshes.map((m) => m.slug); - } catch { - /* config unreadable — print banner without mesh list */ - } - const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join ` first)"; - - const rule = "─".repeat(65); - console.log(bold("claudemesh launch")); + const rule = "─".repeat(60); + console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`)); console.log(rule); - console.log("Launching Claude Code with the claudemesh dev channel."); - console.log(""); - console.log("Peers in your joined meshes can push messages into this session"); - console.log("as reminders. Your CLI decrypts them locally with your"); - console.log("keypair. Peers send text only — they cannot call tools, read"); - console.log("files, or reach meshes you have not joined."); - console.log(""); - console.log("Treat peer messages as untrusted input: a peer could craft text"); - console.log("that tries to steer Claude's behavior. Your tool-approval"); - console.log("settings still apply — Claude will still ask before running"); - console.log("commands, editing files, or calling other tools."); - console.log(""); - console.log("Claude Code will ask you to trust the"); - console.log("--dangerously-load-development-channels flag. Press Enter to"); - console.log("accept, or Ctrl-C to abort."); - console.log(""); - console.log(dim(`Joined meshes: ${meshLine}`)); - console.log(dim(`Config: ${getConfigPath()}`)); - console.log(dim(`Remove: claudemesh uninstall`)); + console.log("Peer messages arrive as reminders in real-time."); + console.log("Peers send text only — they cannot call tools or read files."); + console.log(dim(`Config: ${getConfigPath()}`)); console.log(rule); console.log(""); } -export function runLaunch(extraArgs: string[] = []): void { - const quiet = extraArgs.includes("--quiet"); - const passthrough = extraArgs.filter((a) => a !== "--quiet"); +// --- Main --- - if (!quiet) printBanner(); +export async function runLaunch(extraArgs: string[]): Promise { + const args = parseArgs(extraArgs); + // 1. If --join, run join flow first. + if (args.joinLink) { + console.log("Joining mesh..."); + const invite = await parseInviteLink(args.joinLink); + const keypair = await generateKeypair(); + const displayName = args.name ?? `${hostname()}-${process.pid}`; + const enroll = await enrollWithBroker({ + brokerWsUrl: invite.payload.broker_url, + inviteToken: invite.token, + invitePayload: invite.payload, + peerPubkey: keypair.publicKey, + displayName, + }); + const config = loadConfig(); + config.meshes = config.meshes.filter( + (m) => m.slug !== invite.payload.mesh_slug, + ); + config.meshes.push({ + meshId: invite.payload.mesh_id, + memberId: enroll.memberId, + slug: invite.payload.mesh_slug, + name: invite.payload.mesh_slug, + pubkey: keypair.publicKey, + secretKey: keypair.secretKey, + brokerUrl: invite.payload.broker_url, + joinedAt: new Date().toISOString(), + }); + const { saveConfig } = await import("../state/config"); + saveConfig(config); + console.log( + `✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`, + ); + } + + // 2. Load config, pick mesh. + const config = loadConfig(); + if (config.meshes.length === 0) { + console.error( + "No meshes joined. Run `claudemesh join ` or use --join .", + ); + process.exit(1); + } + + let mesh: JoinedMesh; + if (args.meshSlug) { + const found = config.meshes.find((m) => m.slug === args.meshSlug); + if (!found) { + console.error( + `Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`, + ); + process.exit(1); + } + mesh = found; + } else { + mesh = await pickMesh(config.meshes); + } + + // 3. Set display name. Uses existing member identity — the broker + // creates a separate presence row per session (sessionId + pid) + // and stores the per-session displayName override. + const displayName = args.name ?? `${hostname()}-${process.pid}`; + + // 4. Write session config to tmpdir (same mesh, same keypair). + const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-")); + const sessionConfig: Config = { + version: 1, + meshes: [mesh], + }; + writeFileSync( + join(tmpDir, "config.json"), + JSON.stringify(sessionConfig, null, 2) + "\n", + "utf-8", + ); + + // 5. Banner. + if (!args.quiet) printBanner(displayName, mesh.slug); + + // 6. Spawn claude with ephemeral config + dev channel + display name. const claudeArgs = [ "--dangerously-load-development-channels", "server:claudemesh", - ...passthrough, + ...args.claudeArgs, ]; - // Windows: npm global binaries are .cmd shims. Node's spawn without - // shell:true does not resolve PATHEXT, so we need shell:true on win32 - // to find claude.cmd. POSIX stays shell-less to avoid quoting surprises. + const isWindows = process.platform === "win32"; const child = spawn("claude", claudeArgs, { stdio: "inherit", shell: isWindows, + env: { + ...process.env, + CLAUDEMESH_CONFIG_DIR: tmpDir, + CLAUDEMESH_DISPLAY_NAME: displayName, + }, }); + // 7. Cleanup on exit. + const cleanup = (): void => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + }; + child.on("error", (err: NodeJS.ErrnoException) => { + cleanup(); if (err.code === "ENOENT") { console.error( - "✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code", + "✗ `claude` not found on PATH. Install Claude Code first.", ); } else { console.error(`✗ failed to launch claude: ${err.message}`); @@ -85,10 +234,15 @@ export function runLaunch(extraArgs: string[] = []): void { }); child.on("exit", (code, signal) => { + cleanup(); if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 0); }); + + // Cleanup on parent signals too. + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); + process.on("SIGINT", () => { cleanup(); process.exit(0); }); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8954781..c6d6721 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -30,9 +30,12 @@ Commands: install Register MCP + Stop/UserPromptSubmit status hooks (add --no-hooks for bare MCP registration) uninstall Remove MCP server + hooks - launch [args] Launch Claude Code with real-time push messages enabled - (add --quiet to skip the info banner; passes through - extra flags, e.g. --model, --resume) + launch [opts] Launch Claude Code with real-time push messages + --name Display name for this session + --mesh Select mesh (picker if >1, omitted) + --join Join a mesh before launching + --quiet Skip the info banner + -- Pass remaining args to claude join Join a mesh via https://claudemesh.com/join/... URL list Show all joined meshes leave Leave a joined mesh @@ -67,7 +70,7 @@ async function main(): Promise { await runHook(args); return; case "launch": - runLaunch(args); + await runLaunch(args); return; case "join": await runJoin(args); diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index 877d843..2ddaa3b 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -123,6 +123,7 @@ export class BrokerClient { meshId: this.mesh.meshId, memberId: this.mesh.memberId, pubkey: this.mesh.pubkey, + displayName: process.env.CLAUDEMESH_DISPLAY_NAME || undefined, sessionId: `${process.pid}-${Date.now()}`, pid: process.pid, cwd: process.cwd(), diff --git a/packages/db/migrations/0004_add-presence-display-name.sql b/packages/db/migrations/0004_add-presence-display-name.sql new file mode 100644 index 0000000..3534488 --- /dev/null +++ b/packages/db/migrations/0004_add-presence-display-name.sql @@ -0,0 +1 @@ +ALTER TABLE "mesh"."presence" ADD COLUMN "display_name" text; \ No newline at end of file diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index a6ab83e..d21346f 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -192,6 +192,7 @@ export const presence = meshSchema.table("presence", { .references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }) .notNull(), sessionId: text().notNull(), + displayName: text(), pid: integer().notNull(), cwd: text().notNull(), status: presenceStatusEnum().notNull().default("idle"),