ai-agent-session-center 1.0.0

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 (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,339 @@
1
+ // sshManager.js — 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 { execFile, execSync } from 'child_process';
7
+ import { readdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import log from './logger.js';
11
+
12
+ // List available SSH keys from ~/.ssh/
13
+ export function listSshKeys() {
14
+ const sshDir = join(homedir(), '.ssh');
15
+ try {
16
+ return readdirSync(sshDir)
17
+ .filter(f => !f.endsWith('.pub') && !f.startsWith('known_hosts') && !f.startsWith('config') && !f.startsWith('authorized_keys') && !f.startsWith('.'))
18
+ .map(f => ({ name: f, path: join('~', '.ssh', f) }));
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ // Active terminals: terminalId -> { pty, sessionId, config, wsClient, createdAt }
25
+ const terminals = new Map();
26
+
27
+ // Pending links: workingDir -> { terminalId, host, createdAt }
28
+ // Used to match incoming SessionStart hooks to the terminal that launched Claude
29
+ const pendingLinks = new Map();
30
+
31
+ // Clean up stale pending links every 30s
32
+ setInterval(() => {
33
+ const now = Date.now();
34
+ for (const [key, link] of pendingLinks) {
35
+ if (now - link.createdAt > 60000) {
36
+ log.debug('pty', `Expired pending link for ${key}`);
37
+ pendingLinks.delete(key);
38
+ }
39
+ }
40
+ }, 30000);
41
+
42
+ function resolveWorkDir(dir) {
43
+ if (!dir || dir === '~') return homedir();
44
+ return dir.replace(/^~/, homedir());
45
+ }
46
+
47
+ function isLocal(host) {
48
+ return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
49
+ }
50
+
51
+ function getDefaultShell() {
52
+ return process.env.SHELL || '/bin/bash';
53
+ }
54
+
55
+ // Build SSH command args for remote connections (without -t for non-interactive)
56
+ function buildSshArgs(config, { allocatePty = false } = {}) {
57
+ const args = [];
58
+ if (allocatePty) args.push('-t');
59
+ if (config.port && config.port !== 22) {
60
+ args.push('-p', String(config.port));
61
+ }
62
+ if (config.privateKeyPath) {
63
+ const keyPath = config.privateKeyPath.replace(/^~/, homedir());
64
+ args.push('-i', keyPath);
65
+ }
66
+ args.push('-o', 'StrictHostKeyChecking=accept-new');
67
+ args.push(`${config.username}@${config.host}`);
68
+ return args;
69
+ }
70
+
71
+ // List tmux sessions on local or remote host
72
+ export function listTmuxSessions(config) {
73
+ return new Promise((resolve, reject) => {
74
+ const tmuxFmt = 'tmux list-sessions -F "#{session_name}||#{session_attached}||#{session_created}||#{session_windows}" 2>/dev/null || echo "__no_tmux__"';
75
+
76
+ let cmd, args;
77
+ if (isLocal(config.host)) {
78
+ cmd = 'bash';
79
+ args = ['-c', tmuxFmt];
80
+ } else {
81
+ cmd = 'ssh';
82
+ args = [...buildSshArgs(config), tmuxFmt];
83
+ }
84
+
85
+ execFile(cmd, args, { timeout: 10000 }, (err, stdout) => {
86
+ if (err) {
87
+ if (err.killed) {
88
+ reject(new Error('Connection timed out'));
89
+ } else {
90
+ // tmux not installed or no sessions — not an error
91
+ resolve([]);
92
+ }
93
+ return;
94
+ }
95
+ const output = stdout.toString();
96
+ if (output.includes('__no_tmux__') || !output.trim()) {
97
+ resolve([]);
98
+ return;
99
+ }
100
+ const sessions = output.trim().split('\n').map(line => {
101
+ const [name, attached, created, windows] = line.split('||');
102
+ return {
103
+ name,
104
+ attached: attached === '1',
105
+ created: parseInt(created) * 1000,
106
+ windows: parseInt(windows) || 1,
107
+ };
108
+ }).filter(s => s.name);
109
+ resolve(sessions);
110
+ });
111
+ });
112
+ }
113
+
114
+ export function createTerminal(config, wsClient) {
115
+ return new Promise((resolve, reject) => {
116
+ const terminalId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
117
+ const workDir = resolveWorkDir(config.workingDir);
118
+ const command = config.command || 'claude';
119
+ const local = isLocal(config.host);
120
+
121
+ try {
122
+ let shell, args, cwd;
123
+
124
+ if (local) {
125
+ shell = getDefaultShell();
126
+ args = [];
127
+ cwd = workDir;
128
+ } else {
129
+ // Spawn native ssh — uses system SSH config, agent, keys automatically
130
+ shell = 'ssh';
131
+ args = buildSshArgs(config, { allocatePty: true });
132
+ cwd = homedir();
133
+ }
134
+
135
+ const ptyProcess = pty.spawn(shell, args, {
136
+ name: 'xterm-256color',
137
+ cols: 120,
138
+ rows: 40,
139
+ cwd,
140
+ env: { ...process.env, AGENT_MANAGER_TERMINAL_ID: terminalId },
141
+ });
142
+
143
+ log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
144
+
145
+ terminals.set(terminalId, {
146
+ pty: ptyProcess,
147
+ sessionId: null,
148
+ config: { ...config, workingDir: workDir },
149
+ wsClient,
150
+ createdAt: Date.now(),
151
+ });
152
+
153
+ // Register pending link for session matching
154
+ pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
155
+
156
+ // Stream output to WebSocket client
157
+ ptyProcess.onData((data) => {
158
+ const term = terminals.get(terminalId);
159
+ if (term && term.wsClient && term.wsClient.readyState === 1) {
160
+ term.wsClient.send(JSON.stringify({
161
+ type: 'terminal_output',
162
+ terminalId,
163
+ data: Buffer.from(data).toString('base64'),
164
+ }));
165
+ }
166
+ });
167
+
168
+ ptyProcess.onExit(({ exitCode, signal }) => {
169
+ log.info('pty', `Terminal ${terminalId} exited (code: ${exitCode}, signal: ${signal})`);
170
+ broadcastToClient(terminalId, {
171
+ type: 'terminal_closed',
172
+ terminalId,
173
+ reason: signal ? `signal ${signal}` : 'exited',
174
+ });
175
+ cleanup(terminalId);
176
+ });
177
+
178
+ // Send the launch command after shell/SSH init
179
+ setTimeout(() => {
180
+ let launchCmd;
181
+
182
+ if (config.tmuxSession) {
183
+ // Attach to existing tmux session
184
+ launchCmd = `tmux attach -t "${config.tmuxSession}"`;
185
+ } else if (config.useTmux) {
186
+ // Wrap command in a new tmux session
187
+ const tmuxName = `claude-${Date.now().toString(36)}`;
188
+ let innerCmd = local ? '' : `cd "${workDir}" && `;
189
+ if (config.apiKey) {
190
+ const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
191
+ : command.startsWith('gemini') ? 'GEMINI_API_KEY'
192
+ : 'ANTHROPIC_API_KEY';
193
+ innerCmd += `export ${envVar}='${config.apiKey.replace(/'/g, "'\\''")}' && `;
194
+ }
195
+ innerCmd += command;
196
+ launchCmd = `tmux new-session -s "${tmuxName}" '${innerCmd.replace(/'/g, "'\\''")}'`;
197
+ } else {
198
+ // Direct launch
199
+ launchCmd = local ? '' : `cd "${workDir}"`;
200
+ if (config.apiKey) {
201
+ if (launchCmd) launchCmd += ' && ';
202
+ const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
203
+ : command.startsWith('gemini') ? 'GEMINI_API_KEY'
204
+ : 'ANTHROPIC_API_KEY';
205
+ launchCmd += `export ${envVar}="${config.apiKey}"`;
206
+ }
207
+ if (launchCmd) launchCmd += ' && ';
208
+ launchCmd += command;
209
+ }
210
+
211
+ ptyProcess.write(launchCmd + '\r');
212
+ }, local ? 100 : 500);
213
+
214
+ // Notify client terminal is ready
215
+ if (wsClient && wsClient.readyState === 1) {
216
+ wsClient.send(JSON.stringify({ type: 'terminal_ready', terminalId }));
217
+ }
218
+
219
+ resolve(terminalId);
220
+ } catch (err) {
221
+ log.error('pty', `Failed to create terminal: ${err.message}`);
222
+ reject(err);
223
+ }
224
+ });
225
+ }
226
+
227
+ export function writeToTerminal(terminalId, data) {
228
+ const term = terminals.get(terminalId);
229
+ if (term && term.pty) {
230
+ term.pty.write(data);
231
+ }
232
+ }
233
+
234
+ export function resizeTerminal(terminalId, cols, rows) {
235
+ const term = terminals.get(terminalId);
236
+ if (term && term.pty) {
237
+ try {
238
+ term.pty.resize(cols, rows);
239
+ } catch {
240
+ // Process may already be dead
241
+ }
242
+ }
243
+ }
244
+
245
+ export function closeTerminal(terminalId) {
246
+ const term = terminals.get(terminalId);
247
+ if (term) {
248
+ if (term.pty) {
249
+ try { term.pty.kill(); } catch {}
250
+ }
251
+ cleanup(terminalId);
252
+ }
253
+ }
254
+
255
+ export function linkSession(terminalId, sessionId) {
256
+ const term = terminals.get(terminalId);
257
+ if (term) {
258
+ term.sessionId = sessionId;
259
+ log.info('pty', `Linked terminal ${terminalId} to session ${sessionId}`);
260
+ }
261
+ }
262
+
263
+ export function tryLinkByWorkDir(workDir, sessionId) {
264
+ const link = pendingLinks.get(workDir);
265
+ if (link) {
266
+ linkSession(link.terminalId, sessionId);
267
+ pendingLinks.delete(workDir);
268
+ return link.terminalId;
269
+ }
270
+ // Also try matching with trailing slash variants
271
+ const normalized = workDir.replace(/\/$/, '');
272
+ for (const [dir, link] of pendingLinks) {
273
+ if (dir.replace(/\/$/, '') === normalized) {
274
+ linkSession(link.terminalId, sessionId);
275
+ pendingLinks.delete(dir);
276
+ return link.terminalId;
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+
282
+ export function getTerminalForSession(sessionId) {
283
+ for (const [terminalId, term] of terminals) {
284
+ if (term.sessionId === sessionId) return terminalId;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ // Find terminal whose pty is the parent of the given child PID
290
+ export function getTerminalByPtyChild(childPid) {
291
+ if (!childPid || childPid <= 0) return null;
292
+ try {
293
+ const ppid = parseInt(execSync(`ps -o ppid= -p ${childPid} 2>/dev/null`, { encoding: 'utf-8' }).trim(), 10);
294
+ if (!ppid || ppid <= 0) return null;
295
+ for (const [terminalId, term] of terminals) {
296
+ if (term.pty && term.pty.pid === ppid) return terminalId;
297
+ }
298
+ } catch {}
299
+ return null;
300
+ }
301
+
302
+ export function setWsClient(terminalId, wsClient) {
303
+ const term = terminals.get(terminalId);
304
+ if (term) {
305
+ term.wsClient = wsClient;
306
+ }
307
+ }
308
+
309
+ export function getTerminals() {
310
+ const result = [];
311
+ for (const [terminalId, term] of terminals) {
312
+ result.push({
313
+ terminalId,
314
+ sessionId: term.sessionId,
315
+ host: term.config.host,
316
+ workingDir: term.config.workingDir,
317
+ command: term.config.command,
318
+ createdAt: term.createdAt,
319
+ });
320
+ }
321
+ return result;
322
+ }
323
+
324
+ function broadcastToClient(terminalId, message) {
325
+ const term = terminals.get(terminalId);
326
+ if (term && term.wsClient && term.wsClient.readyState === 1) {
327
+ term.wsClient.send(JSON.stringify(message));
328
+ }
329
+ }
330
+
331
+ function cleanup(terminalId) {
332
+ const term = terminals.get(terminalId);
333
+ if (term) {
334
+ for (const [key, link] of pendingLinks) {
335
+ if (link.terminalId === terminalId) pendingLinks.delete(key);
336
+ }
337
+ terminals.delete(terminalId);
338
+ }
339
+ }
@@ -0,0 +1,83 @@
1
+ // wsManager.js — WebSocket broadcast manager with bidirectional terminal support
2
+ import { getAllSessions, getAllTeams, getEventSeq, getEventsSince } from './sessionStore.js';
3
+ import { writeToTerminal, resizeTerminal, closeTerminal, setWsClient } from './sshManager.js';
4
+ import log from './logger.js';
5
+
6
+ const clients = new Set();
7
+
8
+ export function handleConnection(ws) {
9
+ clients.add(ws);
10
+ ws._terminalIds = new Set(); // Track subscribed terminals
11
+ log.info('ws', `Client connected (total: ${clients.size})`);
12
+
13
+ // Send full snapshot on connect (includes teams + event sequence for replay)
14
+ const sessions = getAllSessions();
15
+ const teams = getAllTeams();
16
+ const seq = getEventSeq();
17
+ log.debug('ws', `Sending snapshot: ${Object.keys(sessions).length} sessions, ${Object.keys(teams).length} teams, seq=${seq}`);
18
+ ws.send(JSON.stringify({ type: 'snapshot', sessions, teams, seq }));
19
+
20
+ // Handle incoming messages (terminal input, resize, etc.)
21
+ ws.on('message', (raw) => {
22
+ try {
23
+ const msg = JSON.parse(raw.toString());
24
+ switch (msg.type) {
25
+ case 'terminal_input':
26
+ if (msg.terminalId && msg.data) {
27
+ writeToTerminal(msg.terminalId, msg.data);
28
+ }
29
+ break;
30
+ case 'terminal_resize':
31
+ if (msg.terminalId && msg.cols && msg.rows) {
32
+ resizeTerminal(msg.terminalId, msg.cols, msg.rows);
33
+ }
34
+ break;
35
+ case 'terminal_disconnect':
36
+ if (msg.terminalId) {
37
+ closeTerminal(msg.terminalId);
38
+ ws._terminalIds.delete(msg.terminalId);
39
+ }
40
+ break;
41
+ case 'terminal_subscribe':
42
+ if (msg.terminalId) {
43
+ ws._terminalIds.add(msg.terminalId);
44
+ setWsClient(msg.terminalId, ws);
45
+ }
46
+ break;
47
+ case 'replay':
48
+ // Client reconnected and wants events since a certain sequence number
49
+ if (typeof msg.sinceSeq === 'number') {
50
+ const missed = getEventsSince(msg.sinceSeq);
51
+ log.debug('ws', `Replaying ${missed.length} events since seq=${msg.sinceSeq}`);
52
+ for (const evt of missed) {
53
+ ws.send(JSON.stringify(evt.data));
54
+ }
55
+ }
56
+ break;
57
+ default:
58
+ log.debug('ws', `Unknown message type: ${msg.type}`);
59
+ }
60
+ } catch (e) {
61
+ log.debug('ws', `Invalid WS message: ${e.message}`);
62
+ }
63
+ });
64
+
65
+ ws.on('close', () => {
66
+ clients.delete(ws);
67
+ log.info('ws', `Client disconnected (total: ${clients.size})`);
68
+ });
69
+ ws.on('error', (err) => {
70
+ clients.delete(ws);
71
+ log.error('ws', 'Client error:', err.message);
72
+ });
73
+ }
74
+
75
+ export function broadcast(data) {
76
+ const msg = JSON.stringify(data);
77
+ log.debug('ws', `Broadcasting ${data.type} to ${clients.size} clients`);
78
+ for (const client of clients) {
79
+ if (client.readyState === 1) {
80
+ client.send(msg);
81
+ }
82
+ }
83
+ }