termbeam 1.15.1 → 1.16.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.
Files changed (66) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/public/assets/{_basePickBy-DDga1sgN.js → _basePickBy-CYR9pyOe.js} +1 -1
  4. package/public/assets/{_baseUniq-CKfoLvLE.js → _baseUniq-DeFSIx-P.js} +1 -1
  5. package/public/assets/{arc-DbSCVdo8.js → arc-CUEX1fu7.js} +1 -1
  6. package/public/assets/{architectureDiagram-2XIMDMQ5-Ce5knFNR.js → architectureDiagram-2XIMDMQ5-BHhXPzZJ.js} +1 -1
  7. package/public/assets/{blockDiagram-WCTKOSBZ-DJiZx7DH.js → blockDiagram-WCTKOSBZ-RsOwF2Ow.js} +1 -1
  8. package/public/assets/{c4Diagram-IC4MRINW-kOHNvx7n.js → c4Diagram-IC4MRINW-B7KKaZ1J.js} +1 -1
  9. package/public/assets/channel-CBJEzKmm.js +1 -0
  10. package/public/assets/{chunk-4BX2VUAB-DxmUWf39.js → chunk-4BX2VUAB-DOUcZxxl.js} +1 -1
  11. package/public/assets/{chunk-55IACEB6-BdvL648G.js → chunk-55IACEB6-bPgkuqF0.js} +1 -1
  12. package/public/assets/{chunk-FMBD7UC4-Bp3FkcH2.js → chunk-FMBD7UC4-BWT_ExWr.js} +1 -1
  13. package/public/assets/{chunk-JSJVCQXG-DOtbuVd2.js → chunk-JSJVCQXG-Df0AgfkZ.js} +1 -1
  14. package/public/assets/{chunk-KX2RTZJC-b_RAN48_.js → chunk-KX2RTZJC-DnYuhgK5.js} +1 -1
  15. package/public/assets/{chunk-NQ4KR5QH-CKHEKES_.js → chunk-NQ4KR5QH-Dge50UUS.js} +1 -1
  16. package/public/assets/{chunk-QZHKN3VN-Ce3Cy8iK.js → chunk-QZHKN3VN-BT0knyhA.js} +1 -1
  17. package/public/assets/{chunk-WL4C6EOR-CYlFnkd_.js → chunk-WL4C6EOR-DutXGT-d.js} +1 -1
  18. package/public/assets/classDiagram-VBA2DB6C-C-rOD9EU.js +1 -0
  19. package/public/assets/classDiagram-v2-RAHNMMFH-C-rOD9EU.js +1 -0
  20. package/public/assets/clone-DIyhZC23.js +1 -0
  21. package/public/assets/{cose-bilkent-S5V4N54A-Curtohg5.js → cose-bilkent-S5V4N54A-doAicD_V.js} +1 -1
  22. package/public/assets/{dagre-KLK3FWXG-CuZNu96V.js → dagre-KLK3FWXG-O4cFm_hK.js} +1 -1
  23. package/public/assets/{diagram-E7M64L7V-CunKBx6l.js → diagram-E7M64L7V-BifAzLVq.js} +1 -1
  24. package/public/assets/{diagram-IFDJBPK2-BN7aHVm4.js → diagram-IFDJBPK2-BfnxORJG.js} +1 -1
  25. package/public/assets/{diagram-P4PSJMXO-B4lVLdoW.js → diagram-P4PSJMXO-DTr1JYXb.js} +1 -1
  26. package/public/assets/{erDiagram-INFDFZHY-EenQr3uP.js → erDiagram-INFDFZHY-l1N_y881.js} +1 -1
  27. package/public/assets/{flowDiagram-PKNHOUZH-CvFkNm_6.js → flowDiagram-PKNHOUZH-CLGWYVco.js} +1 -1
  28. package/public/assets/{ganttDiagram-A5KZAMGK-DY9bVz9l.js → ganttDiagram-A5KZAMGK-9ERk2sFV.js} +1 -1
  29. package/public/assets/{gitGraphDiagram-K3NZZRJ6-BAd-taYp.js → gitGraphDiagram-K3NZZRJ6-BxK3Z85E.js} +1 -1
  30. package/public/assets/{graph-B5Mupk4w.js → graph-PwJPsvsO.js} +1 -1
  31. package/public/assets/index-0a9Qn-A1.js +394 -0
  32. package/public/assets/index-Z_lybSmO.css +32 -0
  33. package/public/assets/{infoDiagram-LFFYTUFH-vhp46Ys0.js → infoDiagram-LFFYTUFH-DygGOypU.js} +1 -1
  34. package/public/assets/{ishikawaDiagram-PHBUUO56-GppOId5G.js → ishikawaDiagram-PHBUUO56-CySga9vu.js} +1 -1
  35. package/public/assets/{journeyDiagram-4ABVD52K-Bf8IH4_E.js → journeyDiagram-4ABVD52K-ZIZNkXyJ.js} +1 -1
  36. package/public/assets/{kanban-definition-K7BYSVSG-CsV7UppO.js → kanban-definition-K7BYSVSG-IxWUQjiQ.js} +1 -1
  37. package/public/assets/{layout-TFe_JtAk.js → layout-DbFs-9Gp.js} +1 -1
  38. package/public/assets/{linear-jRuCITkz.js → linear-F1crC_h8.js} +1 -1
  39. package/public/assets/{mindmap-definition-YRQLILUH-Drqk-jqT.js → mindmap-definition-YRQLILUH-BwXWnIOB.js} +1 -1
  40. package/public/assets/{pieDiagram-SKSYHLDU-zhtwIYVW.js → pieDiagram-SKSYHLDU-CTKX_qGt.js} +1 -1
  41. package/public/assets/{quadrantDiagram-337W2JSQ-CgighrLO.js → quadrantDiagram-337W2JSQ-C9hYwyla.js} +1 -1
  42. package/public/assets/{requirementDiagram-Z7DCOOCP-Blze02a-.js → requirementDiagram-Z7DCOOCP-eOJ_I7lS.js} +1 -1
  43. package/public/assets/{sankeyDiagram-WA2Y5GQK-DP2pOuJP.js → sankeyDiagram-WA2Y5GQK-CNYzy0Z-.js} +1 -1
  44. package/public/assets/{sequenceDiagram-2WXFIKYE-2ZQZVVEw.js → sequenceDiagram-2WXFIKYE-ChHor0a9.js} +1 -1
  45. package/public/assets/{stateDiagram-RAJIS63D-CxBDjO6s.js → stateDiagram-RAJIS63D-CF2B1sp8.js} +1 -1
  46. package/public/assets/stateDiagram-v2-FVOUBMTO-Doxped-1.js +1 -0
  47. package/public/assets/{timeline-definition-YZTLITO2-Du6zdjZw.js → timeline-definition-YZTLITO2-H72vBjMX.js} +1 -1
  48. package/public/assets/{treemap-KZPCXAKY-Duew5oqq.js → treemap-KZPCXAKY-CWEZDi_m.js} +1 -1
  49. package/public/assets/{vennDiagram-LZ73GAT5-D9zfCBm9.js → vennDiagram-LZ73GAT5-BEqSLv2W.js} +1 -1
  50. package/public/assets/{xychartDiagram-JWTSCODW-BJuwWw4_.js → xychartDiagram-JWTSCODW-enJM4ByX.js} +1 -1
  51. package/public/index.html +2 -2
  52. package/public/sw.js +2 -2
  53. package/src/server/index.js +22 -3
  54. package/src/server/push.js +118 -0
  55. package/src/server/routes.js +227 -1
  56. package/src/server/sessions.js +144 -0
  57. package/src/server/websocket.js +21 -2
  58. package/src/utils/git.js +338 -1
  59. package/src/utils/vapid.js +45 -0
  60. package/public/assets/channel-Ccb6hGZz.js +0 -1
  61. package/public/assets/classDiagram-VBA2DB6C-BIrAPXFF.js +0 -1
  62. package/public/assets/classDiagram-v2-RAHNMMFH-BIrAPXFF.js +0 -1
  63. package/public/assets/clone-D5RGMzJC.js +0 -1
  64. package/public/assets/index-BEOqWnh5.js +0 -391
  65. package/public/assets/index-D_1GL6a5.css +0 -32
  66. package/public/assets/stateDiagram-v2-FVOUBMTO-3AffBMDC.js +0 -1
@@ -0,0 +1,118 @@
1
+ const webpush = require('web-push');
2
+ const { getOrCreateVapidKeys } = require('../utils/vapid');
3
+ const log = require('../utils/logger');
4
+
5
+ class PushManager {
6
+ constructor(configDir) {
7
+ this.configDir = configDir;
8
+ this.subscriptions = new Map(); // endpoint -> subscription object
9
+ this.vapidKeys = null;
10
+ }
11
+
12
+ /**
13
+ * Initialize VAPID keys and configure web-push.
14
+ * Call once during server start.
15
+ */
16
+ async init() {
17
+ this.vapidKeys = getOrCreateVapidKeys(this.configDir);
18
+ webpush.setVapidDetails(
19
+ this.vapidKeys.subject,
20
+ this.vapidKeys.publicKey,
21
+ this.vapidKeys.privateKey,
22
+ );
23
+ log.info('Push notification manager initialized');
24
+ }
25
+
26
+ /**
27
+ * Register a push subscription.
28
+ * @param {{ endpoint: string, keys: { p256dh: string, auth: string } }} subscription
29
+ */
30
+ subscribe(subscription) {
31
+ if (!this.vapidKeys) {
32
+ log.warn('Push subscription rejected — VAPID not initialized');
33
+ return;
34
+ }
35
+ this.subscriptions.set(subscription.endpoint, subscription);
36
+ log.info(`Push subscription registered (${this.subscriptions.size} total)`);
37
+ }
38
+
39
+ /**
40
+ * Remove a push subscription by endpoint.
41
+ * @param {string} endpoint
42
+ */
43
+ unsubscribe(endpoint) {
44
+ const removed = this.subscriptions.delete(endpoint);
45
+ if (removed) {
46
+ log.debug(`Push subscription removed (${this.subscriptions.size} total)`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Send a push notification to all registered subscriptions.
52
+ * Removes subscriptions that return 410 Gone or 404 Not Found.
53
+ * @param {{ title: string, body: string, tag?: string, sessionId?: string }} payload
54
+ */
55
+ async notify(payload) {
56
+ if (this.subscriptions.size === 0) {
57
+ log.debug('Push: no subscriptions registered, skipping notification');
58
+ return;
59
+ }
60
+
61
+ if (!this.vapidKeys) {
62
+ log.debug('Push: VAPID not initialized, skipping');
63
+ return;
64
+ }
65
+
66
+ log.info(`Push: sending to ${this.subscriptions.size} subscription(s): ${payload.title}`);
67
+
68
+ const body = JSON.stringify(payload);
69
+ const stale = [];
70
+
71
+ const results = await Promise.allSettled(
72
+ [...this.subscriptions.entries()].map(async ([endpoint, sub]) => {
73
+ try {
74
+ await webpush.sendNotification(sub, body, {
75
+ TTL: 300,
76
+ urgency: 'normal',
77
+ });
78
+ log.debug(`Push sent successfully to ${endpoint.slice(0, 50)}...`);
79
+ sub._failCount = 0;
80
+ } catch (err) {
81
+ if (err.statusCode === 410 || err.statusCode === 404) {
82
+ stale.push(endpoint);
83
+ log.debug(`Removing stale push subscription (${err.statusCode})`);
84
+ } else {
85
+ sub._failCount = (sub._failCount || 0) + 1;
86
+ log.warn(
87
+ `Push notification failed (attempt ${sub._failCount}): ${err.message}` +
88
+ (err.statusCode ? ` (HTTP ${err.statusCode})` : '') +
89
+ (err.body ? ` — ${String(err.body).slice(0, 200)}` : ''),
90
+ );
91
+ if (sub._failCount >= 5) {
92
+ stale.push(endpoint);
93
+ log.warn(`Push subscription removed after ${sub._failCount} consecutive failures`);
94
+ }
95
+ }
96
+ }
97
+ }),
98
+ );
99
+
100
+ for (const endpoint of stale) {
101
+ this.subscriptions.delete(endpoint);
102
+ }
103
+
104
+ log.debug(
105
+ `Push notifications sent: ${results.length} attempted, ${stale.length} stale removed`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Return the VAPID public key for frontend subscription.
111
+ * @returns {string}
112
+ */
113
+ getPublicKey() {
114
+ return this.vapidKeys ? this.vapidKeys.publicKey : null;
115
+ }
116
+ }
117
+
118
+ module.exports = { PushManager };
@@ -34,7 +34,7 @@ function validateMagicBytes(buffer, contentType) {
34
34
  return true;
35
35
  }
36
36
 
37
- function setupRoutes(app, { auth, sessions, config, state }) {
37
+ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
38
38
  const pageRateLimit = rateLimit({
39
39
  windowMs: 1 * 60 * 1000,
40
40
  max: 120,
@@ -149,6 +149,9 @@ function setupRoutes(app, { auth, sessions, config, state }) {
149
149
  app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
150
150
  res.sendFile('index.html', { root: PUBLIC_DIR }),
151
151
  );
152
+ app.get('/code/:sessionId', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
153
+ res.sendFile('index.html', { root: PUBLIC_DIR }),
154
+ );
152
155
 
153
156
  // Share token — generates a temporary share token for the share button
154
157
  app.get('/api/share-token', auth.middleware, (req, res) => {
@@ -517,6 +520,105 @@ function setupRoutes(app, { auth, sessions, config, state }) {
517
520
  }
518
521
  });
519
522
 
523
+ // Recursive file tree for a session's CWD
524
+ app.get('/api/sessions/:id/file-tree', apiRateLimit, auth.middleware, (req, res) => {
525
+ const session = sessions.get(req.params.id);
526
+ if (!session) return res.status(404).json({ error: 'Session not found' });
527
+
528
+ const MAX_DEPTH = 5;
529
+ const MAX_ENTRIES = 2000;
530
+ const EXCLUDED = new Set([
531
+ 'node_modules',
532
+ '.git',
533
+ '__pycache__',
534
+ 'coverage',
535
+ '.next',
536
+ 'dist',
537
+ 'build',
538
+ ]);
539
+
540
+ let depth = 3;
541
+ if (typeof req.query.depth !== 'undefined') {
542
+ const parsedDepth = parseInt(req.query.depth, 10);
543
+ if (Number.isNaN(parsedDepth)) {
544
+ return res.status(400).json({ error: 'Invalid depth' });
545
+ }
546
+ depth = parsedDepth;
547
+ }
548
+ depth = Math.min(Math.max(depth, 1), MAX_DEPTH);
549
+ const rootDir = path.resolve(session.cwd);
550
+ let totalEntries = 0;
551
+
552
+ function buildTree(dir, currentDepth) {
553
+ let dirents;
554
+ try {
555
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
556
+ } catch {
557
+ return [];
558
+ }
559
+
560
+ const entries = [];
561
+ const filtered = dirents
562
+ .filter((e) => {
563
+ if (e.name.startsWith('.')) return false;
564
+ if (EXCLUDED.has(e.name)) return false;
565
+ try {
566
+ return !fs.lstatSync(path.join(dir, e.name)).isSymbolicLink();
567
+ } catch {
568
+ return false;
569
+ }
570
+ })
571
+ .sort((a, b) => {
572
+ const aDir = a.isDirectory();
573
+ const bDir = b.isDirectory();
574
+ if (aDir !== bDir) return aDir ? -1 : 1;
575
+ return a.name.localeCompare(b.name);
576
+ });
577
+
578
+ for (const e of filtered) {
579
+ if (totalEntries >= MAX_ENTRIES) break;
580
+ totalEntries++;
581
+
582
+ const fullPath = path.join(dir, e.name);
583
+ const relativePath = path.relative(rootDir, fullPath);
584
+ const isDir = e.isDirectory();
585
+
586
+ if (isDir) {
587
+ const children = currentDepth < depth ? buildTree(fullPath, currentDepth + 1) : [];
588
+ entries.push({
589
+ name: e.name,
590
+ type: 'directory',
591
+ path: relativePath.replace(/\\/g, '/'),
592
+ children,
593
+ });
594
+ } else {
595
+ let size = 0;
596
+ try {
597
+ size = fs.statSync(fullPath).size;
598
+ } catch {
599
+ // ignore stat errors
600
+ }
601
+ entries.push({
602
+ name: e.name,
603
+ type: 'file',
604
+ path: relativePath.replace(/\\/g, '/'),
605
+ size,
606
+ });
607
+ }
608
+ }
609
+
610
+ return entries;
611
+ }
612
+
613
+ try {
614
+ const tree = buildTree(rootDir, 1);
615
+ res.json({ root: rootDir, tree });
616
+ } catch (err) {
617
+ log.warn(`File tree failed: ${err.message}`);
618
+ res.status(500).json({ error: 'Failed to build file tree' });
619
+ }
620
+ });
621
+
520
622
  // Download a file from within a session's CWD
521
623
  app.get('/api/sessions/:id/download', apiRateLimit, auth.middleware, (req, res) => {
522
624
  const session = sessions.get(req.params.id);
@@ -610,6 +712,95 @@ function setupRoutes(app, { auth, sessions, config, state }) {
610
712
  }
611
713
  });
612
714
 
715
+ // --- Git change endpoints ---
716
+
717
+ const { getDetailedStatus, getFileDiff, getFileBlame, getGitLog } = require('../utils/git');
718
+
719
+ function validateFilePath(file) {
720
+ if (!file || typeof file !== 'string') return false;
721
+ if (path.isAbsolute(file)) return false;
722
+ const normalized = path.normalize(file);
723
+ if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false;
724
+ return true;
725
+ }
726
+
727
+ app.get('/api/sessions/:id/git/status', apiRateLimit, auth.middleware, async (req, res) => {
728
+ const session = sessions.get(req.params.id);
729
+ if (!session) return res.status(404).json({ error: 'Session not found' });
730
+
731
+ try {
732
+ const status = await getDetailedStatus(session.cwd);
733
+ res.json(status);
734
+ } catch (err) {
735
+ log.warn(`Git status failed: ${err.message}`);
736
+ res.status(500).json({ error: 'Failed to get git status' });
737
+ }
738
+ });
739
+
740
+ app.get('/api/sessions/:id/git/diff', apiRateLimit, auth.middleware, async (req, res) => {
741
+ const session = sessions.get(req.params.id);
742
+ if (!session) return res.status(404).json({ error: 'Session not found' });
743
+
744
+ const file = req.query.file;
745
+ if (!validateFilePath(file)) {
746
+ return res.status(400).json({ error: 'Invalid or missing file parameter' });
747
+ }
748
+
749
+ const staged = req.query.staged === 'true';
750
+ const untracked = req.query.untracked === 'true';
751
+ let context;
752
+ if (req.query.context !== undefined) {
753
+ const parsed = parseInt(req.query.context, 10);
754
+ if (Number.isFinite(parsed)) {
755
+ context = Math.min(Math.max(parsed, 0), 99999);
756
+ }
757
+ }
758
+ try {
759
+ const diff = await getFileDiff(session.cwd, file, { staged, untracked, context });
760
+ res.json(diff);
761
+ } catch (err) {
762
+ log.warn(`Git diff failed: ${err.message}`);
763
+ res.status(500).json({ error: 'Failed to get diff' });
764
+ }
765
+ });
766
+
767
+ app.get('/api/sessions/:id/git/blame', apiRateLimit, auth.middleware, async (req, res) => {
768
+ const session = sessions.get(req.params.id);
769
+ if (!session) return res.status(404).json({ error: 'Session not found' });
770
+
771
+ const file = req.query.file;
772
+ if (!validateFilePath(file)) {
773
+ return res.status(400).json({ error: 'Invalid or missing file parameter' });
774
+ }
775
+
776
+ try {
777
+ const blame = await getFileBlame(session.cwd, file);
778
+ res.json(blame);
779
+ } catch (err) {
780
+ log.warn(`Git blame failed: ${err.message}`);
781
+ res.status(500).json({ error: 'Failed to get blame' });
782
+ }
783
+ });
784
+
785
+ app.get('/api/sessions/:id/git/log', apiRateLimit, auth.middleware, async (req, res) => {
786
+ const session = sessions.get(req.params.id);
787
+ if (!session) return res.status(404).json({ error: 'Session not found' });
788
+
789
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
790
+ const file = req.query.file;
791
+ if (file && !validateFilePath(file)) {
792
+ return res.status(400).json({ error: 'Invalid file parameter' });
793
+ }
794
+
795
+ try {
796
+ const logResult = await getGitLog(session.cwd, { limit, file: file || null });
797
+ res.json(logResult);
798
+ } catch (err) {
799
+ log.warn(`Git log failed: ${err.message}`);
800
+ res.status(500).json({ error: 'Failed to get git log' });
801
+ }
802
+ });
803
+
613
804
  // Directory listing for folder browser
614
805
  app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
615
806
  log.debug(`Directory listing requested: ${req.query.q || config.cwd}`);
@@ -631,6 +822,41 @@ function setupRoutes(app, { auth, sessions, config, state }) {
631
822
  res.json({ base: dir, dirs: [], truncated: false });
632
823
  }
633
824
  });
825
+
826
+ // --- Push notification endpoints ---
827
+ if (pushManager) {
828
+ app.get('/api/push/vapid-key', apiRateLimit, auth.middleware, (_req, res) => {
829
+ const publicKey = pushManager.getPublicKey();
830
+ if (!publicKey) {
831
+ return res.status(503).json({ error: 'Push notifications not configured' });
832
+ }
833
+ res.json({ publicKey });
834
+ });
835
+
836
+ app.post('/api/push/subscribe', apiRateLimit, auth.middleware, (req, res) => {
837
+ const { subscription } = req.body || {};
838
+ if (
839
+ !subscription ||
840
+ !subscription.endpoint ||
841
+ !subscription.keys ||
842
+ !subscription.keys.p256dh ||
843
+ !subscription.keys.auth
844
+ ) {
845
+ return res.status(400).json({ error: 'Invalid subscription object' });
846
+ }
847
+ pushManager.subscribe(subscription);
848
+ res.json({ ok: true });
849
+ });
850
+
851
+ app.delete('/api/push/unsubscribe', apiRateLimit, auth.middleware, (req, res) => {
852
+ const { endpoint } = req.body || {};
853
+ if (!endpoint) {
854
+ return res.status(400).json({ error: 'Missing endpoint' });
855
+ }
856
+ pushManager.unsubscribe(endpoint);
857
+ res.json({ ok: true });
858
+ });
859
+ }
634
860
  }
635
861
 
636
862
  function cleanupUploadedFiles() {
@@ -101,6 +101,41 @@ const SESSION_COLORS = [
101
101
  class SessionManager {
102
102
  constructor() {
103
103
  this.sessions = new Map();
104
+ /** @type {((event: {sessionId: string, sessionName: string}) => void)|null} */
105
+ this.onCommandComplete = null;
106
+ }
107
+
108
+ /** Emit a command-complete notification (push + WS broadcast). */
109
+ _emitNotification(id, session) {
110
+ const notification = {
111
+ notificationType: 'command-complete',
112
+ sessionName: session.name,
113
+ timestamp: Date.now(),
114
+ };
115
+
116
+ // Send push notification (works even when app is closed)
117
+ if (this.onCommandComplete) {
118
+ this.onCommandComplete({ sessionId: id, sessionName: session.name });
119
+ }
120
+
121
+ // Broadcast to connected WebSocket clients
122
+ const notifMsg = JSON.stringify({ type: 'notification', ...notification });
123
+ let delivered = false;
124
+ for (const ws of session.clients) {
125
+ if (ws.readyState === 1) {
126
+ ws.send(notifMsg);
127
+ delivered = true;
128
+ }
129
+ }
130
+
131
+ // Only store as pending if no clients received it — prevents
132
+ // duplicate notification when user taps push and app reconnects
133
+ if (!delivered) {
134
+ session.pendingNotifications.push(notification);
135
+ if (session.pendingNotifications.length > 5) {
136
+ session.pendingNotifications = session.pendingNotifications.slice(-5);
137
+ }
138
+ }
104
139
  }
105
140
 
106
141
  create({
@@ -162,6 +197,7 @@ class SessionManager {
162
197
  createdAt: new Date().toISOString(),
163
198
  lastActivity: Date.now(),
164
199
  clients: new Set(),
200
+ pendingNotifications: [],
165
201
  scrollback: [],
166
202
  scrollbackBuf: '',
167
203
  hasHadClient: false,
@@ -175,6 +211,36 @@ class SessionManager {
175
211
  session.lastActivity = Date.now();
176
212
  session.scrollbackBuf += data;
177
213
 
214
+ // Silence-based notification: only active when the shell has a direct
215
+ // child process (session._hasDirectChild). This handles interactive
216
+ // agents (Copilot CLI, Claude Code) that stay running but spawn
217
+ // subtasks. When subtask output goes silent for 5 seconds after
218
+ // sustained activity, that's "task completed."
219
+ if (session._hasDirectChild) {
220
+ const now = Date.now();
221
+ if (!session._outputBurstStart) session._outputBurstStart = now;
222
+ session._outputBytes = (session._outputBytes || 0) + data.length;
223
+ clearTimeout(session._silenceTimer);
224
+
225
+ // Only fire after 5s silence following ≥1s activity with ≥100 bytes
226
+ const duration = now - session._outputBurstStart;
227
+ if (duration >= 1000 && session._outputBytes >= 100) {
228
+ session._silenceTimer = setTimeout(() => {
229
+ const cooldownOk =
230
+ !session._lastNotifyTime || Date.now() - session._lastNotifyTime >= 30000;
231
+ if (cooldownOk) {
232
+ session._lastNotifyTime = Date.now();
233
+ log.info(
234
+ `Command idle in "${session.name}" (${Math.round(duration / 1000)}s activity, ${session._outputBytes} bytes)`,
235
+ );
236
+ this._emitNotification(id, session);
237
+ }
238
+ session._outputBurstStart = null;
239
+ session._outputBytes = 0;
240
+ }, 5000);
241
+ }
242
+ }
243
+
178
244
  // Track alt screen mode so reconnecting clients can re-enter it.
179
245
  // Carry a small tail between chunks so split escape sequences are detected.
180
246
  const scanBuf = session._altScanTail + data;
@@ -219,7 +285,83 @@ class SessionManager {
219
285
  }
220
286
  });
221
287
 
288
+ // Monitor DIRECT child processes of the shell to detect command completion.
289
+ // Two notification triggers:
290
+ // 1. Direct child exits (e.g., `sleep 10` finishes, `copilot` quits)
291
+ // 2. Silence detection (in onData above) fires when output stops for 5s
292
+ // while a child IS running (e.g., Copilot CLI agent finishes a task)
293
+ if (process.platform !== 'win32') {
294
+ const shellPid = ptyProcess.pid;
295
+ let prevChildren = new Set();
296
+ let childCheckCount = 0;
297
+ const POLL_INTERVAL = 2000;
298
+ const NOTIFY_COOLDOWN = 30000;
299
+
300
+ let pollInFlight = false;
301
+ const checkChildren = () => {
302
+ if (pollInFlight) return;
303
+ if (!this.sessions.has(id)) return;
304
+ pollInFlight = true;
305
+
306
+ const { exec } = require('child_process');
307
+
308
+ exec(
309
+ `ps -ax -o pid=,ppid= | awk -v p=${shellPid} '$2 == p { print $1 }'`,
310
+ { timeout: 2000 },
311
+ (err, stdout) => {
312
+ pollInFlight = false;
313
+ if (err) return;
314
+ const currentChildren = new Set(
315
+ (stdout || '')
316
+ .trim()
317
+ .split('\n')
318
+ .filter(Boolean)
319
+ .map((s) => s.trim()),
320
+ );
321
+ childCheckCount++;
322
+
323
+ // Update the flag used by silence detection in onData
324
+ session._hasDirectChild = currentChildren.size > 0;
325
+
326
+ // Skip initial checks — shell startup spawns profile/completion children
327
+ if (childCheckCount <= 3) {
328
+ prevChildren = currentChildren;
329
+ return;
330
+ }
331
+
332
+ // Check if any previously-seen direct child has exited
333
+ const exited = [...prevChildren].filter((pid) => !currentChildren.has(pid));
334
+
335
+ if (exited.length > 0 && prevChildren.size > 0) {
336
+ // Direct child exited — clear silence timer (prevent double notification)
337
+ clearTimeout(session._silenceTimer);
338
+ session._outputBurstStart = null;
339
+ session._outputBytes = 0;
340
+
341
+ const now = Date.now();
342
+ if (!session._lastNotifyTime || now - session._lastNotifyTime >= NOTIFY_COOLDOWN) {
343
+ session._lastNotifyTime = now;
344
+ log.info(
345
+ `Command completed in "${session.name}" (PID ${exited.join(',')} exited, ${currentChildren.size} remaining)`,
346
+ );
347
+ this._emitNotification(id, session);
348
+ }
349
+ }
350
+
351
+ prevChildren = currentChildren;
352
+ },
353
+ );
354
+ };
355
+
356
+ session._childMonitor = setInterval(checkChildren, POLL_INTERVAL);
357
+ if (typeof session._childMonitor.unref === 'function') {
358
+ session._childMonitor.unref();
359
+ }
360
+ }
361
+
222
362
  ptyProcess.onExit(({ exitCode }) => {
363
+ clearInterval(session._childMonitor);
364
+ clearTimeout(session._silenceTimer);
223
365
  log.info(`Session "${name}" (${id}) exited (code ${exitCode})`);
224
366
  for (const ws of session.clients) {
225
367
  if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
@@ -260,6 +402,7 @@ class SessionManager {
260
402
  if (!s) return false;
261
403
  log.info(`Session "${s.name}" deleted (id=${id})`);
262
404
  _gitCache.delete(id);
405
+ clearInterval(s._childMonitor);
263
406
  s.pty.kill();
264
407
  return true;
265
408
  }
@@ -289,6 +432,7 @@ class SessionManager {
289
432
  log.info(`Shutting down ${this.sessions.size} session(s)`);
290
433
  for (const [_id, s] of this.sessions) {
291
434
  try {
435
+ clearInterval(s._childMonitor);
292
436
  s.pty.kill();
293
437
  } catch (err) {
294
438
  log.warn(`Failed to kill session ${_id}: ${err.message}`);
@@ -82,10 +82,22 @@ function setupWebSocket(wss, { auth, sessions }) {
82
82
  }
83
83
 
84
84
  const pingInterval = setInterval(() => {
85
- if (ws.readyState === 1) ws.ping();
86
- }, 30000);
85
+ if (ws.readyState === 1) {
86
+ if (ws._pongPending) {
87
+ // Previous ping never got a pong — connection is dead
88
+ ws.terminate();
89
+ return;
90
+ }
91
+ ws._pongPending = true;
92
+ ws.ping();
93
+ }
94
+ }, 15000);
87
95
  if (typeof pingInterval.unref === 'function') pingInterval.unref();
88
96
 
97
+ ws.on('pong', () => {
98
+ ws._pongPending = false;
99
+ });
100
+
89
101
  let authenticated = !auth.password;
90
102
  let attached = null;
91
103
 
@@ -166,6 +178,13 @@ function setupWebSocket(wss, { auth, sessions }) {
166
178
  }
167
179
  }
168
180
  ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
181
+ // Deliver any command-complete notifications that fired while disconnected
182
+ if (session.pendingNotifications.length > 0) {
183
+ for (const n of session.pendingNotifications) {
184
+ ws.send(JSON.stringify({ type: 'notification', ...n }));
185
+ }
186
+ session.pendingNotifications = [];
187
+ }
169
188
  log.info(`Client attached to session ${msg.sessionId}`);
170
189
  return;
171
190
  }