@jhizzard/termdeck 0.2.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.
@@ -0,0 +1,581 @@
1
+ // TermDeck Server - main entry point
2
+ // Express REST API + WebSocket hub + PTY management
3
+
4
+ const express = require('express');
5
+ const http = require('http');
6
+ const { WebSocketServer } = require('ws');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const fs = require('fs');
10
+ const { v4: uuidv4 } = require('uuid');
11
+
12
+ // Conditional imports (graceful fallback if not installed yet)
13
+ let pty, Database;
14
+ try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
15
+ try { Database = require('better-sqlite3'); } catch { Database = null; }
16
+
17
+ const { SessionManager } = require('./session');
18
+ const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
19
+ const { RAGIntegration } = require('./rag');
20
+ const { createBridge } = require('./engram-bridge');
21
+ const { writeSessionLog } = require('./session-logger');
22
+ const { themes, statusColors } = require('./themes');
23
+ const { loadConfig, addProject } = require('./config');
24
+
25
+ function createServer(config) {
26
+ const app = express();
27
+ const server = http.createServer(app);
28
+ const wss = new WebSocketServer({ server, path: '/ws' });
29
+
30
+ app.use(express.json());
31
+
32
+ // Serve client files
33
+ const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
34
+ app.use(express.static(clientDir));
35
+
36
+ // Initialize database
37
+ let db = null;
38
+ if (Database) {
39
+ try {
40
+ db = initDatabase(Database);
41
+ // Mark orphaned sessions as exited (PTYs lost on server restart)
42
+ const orphaned = db.prepare(
43
+ `UPDATE sessions SET exited_at = ?, exit_code = -1 WHERE exited_at IS NULL`
44
+ ).run(new Date().toISOString());
45
+ if (orphaned.changes > 0) {
46
+ console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
47
+ }
48
+ console.log('[db] SQLite initialized');
49
+ } catch (err) {
50
+ console.warn('[db] SQLite init failed:', err.message);
51
+ }
52
+ }
53
+
54
+ // Initialize session manager
55
+ const sessions = new SessionManager(db);
56
+
57
+ // Initialize RAG + Engram bridge
58
+ const rag = new RAGIntegration(config, db);
59
+ const engramBridge = createBridge(config);
60
+ console.log(`[engram-bridge] mode=${engramBridge.mode}`);
61
+
62
+ // Wire RAG to session events
63
+ sessions.on('session:created', (s) => rag.onSessionCreated(s));
64
+ sessions.on('session:removed', (s) => rag.onSessionEnded(s));
65
+
66
+ // ==================== REST API ====================
67
+
68
+ // GET /api/sessions - list all active sessions
69
+ app.get('/api/sessions', (req, res) => {
70
+ res.json(sessions.getAll());
71
+ });
72
+
73
+ // POST /api/sessions - create a new terminal session
74
+ app.post('/api/sessions', (req, res) => {
75
+ const { command, cwd, project, label, type, theme, reason } = req.body;
76
+
77
+ const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
78
+ const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
79
+
80
+ const session = sessions.create({
81
+ type: type || 'shell',
82
+ project: project || null,
83
+ label: label || command || 'Terminal',
84
+ command: command || config.shell,
85
+ cwd: resolvedCwd,
86
+ theme: theme || config.projects?.[project]?.defaultTheme || config.defaultTheme,
87
+ reason: reason || 'launched via API'
88
+ });
89
+
90
+ // Spawn PTY
91
+ if (pty) {
92
+ // Three launch shapes:
93
+ // (1) no command → spawn the default shell interactively
94
+ // (2) command is a plain shell name (zsh, bash, fish, ...)
95
+ // → spawn THAT shell interactively, no -c wrapper
96
+ // (otherwise `zsh -c zsh` exits immediately)
97
+ // (3) command is a real command string
98
+ // → spawn default shell with -c <command>
99
+ const cmdTrim = (command || '').trim();
100
+ const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
101
+ const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
102
+
103
+ const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
104
+ const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
105
+
106
+ try {
107
+ const term = pty.spawn(spawnShell, args, {
108
+ name: 'xterm-256color',
109
+ cols: 120,
110
+ rows: 30,
111
+ cwd: resolvedCwd,
112
+ env: {
113
+ ...process.env,
114
+ TERMDECK_SESSION: session.id,
115
+ TERMDECK_PROJECT: project || '',
116
+ TERM: 'xterm-256color',
117
+ COLORTERM: 'truecolor',
118
+ // Kill macOS Terminal.app's zsh session save on teardown.
119
+ // We do NOT override TERM_SESSION_ID or SHELL_SESSION_DID_INIT —
120
+ // touching those caused interactive shells to stop accepting
121
+ // input in at least one confirmed reproducer. If ~/.zsh_sessions/
122
+ // files get corrupted externally and produce a one-line startup
123
+ // warning, that is cosmetic and safe to ignore.
124
+ SHELL_SESSION_HISTORY: '0'
125
+ }
126
+ });
127
+
128
+ session.pty = term;
129
+ session.pid = term.pid;
130
+ session.meta.status = 'active';
131
+
132
+ // PTY output → analyze + broadcast to WebSocket
133
+ term.onData((data) => {
134
+ session.analyzeOutput(data);
135
+
136
+ // Send to connected WebSocket
137
+ if (session.ws && session.ws.readyState === 1) {
138
+ session.ws.send(JSON.stringify({ type: 'output', data }));
139
+ }
140
+ });
141
+
142
+ term.onExit(({ exitCode, signal }) => {
143
+ session.meta.status = 'exited';
144
+ session.meta.exitCode = exitCode;
145
+ session.meta.statusDetail = `Exited (${exitCode})${signal ? `, signal ${signal}` : ''}`;
146
+
147
+ if (session.ws && session.ws.readyState === 1) {
148
+ session.ws.send(JSON.stringify({
149
+ type: 'exit',
150
+ exitCode,
151
+ signal
152
+ }));
153
+ }
154
+
155
+ rag.onSessionEnded(session);
156
+
157
+ // Fire-and-forget session log (T2.5)
158
+ writeSessionLog({ session, config, db, getSessionHistory });
159
+ });
160
+
161
+ // Wire command logging to SQLite + RAG
162
+ session.onCommand = (sessionId, command) => {
163
+ if (db) {
164
+ try { logCommand(db, sessionId, command); } catch (err) { console.error('[db] logCommand failed:', err); }
165
+ }
166
+ rag.onCommandExecuted(session, command);
167
+ };
168
+
169
+ // Wire status change tracking to RAG
170
+ session.onStatusChange = (sess, oldStatus, newStatus) => {
171
+ rag.onStatusChanged(sess, oldStatus, newStatus);
172
+ };
173
+
174
+ // Proactive Engram queries on error — fire-and-forget, respects rag.enabled
175
+ session.onErrorDetected = (sess, ctx) => {
176
+ if (!rag.enabled) return;
177
+ const question = `${sess.meta.type} error ${ctx.lastCommand || ''} ${ctx.tail || ''}`.trim();
178
+ engramBridge.queryEngram({
179
+ question,
180
+ project: sess.meta.project,
181
+ searchAll: false,
182
+ sessionContext: {
183
+ type: sess.meta.type,
184
+ project: sess.meta.project,
185
+ lastCommands: sess.meta.lastCommands.slice(-5),
186
+ status: 'errored'
187
+ }
188
+ }).then((result) => {
189
+ const hit = (result.memories || [])[0];
190
+ if (!hit) return;
191
+ if (sess.ws && sess.ws.readyState === 1) {
192
+ try {
193
+ sess.ws.send(JSON.stringify({ type: 'proactive_memory', hit }));
194
+ } catch (err) {
195
+ console.error('[ws] proactive_memory send failed:', err);
196
+ }
197
+ }
198
+ }).catch((err) => {
199
+ console.warn('[engram-bridge] proactive query failed:', err.message);
200
+ });
201
+ };
202
+
203
+ console.log(`[pty] Spawned session ${session.id} (PID ${term.pid}): ${spawnShell} ${args.join(' ')}`);
204
+ } catch (err) {
205
+ session.meta.status = 'errored';
206
+ session.meta.statusDetail = err.message;
207
+ console.error(`[pty] Spawn failed:`, err);
208
+ }
209
+ } else {
210
+ session.meta.status = 'errored';
211
+ session.meta.statusDetail = 'node-pty not available';
212
+ }
213
+
214
+ res.status(201).json(session.toJSON());
215
+ });
216
+
217
+ // GET /api/sessions/:id - get session details
218
+ app.get('/api/sessions/:id', (req, res) => {
219
+ const session = sessions.get(req.params.id);
220
+ if (!session) return res.status(404).json({ error: 'Session not found' });
221
+ res.json(session.toJSON());
222
+ });
223
+
224
+ // PATCH /api/sessions/:id - update session metadata
225
+ app.patch('/api/sessions/:id', (req, res) => {
226
+ const session = sessions.updateMeta(req.params.id, req.body);
227
+ if (!session) return res.status(404).json({ error: 'Session not found' });
228
+ res.json(session.toJSON());
229
+ });
230
+
231
+ // DELETE /api/sessions/:id - kill terminal and remove session
232
+ app.delete('/api/sessions/:id', (req, res) => {
233
+ const session = sessions.get(req.params.id);
234
+ if (!session) return res.status(404).json({ error: 'Session not found' });
235
+
236
+ // Kill PTY process
237
+ if (session.pty) {
238
+ try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
239
+ }
240
+
241
+ sessions.remove(req.params.id);
242
+ res.json({ ok: true });
243
+ });
244
+
245
+ // POST /api/sessions/:id/input - write text into a PTY from an external sender
246
+ // Body: { text: string, source?: 'user' | 'reply' | 'ai', fromSessionId?: string }
247
+ // Used by T1.3 reply button and any agent-to-agent routing.
248
+ const inputRateLimit = new Map(); // sessionId -> { windowStart, count }
249
+ app.post('/api/sessions/:id/input', (req, res) => {
250
+ const session = sessions.get(req.params.id);
251
+ if (!session) return res.status(404).json({ error: 'Session not found' });
252
+ if (session.meta.status === 'exited' || !session.pty) {
253
+ return res.status(404).json({ error: 'Session is exited' });
254
+ }
255
+
256
+ const { text, source, fromSessionId } = req.body || {};
257
+ if (typeof text !== 'string') {
258
+ return res.status(400).json({ error: 'Missing text' });
259
+ }
260
+
261
+ // Rate limit: max 10 writes/sec per target session
262
+ const now = Date.now();
263
+ const bucket = inputRateLimit.get(session.id) || { windowStart: now, count: 0 };
264
+ if (now - bucket.windowStart >= 1000) {
265
+ bucket.windowStart = now;
266
+ bucket.count = 0;
267
+ }
268
+ bucket.count += 1;
269
+ inputRateLimit.set(session.id, bucket);
270
+ if (bucket.count > 10) {
271
+ return res.status(429).json({ error: 'Rate limit exceeded (10/sec)' });
272
+ }
273
+
274
+ // CRLF normalize: zsh/readline want \r for Enter
275
+ const normalized = text.replace(/\r\n?/g, '\r').replace(/\n/g, '\r');
276
+
277
+ try {
278
+ session.pty.write(normalized);
279
+ session.trackInput(normalized);
280
+ } catch (err) {
281
+ return res.status(500).json({ error: err.message });
282
+ }
283
+
284
+ session.meta.replyCount = (session.meta.replyCount || 0) + 1;
285
+
286
+ // Log the injection to command_history with its source. Commands typed by the
287
+ // user get auto-logged via session.onCommand — here we log the raw write so
288
+ // non-newline-terminated injections and agent-to-agent traffic are visible.
289
+ const effectiveSource = source || 'user';
290
+ if (db) {
291
+ try {
292
+ const snippet = fromSessionId ? `from:${fromSessionId}` : null;
293
+ logCommand(db, session.id, text.slice(0, 500), snippet, effectiveSource);
294
+ } catch (err) {
295
+ console.error('[db] logCommand (input endpoint) failed:', err);
296
+ }
297
+ }
298
+
299
+ res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
300
+ });
301
+
302
+ // POST /api/sessions/:id/resize - resize terminal
303
+ app.post('/api/sessions/:id/resize', (req, res) => {
304
+ const session = sessions.get(req.params.id);
305
+ if (!session?.pty) return res.status(404).json({ error: 'Session not found' });
306
+
307
+ const { cols, rows } = req.body;
308
+ try {
309
+ session.pty.resize(cols || 120, rows || 30);
310
+ res.json({ ok: true, cols, rows });
311
+ } catch (err) {
312
+ res.status(500).json({ error: err.message });
313
+ }
314
+ });
315
+
316
+ // GET /api/sessions/:id/history - command history for session
317
+ app.get('/api/sessions/:id/history', (req, res) => {
318
+ if (!db) return res.json([]);
319
+ res.json(getSessionHistory(db, req.params.id));
320
+ });
321
+
322
+ // GET /api/themes - available terminal themes
323
+ app.get('/api/themes', (req, res) => {
324
+ const list = Object.entries(themes).map(([id, t]) => ({
325
+ id,
326
+ label: t.label,
327
+ category: t.category,
328
+ background: t.theme.background,
329
+ foreground: t.theme.foreground,
330
+ theme: t.theme
331
+ }));
332
+ res.json(list);
333
+ });
334
+
335
+ // GET /api/themes/:id - full theme data
336
+ app.get('/api/themes/:id', (req, res) => {
337
+ const t = themes[req.params.id];
338
+ if (!t) return res.status(404).json({ error: 'Theme not found' });
339
+ res.json(t);
340
+ });
341
+
342
+ // GET /api/config - current config (sanitized)
343
+ app.get('/api/config', (req, res) => {
344
+ res.json({
345
+ projects: config.projects || {},
346
+ defaultTheme: config.defaultTheme,
347
+ ragEnabled: rag.enabled,
348
+ aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
349
+ statusColors
350
+ });
351
+ });
352
+
353
+ // POST /api/projects - add a new project on the fly, persist to config.yaml
354
+ // Body: { name, path, defaultTheme?, defaultCommand? }
355
+ // Updates both the on-disk config.yaml and the in-memory config so new
356
+ // sessions can select the project immediately without a server restart.
357
+ app.post('/api/projects', (req, res) => {
358
+ const { name, path: projectPath, defaultTheme, defaultCommand } = req.body || {};
359
+ try {
360
+ const updatedProjects = addProject({ name, path: projectPath, defaultTheme, defaultCommand });
361
+ config.projects = updatedProjects;
362
+ res.json({ ok: true, projects: updatedProjects });
363
+ } catch (err) {
364
+ console.error('[config] addProject failed:', err.message);
365
+ res.status(400).json({ error: err.message });
366
+ }
367
+ });
368
+
369
+ // GET /api/status - global status (control room data)
370
+ app.get('/api/status', (req, res) => {
371
+ const allSessions = sessions.getAll();
372
+ const byProject = {};
373
+ const byStatus = {};
374
+ const byType = {};
375
+
376
+ for (const s of allSessions) {
377
+ const proj = s.meta.project || 'untagged';
378
+ byProject[proj] = (byProject[proj] || 0) + 1;
379
+ byStatus[s.meta.status] = (byStatus[s.meta.status] || 0) + 1;
380
+ byType[s.meta.type] = (byType[s.meta.type] || 0) + 1;
381
+ }
382
+
383
+ res.json({
384
+ totalSessions: allSessions.length,
385
+ byProject,
386
+ byStatus,
387
+ byType,
388
+ uptime: process.uptime(),
389
+ memory: process.memoryUsage(),
390
+ ragEnabled: rag.enabled
391
+ });
392
+ });
393
+
394
+ // GET /api/rag/events - recent RAG events from local buffer
395
+ app.get('/api/rag/events', (req, res) => {
396
+ if (!db) return res.json([]);
397
+ const limit = parseInt(req.query.limit) || 50;
398
+ const rows = db.prepare(
399
+ 'SELECT * FROM rag_events ORDER BY timestamp DESC LIMIT ?'
400
+ ).all(limit);
401
+ res.json(rows.map(r => ({ ...r, payload: JSON.parse(r.payload) })));
402
+ });
403
+
404
+ // GET /api/rag/status - RAG system status
405
+ app.get('/api/rag/status', (req, res) => {
406
+ if (!db) return res.json({ enabled: false, localEvents: 0, unsynced: 0 });
407
+ const total = db.prepare('SELECT COUNT(*) as n FROM rag_events').get().n;
408
+ const unsynced = db.prepare('SELECT COUNT(*) as n FROM rag_events WHERE synced = 0').get().n;
409
+ res.json({
410
+ enabled: rag.enabled,
411
+ supabaseConfigured: !!(rag.supabaseUrl),
412
+ localEvents: total,
413
+ unsynced,
414
+ tables: rag.tables
415
+ });
416
+ });
417
+
418
+ // POST /api/ai/query - query Engram memory via the bridge (direct|webhook|mcp)
419
+ app.post('/api/ai/query', async (req, res) => {
420
+ let { question, sessionId, project } = req.body;
421
+ if (!question) return res.status(400).json({ error: 'Missing question' });
422
+
423
+ let searchAll = false;
424
+ if (question.toLowerCase().startsWith('all:')) {
425
+ question = question.substring(4).trim();
426
+ searchAll = true;
427
+ }
428
+
429
+ const session = sessionId ? sessions.get(sessionId) : null;
430
+ const sessionContext = session ? {
431
+ type: session.meta.type,
432
+ project: session.meta.project,
433
+ lastCommands: session.meta.lastCommands.slice(-5),
434
+ status: session.meta.status
435
+ } : null;
436
+
437
+ try {
438
+ const { memories, total } = await engramBridge.queryEngram({
439
+ question,
440
+ project,
441
+ searchAll,
442
+ sessionContext
443
+ });
444
+
445
+ res.json({
446
+ question,
447
+ memories: memories.slice(0, 5).map((m) => ({
448
+ content: m.content?.substring(0, 500),
449
+ source_type: m.source_type,
450
+ project: m.project,
451
+ similarity: m.similarity,
452
+ created_at: m.created_at
453
+ })),
454
+ sessionContext,
455
+ total
456
+ });
457
+ } catch (err) {
458
+ console.error('[engram-bridge] query failed:', err.message);
459
+ // Config-shaped errors are 503, everything else 502
460
+ const msg = err.message || 'Query failed';
461
+ const status = /not configured|OPENAI_API_KEY/i.test(msg) ? 503 : 502;
462
+ res.status(status).json({ error: msg });
463
+ }
464
+ });
465
+
466
+ // ==================== WebSocket ====================
467
+
468
+ wss.on('connection', (ws, req) => {
469
+ const url = new URL(req.url, `http://${req.headers.host}`);
470
+ const sessionId = url.searchParams.get('session');
471
+
472
+ if (!sessionId) {
473
+ ws.close(4000, 'Missing session parameter');
474
+ return;
475
+ }
476
+
477
+ const session = sessions.get(sessionId);
478
+ if (!session) {
479
+ ws.close(4001, 'Session not found');
480
+ return;
481
+ }
482
+
483
+ // Bind WebSocket to session
484
+ session.ws = ws;
485
+ console.log(`[ws] Client connected to session ${sessionId}`);
486
+
487
+ // Send initial metadata
488
+ ws.send(JSON.stringify({
489
+ type: 'meta',
490
+ session: session.toJSON()
491
+ }));
492
+
493
+ // Client → PTY
494
+ ws.on('message', (msg) => {
495
+ try {
496
+ const parsed = JSON.parse(msg);
497
+
498
+ switch (parsed.type) {
499
+ case 'input':
500
+ if (session.pty) {
501
+ session.pty.write(parsed.data);
502
+ session.trackInput(parsed.data);
503
+ }
504
+ break;
505
+
506
+ case 'resize':
507
+ if (session.pty) {
508
+ session.pty.resize(parsed.cols || 120, parsed.rows || 30);
509
+ }
510
+ break;
511
+
512
+ case 'meta':
513
+ // Client requesting metadata refresh
514
+ ws.send(JSON.stringify({
515
+ type: 'meta',
516
+ session: session.toJSON()
517
+ }));
518
+ break;
519
+ }
520
+ } catch (err) { console.error('[ws] message handler error:', err); }
521
+ });
522
+
523
+ ws.on('close', () => {
524
+ console.log(`[ws] Client disconnected from session ${sessionId}`);
525
+ if (session.ws === ws) {
526
+ session.ws = null;
527
+ }
528
+ });
529
+ });
530
+
531
+ // Periodic metadata broadcast (control room live updates)
532
+ setInterval(() => {
533
+ const allMeta = sessions.getAll();
534
+ const payload = JSON.stringify({ type: 'status_broadcast', sessions: allMeta });
535
+
536
+ wss.clients.forEach((client) => {
537
+ if (client.readyState === 1) {
538
+ try { client.send(payload); } catch (err) { console.error('[ws] broadcast send failed:', err); }
539
+ }
540
+ });
541
+ }, 2000);
542
+
543
+ // Fallback route → serve index.html
544
+ app.get('*', (req, res) => {
545
+ res.sendFile(path.join(clientDir, 'index.html'));
546
+ });
547
+
548
+ return { app, server, wss, sessions, rag, db };
549
+ }
550
+
551
+ // Start server
552
+ if (require.main === module) {
553
+ // Minimal flag parsing for direct-invocation users (the CLI wrapper has its own).
554
+ const argv = process.argv.slice(2);
555
+ if (argv.includes('--session-logs')) {
556
+ process.env.TERMDECK_SESSION_LOGS = '1';
557
+ }
558
+
559
+ const config = loadConfig();
560
+ if (process.env.TERMDECK_SESSION_LOGS === '1') {
561
+ config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
562
+ }
563
+
564
+ const { server } = createServer(config);
565
+ const port = config.port || 3000;
566
+ const host = config.host || '127.0.0.1';
567
+
568
+ server.listen(port, host, () => {
569
+ console.log(`\n TermDeck running at http://${host}:${port}\n`);
570
+ console.log(` Terminals: 0 active`);
571
+ console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
572
+ console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
573
+ console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
574
+ console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
575
+ console.log(`\n WARNING: TermDeck binds to ${host} only.`);
576
+ console.log(` Do NOT expose this to the network without authentication.`);
577
+ console.log(` Terminal sessions have full shell access.\n`);
578
+ });
579
+ }
580
+
581
+ module.exports = { createServer, loadConfig };