fix(cli): clean TTY handoff to claude via spawnSync + defensive reset
Terminals spawned by `claudemesh launch` were dropping keystrokes at
claude's prompt and showing the launch wizard re-rendering on top of
claude's TUI. Two compounding causes:
1. spawn() + child.on('exit') kept the parent node event loop alive
during claude's lifetime. Any stray readline 'data' listener or
late render from the wizard could fire on the inherited stdin/
stdout, stealing keystrokes or painting over claude's Ink TUI.
2. Raw mode / alt-screen / hidden cursor set by the wizard helpers
was not reliably restored before the handoff.
Fix:
- Swap spawn for spawnSync so the parent event loop is fully blocked
while claude runs. No listener or setImmediate can fire during
claude's lifetime.
- Hard TTY reset right before the spawn: setRawMode(false),
removeAllListeners on stdin, show cursor (ESC[?25h), exit alt
screen (ESC[?1049l). Defensive — survives partial wizard cleanup.
- Move cleanup() registration to process.on('exit') so it runs
synchronously on every exit path (normal, signal, throw).
- Preserve signal forwarding: if claude dies from a signal, re-raise
the same signal on the parent so exit codes propagate correctly.
Bumps to v0.10.6.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.9.2",
|
"version": "0.10.6",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* 6. On exit: cleanup tmpdir
|
* 6. On exit: cleanup tmpdir
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { tmpdir, hostname, homedir } from "node:os";
|
import { tmpdir, hostname, homedir } from "node:os";
|
||||||
@@ -129,6 +129,192 @@ async function confirmPermissions(): Promise<void> {
|
|||||||
|
|
||||||
// --- Banner ---
|
// --- Banner ---
|
||||||
|
|
||||||
|
import {
|
||||||
|
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
|
||||||
|
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
|
||||||
|
} from "../tui/colors";
|
||||||
|
import {
|
||||||
|
enterFullScreen, exitFullScreen, writeCentered, termSize,
|
||||||
|
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
|
||||||
|
} from "../tui/screen";
|
||||||
|
import { createSpinner, FRAME_HEIGHT } from "../tui/spinner";
|
||||||
|
|
||||||
|
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 {
|
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
@@ -299,6 +485,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1)
|
||||||
let mesh: JoinedMesh;
|
let mesh: JoinedMesh;
|
||||||
if (args.meshSlug) {
|
if (args.meshSlug) {
|
||||||
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
||||||
@@ -309,43 +496,38 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
mesh = found;
|
mesh = found;
|
||||||
|
} else if (config.meshes.length === 1) {
|
||||||
|
mesh = config.meshes[0]!;
|
||||||
} else {
|
} else {
|
||||||
mesh = await pickMesh(config.meshes);
|
// Multiple meshes — wizard will handle selection
|
||||||
|
mesh = null as unknown as JoinedMesh; // set by wizard below
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Session identity + role/groups.
|
// 3. Session identity + role/groups via TUI wizard.
|
||||||
// The WS client auto-generates a per-session ephemeral keypair on
|
|
||||||
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
|
||||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
|
|
||||||
let role: string | null = args.role;
|
let role: string | null = args.role;
|
||||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||||
|
|
||||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||||
|
|
||||||
if (!args.quiet && !justSynced) {
|
if (!args.quiet && !justSynced) {
|
||||||
if (role === null) {
|
const wizardResult = await runLaunchWizard({
|
||||||
const answer = await askLine(" Role (optional): ");
|
displayName,
|
||||||
if (answer) role = answer;
|
meshes: config.meshes,
|
||||||
}
|
selectedMesh: mesh ?? null,
|
||||||
if (parsedGroups.length === 0 && args.groups === null) {
|
existingRole: args.role,
|
||||||
const answer = await askLine(" Groups (comma-separated, optional): ");
|
existingGroups: parsedGroups,
|
||||||
if (answer) parsedGroups = parseGroupsString(answer);
|
existingMessageMode: args.messageMode ?? null,
|
||||||
}
|
skipPermConfirm: args.skipPermConfirm,
|
||||||
if (args.messageMode === null) {
|
});
|
||||||
console.log("\n Message mode:");
|
mesh = wizardResult.mesh;
|
||||||
console.log(" 1) Push (real-time, peers can interrupt your work)");
|
role = wizardResult.role;
|
||||||
console.log(" 2) Inbox (held until you check, notification only)");
|
parsedGroups = wizardResult.groups;
|
||||||
console.log(" 3) Off (tools only, no messages)");
|
messageMode = wizardResult.messageMode;
|
||||||
console.log("");
|
args.skipPermConfirm = wizardResult.skipPermissions;
|
||||||
const answer = await askLine(" Choice [1]: ");
|
} else if (!mesh) {
|
||||||
const choice = parseInt(answer || "1", 10);
|
// Quiet mode + multiple meshes — fall back to old picker
|
||||||
if (choice === 2) messageMode = "inbox";
|
mesh = await pickMesh(config.meshes);
|
||||||
else if (choice === 3) messageMode = "off";
|
|
||||||
else messageMode = "push";
|
|
||||||
}
|
|
||||||
if (role || parsedGroups.length) console.log("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||||
@@ -425,13 +607,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Banner + permission confirmation.
|
// 5. Print summary banner (wizard already handled all interactive config).
|
||||||
if (!args.quiet) {
|
if (!args.quiet) {
|
||||||
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||||
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
|
||||||
if (!args.skipPermConfirm) {
|
|
||||||
await confirmPermissions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Install native MCP entries for deployed mesh services ---
|
// --- Install native MCP entries for deployed mesh services ---
|
||||||
@@ -518,22 +696,24 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
...filtered,
|
...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";
|
const isWindows = process.platform === "win32";
|
||||||
const child = spawn("claude", claudeArgs, {
|
let claudeBin = "claude";
|
||||||
stdio: "inherit",
|
if (!isWindows) {
|
||||||
shell: isWindows,
|
const candidates = [
|
||||||
env: {
|
join(homedir(), ".local", "bin", "claude"),
|
||||||
...process.env,
|
"/usr/local/bin/claude",
|
||||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
join(homedir(), ".claude", "bin", "claude"),
|
||||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
];
|
||||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
for (const c of candidates) {
|
||||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
if (existsSync(c)) { claudeBin = c; break; }
|
||||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
}
|
||||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Cleanup on exit.
|
// 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 => {
|
const cleanup = (): void => {
|
||||||
// Remove mesh MCP entries from ~/.claude.json
|
// Remove mesh MCP entries from ~/.claude.json
|
||||||
if (meshMcpEntries.length > 0) {
|
if (meshMcpEntries.length > 0) {
|
||||||
@@ -548,36 +728,90 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
// Existing tmpdir cleanup
|
// Ephemeral config dir
|
||||||
try {
|
try {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch { /* best effort */ }
|
||||||
/* best effort */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
// Register cleanup on every exit path — including normal exit, uncaught
|
||||||
cleanup();
|
// 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 } : {}),
|
||||||
|
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") {
|
if (err.code === "ENOENT") {
|
||||||
console.error(
|
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
|
||||||
"✗ `claude` not found on PATH. Install Claude Code first.",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
}
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
if (result.signal) {
|
||||||
cleanup();
|
// Re-raise the same signal so the parent dies the same way the child did.
|
||||||
if (signal) {
|
process.kill(process.pid, result.signal);
|
||||||
process.kill(process.pid, signal);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
process.exit(code ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on parent signals too.
|
process.exit(result.status ?? 0);
|
||||||
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
||||||
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user