per-session presence is small and uncomplicated enough that a rollback flag isn't load-bearing. backwards compat is already covered at the protocol layer — older brokers reply unknown_message_type to session_hello and the SessionBrokerClient marks itself closed for that mesh, which is the same outcome the flag would have given. removing the flag, the helper, and the conditional from the registry hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
966 lines
35 KiB
TypeScript
966 lines
35 KiB
TypeScript
// @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<void> {
|
|
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<JoinedMesh> {
|
|
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<string> {
|
|
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<void> {
|
|
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<LaunchWizardResult> {
|
|
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 <channel> 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<void> {
|
|
// 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 <url>`)}\n`);
|
|
|
|
// Race: localhost callback vs manual paste vs timeout
|
|
const manualPromise = new Promise<string>((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<null>((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 <url>` or use --join <url>.");
|
|
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<string, unknown> = {};
|
|
try {
|
|
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
|
} catch {
|
|
claudeConfig = {};
|
|
}
|
|
|
|
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
|
|
|
// Session-scoped key: mesh:<service>:<sessionId>
|
|
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);
|
|
}
|