termbeam 1.15.2 → 1.17.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 (68) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -1
  3. package/public/assets/{_basePickBy-BSIbg2Hw.js → _basePickBy-Crmlna7W.js} +1 -1
  4. package/public/assets/{_baseUniq-CYmx81nY.js → _baseUniq-h6HY8nD4.js} +1 -1
  5. package/public/assets/{arc-CDJcNcKc.js → arc-BI4RNUD8.js} +1 -1
  6. package/public/assets/{architectureDiagram-2XIMDMQ5-C1qauSxh.js → architectureDiagram-2XIMDMQ5-C2PAl3D6.js} +1 -1
  7. package/public/assets/{blockDiagram-WCTKOSBZ-nTHCaU6g.js → blockDiagram-WCTKOSBZ-CADYyoNx.js} +1 -1
  8. package/public/assets/{c4Diagram-IC4MRINW-CdGuCZNN.js → c4Diagram-IC4MRINW-CQtNNlqT.js} +1 -1
  9. package/public/assets/channel-DlFJ0YtH.js +1 -0
  10. package/public/assets/{chunk-4BX2VUAB-IxfdQ8zN.js → chunk-4BX2VUAB-BZhBHL2q.js} +1 -1
  11. package/public/assets/{chunk-55IACEB6-mjdLMPLu.js → chunk-55IACEB6-DaOODotQ.js} +1 -1
  12. package/public/assets/{chunk-FMBD7UC4-B-QgE8A5.js → chunk-FMBD7UC4-D7ZUE2Qt.js} +1 -1
  13. package/public/assets/{chunk-JSJVCQXG-BlBtV3cx.js → chunk-JSJVCQXG-Cr7LmD49.js} +1 -1
  14. package/public/assets/{chunk-KX2RTZJC-ByLbXYtr.js → chunk-KX2RTZJC-mSzu7V0i.js} +1 -1
  15. package/public/assets/{chunk-NQ4KR5QH-DdgKg6ac.js → chunk-NQ4KR5QH-UNIo7K3P.js} +1 -1
  16. package/public/assets/{chunk-QZHKN3VN-DK0sNhO7.js → chunk-QZHKN3VN-D8pHtVTR.js} +1 -1
  17. package/public/assets/{chunk-WL4C6EOR-CMhwM8MW.js → chunk-WL4C6EOR-CKtSBmtm.js} +1 -1
  18. package/public/assets/classDiagram-VBA2DB6C-Uh272C_T.js +1 -0
  19. package/public/assets/classDiagram-v2-RAHNMMFH-Uh272C_T.js +1 -0
  20. package/public/assets/clone-BiOpyrvc.js +1 -0
  21. package/public/assets/{cose-bilkent-S5V4N54A-C4J5lbLg.js → cose-bilkent-S5V4N54A-C73dVsDU.js} +1 -1
  22. package/public/assets/{dagre-KLK3FWXG-CmPYo_iW.js → dagre-KLK3FWXG-CGtdO-e6.js} +1 -1
  23. package/public/assets/{diagram-E7M64L7V-BSDHjD_1.js → diagram-E7M64L7V-B3RnL1-2.js} +1 -1
  24. package/public/assets/{diagram-IFDJBPK2-DZFEThmE.js → diagram-IFDJBPK2-BhT13Y--.js} +1 -1
  25. package/public/assets/{diagram-P4PSJMXO-D2vA458R.js → diagram-P4PSJMXO-w4ta5qzj.js} +1 -1
  26. package/public/assets/{erDiagram-INFDFZHY-CqngKW80.js → erDiagram-INFDFZHY-p_XdulXc.js} +1 -1
  27. package/public/assets/{flowDiagram-PKNHOUZH-2ndb8I08.js → flowDiagram-PKNHOUZH-cKD9roCC.js} +1 -1
  28. package/public/assets/{ganttDiagram-A5KZAMGK-DGH9iwxm.js → ganttDiagram-A5KZAMGK-kRLcbnHy.js} +1 -1
  29. package/public/assets/{gitGraphDiagram-K3NZZRJ6-DBszyq19.js → gitGraphDiagram-K3NZZRJ6-CfqReYYJ.js} +1 -1
  30. package/public/assets/{graph-B-VDztTg.js → graph-2Z05uqaC.js} +1 -1
  31. package/public/assets/index-Bpz9aDGB.css +32 -0
  32. package/public/assets/index-Cvxh0Fjh.js +394 -0
  33. package/public/assets/{infoDiagram-LFFYTUFH-BQYostn9.js → infoDiagram-LFFYTUFH-D2bxFvYS.js} +1 -1
  34. package/public/assets/{ishikawaDiagram-PHBUUO56-BF9SDQjL.js → ishikawaDiagram-PHBUUO56-olWTIvNJ.js} +1 -1
  35. package/public/assets/{journeyDiagram-4ABVD52K-BVygcg_3.js → journeyDiagram-4ABVD52K-T_3LhARU.js} +1 -1
  36. package/public/assets/{kanban-definition-K7BYSVSG-C360CZ_M.js → kanban-definition-K7BYSVSG-BCmUNdAK.js} +1 -1
  37. package/public/assets/{layout-D1dS_Xae.js → layout-BuQ9md8V.js} +1 -1
  38. package/public/assets/{linear-DSiHoSbJ.js → linear-BGGATdCH.js} +1 -1
  39. package/public/assets/{mindmap-definition-YRQLILUH-DW7C3qtv.js → mindmap-definition-YRQLILUH-Bz_sgl78.js} +1 -1
  40. package/public/assets/{pieDiagram-SKSYHLDU-C8vfomtz.js → pieDiagram-SKSYHLDU-wxt-R3l5.js} +1 -1
  41. package/public/assets/{quadrantDiagram-337W2JSQ-DXT_qKk-.js → quadrantDiagram-337W2JSQ-0yTHkNo0.js} +1 -1
  42. package/public/assets/{requirementDiagram-Z7DCOOCP-Dj2MzFq3.js → requirementDiagram-Z7DCOOCP-CLqLwKcJ.js} +1 -1
  43. package/public/assets/{sankeyDiagram-WA2Y5GQK-YfmbQXg2.js → sankeyDiagram-WA2Y5GQK-CV2OX87k.js} +1 -1
  44. package/public/assets/{sequenceDiagram-2WXFIKYE-Bp9hgUSv.js → sequenceDiagram-2WXFIKYE-DaQifS2p.js} +1 -1
  45. package/public/assets/{stateDiagram-RAJIS63D-D8VgKzZe.js → stateDiagram-RAJIS63D-Bi5e4H5H.js} +1 -1
  46. package/public/assets/stateDiagram-v2-FVOUBMTO-D2d2wuS-.js +1 -0
  47. package/public/assets/{timeline-definition-YZTLITO2-3ErXxqpK.js → timeline-definition-YZTLITO2-Bu0j_UbL.js} +1 -1
  48. package/public/assets/{treemap-KZPCXAKY-D3-uSz_K.js → treemap-KZPCXAKY-BreHb2Q6.js} +1 -1
  49. package/public/assets/{vennDiagram-LZ73GAT5-D7Isk6A4.js → vennDiagram-LZ73GAT5-C5vHpUCv.js} +1 -1
  50. package/public/assets/{xychartDiagram-JWTSCODW-CpuvGwXM.js → xychartDiagram-JWTSCODW-DEN428FH.js} +1 -1
  51. package/public/index.html +2 -2
  52. package/public/sw.js +2 -2
  53. package/src/server/index.js +35 -4
  54. package/src/server/push.js +118 -0
  55. package/src/server/routes.js +350 -5
  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/update-check.js +139 -9
  60. package/src/utils/update-executor.js +340 -0
  61. package/src/utils/vapid.js +45 -0
  62. package/public/assets/channel-Bh_CZXn-.js +0 -1
  63. package/public/assets/classDiagram-VBA2DB6C-CSkcpaag.js +0 -1
  64. package/public/assets/classDiagram-v2-RAHNMMFH-CSkcpaag.js +0 -1
  65. package/public/assets/clone-BR1se3-G.js +0 -1
  66. package/public/assets/index-BWUfRdC9.js +0 -391
  67. package/public/assets/index-D_1GL6a5.css +0 -32
  68. package/public/assets/stateDiagram-v2-FVOUBMTO-By5luAVT.js +0 -1
@@ -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,
@@ -101,23 +101,142 @@ function setupRoutes(app, { auth, sessions, config, state }) {
101
101
 
102
102
  try {
103
103
  const info = await checkForUpdate({ currentVersion: config.version, force });
104
- const installInfo = detectInstallMethod();
105
- state.updateInfo = { ...info, ...installInfo };
104
+ const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
105
+ state.updateInfo = { ...info, ...publicInstallInfo };
106
106
  res.json(state.updateInfo);
107
107
  } catch (err) {
108
108
  log.warn(`Update check failed: ${err.message}`);
109
- const installInfo = detectInstallMethod();
109
+ const { installCmd, installArgs, ...publicInstallInfo } = detectInstallMethod();
110
110
  const fallback = {
111
111
  current: config.version,
112
112
  latest: null,
113
113
  updateAvailable: false,
114
- ...installInfo,
114
+ ...publicInstallInfo,
115
115
  };
116
116
  state.updateInfo = fallback;
117
117
  res.json(fallback);
118
118
  }
119
119
  });
120
120
 
121
+ // Trigger update — rate limited to 1 per 5 minutes
122
+ const updateTriggerLimit = rateLimit({
123
+ windowMs: 5 * 60 * 1000,
124
+ max: 1,
125
+ standardHeaders: true,
126
+ legacyHeaders: false,
127
+ handler: (_req, res) =>
128
+ res
129
+ .status(429)
130
+ .json({ error: 'Update already attempted recently. Try again in a few minutes.' }),
131
+ });
132
+
133
+ app.post('/api/update', auth.middleware, updateTriggerLimit, async (req, res) => {
134
+ const { detectInstallMethod } = require('../utils/update-check');
135
+ const { getUpdateState, executeUpdate, resetState } = require('../utils/update-executor');
136
+
137
+ const currentState = getUpdateState();
138
+ if (currentState.status !== 'idle' && currentState.status !== 'failed') {
139
+ return res.status(409).json({ error: 'Update already in progress', state: currentState });
140
+ }
141
+ // Reset state if retrying after a failure
142
+ if (currentState.status === 'failed') resetState();
143
+
144
+ const installInfo = detectInstallMethod();
145
+ if (!installInfo.canAutoUpdate) {
146
+ return res.status(400).json({
147
+ error: 'Auto-update not available for this installation method',
148
+ method: installInfo.method,
149
+ command: installInfo.command,
150
+ canAutoUpdate: false,
151
+ });
152
+ }
153
+
154
+ // Respond immediately — update runs in background
155
+ res.json({ status: 'updating', method: installInfo.method });
156
+
157
+ // Broadcast progress to WebSocket clients
158
+ const broadcastProgress = (updateStatus) => {
159
+ if (state.wss) {
160
+ const msg = JSON.stringify({ type: 'update-progress', ...updateStatus });
161
+ state.wss.clients.forEach((client) => {
162
+ if (client.readyState === 1) {
163
+ try {
164
+ client.send(msg);
165
+ } catch {
166
+ // Client may have disconnected
167
+ }
168
+ }
169
+ });
170
+ }
171
+ };
172
+
173
+ // Build the restart handler
174
+ const performRestart = async () => {
175
+ if (installInfo.restartStrategy === 'pm2') {
176
+ // PM2 restart — PM2 will bring the process back up
177
+ const { execFile: execFileCb } = require('child_process');
178
+ const serviceName = process.env.pm_id || 'termbeam';
179
+ broadcastProgress({
180
+ status: 'restarting',
181
+ phase: 'Restarting via PM2...',
182
+ restartStrategy: 'pm2',
183
+ });
184
+ // Give WS messages time to reach clients
185
+ await new Promise((r) => setTimeout(r, 1000));
186
+ // Use async execFile so WS messages can flush before the restart
187
+ execFileCb('pm2', ['restart', serviceName], { timeout: 10000, stdio: 'ignore' }, (err) => {
188
+ if (err) {
189
+ log.warn(`PM2 restart failed: ${err.message}`);
190
+ // Fall back to exit
191
+ sessions.shutdown();
192
+ process.exit(0);
193
+ }
194
+ });
195
+ } else {
196
+ // Exit strategy — clean shutdown, user must restart manually
197
+ broadcastProgress({
198
+ status: 'restarting',
199
+ phase: 'Update installed. Server shutting down...',
200
+ restartStrategy: 'exit',
201
+ });
202
+ // Close all WS connections with "Service Restart" close code
203
+ if (state.wss) {
204
+ state.wss.clients.forEach((client) => {
205
+ try {
206
+ client.close(1012, 'Server updated — please restart');
207
+ } catch {
208
+ // ignore
209
+ }
210
+ });
211
+ }
212
+ // Give WS close frames time to be sent
213
+ await new Promise((r) => setTimeout(r, 1000));
214
+ sessions.shutdown();
215
+ process.exit(0);
216
+ }
217
+ };
218
+
219
+ // Execute update in background (don't await in request handler)
220
+ executeUpdate({
221
+ currentVersion: config.version,
222
+ installCmd: installInfo.installCmd,
223
+ installArgs: installInfo.installArgs,
224
+ command: installInfo.command,
225
+ method: installInfo.method,
226
+ restartStrategy: installInfo.restartStrategy,
227
+ onProgress: broadcastProgress,
228
+ performRestart,
229
+ }).catch((err) => {
230
+ log.error(`Update execution error: ${err.message}`);
231
+ });
232
+ });
233
+
234
+ // Poll update status (fallback for when WS isn't connected)
235
+ app.get('/api/update/status', apiRateLimit, auth.middleware, (_req, res) => {
236
+ const { getUpdateState } = require('../utils/update-executor');
237
+ res.json(getUpdateState());
238
+ });
239
+
121
240
  // Share token auto-login middleware: validates ?ott= param, sets session cookie, redirects to clean URL
122
241
  function autoLogin(req, res, next) {
123
242
  const { ott } = req.query;
@@ -149,6 +268,9 @@ function setupRoutes(app, { auth, sessions, config, state }) {
149
268
  app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
150
269
  res.sendFile('index.html', { root: PUBLIC_DIR }),
151
270
  );
271
+ app.get('/code/:sessionId', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
272
+ res.sendFile('index.html', { root: PUBLIC_DIR }),
273
+ );
152
274
 
153
275
  // Share token — generates a temporary share token for the share button
154
276
  app.get('/api/share-token', auth.middleware, (req, res) => {
@@ -517,6 +639,105 @@ function setupRoutes(app, { auth, sessions, config, state }) {
517
639
  }
518
640
  });
519
641
 
642
+ // Recursive file tree for a session's CWD
643
+ app.get('/api/sessions/:id/file-tree', apiRateLimit, auth.middleware, (req, res) => {
644
+ const session = sessions.get(req.params.id);
645
+ if (!session) return res.status(404).json({ error: 'Session not found' });
646
+
647
+ const MAX_DEPTH = 5;
648
+ const MAX_ENTRIES = 2000;
649
+ const EXCLUDED = new Set([
650
+ 'node_modules',
651
+ '.git',
652
+ '__pycache__',
653
+ 'coverage',
654
+ '.next',
655
+ 'dist',
656
+ 'build',
657
+ ]);
658
+
659
+ let depth = 3;
660
+ if (typeof req.query.depth !== 'undefined') {
661
+ const parsedDepth = parseInt(req.query.depth, 10);
662
+ if (Number.isNaN(parsedDepth)) {
663
+ return res.status(400).json({ error: 'Invalid depth' });
664
+ }
665
+ depth = parsedDepth;
666
+ }
667
+ depth = Math.min(Math.max(depth, 1), MAX_DEPTH);
668
+ const rootDir = path.resolve(session.cwd);
669
+ let totalEntries = 0;
670
+
671
+ function buildTree(dir, currentDepth) {
672
+ let dirents;
673
+ try {
674
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
675
+ } catch {
676
+ return [];
677
+ }
678
+
679
+ const entries = [];
680
+ const filtered = dirents
681
+ .filter((e) => {
682
+ if (e.name.startsWith('.')) return false;
683
+ if (EXCLUDED.has(e.name)) return false;
684
+ try {
685
+ return !fs.lstatSync(path.join(dir, e.name)).isSymbolicLink();
686
+ } catch {
687
+ return false;
688
+ }
689
+ })
690
+ .sort((a, b) => {
691
+ const aDir = a.isDirectory();
692
+ const bDir = b.isDirectory();
693
+ if (aDir !== bDir) return aDir ? -1 : 1;
694
+ return a.name.localeCompare(b.name);
695
+ });
696
+
697
+ for (const e of filtered) {
698
+ if (totalEntries >= MAX_ENTRIES) break;
699
+ totalEntries++;
700
+
701
+ const fullPath = path.join(dir, e.name);
702
+ const relativePath = path.relative(rootDir, fullPath);
703
+ const isDir = e.isDirectory();
704
+
705
+ if (isDir) {
706
+ const children = currentDepth < depth ? buildTree(fullPath, currentDepth + 1) : [];
707
+ entries.push({
708
+ name: e.name,
709
+ type: 'directory',
710
+ path: relativePath.replace(/\\/g, '/'),
711
+ children,
712
+ });
713
+ } else {
714
+ let size = 0;
715
+ try {
716
+ size = fs.statSync(fullPath).size;
717
+ } catch {
718
+ // ignore stat errors
719
+ }
720
+ entries.push({
721
+ name: e.name,
722
+ type: 'file',
723
+ path: relativePath.replace(/\\/g, '/'),
724
+ size,
725
+ });
726
+ }
727
+ }
728
+
729
+ return entries;
730
+ }
731
+
732
+ try {
733
+ const tree = buildTree(rootDir, 1);
734
+ res.json({ root: rootDir, tree });
735
+ } catch (err) {
736
+ log.warn(`File tree failed: ${err.message}`);
737
+ res.status(500).json({ error: 'Failed to build file tree' });
738
+ }
739
+ });
740
+
520
741
  // Download a file from within a session's CWD
521
742
  app.get('/api/sessions/:id/download', apiRateLimit, auth.middleware, (req, res) => {
522
743
  const session = sessions.get(req.params.id);
@@ -610,6 +831,95 @@ function setupRoutes(app, { auth, sessions, config, state }) {
610
831
  }
611
832
  });
612
833
 
834
+ // --- Git change endpoints ---
835
+
836
+ const { getDetailedStatus, getFileDiff, getFileBlame, getGitLog } = require('../utils/git');
837
+
838
+ function validateFilePath(file) {
839
+ if (!file || typeof file !== 'string') return false;
840
+ if (path.isAbsolute(file)) return false;
841
+ const normalized = path.normalize(file);
842
+ if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false;
843
+ return true;
844
+ }
845
+
846
+ app.get('/api/sessions/:id/git/status', apiRateLimit, auth.middleware, async (req, res) => {
847
+ const session = sessions.get(req.params.id);
848
+ if (!session) return res.status(404).json({ error: 'Session not found' });
849
+
850
+ try {
851
+ const status = await getDetailedStatus(session.cwd);
852
+ res.json(status);
853
+ } catch (err) {
854
+ log.warn(`Git status failed: ${err.message}`);
855
+ res.status(500).json({ error: 'Failed to get git status' });
856
+ }
857
+ });
858
+
859
+ app.get('/api/sessions/:id/git/diff', apiRateLimit, auth.middleware, async (req, res) => {
860
+ const session = sessions.get(req.params.id);
861
+ if (!session) return res.status(404).json({ error: 'Session not found' });
862
+
863
+ const file = req.query.file;
864
+ if (!validateFilePath(file)) {
865
+ return res.status(400).json({ error: 'Invalid or missing file parameter' });
866
+ }
867
+
868
+ const staged = req.query.staged === 'true';
869
+ const untracked = req.query.untracked === 'true';
870
+ let context;
871
+ if (req.query.context !== undefined) {
872
+ const parsed = parseInt(req.query.context, 10);
873
+ if (Number.isFinite(parsed)) {
874
+ context = Math.min(Math.max(parsed, 0), 99999);
875
+ }
876
+ }
877
+ try {
878
+ const diff = await getFileDiff(session.cwd, file, { staged, untracked, context });
879
+ res.json(diff);
880
+ } catch (err) {
881
+ log.warn(`Git diff failed: ${err.message}`);
882
+ res.status(500).json({ error: 'Failed to get diff' });
883
+ }
884
+ });
885
+
886
+ app.get('/api/sessions/:id/git/blame', apiRateLimit, auth.middleware, async (req, res) => {
887
+ const session = sessions.get(req.params.id);
888
+ if (!session) return res.status(404).json({ error: 'Session not found' });
889
+
890
+ const file = req.query.file;
891
+ if (!validateFilePath(file)) {
892
+ return res.status(400).json({ error: 'Invalid or missing file parameter' });
893
+ }
894
+
895
+ try {
896
+ const blame = await getFileBlame(session.cwd, file);
897
+ res.json(blame);
898
+ } catch (err) {
899
+ log.warn(`Git blame failed: ${err.message}`);
900
+ res.status(500).json({ error: 'Failed to get blame' });
901
+ }
902
+ });
903
+
904
+ app.get('/api/sessions/:id/git/log', apiRateLimit, auth.middleware, async (req, res) => {
905
+ const session = sessions.get(req.params.id);
906
+ if (!session) return res.status(404).json({ error: 'Session not found' });
907
+
908
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
909
+ const file = req.query.file;
910
+ if (file && !validateFilePath(file)) {
911
+ return res.status(400).json({ error: 'Invalid file parameter' });
912
+ }
913
+
914
+ try {
915
+ const logResult = await getGitLog(session.cwd, { limit, file: file || null });
916
+ res.json(logResult);
917
+ } catch (err) {
918
+ log.warn(`Git log failed: ${err.message}`);
919
+ res.status(500).json({ error: 'Failed to get git log' });
920
+ }
921
+ });
922
+
613
923
  // Directory listing for folder browser
614
924
  app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
615
925
  log.debug(`Directory listing requested: ${req.query.q || config.cwd}`);
@@ -631,6 +941,41 @@ function setupRoutes(app, { auth, sessions, config, state }) {
631
941
  res.json({ base: dir, dirs: [], truncated: false });
632
942
  }
633
943
  });
944
+
945
+ // --- Push notification endpoints ---
946
+ if (pushManager) {
947
+ app.get('/api/push/vapid-key', apiRateLimit, auth.middleware, (_req, res) => {
948
+ const publicKey = pushManager.getPublicKey();
949
+ if (!publicKey) {
950
+ return res.status(503).json({ error: 'Push notifications not configured' });
951
+ }
952
+ res.json({ publicKey });
953
+ });
954
+
955
+ app.post('/api/push/subscribe', apiRateLimit, auth.middleware, (req, res) => {
956
+ const { subscription } = req.body || {};
957
+ if (
958
+ !subscription ||
959
+ !subscription.endpoint ||
960
+ !subscription.keys ||
961
+ !subscription.keys.p256dh ||
962
+ !subscription.keys.auth
963
+ ) {
964
+ return res.status(400).json({ error: 'Invalid subscription object' });
965
+ }
966
+ pushManager.subscribe(subscription);
967
+ res.json({ ok: true });
968
+ });
969
+
970
+ app.delete('/api/push/unsubscribe', apiRateLimit, auth.middleware, (req, res) => {
971
+ const { endpoint } = req.body || {};
972
+ if (!endpoint) {
973
+ return res.status(400).json({ error: 'Missing endpoint' });
974
+ }
975
+ pushManager.unsubscribe(endpoint);
976
+ res.json({ ok: true });
977
+ });
978
+ }
634
979
  }
635
980
 
636
981
  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
  }