termbeam 1.18.0 → 1.19.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 (85) hide show
  1. package/package.json +4 -1
  2. package/public/assets/{_basePickBy-CVn0rIeA.js → _basePickBy-CPjv_JEU.js} +1 -1
  3. package/public/assets/{_baseUniq-D-ViDZI1.js → _baseUniq-BtieUu_k.js} +1 -1
  4. package/public/assets/{arc-BWX7iih_.js → arc-Op_Jfgj1.js} +1 -1
  5. package/public/assets/architectureDiagram-Q4EWVU46-BPBC-sFg.js +36 -0
  6. package/public/assets/{blockDiagram-WCTKOSBZ-RZQh_7bp.js → blockDiagram-DXYQGD6D-bhrY5jZb.js} +6 -6
  7. package/public/assets/c4Diagram-AHTNJAMY-Bbj59C07.js +10 -0
  8. package/public/assets/channel-BqlgTU2z.js +1 -0
  9. package/public/assets/{chunk-4BX2VUAB-Cz0Sexay.js → chunk-4BX2VUAB-NTfL5dnQ.js} +1 -1
  10. package/public/assets/chunk-4TB4RGXK-DI71V5fB.js +206 -0
  11. package/public/assets/{chunk-55IACEB6-DhjD4VKO.js → chunk-55IACEB6-DErzjt0v.js} +1 -1
  12. package/public/assets/{chunk-KX2RTZJC-BU-NTTwY.js → chunk-EDXVE4YY-D_8mpmH-.js} +1 -1
  13. package/public/assets/{chunk-FMBD7UC4-H_1l13Rz.js → chunk-FMBD7UC4-DxKx7vX-.js} +1 -1
  14. package/public/assets/chunk-OYMX7WX6-DWu1iUFO.js +231 -0
  15. package/public/assets/{chunk-QZHKN3VN-CrSItx1v.js → chunk-QZHKN3VN-Cnf_5dAG.js} +1 -1
  16. package/public/assets/{chunk-JSJVCQXG-D_GdzPQn.js → chunk-YZCP3GAM-mW6tkOzW.js} +1 -1
  17. package/public/assets/classDiagram-6PBFFD2Q-BUz6cVs_.js +1 -0
  18. package/public/assets/classDiagram-v2-HSJHXN6E-BUz6cVs_.js +1 -0
  19. package/public/assets/clone-RMCUsgPf.js +1 -0
  20. package/public/assets/{cose-bilkent-S5V4N54A-BNYNzK1p.js → cose-bilkent-S5V4N54A-913I4PUt.js} +1 -1
  21. package/public/assets/{dagre-KLK3FWXG-CvP5MaIU.js → dagre-KV5264BT-BDwVAKyX.js} +2 -2
  22. package/public/assets/diagram-5BDNPKRD-OcJJ0e9j.js +10 -0
  23. package/public/assets/diagram-G4DWMVQ6-Dm20tH1B.js +24 -0
  24. package/public/assets/diagram-MMDJMWI5-D-cm46Bv.js +43 -0
  25. package/public/assets/{diagram-P4PSJMXO-D7EqFseu.js → diagram-TYMM5635-CbPn6K5D.js} +1 -1
  26. package/public/assets/erDiagram-SMLLAGMA-4AUfKRLi.js +85 -0
  27. package/public/assets/flowDiagram-DWJPFMVM-BJ4qWDVH.js +162 -0
  28. package/public/assets/{ganttDiagram-A5KZAMGK-C55hiPqb.js → ganttDiagram-T4ZO3ILL-_q7m5Uo2.js} +4 -4
  29. package/public/assets/gitGraphDiagram-UUTBAWPF-Ctcn_coL.js +106 -0
  30. package/public/assets/{graph-mq5DkKwp.js → graph-mh6uBCte.js} +1 -1
  31. package/public/assets/index-2RolNYEW.css +32 -0
  32. package/public/assets/index-CuJ2Kdhb.js +447 -0
  33. package/public/assets/{infoDiagram-LFFYTUFH-Cisyfr_w.js → infoDiagram-42DDH7IO-Cbl2ZkB4.js} +1 -1
  34. package/public/assets/{ishikawaDiagram-PHBUUO56-BjaKmPoV.js → ishikawaDiagram-UXIWVN3A-Buq4uztC.js} +2 -2
  35. package/public/assets/journeyDiagram-VCZTEJTY-CLs6SdFG.js +139 -0
  36. package/public/assets/{kanban-definition-K7BYSVSG-D9TVNfL2.js → kanban-definition-6JOO6SKY-CflX5gNH.js} +8 -8
  37. package/public/assets/{layout-DIfi_3Cg.js → layout-e9E0SnY2.js} +1 -1
  38. package/public/assets/{linear-CNFortHj.js → linear-Bue4Zf_D.js} +1 -1
  39. package/public/assets/mindmap-definition-QFDTVHPH-B8iZj3K3.js +96 -0
  40. package/public/assets/pieDiagram-DEJITSTG-B5dciSBP.js +30 -0
  41. package/public/assets/{quadrantDiagram-337W2JSQ-Dcj0avLR.js → quadrantDiagram-34T5L4WZ-CZaQCVxe.js} +1 -1
  42. package/public/assets/requirementDiagram-MS252O5E-2wYKQi_y.js +84 -0
  43. package/public/assets/{sankeyDiagram-WA2Y5GQK-CpT1bDmZ.js → sankeyDiagram-XADWPNL6-CX5Cdm9d.js} +1 -1
  44. package/public/assets/sequenceDiagram-FGHM5R23-BUWxC38t.js +157 -0
  45. package/public/assets/stateDiagram-FHFEXIEX-85EGIrQk.js +1 -0
  46. package/public/assets/stateDiagram-v2-QKLJ7IA2-OKhwT9tv.js +1 -0
  47. package/public/assets/timeline-definition-GMOUNBTQ-C3BtHzdG.js +120 -0
  48. package/public/assets/{vennDiagram-LZ73GAT5-Ci8AKqD1.js → vennDiagram-DHZGUBPP-Chw5is4z.js} +5 -5
  49. package/public/assets/wardley-RL74JXVD-FEtUMcEE.js +162 -0
  50. package/public/assets/wardleyDiagram-NUSXRM2D-C_sFSy0G.js +20 -0
  51. package/public/assets/xychartDiagram-5P7HB3ND-C9Qs21NZ.js +7 -0
  52. package/public/index.html +2 -2
  53. package/public/sw.js +2 -2
  54. package/src/server/index.js +5 -49
  55. package/src/server/routes.js +59 -72
  56. package/src/server/sessions.js +20 -0
  57. package/src/tunnel/index.js +4 -6
  58. package/src/utils/agent-sessions.js +210 -0
  59. package/src/utils/agents.js +116 -0
  60. package/src/utils/update-check.js +19 -0
  61. package/public/assets/architectureDiagram-2XIMDMQ5-Dr3bqfT2.js +0 -36
  62. package/public/assets/c4Diagram-IC4MRINW-BM1jg1mo.js +0 -10
  63. package/public/assets/channel-N7LACYfb.js +0 -1
  64. package/public/assets/chunk-NQ4KR5QH-_X3IQmYu.js +0 -220
  65. package/public/assets/chunk-WL4C6EOR-XtpH4KKr.js +0 -189
  66. package/public/assets/classDiagram-VBA2DB6C-Ct_F3N6M.js +0 -1
  67. package/public/assets/classDiagram-v2-RAHNMMFH-Ct_F3N6M.js +0 -1
  68. package/public/assets/clone-DfaqXUXL.js +0 -1
  69. package/public/assets/diagram-E7M64L7V-C1ypW2kT.js +0 -24
  70. package/public/assets/diagram-IFDJBPK2-CH5mcu6V.js +0 -43
  71. package/public/assets/erDiagram-INFDFZHY-Dc_91GQC.js +0 -70
  72. package/public/assets/flowDiagram-PKNHOUZH-wUw5Mjvb.js +0 -162
  73. package/public/assets/gitGraphDiagram-K3NZZRJ6-D3HBxR11.js +0 -65
  74. package/public/assets/index-BxVq7AYs.js +0 -394
  75. package/public/assets/index-Cpm34cTy.css +0 -32
  76. package/public/assets/journeyDiagram-4ABVD52K-BNP3C6Ph.js +0 -139
  77. package/public/assets/mindmap-definition-YRQLILUH-CfNe7W8X.js +0 -68
  78. package/public/assets/pieDiagram-SKSYHLDU-C3T1FeFD.js +0 -30
  79. package/public/assets/requirementDiagram-Z7DCOOCP-D4yTZU0p.js +0 -73
  80. package/public/assets/sequenceDiagram-2WXFIKYE-CPsVjHHb.js +0 -145
  81. package/public/assets/stateDiagram-RAJIS63D-CbB0QqSa.js +0 -1
  82. package/public/assets/stateDiagram-v2-FVOUBMTO-CQnYvVz1.js +0 -1
  83. package/public/assets/timeline-definition-YZTLITO2-Bi12ioU0.js +0 -61
  84. package/public/assets/treemap-KZPCXAKY-Ct-pligZ.js +0 -162
  85. package/public/assets/xychartDiagram-JWTSCODW-BsOGuDAL.js +0 -7
@@ -4,6 +4,8 @@ const fs = require('fs');
4
4
  const crypto = require('crypto');
5
5
  const express = require('express');
6
6
  const { detectShells } = require('../utils/shells');
7
+ const { getAvailableAgents } = require('../utils/agents');
8
+ const { getAgentSessions, getResumeCommand } = require('../utils/agent-sessions');
7
9
  const log = require('../utils/logger');
8
10
  const rateLimit = require('express-rate-limit');
9
11
 
@@ -362,6 +364,44 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
362
364
  res.json({ shells, default: match ? match.cmd : ds, cwd: config.cwd });
363
365
  });
364
366
 
367
+ // Available AI agents
368
+ app.get('/api/agents', apiRateLimit, auth.middleware, async (_req, res) => {
369
+ try {
370
+ const agents = await getAvailableAgents();
371
+ res.json({ agents });
372
+ } catch (err) {
373
+ log.warn(`Agent detection failed: ${err.message}`);
374
+ res.json({ agents: [] });
375
+ }
376
+ });
377
+
378
+ // Agent session history (for resume)
379
+ app.get('/api/agent-sessions', apiRateLimit, auth.middleware, async (req, res) => {
380
+ try {
381
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
382
+ const agent = req.query.agent || null;
383
+ const search = req.query.search || null;
384
+ const sessions = await getAgentSessions({ limit, agent, search });
385
+ res.json({ sessions });
386
+ } catch (err) {
387
+ log.warn(`Failed to read agent sessions: ${err.message}`);
388
+ res.json({ sessions: [] });
389
+ }
390
+ });
391
+
392
+ // Get resume command for a specific session
393
+ app.get(
394
+ '/api/agent-sessions/:agent/:id/resume-command',
395
+ apiRateLimit,
396
+ auth.middleware,
397
+ (req, res) => {
398
+ const { agent, id } = req.params;
399
+ const command = getResumeCommand({ agent, id });
400
+ if (!command) return res.status(400).json({ error: 'Unknown agent' });
401
+ res.json({ command });
402
+ },
403
+ );
404
+
365
405
  app.get('/api/sessions/:id/detect-port', auth.middleware, (req, res) => {
366
406
  log.debug(`Port detection requested for session ${req.params.id}`);
367
407
  const session = sessions.get(req.params.id);
@@ -595,7 +635,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
595
635
  return res.status(400).json({ error: 'Invalid dir parameter' });
596
636
  }
597
637
 
598
- const rootDir = path.resolve(session.cwd);
638
+ const rootDir = path.resolve(sessions.getSessionCwd(req.params.id));
599
639
  const dir = path.resolve(rootDir, req.query.dir || '.');
600
640
 
601
641
  const MAX_ENTRIES = 1000;
@@ -666,7 +706,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
666
706
  depth = parsedDepth;
667
707
  }
668
708
  depth = Math.min(Math.max(depth, 1), MAX_DEPTH);
669
- const rootDir = path.resolve(session.cwd);
709
+ const rootDir = path.resolve(sessions.getSessionCwd(req.params.id));
670
710
  let totalEntries = 0;
671
711
 
672
712
  function buildTree(dir, currentDepth) {
@@ -749,7 +789,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
749
789
  return res.status(400).json({ error: 'Missing file parameter' });
750
790
  }
751
791
 
752
- const rootDir = path.resolve(session.cwd);
792
+ const rootDir = path.resolve(sessions.getSessionCwd(req.params.id));
753
793
  const filePath = path.resolve(rootDir, file);
754
794
 
755
795
  try {
@@ -780,7 +820,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
780
820
  return res.status(400).json({ error: 'Missing file parameter' });
781
821
  }
782
822
 
783
- const rootDir = path.resolve(session.cwd);
823
+ const rootDir = path.resolve(sessions.getSessionCwd(req.params.id));
784
824
  const filePath = path.resolve(rootDir, file);
785
825
 
786
826
  try {
@@ -811,7 +851,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
811
851
  return res.status(400).json({ error: 'Missing file parameter' });
812
852
  }
813
853
 
814
- const rootDir = path.resolve(session.cwd);
854
+ const rootDir = path.resolve(sessions.getSessionCwd(req.params.id));
815
855
  const filePath = path.resolve(rootDir, file);
816
856
 
817
857
  try {
@@ -849,7 +889,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
849
889
  if (!session) return res.status(404).json({ error: 'Session not found' });
850
890
 
851
891
  try {
852
- const status = await getDetailedStatus(session.cwd);
892
+ const status = await getDetailedStatus(sessions.getSessionCwd(req.params.id));
853
893
  res.json(status);
854
894
  } catch (err) {
855
895
  log.warn(`Git status failed: ${err.message}`);
@@ -876,7 +916,11 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
876
916
  }
877
917
  }
878
918
  try {
879
- const diff = await getFileDiff(session.cwd, file, { staged, untracked, context });
919
+ const diff = await getFileDiff(sessions.getSessionCwd(req.params.id), file, {
920
+ staged,
921
+ untracked,
922
+ context,
923
+ });
880
924
  res.json(diff);
881
925
  } catch (err) {
882
926
  log.warn(`Git diff failed: ${err.message}`);
@@ -894,7 +938,7 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
894
938
  }
895
939
 
896
940
  try {
897
- const blame = await getFileBlame(session.cwd, file);
941
+ const blame = await getFileBlame(sessions.getSessionCwd(req.params.id), file);
898
942
  res.json(blame);
899
943
  } catch (err) {
900
944
  log.warn(`Git blame failed: ${err.message}`);
@@ -913,7 +957,10 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
913
957
  }
914
958
 
915
959
  try {
916
- const logResult = await getGitLog(session.cwd, { limit, file: file || null });
960
+ const logResult = await getGitLog(sessions.getSessionCwd(req.params.id), {
961
+ limit,
962
+ file: file || null,
963
+ });
917
964
  res.json(logResult);
918
965
  } catch (err) {
919
966
  log.warn(`Git log failed: ${err.message}`);
@@ -991,69 +1038,9 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
991
1038
  });
992
1039
  });
993
1040
 
994
- app.post('/api/tunnel/renew', apiRateLimit, auth.middleware, (_req, res) => {
995
- const { spawn } = require('child_process');
996
- const { findDevtunnel } = require('../tunnel');
997
- const cmd = findDevtunnel() || 'devtunnel';
998
- const proc = spawn(cmd, ['user', 'login', '-d'], {
999
- stdio: ['pipe', 'pipe', 'pipe'],
1000
- });
1001
-
1002
- let output = '';
1003
- let responded = false;
1004
-
1005
- const timeout = setTimeout(() => {
1006
- if (!responded) {
1007
- responded = true;
1008
- proc.kill();
1009
- res.status(504).json({ error: 'Timed out waiting for device code' });
1010
- }
1011
- }, 15000);
1012
-
1013
- function tryParse(data) {
1014
- if (responded || output.length > 10_000) return;
1015
- output += data;
1016
- // Entra: "open the page https://... and enter the code ABC123 to authenticate"
1017
- // GitHub: "Browse to https://... and enter the code: AB12-CD34"
1018
- const match =
1019
- output.match(/open the page (https:\/\/[^\s]+) and enter the code ([A-Z0-9]+)/i) ||
1020
- output.match(/Browse to (https:\/\/[^\s]+) and enter the code:?\s*([A-Z0-9-]+)/i);
1021
- if (match) {
1022
- responded = true;
1023
- clearTimeout(timeout);
1024
- // Stop reading output — we have what we need
1025
- proc.stdout.removeAllListeners('data');
1026
- proc.stderr.removeAllListeners('data');
1027
- res.json({
1028
- url: match[1],
1029
- code: match[2],
1030
- });
1031
- }
1032
- }
1033
-
1034
- proc.stdout.on('data', (d) => tryParse(d.toString()));
1035
- proc.stderr.on('data', (d) => tryParse(d.toString()));
1036
-
1037
- proc.on('close', (code) => {
1038
- clearTimeout(timeout);
1039
- if (!responded) {
1040
- responded = true;
1041
- if (code === 0) {
1042
- res.json({ ok: true, message: 'Already authenticated' });
1043
- } else {
1044
- res.status(500).json({ error: 'DevTunnel login failed' });
1045
- }
1046
- }
1047
- });
1048
-
1049
- proc.on('error', (err) => {
1050
- clearTimeout(timeout);
1051
- if (!responded) {
1052
- responded = true;
1053
- res.status(500).json({ error: err.message });
1054
- }
1055
- });
1056
- });
1041
+ // Tunnel renew endpoint removed DevTunnel CLI auto-refreshes OAuth
1042
+ // tokens. If auth truly expires, user must run "devtunnel user login" on
1043
+ // the host machine; the watchdog auto-reconnects after re-auth.
1057
1044
  }
1058
1045
 
1059
1046
  function cleanupUploadedFiles() {
@@ -407,10 +407,30 @@ class SessionManager {
407
407
  return true;
408
408
  }
409
409
 
410
+ /** Get the live CWD for a session (detected from PTY process). */
411
+ getSessionCwd(id) {
412
+ const s = this.sessions.get(id);
413
+ if (!s) return null;
414
+ const cached = _gitCache.get(id);
415
+ if (cached && cached.cwd) {
416
+ // Update session.cwd so endpoints using session.cwd directly get fresh values
417
+ s.cwd = cached.cwd;
418
+ return cached.cwd;
419
+ }
420
+ // Trigger a refresh for next time
421
+ scheduleGitRefresh(id, s.pty.pid, s.cwd);
422
+ return s.cwd;
423
+ }
424
+
410
425
  list() {
411
426
  const list = [];
412
427
  for (const [id, s] of this.sessions) {
413
428
  const { cwd, git } = getCachedGitInfo(id, s.pty.pid, s.cwd);
429
+ // Keep session.cwd in sync with detected live CWD
430
+ if (cwd !== s.cwd) {
431
+ log.debug(`Session ${id} CWD changed: ${s.cwd} → ${cwd}`);
432
+ s.cwd = cwd;
433
+ }
414
434
  list.push({
415
435
  id,
416
436
  name: s.name,
@@ -293,13 +293,11 @@ function checkTokenExpiry() {
293
293
  if (remaining <= TOKEN_EXPIRY_WARN_SECONDS && !expiryWarned) {
294
294
  expiryWarned = true;
295
295
  const minutes = Math.round(remaining / 60);
296
- log.warn(`DevTunnel token expires in ${minutes}m`);
297
- tunnelEvents.emit('auth-expiring', {
298
- expiresIn: remaining * 1000,
299
- provider: info.provider,
300
- });
296
+ log.warn(`DevTunnel token expires in ${minutes}m (will auto-refresh)`);
297
+ } else if (remaining > TOKEN_EXPIRY_WARN_SECONDS && expiryWarned) {
298
+ expiryWarned = false;
299
+ log.info('DevTunnel token was auto-refreshed');
301
300
  } else if (remaining > TOKEN_EXPIRY_WARN_SECONDS) {
302
- // Reset the warning flag when token is refreshed
303
301
  expiryWarned = false;
304
302
  }
305
303
  }
@@ -0,0 +1,210 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const log = require('./logger');
5
+
6
+ /**
7
+ * Read Copilot sessions from SQLite store.
8
+ * Returns array of { id, agent, summary, cwd, repo, branch, updatedAt, turnCount }
9
+ */
10
+ function readCopilotSessions(limit = 50) {
11
+ let Database;
12
+ try {
13
+ Database = require('better-sqlite3');
14
+ } catch {
15
+ return [];
16
+ }
17
+ const dbPath = path.join(os.homedir(), '.copilot', 'session-store.db');
18
+ if (!fs.existsSync(dbPath)) return [];
19
+
20
+ try {
21
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
22
+ const sessions = db
23
+ .prepare(
24
+ `
25
+ SELECT s.id, s.summary, s.cwd, s.repository, s.branch, s.updated_at,
26
+ (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id) as turn_count,
27
+ (SELECT substr(t.user_message, 1, 200) FROM turns t WHERE t.session_id = s.id ORDER BY t.turn_index ASC LIMIT 1) as first_msg
28
+ FROM sessions s
29
+ ORDER BY s.updated_at DESC
30
+ LIMIT ?
31
+ `,
32
+ )
33
+ .all(limit);
34
+ db.close();
35
+
36
+ return sessions
37
+ .filter((s) => s.turn_count > 0)
38
+ .map((s) => ({
39
+ id: s.id,
40
+ agent: 'copilot',
41
+ agentName: 'GitHub Copilot',
42
+ agentIcon: 'copilot',
43
+ summary: s.summary || s.first_msg || null,
44
+ cwd: s.cwd || null,
45
+ repo: s.repository || null,
46
+ branch: s.branch || null,
47
+ updatedAt: s.updated_at || null,
48
+ turnCount: s.turn_count || 0,
49
+ }));
50
+ } catch (err) {
51
+ log.warn(`Failed to read Copilot sessions: ${err.message}`);
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read Claude Code sessions from JSONL files.
58
+ * Returns array of unified session objects.
59
+ */
60
+ function readClaudeSessions(limit = 50) {
61
+ const baseDir = path.join(os.homedir(), '.claude', 'projects');
62
+ if (!fs.existsSync(baseDir)) return [];
63
+
64
+ try {
65
+ const sessions = [];
66
+ const projectDirs = fs.readdirSync(baseDir);
67
+
68
+ for (const projDir of projectDirs) {
69
+ const fullProjDir = path.join(baseDir, projDir);
70
+ if (!fs.statSync(fullProjDir).isDirectory()) continue;
71
+
72
+ // Decode CWD from directory name: -Users-foo-bar → /Users/foo/bar
73
+ const cwd = projDir.replace(/^-/, '/').replace(/-/g, '/');
74
+
75
+ const jsonlFiles = fs
76
+ .readdirSync(fullProjDir)
77
+ .filter((f) => f.endsWith('.jsonl'))
78
+ .map((f) => {
79
+ const fullPath = path.join(fullProjDir, f);
80
+ const stat = fs.statSync(fullPath);
81
+ return { file: f, path: fullPath, mtime: stat.mtime, size: stat.size };
82
+ })
83
+ .sort((a, b) => b.mtime - a.mtime);
84
+
85
+ for (const fileInfo of jsonlFiles.slice(0, 10)) {
86
+ try {
87
+ const sessionId = path.basename(fileInfo.file, '.jsonl');
88
+
89
+ // Read file and split into lines
90
+ const content = fs.readFileSync(fileInfo.path, 'utf8');
91
+ const rawLines = content.split('\n');
92
+
93
+ let cwdFromFile = cwd;
94
+ let branch = null;
95
+ let userTurnCount = 0;
96
+ let firstUserMsg = null;
97
+
98
+ for (const line of rawLines) {
99
+ if (!line.trim()) continue;
100
+ try {
101
+ const entry = JSON.parse(line);
102
+ if (!branch && entry.gitBranch) branch = entry.gitBranch;
103
+ if (entry.cwd) cwdFromFile = entry.cwd;
104
+ if (entry.type === 'user') {
105
+ userTurnCount++;
106
+ if (!firstUserMsg) {
107
+ // Claude stores user message at entry.message.content (not entry.data)
108
+ const msg = entry.message;
109
+ if (msg && typeof msg === 'object') {
110
+ const content = msg.content;
111
+ if (typeof content === 'string') {
112
+ // Skip meta/command messages (XML-tagged system entries)
113
+ if (!content.startsWith('<') && content.trim().length > 5) {
114
+ firstUserMsg = content.slice(0, 200);
115
+ }
116
+ } else if (Array.isArray(content)) {
117
+ for (const item of content) {
118
+ if (item && item.type === 'text' && typeof item.text === 'string') {
119
+ if (!item.text.startsWith('<') && item.text.trim().length > 5) {
120
+ firstUserMsg = item.text.slice(0, 200);
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {
130
+ // skip malformed line
131
+ }
132
+ }
133
+
134
+ // Skip empty sessions (no user interaction)
135
+ if (userTurnCount === 0) continue;
136
+
137
+ sessions.push({
138
+ id: sessionId,
139
+ agent: 'claude',
140
+ agentName: 'Claude Code',
141
+ agentIcon: 'claude',
142
+ summary: firstUserMsg || null,
143
+ cwd: cwdFromFile,
144
+ repo: null,
145
+ branch,
146
+ updatedAt: fileInfo.mtime.toISOString(),
147
+ turnCount: userTurnCount,
148
+ });
149
+ } catch (err) {
150
+ log.debug(`Failed to parse Claude session ${fileInfo.file}: ${err.message}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Sort by updated time descending
156
+ sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
157
+ return sessions.slice(0, limit);
158
+ } catch (err) {
159
+ log.warn(`Failed to read Claude sessions: ${err.message}`);
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Get all agent sessions from all sources, unified and sorted.
166
+ */
167
+ async function getAgentSessions({ limit = 100, agent = null, search = null } = {}) {
168
+ const results = [];
169
+
170
+ if (!agent || agent === 'copilot') {
171
+ results.push(...readCopilotSessions(limit));
172
+ }
173
+ if (!agent || agent === 'claude') {
174
+ results.push(...readClaudeSessions(limit));
175
+ }
176
+
177
+ // Sort all by updatedAt descending
178
+ results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
179
+
180
+ // Apply search filter (case-insensitive substring match)
181
+ if (search) {
182
+ const q = search.toLowerCase();
183
+ const filtered = results.filter(
184
+ (s) =>
185
+ (s.summary && s.summary.toLowerCase().includes(q)) ||
186
+ (s.cwd && s.cwd.toLowerCase().includes(q)) ||
187
+ (s.repo && s.repo.toLowerCase().includes(q)) ||
188
+ (s.branch && s.branch.toLowerCase().includes(q)),
189
+ );
190
+ return filtered.slice(0, limit);
191
+ }
192
+
193
+ return results.slice(0, limit);
194
+ }
195
+
196
+ /**
197
+ * Build the resume command for a given agent session.
198
+ */
199
+ function getResumeCommand(session) {
200
+ switch (session.agent) {
201
+ case 'copilot':
202
+ return `copilot --resume=${session.id}`;
203
+ case 'claude':
204
+ return `claude --resume ${session.id}`;
205
+ default:
206
+ return null;
207
+ }
208
+ }
209
+
210
+ module.exports = { getAgentSessions, getResumeCommand, readCopilotSessions, readClaudeSessions };
@@ -0,0 +1,116 @@
1
+ const child_process = require('child_process');
2
+ const os = require('os');
3
+ const log = require('./logger');
4
+
5
+ const KNOWN_AGENTS = [
6
+ {
7
+ id: 'copilot',
8
+ name: 'GitHub Copilot',
9
+ cmd: 'copilot',
10
+ icon: 'copilot',
11
+ detect: ['copilot', ['--version']],
12
+ },
13
+ {
14
+ id: 'gh-copilot',
15
+ name: 'GitHub Copilot (gh)',
16
+ cmd: 'gh',
17
+ args: ['copilot'],
18
+ icon: 'copilot',
19
+ detect: ['gh', ['copilot', '--version']],
20
+ },
21
+ {
22
+ id: 'claude',
23
+ name: 'Claude Code',
24
+ cmd: 'claude',
25
+ icon: 'claude',
26
+ detect: ['claude', ['--version']],
27
+ },
28
+ {
29
+ id: 'aider',
30
+ name: 'Aider',
31
+ cmd: 'aider',
32
+ icon: 'aider',
33
+ detect: ['aider', ['--version']],
34
+ },
35
+ {
36
+ id: 'codex',
37
+ name: 'Codex CLI',
38
+ cmd: 'codex',
39
+ icon: 'codex',
40
+ detect: ['codex', ['--version']],
41
+ },
42
+ ];
43
+
44
+ let cachedAgents = null;
45
+ let cacheTime = 0;
46
+ const CACHE_TTL = 60_000; // 60 seconds
47
+
48
+ function tryDetectAgent(agent) {
49
+ const [cmd, args] = agent.detect;
50
+ const isWindows = os.platform() === 'win32';
51
+ const candidates = isWindows ? [cmd, `${cmd}.cmd`, `${cmd}.exe`] : [cmd];
52
+
53
+ return new Promise((resolve) => {
54
+ let resolved = false;
55
+ let remaining = candidates.length;
56
+
57
+ for (const bin of candidates) {
58
+ child_process.execFile(
59
+ bin,
60
+ args,
61
+ { timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' },
62
+ (err, stdout) => {
63
+ remaining--;
64
+ if (resolved) return;
65
+ if (!err) {
66
+ resolved = true;
67
+ const version = (stdout || '').trim().split('\n')[0] || 'unknown';
68
+ resolve({
69
+ id: agent.id,
70
+ name: agent.name,
71
+ cmd: agent.cmd,
72
+ args: agent.args || [],
73
+ icon: agent.icon,
74
+ version,
75
+ });
76
+ } else if (remaining === 0) {
77
+ resolve(null);
78
+ }
79
+ },
80
+ );
81
+ }
82
+ });
83
+ }
84
+
85
+ async function detectAgents() {
86
+ log.debug('Detecting available AI agents...');
87
+ const results = await Promise.allSettled(KNOWN_AGENTS.map(tryDetectAgent));
88
+
89
+ const agents = [];
90
+ for (const result of results) {
91
+ if (result.status === 'fulfilled' && result.value) {
92
+ agents.push(result.value);
93
+ }
94
+ }
95
+
96
+ // Deduplicate: prefer standalone copilot over gh copilot
97
+ const hasCopilot = agents.some((a) => a.id === 'copilot');
98
+ const deduped = hasCopilot ? agents.filter((a) => a.id !== 'gh-copilot') : agents;
99
+
100
+ log.debug(
101
+ `Detected ${deduped.length} AI agent(s): ${deduped.map((a) => a.name).join(', ') || 'none'}`,
102
+ );
103
+ return deduped;
104
+ }
105
+
106
+ async function getAvailableAgents() {
107
+ const now = Date.now();
108
+ if (cachedAgents && now - cacheTime < CACHE_TTL) {
109
+ return cachedAgents;
110
+ }
111
+ cachedAgents = await detectAgents();
112
+ cacheTime = Date.now();
113
+ return cachedAgents;
114
+ }
115
+
116
+ module.exports = { detectAgents, getAvailableAgents, KNOWN_AGENTS };
@@ -78,6 +78,16 @@ function normalizeVersion(version) {
78
78
  * Returns false if either version cannot be parsed.
79
79
  */
80
80
  function isNewerVersion(current, latest) {
81
+ // Dev builds (e.g. 1.18.1-dev+dirty) of the same base version are running
82
+ // from source — never prompt to "update" to the same stable release.
83
+ if (isDevBuild(current)) {
84
+ const cur = normalizeVersion(current);
85
+ const lat = normalizeVersion(latest);
86
+ if (!cur || !lat) return false;
87
+ const sameBase = cur[0] === lat[0] && cur[1] === lat[1] && cur[2] === lat[2];
88
+ if (sameBase) return false;
89
+ // Different base version — fall through to normal comparison
90
+ }
81
91
  const cur = normalizeVersion(current);
82
92
  const lat = normalizeVersion(latest);
83
93
  if (!cur || !lat) return false;
@@ -104,6 +114,15 @@ function isPreRelease(version) {
104
114
  return v.includes('-');
105
115
  }
106
116
 
117
+ /**
118
+ * Check if a version is a local dev build (e.g. "1.18.1-dev+dirty", "1.18.1-dev.3+abcdef").
119
+ * Dev builds should never trigger update prompts — they're running from source.
120
+ */
121
+ function isDevBuild(version) {
122
+ if (typeof version !== 'string') return false;
123
+ return /-(dev|dirty)/.test(version) || /\+(dirty|dev)/.test(version);
124
+ }
125
+
107
126
  /**
108
127
  * Strip ANSI escape sequences and control characters from a string.
109
128
  * Prevents terminal injection if the registry returns malicious data.