ai-agent-session-center 2.0.2 → 2.0.3

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.
Files changed (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. package/server/wsManager.ts +200 -0
@@ -0,0 +1,671 @@
1
+ // sshManager.ts — PTY-based terminal multiplexer using node-pty
2
+ // Manages terminal lifecycle for local and remote (via native ssh) sessions.
3
+ // Terminal I/O is relayed through WebSocket to xterm.js in the browser.
4
+
5
+ import pty from 'node-pty';
6
+ import type { IPty, IDisposable } from 'node-pty';
7
+ import { execFile, execSync } from 'child_process';
8
+ import { readdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+ import log from './logger.js';
12
+ import type { Terminal, TerminalConfig, TerminalInfo, TmuxSessionInfo, SshKeyInfo } from '../src/types/terminal.js';
13
+ import type { PendingLink } from '../src/types/session.js';
14
+ import type WebSocket from 'ws';
15
+
16
+ // ---- Input Validation Helpers ----
17
+
18
+ // Shell metacharacters that indicate injection attempts
19
+ const SHELL_META_RE = /[;|&$`\\!><()\n\r{}[\]]/;
20
+
21
+ // tmuxSession names: alphanumeric, dash, underscore, dot only
22
+ const TMUX_SESSION_RE = /^[a-zA-Z0-9_.\-]+$/;
23
+
24
+ function validateWorkingDir(dir: string | undefined): string | null {
25
+ if (!dir) return null;
26
+ if (typeof dir !== 'string') return 'workingDir must be a string';
27
+ if (dir.length > 1024) return 'workingDir too long';
28
+ // Allow ~ at start, then normal path chars
29
+ if (SHELL_META_RE.test(dir.replace(/^~/, ''))) return 'workingDir contains invalid characters';
30
+ return null;
31
+ }
32
+
33
+ function validateCommand(cmd: string | undefined): string | null {
34
+ if (!cmd) return null;
35
+ if (typeof cmd !== 'string') return 'command must be a string';
36
+ if (cmd.length > 512) return 'command too long';
37
+ // Allow known CLI commands with flags, but reject shell metacharacters
38
+ if (/[;|&$`\\!><()\n\r{}[\]]/.test(cmd)) return 'command contains invalid shell characters';
39
+ return null;
40
+ }
41
+
42
+ function validateTmuxSession(name: string | undefined): string | null {
43
+ if (!name) return null;
44
+ if (typeof name !== 'string') return 'tmuxSession must be a string';
45
+ if (name.length > 128) return 'tmuxSession name too long';
46
+ if (!TMUX_SESSION_RE.test(name)) return 'tmuxSession must be alphanumeric, dash, underscore, or dot only';
47
+ return null;
48
+ }
49
+
50
+ function validatePid(pid: unknown): number | null {
51
+ const n = parseInt(String(pid), 10);
52
+ return Number.isFinite(n) && n > 0 ? n : null;
53
+ }
54
+
55
+ // Escape a string for safe use inside single quotes in shell commands
56
+ function shellEscapeSingleQuote(str: string): string {
57
+ return str.replace(/'/g, "'\\''");
58
+ }
59
+
60
+ // ---- Shell Ready Detection ----
61
+
62
+ // Match ANSI escape sequences (CSI + OSC) for stripping from PTY output
63
+ const ANSI_ESC_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)/g;
64
+
65
+ // Common shell prompt endings: $ (bash/zsh), % (zsh), # (root), > (fish/powershell)
66
+ const SHELL_PROMPT_RE = /[#$%>]\s*$/;
67
+
68
+ /**
69
+ * Watch PTY output to detect when the shell is ready (prompt visible).
70
+ * Resolves `true` when a prompt is detected, `false` on timeout or PTY exit.
71
+ * Uses a 100ms settle timer to avoid false-matching mid-stream MOTD output —
72
+ * shell prompts are always the last thing printed before the shell waits for input.
73
+ */
74
+ function detectShellReady(ptyProcess: IPty, terminalId: string, timeoutMs: number): Promise<boolean> {
75
+ let resolveFn: (value: boolean) => void;
76
+ const promise = new Promise<boolean>(r => { resolveFn = r; });
77
+ let buffer = '';
78
+ let done = false;
79
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
80
+
81
+ function finish(detected: boolean): void {
82
+ if (done) return;
83
+ done = true;
84
+ clearTimeout(fallbackTimer);
85
+ if (settleTimer) clearTimeout(settleTimer);
86
+ dataDisp.dispose();
87
+ exitDisp.dispose();
88
+ resolveFn(detected);
89
+ }
90
+
91
+ function checkPrompt(): void {
92
+ const stripped = buffer.replace(ANSI_ESC_RE, '');
93
+ const lines = stripped.split(/[\r\n]+/);
94
+ // Find the last non-empty line
95
+ let lastLine = '';
96
+ for (let i = lines.length - 1; i >= 0; i--) {
97
+ if (lines[i].trim()) { lastLine = lines[i].trim(); break; }
98
+ }
99
+ // Shell prompts are short and end with $ % # >
100
+ if (lastLine && lastLine.length < 200 && SHELL_PROMPT_RE.test(lastLine)) {
101
+ log.debug('pty', `Shell prompt detected for ${terminalId}: "${lastLine.slice(-60)}"`);
102
+ finish(true);
103
+ }
104
+ }
105
+
106
+ const dataDisp: IDisposable = ptyProcess.onData((data: string) => {
107
+ if (done) return;
108
+ buffer += data;
109
+ // Cap buffer to avoid memory issues with large MOTD output
110
+ if (buffer.length > 4096) buffer = buffer.slice(-4096);
111
+ // Wait for output to settle (100ms of silence) before checking —
112
+ // MOTD lines arrive in bursts, but the final prompt is followed by silence
113
+ if (settleTimer) clearTimeout(settleTimer);
114
+ settleTimer = setTimeout(checkPrompt, 100);
115
+ });
116
+
117
+ const exitDisp: IDisposable = ptyProcess.onExit(() => {
118
+ log.debug('pty', `PTY ${terminalId} exited before shell ready detected`);
119
+ finish(false);
120
+ });
121
+
122
+ const fallbackTimer = setTimeout(() => {
123
+ log.warn('pty', `Shell ready detection timed out for ${terminalId} after ${timeoutMs}ms — sending command as fallback`);
124
+ finish(false);
125
+ }, timeoutMs);
126
+
127
+ return promise;
128
+ }
129
+
130
+ // List available SSH keys from ~/.ssh/
131
+ export function listSshKeys(): SshKeyInfo[] {
132
+ const sshDir = join(homedir(), '.ssh');
133
+ try {
134
+ return readdirSync(sshDir)
135
+ .filter(f => !f.endsWith('.pub') && !f.startsWith('known_hosts') && !f.startsWith('config') && !f.startsWith('authorized_keys') && !f.startsWith('.'))
136
+ .map(f => ({ name: f, path: join('~', '.ssh', f) }));
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ // Active terminals: terminalId -> Terminal
143
+ const terminals = new Map<string, Terminal>();
144
+
145
+ // Ring buffer size for PTY output replay (128KB — enough for ~2 full screens of scrollback)
146
+ const OUTPUT_BUFFER_MAX = 128 * 1024;
147
+
148
+ // Pending links: workingDir -> { terminalId, host, createdAt }
149
+ // Used to match incoming SessionStart hooks to the terminal that launched Claude
150
+ const pendingLinks = new Map<string, PendingLink>();
151
+
152
+ // Clean up stale pending links every 30s
153
+ setInterval(() => {
154
+ const now = Date.now();
155
+ for (const [key, link] of pendingLinks) {
156
+ if (now - link.createdAt > 60000) {
157
+ log.debug('pty', `Expired pending link for ${key}`);
158
+ pendingLinks.delete(key);
159
+ }
160
+ }
161
+ }, 30000);
162
+
163
+ function resolveWorkDir(dir: string | undefined): string {
164
+ if (!dir || dir === '~') return homedir();
165
+ return dir.replace(/^~/, homedir());
166
+ }
167
+
168
+ function isLocal(host: string | undefined): boolean {
169
+ return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
170
+ }
171
+
172
+ function getDefaultShell(): string {
173
+ return process.env.SHELL || '/bin/bash';
174
+ }
175
+
176
+ // Build SSH command args for remote connections (without -t for non-interactive)
177
+ function buildSshArgs(config: TerminalConfig, options: { allocatePty?: boolean } = {}): string[] {
178
+ const args: string[] = [];
179
+ if (options.allocatePty) args.push('-t');
180
+ if (config.port && config.port !== 22) {
181
+ args.push('-p', String(config.port));
182
+ }
183
+ if (config.privateKeyPath) {
184
+ const keyPath = config.privateKeyPath.replace(/^~/, homedir());
185
+ args.push('-i', keyPath);
186
+ }
187
+ args.push('-o', 'StrictHostKeyChecking=accept-new');
188
+ args.push(`${config.username}@${config.host}`);
189
+ return args;
190
+ }
191
+
192
+ // List tmux sessions on local or remote host
193
+ export function listTmuxSessions(config: TerminalConfig): Promise<TmuxSessionInfo[]> {
194
+ return new Promise((resolve, reject) => {
195
+ const tmuxFmt = 'tmux list-sessions -F "#{session_name}||#{session_attached}||#{session_created}||#{session_windows}" 2>/dev/null || echo "__no_tmux__"';
196
+
197
+ let cmd: string;
198
+ let args: string[];
199
+ if (isLocal(config.host)) {
200
+ cmd = 'bash';
201
+ args = ['-c', tmuxFmt];
202
+ } else {
203
+ cmd = 'ssh';
204
+ args = [...buildSshArgs(config), tmuxFmt];
205
+ }
206
+
207
+ execFile(cmd, args, { timeout: 10000 }, (err, stdout) => {
208
+ if (err) {
209
+ if ((err as NodeJS.ErrnoException & { killed?: boolean }).killed) {
210
+ reject(new Error('Connection timed out'));
211
+ } else {
212
+ // tmux not installed or no sessions — not an error
213
+ resolve([]);
214
+ }
215
+ return;
216
+ }
217
+ const output = stdout.toString();
218
+ if (output.includes('__no_tmux__') || !output.trim()) {
219
+ resolve([]);
220
+ return;
221
+ }
222
+ const sessions = output.trim().split('\n').map(line => {
223
+ const [name, attached, created, windows] = line.split('||');
224
+ return {
225
+ name,
226
+ attached: attached === '1',
227
+ created: parseInt(created) * 1000,
228
+ windows: parseInt(windows) || 1,
229
+ };
230
+ }).filter(s => s.name);
231
+ resolve(sessions);
232
+ });
233
+ });
234
+ }
235
+
236
+ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | null): Promise<string> {
237
+ return new Promise((resolve, reject) => {
238
+ // Validate inputs before any shell interaction
239
+ const wdErr = validateWorkingDir(config.workingDir);
240
+ if (wdErr) return reject(new Error(wdErr));
241
+ const cmdErr = validateCommand(config.command);
242
+ if (cmdErr) return reject(new Error(cmdErr));
243
+ const tmuxErr = validateTmuxSession(config.tmuxSession);
244
+ if (tmuxErr) return reject(new Error(tmuxErr));
245
+
246
+ const terminalId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
247
+ const workDir = resolveWorkDir(config.workingDir);
248
+ const command = config.command || 'claude';
249
+ const skipAutoLaunch = config.command === '';
250
+ const local = isLocal(config.host);
251
+
252
+ try {
253
+ let shell: string;
254
+ let args: string[];
255
+ let cwd: string;
256
+ // Build environment — API keys go here instead of shell command strings
257
+ const env: Record<string, string> = { ...process.env as Record<string, string>, AGENT_MANAGER_TERMINAL_ID: terminalId };
258
+
259
+ if (config.apiKey) {
260
+ const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
261
+ : command.startsWith('gemini') ? 'GEMINI_API_KEY'
262
+ : 'ANTHROPIC_API_KEY';
263
+ env[envVar] = config.apiKey;
264
+ }
265
+
266
+ if (local) {
267
+ shell = getDefaultShell();
268
+ args = [];
269
+ cwd = workDir;
270
+ } else {
271
+ // Spawn native ssh — uses system SSH config, agent, keys automatically
272
+ shell = 'ssh';
273
+ args = buildSshArgs(config, { allocatePty: true });
274
+ cwd = homedir();
275
+ }
276
+
277
+ const ptyProcess: IPty = pty.spawn(shell, args, {
278
+ name: 'xterm-256color',
279
+ cols: 120,
280
+ rows: 40,
281
+ cwd,
282
+ env,
283
+ });
284
+
285
+ log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
286
+
287
+ // Detect when the shell is ready (prompt visible) before sending commands.
288
+ // Local shells init in ~100-300ms; remote SSH can take seconds for key exchange.
289
+ const shellReady = detectShellReady(ptyProcess, terminalId, local ? 5000 : 15000);
290
+
291
+ terminals.set(terminalId, {
292
+ pty: ptyProcess,
293
+ sessionId: null,
294
+ config: { ...config, workingDir: workDir },
295
+ wsClient,
296
+ createdAt: Date.now(),
297
+ outputBuffer: Buffer.alloc(0),
298
+ shellReady,
299
+ });
300
+
301
+ // Register pending link for session matching
302
+ pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
303
+
304
+ // Stream output to WebSocket client + buffer for replay
305
+ ptyProcess.onData((data: string) => {
306
+ const term = terminals.get(terminalId);
307
+ if (!term) return;
308
+
309
+ // Append to ring buffer for replay on (re)subscribe
310
+ const chunk = Buffer.from(data);
311
+ term.outputBuffer = Buffer.concat([term.outputBuffer, chunk]);
312
+ if (term.outputBuffer.length > OUTPUT_BUFFER_MAX) {
313
+ term.outputBuffer = term.outputBuffer.slice(term.outputBuffer.length - OUTPUT_BUFFER_MAX);
314
+ }
315
+
316
+ if (term.wsClient && term.wsClient.readyState === 1) {
317
+ term.wsClient.send(JSON.stringify({
318
+ type: 'terminal_output',
319
+ terminalId,
320
+ data: chunk.toString('base64'),
321
+ }));
322
+ }
323
+ });
324
+
325
+ ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
326
+ log.info('pty', `Terminal ${terminalId} exited (code: ${exitCode}, signal: ${signal})`);
327
+ broadcastToClient(terminalId, {
328
+ type: 'terminal_closed',
329
+ terminalId,
330
+ reason: signal ? `signal ${signal}` : 'exited',
331
+ });
332
+ cleanup(terminalId);
333
+ });
334
+
335
+ // Send the launch command once the shell is ready (prompt detected).
336
+ // API keys are passed via env object to pty.spawn (above), not via shell commands.
337
+ // For remote SSH sessions, we export the env var in the shell since env doesn't
338
+ // propagate across SSH.
339
+ // When skipAutoLaunch is true, the caller will write the command itself
340
+ // (e.g., resume with || fallback that contains shell metacharacters).
341
+ if (!skipAutoLaunch) {
342
+ // Build the launch command eagerly — only the write is deferred
343
+ let launchCmd: string;
344
+
345
+ if (config.tmuxSession) {
346
+ // Attach to existing tmux session (validated above as alphanumeric+dash+underscore+dot)
347
+ launchCmd = `tmux attach -t '${shellEscapeSingleQuote(config.tmuxSession)}'`;
348
+ } else if (config.useTmux) {
349
+ // Wrap command in a new tmux session
350
+ const tmuxName = `claude-${Date.now().toString(36)}`;
351
+ let innerCmd = local ? '' : `cd '${shellEscapeSingleQuote(workDir)}' && `;
352
+ if (!local) {
353
+ // Export terminal ID for hook matching over SSH
354
+ innerCmd += `export AGENT_MANAGER_TERMINAL_ID='${shellEscapeSingleQuote(terminalId)}' && `;
355
+ if (config.apiKey) {
356
+ const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
357
+ : command.startsWith('gemini') ? 'GEMINI_API_KEY'
358
+ : 'ANTHROPIC_API_KEY';
359
+ innerCmd += `export ${envVar}='${shellEscapeSingleQuote(config.apiKey)}' && `;
360
+ }
361
+ }
362
+ innerCmd += command;
363
+ launchCmd = `tmux new-session -s '${tmuxName}' '${shellEscapeSingleQuote(innerCmd)}'`;
364
+ } else {
365
+ // Direct launch
366
+ launchCmd = local ? '' : `cd '${shellEscapeSingleQuote(workDir)}'`;
367
+ if (!local) {
368
+ // Export AGENT_MANAGER_TERMINAL_ID on the remote side so hooks
369
+ // can include it for Priority 0/1 session matching (SSH doesn't
370
+ // forward env vars from the local PTY).
371
+ if (launchCmd) launchCmd += ' && ';
372
+ launchCmd += `export AGENT_MANAGER_TERMINAL_ID='${shellEscapeSingleQuote(terminalId)}'`;
373
+ if (config.apiKey) {
374
+ const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
375
+ : command.startsWith('gemini') ? 'GEMINI_API_KEY'
376
+ : 'ANTHROPIC_API_KEY';
377
+ launchCmd += ` && export ${envVar}='${shellEscapeSingleQuote(config.apiKey)}'`;
378
+ }
379
+ }
380
+ if (launchCmd) launchCmd += ' && ';
381
+ launchCmd += command;
382
+ }
383
+
384
+ // Wait for shell prompt before writing — replaces the old blind setTimeout
385
+ shellReady.then((detected) => {
386
+ const term = terminals.get(terminalId);
387
+ if (!term || !term.pty) return;
388
+ if (!detected) {
389
+ log.warn('pty', `Sending launch command to ${terminalId} despite no prompt detected`);
390
+ }
391
+ term.pty.write(launchCmd + '\r');
392
+ });
393
+ }
394
+
395
+ // Notify client terminal is ready
396
+ if (wsClient && wsClient.readyState === 1) {
397
+ wsClient.send(JSON.stringify({ type: 'terminal_ready', terminalId }));
398
+ }
399
+
400
+ resolve(terminalId);
401
+ } catch (err: unknown) {
402
+ const msg = err instanceof Error ? err.message : String(err);
403
+ log.error('pty', `Failed to create terminal: ${msg}`);
404
+ reject(err);
405
+ }
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Attach to an existing tmux pane, creating a terminal that views the pane's output.
411
+ * Uses `tmux attach -t {paneId}` to attach to the session containing the pane.
412
+ */
413
+ export function attachToTmuxPane(tmuxPaneId: string, wsClient: WebSocket | null): Promise<string> {
414
+ return new Promise((resolve, reject) => {
415
+ // Validate pane ID: must be % followed by digits
416
+ if (!tmuxPaneId || typeof tmuxPaneId !== 'string') {
417
+ return reject(new Error('tmuxPaneId is required'));
418
+ }
419
+ if (!/^%\d+$/.test(tmuxPaneId)) {
420
+ return reject(new Error('tmuxPaneId must be in format "%N" (e.g. "%5")'));
421
+ }
422
+
423
+ const terminalId = `term-tmux-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
424
+
425
+ try {
426
+ // First, resolve which tmux session this pane belongs to
427
+ // Then attach to that session targeting the specific pane
428
+ const shell = getDefaultShell();
429
+ const env: Record<string, string> = { ...process.env as Record<string, string>, AGENT_MANAGER_TERMINAL_ID: terminalId };
430
+
431
+ const ptyProcess: IPty = pty.spawn(shell, [], {
432
+ name: 'xterm-256color',
433
+ cols: 120,
434
+ rows: 40,
435
+ cwd: homedir(),
436
+ env,
437
+ });
438
+
439
+ log.info('pty', `Spawned tmux attach terminal ${terminalId} for pane ${tmuxPaneId} (pid: ${ptyProcess.pid})`);
440
+
441
+ terminals.set(terminalId, {
442
+ pty: ptyProcess,
443
+ sessionId: null,
444
+ config: { host: 'localhost', workingDir: homedir(), command: `tmux (pane ${tmuxPaneId})` },
445
+ wsClient,
446
+ createdAt: Date.now(),
447
+ outputBuffer: Buffer.alloc(0),
448
+ });
449
+
450
+ // Stream output to WebSocket client + buffer for replay
451
+ ptyProcess.onData((data: string) => {
452
+ const term = terminals.get(terminalId);
453
+ if (!term) return;
454
+
455
+ const chunk = Buffer.from(data);
456
+ term.outputBuffer = Buffer.concat([term.outputBuffer, chunk]);
457
+ if (term.outputBuffer.length > OUTPUT_BUFFER_MAX) {
458
+ term.outputBuffer = term.outputBuffer.slice(term.outputBuffer.length - OUTPUT_BUFFER_MAX);
459
+ }
460
+
461
+ if (term.wsClient && term.wsClient.readyState === 1) {
462
+ term.wsClient.send(JSON.stringify({
463
+ type: 'terminal_output',
464
+ terminalId,
465
+ data: chunk.toString('base64'),
466
+ }));
467
+ }
468
+ });
469
+
470
+ ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
471
+ log.info('pty', `Tmux terminal ${terminalId} exited (code: ${exitCode}, signal: ${signal})`);
472
+ broadcastToClient(terminalId, {
473
+ type: 'terminal_closed',
474
+ terminalId,
475
+ reason: signal ? `signal ${signal}` : 'exited',
476
+ });
477
+ cleanup(terminalId);
478
+ });
479
+
480
+ // Send the tmux attach command after shell init
481
+ // select-pane -t ensures we're looking at the right pane
482
+ setTimeout(() => {
483
+ // Pane ID is validated above as %N, safe to interpolate
484
+ ptyProcess.write(`tmux select-pane -t '${tmuxPaneId}' && tmux attach\r`);
485
+ }, 100);
486
+
487
+ // Notify client terminal is ready
488
+ if (wsClient && wsClient.readyState === 1) {
489
+ wsClient.send(JSON.stringify({ type: 'terminal_ready', terminalId }));
490
+ }
491
+
492
+ resolve(terminalId);
493
+ } catch (err: unknown) {
494
+ const msg = err instanceof Error ? err.message : String(err);
495
+ log.error('pty', `Failed to attach to tmux pane ${tmuxPaneId}: ${msg}`);
496
+ reject(err);
497
+ }
498
+ });
499
+ }
500
+
501
+ export function writeToTerminal(terminalId: string, data: string): void {
502
+ const term = terminals.get(terminalId);
503
+ if (term && term.pty) {
504
+ term.pty.write(data);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Write data to a terminal after its shell is ready.
510
+ * Awaits the shell prompt detection before writing, so commands aren't lost
511
+ * if SSH hasn't finished connecting yet.
512
+ */
513
+ export async function writeWhenReady(terminalId: string, data: string): Promise<boolean> {
514
+ const term = terminals.get(terminalId);
515
+ if (!term) return false;
516
+ if (term.shellReady) await term.shellReady;
517
+ // Terminal might have been cleaned up while waiting
518
+ const termNow = terminals.get(terminalId);
519
+ if (!termNow || !termNow.pty) return false;
520
+ termNow.pty.write(data);
521
+ return true;
522
+ }
523
+
524
+ export function resizeTerminal(terminalId: string, cols: number, rows: number): void {
525
+ const term = terminals.get(terminalId);
526
+ if (term && term.pty) {
527
+ try {
528
+ term.pty.resize(cols, rows);
529
+ } catch (e: unknown) {
530
+ const msg = e instanceof Error ? e.message : String(e);
531
+ log.debug('pty', `Resize failed for ${terminalId} (process may be dead): ${msg}`);
532
+ }
533
+ }
534
+ }
535
+
536
+ export function closeTerminal(terminalId: string): void {
537
+ const term = terminals.get(terminalId);
538
+ if (term) {
539
+ if (term.pty) {
540
+ try { term.pty.kill(); } catch (e: unknown) {
541
+ const msg = e instanceof Error ? e.message : String(e);
542
+ log.debug('pty', `Kill failed for ${terminalId}: ${msg}`);
543
+ }
544
+ }
545
+ cleanup(terminalId);
546
+ }
547
+ }
548
+
549
+ export function linkSession(terminalId: string, sessionId: string): void {
550
+ const term = terminals.get(terminalId);
551
+ if (term) {
552
+ term.sessionId = sessionId;
553
+ log.info('pty', `Linked terminal ${terminalId} to session ${sessionId}`);
554
+ }
555
+ }
556
+
557
+ export function tryLinkByWorkDir(workDir: string, sessionId: string): string | null {
558
+ const link = pendingLinks.get(workDir);
559
+ if (link) {
560
+ linkSession(link.terminalId, sessionId);
561
+ pendingLinks.delete(workDir);
562
+ return link.terminalId;
563
+ }
564
+ // Also try matching with trailing slash variants
565
+ const normalized = workDir.replace(/\/$/, '');
566
+ for (const [dir, lnk] of pendingLinks) {
567
+ if (dir.replace(/\/$/, '') === normalized) {
568
+ linkSession(lnk.terminalId, sessionId);
569
+ pendingLinks.delete(dir);
570
+ return lnk.terminalId;
571
+ }
572
+ }
573
+ return null;
574
+ }
575
+
576
+ /**
577
+ * Consume (remove) a pending link for a given workDir.
578
+ * Called after Priority 0 resume match to prevent stale links from
579
+ * creating duplicate sessions at Priority 2.
580
+ */
581
+ export function consumePendingLink(workDir: string): void {
582
+ if (!workDir) return;
583
+ if (pendingLinks.delete(workDir)) return;
584
+ const normalized = workDir.replace(/\/$/, '');
585
+ for (const [dir] of pendingLinks) {
586
+ if (dir.replace(/\/$/, '') === normalized) {
587
+ pendingLinks.delete(dir);
588
+ return;
589
+ }
590
+ }
591
+ }
592
+
593
+ export function getTerminalForSession(sessionId: string): string | null {
594
+ for (const [terminalId, term] of terminals) {
595
+ if (term.sessionId === sessionId) return terminalId;
596
+ }
597
+ return null;
598
+ }
599
+
600
+ // Find terminal whose pty is the parent of the given child PID
601
+ export function getTerminalByPtyChild(childPid: number): string | null {
602
+ const validPid = validatePid(childPid);
603
+ if (!validPid) return null;
604
+ try {
605
+ const ppid = parseInt(execSync(`ps -o ppid= -p ${validPid} 2>/dev/null`, { encoding: 'utf-8' }).trim(), 10);
606
+ if (!ppid || ppid <= 0) return null;
607
+ for (const [terminalId, term] of terminals) {
608
+ if (term.pty && term.pty.pid === ppid) return terminalId;
609
+ }
610
+ } catch (e: unknown) {
611
+ const msg = e instanceof Error ? e.message : String(e);
612
+ log.debug('pty', `getTerminalByPtyChild failed for pid=${validPid}: ${msg}`);
613
+ }
614
+ return null;
615
+ }
616
+
617
+ export function setWsClient(terminalId: string, wsClient: WebSocket | null): void {
618
+ const term = terminals.get(terminalId);
619
+ if (term) {
620
+ term.wsClient = wsClient;
621
+
622
+ if (wsClient && wsClient.readyState === 1) {
623
+ // Send terminal_ready so the frontend runs onTerminalReady (refit + resize sync).
624
+ // This is important for REST-API-created terminals where the original terminal_ready
625
+ // was sent to a null wsClient and never reached the browser.
626
+ wsClient.send(JSON.stringify({ type: 'terminal_ready', terminalId }));
627
+
628
+ // Replay buffered output so the client sees previous terminal content
629
+ if (term.outputBuffer.length > 0) {
630
+ wsClient.send(JSON.stringify({
631
+ type: 'terminal_output',
632
+ terminalId,
633
+ data: term.outputBuffer.toString('base64'),
634
+ }));
635
+ log.debug('pty', `Replayed ${term.outputBuffer.length} bytes to new client for ${terminalId}`);
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ export function getTerminals(): TerminalInfo[] {
642
+ const result: TerminalInfo[] = [];
643
+ for (const [terminalId, term] of terminals) {
644
+ result.push({
645
+ terminalId,
646
+ sessionId: term.sessionId,
647
+ host: term.config.host,
648
+ workingDir: term.config.workingDir,
649
+ command: term.config.command,
650
+ createdAt: term.createdAt,
651
+ });
652
+ }
653
+ return result;
654
+ }
655
+
656
+ function broadcastToClient(terminalId: string, message: Record<string, unknown>): void {
657
+ const term = terminals.get(terminalId);
658
+ if (term && term.wsClient && term.wsClient.readyState === 1) {
659
+ term.wsClient.send(JSON.stringify(message));
660
+ }
661
+ }
662
+
663
+ function cleanup(terminalId: string): void {
664
+ const term = terminals.get(terminalId);
665
+ if (term) {
666
+ for (const [key, link] of pendingLinks) {
667
+ if (link.terminalId === terminalId) pendingLinks.delete(key);
668
+ }
669
+ terminals.delete(terminalId);
670
+ }
671
+ }