pty-session.ts

  1import { join } from 'path';
  2import { EventEmitter } from 'events';
  3import { findClaude } from './export.ts';
  4import { getRepoDir } from './vcs.ts';
  5
  6// node-pty is a native module — dynamic import to avoid bundling issues
  7let pty: typeof import('node-pty') | null = null;
  8async function getPty() {
  9	if (!pty) {
 10		pty = await import('node-pty');
 11	}
 12	return pty;
 13}
 14
 15type IPty = import('node-pty').IPty;
 16
 17const emitter = new EventEmitter();
 18emitter.setMaxListeners(50);
 19
 20let ptyProcess: IPty | null = null;
 21let outputBuffer: string[] = [];
 22const MAX_BUFFER = 50000; // characters to keep for reconnection
 23
 24export async function startSession(cols?: number, rows?: number): Promise<void> {
 25	if (ptyProcess) return; // already running
 26
 27	try {
 28		const nodePty = await getPty();
 29		const claudePath = findClaude();
 30		const repoDir = getRepoDir();
 31		const shell = process.env.SHELL || '/bin/zsh';
 32		const homeBin = join(process.env.HOME || '', '.local', 'bin');
 33
 34		// Filter out undefined env values — node-pty/posix_spawnp requires all strings
 35		const cleanEnv: Record<string, string> = {};
 36		for (const [k, v] of Object.entries(process.env)) {
 37			if (v !== undefined) cleanEnv[k] = v;
 38		}
 39		cleanEnv.PATH = `${homeBin}:/usr/local/bin:/opt/homebrew/bin:${cleanEnv.PATH || ''}`;
 40		cleanEnv.TERM = 'xterm-256color';
 41
 42		ptyProcess = nodePty.spawn(shell, ['-l'], {
 43			name: 'xterm-256color',
 44			cols: cols || 80,
 45			rows: rows || 24,
 46			cwd: repoDir,
 47			env: cleanEnv
 48		});
 49
 50		ptyProcess.onData((data) => {
 51			outputBuffer.push(data);
 52			while (outputBuffer.join('').length > MAX_BUFFER) {
 53				outputBuffer.shift();
 54			}
 55			emitter.emit('data', data);
 56		});
 57
 58		ptyProcess.onExit(({ exitCode }) => {
 59			ptyProcess = null;
 60			emitter.emit('exit', exitCode);
 61		});
 62
 63		// Give shell a moment to init, then start claude
 64		setTimeout(() => {
 65			if (ptyProcess) {
 66				ptyProcess.write(`${claudePath}\r`);
 67			}
 68		}, 500);
 69	} catch (err) {
 70		console.error('Failed to start PTY session:', err);
 71		emitter.emit('data', `\r\n\x1b[31mFailed to start terminal: ${err}\x1b[0m\r\n`);
 72	}
 73}
 74
 75export function sendInput(data: string): void {
 76	if (ptyProcess) {
 77		ptyProcess.write(data);
 78	}
 79}
 80
 81export function sendReviewPrompt(prompt: string): void {
 82	if (!ptyProcess) return;
 83
 84	// Write the prompt text, then send Enter after a short delay
 85	// to ensure the terminal has processed all the text
 86	ptyProcess.write(prompt);
 87	setTimeout(() => {
 88		if (ptyProcess) {
 89			ptyProcess.write('\r');
 90		}
 91	}, 200);
 92}
 93
 94export function resize(cols: number, rows: number): void {
 95	if (ptyProcess) {
 96		ptyProcess.resize(cols, rows);
 97	}
 98}
 99
100export function killSession(): void {
101	if (ptyProcess) {
102		ptyProcess.kill();
103		ptyProcess = null;
104	}
105	outputBuffer = [];
106}
107
108export function isAlive(): boolean {
109	return ptyProcess !== null;
110}
111
112export function getBufferedOutput(): string {
113	return outputBuffer.join('');
114}
115
116export function onData(listener: (data: string) => void): () => void {
117	emitter.on('data', listener);
118	return () => emitter.off('data', listener);
119}
120
121export function onExit(listener: (code: number) => void): () => void {
122	emitter.on('exit', listener);
123	return () => emitter.off('exit', listener);
124}
125
126// Cleanup on process exit
127process.on('SIGTERM', () => killSession());
128process.on('SIGINT', () => killSession());