diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index 47e408ad8..843bcc1bb 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -600,7 +600,10 @@ describe("update command", () => { expect(mockSpawn.mock.calls[0]).toEqual([ "ao", ["stop", "--yes"], - expect.objectContaining({ stdio: "inherit" }), + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), ]); expect(mockSpawn.mock.calls[1][0]).toBe("pnpm"); expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]); @@ -609,7 +612,10 @@ describe("update command", () => { expect(mockSpawn.mock.calls[3]).toEqual([ "ao", ["start", "my-app", "--restore"], - expect.objectContaining({ stdio: "inherit" }), + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), ]); const stopOrder = mockSpawn.mock.invocationCallOrder[0]; @@ -662,6 +668,11 @@ describe("update command", () => { expect(mockSpawn.mock.calls[0][0]).toBe("ao"); expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); + expect(mockSpawn.mock.calls[0][2]).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), + ); expect(mockSpawn.mock.calls[1][0]).toBe("pnpm"); expect(mockSpawn.mock.calls[2][0]).toBe("ao"); expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]); @@ -683,7 +694,10 @@ describe("update command", () => { expect(mockSpawn.mock.calls.at(-1)).toEqual([ "ao", ["start", "my-app", "--no-restore"], - expect.objectContaining({ stdio: "inherit" }), + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), ]); }); @@ -705,7 +719,10 @@ describe("update command", () => { expect(mockSpawn.mock.calls[0][0]).toBe("ao"); expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false); - const stderr = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); expect(stderr).toContain("AO still appears to be running after `ao stop --yes`"); }); diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index 6315d48ef..514100cac 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -7,6 +7,7 @@ import { readFileSync, rmSync, symlinkSync, + statSync, utimesSync, writeFileSync, } from "node:fs"; @@ -34,7 +35,14 @@ function createHealthyRepo(tempRoot: string): string { mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n"); + writeFileSync( + join(fakeRepo, "packages", "core", "package.json"), + JSON.stringify({ type: "module", main: "dist/index.js" }, null, 2), + ); + writeFileSync( + join(fakeRepo, "packages", "core", "dist", "index.js"), + 'export function getNodePtyPrebuildsSubdir() { return process.platform + "-" + process.arch; }\n', + ); writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n"); writeFileSync( join(fakeRepo, "packages", "ao", "bin", "ao.js"), @@ -61,7 +69,7 @@ function createHealthyPath(binDir: string): void { createFakeBinary( binDir, "node", - 'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0', + `if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"`, ); createFakeBinary( binDir, @@ -252,6 +260,67 @@ exit 0`, expect(npmCommands).toContain("link --force"); }); + it("warns about and repairs a non-executable node-pty spawn-helper", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-node-pty-helper-")); + const fakeRepo = createHealthyRepo(tempRoot); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + createHealthyPath(binDir); + + const helperPath = join( + fakeRepo, + "node_modules", + "node-pty", + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + mkdirSync(dirname(helperPath), { recursive: true }); + writeFileSync(join(fakeRepo, "node_modules", "node-pty", "package.json"), "{}\n"); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o644); + + const configPath = join(tempRoot, "agent-orchestrator.yaml"); + const dataDir = join(tempRoot, "data"); + const worktreeDir = join(tempRoot, "worktrees"); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + writeFileSync( + configPath, + [`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"), + ); + + const warnResult = spawnSync("bash", [scriptPath], { + env: { + ...process.env, + PATH: `${binDir}:/bin:/usr/bin`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + const fixResult = spawnSync("bash", [scriptPath, "--fix"], { + env: { + ...process.env, + PATH: `${binDir}:/bin:/usr/bin`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + const statMode = (statSync(helperPath).mode & 0o111) !== 0; + rmSync(tempRoot, { recursive: true, force: true }); + + expect(warnResult.status).toBe(0); + expect(warnResult.stdout).toContain("WARN node-pty spawn-helper is not executable"); + expect(warnResult.stdout).toContain("posix_spawnp failed"); + expect(fixResult.status).toBe(0); + expect(fixResult.stdout).toContain("FIXED chmod +x applied to node-pty spawn-helper"); + expect(statMode).toBe(true); + }); + it("reports a healthy packaged install without source-checkout failures", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-")); const fakeInstall = createHealthyPackageInstall(tempRoot); diff --git a/packages/cli/src/assets/scripts/ao-doctor.sh b/packages/cli/src/assets/scripts/ao-doctor.sh index bd3fd8895..2b20735bf 100755 --- a/packages/cli/src/assets/scripts/ao-doctor.sh +++ b/packages/cli/src/assets/scripts/ao-doctor.sh @@ -13,10 +13,10 @@ while [ $# -gt 0 ]; do cat <<'EOF' Usage: ao doctor [--fix] -Checks install, PATH, binaries, service health, stale temp files, and runtime sanity. +Checks install, PATH, binaries, service health, web terminal support, stale temp files, and runtime sanity. Options: - --fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files + --fix Apply safe fixes for missing launcher links, missing support dirs, node-pty spawn-helper permissions, and stale temp files EOF exit 0 ;; @@ -398,6 +398,136 @@ check_stale_temp_files() { warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix" } +file_mode_octal() { + case "$(uname -s 2>/dev/null || true)" in + Darwin|FreeBSD|OpenBSD|NetBSD) + stat -f '%Lp' "$1" 2>/dev/null || printf 'unknown' + ;; + *) + stat -c '%a' "$1" 2>/dev/null || printf 'unknown' + ;; + esac +} + +resolve_node_pty_spawn_helper() { + node - "$REPO_ROOT" <<'NODE' +const fs = require("node:fs"); +const { createRequire } = require("node:module"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const repoRoot = process.argv[2]; + +function resolvePackageJson(fromDir) { + try { + return createRequire(path.join(fromDir, "ao-doctor.js")).resolve("node-pty/package.json"); + } catch { + return null; + } +} + +function findPackageUp(startDir, ...segments) { + let dir = path.resolve(startDir); + while (true) { + const candidate = path.resolve(dir, "node_modules", ...segments); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function resolveNodeModulesPackage(fromDir, ...segments) { + const packageDir = path.resolve(fromDir, "node_modules", ...segments); + return fs.existsSync(path.resolve(packageDir, "package.json")) ? packageDir : null; +} + +function resolveCoreEntrypoint() { + const sourceCoreDir = path.resolve(repoRoot, "packages", "core"); + const coreDir = + (fs.existsSync(path.join(sourceCoreDir, "package.json")) ? sourceCoreDir : null) ?? + findPackageUp(repoRoot, "@aoagents", "ao-core") ?? + resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-core"); + if (!coreDir) return null; + + try { + const pkg = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8")); + const entry = pkg.exports?.["."]?.import ?? pkg.module ?? pkg.main ?? "dist/index.js"; + return path.resolve(coreDir, entry); + } catch { + return null; + } +} + +async function getNodePtyPrebuildsSubdir() { + const coreEntrypoint = resolveCoreEntrypoint(); + if (!coreEntrypoint || !fs.existsSync(coreEntrypoint)) return null; + + const core = await import(pathToFileURL(coreEntrypoint).href); + return typeof core.getNodePtyPrebuildsSubdir === "function" + ? core.getNodePtyPrebuildsSubdir() + : null; +} + +(async () => { + const packageJsonPath = + resolvePackageJson(repoRoot) ?? + (() => { + const directNodePtyDir = findPackageUp(repoRoot, "node-pty"); + if (directNodePtyDir) return path.join(directNodePtyDir, "package.json"); + + const sourceWebDir = path.resolve(repoRoot, "packages", "web"); + const webDir = + (fs.existsSync(path.join(sourceWebDir, "package.json")) ? sourceWebDir : null) ?? + findPackageUp(repoRoot, "@aoagents", "ao-web") ?? + resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-web"); + if (!webDir) return null; + + const webNodePtyDir = + resolveNodeModulesPackage(webDir, "node-pty") ?? findPackageUp(webDir, "node-pty"); + return webNodePtyDir ? path.join(webNodePtyDir, "package.json") : null; + })(); + + const prebuildsSubdir = await getNodePtyPrebuildsSubdir(); + if (!packageJsonPath || !prebuildsSubdir) process.exit(0); + + console.log(path.join(path.dirname(packageJsonPath), "prebuilds", prebuildsSubdir, "spawn-helper")); +})().catch(() => process.exit(0)); +NODE +} + +check_node_pty_spawn_helper() { + if ! command -v node >/dev/null 2>&1; then + warn "node-pty spawn-helper check skipped because node is unavailable" + return + fi + + local helper_path mode + helper_path="$(resolve_node_pty_spawn_helper 2>/dev/null || true)" + if [ -z "$helper_path" ] || [ ! -f "$helper_path" ]; then + pass "node-pty spawn-helper check skipped because no helper was found for this platform" + return + fi + + mode="$(file_mode_octal "$helper_path")" + if [ -x "$helper_path" ]; then + pass "node-pty spawn-helper is executable at $helper_path (mode 0o$mode)" + return + fi + + if [ "$FIX_MODE" = true ]; then + if chmod 755 "$helper_path"; then + fixed "chmod +x applied to node-pty spawn-helper at $helper_path (was 0o$mode)" + return + fi + warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode), and chmod failed. Web dashboard terminals can fail with posix_spawnp failed. Fix: chmod +x $helper_path" + return + fi + + warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode). Web dashboard terminals can fail with posix_spawnp failed. Fix: run ao doctor --fix or chmod +x $helper_path. See ao#1770." +} + printf 'Agent Orchestrator Doctor\n\n' check_node @@ -409,6 +539,7 @@ check_gh check_config_dirs check_stale_temp_files check_install_layout +check_node_pty_spawn_helper check_runtime_sanity printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT" diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 16f13ede0..b181d4370 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -147,12 +147,14 @@ async function handleCheck(): Promise { */ interface UpdateLifecyclePlan { runningBeforeUpdate: boolean; + configPath?: string; primaryProjectId?: string; activeSessions: Session[]; } async function getUpdateLifecyclePlan(): Promise { let sessions: Session[]; + let configPath: string | undefined; let primaryProjectId: string | undefined; let runningBeforeUpdate = false; try { @@ -168,6 +170,7 @@ async function getUpdateLifecyclePlan(): Promise { const running = await getRunning(); if (running && running.projects.length > 0) { runningBeforeUpdate = true; + configPath = running.configPath; primaryProjectId = running.projects[0]; // running.configPath could be local-wrapped (a project's // agent-orchestrator.yaml) OR the canonical global path. loadConfig @@ -184,15 +187,16 @@ async function getUpdateLifecyclePlan(): Promise { // sessions to `killed`, so terminal statuses don't block the update. const globalPath = getGlobalConfigPath(); if (!existsSync(globalPath)) { - return { runningBeforeUpdate, primaryProjectId, activeSessions: [] }; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } const globalConfig = loadGlobalConfig(globalPath); if (!globalConfig || Object.keys(globalConfig.projects).length === 0) { - return { runningBeforeUpdate, primaryProjectId, activeSessions: [] }; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } if (!isCanonicalGlobalConfigPath(globalPath)) { - return { runningBeforeUpdate, primaryProjectId, activeSessions: [] }; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } + configPath = globalPath; const config = loadConfig(globalPath); primaryProjectId = Object.keys(config.projects)[0]; const sm = await getSessionManager(config); @@ -204,11 +208,11 @@ async function getUpdateLifecyclePlan(): Promise { console.error( chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."), ); - return { runningBeforeUpdate, primaryProjectId, activeSessions: [] }; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status)); - return { runningBeforeUpdate, primaryProjectId, activeSessions: active }; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active }; } async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { @@ -232,7 +236,9 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { console.log(chalk.dim("\nAO is running; it will be restarted after the update.")); } - const stopExit = await runAoLifecycleCommand(["stop", "--yes"]); + const stopExit = await runAoLifecycleCommand(["stop", "--yes"], { + configPath: plan.configPath, + }); if (stopExit !== 0) { recordActivityEvent({ source: "cli", @@ -288,7 +294,7 @@ async function restartAoAfterUpdate( args.push(opts.restore ? "--restore" : "--no-restore"); console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`)); - const exitCode = await runAoLifecycleCommand(args); + const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath }); if (exitCode !== 0) { recordActivityEvent({ source: "cli", @@ -307,12 +313,16 @@ async function restartAoAfterUpdate( } } -function runAoLifecycleCommand(args: string[]): Promise { +function runAoLifecycleCommand( + args: string[], + opts: { configPath?: string } = {}, +): Promise { return new Promise((resolveExit) => { const child = spawn("ao", args, { stdio: "inherit", shell: isWindows(), windowsHide: true, + env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env, }); child.on("error", (error) => { console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`)); diff --git a/packages/core/src/__tests__/platform.test.ts b/packages/core/src/__tests__/platform.test.ts index 06667db02..0e3fcfb80 100644 --- a/packages/core/src/__tests__/platform.test.ts +++ b/packages/core/src/__tests__/platform.test.ts @@ -44,6 +44,14 @@ describe("platform adapter", () => { }); }); + describe("getNodePtyPrebuildsSubdir", () => { + it("centralizes node-pty prebuild platform/arch naming", async () => { + setPlatform("darwin"); + const mod = await import("../platform.js"); + expect(mod.getNodePtyPrebuildsSubdir()).toBe(`darwin-${process.arch}`); + }); + }); + describe("getShell", () => { it("always returns /bin/sh on unix (ignores $SHELL)", async () => { setPlatform("linux"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c5936c82..cbf2024a7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -366,6 +366,7 @@ export { isMac, isLinux, getDefaultRuntime, + getNodePtyPrebuildsSubdir, getShell, killProcessTree, findPidByPort, diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 5cde1af87..2f4323555 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -28,6 +28,10 @@ export function getDefaultRuntime(): "tmux" | "process" { return isWindows() ? "process" : "tmux"; } +export function getNodePtyPrebuildsSubdir(): string { + return `${process.platform}-${process.arch}`; +} + // -- Shell resolution -- interface ShellInfo { diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index db6ed9548..3f5cc0216 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Socket } from "node:net"; import { WebSocket } from "ws"; -import { appendDashboardNotification, type OrchestratorEvent } from "@aoagents/ao-core"; +import { appendDashboardNotification, isWindows, type OrchestratorEvent } from "@aoagents/ao-core"; import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; // vi.mock factories run before module-level statements. Hoist the mock @@ -966,6 +966,41 @@ describe("TerminalManager.open — tmux target args (regression for #1714)", () const [, args] = mockPtySpawn.mock.calls[0]; expect(args).toEqual(["attach-session", "-t", "=ao-177"]); }); + + it("repairs node-pty spawn-helper when applicable and retries once after posix_spawnp failure", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-mux-spawn-helper-")); + const helperPath = join(tempRoot, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o644); + process.env.AO_NODE_PTY_SPAWN_HELPER_PATH = helperPath; + + const pty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + }; + + mockPtySpawn + .mockImplementationOnce(() => { + throw new Error("posix_spawnp failed."); + }) + .mockImplementationOnce(() => pty); + + try { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + if (!isWindows()) { + expect((statSync(helperPath).mode & 0o111) !== 0).toBe(true); + } + } finally { + delete process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); describe("TerminalManager.open — re-attach skipped when tmux session is gone (regression for #1756)", () => { diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 6b14436f2..9be199e2c 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -8,11 +8,15 @@ import { WebSocketServer, WebSocket } from "ws"; import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; import { type Socket, connect as netConnect } from "node:net"; +import { dirname, join } from "node:path"; import { DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, getEnvDefaults, getDashboardNotificationStorePath, + getNodePtyPrebuildsSubdir, isWindows, loadConfig, normalizeDashboardNotificationLimit, @@ -380,8 +384,38 @@ export class NotificationBroadcaster { // node-pty is an optionalDependency — load dynamically /* eslint-disable @typescript-eslint/consistent-type-imports -- node-pty is optional; static import would crash if missing */ type IPty = import("node-pty").IPty; -let ptySpawn: typeof import("node-pty").spawn | undefined; +type PtySpawn = typeof import("node-pty").spawn; +type PtySpawnOptions = Parameters[2]; +let ptySpawn: PtySpawn | undefined; /* eslint-enable @typescript-eslint/consistent-type-imports */ +const nodePtyRequire = createRequire(import.meta.url); + +export function resolveNodePtySpawnHelperPath(): string | null { + const override = process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + if (override) return override; + + try { + const packageJsonPath = nodePtyRequire.resolve("node-pty/package.json"); + return join(dirname(packageJsonPath), "prebuilds", getNodePtyPrebuildsSubdir(), "spawn-helper"); + } catch { + return null; + } +} + +function isPosixSpawnpFailure(err: unknown): boolean { + return err instanceof Error && err.message.includes("posix_spawnp"); +} + +function repairNodePtySpawnHelper(): string | null { + if (isWindows()) return null; + + const spawnHelperPath = resolveNodePtySpawnHelperPath(); + if (!spawnHelperPath || !fs.existsSync(spawnHelperPath)) return null; + + fs.chmodSync(spawnHelperPath, 0o755); + return spawnHelperPath; +} + try { const nodePty = await import("node-pty"); ptySpawn = nodePty.spawn; @@ -432,6 +466,7 @@ const REATTACH_RESET_GRACE_MS = 5_000; export class TerminalManager { private terminals = new Map(); private TMUX: string; + private spawnHelperRepairAttempted = false; constructor(tmuxPath?: string) { const resolved = tmuxPath ?? findTmux(); @@ -445,6 +480,41 @@ export class TerminalManager { return projectId ? `${projectId}:${id}` : id; } + private spawnTmuxPty(args: string[], options: PtySpawnOptions): IPty { + if (!ptySpawn) { + throw new Error("node-pty not available"); + } + + try { + return ptySpawn(this.TMUX, args, options); + } catch (err) { + if (this.spawnHelperRepairAttempted || !isPosixSpawnpFailure(err)) { + throw err; + } + + this.spawnHelperRepairAttempted = true; + try { + const repairedPath = repairNodePtySpawnHelper(); + if (repairedPath) { + console.warn( + `[MuxServer] node-pty posix_spawnp failed; set executable bit on ${repairedPath} and retrying once.`, + ); + } else { + console.warn( + "[MuxServer] node-pty posix_spawnp failed; spawn-helper was not found, retrying once.", + ); + } + } catch (repairErr) { + const message = repairErr instanceof Error ? repairErr.message : String(repairErr); + console.warn( + `[MuxServer] node-pty posix_spawnp failed; chmod spawn-helper failed (${message}), retrying once.`, + ); + } + + return ptySpawn(this.TMUX, args, options); + } + } + /** * Open/attach to a terminal. If already open, just return. * If has subscribers but PTY crashed, re-attach. @@ -515,14 +585,10 @@ export class TerminalManager { TMPDIR: platformDefaults.TMPDIR, }; - if (!ptySpawn) { - throw new Error("node-pty not available"); - } - // Spawn PTY — use `=`-prefixed exact-match target so we never attach to // a session whose name happens to be a prefix of the requested id. const exactTmuxTarget = `=${tmuxSessionId}`; - const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], { + const pty = this.spawnTmuxPty(["attach-session", "-t", exactTmuxTarget], { name: "xterm-256color", cols: 80, rows: 24,