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
|
@@ -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
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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
|
package/public/js/sessionCard.js
CHANGED
|
@@ -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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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">▶</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">▶</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;
|
package/server/apiRouter.js
CHANGED
|
@@ -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
|
|
253
|
-
// For remote sessions,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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:
|
|
533
|
+
host: resolvedHost,
|
|
489
534
|
port: port || 22,
|
|
490
535
|
username,
|
|
491
536
|
authMethod: authMethod || 'key',
|