fix: fall back to node-pty when Bun is not defined (Electron/Node.js) (#1446)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
2
build.js
2
build.js
@@ -49,7 +49,7 @@ await Bun.build({
|
|||||||
// Keep most native Node.js modules external to avoid bundling issues
|
// Keep most native Node.js modules external to avoid bundling issues
|
||||||
// But don't make `sharp` external, causes issues with global Bun-based installs
|
// But don't make `sharp` external, causes issues with global Bun-based installs
|
||||||
// ref: #745, #1200
|
// ref: #745, #1200
|
||||||
external: ["ws", "@vscode/ripgrep"],
|
external: ["ws", "@vscode/ripgrep", "node-pty"],
|
||||||
features: features,
|
features: features,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
"test:update-chain:manual": "bun run src/tests/update-chain-smoke.ts --mode manual",
|
"test:update-chain:manual": "bun run src/tests/update-chain-smoke.ts --mode manual",
|
||||||
"test:update-chain:startup": "bun run src/tests/update-chain-smoke.ts --mode startup",
|
"test:update-chain:startup": "bun run src/tests/update-chain-smoke.ts --mode startup",
|
||||||
"prepublishOnly": "bun run build",
|
"prepublishOnly": "bun run build",
|
||||||
"postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped"
|
"postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx,json}": [
|
"*.{ts,tsx,js,jsx,json}": [
|
||||||
|
|||||||
@@ -2,29 +2,33 @@
|
|||||||
* PTY terminal handler for listen mode.
|
* PTY terminal handler for listen mode.
|
||||||
* Manages interactive terminal sessions spawned by the web UI.
|
* Manages interactive terminal sessions spawned by the web UI.
|
||||||
*
|
*
|
||||||
* Uses Bun's native Bun.Terminal API (available since Bun v1.3.5)
|
* Runtime strategy:
|
||||||
* for real PTY support without node-pty.
|
* - Bun → Bun.spawn with terminal option (native PTY, no node-pty needed)
|
||||||
|
* - Node.js / Electron → node-pty (Bun.spawn unavailable; node-pty's libuv
|
||||||
|
* poll handles integrate correctly with Node.js but NOT with Bun's event loop)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const IS_BUN = typeof Bun !== "undefined";
|
||||||
|
|
||||||
|
// 16ms debounce window for output batching; flush immediately at 64 KB
|
||||||
|
// to prevent unbounded string growth on high-throughput commands.
|
||||||
|
const FLUSH_INTERVAL_MS = 16;
|
||||||
|
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||||
|
|
||||||
interface TerminalSession {
|
interface TerminalSession {
|
||||||
process: ReturnType<typeof Bun.spawn>;
|
write: (data: string) => void;
|
||||||
terminal: {
|
resize: (cols: number, rows: number) => void;
|
||||||
write: (data: string) => void;
|
kill: () => void;
|
||||||
resize: (cols: number, rows: number) => void;
|
pid: number;
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
spawnedAt: number;
|
spawnedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminals = new Map<string, TerminalSession>();
|
const terminals = new Map<string, TerminalSession>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default shell for the current platform.
|
|
||||||
*/
|
|
||||||
function getDefaultShell(): string {
|
function getDefaultShell(): string {
|
||||||
if (os.platform() === "win32") {
|
if (os.platform() === "win32") {
|
||||||
return process.env.COMSPEC || "cmd.exe";
|
return process.env.COMSPEC || "cmd.exe";
|
||||||
@@ -32,9 +36,6 @@ function getDefaultShell(): string {
|
|||||||
return process.env.SHELL || "/bin/zsh";
|
return process.env.SHELL || "/bin/zsh";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a terminal message back to the web client via the device WebSocket.
|
|
||||||
*/
|
|
||||||
function sendTerminalMessage(
|
function sendTerminalMessage(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
message: Record<string, unknown>,
|
message: Record<string, unknown>,
|
||||||
@@ -44,9 +45,177 @@ function sendTerminalMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Create a flush-on-size-or-timer output batcher. */
|
||||||
* Spawn a new PTY terminal session using Bun's native Terminal API.
|
function makeOutputBatcher(
|
||||||
*/
|
onFlush: (data: string) => void,
|
||||||
|
): (chunk: string) => void {
|
||||||
|
let buffer = "";
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
onFlush(buffer);
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
if (buffer.length >= MAX_BUFFER_BYTES) {
|
||||||
|
flush();
|
||||||
|
} else if (!timer) {
|
||||||
|
timer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bun spawn ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function spawnBun(
|
||||||
|
shell: string,
|
||||||
|
cwd: string,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
terminal_id: string,
|
||||||
|
socket: WebSocket,
|
||||||
|
): TerminalSession {
|
||||||
|
const handleData = makeOutputBatcher((data) =>
|
||||||
|
sendTerminalMessage(socket, { type: "terminal_output", terminal_id, data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const proc = Bun.spawn([shell], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
|
||||||
|
terminal: {
|
||||||
|
cols: cols || 80,
|
||||||
|
rows: rows || 24,
|
||||||
|
data: (_t: unknown, chunk: Uint8Array) =>
|
||||||
|
handleData(new TextDecoder().decode(chunk)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminal = (
|
||||||
|
proc as unknown as {
|
||||||
|
terminal: {
|
||||||
|
write: (d: string) => void;
|
||||||
|
resize: (c: number, r: number) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).terminal;
|
||||||
|
|
||||||
|
if (!terminal) {
|
||||||
|
throw new Error("Bun.spawn terminal object missing — API unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.exited.then((exitCode) => {
|
||||||
|
const current = terminals.get(terminal_id);
|
||||||
|
if (current && current.pid === proc.pid) {
|
||||||
|
terminals.delete(terminal_id);
|
||||||
|
sendTerminalMessage(socket, {
|
||||||
|
type: "terminal_exited",
|
||||||
|
terminal_id,
|
||||||
|
exitCode: exitCode ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
write: (d) => {
|
||||||
|
try {
|
||||||
|
terminal.write(d);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
resize: (c, r) => {
|
||||||
|
try {
|
||||||
|
terminal.resize(c, r);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
kill: () => {
|
||||||
|
try {
|
||||||
|
terminal.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
pid: proc.pid,
|
||||||
|
terminalId: terminal_id,
|
||||||
|
spawnedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── node-pty spawn (Node.js / Electron) ───────────────────────────────────
|
||||||
|
|
||||||
|
function spawnNodePty(
|
||||||
|
shell: string,
|
||||||
|
cwd: string,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
terminal_id: string,
|
||||||
|
socket: WebSocket,
|
||||||
|
): TerminalSession {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const pty = require("node-pty") as typeof import("node-pty");
|
||||||
|
|
||||||
|
const handleData = makeOutputBatcher((data) =>
|
||||||
|
sendTerminalMessage(socket, { type: "terminal_output", terminal_id, data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cols: cols || 80,
|
||||||
|
rows: rows || 24,
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
COLORTERM: "truecolor",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProcess.onData(handleData);
|
||||||
|
|
||||||
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
|
const current = terminals.get(terminal_id);
|
||||||
|
if (current && current.pid === ptyProcess.pid) {
|
||||||
|
terminals.delete(terminal_id);
|
||||||
|
sendTerminalMessage(socket, {
|
||||||
|
type: "terminal_exited",
|
||||||
|
terminal_id,
|
||||||
|
exitCode: exitCode ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
write: (d) => {
|
||||||
|
try {
|
||||||
|
ptyProcess.write(d);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
resize: (c, r) => {
|
||||||
|
try {
|
||||||
|
ptyProcess.resize(c, r);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
kill: () => {
|
||||||
|
try {
|
||||||
|
ptyProcess.kill();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
pid: ptyProcess.pid,
|
||||||
|
terminalId: terminal_id,
|
||||||
|
spawnedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function handleTerminalSpawn(
|
export function handleTerminalSpawn(
|
||||||
msg: { terminal_id: string; cols: number; rows: number },
|
msg: { terminal_id: string; cols: number; rows: number },
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
@@ -54,111 +223,56 @@ export function handleTerminalSpawn(
|
|||||||
): void {
|
): void {
|
||||||
const { terminal_id, cols, rows } = msg;
|
const { terminal_id, cols, rows } = msg;
|
||||||
|
|
||||||
// Kill existing session with same ID if any
|
// React Strict Mode fires mount→unmount→mount which produces spawn→kill→spawn
|
||||||
killTerminal(terminal_id);
|
// in rapid succession. The kill is already ignored (< 2s guard below), but the
|
||||||
|
// second spawn would normally kill and restart. If the session is < 2s old and
|
||||||
|
// still alive, reuse it and resend terminal_spawned instead.
|
||||||
|
const existing = terminals.get(terminal_id);
|
||||||
|
if (existing && Date.now() - existing.spawnedAt < 2000) {
|
||||||
|
let alive = true;
|
||||||
|
try {
|
||||||
|
existing.write("\r");
|
||||||
|
} catch {
|
||||||
|
alive = false;
|
||||||
|
}
|
||||||
|
|
||||||
const shell = getDefaultShell();
|
if (alive) {
|
||||||
|
console.log(
|
||||||
console.log(
|
`[Terminal] Reusing session (age=${Date.now() - existing.spawnedAt}ms), pid=${existing.pid}`,
|
||||||
`[Terminal] Spawning PTY: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawn([shell], {
|
|
||||||
cwd,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: "xterm-256color",
|
|
||||||
COLORTERM: "truecolor",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
cols: cols || 80,
|
|
||||||
rows: rows || 24,
|
|
||||||
data: (() => {
|
|
||||||
// Batch output chunks within a 16ms window into a single WS message
|
|
||||||
// to avoid flooding the WebSocket with many small frames.
|
|
||||||
let buffer = "";
|
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
return (_terminal: unknown, data: Uint8Array) => {
|
|
||||||
buffer += new TextDecoder().decode(data);
|
|
||||||
|
|
||||||
if (!flushTimer) {
|
|
||||||
flushTimer = setTimeout(() => {
|
|
||||||
if (buffer.length > 0) {
|
|
||||||
sendTerminalMessage(socket, {
|
|
||||||
type: "terminal_output",
|
|
||||||
terminal_id,
|
|
||||||
data: buffer,
|
|
||||||
});
|
|
||||||
buffer = "";
|
|
||||||
}
|
|
||||||
flushTimer = null;
|
|
||||||
}, 16);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// The terminal object is available on the proc when using the terminal option
|
|
||||||
const terminal = (
|
|
||||||
proc as unknown as { terminal: TerminalSession["terminal"] }
|
|
||||||
).terminal;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Terminal] proc.pid=${proc.pid}, terminal=${typeof terminal}, keys=${Object.keys(proc as unknown as Record<string, unknown>).join(",")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!terminal) {
|
|
||||||
console.error(
|
|
||||||
"[Terminal] terminal object is undefined on proc — Bun.Terminal API may not be available",
|
|
||||||
);
|
);
|
||||||
sendTerminalMessage(socket, {
|
sendTerminalMessage(socket, {
|
||||||
type: "terminal_exited",
|
type: "terminal_spawned",
|
||||||
terminal_id,
|
terminal_id,
|
||||||
exitCode: 1,
|
pid: existing.pid,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: TerminalSession = {
|
// Session dead — fall through to spawn a fresh one
|
||||||
process: proc,
|
terminals.delete(terminal_id);
|
||||||
terminal,
|
}
|
||||||
terminalId: terminal_id,
|
|
||||||
spawnedAt: Date.now(),
|
killTerminal(terminal_id);
|
||||||
};
|
|
||||||
|
const shell = getDefaultShell();
|
||||||
|
console.log(
|
||||||
|
`[Terminal] Spawning PTY (${IS_BUN ? "bun" : "node-pty"}): shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = IS_BUN
|
||||||
|
? spawnBun(shell, cwd, cols, rows, terminal_id, socket)
|
||||||
|
: spawnNodePty(shell, cwd, cols, rows, terminal_id, socket);
|
||||||
|
|
||||||
terminals.set(terminal_id, session);
|
terminals.set(terminal_id, session);
|
||||||
console.log(
|
console.log(
|
||||||
`[Terminal] Session stored for terminal_id=${terminal_id}, map size=${terminals.size}`,
|
`[Terminal] Session stored for terminal_id=${terminal_id}, pid=${session.pid}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle process exit — only clean up if this is still the active session
|
|
||||||
// (a newer spawn may have replaced us in the map)
|
|
||||||
const myPid = proc.pid;
|
|
||||||
proc.exited.then((exitCode) => {
|
|
||||||
const current = terminals.get(terminal_id);
|
|
||||||
if (current && current.process.pid === myPid) {
|
|
||||||
console.log(
|
|
||||||
`[Terminal] PTY process exited: terminal_id=${terminal_id}, pid=${myPid}, exitCode=${exitCode}`,
|
|
||||||
);
|
|
||||||
terminals.delete(terminal_id);
|
|
||||||
sendTerminalMessage(socket, {
|
|
||||||
type: "terminal_exited",
|
|
||||||
terminal_id,
|
|
||||||
exitCode: exitCode ?? 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[Terminal] Stale PTY exit ignored: terminal_id=${terminal_id}, pid=${myPid} (current pid=${current?.process.pid})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sendTerminalMessage(socket, {
|
sendTerminalMessage(socket, {
|
||||||
type: "terminal_spawned",
|
type: "terminal_spawned",
|
||||||
terminal_id,
|
terminal_id,
|
||||||
pid: proc.pid,
|
pid: session.pid,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Terminal] Failed to spawn PTY:", error);
|
console.error("[Terminal] Failed to spawn PTY:", error);
|
||||||
@@ -170,41 +284,23 @@ export function handleTerminalSpawn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write input data to a terminal session.
|
|
||||||
*/
|
|
||||||
export function handleTerminalInput(msg: {
|
export function handleTerminalInput(msg: {
|
||||||
terminal_id: string;
|
terminal_id: string;
|
||||||
data: string;
|
data: string;
|
||||||
}): void {
|
}): void {
|
||||||
const session = terminals.get(msg.terminal_id);
|
terminals.get(msg.terminal_id)?.write(msg.data);
|
||||||
if (session) {
|
|
||||||
session.terminal.write(msg.data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize a terminal session.
|
|
||||||
*/
|
|
||||||
export function handleTerminalResize(msg: {
|
export function handleTerminalResize(msg: {
|
||||||
terminal_id: string;
|
terminal_id: string;
|
||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
}): void {
|
}): void {
|
||||||
const session = terminals.get(msg.terminal_id);
|
terminals.get(msg.terminal_id)?.resize(msg.cols, msg.rows);
|
||||||
if (session) {
|
|
||||||
session.terminal.resize(msg.cols, msg.rows);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill a terminal session.
|
|
||||||
*/
|
|
||||||
export function handleTerminalKill(msg: { terminal_id: string }): void {
|
export function handleTerminalKill(msg: { terminal_id: string }): void {
|
||||||
const session = terminals.get(msg.terminal_id);
|
const session = terminals.get(msg.terminal_id);
|
||||||
// Ignore kill if the session was spawned very recently (< 2s).
|
|
||||||
// This handles the React Strict Mode race where cleanup's kill arrives
|
|
||||||
// after the remount's spawn due to async WS relay latency.
|
|
||||||
if (session && Date.now() - session.spawnedAt < 2000) {
|
if (session && Date.now() - session.spawnedAt < 2000) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Terminal] Ignoring kill for recently spawned session (age=${Date.now() - session.spawnedAt}ms)`,
|
`[Terminal] Ignoring kill for recently spawned session (age=${Date.now() - session.spawnedAt}ms)`,
|
||||||
@@ -218,22 +314,13 @@ function killTerminal(terminalId: string): void {
|
|||||||
const session = terminals.get(terminalId);
|
const session = terminals.get(terminalId);
|
||||||
if (session) {
|
if (session) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Terminal] killTerminal: terminalId=${terminalId}, pid=${session.process.pid}`,
|
`[Terminal] killTerminal: terminalId=${terminalId}, pid=${session.pid}`,
|
||||||
);
|
);
|
||||||
try {
|
session.kill();
|
||||||
session.terminal.close();
|
|
||||||
} catch {
|
|
||||||
// terminal may already be closed
|
|
||||||
}
|
|
||||||
session.process.kill();
|
|
||||||
terminals.delete(terminalId);
|
terminals.delete(terminalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill all active terminal sessions.
|
|
||||||
* Call on disconnect/cleanup.
|
|
||||||
*/
|
|
||||||
export function killAllTerminals(): void {
|
export function killAllTerminals(): void {
|
||||||
for (const [id] of terminals) {
|
for (const [id] of terminals) {
|
||||||
killTerminal(id);
|
killTerminal(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user