ai-agent-session-center 2.8.2 → 2.9.2

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.
@@ -7,8 +7,8 @@
7
7
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
8
  <link rel="apple-touch-icon" href="/apple-touch-icon.svg">
9
9
  <meta name="theme-color" content="#0a0a1a">
10
- <script type="module" crossorigin src="/assets/index-CCOZp8gY.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-bOzE64NK.css">
10
+ <script type="module" crossorigin src="/assets/index-DI0_qhqI.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-D3mXxwbI.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agent-session-center",
3
- "version": "2.8.2",
3
+ "version": "2.9.2",
4
4
  "description": "A real-time dashboard for monitoring AI agent sessions (Claude Code, Gemini CLI, Codex) with 3D visualization",
5
5
  "type": "module",
6
6
  "main": "server/index.ts",
@@ -80,6 +80,7 @@ const terminalCreateSchema = z.object({
80
80
  sessionTitle: z.string().max(500).optional(),
81
81
  label: z.string().optional(),
82
82
  enableOpsTerminal: z.boolean().optional(),
83
+ forceNew: z.boolean().optional(),
83
84
  });
84
85
 
85
86
  const tmuxSessionsSchema = z.object({
@@ -747,11 +748,14 @@ router.post('/terminals', async (req: Request, res: Response) => {
747
748
  }
748
749
 
749
750
  // Deduplicate: if an active session with matching config already exists, return it
750
- const existing = findActiveSessionByConfig(config);
751
- if (existing) {
752
- log.info('api', `Deduplicated terminal creation — reusing session ${existing.sessionId} for ${resolvedHost}:${config.workingDir}`);
753
- res.json({ ok: true, terminalId: existing.sessionId, deduplicated: true });
754
- return;
751
+ // Skip deduplication when forceNew is explicitly requested (e.g. Quick session modal)
752
+ if (!body.forceNew) {
753
+ const existing = findActiveSessionByConfig(config);
754
+ if (existing) {
755
+ log.info('api', `Deduplicated terminal creation — reusing session ${existing.sessionId} for ${resolvedHost}:${config.workingDir}`);
756
+ res.json({ ok: true, terminalId: existing.sessionId, deduplicated: true });
757
+ return;
758
+ }
755
759
  }
756
760
 
757
761
  const terminalId = await createTerminal(config, null);
@@ -1245,11 +1249,33 @@ router.get('/files/stream', (req: Request, res: Response) => {
1245
1249
  };
1246
1250
  const contentType = mimeMap[fileExt] || 'application/octet-stream';
1247
1251
 
1248
- res.setHeader('Content-Type', contentType);
1249
- res.setHeader('Content-Length', stat.size);
1250
1252
  const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1251
1253
  res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
1252
1254
 
1255
+ // Video/audio: support HTTP Range requests so browsers can seek
1256
+ const isMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
1257
+ if (isMedia) {
1258
+ res.setHeader('Accept-Ranges', 'bytes');
1259
+ const rangeHeader = req.headers.range;
1260
+ if (rangeHeader) {
1261
+ const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
1262
+ const start = parseInt(startStr, 10);
1263
+ const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
1264
+ const chunkSize = end - start + 1;
1265
+ res.status(206);
1266
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
1267
+ res.setHeader('Content-Length', chunkSize);
1268
+ res.setHeader('Content-Type', contentType);
1269
+ const rangeStream = createReadStream(fullPath, { start, end });
1270
+ rangeStream.pipe(res);
1271
+ rangeStream.on('error', () => { if (!res.headersSent) res.status(500).json({ error: 'Stream error' }); });
1272
+ return;
1273
+ }
1274
+ }
1275
+
1276
+ res.setHeader('Content-Type', contentType);
1277
+ res.setHeader('Content-Length', stat.size);
1278
+
1253
1279
  const stream = createReadStream(fullPath);
1254
1280
  stream.pipe(res);
1255
1281
  stream.on('error', () => {
@@ -928,21 +928,24 @@ export async function createTerminalSession(terminalId: string, config: Terminal
928
928
 
929
929
  await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
930
930
 
931
- // Non-Claude CLIs (codex, gemini, etc.) don't send hooks — auto-transition to idle
931
+ // Auto-transition from connecting to idle if hooks don't arrive in time.
932
+ // Non-Claude CLIs: 3s (they never send hooks).
933
+ // Claude: 30s fallback — hooks should arrive via SessionStart, but if the
934
+ // session_id or path doesn't match (e.g. SSH remote path mismatch), the card
935
+ // would be stuck in connecting forever without this safety net.
932
936
  const command = config.command || 'claude';
933
- if (!command.startsWith('claude')) {
934
- setTimeout(async () => {
935
- const s = sessions.get(terminalId);
936
- if (s && s.status === (SESSION_STATUS.CONNECTING as string)) {
937
- s.status = SESSION_STATUS.IDLE;
938
- s.animationState = ANIMATION_STATE.IDLE;
939
- s.emote = null;
940
- s.model = command; // Show command name as model
941
- await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...s } });
942
- log.info('session', `Auto-transitioned non-Claude session ${terminalId} to idle (${command})`);
943
- }
944
- }, 3000);
945
- }
937
+ const connectingTimeout = command.startsWith('claude') ? 30_000 : 3_000;
938
+ setTimeout(async () => {
939
+ const s = sessions.get(terminalId);
940
+ if (s && s.status === (SESSION_STATUS.CONNECTING as string)) {
941
+ s.status = SESSION_STATUS.IDLE;
942
+ s.animationState = ANIMATION_STATE.IDLE;
943
+ s.emote = null;
944
+ if (!command.startsWith('claude')) s.model = command;
945
+ await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...s } });
946
+ log.info('session', `Auto-transitioned session ${terminalId} to idle after connecting timeout (${command})`);
947
+ }
948
+ }, connectingTimeout);
946
949
 
947
950
  return session;
948
951
  }
@@ -313,6 +313,44 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
313
313
 
314
314
  log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
315
315
 
316
+ // Auto-type SSH password when password auth is used.
317
+ // Watches PTY output for "password:" prompts and sends the stored password.
318
+ if (!local && config.password) {
319
+ const storedPassword = config.password;
320
+ let passwordBuffer = '';
321
+ let passwordAttempts = 0;
322
+ const maxAttempts = 2;
323
+ const passwordDisp = ptyProcess.onData((data: string) => {
324
+ passwordBuffer += data;
325
+ if (passwordBuffer.length > 4096) passwordBuffer = passwordBuffer.slice(-4096);
326
+ const stripped = passwordBuffer.replace(ANSI_ESC_RE, '').toLowerCase();
327
+ if (stripped.includes('password:') || stripped.includes('password for')) {
328
+ passwordAttempts++;
329
+ if (passwordAttempts > maxAttempts) {
330
+ log.error('pty', `SSH password rejected after ${maxAttempts} attempts for ${terminalId}`);
331
+ passwordDisp.dispose();
332
+ return;
333
+ }
334
+ // Small delay to ensure the SSH process is ready for input
335
+ setTimeout(() => {
336
+ const term = terminals.get(terminalId);
337
+ if (term?.pty) {
338
+ term.pty.write(storedPassword + '\r');
339
+ log.info('pty', `Auto-typed SSH password for ${terminalId} (attempt ${passwordAttempts})`);
340
+ }
341
+ }, 100);
342
+ // Reset buffer so we don't re-trigger on the same prompt
343
+ passwordBuffer = '';
344
+ }
345
+ // Stop watching after successful auth (shell prompt appears)
346
+ if (stripped.includes('last login') || SHELL_PROMPT_RE.test(stripped.split(/[\r\n]+/).filter(l => l.trim()).pop() || '')) {
347
+ passwordDisp.dispose();
348
+ }
349
+ });
350
+ // Safety cleanup — stop watching after 30s regardless
351
+ setTimeout(() => { passwordDisp.dispose(); }, 30000);
352
+ }
353
+
316
354
  // Detect when the shell is ready (prompt visible) before sending commands.
317
355
  // Local shells init in ~100-300ms; remote SSH can take seconds for key exchange.
318
356
  const shellReady = detectShellReady(ptyProcess, terminalId, local ? 2000 : 10000);
@@ -327,8 +365,12 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
327
365
  shellReady,
328
366
  });
329
367
 
330
- // Register pending link for session matching
331
- pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
368
+ // Register pending link for session matching.
369
+ // Skip for ops terminals (command='') they never run Claude so they
370
+ // must not overwrite the main terminal's pending link for the same workDir.
371
+ if (!skipAutoLaunch) {
372
+ pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
373
+ }
332
374
 
333
375
  // #19: Store disposables for proper cleanup on terminal close
334
376
  const disposables: IDisposable[] = [];