@ts47andres/exeggutor 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,414 +1,414 @@
1
- import * as pty from 'node-pty';
2
- import * as os from 'os';
3
- import * as path from 'path';
4
- import * as fs from 'fs';
5
- import { exec } from 'child_process';
6
- import * as db from './workspaceDb'; // Reference to local sessions JSON database.
7
-
8
- export interface TerminalSession {
9
- id: string; // The unique tab ID associated with this terminal process.
10
- workspaceId: string; // The ID of the workspace that owns this session.
11
- ptyProcess: pty.IPty; // The live node-pty process instance.
12
- outputBuffer: string[]; // Accumulated recent terminal output lines.
13
- lastOutputTime: number; // Unix timestamp of the last stdout chunk received.
14
- status: 'Active' | 'Waiting' | 'Idle' | 'Errored'; // Real-time state of the terminal process.
15
- lastLinePreview: string; // The single most recent line of terminal output for sidebar observers.
16
- onData: (data: string) => void; // Event dispatcher callback for new stdout streams.
17
- activeSocket?: any; // The active WebSocket connection associated with this session.
18
- broadcastCallback?: () => void; // Optional callback to notify observer of state changes.
19
- }
20
-
21
- const sessions = new Map<string, TerminalSession>(); // Global map cache linking tab IDs to their active persistent TerminalSessions.
22
- let statusCheckInterval: NodeJS.Timeout | null = null; // Background interval reference for running periodic status audits.
23
- let spawnLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY spawn executes at a time on Windows.
24
- let killLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY kill executes at a time on Windows.
25
-
26
- // Queries the operating system to recursively find all active child and descendant process IDs of the specified parent process.
27
- function getDescendants(parentPid: number): Promise<Array<{ pid: number; name: string }>> {
28
- console.log(`[PTY] getDescendants(parentPid=${parentPid})`);
29
- const p = new Promise<Array<{ pid: number; name: string }>>((resolve) => {
30
- const isWin = process.platform === 'win32'; // Flag denoting if the host operating system is Windows.
31
- const cmd = isWin ? 'wmic process get ParentProcessId,ProcessId,Name' : 'ps -A -o ppid,pid,comm'; // System command to run.
32
- console.log(`[PTY] getDescendants -> running: "${cmd}"`);
33
- const runOptions = { windowsHide: true }; // Hide window option.
34
- exec(cmd, runOptions, (err, stdout) => {
35
- if (err || !stdout) {
36
- resolve([]);
37
- return;
38
- }
39
- const lines = stdout.split(/\r?\n/); // Process lines array.
40
- const procList: Array<{ pid: number; ppid: number; name: string }> = []; // Registry of all active system processes.
41
- for (const line of lines) {
42
- const parts = line.trim().split(/\s+/); // Splitted columns.
43
- if (parts.length >= 3) {
44
- if (isWin) {
45
- const ppid = parseInt(parts[0], 10); // Parent process identifier.
46
- const pid = parseInt(parts[1], 10); // Target process identifier.
47
- const name = parts.slice(2).join(' '); // Process executable name.
48
- if (!isNaN(pid) && !isNaN(ppid)) {
49
- procList.push({ pid, ppid, name });
50
- }
51
- } else {
52
- const ppid = parseInt(parts[0], 10); // Parent process identifier.
53
- const pid = parseInt(parts[1], 10); // Target process identifier.
54
- const name = parts.slice(2).join(' '); // Executable name or command line.
55
- if (!isNaN(pid) && !isNaN(ppid)) {
56
- procList.push({ pid, ppid, name });
57
- }
58
- }
59
- }
60
- }
61
-
62
- const descendants: Array<{ pid: number; name: string }> = []; // Flat registry of descendant processes.
63
- const queue = [parentPid]; // Queue container for breath-first traversal.
64
- const visited = new Set<number>(); // Visited PID tracker to prevent circular reference locks.
65
- while (queue.length > 0) {
66
- const current = queue.shift()!; // Dequeued parent PID.
67
- if (visited.has(current)) {
68
- continue;
69
- }
70
- visited.add(current);
71
- const children = procList.filter(p => p.ppid === current); // Direct children matching current process.
72
- for (const child of children) {
73
- descendants.push({ pid: child.pid, name: child.name });
74
- queue.push(child.pid);
75
- }
76
- }
77
- console.log(`[PTY] getDescendants(${parentPid}) -> ${descendants.length} descendants: [${descendants.map(d => `${d.name}(${d.pid})`).join(', ')}]`);
78
- resolve(descendants);
79
- });
80
- }); // Spawns execution promise handle.
81
- return p;
82
- }
83
-
84
- // Audits the activity state of all terminal sessions to update active vs idle/waiting states.
85
- export function startStatusAuditor(broadcastCallback: () => void): void {
86
- if (statusCheckInterval) {
87
- return;
88
- }
89
- statusCheckInterval = setInterval(async () => {
90
- const now = Date.now(); // The current high resolution timestamp.
91
- let changed = false; // Flag to indicate if any session state changed during this audit cycle.
92
-
93
- const sessionPromises = Array.from(sessions.values()).map(async (session) => {
94
- const descendants = await getDescendants(session.ptyProcess.pid); // Retrieved process descendants of the PTY.
95
- const utilityNames = new Set([
96
- 'winpty-agent.exe', 'conhost.exe', 'powershell.exe', 'cmd.exe',
97
- 'wmic.exe', 'openconsole.exe', 'conconsole.exe', 'bash', 'zsh', 'sh', 'ps'
98
- ]); // Process utility names set.
99
-
100
- const hasActiveProcess = descendants.some(d => {
101
- const nameLower = d.name.toLowerCase(); // Lowercased process name.
102
- const isUtility = Array.from(utilityNames).some(u => nameLower.includes(u.toLowerCase())); // Flag identifying utility processes.
103
- return !isUtility;
104
- }); // Verification flag identifying if any non-utility process is running.
105
-
106
- if (!hasActiveProcess) {
107
- if (session.status !== 'Idle') {
108
- session.status = 'Idle';
109
- changed = true;
110
- }
111
- } else {
112
- if (session.status === 'Active' && now - session.lastOutputTime > 2000) {
113
- const fullOutput = session.outputBuffer.join(''); // Combined stdout stream of the session.
114
- const lines = fullOutput.split(/\r?\n/); // Splitted chunk array.
115
- const nonLines = lines.map(l => l.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim()); // Cleaned line array without ANSI.
116
- const nonZeroLines = nonLines.filter(l => l.length > 0); // Non-empty cleaned lines.
117
- const lastFewLines = nonZeroLines.slice(-5); // Last 5 non-empty output lines.
118
- const lastFewTextLower = lastFewLines.join(' ').toLowerCase(); // Combined lowercased string of recent lines.
119
- const interactiveGuides = ['tab', 'select', 'enter', 'confirm', 'dismiss', 'arrow', 'esc', 'up/down', '[y/n]']; // Interactive keyboard control terms.
120
- const promptIndicators = ['password:', 'password for', 'enter passphrase', 'input:', 'confirm:', 'proceed?']; // Standard CLI input indicators.
121
- const hasInteractiveGuides = interactiveGuides.some(guide => lastFewTextLower.includes(guide)); // Flag identifying key guides in output.
122
- const hasPromptIndicators = promptIndicators.some(indicator => lastFewTextLower.includes(indicator)); // Flag identifying standard prompts in output.
123
- const lastLine = nonZeroLines[nonZeroLines.length - 1] || ''; // The single last line of output.
124
- const endsWithQuestion = lastLine.endsWith('?') || lastFewLines.some(l => l.endsWith('?')); // Flag verifying if any recent lines end with a question mark.
125
- const isWaiting = hasInteractiveGuides || hasPromptIndicators || endsWithQuestion; // Combined check for form prompt or question.
126
- const nextStatus = isWaiting ? 'Waiting' : 'Idle'; // Computed target status mapping.
127
- session.status = nextStatus;
128
- changed = true;
129
- }
130
- }
131
- }); // Spawns concurrent auditing sessions.
132
-
133
- await Promise.all(sessionPromises);
134
- if (changed) {
135
- broadcastCallback();
136
- }
137
- }, 5000); // Trigger auditor check every 5 seconds to reduce wmic process spawn frequency on Windows.
138
- }
139
-
140
- // Spawns a persistent terminal process or retrieves an existing one, matching it to the tab.
141
- // Uses an async spawn lock to serialize concurrent node-pty process creation on Windows.
142
- export async function getOrCreatePtySession(
143
- workspaceId: string,
144
- tabId: string,
145
- cwd: string,
146
- shellPath?: string,
147
- broadcastCallback?: () => void
148
- ): Promise<TerminalSession> {
149
- const existing = sessions.get(tabId); // Attempt to retrieve an existing session from the cache.
150
- if (existing) {
151
- console.log(`[PTY] getOrCreatePtySession -> returning existing session for tab=${tabId} PID=${existing.ptyProcess.pid}`);
152
- const foundSession = existing; // Explicit reference to the cached session.
153
- return foundSession;
154
- }
155
-
156
- console.log(`[PTY] getOrCreatePtySession(workspaceId=${workspaceId}, tabId=${tabId}, cwd=${cwd}, shellPath=${shellPath})`);
157
-
158
- const defaultShell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; // Choose shell path depending on underlying server OS.
159
- const targetShell = shellPath || defaultShell; // Evaluated shell executable to run.
160
-
161
- const ptyArgs: string[] = []; // Arguments passed to the spawned shell.
162
- const ptyEnv = { ...process.env } as Record<string, string>; // Environment variables for the spawned process.
163
-
164
- const isWin = os.platform() === 'win32'; // Flag indicating Windows platform.
165
- if (isWin) {
166
- const guardScript = path.resolve(__dirname, '../scripts/git-guard.ps1');
167
- if (fs.existsSync(guardScript)) {
168
- ptyArgs.push('-NoLogo', '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', `. '${guardScript}'`);
169
- }
170
- } else {
171
- const wrapperDir = path.resolve(__dirname, '../git-wrapper');
172
- if (fs.existsSync(path.join(wrapperDir, 'git'))) {
173
- ptyEnv.PATH = `${wrapperDir}${path.delimiter}${ptyEnv.PATH || ''}`;
174
- }
175
- }
176
-
177
- // Wait for any in-flight PTY spawn to finish before creating a new one to avoid ConPTY conflicts on Windows.
178
- while (spawnLock) {
179
- console.log(`[PTY] Spawn lock held, waiting for tab=${tabId}`);
180
- await spawnLock;
181
- }
182
- let releaseLock: () => void = () => {}; // Callback to release the spawn mutex after creation completes, default no-op to avoid TS usage-before-init.
183
- spawnLock = new Promise((resolve) => { releaseLock = resolve; });
184
-
185
- console.log(`[PTY] Spawning shell: targetShell="${targetShell}" args=[${ptyArgs.join(', ')}] useConpty=true`);
186
- let ptyProcess: pty.IPty; // Reference to the successfully spawned node-pty process.
187
- try {
188
- ptyProcess = pty.spawn(targetShell, ptyArgs, {
189
- name: 'xterm-256color',
190
- cols: 80,
191
- rows: 24,
192
- cwd: cwd,
193
- env: ptyEnv,
194
- useConpty: true, // Enables the Windows Pseudo Console API for accurate dimension resizes.
195
- }); // Spawn the process via node-pty.
196
- console.log(`[PTY] Spawned PID=${ptyProcess.pid} for tab=${tabId}`);
197
- } catch (err: any) {
198
- console.log(`[PTY] Spawn FAILED for tab=${tabId}: ${err.message}. Stack: ${err.stack}`);
199
- const spawnErr = new Error(`PTY spawn failed for tab ${tabId}: ${err.message}`); // Error wrapping the spawn failure.
200
- spawnLock = null;
201
- releaseLock();
202
- throw spawnErr;
203
- }
204
- spawnLock = null;
205
- releaseLock();
206
-
207
- const newSession: TerminalSession = {
208
- id: tabId,
209
- workspaceId: workspaceId,
210
- ptyProcess: ptyProcess,
211
- outputBuffer: [],
212
- lastOutputTime: Date.now(),
213
- status: 'Idle',
214
- lastLinePreview: '',
215
- onData: () => {},
216
- broadcastCallback: broadcastCallback,
217
- }; // The new terminal session schema instance mapping this tab.
218
-
219
- sessions.set(tabId, newSession);
220
-
221
- // Save process PID to database for future orphan recovery.
222
- try {
223
- db.updateTerminalTab(workspaceId, tabId, { pid: ptyProcess.pid });
224
- } catch (err) {
225
- // Safe ignore database update failure.
226
- }
227
-
228
- ptyProcess.onData(data => {
229
- newSession.lastOutputTime = Date.now();
230
-
231
- const maxBufferLines = 1000; // Limit total cached rows in memory to save memory usage.
232
- newSession.outputBuffer.push(data);
233
- if (newSession.outputBuffer.length > maxBufferLines) {
234
- newSession.outputBuffer.shift();
235
- }
236
-
237
- const lines = data.split(/\r?\n/); // Splitted chunk array.
238
- const lastNonEmpty = lines.filter(l => l.trim().length > 0).pop(); // The last non-empty line of text.
239
- if (lastNonEmpty) {
240
- const cleanLine = lastNonEmpty.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); // Remove ANSI terminal escape codes.
241
- if (cleanLine.length > 0) {
242
- newSession.lastLinePreview = cleanLine.substring(0, 100);
243
- }
244
- }
245
-
246
- const oldStatus = newSession.status; // Preserve original status before computation.
247
- newSession.status = 'Active'; // Mark status as active immediately when receiving new data.
248
-
249
- newSession.onData(data);
250
-
251
- if (oldStatus !== newSession.status && broadcastCallback) {
252
- broadcastCallback();
253
- }
254
- });
255
-
256
- ptyProcess.onExit(res => {
257
- newSession.status = res.exitCode === 0 ? 'Idle' : 'Errored';
258
- if (broadcastCallback) {
259
- broadcastCallback();
260
- }
261
- });
262
-
263
- const createdSession = newSession; // Explicit reference to return.
264
- return createdSession;
265
- }
266
-
267
- // Resizes the terminal process row and column dimensions.
268
- export function resizePtySession(tabId: string, cols: number, rows: number): void {
269
- const session = sessions.get(tabId); // Look up session by ID.
270
- if (session && typeof cols === 'number' && typeof rows === 'number' && cols > 0 && rows > 0) {
271
- const parsedCols = Math.max(Math.floor(cols), 40); // Ensures PTY size is at least 40 columns wide.
272
- const parsedRows = Math.max(Math.floor(rows), 10); // Ensures PTY size is at least 10 rows high.
273
- if (parsedCols > 0 && parsedRows > 0) {
274
- session.ptyProcess.resize(parsedCols, parsedRows);
275
- }
276
- }
277
- }
278
-
279
- // Write input data directly to the active terminal process.
280
- // Does not change the session status — the periodic auditor handles status transitions,
281
- // preventing rapid Idle↔Active flickering that occurred on every keystroke.
282
- export function writeToPtySession(tabId: string, data: string): void {
283
- const session = sessions.get(tabId); // Look up session by ID.
284
- if (session) {
285
- session.ptyProcess.write(data);
286
- }
287
- }
288
-
289
- // Forces the termination of a terminal session and frees system process resources.
290
- // Serializes concurrent kills on Windows to avoid overlapping conhost termination window flashes.
291
- export async function killPtySession(tabId: string): Promise<void> {
292
- const session = sessions.get(tabId); // Find session by tab ID.
293
- if (session) {
294
- console.log(`[PTY] killPtySession(tabId=${tabId}) -> killing PID=${session.ptyProcess.pid}`);
295
- // Wait for any in-flight PTY kill to finish before terminating this one to avoid ConPTY conflicts on Windows.
296
- while (killLock) {
297
- console.log(`[PTY] Kill lock held, waiting for tab=${tabId}`);
298
- await killLock;
299
- }
300
- let releaseKillLock: () => void = () => {}; // Callback to release the kill mutex after termination completes.
301
- killLock = new Promise((resolve) => { releaseKillLock = resolve; });
302
-
303
- try {
304
- session.ptyProcess.kill();
305
- console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill OK`);
306
- } catch (err: any) {
307
- console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill error: ${err.message}`);
308
- } finally {
309
- killLock = null;
310
- releaseKillLock();
311
- }
312
- try {
313
- db.updateTerminalTab(session.workspaceId, tabId, { pid: undefined }); // Clear the PID record from the database.
314
- } catch (err) {
315
- // Safe ignore database write failure.
316
- }
317
- sessions.delete(tabId);
318
- console.log(`[PTY] killPtySession(tabId=${tabId}) -> session removed from map`);
319
- } else {
320
- console.log(`[PTY] killPtySession(tabId=${tabId}) -> no active session found`);
321
- }
322
- }
323
-
324
- // Retrieves all terminal session descriptors currently managed by the backend.
325
- export function getAllSessions(): Array<{ id: string; workspaceId: string; status: string; preview: string }> {
326
- const list: Array<{ id: string; workspaceId: string; status: string; preview: string }> = []; // Return sessions descriptors.
327
- sessions.forEach(s => {
328
- list.push({
329
- id: s.id,
330
- workspaceId: s.workspaceId,
331
- status: s.status,
332
- preview: s.lastLinePreview,
333
- });
334
- });
335
- return list;
336
- }
337
-
338
- // Verifies that a given PID belongs to an expected Exeggutor-managed process to avoid killing unrelated recycled PIDs.
339
- function verifyPidOwnership(pid: number): Promise<boolean> {
340
- console.log(`[PTY] verifyPidOwnership(pid=${pid})`);
341
- return new Promise((resolve) => {
342
- const isWin = process.platform === 'win32'; // Flag indicating Windows platform.
343
- if (isWin) {
344
- console.log(`[PTY] verifyPidOwnership -> running: tasklist /FI "PID eq ${pid}"`);
345
- exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (err, stdout) => {
346
- if (err || !stdout) {
347
- resolve(false);
348
- return;
349
- }
350
- const nameMatch = stdout.match(/"([^"]+)"/); // Capture the process executable name from CSV output.
351
- if (!nameMatch) {
352
- resolve(false);
353
- return;
354
- }
355
- const name = nameMatch[1].toLowerCase(); // Process name from tasklist.
356
- const knownNames = ['node.exe', 'powershell.exe', 'cmd.exe', 'bash.exe', 'wmic.exe', 'conhost.exe'];
357
- const result = knownNames.some(k => name.includes(k));
358
- console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${name}" known=${result}`);
359
- resolve(result);
360
- }); // Spawn tasklist to verify process identity.
361
- } else {
362
- try {
363
- const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); // Process command name from proc filesystem.
364
- const knownNames = ['node', 'bash', 'zsh', 'sh', 'dash', 'powershell'];
365
- const result = knownNames.includes(comm);
366
- console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${comm}" known=${result}`);
367
- resolve(result);
368
- } catch {
369
- console.log(`[PTY] verifyPidOwnership(${pid}) -> error reading /proc/${pid}/comm`);
370
- resolve(false);
371
- }
372
- }
373
- }); // Verifies that the PID matches an expected Exeggutor-managed process.
374
- }
375
-
376
- // Scans the database for previously persisted terminal tab process IDs and forcefully terminates any orphaned instances.
377
- export async function cleanOrphanedPtyProcesses(): Promise<void> {
378
- try {
379
- const workspaces = db.getWorkspaces(); // Load workspaces from database.
380
- console.log(`[PTY] cleanOrphanedPtyProcesses() -> scanning ${workspaces.length} workspaces`);
381
- for (const ws of workspaces) {
382
- console.log(`[PTY] cleanOrphanedPtyProcesses -> workspace=${ws.id} (${ws.name}) has ${ws.tabs.length} tabs`);
383
- for (const tab of ws.tabs) {
384
- if (tab.pid) {
385
- console.log(`[PTY] cleanOrphanedPtyProcesses -> checking tab=${tab.id} PID=${tab.pid}`);
386
- const ownsPid = await verifyPidOwnership(tab.pid); // Flag confirming the PID belongs to an expected process.
387
- if (!ownsPid) {
388
- console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} not owned, clearing record`);
389
- try {
390
- db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear stale PID record without killing.
391
- } catch (err) {
392
- // Safe ignore database write failure.
393
- }
394
- continue;
395
- }
396
- console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} owned, sending SIGKILL`);
397
- try {
398
- process.kill(tab.pid, 'SIGKILL'); // Force terminate the orphaned shell process.
399
- console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} killed`);
400
- } catch (err: any) {
401
- console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} kill error: ${err.message}`);
402
- }
403
- try {
404
- db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear the PID record from the database.
405
- } catch (err) {
406
- // Safe ignore database write failure.
407
- }
408
- }
409
- }
410
- }
411
- } catch (err: any) {
412
- console.log(`[PTY] cleanOrphanedPtyProcesses() -> error: ${err.message}`);
413
- }
414
- }
1
+ import * as pty from 'node-pty';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+ import { exec } from 'child_process';
6
+ import * as db from './workspaceDb'; // Reference to local sessions JSON database.
7
+
8
+ export interface TerminalSession {
9
+ id: string; // The unique tab ID associated with this terminal process.
10
+ workspaceId: string; // The ID of the workspace that owns this session.
11
+ ptyProcess: pty.IPty; // The live node-pty process instance.
12
+ outputBuffer: string[]; // Accumulated recent terminal output lines.
13
+ lastOutputTime: number; // Unix timestamp of the last stdout chunk received.
14
+ status: 'Active' | 'Waiting' | 'Idle' | 'Errored'; // Real-time state of the terminal process.
15
+ lastLinePreview: string; // The single most recent line of terminal output for sidebar observers.
16
+ onData: (data: string) => void; // Event dispatcher callback for new stdout streams.
17
+ activeSocket?: any; // The active WebSocket connection associated with this session.
18
+ broadcastCallback?: () => void; // Optional callback to notify observer of state changes.
19
+ }
20
+
21
+ const sessions = new Map<string, TerminalSession>(); // Global map cache linking tab IDs to their active persistent TerminalSessions.
22
+ let statusCheckInterval: NodeJS.Timeout | null = null; // Background interval reference for running periodic status audits.
23
+ let spawnLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY spawn executes at a time on Windows.
24
+ let killLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY kill executes at a time on Windows.
25
+
26
+ // Queries the operating system to recursively find all active child and descendant process IDs of the specified parent process.
27
+ function getDescendants(parentPid: number): Promise<Array<{ pid: number; name: string }>> {
28
+ console.log(`[PTY] getDescendants(parentPid=${parentPid})`);
29
+ const p = new Promise<Array<{ pid: number; name: string }>>((resolve) => {
30
+ const isWin = process.platform === 'win32'; // Flag denoting if the host operating system is Windows.
31
+ const cmd = isWin ? 'wmic process get ParentProcessId,ProcessId,Name' : 'ps -A -o ppid,pid,comm'; // System command to run.
32
+ console.log(`[PTY] getDescendants -> running: "${cmd}"`);
33
+ const runOptions = { windowsHide: true }; // Hide window option.
34
+ exec(cmd, runOptions, (err, stdout) => {
35
+ if (err || !stdout) {
36
+ resolve([]);
37
+ return;
38
+ }
39
+ const lines = stdout.split(/\r?\n/); // Process lines array.
40
+ const procList: Array<{ pid: number; ppid: number; name: string }> = []; // Registry of all active system processes.
41
+ for (const line of lines) {
42
+ const parts = line.trim().split(/\s+/); // Splitted columns.
43
+ if (parts.length >= 3) {
44
+ if (isWin) {
45
+ const ppid = parseInt(parts[0], 10); // Parent process identifier.
46
+ const pid = parseInt(parts[1], 10); // Target process identifier.
47
+ const name = parts.slice(2).join(' '); // Process executable name.
48
+ if (!isNaN(pid) && !isNaN(ppid)) {
49
+ procList.push({ pid, ppid, name });
50
+ }
51
+ } else {
52
+ const ppid = parseInt(parts[0], 10); // Parent process identifier.
53
+ const pid = parseInt(parts[1], 10); // Target process identifier.
54
+ const name = parts.slice(2).join(' '); // Executable name or command line.
55
+ if (!isNaN(pid) && !isNaN(ppid)) {
56
+ procList.push({ pid, ppid, name });
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ const descendants: Array<{ pid: number; name: string }> = []; // Flat registry of descendant processes.
63
+ const queue = [parentPid]; // Queue container for breath-first traversal.
64
+ const visited = new Set<number>(); // Visited PID tracker to prevent circular reference locks.
65
+ while (queue.length > 0) {
66
+ const current = queue.shift()!; // Dequeued parent PID.
67
+ if (visited.has(current)) {
68
+ continue;
69
+ }
70
+ visited.add(current);
71
+ const children = procList.filter(p => p.ppid === current); // Direct children matching current process.
72
+ for (const child of children) {
73
+ descendants.push({ pid: child.pid, name: child.name });
74
+ queue.push(child.pid);
75
+ }
76
+ }
77
+ console.log(`[PTY] getDescendants(${parentPid}) -> ${descendants.length} descendants: [${descendants.map(d => `${d.name}(${d.pid})`).join(', ')}]`);
78
+ resolve(descendants);
79
+ });
80
+ }); // Spawns execution promise handle.
81
+ return p;
82
+ }
83
+
84
+ // Audits the activity state of all terminal sessions to update active vs idle/waiting states.
85
+ export function startStatusAuditor(broadcastCallback: () => void): void {
86
+ if (statusCheckInterval) {
87
+ return;
88
+ }
89
+ statusCheckInterval = setInterval(async () => {
90
+ const now = Date.now(); // The current high resolution timestamp.
91
+ let changed = false; // Flag to indicate if any session state changed during this audit cycle.
92
+
93
+ const sessionPromises = Array.from(sessions.values()).map(async (session) => {
94
+ const descendants = await getDescendants(session.ptyProcess.pid); // Retrieved process descendants of the PTY.
95
+ const utilityNames = new Set([
96
+ 'winpty-agent.exe', 'conhost.exe', 'powershell.exe', 'cmd.exe',
97
+ 'wmic.exe', 'openconsole.exe', 'conconsole.exe', 'bash', 'zsh', 'sh', 'ps'
98
+ ]); // Process utility names set.
99
+
100
+ const hasActiveProcess = descendants.some(d => {
101
+ const nameLower = d.name.toLowerCase(); // Lowercased process name.
102
+ const isUtility = Array.from(utilityNames).some(u => nameLower.includes(u.toLowerCase())); // Flag identifying utility processes.
103
+ return !isUtility;
104
+ }); // Verification flag identifying if any non-utility process is running.
105
+
106
+ if (!hasActiveProcess) {
107
+ if (session.status !== 'Idle') {
108
+ session.status = 'Idle';
109
+ changed = true;
110
+ }
111
+ } else {
112
+ if (session.status === 'Active' && now - session.lastOutputTime > 2000) {
113
+ const fullOutput = session.outputBuffer.join(''); // Combined stdout stream of the session.
114
+ const lines = fullOutput.split(/\r?\n/); // Splitted chunk array.
115
+ const nonLines = lines.map(l => l.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim()); // Cleaned line array without ANSI.
116
+ const nonZeroLines = nonLines.filter(l => l.length > 0); // Non-empty cleaned lines.
117
+ const lastFewLines = nonZeroLines.slice(-5); // Last 5 non-empty output lines.
118
+ const lastFewTextLower = lastFewLines.join(' ').toLowerCase(); // Combined lowercased string of recent lines.
119
+ const interactiveGuides = ['tab', 'select', 'enter', 'confirm', 'dismiss', 'arrow', 'esc', 'up/down', '[y/n]']; // Interactive keyboard control terms.
120
+ const promptIndicators = ['password:', 'password for', 'enter passphrase', 'input:', 'confirm:', 'proceed?']; // Standard CLI input indicators.
121
+ const hasInteractiveGuides = interactiveGuides.some(guide => lastFewTextLower.includes(guide)); // Flag identifying key guides in output.
122
+ const hasPromptIndicators = promptIndicators.some(indicator => lastFewTextLower.includes(indicator)); // Flag identifying standard prompts in output.
123
+ const lastLine = nonZeroLines[nonZeroLines.length - 1] || ''; // The single last line of output.
124
+ const endsWithQuestion = lastLine.endsWith('?') || lastFewLines.some(l => l.endsWith('?')); // Flag verifying if any recent lines end with a question mark.
125
+ const isWaiting = hasInteractiveGuides || hasPromptIndicators || endsWithQuestion; // Combined check for form prompt or question.
126
+ const nextStatus = isWaiting ? 'Waiting' : 'Idle'; // Computed target status mapping.
127
+ session.status = nextStatus;
128
+ changed = true;
129
+ }
130
+ }
131
+ }); // Spawns concurrent auditing sessions.
132
+
133
+ await Promise.all(sessionPromises);
134
+ if (changed) {
135
+ broadcastCallback();
136
+ }
137
+ }, 5000); // Trigger auditor check every 5 seconds to reduce wmic process spawn frequency on Windows.
138
+ }
139
+
140
+ // Spawns a persistent terminal process or retrieves an existing one, matching it to the tab.
141
+ // Uses an async spawn lock to serialize concurrent node-pty process creation on Windows.
142
+ export async function getOrCreatePtySession(
143
+ workspaceId: string,
144
+ tabId: string,
145
+ cwd: string,
146
+ shellPath?: string,
147
+ broadcastCallback?: () => void
148
+ ): Promise<TerminalSession> {
149
+ const existing = sessions.get(tabId); // Attempt to retrieve an existing session from the cache.
150
+ if (existing) {
151
+ console.log(`[PTY] getOrCreatePtySession -> returning existing session for tab=${tabId} PID=${existing.ptyProcess.pid}`);
152
+ const foundSession = existing; // Explicit reference to the cached session.
153
+ return foundSession;
154
+ }
155
+
156
+ console.log(`[PTY] getOrCreatePtySession(workspaceId=${workspaceId}, tabId=${tabId}, cwd=${cwd}, shellPath=${shellPath})`);
157
+
158
+ const defaultShell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; // Choose shell path depending on underlying server OS.
159
+ const targetShell = shellPath || defaultShell; // Evaluated shell executable to run.
160
+
161
+ const ptyArgs: string[] = []; // Arguments passed to the spawned shell.
162
+ const ptyEnv = { ...process.env } as Record<string, string>; // Environment variables for the spawned process.
163
+
164
+ const isWin = os.platform() === 'win32'; // Flag indicating Windows platform.
165
+ if (isWin) {
166
+ const guardScript = path.resolve(__dirname, '../scripts/git-guard.ps1');
167
+ if (fs.existsSync(guardScript)) {
168
+ ptyArgs.push('-NoLogo', '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', `. '${guardScript}'`);
169
+ }
170
+ } else {
171
+ const wrapperDir = path.resolve(__dirname, '../git-wrapper');
172
+ if (fs.existsSync(path.join(wrapperDir, 'git'))) {
173
+ ptyEnv.PATH = `${wrapperDir}${path.delimiter}${ptyEnv.PATH || ''}`;
174
+ }
175
+ }
176
+
177
+ // Wait for any in-flight PTY spawn to finish before creating a new one to avoid ConPTY conflicts on Windows.
178
+ while (spawnLock) {
179
+ console.log(`[PTY] Spawn lock held, waiting for tab=${tabId}`);
180
+ await spawnLock;
181
+ }
182
+ let releaseLock: () => void = () => {}; // Callback to release the spawn mutex after creation completes, default no-op to avoid TS usage-before-init.
183
+ spawnLock = new Promise((resolve) => { releaseLock = resolve; });
184
+
185
+ console.log(`[PTY] Spawning shell: targetShell="${targetShell}" args=[${ptyArgs.join(', ')}] useConpty=true`);
186
+ let ptyProcess: pty.IPty; // Reference to the successfully spawned node-pty process.
187
+ try {
188
+ ptyProcess = pty.spawn(targetShell, ptyArgs, {
189
+ name: 'xterm-256color',
190
+ cols: 80,
191
+ rows: 24,
192
+ cwd: cwd,
193
+ env: ptyEnv,
194
+ useConpty: true, // Enables the Windows Pseudo Console API for accurate dimension resizes.
195
+ }); // Spawn the process via node-pty.
196
+ console.log(`[PTY] Spawned PID=${ptyProcess.pid} for tab=${tabId}`);
197
+ } catch (err: any) {
198
+ console.log(`[PTY] Spawn FAILED for tab=${tabId}: ${err.message}. Stack: ${err.stack}`);
199
+ const spawnErr = new Error(`PTY spawn failed for tab ${tabId}: ${err.message}`); // Error wrapping the spawn failure.
200
+ spawnLock = null;
201
+ releaseLock();
202
+ throw spawnErr;
203
+ }
204
+ spawnLock = null;
205
+ releaseLock();
206
+
207
+ const newSession: TerminalSession = {
208
+ id: tabId,
209
+ workspaceId: workspaceId,
210
+ ptyProcess: ptyProcess,
211
+ outputBuffer: [],
212
+ lastOutputTime: Date.now(),
213
+ status: 'Idle',
214
+ lastLinePreview: '',
215
+ onData: () => {},
216
+ broadcastCallback: broadcastCallback,
217
+ }; // The new terminal session schema instance mapping this tab.
218
+
219
+ sessions.set(tabId, newSession);
220
+
221
+ // Save process PID to database for future orphan recovery.
222
+ try {
223
+ db.updateTerminalTab(workspaceId, tabId, { pid: ptyProcess.pid });
224
+ } catch (err) {
225
+ // Safe ignore database update failure.
226
+ }
227
+
228
+ ptyProcess.onData(data => {
229
+ newSession.lastOutputTime = Date.now();
230
+
231
+ const maxBufferLines = 1000; // Limit total cached rows in memory to save memory usage.
232
+ newSession.outputBuffer.push(data);
233
+ if (newSession.outputBuffer.length > maxBufferLines) {
234
+ newSession.outputBuffer.shift();
235
+ }
236
+
237
+ const lines = data.split(/\r?\n/); // Splitted chunk array.
238
+ const lastNonEmpty = lines.filter(l => l.trim().length > 0).pop(); // The last non-empty line of text.
239
+ if (lastNonEmpty) {
240
+ const cleanLine = lastNonEmpty.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); // Remove ANSI terminal escape codes.
241
+ if (cleanLine.length > 0) {
242
+ newSession.lastLinePreview = cleanLine.substring(0, 100);
243
+ }
244
+ }
245
+
246
+ const oldStatus = newSession.status; // Preserve original status before computation.
247
+ newSession.status = 'Active'; // Mark status as active immediately when receiving new data.
248
+
249
+ newSession.onData(data);
250
+
251
+ if (oldStatus !== newSession.status && broadcastCallback) {
252
+ broadcastCallback();
253
+ }
254
+ });
255
+
256
+ ptyProcess.onExit(res => {
257
+ newSession.status = res.exitCode === 0 ? 'Idle' : 'Errored';
258
+ if (broadcastCallback) {
259
+ broadcastCallback();
260
+ }
261
+ });
262
+
263
+ const createdSession = newSession; // Explicit reference to return.
264
+ return createdSession;
265
+ }
266
+
267
+ // Resizes the terminal process row and column dimensions.
268
+ export function resizePtySession(tabId: string, cols: number, rows: number): void {
269
+ const session = sessions.get(tabId); // Look up session by ID.
270
+ if (session && typeof cols === 'number' && typeof rows === 'number' && cols > 0 && rows > 0) {
271
+ const parsedCols = Math.max(Math.floor(cols), 40); // Ensures PTY size is at least 40 columns wide.
272
+ const parsedRows = Math.max(Math.floor(rows), 10); // Ensures PTY size is at least 10 rows high.
273
+ if (parsedCols > 0 && parsedRows > 0) {
274
+ session.ptyProcess.resize(parsedCols, parsedRows);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Write input data directly to the active terminal process.
280
+ // Does not change the session status — the periodic auditor handles status transitions,
281
+ // preventing rapid Idle↔Active flickering that occurred on every keystroke.
282
+ export function writeToPtySession(tabId: string, data: string): void {
283
+ const session = sessions.get(tabId); // Look up session by ID.
284
+ if (session) {
285
+ session.ptyProcess.write(data);
286
+ }
287
+ }
288
+
289
+ // Forces the termination of a terminal session and frees system process resources.
290
+ // Serializes concurrent kills on Windows to avoid overlapping conhost termination window flashes.
291
+ export async function killPtySession(tabId: string): Promise<void> {
292
+ const session = sessions.get(tabId); // Find session by tab ID.
293
+ if (session) {
294
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> killing PID=${session.ptyProcess.pid}`);
295
+ // Wait for any in-flight PTY kill to finish before terminating this one to avoid ConPTY conflicts on Windows.
296
+ while (killLock) {
297
+ console.log(`[PTY] Kill lock held, waiting for tab=${tabId}`);
298
+ await killLock;
299
+ }
300
+ let releaseKillLock: () => void = () => {}; // Callback to release the kill mutex after termination completes.
301
+ killLock = new Promise((resolve) => { releaseKillLock = resolve; });
302
+
303
+ try {
304
+ session.ptyProcess.kill();
305
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill OK`);
306
+ } catch (err: any) {
307
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill error: ${err.message}`);
308
+ } finally {
309
+ killLock = null;
310
+ releaseKillLock();
311
+ }
312
+ try {
313
+ db.updateTerminalTab(session.workspaceId, tabId, { pid: undefined }); // Clear the PID record from the database.
314
+ } catch (err) {
315
+ // Safe ignore database write failure.
316
+ }
317
+ sessions.delete(tabId);
318
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> session removed from map`);
319
+ } else {
320
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> no active session found`);
321
+ }
322
+ }
323
+
324
+ // Retrieves all terminal session descriptors currently managed by the backend.
325
+ export function getAllSessions(): Array<{ id: string; workspaceId: string; status: string; preview: string }> {
326
+ const list: Array<{ id: string; workspaceId: string; status: string; preview: string }> = []; // Return sessions descriptors.
327
+ sessions.forEach(s => {
328
+ list.push({
329
+ id: s.id,
330
+ workspaceId: s.workspaceId,
331
+ status: s.status,
332
+ preview: s.lastLinePreview,
333
+ });
334
+ });
335
+ return list;
336
+ }
337
+
338
+ // Verifies that a given PID belongs to an expected Exeggutor-managed process to avoid killing unrelated recycled PIDs.
339
+ function verifyPidOwnership(pid: number): Promise<boolean> {
340
+ console.log(`[PTY] verifyPidOwnership(pid=${pid})`);
341
+ return new Promise((resolve) => {
342
+ const isWin = process.platform === 'win32'; // Flag indicating Windows platform.
343
+ if (isWin) {
344
+ console.log(`[PTY] verifyPidOwnership -> running: tasklist /FI "PID eq ${pid}"`);
345
+ exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (err, stdout) => {
346
+ if (err || !stdout) {
347
+ resolve(false);
348
+ return;
349
+ }
350
+ const nameMatch = stdout.match(/"([^"]+)"/); // Capture the process executable name from CSV output.
351
+ if (!nameMatch) {
352
+ resolve(false);
353
+ return;
354
+ }
355
+ const name = nameMatch[1].toLowerCase(); // Process name from tasklist.
356
+ const knownNames = ['node.exe', 'powershell.exe', 'cmd.exe', 'bash.exe', 'wmic.exe', 'conhost.exe'];
357
+ const result = knownNames.some(k => name.includes(k));
358
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${name}" known=${result}`);
359
+ resolve(result);
360
+ }); // Spawn tasklist to verify process identity.
361
+ } else {
362
+ try {
363
+ const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); // Process command name from proc filesystem.
364
+ const knownNames = ['node', 'bash', 'zsh', 'sh', 'dash', 'powershell'];
365
+ const result = knownNames.includes(comm);
366
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${comm}" known=${result}`);
367
+ resolve(result);
368
+ } catch {
369
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> error reading /proc/${pid}/comm`);
370
+ resolve(false);
371
+ }
372
+ }
373
+ }); // Verifies that the PID matches an expected Exeggutor-managed process.
374
+ }
375
+
376
+ // Scans the database for previously persisted terminal tab process IDs and forcefully terminates any orphaned instances.
377
+ export async function cleanOrphanedPtyProcesses(): Promise<void> {
378
+ try {
379
+ const workspaces = db.getWorkspaces(); // Load workspaces from database.
380
+ console.log(`[PTY] cleanOrphanedPtyProcesses() -> scanning ${workspaces.length} workspaces`);
381
+ for (const ws of workspaces) {
382
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> workspace=${ws.id} (${ws.name}) has ${ws.tabs.length} tabs`);
383
+ for (const tab of ws.tabs) {
384
+ if (tab.pid) {
385
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> checking tab=${tab.id} PID=${tab.pid}`);
386
+ const ownsPid = await verifyPidOwnership(tab.pid); // Flag confirming the PID belongs to an expected process.
387
+ if (!ownsPid) {
388
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} not owned, clearing record`);
389
+ try {
390
+ db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear stale PID record without killing.
391
+ } catch (err) {
392
+ // Safe ignore database write failure.
393
+ }
394
+ continue;
395
+ }
396
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} owned, sending SIGKILL`);
397
+ try {
398
+ process.kill(tab.pid, 'SIGKILL'); // Force terminate the orphaned shell process.
399
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} killed`);
400
+ } catch (err: any) {
401
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} kill error: ${err.message}`);
402
+ }
403
+ try {
404
+ db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear the PID record from the database.
405
+ } catch (err) {
406
+ // Safe ignore database write failure.
407
+ }
408
+ }
409
+ }
410
+ }
411
+ } catch (err: any) {
412
+ console.log(`[PTY] cleanOrphanedPtyProcesses() -> error: ${err.message}`);
413
+ }
414
+ }