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
@@ -10,6 +10,38 @@ import * as terminalManager from './terminalManager.js';
10
10
  import { escapeHtml, debugWarn } from './utils.js';
11
11
  import { STORAGE_KEYS, LABELS } from './constants.js';
12
12
 
13
+ /**
14
+ * After creating a terminal, open the detail panel on the Terminal tab.
15
+ * The session may not exist in the frontend map yet (WebSocket broadcast is async),
16
+ * so we poll briefly until the session appears, then select + attach.
17
+ */
18
+ function openTerminalPanel(terminalId) {
19
+ const maxAttempts = 20;
20
+ let attempt = 0;
21
+ const interval = setInterval(async () => {
22
+ attempt++;
23
+ const { selectSession } = await import('./detailPanel.js');
24
+ const { getSessionsData } = await import('./sessionPanel.js');
25
+ const sessions = getSessionsData();
26
+ const session = sessions.get(terminalId);
27
+ if (session || attempt >= maxAttempts) {
28
+ clearInterval(interval);
29
+ if (!session) return;
30
+ // Switch to terminal tab before selecting so initTerminal gets real dimensions
31
+ const tabBtn = document.querySelector('.detail-tabs .tab[data-tab="terminal"]');
32
+ if (tabBtn) {
33
+ document.querySelectorAll('.detail-tabs .tab').forEach(t => t.classList.remove('active'));
34
+ tabBtn.classList.add('active');
35
+ document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
36
+ const tabContent = document.getElementById('tab-terminal');
37
+ if (tabContent) tabContent.classList.add('active');
38
+ }
39
+ selectSession(terminalId);
40
+ terminalManager.attachToSession(terminalId, terminalId);
41
+ }
42
+ }, 100);
43
+ }
44
+
13
45
  // ---- Working Directory History ----
14
46
  function getWorkdirHistory() {
15
47
  try {
@@ -304,18 +336,13 @@ function initQuickSessionModal() {
304
336
  try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.LAST_SESSION) || '{}'); } catch { return {}; }
305
337
  })();
306
338
 
307
- if (!saved.username) {
308
- showToast('ERROR', 'No saved session config. Use "+ NEW SESSION" first.');
309
- return;
310
- }
311
-
312
339
  launchBtn.disabled = true;
313
340
  launchBtn.textContent = 'LAUNCHING...';
314
341
  try {
315
342
  const body = {
316
343
  host: saved.host || window.location.hostname || 'localhost',
317
344
  port: saved.port || 22,
318
- username: saved.username,
345
+ username: saved.username || undefined,
319
346
  authMethod: saved.authMethod || 'key',
320
347
  privateKeyPath: saved.privateKeyPath,
321
348
  workingDir: workingDir,
@@ -360,6 +387,7 @@ function initQuickSessionModal() {
360
387
  } else {
361
388
  showToast('CONNECTED', 'Quick session launched');
362
389
  }
390
+ openTerminalPanel(result.terminalId);
363
391
  } catch (e) {
364
392
  showToast('ERROR', e.message);
365
393
  } finally {
@@ -486,19 +514,7 @@ function initNewSessionModal() {
486
514
  label: labelVal,
487
515
  };
488
516
 
489
- if (currentSshMode === 'tmux-attach') {
490
- body.tmuxSession = selectedTmuxSession;
491
- } else if (currentSshMode === 'tmux-wrap') {
492
- body.useTmux = true;
493
- }
494
- const resp = await fetch('/api/terminals', {
495
- method: 'POST',
496
- headers: { 'Content-Type': 'application/json' },
497
- body: JSON.stringify(body),
498
- });
499
- const result = await resp.json();
500
- if (!resp.ok) throw new Error(result.error || 'Connection failed');
501
-
517
+ // Save config before connecting so quick buttons can use it even if connection fails
502
518
  try {
503
519
  localStorage.setItem(STORAGE_KEYS.LAST_SESSION, JSON.stringify({
504
520
  host: body.host,
@@ -512,6 +528,19 @@ function initNewSessionModal() {
512
528
  }));
513
529
  } catch (_) {}
514
530
 
531
+ if (currentSshMode === 'tmux-attach') {
532
+ body.tmuxSession = selectedTmuxSession;
533
+ } else if (currentSshMode === 'tmux-wrap') {
534
+ body.useTmux = true;
535
+ }
536
+ const resp = await fetch('/api/terminals', {
537
+ method: 'POST',
538
+ headers: { 'Content-Type': 'application/json' },
539
+ body: JSON.stringify(body),
540
+ });
541
+ const result = await resp.json();
542
+ if (!resp.ok) throw new Error(result.error || 'Connection failed');
543
+
515
544
  if (labelVal) saveLabel(labelVal);
516
545
  saveWorkdir(body.workingDir);
517
546
 
@@ -520,6 +549,7 @@ function initNewSessionModal() {
520
549
 
521
550
  const theme = document.getElementById('ssh-terminal-theme')?.value || getDefaultTerminalTheme();
522
551
  terminalManager.setTerminalTheme(result.terminalId, theme);
552
+ openTerminalPanel(result.terminalId);
523
553
  } catch (e) {
524
554
  showToast('ERROR', e.message);
525
555
  } finally {
@@ -529,6 +559,87 @@ function initNewSessionModal() {
529
559
  });
530
560
  }
531
561
 
562
+ async function directLaunchSession(label) {
563
+ // Read from New Session modal form fields first (user may have filled them in)
564
+ const formHost = document.getElementById('ssh-host')?.value?.trim();
565
+ const formUsername = document.getElementById('ssh-username')?.value?.trim();
566
+ const formPort = document.getElementById('ssh-port')?.value;
567
+ const formAuthMethod = document.getElementById('ssh-auth-method')?.value;
568
+ const formKeyPath = document.getElementById('ssh-key-select')?.value;
569
+ const formWorkDir = document.getElementById('ssh-workdir')?.value?.trim();
570
+ const formTheme = document.getElementById('ssh-terminal-theme')?.value;
571
+ const formCommand = (() => {
572
+ const preset = document.getElementById('ssh-command-preset')?.value;
573
+ if (preset === 'custom') return document.getElementById('ssh-custom-command')?.value || 'claude';
574
+ return preset;
575
+ })();
576
+
577
+ // Fall back to saved config from localStorage
578
+ const saved = (() => {
579
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.LAST_SESSION) || '{}'); } catch { return {}; }
580
+ })();
581
+
582
+ const username = formUsername || saved.username || undefined;
583
+
584
+ const body = {
585
+ host: formHost || saved.host || window.location.hostname || 'localhost',
586
+ port: parseInt(formPort || saved.port) || 22,
587
+ username,
588
+ authMethod: formAuthMethod || saved.authMethod || 'key',
589
+ privateKeyPath: formKeyPath || saved.privateKeyPath,
590
+ workingDir: formWorkDir || saved.workingDir || '~',
591
+ command: formCommand || saved.command || 'claude',
592
+ terminalTheme: formTheme || saved.terminalTheme || getDefaultTerminalTheme(),
593
+ label: label || undefined,
594
+ };
595
+
596
+ const globalKey = getApiKeyForCommand(body.command);
597
+ if (globalKey) body.apiKey = globalKey;
598
+
599
+ try {
600
+ const resp = await fetch('/api/terminals', {
601
+ method: 'POST',
602
+ headers: { 'Content-Type': 'application/json' },
603
+ body: JSON.stringify(body),
604
+ });
605
+ const result = await resp.json();
606
+ if (!resp.ok) throw new Error(result.error || 'Connection failed');
607
+
608
+ // Save config for future use
609
+ try {
610
+ localStorage.setItem(STORAGE_KEYS.LAST_SESSION, JSON.stringify({
611
+ host: body.host,
612
+ port: body.port,
613
+ username: body.username,
614
+ authMethod: body.authMethod,
615
+ privateKeyPath: body.privateKeyPath,
616
+ workingDir: body.workingDir,
617
+ command: body.command,
618
+ terminalTheme: body.terminalTheme,
619
+ }));
620
+ } catch (_) {}
621
+
622
+ if (label) saveLabel(label);
623
+
624
+ terminalManager.setTerminalTheme(result.terminalId, body.terminalTheme);
625
+
626
+ if (label === LABELS.HEAVY) {
627
+ setTimeout(() => pinSession(result.terminalId), 500);
628
+ showToast('HEAVY SESSION', 'High-priority session launched & pinned');
629
+ } else if (label === LABELS.IMPORTANT) {
630
+ setTimeout(() => pinSession(result.terminalId), 500);
631
+ showToast('IMPORTANT SESSION', 'Important session launched & pinned');
632
+ } else if (label === LABELS.ONEOFF) {
633
+ showToast('ONEOFF SESSION', 'One-off session launched');
634
+ } else {
635
+ showToast('CONNECTED', 'Quick session launched');
636
+ }
637
+ openTerminalPanel(result.terminalId);
638
+ } catch (e) {
639
+ showToast('ERROR', e.message);
640
+ }
641
+ }
642
+
532
643
  function openQuickModalWithLabel(label) {
533
644
  const modal = document.getElementById('quick-session-modal');
534
645
  modal.classList.remove('hidden');
@@ -586,25 +697,7 @@ export function initQuickActions() {
586
697
  // QUICK SESSION button
587
698
  const quickBtn = document.getElementById('qa-quick-session');
588
699
  if (quickBtn) {
589
- quickBtn.addEventListener('click', () => openQuickModalWithLabel(''));
590
- }
591
-
592
- // ONEOFF button
593
- const oneoffBtn = document.getElementById('qa-oneoff');
594
- if (oneoffBtn) {
595
- oneoffBtn.addEventListener('click', () => openQuickModalWithLabel(LABELS.ONEOFF));
596
- }
597
-
598
- // HEAVY button
599
- const heavyBtn = document.getElementById('qa-heavy');
600
- if (heavyBtn) {
601
- heavyBtn.addEventListener('click', () => openQuickModalWithLabel(LABELS.HEAVY));
602
- }
603
-
604
- // IMPORTANT button
605
- const importantBtn = document.getElementById('qa-important');
606
- if (importantBtn) {
607
- importantBtn.addEventListener('click', () => openQuickModalWithLabel(LABELS.IMPORTANT));
700
+ quickBtn.addEventListener('click', () => directLaunchSession(''));
608
701
  }
609
702
 
610
703
  // Quick Session modal buttons
@@ -661,16 +754,7 @@ export function initQuickActions() {
661
754
  openNewSessionModal();
662
755
  break;
663
756
  case 'quick-session':
664
- openQuickModalWithLabel('');
665
- break;
666
- case 'oneoff':
667
- openQuickModalWithLabel(LABELS.ONEOFF);
668
- break;
669
- case 'heavy':
670
- openQuickModalWithLabel(LABELS.HEAVY);
671
- break;
672
- case 'important':
673
- openQuickModalWithLabel(LABELS.IMPORTANT);
757
+ directLaunchSession('');
674
758
  break;
675
759
  case 'new-group':
676
760
  // Trigger new group creation
@@ -278,13 +278,11 @@ function _applyCardUpdate(session) {
278
278
  if (sess && sess.terminalId) {
279
279
  fetch(`/api/terminals/${sess.terminalId}`, { method: 'DELETE' }).catch(() => {});
280
280
  }
281
- db.get('sessions', sid).then(record => {
282
- if (record && record.status !== 'ended') {
283
- record.status = 'ended';
284
- record.endedAt = record.endedAt || Date.now();
285
- db.put('sessions', record);
286
- }
287
- }).catch(() => {});
281
+ // Delete from IndexedDB immediately — don't race with the server's
282
+ // session_removed broadcast which also calls del('sessions', sid).
283
+ // Previously this did a get→put (mark as ended) which could re-create
284
+ // the record AFTER the session_removed handler already deleted it.
285
+ db.del('sessions', sid).catch(() => {});
288
286
  fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).catch(() => {});
289
287
  mutedSessions.delete(sid);
290
288
  saveMuted(mutedSessions);
@@ -595,19 +593,6 @@ function _applyCardUpdate(session) {
595
593
  const isImportant = (session.label || '').toUpperCase() === 'IMPORTANT';
596
594
  card.classList.toggle('important-session', isImportant);
597
595
 
598
- const labelUpper = (session.label || '').toUpperCase();
599
- if (labelUpper === 'ONEOFF' || labelUpper === 'HEAVY' || labelUpper === 'IMPORTANT') {
600
- const labelCfg = settingsManager.getLabelSettings();
601
- const frameName = labelCfg[labelUpper]?.frame || 'none';
602
- if (frameName && frameName !== 'none') {
603
- card.dataset.frame = frameName;
604
- } else {
605
- delete card.dataset.frame;
606
- }
607
- } else {
608
- delete card.dataset.frame;
609
- }
610
-
611
596
  const sourceBadge = card.querySelector('.source-badge');
612
597
  if (sourceBadge) {
613
598
  const src = session.source || 'ssh';
@@ -710,6 +710,14 @@ export function initControlHandlers() {
710
710
  detailTitleInput.blur();
711
711
  }
712
712
  });
713
+
714
+ const titleEditBtn = document.getElementById('detail-title-edit-btn');
715
+ if (titleEditBtn) {
716
+ titleEditBtn.addEventListener('click', () => {
717
+ detailTitleInput.focus();
718
+ detailTitleInput.select();
719
+ });
720
+ }
713
721
  }
714
722
 
715
723
  // Session Label Save (blur/Enter)
@@ -19,11 +19,6 @@ const defaults = {
19
19
  movementActions: '',
20
20
  autoSendQueue: 'false',
21
21
  hookDensity: 'off',
22
- labelSettings: JSON.stringify({
23
- ONEOFF: { sound: 'alarm', movement: 'shake', frame: 'none' },
24
- HEAVY: { sound: 'urgentAlarm', movement: 'flash', frame: 'electric' },
25
- IMPORTANT: { sound: 'fanfare', movement: 'bounce', frame: 'liquid' },
26
- })
27
22
  };
28
23
 
29
24
  let settings = { ...defaults };
@@ -561,9 +556,6 @@ export function initSettingsUI() {
561
556
  // Build per-action movement effect grid
562
557
  initMovementGrid();
563
558
 
564
- // Build label completion alerts grid
565
- initLabelGrid();
566
-
567
559
  // Build summary prompt template management
568
560
  initSummaryPromptSettings();
569
561
  }
@@ -822,138 +814,4 @@ async function initSummaryPromptSettings() {
822
814
  loadAndRender();
823
815
  }
824
816
 
825
- // ---- Label Settings (per-label completion alerts) ----
826
-
827
- export function getLabelSettings() {
828
- const raw = get('labelSettings');
829
- try {
830
- return JSON.parse(raw);
831
- } catch {
832
- return JSON.parse(defaults.labelSettings);
833
- }
834
- }
835
-
836
- export async function setLabelSetting(label, field, value) {
837
- const current = getLabelSettings();
838
- if (!current[label]) current[label] = { sound: 'none', movement: 'none' };
839
- current[label] = { ...current[label], [field]: value };
840
- await set('labelSettings', JSON.stringify(current));
841
- }
842
-
843
- // Frame effect library for label cards
844
- const FRAME_EFFECTS = {
845
- none: 'None',
846
- fire: 'Burning Fire',
847
- electric: 'Electric Current',
848
- chains: 'Golden Chains',
849
- liquid: 'Liquid Flow',
850
- plasma: 'Plasma Ring',
851
- };
852
-
853
- export function getFrameEffects() {
854
- return { ...FRAME_EFFECTS };
855
- }
856
-
857
- async function initLabelGrid() {
858
- const container = document.getElementById('label-settings-grid');
859
- if (!container) return;
860
-
861
- const soundManager = await import('./soundManager.js');
862
- const movementManager = await import('./movementManager.js');
863
-
864
- const sounds = soundManager.getSoundLibrary(); // string[]
865
- const effects = movementManager.getEffectLibrary(); // { key: label }
866
- const labelConfig = getLabelSettings();
867
-
868
- const LABELS = ['ONEOFF', 'HEAVY', 'IMPORTANT'];
869
- const LABEL_COLORS = { ONEOFF: '#ff9100', HEAVY: '#ff3355', IMPORTANT: '#aa66ff' };
870
- const LABEL_ICONS = { ONEOFF: '🔥', HEAVY: '★', IMPORTANT: '⚠' };
871
-
872
- let html = '';
873
- for (const label of LABELS) {
874
- const cfg = labelConfig[label] || { sound: 'none', movement: 'none', frame: 'none' };
875
- const color = LABEL_COLORS[label];
876
-
877
- const soundOpts = sounds.map(s =>
878
- `<option value="${s}"${s === cfg.sound ? ' selected' : ''}>${s}</option>`
879
- ).join('');
880
-
881
- const effectOpts = Object.entries(effects).map(([key, name]) =>
882
- `<option value="${key}"${key === cfg.movement ? ' selected' : ''}>${name}</option>`
883
- ).join('');
884
-
885
- const frameOpts = Object.entries(FRAME_EFFECTS).map(([key, name]) =>
886
- `<option value="${key}"${key === (cfg.frame || 'none') ? ' selected' : ''}>${name}</option>`
887
- ).join('');
888
-
889
- html += `
890
- <div class="label-config-card" style="--label-color: ${color}" data-frame="${cfg.frame || 'none'}">
891
- <div class="label-config-header">
892
- <span class="label-config-icon">${LABEL_ICONS[label]}</span>
893
- <span class="label-config-name">${label}</span>
894
- </div>
895
- <div class="label-config-row">
896
- <span class="label-config-field">Card Frame</span>
897
- <select class="label-config-select" data-label="${label}" data-field="frame">${frameOpts}</select>
898
- </div>
899
- <div class="label-config-row">
900
- <span class="label-config-field">Sound</span>
901
- <select class="label-config-select" data-label="${label}" data-field="sound">${soundOpts}</select>
902
- <button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="sound" title="Preview">&#9654;</button>
903
- </div>
904
- <div class="label-config-row">
905
- <span class="label-config-field">Movement</span>
906
- <select class="label-config-select" data-label="${label}" data-field="movement">${effectOpts}</select>
907
- <button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="movement" title="Preview">&#9654;</button>
908
- </div>
909
- </div>`;
910
- }
911
- container.innerHTML = html;
912
-
913
- // Change handler
914
- container.addEventListener('change', (e) => {
915
- const sel = e.target.closest('.label-config-select');
916
- if (!sel) return;
917
- const field = sel.dataset.field;
918
- const label = sel.dataset.label;
919
- setLabelSetting(label, field, sel.value);
920
-
921
- // Live-preview frame effect on the config card itself
922
- if (field === 'frame') {
923
- const configCard = sel.closest('.label-config-card');
924
- if (configCard) configCard.dataset.frame = sel.value;
925
- // Also update any live session cards with this label
926
- document.querySelectorAll(`.session-card.${label.toLowerCase()}-session`).forEach(card => {
927
- if (sel.value && sel.value !== 'none') {
928
- card.dataset.frame = sel.value;
929
- } else {
930
- delete card.dataset.frame;
931
- }
932
- });
933
- }
934
- });
935
-
936
- // Preview handler
937
- container.addEventListener('click', (e) => {
938
- const btn = e.target.closest('.label-preview-btn');
939
- if (!btn) return;
940
- const label = btn.dataset.label;
941
- const field = btn.dataset.field;
942
- const sel = container.querySelector(`.label-config-select[data-label="${label}"][data-field="${field}"]`);
943
- if (!sel) return;
944
- if (field === 'sound') {
945
- soundManager.previewSound(sel.value);
946
- } else {
947
- // Preview movement on the first session card character
948
- const card = document.querySelector('.session-card .css-robot');
949
- if (card && sel.value !== 'none') {
950
- card.removeAttribute('data-movement');
951
- void card.offsetWidth;
952
- card.setAttribute('data-movement', sel.value);
953
- setTimeout(() => card.removeAttribute('data-movement'), 3500);
954
- }
955
- }
956
- });
957
- }
958
-
959
817
  const escapeHtml = _escapeHtml;
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  // apiRouter.js — Express router for all API endpoints
3
3
  import { Router } from 'express';
4
- import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSummary, getSession, detectSessionSource, createTerminalSession, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal } from './sessionStore.js';
5
- import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions, writeToTerminal, attachToTmuxPane } from './sshManager.js';
4
+ import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionAccentColor, setSummary, getSession, detectSessionSource, createTerminalSession, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal } from './sessionStore.js';
5
+ import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions, writeToTerminal, writeWhenReady, attachToTmuxPane, consumePendingLink } from './sshManager.js';
6
6
  import { getTeam, readTeamConfig } from './teamManager.js';
7
7
  import { getStats as getHookStats, resetStats as resetHookStats } from './hookStats.js';
8
8
  import * as db from './db.js';
@@ -10,7 +10,7 @@ import { getMqStats } from './mqReader.js';
10
10
  import { execFile } from 'child_process';
11
11
  import { readFileSync, writeFileSync } from 'fs';
12
12
  import { join, dirname } from 'path';
13
- import { homedir } from 'os';
13
+ import { homedir, userInfo } from 'os';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
16
16
  import log from './logger.js';
@@ -19,6 +19,23 @@ const __apiDirname = dirname(fileURLToPath(import.meta.url));
19
19
 
20
20
  const router = Router();
21
21
 
22
+ // ---- Last-used Username Persistence ----
23
+
24
+ let _lastUsedUsername = null;
25
+
26
+ function getDefaultUsername() {
27
+ if (_lastUsedUsername) return _lastUsedUsername;
28
+ try { return userInfo().username; } catch { return null; }
29
+ }
30
+
31
+ function saveLastUsername(username) {
32
+ if (username) _lastUsedUsername = username;
33
+ }
34
+
35
+ function isLocalHost(host) {
36
+ return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
37
+ }
38
+
22
39
  // ---- Input Validation Helpers ----
23
40
 
24
41
  const SHELL_META_RE = /[;|&$`\\!><()\n\r{}[\]]/;
@@ -245,17 +262,26 @@ router.post('/sessions/:id/resume', async (req, res) => {
245
262
  : { host: 'localhost', workingDir: session.projectPath || '~', command: '' };
246
263
  const newTerminalId = await createTerminal(newConfig, null);
247
264
 
265
+ // Immediately consume the pendingLink that createTerminal registered.
266
+ // The resume flow uses pendingResume (not pendingLinks) for session matching.
267
+ // If we leave the pendingLink alive, ANY other Claude session in the same
268
+ // working directory could match it via Priority 2 (tryLinkByWorkDir),
269
+ // stealing the terminal and creating a duplicate card.
270
+ consumePendingLink(newConfig.workingDir || session.projectPath || '');
271
+
248
272
  // Update the REAL session and register pendingResume (no duplicate session)
249
273
  const result = reconnectSessionTerminal(sessionId, newTerminalId);
250
274
  if (result.error) return res.status(500).json({ error: result.error });
251
275
 
252
- // Write the resume command after the shell has initialized.
253
- // For remote sessions, cd to workDir first.
254
- const delay = isRemote ? 600 : 200;
255
- setTimeout(() => {
256
- const prefix = isRemote && cfg.workingDir ? `cd '${cfg.workingDir}' && ` : '';
257
- writeToTerminal(newTerminalId, `${prefix}${resumeCmd}\r`);
258
- }, delay);
276
+ // Write the resume command once the shell is ready (prompt detected).
277
+ // For remote sessions, export AGENT_MANAGER_TERMINAL_ID (SSH doesn't
278
+ // forward env vars) and cd to workDir first.
279
+ let prefix = '';
280
+ if (isRemote) {
281
+ prefix += `export AGENT_MANAGER_TERMINAL_ID='${newTerminalId}' && `;
282
+ if (cfg.workingDir) prefix += `cd '${cfg.workingDir}' && `;
283
+ }
284
+ writeWhenReady(newTerminalId, `${prefix}${resumeCmd}\r`);
259
285
 
260
286
  const { broadcast } = await import('./wsManager.js');
261
287
  broadcast({ type: WS_TYPES.SESSION_UPDATE, session: result.session });
@@ -353,6 +379,16 @@ router.put('/sessions/:id/label', (req, res) => {
353
379
  res.json({ ok: true });
354
380
  });
355
381
 
382
+ // Update session accent color
383
+ router.put('/sessions/:id/accent-color', (req, res) => {
384
+ const { color } = req.body;
385
+ if (!color || typeof color !== 'string' || color.length > 50) {
386
+ return res.status(400).json({ error: 'color must be a string (max 50 chars)' });
387
+ }
388
+ setSessionAccentColor(req.params.id, color);
389
+ res.json({ ok: true });
390
+ });
391
+
356
392
  /**
357
393
  * Summarize session using Claude CLI.
358
394
  * The frontend sends { context, promptTemplate } from IndexedDB data.
@@ -429,10 +465,12 @@ router.get('/ssh-keys', (req, res) => {
429
465
 
430
466
  router.post('/tmux-sessions', async (req, res) => {
431
467
  try {
432
- const { host, port, username, password, privateKeyPath, authMethod, passphrase } = req.body;
468
+ const { host, port, username: rawUsername, password, privateKeyPath, authMethod, passphrase } = req.body;
469
+ const resolvedHost = host || 'localhost';
470
+ const username = rawUsername || getDefaultUsername() || (isLocalHost(resolvedHost) ? 'local' : null);
433
471
  if (!username) return res.status(400).json({ error: 'username required' });
434
472
  const config = {
435
- host: host || 'localhost',
473
+ host: resolvedHost,
436
474
  port: port || 22,
437
475
  username,
438
476
  authMethod: authMethod || 'key',
@@ -458,10 +496,14 @@ router.post('/terminals', async (req, res) => {
458
496
  }
459
497
 
460
498
  try {
461
- const { host, port, username, password, privateKeyPath, authMethod, workingDir, command, apiKey, tmuxSession, useTmux, sessionTitle, label } = req.body;
499
+ const { host, port, username: rawUsername, password, privateKeyPath, authMethod, workingDir, command, apiKey, tmuxSession, useTmux, sessionTitle, label } = req.body;
500
+
501
+ // Resolve username: provided > last-used > OS user (local only)
502
+ const resolvedHost = host || 'localhost';
503
+ const username = rawUsername || getDefaultUsername() || (isLocalHost(resolvedHost) ? 'local' : null);
462
504
 
463
505
  // Input validation
464
- if (!username) return res.status(400).json({ success: false, error: 'username required' });
506
+ if (!username) return res.status(400).json({ success: false, error: 'username required — set it once in "+ NEW SESSION" and it will be reused' });
465
507
  if (!isValidUsername(username)) {
466
508
  return res.status(400).json({ success: false, error: 'username contains invalid characters' });
467
509
  }
@@ -484,8 +526,11 @@ router.post('/terminals', async (req, res) => {
484
526
  return res.status(400).json({ success: false, error: 'sessionTitle must be a string (max 500 chars)' });
485
527
  }
486
528
 
529
+ // Remember this username for future sessions
530
+ saveLastUsername(username);
531
+
487
532
  const config = {
488
- host: host || 'localhost',
533
+ host: resolvedHost,
489
534
  port: port || 22,
490
535
  username,
491
536
  authMethod: authMethod || 'key',