fix(web): self-heal node-pty spawn-helper (#1978)

* fix(web): self-heal node-pty spawn-helper

* chore: remove changeset

* fix(web): centralize node-pty prebuild path

* fix(cli): preserve update lifecycle config path

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
i-trytoohard 2026-05-21 08:02:41 +05:30 committed by GitHub
parent 018fefd6fb
commit a66a087ef6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 365 additions and 24 deletions

View File

@ -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`");
});

View File

@ -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);

View File

@ -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"

View File

@ -147,12 +147,14 @@ async function handleCheck(): Promise<void> {
*/
interface UpdateLifecyclePlan {
runningBeforeUpdate: boolean;
configPath?: string;
primaryProjectId?: string;
activeSessions: Session[];
}
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
let sessions: Session[];
let configPath: string | undefined;
let primaryProjectId: string | undefined;
let runningBeforeUpdate = false;
try {
@ -168,6 +170,7 @@ async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
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<UpdateLifecyclePlan> {
// 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<UpdateLifecyclePlan> {
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<boolean> {
@ -232,7 +236,9 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
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<number> {
function runAoLifecycleCommand(
args: string[],
opts: { configPath?: string } = {},
): Promise<number> {
return new Promise<number>((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}`));

View File

@ -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");

View File

@ -366,6 +366,7 @@ export {
isMac,
isLinux,
getDefaultRuntime,
getNodePtyPrebuildsSubdir,
getShell,
killProcessTree,
findPidByPort,

View File

@ -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 {

View File

@ -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)", () => {

View File

@ -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<PtySpawn>[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<string, ManagedTerminal>();
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,