codedash-app 1.7.0 → 2.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.
- package/package.json +1 -1
- package/src/data.js +166 -35
- package/src/frontend/app.js +77 -2
- package/src/frontend/index.html +5 -0
- package/src/frontend/styles.css +32 -7
- package/src/server.js +7 -1
package/package.json
CHANGED
package/src/data.js
CHANGED
|
@@ -437,65 +437,195 @@ function getSessionPreview(sessionId, project, limit) {
|
|
|
437
437
|
return messages;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
-
// ── Full-text search
|
|
440
|
+
// ── Full-text search index ─────────────────────────────────
|
|
441
|
+
//
|
|
442
|
+
// Built once on first search, then cached in memory.
|
|
443
|
+
// Each entry: { sessionId, texts: [{role, content}] }
|
|
444
|
+
// Total text is kept lowercase for fast substring matching.
|
|
441
445
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
+
let searchIndex = null;
|
|
447
|
+
let searchIndexBuiltAt = 0;
|
|
448
|
+
const INDEX_TTL = 60000; // rebuild every 60s
|
|
449
|
+
|
|
450
|
+
function buildSearchIndex(sessions) {
|
|
451
|
+
const startMs = Date.now();
|
|
452
|
+
const index = [];
|
|
446
453
|
|
|
447
454
|
for (const s of sessions) {
|
|
448
|
-
if (
|
|
455
|
+
if (!s.has_detail) continue;
|
|
449
456
|
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
if (!fs.existsSync(sessionFile)) continue;
|
|
457
|
+
const found = findSessionFile(s.id, s.project);
|
|
458
|
+
if (!found) continue;
|
|
453
459
|
|
|
454
460
|
try {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
if (data.toLowerCase().indexOf(q) === -1) continue;
|
|
461
|
+
const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
|
|
462
|
+
const texts = [];
|
|
458
463
|
|
|
459
|
-
// Find matching messages
|
|
460
|
-
const lines = data.split('\n').filter(Boolean);
|
|
461
|
-
const matches = [];
|
|
462
464
|
for (const line of lines) {
|
|
463
|
-
if (matches.length >= 3) break; // max 3 matches per session
|
|
464
465
|
try {
|
|
465
466
|
const entry = JSON.parse(line);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
467
|
+
let role, content;
|
|
468
|
+
|
|
469
|
+
if (found.format === 'claude') {
|
|
470
|
+
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
471
|
+
role = entry.type;
|
|
472
|
+
content = extractContent((entry.message || {}).content);
|
|
473
|
+
} else {
|
|
474
|
+
if (entry.type !== 'response_item' || !entry.payload) continue;
|
|
475
|
+
role = entry.payload.role;
|
|
476
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
477
|
+
content = extractContent(entry.payload.content);
|
|
474
478
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const start = Math.max(0, idx - 50);
|
|
479
|
-
const end = Math.min(content.length, idx + q.length + 50);
|
|
480
|
-
matches.push({
|
|
481
|
-
role: entry.type,
|
|
482
|
-
snippet: (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : ''),
|
|
483
|
-
});
|
|
479
|
+
|
|
480
|
+
if (content && !isSystemMessage(content)) {
|
|
481
|
+
texts.push({ role, content: content.slice(0, 500) });
|
|
484
482
|
}
|
|
485
483
|
} catch {}
|
|
486
484
|
}
|
|
487
485
|
|
|
488
|
-
if (
|
|
489
|
-
|
|
486
|
+
if (texts.length > 0) {
|
|
487
|
+
// Pre-compute lowercase full text for fast matching
|
|
488
|
+
const fullText = texts.map(t => t.content).join(' ').toLowerCase();
|
|
489
|
+
index.push({ sessionId: s.id, texts, fullText });
|
|
490
490
|
}
|
|
491
491
|
} catch {}
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
const elapsed = Date.now() - startMs;
|
|
495
|
+
console.log(` \x1b[2mSearch index: ${index.length} sessions, ${elapsed}ms\x1b[0m`);
|
|
496
|
+
return index;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function getSearchIndex(sessions) {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) {
|
|
502
|
+
searchIndex = buildSearchIndex(sessions);
|
|
503
|
+
searchIndexBuiltAt = now;
|
|
504
|
+
}
|
|
505
|
+
return searchIndex;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function searchFullText(query, sessions) {
|
|
509
|
+
if (!query || query.length < 2) return [];
|
|
510
|
+
const q = query.toLowerCase();
|
|
511
|
+
const index = getSearchIndex(sessions);
|
|
512
|
+
const results = [];
|
|
513
|
+
|
|
514
|
+
for (const entry of index) {
|
|
515
|
+
if (entry.fullText.indexOf(q) === -1) continue;
|
|
516
|
+
|
|
517
|
+
// Find matching messages with snippets
|
|
518
|
+
const matches = [];
|
|
519
|
+
for (const t of entry.texts) {
|
|
520
|
+
if (matches.length >= 3) break;
|
|
521
|
+
const idx = t.content.toLowerCase().indexOf(q);
|
|
522
|
+
if (idx >= 0) {
|
|
523
|
+
const start = Math.max(0, idx - 50);
|
|
524
|
+
const end = Math.min(t.content.length, idx + q.length + 50);
|
|
525
|
+
matches.push({
|
|
526
|
+
role: t.role,
|
|
527
|
+
snippet: (start > 0 ? '...' : '') + t.content.slice(start, end) + (end < t.content.length ? '...' : ''),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (matches.length > 0) {
|
|
533
|
+
results.push({ sessionId: entry.sessionId, matches });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
494
537
|
return results;
|
|
495
538
|
}
|
|
496
539
|
|
|
497
540
|
// ── Exports ────────────────────────────────────────────────
|
|
498
541
|
|
|
542
|
+
// ── Active sessions detection ─────────────────────────────
|
|
543
|
+
|
|
544
|
+
function getActiveSessions() {
|
|
545
|
+
const active = [];
|
|
546
|
+
const sessionsDir = path.join(CLAUDE_DIR, 'sessions');
|
|
547
|
+
|
|
548
|
+
// Read ~/.claude/sessions/<PID>.json files
|
|
549
|
+
if (fs.existsSync(sessionsDir)) {
|
|
550
|
+
for (const file of fs.readdirSync(sessionsDir)) {
|
|
551
|
+
if (!file.endsWith('.json')) continue;
|
|
552
|
+
try {
|
|
553
|
+
const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
|
|
554
|
+
const pid = data.pid;
|
|
555
|
+
if (!pid) continue;
|
|
556
|
+
|
|
557
|
+
// Check if process is alive + get CPU
|
|
558
|
+
try {
|
|
559
|
+
const psOut = execSync(`ps -p ${pid} -o pid=,%cpu=,rss=,stat= 2>/dev/null`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
560
|
+
if (!psOut) continue;
|
|
561
|
+
|
|
562
|
+
const parts = psOut.trim().split(/\s+/);
|
|
563
|
+
const cpu = parseFloat(parts[1]) || 0;
|
|
564
|
+
const rss = parseInt(parts[2]) || 0; // KB
|
|
565
|
+
const stat = parts[3] || '';
|
|
566
|
+
|
|
567
|
+
// Determine status
|
|
568
|
+
let status = 'active';
|
|
569
|
+
if (cpu < 1 && (stat.includes('S') || stat.includes('T'))) {
|
|
570
|
+
status = 'waiting'; // idle/sleeping — likely waiting for user input
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
active.push({
|
|
574
|
+
pid: pid,
|
|
575
|
+
sessionId: data.sessionId,
|
|
576
|
+
cwd: data.cwd || '',
|
|
577
|
+
startedAt: data.startedAt || 0,
|
|
578
|
+
kind: data.kind || 'interactive',
|
|
579
|
+
entrypoint: data.entrypoint || '',
|
|
580
|
+
status: status,
|
|
581
|
+
cpu: cpu,
|
|
582
|
+
memoryMB: Math.round(rss / 1024),
|
|
583
|
+
});
|
|
584
|
+
} catch {
|
|
585
|
+
// Process not found — stale file, skip
|
|
586
|
+
}
|
|
587
|
+
} catch {}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Also check Codex processes
|
|
592
|
+
try {
|
|
593
|
+
const codexPs = execSync('ps aux 2>/dev/null | grep "[c]odex" || true', { encoding: 'utf8', timeout: 2000 });
|
|
594
|
+
for (const line of codexPs.split('\n').filter(Boolean)) {
|
|
595
|
+
const parts = line.trim().split(/\s+/);
|
|
596
|
+
if (parts.length < 11) continue;
|
|
597
|
+
const pid = parseInt(parts[1]);
|
|
598
|
+
const cpu = parseFloat(parts[2]) || 0;
|
|
599
|
+
const rss = parseInt(parts[5]) || 0;
|
|
600
|
+
|
|
601
|
+
// Skip if already found via claude sessions
|
|
602
|
+
if (active.find(a => a.pid === pid)) continue;
|
|
603
|
+
|
|
604
|
+
// Try to get cwd
|
|
605
|
+
let cwd = '';
|
|
606
|
+
try {
|
|
607
|
+
const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
|
|
608
|
+
const match = lsofOut.match(/\nn(\/[^\n]+)/);
|
|
609
|
+
if (match) cwd = match[1];
|
|
610
|
+
} catch {}
|
|
611
|
+
|
|
612
|
+
active.push({
|
|
613
|
+
pid: pid,
|
|
614
|
+
sessionId: '',
|
|
615
|
+
cwd: cwd,
|
|
616
|
+
startedAt: 0,
|
|
617
|
+
kind: 'codex',
|
|
618
|
+
entrypoint: 'codex',
|
|
619
|
+
status: cpu < 1 ? 'waiting' : 'active',
|
|
620
|
+
cpu: cpu,
|
|
621
|
+
memoryMB: Math.round(rss / 1024),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
} catch {}
|
|
625
|
+
|
|
626
|
+
return active;
|
|
627
|
+
}
|
|
628
|
+
|
|
499
629
|
module.exports = {
|
|
500
630
|
loadSessions,
|
|
501
631
|
loadSessionDetail,
|
|
@@ -504,6 +634,7 @@ module.exports = {
|
|
|
504
634
|
exportSessionMarkdown,
|
|
505
635
|
getSessionPreview,
|
|
506
636
|
searchFullText,
|
|
637
|
+
getActiveSessions,
|
|
507
638
|
CLAUDE_DIR,
|
|
508
639
|
CODEX_DIR,
|
|
509
640
|
HISTORY_FILE,
|
package/src/frontend/app.js
CHANGED
|
@@ -18,6 +18,7 @@ let selectedIds = new Set();
|
|
|
18
18
|
let focusedIndex = -1;
|
|
19
19
|
let availableTerminals = [];
|
|
20
20
|
let pendingDelete = null;
|
|
21
|
+
let activeSessions = {}; // sessionId -> {status, cpu, memoryMB, pid}
|
|
21
22
|
|
|
22
23
|
// Persisted in localStorage
|
|
23
24
|
let stars = JSON.parse(localStorage.getItem('codedash-stars') || '[]');
|
|
@@ -104,10 +105,20 @@ function showTagDropdown(event, sessionId) {
|
|
|
104
105
|
document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); });
|
|
105
106
|
var dd = document.createElement('div');
|
|
106
107
|
dd.className = 'tag-dropdown';
|
|
108
|
+
var existingTags = tags[sessionId] || [];
|
|
107
109
|
dd.innerHTML = TAG_OPTIONS.map(function(t) {
|
|
108
|
-
|
|
110
|
+
var has = existingTags.indexOf(t) >= 0;
|
|
111
|
+
return '<div class="tag-dropdown-item" onclick="event.stopPropagation();' +
|
|
112
|
+
(has ? 'removeTag' : 'addTag') + '(\'' + sessionId + '\',\'' + t + '\')">' +
|
|
113
|
+
(has ? '✓ ' : '') + t + '</div>';
|
|
109
114
|
}).join('');
|
|
110
|
-
|
|
115
|
+
|
|
116
|
+
// Position near the button
|
|
117
|
+
var rect = event.target.getBoundingClientRect();
|
|
118
|
+
dd.style.top = (rect.bottom + 4) + 'px';
|
|
119
|
+
dd.style.left = rect.left + 'px';
|
|
120
|
+
|
|
121
|
+
document.body.appendChild(dd);
|
|
111
122
|
setTimeout(function() {
|
|
112
123
|
document.addEventListener('click', function() { dd.remove(); }, { once: true });
|
|
113
124
|
}, 0);
|
|
@@ -186,6 +197,45 @@ function saveTerminalPref(val) {
|
|
|
186
197
|
localStorage.setItem('codedash-terminal', val);
|
|
187
198
|
}
|
|
188
199
|
|
|
200
|
+
// ── Active sessions polling ───────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async function pollActiveSessions() {
|
|
203
|
+
try {
|
|
204
|
+
var resp = await fetch('/api/active');
|
|
205
|
+
var data = await resp.json();
|
|
206
|
+
activeSessions = {};
|
|
207
|
+
data.forEach(function(a) {
|
|
208
|
+
if (a.sessionId) {
|
|
209
|
+
activeSessions[a.sessionId] = a;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
// Update badges without full re-render
|
|
213
|
+
document.querySelectorAll('.card').forEach(function(card) {
|
|
214
|
+
var id = card.getAttribute('data-id');
|
|
215
|
+
var existing = card.querySelector('.live-badge');
|
|
216
|
+
if (existing) existing.remove();
|
|
217
|
+
if (activeSessions[id]) {
|
|
218
|
+
var a = activeSessions[id];
|
|
219
|
+
var badge = document.createElement('span');
|
|
220
|
+
badge.className = 'live-badge live-' + a.status;
|
|
221
|
+
badge.textContent = a.status === 'waiting' ? 'WAITING' : 'LIVE';
|
|
222
|
+
badge.title = 'PID ' + a.pid + ' | CPU ' + a.cpu.toFixed(1) + '% | ' + a.memoryMB + 'MB';
|
|
223
|
+
var top = card.querySelector('.card-top');
|
|
224
|
+
if (top) top.insertBefore(badge, top.firstChild);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
var activeInterval = null;
|
|
231
|
+
function startActivePolling() {
|
|
232
|
+
pollActiveSessions();
|
|
233
|
+
activeInterval = setInterval(pollActiveSessions, 5000);
|
|
234
|
+
}
|
|
235
|
+
function stopActivePolling() {
|
|
236
|
+
if (activeInterval) clearInterval(activeInterval);
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
// ── Trigram search ─────────────────────────────────────────────
|
|
190
240
|
|
|
191
241
|
function trigrams(str) {
|
|
@@ -1338,6 +1388,30 @@ document.addEventListener('keydown', function(e) {
|
|
|
1338
1388
|
}
|
|
1339
1389
|
});
|
|
1340
1390
|
|
|
1391
|
+
// ── Export/Import dialog ──────────────────────────────────────
|
|
1392
|
+
|
|
1393
|
+
function showExportDialog() {
|
|
1394
|
+
var overlay = document.getElementById('confirmOverlay');
|
|
1395
|
+
document.getElementById('confirmTitle').textContent = 'Export / Import Sessions';
|
|
1396
|
+
document.getElementById('confirmText').innerHTML =
|
|
1397
|
+
'<strong>Export</strong> all sessions to migrate to another PC:<br>' +
|
|
1398
|
+
'<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash export</code>' +
|
|
1399
|
+
'Creates a tar.gz with all Claude & Codex session data.<br><br>' +
|
|
1400
|
+
'<strong>Import</strong> on the new machine:<br>' +
|
|
1401
|
+
'<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash import <file.tar.gz></code>' +
|
|
1402
|
+
'<br><em style="color:var(--text-muted);font-size:12px">Don\'t forget to clone your git repos separately.</em>';
|
|
1403
|
+
document.getElementById('confirmId').textContent = '';
|
|
1404
|
+
document.getElementById('confirmAction').textContent = 'Copy Export Command';
|
|
1405
|
+
document.getElementById('confirmAction').className = 'launch-btn btn-primary';
|
|
1406
|
+
document.getElementById('confirmAction').onclick = function() {
|
|
1407
|
+
navigator.clipboard.writeText('codedash export').then(function() {
|
|
1408
|
+
showToast('Copied: codedash export');
|
|
1409
|
+
});
|
|
1410
|
+
closeConfirm();
|
|
1411
|
+
};
|
|
1412
|
+
if (overlay) overlay.style.display = 'flex';
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1341
1415
|
// ── Update check ──────────────────────────────────────────────
|
|
1342
1416
|
|
|
1343
1417
|
async function checkForUpdates() {
|
|
@@ -1392,6 +1466,7 @@ function dismissUpdate() {
|
|
|
1392
1466
|
loadTerminals();
|
|
1393
1467
|
checkForUpdates();
|
|
1394
1468
|
initHoverPreview();
|
|
1469
|
+
startActivePolling();
|
|
1395
1470
|
|
|
1396
1471
|
// Apply saved theme
|
|
1397
1472
|
var savedTheme = localStorage.getItem('codedash-theme') || 'dark';
|
package/src/frontend/index.html
CHANGED
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
45
45
|
Codex
|
|
46
46
|
</div>
|
|
47
|
+
<div class="sidebar-divider"></div>
|
|
48
|
+
<div class="sidebar-item" onclick="showExportDialog()">
|
|
49
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
|
50
|
+
Export / Import
|
|
51
|
+
</div>
|
|
47
52
|
<div class="sidebar-settings">
|
|
48
53
|
<label>Terminal</label>
|
|
49
54
|
<select id="terminalSelect" onchange="saveTerminalPref(this.value)">
|
package/src/frontend/styles.css
CHANGED
|
@@ -484,20 +484,15 @@ body {
|
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
.tag-dropdown {
|
|
487
|
-
position:
|
|
488
|
-
top: 100%;
|
|
489
|
-
left: 0;
|
|
490
|
-
margin-top: 4px;
|
|
487
|
+
position: fixed;
|
|
491
488
|
background: var(--bg-secondary);
|
|
492
489
|
border: 1px solid var(--border);
|
|
493
490
|
border-radius: 8px;
|
|
494
491
|
padding: 6px;
|
|
495
492
|
min-width: 140px;
|
|
496
493
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
497
|
-
z-index:
|
|
498
|
-
display: none;
|
|
494
|
+
z-index: 150;
|
|
499
495
|
}
|
|
500
|
-
.tag-dropdown.open { display: block; }
|
|
501
496
|
|
|
502
497
|
.tag-dropdown-item {
|
|
503
498
|
display: flex;
|
|
@@ -1390,6 +1385,36 @@ body {
|
|
|
1390
1385
|
color: #fff;
|
|
1391
1386
|
}
|
|
1392
1387
|
|
|
1388
|
+
/* ── Live session badges ────────────────────────────────────── */
|
|
1389
|
+
|
|
1390
|
+
.live-badge {
|
|
1391
|
+
font-size: 9px;
|
|
1392
|
+
font-weight: 700;
|
|
1393
|
+
letter-spacing: 0.5px;
|
|
1394
|
+
padding: 2px 7px;
|
|
1395
|
+
border-radius: 4px;
|
|
1396
|
+
text-transform: uppercase;
|
|
1397
|
+
animation: pulse 2s infinite;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
.live-active {
|
|
1401
|
+
background: rgba(74, 222, 128, 0.2);
|
|
1402
|
+
color: var(--accent-green);
|
|
1403
|
+
border: 1px solid rgba(74, 222, 128, 0.3);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
.live-waiting {
|
|
1407
|
+
background: rgba(251, 191, 36, 0.15);
|
|
1408
|
+
color: #fbbf24;
|
|
1409
|
+
border: 1px solid rgba(251, 191, 36, 0.25);
|
|
1410
|
+
animation: pulse 1.5s infinite;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
@keyframes pulse {
|
|
1414
|
+
0%, 100% { opacity: 1; }
|
|
1415
|
+
50% { opacity: 0.6; }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1393
1418
|
/* ── Card expand preview ────────────────────────────────────── */
|
|
1394
1419
|
|
|
1395
1420
|
.card-expand-btn {
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ const http = require('http');
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const { URL } = require('url');
|
|
5
5
|
const { exec } = require('child_process');
|
|
6
|
-
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText } = require('./data');
|
|
6
|
+
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions } = require('./data');
|
|
7
7
|
const { detectTerminals, openInTerminal } = require('./terminals');
|
|
8
8
|
const { getHTML } = require('./html');
|
|
9
9
|
|
|
@@ -104,6 +104,12 @@ function startServer(port, openBrowser = true) {
|
|
|
104
104
|
json(res, commits);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// ── Active sessions ─────────────────────
|
|
108
|
+
else if (req.method === 'GET' && pathname === '/api/active') {
|
|
109
|
+
const active = getActiveSessions();
|
|
110
|
+
json(res, active);
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
// ── Session preview ─────────────────────
|
|
108
114
|
else if (req.method === 'GET' && pathname.startsWith('/api/preview/')) {
|
|
109
115
|
const sessionId = pathname.split('/').pop();
|