@jhizzard/termdeck 0.3.0 → 0.3.1

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,53 @@ 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([]);
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
+ app.get('/api/transcripts/recent', async (req, res) => {
503
+ if (!transcriptWriter) return res.json([]);
504
+ const minutes = Math.min(Math.max(parseInt(req.query.minutes) || 60, 1), 1440);
505
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 500, 1), 2000);
506
+ try {
507
+ const results = await transcriptWriter.getRecent(minutes, limit);
508
+ res.json(results);
509
+ } catch (err) {
510
+ console.error('[transcript] recent endpoint error:', err.message);
511
+ res.status(500).json({ error: 'Transcript recent query failed' });
512
+ }
513
+ });
514
+
515
+ // GET /api/transcripts/:sessionId - ordered chunks for a session
516
+ app.get('/api/transcripts/:sessionId', async (req, res) => {
517
+ if (!transcriptWriter) return res.json([]);
518
+ const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit), 1), 5000) : undefined;
519
+ const since = req.query.since || undefined;
520
+ try {
521
+ const chunks = await transcriptWriter.getSessionTranscript(req.params.sessionId, { limit, since });
522
+ res.json(chunks);
523
+ } catch (err) {
524
+ console.error('[transcript] session transcript endpoint error:', err.message);
525
+ res.status(500).json({ error: 'Transcript retrieval failed' });
526
+ }
527
+ });
528
+
450
529
  // ==================== Rumen insights (Sprint 4 T2) ====================
451
530
  // Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
452
531
  // instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
@@ -717,7 +796,7 @@ function createServer(config) {
717
796
  res.sendFile(path.join(clientDir, 'index.html'));
718
797
  });
719
798
 
720
- return { app, server, wss, sessions, rag, db };
799
+ return { app, server, wss, sessions, rag, db, transcriptWriter };
721
800
  }
722
801
 
723
802
  // Start server
@@ -733,10 +812,29 @@ if (require.main === module) {
733
812
  config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
734
813
  }
735
814
 
736
- const { server } = createServer(config);
815
+ const { server, transcriptWriter } = createServer(config);
737
816
  const port = config.port || 3000;
738
817
  const host = config.host || '127.0.0.1';
739
818
 
819
+ // Graceful shutdown — flush transcript buffer before exit
820
+ let shutdownInProgress = false;
821
+ async function handleShutdown(signal) {
822
+ if (shutdownInProgress) return;
823
+ shutdownInProgress = true;
824
+ console.log(`\n[server] ${signal} received, shutting down...`);
825
+ if (transcriptWriter) {
826
+ console.log('[transcript] Flushing buffer before exit...');
827
+ try { await transcriptWriter.close(); } catch (err) {
828
+ console.error('[transcript] Shutdown flush failed:', err.message);
829
+ }
830
+ }
831
+ server.close(() => process.exit(0));
832
+ // Force exit after 5s if server.close hangs
833
+ setTimeout(() => process.exit(1), 5000).unref();
834
+ }
835
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
836
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
837
+
740
838
  server.listen(port, host, () => {
741
839
  console.log(`\n TermDeck running at http://${host}:${port}\n`);
742
840
  console.log(` Terminals: 0 active`);
@@ -744,6 +842,7 @@ if (require.main === module) {
744
842
  console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
745
843
  console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
746
844
  console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
845
+ console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
747
846
  console.log(`\n WARNING: TermDeck binds to ${host} only.`);
748
847
  console.log(` Do NOT expose this to the network without authentication.`);
749
848
  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;