// @ts-nocheck — v1 port, runtime-tested /** * `claudemesh launch` — spawn `claude` with peer mesh identity. * * Flags are defined in index.ts (citty command) — that is the source of * truth. This file receives already-parsed flags and rawArgs. * * Flow: * 1. Receive parsed flags from citty + rawArgs for -- passthrough * 2. If --join: run join flow first * 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 { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs"; import { tmpdir, hostname, homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; import { readConfig, getConfigPath } from "~/services/config/facade.js"; import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js"; import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js"; import { openBrowser } from "~/services/spawn/facade.js"; import { BrokerClient } from "~/services/broker/facade.js"; import { render } from "~/ui/render.js"; // Flags as parsed by citty (index.ts is the source of truth for definitions). export interface LaunchFlags { name?: string; role?: string; groups?: string; join?: string; mesh?: string; "message-mode"?: string; "system-prompt"?: string; resume?: string; continue?: boolean; yes?: boolean; quiet?: boolean; } // --- Interactive mesh picker --- /** * Ensure the per-user daemon is running before we hand off to Claude Code. * * As of 1.24.0 the daemon owns the broker WS and feeds the MCP push-pipe * over IPC SSE. If the socket is absent when Claude boots its MCP shim, * the shim bails (no fallback). Delegates to the shared lifecycle helper * (services/daemon/lifecycle.ts) which probes the socket properly * (avoiding the stale-socket bug where existsSync was a false positive * after a daemon crash), spawns under a file-lock, and polls for liveness. */ async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise { const { ensureDaemonReady } = await import("~/services/daemon/lifecycle.js"); if (!quiet) render.info("ensuring claudemesh daemon is running…"); // Larger budget for `launch` — it's a one-shot flow where the user // is actively waiting; cold node start + broker hello can take // longer than the default 3s budget for ad-hoc verbs. const res = await ensureDaemonReady({ budgetMs: 10_000, mesh: meshSlug }); if (res.state === "up") { if (!quiet) render.ok("daemon already running"); return; } if (res.state === "started") { if (!quiet) render.ok(`daemon ready (${res.durationMs}ms)`); return; } render.warn( `daemon ${res.state}${res.reason ? `: ${res.reason}` : ""}`, "Run `claudemesh daemon up --mesh " + meshSlug + "` manually, then re-launch.", ); } 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]!); } }); }); } // --- Group string parser --- /** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */ function parseGroupsString(raw: string): GroupEntry[] { return raw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((token) => { const idx = token.indexOf(":"); if (idx === -1) return { name: token }; return { name: token.slice(0, idx), role: token.slice(idx + 1) }; }); } // --- Interactive role/groups prompts --- function askLine(prompt: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); }); }); } // --- Permission confirmation --- async function confirmPermissions(): Promise { const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s); const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s); console.log(yellow(bold(" Autonomous mode"))); console.log(""); console.log(" Claude will run with --dangerously-skip-permissions, bypassing"); console.log(" ALL permission prompts — not just claudemesh tools."); console.log(" Peers exchange text only — no file access, no tool calls."); console.log(""); console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools).")); console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes.")); console.log(""); const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve, reject) => { rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => { rl.close(); const a = answer.trim().toLowerCase(); if (a === "" || a === "y" || a === "yes") { resolve(); } else { console.log("\n Aborted. Run without autonomous mode:"); console.log(" claude --dangerously-load-development-channels server:claudemesh\n"); process.exit(0); } }); }); } // --- Banner --- import { bold as tBold, dim as tDim, green as tGreen, orange as tOrange, boldOrange, HIDE_CURSOR, SHOW_CURSOR, } from "~/ui/styles.js"; import { enterFullScreen, exitFullScreen, writeCentered, termSize, drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt, } from "~/ui/screen.js"; import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js"; interface LaunchWizardResult { mesh: JoinedMesh; role: string | null; groups: GroupEntry[]; messageMode: "push" | "inbox" | "off"; skipPermissions: boolean; } /** * Full-screen launch wizard — spinning logo + interactive config. * Mesh selection, role, groups, message mode, permissions — all in one TUI. * Falls back to plain text on non-TTY. */ async function runLaunchWizard(opts: { displayName: string; meshes: JoinedMesh[]; selectedMesh: JoinedMesh | null; existingRole: string | null; existingGroups: GroupEntry[]; existingMessageMode: "push" | "inbox" | "off" | null; skipPermConfirm: boolean; }): Promise { if (!process.stdout.isTTY) { return { mesh: opts.selectedMesh ?? opts.meshes[0]!, role: opts.existingRole, groups: opts.existingGroups, messageMode: opts.existingMessageMode ?? "push", skipPermissions: opts.skipPermConfirm, }; } const { rows } = termSize(); enterFullScreen(); drawTopBar(); // Spinning logo centered in upper portion const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2); const brandRow = logoTop + FRAME_HEIGHT + 1; const subtitleRow = brandRow + 1; const formRow = subtitleRow + 2; writeCentered(brandRow, boldOrange("claudemesh")); writeCentered(subtitleRow, tDim("peer mesh for Claude Code")); const spinner = createSpinner({ render(lines) { for (let i = 0; i < lines.length; i++) { writeCentered(logoTop + i, lines[i]!); } }, interval: 70, }); spinner.start(); // Show detected info let row = formRow; writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`); row++; writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`); row += 2; // Mesh selection let mesh: JoinedMesh; if (opts.selectedMesh) { mesh = opts.selectedMesh; writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`); row++; } else if (opts.meshes.length === 1) { mesh = opts.meshes[0]!; writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`); row++; } else { spinner.stop(); const choice = await menuSelect({ title: "Select mesh", items: opts.meshes.map((m) => m.slug), row, }); mesh = opts.meshes[choice]!; // Redraw as confirmed for (let i = 0; i < opts.meshes.length + 1; i++) { writeCentered(row + i, " "); } writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`); spinner.start(); row++; } row++; // Interactive fields let role = opts.existingRole; let groups = opts.existingGroups; let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off"; // Role input if (role === null) { spinner.stop(); const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" }); if (answer) role = answer; spinner.start(); row++; } else { writeCentered(row, `Role ${tGreen("✓")} ${role}`); row++; } // Groups input if (groups.length === 0) { spinner.stop(); const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" }); if (answer) groups = parseGroupsString(answer); spinner.start(); row++; } else { const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", "); writeCentered(row, `Groups ${tGreen("✓")} ${tags}`); row++; } // Message mode selection if (opts.existingMessageMode === null) { row++; spinner.stop(); const choice = await menuSelect({ title: "Message mode", items: [ "Push (real-time, peers can interrupt)", "Inbox (held until you check)", "Off (tools only, no messages)", ], row, }); messageMode = (["push", "inbox", "off"] as const)[choice]; spinner.start(); row += 5; } else { writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`); row++; } // Permissions confirmation let skipPermissions = opts.skipPermConfirm; if (!skipPermissions) { row++; spinner.stop(); writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,")); writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh.")); row += 3; const confirmed = await confirmPrompt({ message: boldOrange("Autonomous mode?"), row, defaultYes: true, }); if (!confirmed) { exitFullScreen(); console.log(" Run without autonomous mode:"); console.log(" claude --dangerously-load-development-channels server:claudemesh\n"); process.exit(0); } skipPermissions = true; spinner.start(); } // Final animation row += 2; writeCentered(row, tDim("Launching Claude Code...")); await new Promise(r => setTimeout(r, 800)); spinner.stop(); exitFullScreen(); return { mesh, role, groups, messageMode, skipPermissions }; } 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); const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s); const roleSuffix = role ? ` (${role})` : ""; const groupTags = groups.length ? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]" : ""; const rule = "─".repeat(60); console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`)); console.log(rule); 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); console.log(""); } // --- Main --- export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise { // Extract args that follow "--" — passed straight through to claude. const dashIdx = rawArgs.indexOf("--"); const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : []; // Normalise flags into the internal shape used below. const args = { name: flags.name ?? null, role: flags.role ?? null, groups: flags.groups ?? null, joinLink: flags.join ?? null, meshSlug: flags.mesh ?? null, messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "") ? flags["message-mode"] as "push" | "inbox" | "off" : null), systemPrompt: flags["system-prompt"] ?? null, resume: flags.resume ?? null, continueSession: flags.continue ?? false, quiet: flags.quiet ?? false, skipPermConfirm: flags.yes ?? false, claudeArgs: claudePassthrough, }; // 1. If --join, run join flow first. if (args.joinLink) { render.info(tDim("Joining mesh…")); const invite = await parseInviteLink(args.joinLink); const keypair = await generateKeypair(); const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname()); const enroll = await enrollWithBroker({ brokerWsUrl: invite.payload.broker_url, inviteToken: invite.token, invitePayload: invite.payload, peerPubkey: keypair.publicKey, displayName, }); const config = readConfig(); 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 { writeConfig } = await import("~/services/config/facade.js"); writeConfig(config); render.ok( `joined ${tBold(invite.payload.mesh_slug)}`, enroll.alreadyMember ? "already member" : undefined, ); } // 2. Load config, pick mesh. const config = readConfig(); let justSynced = false; if (config.meshes.length === 0 && !args.joinLink) { const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s); const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s); const code = generatePairingCode(); const listener = await startCallbackListener(); const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`; console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`); console.log(` Opening browser to sign in...\n`); const opened = await openBrowser(url); if (!opened) { console.log(` Couldn't open browser automatically.`); } console.log(` ${dim(`Visit: ${url}`)}`); console.log(` ${dim(`Or join with invite: claudemesh launch --join `)}\n`); // Race: localhost callback vs manual paste vs timeout const manualPromise = new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); rl.question(" Paste sync token (or wait for browser): ", (answer) => { rl.close(); if (answer.trim()) resolve(answer.trim()); }); }); const timeoutPromise = new Promise((resolve) => { setTimeout(() => resolve(null), 15 * 60_000); }); const syncToken = await Promise.race([ listener.token, manualPromise, timeoutPromise, ]); listener.close(); if (!syncToken) { console.error("\n Timed out waiting for sign-in."); process.exit(1); } // Generate keypair and sync with broker const { generateKeypair } = await import("~/services/crypto/facade.js"); const keypair = await generateKeypair(); const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname()); const { syncWithBroker } = await import("~/services/auth/facade.js"); const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync); // Write all meshes to config const { writeConfig } = await import("~/services/config/facade.js"); for (const m of result.meshes) { config.meshes.push({ meshId: m.mesh_id, memberId: m.member_id, slug: m.slug, name: m.slug, pubkey: keypair.publicKey, secretKey: keypair.secretKey, brokerUrl: m.broker_url, joinedAt: new Date().toISOString(), }); } config.accountId = result.account_id; writeConfig(config); justSynced = true; console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`); } if (config.meshes.length === 0) { render.err("No meshes joined.", "Run `claudemesh join ` or use --join ."); process.exit(1); } // Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1) let mesh: JoinedMesh; if (args.meshSlug) { const found = config.meshes.find((m) => m.slug === args.meshSlug); if (!found) { render.err( `Mesh "${args.meshSlug}" not found.`, `Joined: ${config.meshes.map((m) => m.slug).join(", ")}`, ); process.exit(1); } mesh = found; } else if (config.meshes.length === 1) { mesh = config.meshes[0]!; } else { // Multiple meshes — wizard will handle selection mesh = null as unknown as JoinedMesh; // set by wizard below } // 3. Session identity + role/groups via TUI wizard. const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname()); let role: string | null = args.role; let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : []; let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push"; // `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard // entirely and use sensible defaults (role=member, no groups, push mode). // Same applies to `--quiet` and the post-sync path where we already picked. const nonInteractive = args.quiet || justSynced || args.skipPermConfirm; if (!nonInteractive) { const wizardResult = await runLaunchWizard({ displayName, meshes: config.meshes, selectedMesh: mesh ?? null, existingRole: args.role, existingGroups: parsedGroups, existingMessageMode: args.messageMode ?? null, skipPermConfirm: args.skipPermConfirm, }); mesh = wizardResult.mesh; role = wizardResult.role; parsedGroups = wizardResult.groups; messageMode = wizardResult.messageMode; args.skipPermConfirm = wizardResult.skipPermissions; } else if (!mesh) { // No mesh picked yet + non-interactive — pick the first one deterministically. mesh = config.meshes[0]!; } // Clean up orphaned tmpdirs from crashed sessions (older than 1 hour) const tmpBase = tmpdir(); try { for (const entry of readdirSync(tmpBase)) { if (!entry.startsWith("claudemesh-")) continue; const full = join(tmpBase, entry); const age = Date.now() - statSync(full).mtimeMs; if (age > 3600_000) rmSync(full, { recursive: true, force: true }); } } catch { /* best effort */ } // Ensure the daemon is running before we spawn Claude. The MCP shim // (loaded by --dangerously-load-development-channels server:claudemesh) // requires the daemon's UDS to be reachable at boot — if it isn't, // channel push, slash commands, and resources fail. await ensureDaemonRunning(mesh.slug, args.quiet); // Clean up stale mesh MCP entries from crashed sessions try { const claudeConfigPath = join(homedir(), ".claude.json"); if (existsSync(claudeConfigPath)) { const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); const mcpServers = claudeConfig.mcpServers ?? {}; let cleaned = 0; for (const key of Object.keys(mcpServers)) { if (!key.startsWith("mesh:")) continue; const meta = mcpServers[key]?._meshSession; if (!meta?.pid) continue; // Check if the PID is still alive try { process.kill(meta.pid, 0); // signal 0 = check existence } catch { // PID is dead — remove stale entry delete mcpServers[key]; cleaned++; } } if (cleaned > 0) { claudeConfig.mcpServers = mcpServers; writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8"); } } } catch { /* best effort */ } // --- Fetch deployed services for native MCP entries --- let serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string; }> = []; try { const tmpClient = new BrokerClient(mesh, { displayName }); await tmpClient.connect(); // Wait briefly for hello_ack with service catalog await new Promise(r => setTimeout(r, 2000)); serviceCatalog = tmpClient.serviceCatalog; tmpClient.close(); } catch { // Non-fatal — launch without native service entries if (!args.quiet) { console.log(" (Could not fetch service catalog — mesh services won't be natively available)"); } } // 4. Write session config to tmpdir (isolates mesh selection). const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-")); const sessionConfig: Config = { version: 1, meshes: [mesh], displayName, ...(role ? { role } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), messageMode, }; writeFileSync( join(tmpDir, "config.json"), JSON.stringify(sessionConfig, null, 2) + "\n", "utf-8", ); // 4b. Mint a per-session IPC token, persist it under tmpDir, and // register it with the daemon. The token's path is exposed to // the spawned claude (and all its descendants) via env so // CLI invocations from inside the session auto-attribute to it. // // 1.30.0: also mint an ephemeral ed25519 session keypair and a // parent-vouched attestation. The daemon uses these to open a // long-lived broker WebSocket per session (presence row keyed on // the session pubkey, member_id from the parent), so sibling // sessions in the same mesh see each other in `peer list`. // // Session-id resolution: 1.29.0 referenced `claudeSessionId` // before its `const` declaration further down the file, hitting // the TDZ → ReferenceError swallowed by the surrounding catch. // The IPC registration has been silently failing every launch // since 1.29.0. Hoist the declaration up so it actually runs. const isResume = args.resume !== null || args.continueSession; const claudeSessionId = isResume ? undefined : randomUUID(); let sessionTokenFilePath: string | null = null; let sessionTokenForCleanup: string | null = null; try { const { mintSessionToken, TOKEN_FILE_ENV } = await import("~/services/session/token.js"); const minted = mintSessionToken(tmpDir); sessionTokenFilePath = minted.filePath; sessionTokenForCleanup = minted.token; // Per-session ephemeral keypair + parent attestation (1.30.0+). // Older daemons ignore unknown body fields, so sending presence // material always is forward-compatible. let presencePayload: { session_pubkey: string; session_secret_key: string; parent_attestation: { session_pubkey: string; parent_member_pubkey: string; expires_at: number; signature: string; }; } | undefined; try { const { generateKeypair } = await import("~/services/crypto/facade.js"); const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js"); const sessionKp = await generateKeypair(); const att = await signParentAttestation({ parentMemberPubkey: mesh.pubkey, parentSecretKey: mesh.secretKey, sessionPubkey: sessionKp.publicKey, }); presencePayload = { session_pubkey: sessionKp.publicKey, session_secret_key: sessionKp.secretKey, parent_attestation: { session_pubkey: att.sessionPubkey, parent_member_pubkey: att.parentMemberPubkey, expires_at: att.expiresAt, signature: att.signature, }, }; } catch { // Keypair / attestation failure — proceed without per-session // presence. The session still registers; only the broker-side // presence row is skipped. } // Register with the daemon. Best-effort: a daemon failure here // means the session falls back to user-level scope, which is fine. const { ipc } = await import("~/daemon/ipc/client.js"); const sessionIdForRegister = claudeSessionId ?? randomUUID(); await ipc({ method: "POST", path: "/v1/sessions/register", timeoutMs: 3_000, body: { token: minted.token, session_id: sessionIdForRegister, mesh: mesh.slug, display_name: displayName, pid: process.pid, cwd: process.cwd(), ...(role ? { role } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`) } : {}), ...(presencePayload ? { presence: presencePayload } : {}), }, }).catch(() => null); // Pin the env name on a global so the spawn block below can pick it up. (process as unknown as { _claudemeshTokenEnv?: { name: string; value: string } })._claudemeshTokenEnv = { name: TOKEN_FILE_ENV, value: minted.filePath, }; } catch { // Token mint or registration failed — proceed without per-session // attribution. CLI invocations from the session will still work, // they'll just default to user-level scope. } // 5. Print summary banner (wizard already handled all interactive config). if (!args.quiet) { printBanner(displayName, mesh.slug, role, parsedGroups, messageMode); } // --- Install native MCP entries for deployed mesh services --- const meshMcpEntries: Array<{ key: string; entry: unknown }> = []; if (serviceCatalog.length > 0) { const claudeConfigPath = join(homedir(), ".claude.json"); // Read-modify-write: only touch mesh:* entries in mcpServers let claudeConfig: Record = {}; try { claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); } catch { claudeConfig = {}; } const mcpServers = (claudeConfig.mcpServers ?? {}) as Record; // Session-scoped key: mesh:: const sessionTag = `${process.pid}`; for (const svc of serviceCatalog) { if (svc.status !== "running") continue; const entryKey = `mesh:${svc.name}:${sessionTag}`; const entry = { command: "claudemesh", args: ["mcp", "--service", svc.name], env: { CLAUDEMESH_CONFIG_DIR: tmpDir, }, _meshSession: { pid: process.pid, meshSlug: mesh.slug, serviceName: svc.name, createdAt: new Date().toISOString(), }, }; mcpServers[entryKey] = entry; meshMcpEntries.push({ key: entryKey, entry }); } claudeConfig.mcpServers = mcpServers; writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8"); if (!args.quiet && meshMcpEntries.length > 0) { console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`); for (const { key } of meshMcpEntries) { const svcName = key.split(":")[1]; const svc = serviceCatalog.find(s => s.name === svcName); console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`); } console.log(""); } } // 6. Spawn claude with ephemeral config + dev channel + auto-permissions. // Strip any user-supplied --dangerously flags to avoid duplicates. const filtered: string[] = []; for (let i = 0; i < args.claudeArgs.length; i++) { if (args.claudeArgs[i] === "--dangerously-load-development-channels" || args.claudeArgs[i] === "--dangerously-skip-permissions") { if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++; continue; } filtered.push(args.claudeArgs[i]!); } // --dangerously-skip-permissions is only added when the user explicitly // passes -y / --yes. Without it, claudemesh tools still work because // `claudemesh install` pre-approves them via allowedTools in settings.json. // This keeps permissions tight for multi-person meshes. // Session identity: claudeSessionId was generated above (4b) so the // session-token registration could include it. Reuse here. const claudeArgs = [ "--dangerously-load-development-channels", "server:claudemesh", ...(claudeSessionId ? ["--session-id", claudeSessionId] : []), ...(args.resume ? ["--resume", args.resume] : []), ...(args.continueSession ? ["--continue"] : []), ...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []), ...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []), ...filtered, ]; // Resolve the full path to `claude` — when launched from a non-interactive // shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH. const isWindows = process.platform === "win32"; let claudeBin = "claude"; if (!isWindows) { const candidates = [ join(homedir(), ".local", "bin", "claude"), "/usr/local/bin/claude", join(homedir(), ".claude", "bin", "claude"), ]; for (const c of candidates) { if (existsSync(c)) { claudeBin = c; break; } } } // 7. Define cleanup — runs on every exit path via process.on('exit'). // Synchronous-only (rmSync + writeFileSync) so it works inside the // 'exit' event, which does not allow async work. const cleanup = (): void => { // Remove mesh MCP entries from ~/.claude.json if (meshMcpEntries.length > 0) { try { const claudeConfigPath = join(homedir(), ".claude.json"); const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); const mcpServers = claudeConfig.mcpServers ?? {}; for (const { key } of meshMcpEntries) { delete mcpServers[key]; } claudeConfig.mcpServers = mcpServers; writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8"); } catch { /* best effort */ } } // The token's session-token file lives inside tmpDir; rmSync below // shreds the secret. The daemon's session reaper notices the // launched session's pid is gone within 30s and drops the registry // entry. Explicit DELETE on /v1/sessions is feasible only from an // async exit hook, which adds complexity for ~30s of memory the // reaper will reclaim anyway. Leaving as-is; revisit if the // registry ever grows persistence. // Ephemeral config dir (also drops the session-token file) try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } }; // Register cleanup on every exit path — including normal exit, uncaught // throws, and fatal signals. process.on('exit') fires synchronously, which // is what the rmSync + writeFileSync above need. process.on("exit", cleanup); // 8. Hard-reset the TTY before handing control to claude. // // Every interactive element in the pre-launch flow — the full-screen // wizard (tui/screen.ts), the permission confirmation, the callback- // listener paste prompt, the mesh picker — attaches listeners to // process.stdin, toggles raw mode, hides the cursor, and sometimes // enters the alt-screen. Those helpers do best-effort cleanup in their // own finally blocks, but any leak — an orphaned 'data' listener, a // still-raw TTY, a pending render paint — means the parent node process // keeps competing with claude's Ink TUI for the same keystrokes and // stdout frames. Symptoms: dropped keystrokes at the claude prompt, or // the wizard visibly repainting on top of claude after launch. // // Defensive reset here is cheap and guarantees a clean TTY regardless // of what the wizard helpers did or didn't restore. if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ } } process.stdin.removeAllListeners("data"); process.stdin.removeAllListeners("keypress"); process.stdin.removeAllListeners("readable"); process.stdin.pause(); if (process.stdout.isTTY) { process.stdout.write("\x1b[?25h"); // show cursor process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it } // 9. Block-and-wait on claude with spawnSync. // // Why spawnSync instead of spawn + child.on('exit'): // - spawn keeps the parent node event loop running alongside claude. // Any stray listener, setImmediate, or async wizard tail-end can // still fire during claude's lifetime, stealing input or painting // over claude's TUI. // - spawnSync blocks the parent event loop completely until claude // exits. No listeners fire. Nothing paints. The parent is effectively // suspended, and claude has exclusive ownership of the TTY. // // Signal forwarding: claude inherits the TTY process group via // stdio: "inherit". When the user hits Ctrl-C, the terminal sends // SIGINT to the whole group. Claude handles it (Ink unmounts, exits // cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise // the same signal on the parent so it dies the same way. const result = spawnSync(claudeBin, claudeArgs, { stdio: "inherit", shell: isWindows, env: { ...process.env, CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_DISPLAY_NAME: displayName, ...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}), ...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}), MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000", MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000", ...(role ? { CLAUDEMESH_ROLE: role } : {}), }, }); // 10. Handle the result. Cleanup runs automatically via process.on('exit'). if (result.error) { const err = result.error as NodeJS.ErrnoException; if (err.code === "ENOENT") { render.err("`claude` not found on PATH.", "Install Claude Code first."); } else { render.err(`failed to launch claude: ${err.message}`); } process.exit(1); } if (result.signal) { // Re-raise the same signal so the parent dies the same way the child did. process.kill(process.pid, result.signal); return; } process.exit(result.status ?? 0); }