ai-agent-session-center 2.8.2 → 2.9.4

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.
@@ -10,7 +10,7 @@ function str(val: unknown): string {
10
10
  if (Array.isArray(val)) return String(val[0] ?? '');
11
11
  return val != null ? String(val) : '';
12
12
  }
13
- import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionPinned, setSessionAccentColor, setSummary, getSession, getAllSessions, detectSessionSource, createTerminalSession, findActiveSessionByConfig, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal, reconnectOpsTerminal } from './sessionStore.js';
13
+ import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionPinned, setSessionAccentColor, setSessionCharacterModel, setSummary, getSession, getAllSessions, detectSessionSource, createTerminalSession, findActiveSessionByConfig, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal, reconnectOpsTerminal } from './sessionStore.js';
14
14
  import { config as serverConfig } from './serverConfig.js';
15
15
  import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions, writeToTerminal, writeWhenReady, attachToTmuxPane, consumePendingLink } from './sshManager.js';
16
16
  import { getTeam, readTeamConfig } from './teamManager.js';
@@ -20,7 +20,7 @@ import { getMqStats } from './mqReader.js';
20
20
  import { execFile } from 'child_process';
21
21
  import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync, rmSync } from 'fs';
22
22
  import { join, dirname, extname, basename, resolve, sep } from 'path';
23
- import { homedir, userInfo } from 'os';
23
+ import { homedir, userInfo, networkInterfaces, hostname } from 'os';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
26
26
  import log from './logger.js';
@@ -44,8 +44,22 @@ function saveLastUsername(username: string): void {
44
44
  if (username) _lastUsedUsername = username;
45
45
  }
46
46
 
47
+ // Build set of all addresses/hostnames that resolve to this machine.
48
+ // Matches sshManager.ts approach so local-session detection is consistent.
49
+ const LOCAL_HOSTS = new Set<string>(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
50
+ try {
51
+ const h = hostname();
52
+ LOCAL_HOSTS.add(h);
53
+ LOCAL_HOSTS.add(`${h}.local`); // macOS mDNS
54
+ const ifaces = networkInterfaces();
55
+ for (const addrs of Object.values(ifaces)) {
56
+ if (!addrs) continue;
57
+ for (const addr of addrs) LOCAL_HOSTS.add(addr.address);
58
+ }
59
+ } catch { /* ignore — hardcoded fallback is sufficient */ }
60
+
47
61
  function isLocalHost(host: string): boolean {
48
- return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
62
+ return !host || LOCAL_HOSTS.has(host);
49
63
  }
50
64
 
51
65
  // ---- Zod Validation Schemas ----
@@ -80,6 +94,7 @@ const terminalCreateSchema = z.object({
80
94
  sessionTitle: z.string().max(500).optional(),
81
95
  label: z.string().optional(),
82
96
  enableOpsTerminal: z.boolean().optional(),
97
+ forceNew: z.boolean().optional(),
83
98
  });
84
99
 
85
100
  const tmuxSessionsSchema = z.object({
@@ -115,6 +130,10 @@ const accentColorSchema = z.object({
115
130
  color: z.string().min(1).max(50),
116
131
  });
117
132
 
133
+ const characterModelSchema = z.object({
134
+ model: z.string().min(1).max(50),
135
+ });
136
+
118
137
  const summarizeSchema = z.object({
119
138
  context: z.string().min(1),
120
139
  promptTemplate: z.string().optional(),
@@ -615,6 +634,14 @@ router.put('/sessions/:id/accent-color', (req: Request, res: Response) => {
615
634
  res.json({ ok: true });
616
635
  });
617
636
 
637
+ // Update session character model
638
+ router.put('/sessions/:id/character-model', (req: Request, res: Response) => {
639
+ const body = validateBody(characterModelSchema, req.body, res);
640
+ if (!body) return;
641
+ setSessionCharacterModel(str(req.params.id), body.model);
642
+ res.json({ ok: true });
643
+ });
644
+
618
645
  /**
619
646
  * Summarize session using Claude CLI.
620
647
  * The frontend sends { context, promptTemplate } from IndexedDB data.
@@ -716,8 +743,10 @@ router.post('/terminals', async (req: Request, res: Response) => {
716
743
  if (!body) return;
717
744
 
718
745
  try {
719
- const resolvedHost = body.host || 'localhost';
720
- const username = body.username || getDefaultUsername() || (isLocalHost(resolvedHost) ? 'local' : null);
746
+ // Normalize any local address (IP, hostname, .local) to 'localhost' so that
747
+ // dedup matching, PTY creation, and stored sshHost are all consistent.
748
+ const resolvedHost = isLocalHost(body.host || '') ? 'localhost' : (body.host || 'localhost');
749
+ const username = body.username || getDefaultUsername() || (resolvedHost === 'localhost' ? 'local' : null);
721
750
  if (!username) {
722
751
  res.status(400).json({ success: false, error: 'username required — set it once in "+ NEW SESSION" and it will be reused' });
723
752
  return;
@@ -747,11 +776,14 @@ router.post('/terminals', async (req: Request, res: Response) => {
747
776
  }
748
777
 
749
778
  // 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;
779
+ // Skip deduplication when forceNew is explicitly requested (e.g. Quick session modal)
780
+ if (!body.forceNew) {
781
+ const existing = findActiveSessionByConfig(config);
782
+ if (existing) {
783
+ log.info('api', `Deduplicated terminal creation — reusing session ${existing.sessionId} for ${resolvedHost}:${config.workingDir}`);
784
+ res.json({ ok: true, terminalId: existing.sessionId, deduplicated: true, hasTerminal: !!existing.terminalId });
785
+ return;
786
+ }
755
787
  }
756
788
 
757
789
  const terminalId = await createTerminal(config, null);
@@ -1058,9 +1090,9 @@ const STREAMABLE_EXTENSIONS = new Set([
1058
1090
  ]);
1059
1091
 
1060
1092
  /** Directories to skip when listing. */
1093
+ // Always-hidden dirs (even when showHidden=true) — large/noisy dirs with no browsing value
1061
1094
  const HIDDEN_DIRS = new Set([
1062
- 'node_modules', '.git', '.next', '.nuxt', '__pycache__', '.venv',
1063
- 'venv', 'dist', 'build', '.cache', '.turbo', 'coverage', '.svelte-kit',
1095
+ 'node_modules', '__pycache__', 'venv', 'dist', 'build', 'coverage',
1064
1096
  ]);
1065
1097
 
1066
1098
  function isTextFile(name: string): boolean {
@@ -1121,11 +1153,12 @@ router.get('/files/list', (req: Request, res: Response) => {
1121
1153
  const entries = readdirSync(fullPath, { withFileTypes: true });
1122
1154
  const items: Array<{ name: string; type: 'dir' | 'file'; size?: number; mtime?: string }> = [];
1123
1155
 
1156
+ const showHidden = str(req.query.showHidden) === 'true';
1124
1157
  for (const entry of entries) {
1125
- // Skip hidden dirs (but show hidden files like .env)
1158
+ // Skip certain always-hidden dirs regardless of showHidden flag
1126
1159
  if (entry.isDirectory() && HIDDEN_DIRS.has(entry.name)) continue;
1127
- // Skip dot-prefixed directories (e.g. .git) but show dot files
1128
- if (entry.isDirectory() && entry.name.startsWith('.')) continue;
1160
+ // Skip dot-prefixed directories unless showHidden is set
1161
+ if (!showHidden && entry.isDirectory() && entry.name.startsWith('.')) continue;
1129
1162
 
1130
1163
  if (entry.isDirectory()) {
1131
1164
  try {
@@ -1245,11 +1278,33 @@ router.get('/files/stream', (req: Request, res: Response) => {
1245
1278
  };
1246
1279
  const contentType = mimeMap[fileExt] || 'application/octet-stream';
1247
1280
 
1248
- res.setHeader('Content-Type', contentType);
1249
- res.setHeader('Content-Length', stat.size);
1250
1281
  const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1251
1282
  res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
1252
1283
 
1284
+ // Video/audio: support HTTP Range requests so browsers can seek
1285
+ const isMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
1286
+ if (isMedia) {
1287
+ res.setHeader('Accept-Ranges', 'bytes');
1288
+ const rangeHeader = req.headers.range;
1289
+ if (rangeHeader) {
1290
+ const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
1291
+ const start = parseInt(startStr, 10);
1292
+ const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
1293
+ const chunkSize = end - start + 1;
1294
+ res.status(206);
1295
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
1296
+ res.setHeader('Content-Length', chunkSize);
1297
+ res.setHeader('Content-Type', contentType);
1298
+ const rangeStream = createReadStream(fullPath, { start, end });
1299
+ rangeStream.pipe(res);
1300
+ rangeStream.on('error', () => { if (!res.headersSent) res.status(500).json({ error: 'Stream error' }); });
1301
+ return;
1302
+ }
1303
+ }
1304
+
1305
+ res.setHeader('Content-Type', contentType);
1306
+ res.setHeader('Content-Length', stat.size);
1307
+
1253
1308
  const stream = createReadStream(fullPath);
1254
1309
  stream.pipe(res);
1255
1310
  stream.on('error', () => {
@@ -1,5 +1,6 @@
1
1
  // fileIndexCache.ts — Cached file index for fast fuzzy search
2
2
  import { readdir } from 'fs/promises';
3
+ import { watch as fsWatch } from 'fs';
3
4
  import { join } from 'path';
4
5
  import log from './logger.js';
5
6
 
@@ -28,10 +29,13 @@ interface CachedIndex {
28
29
 
29
30
  const cache = new Map<string, CachedIndex>();
30
31
  const building = new Set<string>();
32
+ const watchers = new Map<string, ReturnType<typeof fsWatch>>();
33
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
31
34
 
32
- const CACHE_TTL_MS = 30_000; // 30 seconds
35
+ const CACHE_TTL_MS = 30_000; // 30 seconds (watcher keeps it fresh in practice)
33
36
  const MAX_ENTRIES = 50_000;
34
37
  const MAX_DEPTH = 10;
38
+ const WATCHER_DEBOUNCE_MS = 300;
35
39
 
36
40
  /** Build the file index for a project root (async, non-blocking). */
37
41
  async function buildIndex(root: string): Promise<FileEntry[]> {
@@ -74,7 +78,10 @@ async function buildIndex(root: string): Promise<FileEntry[]> {
74
78
  */
75
79
  function ensureIndex(root: string): void {
76
80
  const cached = cache.get(root);
77
- if (cached && Date.now() - cached.builtAt < CACHE_TTL_MS) return;
81
+ if (cached && Date.now() - cached.builtAt < CACHE_TTL_MS) {
82
+ startWatcher(root);
83
+ return;
84
+ }
78
85
  if (building.has(root)) return;
79
86
 
80
87
  building.add(root);
@@ -82,6 +89,7 @@ function ensureIndex(root: string): void {
82
89
  .then(entries => {
83
90
  cache.set(root, { entries, builtAt: Date.now() });
84
91
  log.debug('file-index', `Built index for ${root}: ${entries.length} entries`);
92
+ startWatcher(root);
85
93
  })
86
94
  .catch(err => {
87
95
  log.error('file-index', `Failed to build index for ${root}: ${err instanceof Error ? err.message : String(err)}`);
@@ -91,6 +99,30 @@ function ensureIndex(root: string): void {
91
99
  });
92
100
  }
93
101
 
102
+ /** Start a recursive fs.watch on root to invalidate cache on any file change. */
103
+ function startWatcher(root: string): void {
104
+ if (watchers.has(root)) return;
105
+ try {
106
+ const watcher = fsWatch(root, { recursive: true }, (_event, filename) => {
107
+ // Skip node_modules and hidden dirs for performance
108
+ if (filename && (filename.includes('node_modules') || filename.includes('/.git/'))) return;
109
+ const existing = debounceTimers.get(root);
110
+ if (existing) clearTimeout(existing);
111
+ debounceTimers.set(root, setTimeout(() => {
112
+ debounceTimers.delete(root);
113
+ cache.delete(root);
114
+ log.debug('file-index', `Cache invalidated for ${root} (file change: ${filename})`);
115
+ }, WATCHER_DEBOUNCE_MS));
116
+ });
117
+ watcher.on('error', () => {
118
+ watchers.delete(root);
119
+ });
120
+ watchers.set(root, watcher);
121
+ } catch {
122
+ // Recursive watch not supported on this platform/path — silently ignore
123
+ }
124
+ }
125
+
94
126
  /** Score a fuzzy match. Higher = better. Returns -1 if no match. */
95
127
  function fuzzyScore(query: string, nameLower: string, pathLower: string): number {
96
128
  // Exact filename match
@@ -178,7 +210,7 @@ export function searchFiles(
178
210
  return { results: scored.slice(0, maxResults), indexing: false };
179
211
  }
180
212
 
181
- /** Invalidate cache for a project root. */
213
+ /** Invalidate cache for a project root (watcher stays active). */
182
214
  export function invalidateCache(root: string): void {
183
215
  cache.delete(root);
184
216
  }
@@ -508,6 +508,37 @@ export function handleEvent(hookData: HookPayload): HandleEventResult | null {
508
508
  const session = matchSession(hookData, sessions, pendingResume, pidToSession, projectSessionCounters);
509
509
  if (!session) return null;
510
510
 
511
+ // When a session is re-keyed (replacesId is set), absorb any CONNECTING session for the
512
+ // same path that was created by workspace auto-load. After server restart:
513
+ // - Priority 0.5 re-keys the old ended session (keeping its stale terminalId)
514
+ // - Workspace auto-load created a fresh CONNECTING session for the same path
515
+ // - Without merging, the CONNECTING card persists as a duplicate
516
+ // By merging, the re-keyed session gets the fresh SSH terminal and the orphan is removed.
517
+ // Safe for Priority 1/2/3 re-keys too: those consume the CONNECTING session during re-key,
518
+ // so no orphan exists for the same path and the loop finds nothing to merge.
519
+ if (session.replacesId && cwd) {
520
+ const normalizedCwd = cwd.replace(/\/$/, '');
521
+ for (const [orphanKey, orphan] of sessions) {
522
+ if (
523
+ orphanKey !== session.sessionId &&
524
+ orphan.terminalId &&
525
+ orphan.status === SESSION_STATUS.CONNECTING &&
526
+ orphan.projectPath?.replace(/\/$/, '') === normalizedCwd
527
+ ) {
528
+ session.terminalId = orphan.terminalId;
529
+ if (orphan.opsTerminalId) session.opsTerminalId = orphan.opsTerminalId;
530
+ if (orphan.sshConfig) session.sshConfig = orphan.sshConfig;
531
+ if (orphan.sshHost) session.sshHost = orphan.sshHost;
532
+ if (orphan.sshCommand) session.sshCommand = orphan.sshCommand;
533
+ sessions.delete(orphanKey);
534
+ invalidateSessionsCache();
535
+ broadcastAsync({ type: WS_TYPES.SESSION_REMOVED, sessionId: orphanKey });
536
+ log.info('session', `Merged orphan CONNECTING terminal ${orphan.terminalId?.slice(0,8)} into re-keyed session ${session.sessionId?.slice(0,8)} (path=${normalizedCwd})`);
537
+ break;
538
+ }
539
+ }
540
+ }
541
+
511
542
  // Auto-revive sessions that were marked ended by ServerRestart but whose Claude process survived.
512
543
  // This happens when Claude runs in tmux/screen and keeps sending hooks after server restart.
513
544
  const REVIVABLE_EVENTS: Set<string> = new Set([
@@ -928,21 +959,24 @@ export async function createTerminalSession(terminalId: string, config: Terminal
928
959
 
929
960
  await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
930
961
 
931
- // Non-Claude CLIs (codex, gemini, etc.) don't send hooks — auto-transition to idle
962
+ // Auto-transition from connecting to idle if hooks don't arrive in time.
963
+ // Non-Claude CLIs: 3s (they never send hooks).
964
+ // Claude: 30s fallback — hooks should arrive via SessionStart, but if the
965
+ // session_id or path doesn't match (e.g. SSH remote path mismatch), the card
966
+ // would be stuck in connecting forever without this safety net.
932
967
  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
- }
968
+ const connectingTimeout = command.startsWith('claude') ? 30_000 : 3_000;
969
+ setTimeout(async () => {
970
+ const s = sessions.get(terminalId);
971
+ if (s && s.status === (SESSION_STATUS.CONNECTING as string)) {
972
+ s.status = SESSION_STATUS.IDLE;
973
+ s.animationState = ANIMATION_STATE.IDLE;
974
+ s.emote = null;
975
+ if (!command.startsWith('claude')) s.model = command;
976
+ await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...s } });
977
+ log.info('session', `Auto-transitioned session ${terminalId} to idle after connecting timeout (${command})`);
978
+ }
979
+ }, connectingTimeout);
946
980
 
947
981
  return session;
948
982
  }
@@ -7,7 +7,7 @@ import type { IPty, IDisposable } from 'node-pty';
7
7
  import { execFile, execSync } from 'child_process';
8
8
  import { readdirSync } from 'fs';
9
9
  import { join } from 'path';
10
- import { homedir, networkInterfaces } from 'os';
10
+ import { homedir, networkInterfaces, hostname as osHostname } from 'os';
11
11
  import log from './logger.js';
12
12
  import type { Terminal, TerminalConfig, TerminalInfo, TmuxSessionInfo, SshKeyInfo } from '../src/types/terminal.js';
13
13
  import type { PendingLink } from '../src/types/session.js';
@@ -181,6 +181,9 @@ function resolveWorkDir(dir: string | undefined): string {
181
181
  // even when the user accesses the dashboard via a LAN IP (e.g. 192.168.x.x).
182
182
  const localAddresses = new Set<string>(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
183
183
  try {
184
+ const h = osHostname();
185
+ localAddresses.add(h);
186
+ localAddresses.add(`${h}.local`); // macOS mDNS
184
187
  const ifaces = networkInterfaces();
185
188
  for (const addrs of Object.values(ifaces)) {
186
189
  if (!addrs) continue;
@@ -313,6 +316,44 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
313
316
 
314
317
  log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
315
318
 
319
+ // Auto-type SSH password when password auth is used.
320
+ // Watches PTY output for "password:" prompts and sends the stored password.
321
+ if (!local && config.password) {
322
+ const storedPassword = config.password;
323
+ let passwordBuffer = '';
324
+ let passwordAttempts = 0;
325
+ const maxAttempts = 2;
326
+ const passwordDisp = ptyProcess.onData((data: string) => {
327
+ passwordBuffer += data;
328
+ if (passwordBuffer.length > 4096) passwordBuffer = passwordBuffer.slice(-4096);
329
+ const stripped = passwordBuffer.replace(ANSI_ESC_RE, '').toLowerCase();
330
+ if (stripped.includes('password:') || stripped.includes('password for')) {
331
+ passwordAttempts++;
332
+ if (passwordAttempts > maxAttempts) {
333
+ log.error('pty', `SSH password rejected after ${maxAttempts} attempts for ${terminalId}`);
334
+ passwordDisp.dispose();
335
+ return;
336
+ }
337
+ // Small delay to ensure the SSH process is ready for input
338
+ setTimeout(() => {
339
+ const term = terminals.get(terminalId);
340
+ if (term?.pty) {
341
+ term.pty.write(storedPassword + '\r');
342
+ log.info('pty', `Auto-typed SSH password for ${terminalId} (attempt ${passwordAttempts})`);
343
+ }
344
+ }, 100);
345
+ // Reset buffer so we don't re-trigger on the same prompt
346
+ passwordBuffer = '';
347
+ }
348
+ // Stop watching after successful auth (shell prompt appears)
349
+ if (stripped.includes('last login') || SHELL_PROMPT_RE.test(stripped.split(/[\r\n]+/).filter(l => l.trim()).pop() || '')) {
350
+ passwordDisp.dispose();
351
+ }
352
+ });
353
+ // Safety cleanup — stop watching after 30s regardless
354
+ setTimeout(() => { passwordDisp.dispose(); }, 30000);
355
+ }
356
+
316
357
  // Detect when the shell is ready (prompt visible) before sending commands.
317
358
  // Local shells init in ~100-300ms; remote SSH can take seconds for key exchange.
318
359
  const shellReady = detectShellReady(ptyProcess, terminalId, local ? 2000 : 10000);
@@ -327,8 +368,12 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
327
368
  shellReady,
328
369
  });
329
370
 
330
- // Register pending link for session matching
331
- pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
371
+ // Register pending link for session matching.
372
+ // Skip for ops terminals (command='') they never run Claude so they
373
+ // must not overwrite the main terminal's pending link for the same workDir.
374
+ if (!skipAutoLaunch) {
375
+ pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
376
+ }
332
377
 
333
378
  // #19: Store disposables for proper cleanup on terminal close
334
379
  const disposables: IDisposable[] = [];
@@ -1 +0,0 @@
1
- import{z as o,k as s,A as e,P as r}from"./index-CCOZp8gY.js";function l(){const[t]=o(),a=t.get("path");if(!a)return s.jsx("div",{className:e.standalone,children:s.jsxs("div",{className:e.standaloneEmpty,children:["No project path specified. Use ",s.jsx("code",{children:"?path=/your/project"})," to open a project."]})});const n=a.split("/").filter(Boolean).pop()||a;return s.jsxs("div",{className:e.standalone,children:[s.jsxs("div",{className:e.standaloneHeader,children:[s.jsx("span",{className:e.standaloneTitle,children:n}),s.jsx("span",{className:e.standalonePath,children:a})]}),s.jsx("div",{className:e.standaloneContent,children:s.jsx(r,{projectPath:a})})]})}export{l as default};