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.
- package/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- 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
|
+
}
|