From e1d903297b64baa1df8d364a133b48c8285ca419 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 12 Aug 2025 18:24:55 +0200 Subject: [PATCH 1/7] feat: add pty manager using node-pty --- package-lock.json | 11 +++ package.json | 1 + src/ai/tools/pty-manager.ts | 137 ++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/ai/tools/pty-manager.ts diff --git a/package-lock.json b/package-lock.json index 4e05292..06ec101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "langchain": "^0.3.29", "marked": "^15.0.12", "marked-terminal": "^7.3.0", + "node-pty": "^1.0.0", "react": "^19.1.0", "ulid": "^3.0.1", "write-file-atomic": "^6.0.0" @@ -6182,6 +6183,16 @@ } } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", diff --git a/package.json b/package.json index 4b01328..7f96448 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "langchain": "^0.3.29", "marked": "^15.0.12", "marked-terminal": "^7.3.0", + "node-pty": "^1.0.0", "react": "^19.1.0", "ulid": "^3.0.1", "write-file-atomic": "^6.0.0" diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts new file mode 100644 index 0000000..3f2ac4b --- /dev/null +++ b/src/ai/tools/pty-manager.ts @@ -0,0 +1,137 @@ +import pty, { IPty, IPtyForkOptions } from "node-pty"; + +interface OnExitProps +{ + name: string; + history: string[]; + proc: IPty; + exitCode: number; + signal?: number; +} + +interface IOptions +{ + readTimeout?: number; + sessionTimeout?: number; + onExit?: (props: OnExitProps) => void; + spawnOptions?: IPtyForkOptions; +} + +interface Session +{ + proc: IPty; + buffer: string[]; + history: string[]; +} + +export class PtyManager +{ + private sessions: Map = new Map(); + + constructor() { } + + createSession(name: string, command: string, args: string[] = [], options: IOptions = {}): string + { + if (this.sessions.has(name)) + { + throw new Error(`Session "${name}" already exists`); + } + + const proc = pty.spawn(command, args, { + name: "xterm-color", + cols: 80, + rows: 30, + cwd: process.cwd(), + env: process.env as { [key: string]: string }, + ...options.spawnOptions, + }); + + const buffer: string[] = []; // recent unread output + const history: string[] = []; // full output history + + const handleTimeout = () => + { + proc.kill(); + }; + + if (options.sessionTimeout && options.sessionTimeout > 0) + { + setTimeout(handleTimeout, options.sessionTimeout); + } + + let readTimeout: NodeJS.Timeout | null = null; + + if (options.readTimeout && options.readTimeout > 0) + { + readTimeout = setTimeout(handleTimeout, options.readTimeout); + } + + proc.onData((data: string) => + { + if (readTimeout) + { + clearTimeout(readTimeout); + readTimeout = setTimeout(handleTimeout, options.readTimeout); + } + + buffer.push(data); + history.push(data); + }); + + proc.onExit(({ exitCode, signal }) => + { + if (readTimeout) + { + clearTimeout(readTimeout); + } + + options.onExit?.({ name, history, proc, exitCode, signal }); + + // this.sessions.delete(name); // Remove session on exit + }); + + this.sessions.set(name, { proc, buffer, history }); + + return name; + } + + write(name: string, input: string): void + { + const session = this.sessions.get(name); + if (!session) {throw new Error(`Session "${name}" not found`);} + session.proc.write(input.endsWith("\n") ? input : input + "\n"); + } + + /** Get unread output since last call */ + read(name: string): string + { + const session = this.sessions.get(name); + if (!session) {throw new Error(`Session "${name}" not found`);} + const output = session.buffer.join(""); + session.buffer.length = 0; // Clear unread buffer + return output; + } + + /** Get full history of the session */ + getHistory(name: string): string + { + const session = this.sessions.get(name); + if (!session) {throw new Error(`Session "${name}" not found`);} + return session.history.join(""); + } + + resize(name: string, cols: number, rows: number): void + { + const session = this.sessions.get(name); + if (!session) {throw new Error(`Session "${name}" not found`);} + session.proc.resize(cols, rows); + } + + kill(name: string, signal: string = "SIGTERM"): void + { + const session = this.sessions.get(name); + if (!session) {throw new Error(`Session "${name}" not found`);} + session.proc.kill(signal); + // this.sessions.delete(name); + } +} From a7bfd6d73b0f711c341e423945b443304dba2a48 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 16 Aug 2025 18:37:57 +0200 Subject: [PATCH 2/7] feat: add strip-ansi dependency and implement text cleaning methods in PtyManager to remove ANSI escape codes --- package-lock.json | 1 + package.json | 1 + src/ai/tools/pty-manager.ts | 11 +++++++++++ 3 files changed, 13 insertions(+) diff --git a/package-lock.json b/package-lock.json index 06ec101..f060868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "marked-terminal": "^7.3.0", "node-pty": "^1.0.0", "react": "^19.1.0", + "strip-ansi": "^7.1.0", "ulid": "^3.0.1", "write-file-atomic": "^6.0.0" }, diff --git a/package.json b/package.json index 7f96448..2fe9bbc 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "marked-terminal": "^7.3.0", "node-pty": "^1.0.0", "react": "^19.1.0", + "strip-ansi": "^7.1.0", "ulid": "^3.0.1", "write-file-atomic": "^6.0.0" }, diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts index 3f2ac4b..ce681eb 100644 --- a/src/ai/tools/pty-manager.ts +++ b/src/ai/tools/pty-manager.ts @@ -1,4 +1,5 @@ import pty, { IPty, IPtyForkOptions } from "node-pty"; +import stripAnsi from "strip-ansi"; interface OnExitProps { @@ -112,6 +113,11 @@ export class PtyManager return output; } + readText(name: string): string + { + return stripAnsi(this.read(name)); + } + /** Get full history of the session */ getHistory(name: string): string { @@ -120,6 +126,11 @@ export class PtyManager return session.history.join(""); } + getHistoryText(name: string): string + { + return stripAnsi(this.getHistory(name)); + } + resize(name: string, cols: number, rows: number): void { const session = this.sessions.get(name); From 4e4f14a107f236d714891f0e5977883026fe7ae2 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 16 Aug 2025 18:38:55 +0200 Subject: [PATCH 3/7] feat: store exitCode and signal in session on process exit event --- src/ai/tools/pty-manager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts index ce681eb..aae7ad6 100644 --- a/src/ai/tools/pty-manager.ts +++ b/src/ai/tools/pty-manager.ts @@ -23,6 +23,8 @@ interface Session proc: IPty; buffer: string[]; history: string[]; + exitCode?: number; + signal?: number; } export class PtyManager @@ -88,6 +90,13 @@ export class PtyManager options.onExit?.({ name, history, proc, exitCode, signal }); + const session = this.sessions.get(name); + if (session) + { + session.exitCode = exitCode; + session.signal = signal; + } + // this.sessions.delete(name); // Remove session on exit }); From c6d93bf44ad1d7b0cae9a3ab26e6c3bc8f1f6831 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 16 Aug 2025 18:39:27 +0200 Subject: [PATCH 4/7] style: add spaces after if statements for better readability in pty-manager.ts --- src/ai/tools/pty-manager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts index aae7ad6..21d0af4 100644 --- a/src/ai/tools/pty-manager.ts +++ b/src/ai/tools/pty-manager.ts @@ -108,7 +108,7 @@ export class PtyManager write(name: string, input: string): void { const session = this.sessions.get(name); - if (!session) {throw new Error(`Session "${name}" not found`);} + if (!session) { throw new Error(`Session "${name}" not found`); } session.proc.write(input.endsWith("\n") ? input : input + "\n"); } @@ -116,7 +116,7 @@ export class PtyManager read(name: string): string { const session = this.sessions.get(name); - if (!session) {throw new Error(`Session "${name}" not found`);} + if (!session) { throw new Error(`Session "${name}" not found`); } const output = session.buffer.join(""); session.buffer.length = 0; // Clear unread buffer return output; @@ -131,7 +131,7 @@ export class PtyManager getHistory(name: string): string { const session = this.sessions.get(name); - if (!session) {throw new Error(`Session "${name}" not found`);} + if (!session) { throw new Error(`Session "${name}" not found`); } return session.history.join(""); } @@ -143,14 +143,14 @@ export class PtyManager resize(name: string, cols: number, rows: number): void { const session = this.sessions.get(name); - if (!session) {throw new Error(`Session "${name}" not found`);} + if (!session) { throw new Error(`Session "${name}" not found`); } session.proc.resize(cols, rows); } kill(name: string, signal: string = "SIGTERM"): void { const session = this.sessions.get(name); - if (!session) {throw new Error(`Session "${name}" not found`);} + if (!session) { throw new Error(`Session "${name}" not found`); } session.proc.kill(signal); // this.sessions.delete(name); } From ac70de6d71823f2df26aaa03cbad549463ebff03 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 16 Aug 2025 18:48:49 +0200 Subject: [PATCH 5/7] feat: add waitForExit method to PtyManager to wait for session exit with optional timeout --- src/ai/tools/pty-manager.ts | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts index 21d0af4..ac277c5 100644 --- a/src/ai/tools/pty-manager.ts +++ b/src/ai/tools/pty-manager.ts @@ -154,4 +154,49 @@ export class PtyManager session.proc.kill(signal); // this.sessions.delete(name); } + + /** + * Waits for a session to exit. + * + * @param name The name of the session. + * @param timeout Optional timeout in milliseconds. + * @returns A promise that resolves with the exit code and signal, or rejects on timeout. + */ + waitForExit(name: string, timeout?: number): Promise<{ exitCode: number, signal?: number }> + { + const session = this.sessions.get(name); + if (!session) + { + return Promise.reject(new Error(`Session "${name}" not found`)); + } + + if (typeof session.exitCode === 'number') + { + return Promise.resolve({ exitCode: session.exitCode, signal: session.signal }); + } + + return new Promise((resolve, reject) => + { + let timeoutId: NodeJS.Timeout | null = null; + + const disposable = session.proc.onExit(({ exitCode, signal }) => + { + if (timeoutId) + { + clearTimeout(timeoutId); + } + disposable.dispose(); + resolve({ exitCode, signal }); + }); + + if (timeout && timeout > 0) + { + timeoutId = setTimeout(() => + { + disposable.dispose(); + reject(new Error(`Timeout waiting for session "${name}" to exit`)); + }, timeout); + } + }); + } } From 9df5c96c2b61d5f2c364a1c481381d02438a17e9 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 16 Aug 2025 23:05:32 +0200 Subject: [PATCH 6/7] feat: add onData callback to PtyManager and introduce writeLine and writeRaw methods --- src/ai/tools/pty-manager.ts | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/ai/tools/pty-manager.ts b/src/ai/tools/pty-manager.ts index ac277c5..9e24c50 100644 --- a/src/ai/tools/pty-manager.ts +++ b/src/ai/tools/pty-manager.ts @@ -1,6 +1,14 @@ import pty, { IPty, IPtyForkOptions } from "node-pty"; import stripAnsi from "strip-ansi"; +interface OnDataProps +{ + name: string; + history: string[]; + proc: IPty; + data: string; +} + interface OnExitProps { name: string; @@ -14,6 +22,7 @@ interface IOptions { readTimeout?: number; sessionTimeout?: number; + onData?: (props: OnDataProps) => void; onExit?: (props: OnExitProps) => void; spawnOptions?: IPtyForkOptions; } @@ -77,6 +86,8 @@ export class PtyManager readTimeout = setTimeout(handleTimeout, options.readTimeout); } + options.onData?.({ name, history, proc, data }); + buffer.push(data); history.push(data); }); @@ -105,13 +116,23 @@ export class PtyManager return name; } - write(name: string, input: string): void + /** Writes a line of text to the session, automatically adding a newline. */ + writeLine(name: string, input: string): void { const session = this.sessions.get(name); if (!session) { throw new Error(`Session "${name}" not found`); } session.proc.write(input.endsWith("\n") ? input : input + "\n"); } + /** Writes raw data to the session's stdin without modification. */ + writeRaw(name: string, data: string, eof?: boolean): void + { + const session = this.sessions.get(name); + if (!session) { throw new Error(`Session "${name}" not found`); } + session.proc.write(data); + eof && session.proc.write("\x04"); + } + /** Get unread output since last call */ read(name: string): string { @@ -155,6 +176,11 @@ export class PtyManager // this.sessions.delete(name); } + get(name: string) + { + return this.sessions.get(name); + } + /** * Waits for a session to exit. * @@ -170,7 +196,7 @@ export class PtyManager return Promise.reject(new Error(`Session "${name}" not found`)); } - if (typeof session.exitCode === 'number') + if (typeof session.exitCode === "number") { return Promise.resolve({ exitCode: session.exitCode, signal: session.signal }); } From 03c1f31728bebd2add7ea711d61f0a830843b067 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 20 Aug 2025 22:39:32 +0200 Subject: [PATCH 7/7] chore: update package-lock.json with nan dependency version 2.23.0 addition --- package-lock.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package-lock.json b/package-lock.json index f060868..81dc858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6107,6 +6107,12 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz",