@jhizzard/termdeck 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1432,6 +1432,343 @@
1432
1432
  border-color: var(--tg-accent);
1433
1433
  }
1434
1434
 
1435
+ /* ===== HEALTH BADGE (Sprint 6 T4) ===== */
1436
+ .health-badge {
1437
+ display: inline-flex;
1438
+ align-items: center;
1439
+ gap: 5px;
1440
+ font-family: var(--tg-mono);
1441
+ font-size: 11px;
1442
+ padding: 2px 8px;
1443
+ border-radius: 10px;
1444
+ border: 1px solid var(--tg-border);
1445
+ background: var(--tg-surface);
1446
+ color: var(--tg-text-dim);
1447
+ cursor: pointer;
1448
+ transition: all 0.15s;
1449
+ }
1450
+ .health-badge:hover {
1451
+ color: var(--tg-text);
1452
+ border-color: var(--tg-border-active);
1453
+ }
1454
+ .health-badge .hb-icon { font-size: 12px; line-height: 1; }
1455
+ .health-badge.hb-green {
1456
+ color: var(--tg-green);
1457
+ border-color: rgba(158, 206, 106, 0.3);
1458
+ }
1459
+ .health-badge.hb-amber {
1460
+ color: var(--tg-amber);
1461
+ border-color: var(--tg-amber);
1462
+ background: rgba(224, 175, 104, 0.08);
1463
+ animation: health-pulse 2s ease-in-out infinite;
1464
+ }
1465
+ .health-badge.hb-red {
1466
+ color: var(--tg-red);
1467
+ border-color: var(--tg-red);
1468
+ background: rgba(247, 118, 142, 0.08);
1469
+ animation: health-pulse 2s ease-in-out infinite;
1470
+ }
1471
+ @keyframes health-pulse {
1472
+ 0%, 100% { opacity: 1; }
1473
+ 50% { opacity: 0.6; }
1474
+ }
1475
+
1476
+ /* Health dropdown */
1477
+ .health-dropdown {
1478
+ display: none;
1479
+ position: fixed;
1480
+ z-index: 3100;
1481
+ background: var(--tg-surface);
1482
+ border: 1px solid var(--tg-border);
1483
+ border-radius: var(--tg-radius);
1484
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
1485
+ padding: 10px 14px;
1486
+ min-width: 300px;
1487
+ max-width: 420px;
1488
+ font-family: var(--tg-mono);
1489
+ font-size: 11px;
1490
+ }
1491
+ .health-dropdown.open { display: block; }
1492
+ .hd-check {
1493
+ display: grid;
1494
+ grid-template-columns: 16px 1fr auto auto;
1495
+ gap: 6px;
1496
+ align-items: baseline;
1497
+ padding: 5px 0;
1498
+ border-bottom: 1px solid var(--tg-border);
1499
+ }
1500
+ .hd-check:last-child { border-bottom: none; }
1501
+ .hd-icon { text-align: center; font-weight: 700; }
1502
+ .hd-ok .hd-icon { color: var(--tg-green); }
1503
+ .hd-fail .hd-icon { color: var(--tg-red); }
1504
+ .hd-name { color: var(--tg-text); }
1505
+ .hd-dots {
1506
+ border-bottom: 1px dotted var(--tg-border);
1507
+ min-width: 20px;
1508
+ align-self: end;
1509
+ margin-bottom: 3px;
1510
+ }
1511
+ .hd-status { font-weight: 600; }
1512
+ .hd-ok .hd-status { color: var(--tg-green); }
1513
+ .hd-fail .hd-status { color: var(--tg-red); }
1514
+ .hd-detail {
1515
+ grid-column: 2 / -1;
1516
+ color: var(--tg-text-dim);
1517
+ font-size: 10px;
1518
+ }
1519
+ .hd-detail:empty { display: none; }
1520
+ .hd-remediation {
1521
+ grid-column: 2 / -1;
1522
+ color: var(--tg-amber);
1523
+ font-size: 10px;
1524
+ padding: 2px 6px;
1525
+ background: rgba(224, 175, 104, 0.08);
1526
+ border-radius: 3px;
1527
+ margin-top: 2px;
1528
+ }
1529
+ .hd-loading, .hd-empty {
1530
+ color: var(--tg-text-dim);
1531
+ padding: 8px 0;
1532
+ text-align: center;
1533
+ }
1534
+
1535
+ /* ===== TRANSCRIPT MODAL (Sprint 6 T4) ===== */
1536
+ .transcript-modal {
1537
+ display: none;
1538
+ position: fixed;
1539
+ inset: 0;
1540
+ z-index: 3000;
1541
+ align-items: center;
1542
+ justify-content: center;
1543
+ }
1544
+ .transcript-modal.open { display: flex; }
1545
+ .transcript-backdrop {
1546
+ position: absolute;
1547
+ inset: 0;
1548
+ background: rgba(0, 0, 0, 0.72);
1549
+ }
1550
+ .transcript-card {
1551
+ position: relative;
1552
+ background: var(--tg-surface);
1553
+ border: 1px solid var(--tg-accent-dim);
1554
+ border-radius: 10px;
1555
+ width: 800px;
1556
+ max-width: calc(100vw - 40px);
1557
+ max-height: calc(100vh - 80px);
1558
+ display: flex;
1559
+ flex-direction: column;
1560
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
1561
+ font-family: var(--tg-sans);
1562
+ color: var(--tg-text);
1563
+ }
1564
+ .transcript-card header {
1565
+ padding: 18px 22px 10px;
1566
+ border-bottom: 1px solid var(--tg-border);
1567
+ display: flex;
1568
+ align-items: center;
1569
+ justify-content: space-between;
1570
+ }
1571
+ .transcript-card header h3 {
1572
+ margin: 0;
1573
+ font-size: 15px;
1574
+ color: var(--tg-accent);
1575
+ }
1576
+ .transcript-tabs {
1577
+ display: flex;
1578
+ gap: 4px;
1579
+ }
1580
+ .transcript-tab {
1581
+ background: none;
1582
+ border: 1px solid var(--tg-border);
1583
+ color: var(--tg-text-dim);
1584
+ font-size: 11px;
1585
+ padding: 4px 12px;
1586
+ border-radius: 3px;
1587
+ cursor: pointer;
1588
+ font-family: var(--tg-sans);
1589
+ transition: all 0.1s;
1590
+ }
1591
+ .transcript-tab:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
1592
+ .transcript-tab.active {
1593
+ color: var(--tg-accent);
1594
+ border-color: var(--tg-accent-dim);
1595
+ background: var(--tg-bg);
1596
+ }
1597
+ .transcript-search-bar {
1598
+ padding: 10px 22px;
1599
+ border-bottom: 1px solid var(--tg-border);
1600
+ }
1601
+ .transcript-search-bar .ctrl-input {
1602
+ width: 100%;
1603
+ }
1604
+ .transcript-body {
1605
+ flex: 1;
1606
+ overflow-y: auto;
1607
+ padding: 12px 22px;
1608
+ min-height: 200px;
1609
+ max-height: 60vh;
1610
+ }
1611
+ .transcript-loading, .transcript-empty {
1612
+ color: var(--tg-text-dim);
1613
+ font-size: 12px;
1614
+ text-align: center;
1615
+ padding: 40px 0;
1616
+ font-family: var(--tg-mono);
1617
+ }
1618
+ .transcript-card footer {
1619
+ padding: 10px 22px 14px;
1620
+ border-top: 1px solid var(--tg-border);
1621
+ display: flex;
1622
+ justify-content: space-between;
1623
+ align-items: center;
1624
+ }
1625
+
1626
+ /* Transcript session cards (recent view) */
1627
+ .transcript-session {
1628
+ padding: 10px 12px;
1629
+ border: 1px solid var(--tg-border);
1630
+ border-radius: var(--tg-radius-sm);
1631
+ margin-bottom: 8px;
1632
+ cursor: pointer;
1633
+ transition: border-color 0.15s;
1634
+ }
1635
+ .transcript-session:hover { border-color: var(--tg-accent-dim); }
1636
+ .ts-header {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ gap: 8px;
1640
+ margin-bottom: 6px;
1641
+ font-size: 11px;
1642
+ }
1643
+ .ts-id {
1644
+ font-family: var(--tg-mono);
1645
+ color: var(--tg-accent);
1646
+ font-weight: 600;
1647
+ }
1648
+ .ts-type {
1649
+ color: var(--tg-text-dim);
1650
+ font-family: var(--tg-mono);
1651
+ }
1652
+ .ts-project {
1653
+ padding: 1px 6px;
1654
+ border-radius: 3px;
1655
+ background: var(--tg-bg);
1656
+ border: 1px solid var(--tg-accent-dim);
1657
+ color: var(--tg-accent);
1658
+ font-size: 10px;
1659
+ }
1660
+ .ts-lines {
1661
+ color: var(--tg-text-dim);
1662
+ font-size: 10px;
1663
+ margin-left: auto;
1664
+ }
1665
+ .ts-preview {
1666
+ font-family: var(--tg-mono);
1667
+ font-size: 11px;
1668
+ color: var(--tg-text-dim);
1669
+ background: var(--tg-bg);
1670
+ padding: 6px 8px;
1671
+ border-radius: 3px;
1672
+ margin: 0;
1673
+ max-height: 80px;
1674
+ overflow: hidden;
1675
+ white-space: pre-wrap;
1676
+ word-break: break-word;
1677
+ }
1678
+
1679
+ /* Transcript search results */
1680
+ .transcript-result {
1681
+ padding: 8px 10px;
1682
+ border: 1px solid var(--tg-border);
1683
+ border-radius: var(--tg-radius-sm);
1684
+ margin-bottom: 6px;
1685
+ cursor: pointer;
1686
+ transition: border-color 0.15s;
1687
+ }
1688
+ .transcript-result:hover { border-color: var(--tg-accent-dim); }
1689
+ .tr-meta {
1690
+ display: flex;
1691
+ gap: 8px;
1692
+ font-size: 10px;
1693
+ color: var(--tg-text-dim);
1694
+ margin-bottom: 4px;
1695
+ }
1696
+ .tr-session { font-family: var(--tg-mono); color: var(--tg-accent); }
1697
+ .tr-time { font-family: var(--tg-mono); }
1698
+ .tr-line {
1699
+ font-family: var(--tg-mono);
1700
+ font-size: 11px;
1701
+ color: var(--tg-text);
1702
+ margin: 0;
1703
+ white-space: pre-wrap;
1704
+ word-break: break-word;
1705
+ }
1706
+ mark.tr-highlight {
1707
+ background: rgba(224, 175, 104, 0.25);
1708
+ color: var(--tg-amber);
1709
+ border-radius: 2px;
1710
+ padding: 0 1px;
1711
+ }
1712
+
1713
+ /* Transcript replay view */
1714
+ .transcript-replay-header {
1715
+ display: flex;
1716
+ align-items: center;
1717
+ justify-content: space-between;
1718
+ margin-bottom: 10px;
1719
+ }
1720
+ .tr-replay-id {
1721
+ font-family: var(--tg-mono);
1722
+ font-size: 12px;
1723
+ color: var(--tg-accent);
1724
+ }
1725
+ .transcript-copy {
1726
+ background: none;
1727
+ border: 1px solid var(--tg-border);
1728
+ color: var(--tg-text-dim);
1729
+ font-family: var(--tg-mono);
1730
+ font-size: 11px;
1731
+ padding: 4px 10px;
1732
+ border-radius: 3px;
1733
+ cursor: pointer;
1734
+ transition: all 0.15s;
1735
+ }
1736
+ .transcript-copy:hover {
1737
+ color: var(--tg-text);
1738
+ border-color: var(--tg-border-active);
1739
+ }
1740
+ .transcript-copy.copied {
1741
+ color: var(--tg-green);
1742
+ border-color: var(--tg-green);
1743
+ }
1744
+ .transcript-replay-content {
1745
+ font-family: var(--tg-mono);
1746
+ font-size: 11px;
1747
+ color: var(--tg-text);
1748
+ background: var(--tg-bg);
1749
+ padding: 12px 14px;
1750
+ border-radius: var(--tg-radius-sm);
1751
+ margin: 0;
1752
+ white-space: pre-wrap;
1753
+ word-break: break-word;
1754
+ max-height: 50vh;
1755
+ overflow-y: auto;
1756
+ }
1757
+ .transcript-back {
1758
+ background: none;
1759
+ border: 1px solid var(--tg-border);
1760
+ color: var(--tg-text-dim);
1761
+ font-family: var(--tg-sans);
1762
+ font-size: 11px;
1763
+ padding: 5px 14px;
1764
+ border-radius: 3px;
1765
+ cursor: pointer;
1766
+ }
1767
+ .transcript-back:hover {
1768
+ color: var(--tg-text);
1769
+ border-color: var(--tg-border-active);
1770
+ }
1771
+
1435
1772
  /* ===== SCROLLBAR ===== */
1436
1773
  ::-webkit-scrollbar { width: 6px; }
1437
1774
  ::-webkit-scrollbar-track { background: transparent; }
@@ -47,6 +47,8 @@ const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = requ
47
47
  const { RAGIntegration } = require('./rag');
48
48
  const { createBridge } = require('./mnestra-bridge');
49
49
  const { writeSessionLog } = require('./session-logger');
50
+ const { TranscriptWriter } = require('./transcripts');
51
+ const { createHealthHandler } = require('./preflight');
50
52
  const { themes, statusColors } = require('./themes');
51
53
  const { loadConfig, addProject } = require('./config');
52
54
 
@@ -87,12 +89,33 @@ function createServer(config) {
87
89
  const mnestraBridge = createBridge(config);
88
90
  console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
89
91
 
92
+ // Initialize transcript writer (Session Transcripts — Sprint 6)
93
+ const transcriptConfig = config.transcripts || {};
94
+ const transcriptEnabled = transcriptConfig.enabled !== undefined
95
+ ? transcriptConfig.enabled
96
+ : !!process.env.DATABASE_URL;
97
+ let transcriptWriter = null;
98
+ if (transcriptEnabled && process.env.DATABASE_URL) {
99
+ transcriptWriter = new TranscriptWriter(process.env.DATABASE_URL, {
100
+ batchSize: transcriptConfig.batchSize || 50,
101
+ flushIntervalMs: transcriptConfig.flushIntervalMs || 2000,
102
+ enabled: true
103
+ });
104
+ console.log('[transcript] Writer initialized (flush every %dms, batch %d)',
105
+ transcriptConfig.flushIntervalMs || 2000, transcriptConfig.batchSize || 50);
106
+ } else {
107
+ console.log('[transcript] Writer disabled (no DATABASE_URL or transcripts.enabled=false)');
108
+ }
109
+
90
110
  // Wire RAG to session events
91
111
  sessions.on('session:created', (s) => rag.onSessionCreated(s));
92
112
  sessions.on('session:removed', (s) => rag.onSessionEnded(s));
93
113
 
94
114
  // ==================== REST API ====================
95
115
 
116
+ // GET /api/health - preflight health checks (Sprint 6 T1, wired by T3)
117
+ app.get('/api/health', createHealthHandler(config));
118
+
96
119
  // GET /api/sessions - list all active sessions
97
120
  app.get('/api/sessions', (req, res) => {
98
121
  res.json(sessions.getAll());
@@ -157,7 +180,7 @@ function createServer(config) {
157
180
  session.pid = term.pid;
158
181
  session.meta.status = 'active';
159
182
 
160
- // PTY output → analyze + broadcast to WebSocket
183
+ // PTY output → analyze + broadcast to WebSocket + transcript archive
161
184
  term.onData((data) => {
162
185
  session.analyzeOutput(data);
163
186
 
@@ -165,6 +188,15 @@ function createServer(config) {
165
188
  if (session.ws && session.ws.readyState === 1) {
166
189
  session.ws.send(JSON.stringify({ type: 'output', data }));
167
190
  }
191
+
192
+ // Archive to transcript writer (non-blocking, failure-safe)
193
+ if (transcriptWriter) {
194
+ try {
195
+ transcriptWriter.append(session.id, data, Buffer.byteLength(data, 'utf8'));
196
+ } catch (err) {
197
+ // Never let transcript failures disrupt the PTY data path
198
+ }
199
+ }
168
200
  });
169
201
 
170
202
  term.onExit(({ exitCode, signal }) => {
@@ -447,6 +479,67 @@ function createServer(config) {
447
479
  });
448
480
  });
449
481
 
482
+ // ==================== Transcript endpoints (Sprint 6 T3) ====================
483
+
484
+ // GET /api/transcripts/search - FTS across all sessions
485
+ // (Must be registered before :sessionId to avoid route collision)
486
+ app.get('/api/transcripts/search', async (req, res) => {
487
+ if (!transcriptWriter) return res.json({ results: [] });
488
+ const q = req.query.q;
489
+ if (!q) return res.status(400).json({ error: 'Missing q parameter' });
490
+ const since = req.query.since || null;
491
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
492
+ try {
493
+ const results = await transcriptWriter.search(q, { since, limit });
494
+ res.json({ results });
495
+ } catch (err) {
496
+ console.error('[transcript] search endpoint error:', err.message);
497
+ res.status(500).json({ error: 'Transcript search failed' });
498
+ }
499
+ });
500
+
501
+ // GET /api/transcripts/recent - time-windowed crash recovery
502
+ // Returns { sessions: [ { session_id, chunks: [...] }, ... ] }
503
+ app.get('/api/transcripts/recent', async (req, res) => {
504
+ if (!transcriptWriter) return res.json({ sessions: [] });
505
+ const minutes = Math.min(Math.max(parseInt(req.query.minutes) || 60, 1), 1440);
506
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 500, 1), 2000);
507
+ try {
508
+ const rows = await transcriptWriter.getRecent(minutes, limit);
509
+ // Group by session_id for client consumption
510
+ const grouped = new Map();
511
+ for (const row of rows) {
512
+ if (!grouped.has(row.session_id)) grouped.set(row.session_id, []);
513
+ grouped.get(row.session_id).push(row);
514
+ }
515
+ const sessions = [];
516
+ for (const [session_id, chunks] of grouped) {
517
+ sessions.push({ session_id, chunks });
518
+ }
519
+ res.json({ sessions });
520
+ } catch (err) {
521
+ console.error('[transcript] recent endpoint error:', err.message);
522
+ res.status(500).json({ error: 'Transcript recent query failed' });
523
+ }
524
+ });
525
+
526
+ // GET /api/transcripts/:sessionId - ordered chunks for a session
527
+ // Returns { content: string } (joined transcript text)
528
+ app.get('/api/transcripts/:sessionId', async (req, res) => {
529
+ if (!transcriptWriter) return res.json({ content: '', lines: [] });
530
+ const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit), 1), 5000) : undefined;
531
+ const since = req.query.since || undefined;
532
+ try {
533
+ const chunks = await transcriptWriter.getSessionTranscript(req.params.sessionId, { limit, since });
534
+ const lines = chunks.map(c => c.content);
535
+ const content = lines.join('');
536
+ res.json({ content, lines, chunks });
537
+ } catch (err) {
538
+ console.error('[transcript] session transcript endpoint error:', err.message);
539
+ res.status(500).json({ error: 'Transcript retrieval failed' });
540
+ }
541
+ });
542
+
450
543
  // ==================== Rumen insights (Sprint 4 T2) ====================
451
544
  // Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
452
545
  // instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
@@ -717,7 +810,7 @@ function createServer(config) {
717
810
  res.sendFile(path.join(clientDir, 'index.html'));
718
811
  });
719
812
 
720
- return { app, server, wss, sessions, rag, db };
813
+ return { app, server, wss, sessions, rag, db, transcriptWriter };
721
814
  }
722
815
 
723
816
  // Start server
@@ -733,10 +826,29 @@ if (require.main === module) {
733
826
  config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
734
827
  }
735
828
 
736
- const { server } = createServer(config);
829
+ const { server, transcriptWriter } = createServer(config);
737
830
  const port = config.port || 3000;
738
831
  const host = config.host || '127.0.0.1';
739
832
 
833
+ // Graceful shutdown — flush transcript buffer before exit
834
+ let shutdownInProgress = false;
835
+ async function handleShutdown(signal) {
836
+ if (shutdownInProgress) return;
837
+ shutdownInProgress = true;
838
+ console.log(`\n[server] ${signal} received, shutting down...`);
839
+ if (transcriptWriter) {
840
+ console.log('[transcript] Flushing buffer before exit...');
841
+ try { await transcriptWriter.close(); } catch (err) {
842
+ console.error('[transcript] Shutdown flush failed:', err.message);
843
+ }
844
+ }
845
+ server.close(() => process.exit(0));
846
+ // Force exit after 5s if server.close hangs
847
+ setTimeout(() => process.exit(1), 5000).unref();
848
+ }
849
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
850
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
851
+
740
852
  server.listen(port, host, () => {
741
853
  console.log(`\n TermDeck running at http://${host}:${port}\n`);
742
854
  console.log(` Terminals: 0 active`);
@@ -744,6 +856,7 @@ if (require.main === module) {
744
856
  console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
745
857
  console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
746
858
  console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
859
+ console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
747
860
  console.log(`\n WARNING: TermDeck binds to ${host} only.`);
748
861
  console.log(` Do NOT expose this to the network without authentication.`);
749
862
  console.log(` Terminal sessions have full shell access.\n`);
@@ -207,7 +207,7 @@ function createBridge(config) {
207
207
  } catch (err) {
208
208
  // Kill child so it respawns next call
209
209
  if (state.mcpChild) {
210
- try { state.mcpChild.kill(); } catch {}
210
+ try { state.mcpChild.kill(); } catch (err) { /* process may already be dead */ }
211
211
  state.mcpChild = null;
212
212
  }
213
213
  throw err;