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