termbeam 1.19.4 → 1.20.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 +16 -0
  2. package/package.json +1 -1
  3. package/public/assets/{_basePickBy-D2x9UR-Z.js → _basePickBy-CsEFpeup.js} +1 -1
  4. package/public/assets/{_baseUniq-C01jsmVS.js → _baseUniq-ZNfm73QS.js} +1 -1
  5. package/public/assets/{arc-t3uO9VFT.js → arc-89CrHVpy.js} +1 -1
  6. package/public/assets/{architectureDiagram-Q4EWVU46-DWUIuXit.js → architectureDiagram-Q4EWVU46-oOX9WpjV.js} +1 -1
  7. package/public/assets/{blockDiagram-DXYQGD6D-DnP4lNOB.js → blockDiagram-DXYQGD6D-Dv7Qlwwa.js} +1 -1
  8. package/public/assets/{c4Diagram-AHTNJAMY-B29P8b7E.js → c4Diagram-AHTNJAMY-DH6nUY9v.js} +1 -1
  9. package/public/assets/channel-1hhrLuQG.js +1 -0
  10. package/public/assets/{chunk-4BX2VUAB-BH7Ixc1K.js → chunk-4BX2VUAB-br_MLFTb.js} +1 -1
  11. package/public/assets/{chunk-4TB4RGXK-h7uQ9ZtR.js → chunk-4TB4RGXK-D6RT0VpP.js} +1 -1
  12. package/public/assets/{chunk-55IACEB6-D9ZHEhWx.js → chunk-55IACEB6-B6WHKDLC.js} +1 -1
  13. package/public/assets/{chunk-EDXVE4YY-BEKltVR7.js → chunk-EDXVE4YY-CfyEh_gx.js} +1 -1
  14. package/public/assets/{chunk-FMBD7UC4-BPkcv-bj.js → chunk-FMBD7UC4-IxUFwHkQ.js} +1 -1
  15. package/public/assets/{chunk-OYMX7WX6-C-wnBny1.js → chunk-OYMX7WX6-BuoXiEMH.js} +1 -1
  16. package/public/assets/{chunk-QZHKN3VN-DBZnU2yp.js → chunk-QZHKN3VN-gwjweP0s.js} +1 -1
  17. package/public/assets/{chunk-YZCP3GAM-C8GNavGc.js → chunk-YZCP3GAM-AxzesjLV.js} +1 -1
  18. package/public/assets/classDiagram-6PBFFD2Q-BCCxmX4-.js +1 -0
  19. package/public/assets/classDiagram-v2-HSJHXN6E-BCCxmX4-.js +1 -0
  20. package/public/assets/clone-Zlu95tC9.js +1 -0
  21. package/public/assets/{cose-bilkent-S5V4N54A-BeFh7BYc.js → cose-bilkent-S5V4N54A-DA0VKF4u.js} +1 -1
  22. package/public/assets/{dagre-KV5264BT-DlsYCBSj.js → dagre-KV5264BT-TROeRxkl.js} +1 -1
  23. package/public/assets/{diagram-5BDNPKRD-CnTlMSc9.js → diagram-5BDNPKRD-Cy0d3UWt.js} +1 -1
  24. package/public/assets/{diagram-G4DWMVQ6-CKODi7zI.js → diagram-G4DWMVQ6-DLqyiLD9.js} +1 -1
  25. package/public/assets/{diagram-MMDJMWI5-DEJGgmOX.js → diagram-MMDJMWI5-COX5ltag.js} +1 -1
  26. package/public/assets/{diagram-TYMM5635-Dju-tIVS.js → diagram-TYMM5635-jNMi-Wxw.js} +1 -1
  27. package/public/assets/{erDiagram-SMLLAGMA-CqPQSqot.js → erDiagram-SMLLAGMA-DPNWOmMF.js} +1 -1
  28. package/public/assets/{flowDiagram-DWJPFMVM-BeIRzZQp.js → flowDiagram-DWJPFMVM-CtEH78aJ.js} +1 -1
  29. package/public/assets/{ganttDiagram-T4ZO3ILL-B6BnA7VR.js → ganttDiagram-T4ZO3ILL-DnmNU9Yo.js} +1 -1
  30. package/public/assets/{gitGraphDiagram-UUTBAWPF-BoSi7fJX.js → gitGraphDiagram-UUTBAWPF-BEBkdt6e.js} +1 -1
  31. package/public/assets/{graph-uVutBrOm.js → graph-BrV-Z5FX.js} +1 -1
  32. package/public/assets/index-BBM5qWeb.js +455 -0
  33. package/public/assets/index-C2HbSpIZ.css +32 -0
  34. package/public/assets/{infoDiagram-42DDH7IO-DD-KdApo.js → infoDiagram-42DDH7IO-5B0oQBTo.js} +1 -1
  35. package/public/assets/{ishikawaDiagram-UXIWVN3A-D36iFaUH.js → ishikawaDiagram-UXIWVN3A-JvObAFoj.js} +1 -1
  36. package/public/assets/{journeyDiagram-VCZTEJTY-BMQDm-H-.js → journeyDiagram-VCZTEJTY-B4x5Q9FT.js} +1 -1
  37. package/public/assets/{kanban-definition-6JOO6SKY-D1FZXkK7.js → kanban-definition-6JOO6SKY-BJnqk1Pz.js} +1 -1
  38. package/public/assets/{layout-xVUStQT2.js → layout-CFi5eoUP.js} +1 -1
  39. package/public/assets/{linear-BTv56PNK.js → linear-BdauaB9G.js} +1 -1
  40. package/public/assets/{mindmap-definition-QFDTVHPH-CvhBJGrR.js → mindmap-definition-QFDTVHPH-B5a5e6e4.js} +1 -1
  41. package/public/assets/{pieDiagram-DEJITSTG-DcxBOIJ2.js → pieDiagram-DEJITSTG-BasykeiV.js} +1 -1
  42. package/public/assets/{quadrantDiagram-34T5L4WZ-D79TxdrP.js → quadrantDiagram-34T5L4WZ-BpIn9pue.js} +1 -1
  43. package/public/assets/{requirementDiagram-MS252O5E-gOOiR6tu.js → requirementDiagram-MS252O5E-bScQOYzc.js} +1 -1
  44. package/public/assets/{sankeyDiagram-XADWPNL6-YUncdO2g.js → sankeyDiagram-XADWPNL6-CdwsbfFQ.js} +1 -1
  45. package/public/assets/{sequenceDiagram-FGHM5R23-eoBFRqV1.js → sequenceDiagram-FGHM5R23-BLSeDdFS.js} +1 -1
  46. package/public/assets/{stateDiagram-FHFEXIEX-DeQeLuN0.js → stateDiagram-FHFEXIEX-DwQJYdc7.js} +1 -1
  47. package/public/assets/stateDiagram-v2-QKLJ7IA2-BxWrp5cC.js +1 -0
  48. package/public/assets/{timeline-definition-GMOUNBTQ-CV0p2TOx.js → timeline-definition-GMOUNBTQ-BkLAz5O2.js} +1 -1
  49. package/public/assets/{vennDiagram-DHZGUBPP-CciIt7hk.js → vennDiagram-DHZGUBPP-DiiYjk-t.js} +1 -1
  50. package/public/assets/{wardley-RL74JXVD-DpAn0g0p.js → wardley-RL74JXVD-BsijixfH.js} +1 -1
  51. package/public/assets/{wardleyDiagram-NUSXRM2D-BcEpTQV4.js → wardleyDiagram-NUSXRM2D-DZ7LhIvm.js} +1 -1
  52. package/public/assets/{xychartDiagram-5P7HB3ND-B-PklpIN.js → xychartDiagram-5P7HB3ND-CJNPkFhM.js} +1 -1
  53. package/public/index.html +2 -2
  54. package/public/sw.js +1 -1
  55. package/src/server/copilot-sdk.js +617 -0
  56. package/src/server/index.js +11 -2
  57. package/src/server/routes.js +440 -10
  58. package/src/server/sessions.js +6 -0
  59. package/src/server/websocket.js +175 -2
  60. package/public/assets/channel-Du2155FM.js +0 -1
  61. package/public/assets/classDiagram-6PBFFD2Q-Dzf6e5xB.js +0 -1
  62. package/public/assets/classDiagram-v2-HSJHXN6E-Dzf6e5xB.js +0 -1
  63. package/public/assets/clone-VT9_rs7L.js +0 -1
  64. package/public/assets/index-C0J_Dxjj.css +0 -32
  65. package/public/assets/index-NvPavSM9.js +0 -447
  66. package/public/assets/stateDiagram-v2-QKLJ7IA2-BhqrHPnX.js +0 -1
@@ -7,6 +7,7 @@ const { detectShells } = require('../utils/shells');
7
7
  const { getAvailableAgents } = require('../utils/agents');
8
8
  const { getAgentSessions, getResumeCommand } = require('../utils/agent-sessions');
9
9
  const log = require('../utils/logger');
10
+ const { getGitInfo } = require('../utils/git');
10
11
  const rateLimit = require('express-rate-limit');
11
12
 
12
13
  const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -19,8 +20,46 @@ function safePath(rootDir, userPath) {
19
20
  return resolved;
20
21
  }
21
22
 
23
+ /**
24
+ * Validate and sanitize a user-provided cwd path.
25
+ * Returns the canonical real path or null if invalid.
26
+ */
27
+ function validateCwd(userCwd) {
28
+ if (!userCwd || typeof userCwd !== 'string') return null;
29
+ try {
30
+ const real = fs.realpathSync(path.resolve(userCwd));
31
+ if (!path.isAbsolute(real)) return null;
32
+ if (!fs.statSync(real).isDirectory()) return null; // lgtm[js/path-injection]
33
+ return real;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
22
39
  const uploadedFiles = new Map(); // id -> filepath
23
40
 
41
+ // Cache git info per cwd to avoid repeated git calls on each /api/sessions request
42
+ const gitInfoCache = new Map(); // cwd -> { data, ts }
43
+ const GIT_CACHE_TTL = 10_000; // 10 seconds
44
+
45
+ function getCachedGitInfo(cwd) {
46
+ if (!cwd) return null;
47
+ const cached = gitInfoCache.get(cwd);
48
+ if (cached && Date.now() - cached.ts < GIT_CACHE_TTL) return cached.data;
49
+ try {
50
+ const data = getGitInfo(cwd);
51
+ gitInfoCache.set(cwd, { data, ts: Date.now() });
52
+ // Evict oldest entry when cache exceeds 100 entries
53
+ if (gitInfoCache.size > 100) {
54
+ const oldest = gitInfoCache.keys().next().value;
55
+ gitInfoCache.delete(oldest);
56
+ }
57
+ return data;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
24
63
  const IMAGE_SIGNATURES = [
25
64
  { type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
26
65
  { type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
@@ -45,7 +84,7 @@ function validateMagicBytes(buffer, contentType) {
45
84
  return true;
46
85
  }
47
86
 
48
- function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
87
+ function setupRoutes(app, { auth, sessions, config, state, pushManager, copilotService }) {
49
88
  const pageRateLimit = rateLimit({
50
89
  windowMs: 1 * 60 * 1000,
51
90
  max: 120,
@@ -64,7 +103,12 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
64
103
  res.status(429).json({ error: 'Too many requests, please try again later.' }),
65
104
  });
66
105
 
67
- // Serve static files
106
+ // Serve static files — sw.js must never be cached by the browser
107
+ app.get('/sw.js', (_req, res, next) => {
108
+ res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
109
+ res.set('Service-Worker-Allowed', '/');
110
+ next();
111
+ });
68
112
  app.use(express.static(PUBLIC_DIR, { index: false }));
69
113
 
70
114
  // Login page
@@ -280,6 +324,9 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
280
324
  app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
281
325
  res.sendFile('index.html', { root: PUBLIC_DIR }),
282
326
  );
327
+ app.get('/agent', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
328
+ res.sendFile('index.html', { root: PUBLIC_DIR }),
329
+ );
283
330
  app.get('/code/:sessionId', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
284
331
  res.sendFile('index.html', { root: PUBLIC_DIR }),
285
332
  );
@@ -296,11 +343,47 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
296
343
  // Session API
297
344
  app.get('/api/sessions', apiRateLimit, auth.middleware, (_req, res) => {
298
345
  log.debug('Sessions list requested');
299
- res.json(sessions.list());
346
+ const ptySessions = sessions.list();
347
+ const copilotSessions = copilotService?.listSessionsDetailed() || [];
348
+
349
+ const all = [
350
+ ...ptySessions,
351
+ ...copilotSessions.map((s) => {
352
+ const cwdValid = typeof s.cwd === 'string' && path.isAbsolute(s.cwd);
353
+ return {
354
+ id: s.id,
355
+ name: s.name,
356
+ type: 'copilot',
357
+ cwd: cwdValid ? s.cwd : null,
358
+ model: s.model,
359
+ ptySessionId: s.ptySessionId || null,
360
+ createdAt: s.createdAt || new Date().toISOString(),
361
+ lastActivity: s.lastActivity || s.createdAt || new Date().toISOString(),
362
+ shell: 'copilot-sdk',
363
+ pid: 0,
364
+ clients: 0,
365
+ color: '#8b5cf6',
366
+ git: cwdValid ? getCachedGitInfo(s.cwd) : null,
367
+ };
368
+ }),
369
+ ];
370
+
371
+ res.json(all);
300
372
  });
301
373
 
302
- app.post('/api/sessions', apiRateLimit, auth.middleware, (req, res) => {
303
- const { name, shell, args: shellArgs, cwd, initialCommand, color, cols, rows } = req.body || {};
374
+ app.post('/api/sessions', apiRateLimit, auth.middleware, async (req, res) => {
375
+ const {
376
+ name,
377
+ shell,
378
+ args: shellArgs,
379
+ cwd,
380
+ initialCommand,
381
+ color,
382
+ cols,
383
+ rows,
384
+ type,
385
+ hidden,
386
+ } = req.body || {};
304
387
 
305
388
  // Validate shell field
306
389
  if (shell) {
@@ -328,6 +411,68 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
328
411
  }
329
412
  }
330
413
 
414
+ // Validate type field
415
+ if (type !== undefined && type !== 'terminal' && type !== 'agent' && type !== 'copilot') {
416
+ log.warn(`Session creation failed: invalid type "${type}"`);
417
+ return res.status(400).json({ error: 'type must be "terminal", "agent", or "copilot"' });
418
+ }
419
+
420
+ // Handle copilot SDK sessions — create both SDK + companion PTY
421
+ if (type === 'copilot') {
422
+ if (!copilotService) {
423
+ return res.status(400).json({ error: 'Copilot service is not available' });
424
+ }
425
+ // Validate cwd for copilot sessions
426
+ if (cwd) {
427
+ const validCwd = validateCwd(cwd);
428
+ if (!validCwd)
429
+ return res.status(400).json({ error: 'cwd must be an existing absolute directory' });
430
+ }
431
+
432
+ let ptySessionId = null;
433
+ try {
434
+ const sessionCwd = cwd ? validateCwd(cwd) || config.cwd : config.cwd;
435
+
436
+ // Create a companion PTY terminal first
437
+ try {
438
+ ptySessionId = sessions.create({
439
+ name: `${name || 'Copilot'} Terminal`,
440
+ shell: config.defaultShell,
441
+ cwd: sessionCwd,
442
+ type: 'terminal',
443
+ hidden: true,
444
+ });
445
+ } catch (ptyErr) {
446
+ log.warn('Failed to create companion PTY for copilot session:', ptyErr.message);
447
+ }
448
+
449
+ const sdkSessionId = await copilotService.createSession({
450
+ model: req.body.model,
451
+ cwd: sessionCwd,
452
+ name: name || 'Copilot Session',
453
+ ptySessionId,
454
+ });
455
+
456
+ return res.status(201).json({
457
+ id: sdkSessionId,
458
+ type: 'copilot',
459
+ ptySessionId,
460
+ url: `/terminal?id=${sdkSessionId}`,
461
+ });
462
+ } catch (err) {
463
+ // Clean up companion PTY if SDK session creation failed
464
+ if (ptySessionId) {
465
+ try {
466
+ sessions.delete(ptySessionId);
467
+ } catch {
468
+ /* ignore */
469
+ }
470
+ }
471
+ log.error('Failed to create Copilot session:', err.message);
472
+ return res.status(500).json({ error: 'Failed to create Copilot session: ' + err.message });
473
+ }
474
+ }
475
+
331
476
  // Validate cwd field
332
477
  if (cwd) {
333
478
  if (!path.isAbsolute(cwd)) {
@@ -356,12 +501,15 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
356
501
  color: color || null,
357
502
  cols: typeof cols === 'number' && cols > 0 && cols <= 500 ? Math.floor(cols) : undefined,
358
503
  rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
504
+ type: type || 'terminal',
505
+ hidden: hidden === true,
359
506
  });
360
507
  } catch (err) {
361
508
  log.warn(`Session creation failed: ${err.message}`);
362
509
  return res.status(400).json({ error: 'Failed to create session' });
363
510
  }
364
- res.status(201).json({ id, url: `/terminal?id=${id}` });
511
+ const url = `/terminal?id=${id}`;
512
+ res.status(201).json({ id, url });
365
513
  });
366
514
 
367
515
  // Available shells
@@ -441,12 +589,26 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
441
589
  }
442
590
  });
443
591
 
444
- app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
445
- if (sessions.delete(req.params.id)) {
446
- log.info(`Session deleted: ${req.params.id}`);
592
+ app.delete('/api/sessions/:id', auth.middleware, async (req, res) => {
593
+ const { id } = req.params;
594
+
595
+ // Try copilot first
596
+ if (copilotService?.sessions.has(id)) {
597
+ const entry = copilotService.sessions.get(id);
598
+ const ptyId = entry?.ptySessionId;
599
+ // Delete companion PTY first
600
+ if (ptyId) sessions.delete(ptyId);
601
+ await copilotService.disconnectSession(id);
602
+ log.info(`Copilot session deleted: ${id}`);
603
+ return res.status(204).end();
604
+ }
605
+
606
+ // Fall back to PTY
607
+ if (sessions.delete(id)) {
608
+ log.info(`Session deleted: ${id}`);
447
609
  res.status(204).end();
448
610
  } else {
449
- log.warn(`Session delete failed: not found (${req.params.id})`);
611
+ log.warn(`Session delete failed: not found (${id})`);
450
612
  res.status(404).json({ error: 'not found' });
451
613
  }
452
614
  });
@@ -1070,6 +1232,274 @@ function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
1070
1232
  // Tunnel renew endpoint removed — DevTunnel CLI auto-refreshes OAuth
1071
1233
  // tokens. If auth truly expires, user must run "devtunnel user login" on
1072
1234
  // the host machine; the watchdog auto-reconnects after re-auth.
1235
+
1236
+ // --- Copilot CLI session events ---
1237
+ const copilotSessionsDir =
1238
+ process.env.COPILOT_SESSIONS_DIR || path.join(os.homedir(), '.copilot', 'session-state');
1239
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
1240
+ const HOOK_TYPES = new Set(['hook.start', 'hook.end']);
1241
+
1242
+ let copilotSessionsCache = null;
1243
+ let copilotSessionsCacheTime = 0;
1244
+ const COPILOT_CACHE_TTL = 5000;
1245
+
1246
+ async function readCopilotSessions() {
1247
+ const now = Date.now();
1248
+ if (copilotSessionsCache && now - copilotSessionsCacheTime < COPILOT_CACHE_TTL) {
1249
+ return copilotSessionsCache;
1250
+ }
1251
+
1252
+ let entries;
1253
+ try {
1254
+ entries = await fs.promises.readdir(copilotSessionsDir, { withFileTypes: true });
1255
+ } catch {
1256
+ copilotSessionsCache = [];
1257
+ copilotSessionsCacheTime = now;
1258
+ return [];
1259
+ }
1260
+
1261
+ const dirs = entries.filter((e) => e.isDirectory() && UUID_RE.test(e.name)).map((e) => e.name);
1262
+
1263
+ const results = await Promise.all(
1264
+ dirs.map(async (id) => {
1265
+ const eventsPath = path.join(copilotSessionsDir, id, 'events.jsonl');
1266
+ try {
1267
+ const content = await fs.promises.readFile(eventsPath, 'utf8');
1268
+ const lines = content.split('\n').filter((l) => l.trim());
1269
+ let title = null;
1270
+ let startTime = null;
1271
+ let cwd = null;
1272
+ let branch = null;
1273
+ let repository = null;
1274
+
1275
+ for (const line of lines.slice(0, 20)) {
1276
+ try {
1277
+ const event = JSON.parse(line);
1278
+ if (event.type === 'session.start' && !startTime) {
1279
+ startTime = event.data?.startTime || event.timestamp;
1280
+ cwd = event.data?.context?.cwd || null;
1281
+ branch = event.data?.context?.branch || null;
1282
+ repository = event.data?.context?.repository || null;
1283
+ }
1284
+ if (event.type === 'user.message' && !title) {
1285
+ const msg = event.data?.content || '';
1286
+ title = msg.length > 80 ? msg.slice(0, 80) + '…' : msg;
1287
+ }
1288
+ if (startTime && title) break;
1289
+ } catch {
1290
+ // skip malformed lines
1291
+ }
1292
+ }
1293
+
1294
+ return {
1295
+ id,
1296
+ title: title || '(untitled)',
1297
+ startTime: startTime || null,
1298
+ cwd,
1299
+ branch,
1300
+ repository,
1301
+ eventCount: lines.length,
1302
+ };
1303
+ } catch {
1304
+ return null;
1305
+ }
1306
+ }),
1307
+ );
1308
+
1309
+ const sessions = results
1310
+ .filter(Boolean)
1311
+ .sort((a, b) => {
1312
+ if (!a.startTime) return 1;
1313
+ if (!b.startTime) return -1;
1314
+ return new Date(b.startTime) - new Date(a.startTime);
1315
+ })
1316
+ .slice(0, 50);
1317
+
1318
+ copilotSessionsCache = sessions;
1319
+ copilotSessionsCacheTime = now;
1320
+ return sessions;
1321
+ }
1322
+
1323
+ // --- Copilot SDK session creation ---
1324
+ if (copilotService) {
1325
+ app.post('/api/copilot/sdk/sessions', apiRateLimit, auth.middleware, async (req, res) => {
1326
+ try {
1327
+ const sessionId = await copilotService.createSession({
1328
+ model: req.body.model,
1329
+ });
1330
+ res.status(201).json({ sessionId });
1331
+ } catch (err) {
1332
+ res.status(500).json({ error: err.message });
1333
+ }
1334
+ });
1335
+
1336
+ app.post(
1337
+ '/api/copilot/sdk/sessions/:sdkSessionId/resume',
1338
+ apiRateLimit,
1339
+ auth.middleware,
1340
+ async (req, res) => {
1341
+ // Validate cwd for resume endpoint
1342
+ if (req.body.cwd) {
1343
+ const validCwd = validateCwd(req.body.cwd);
1344
+ if (!validCwd)
1345
+ return res.status(400).json({ error: 'cwd must be an existing absolute directory' });
1346
+ }
1347
+
1348
+ let ptySessionId = null;
1349
+ try {
1350
+ const { sdkSessionId } = req.params;
1351
+ const sessionCwd = req.body.cwd ? validateCwd(req.body.cwd) || config.cwd : config.cwd;
1352
+
1353
+ // Create companion PTY for the resumed session
1354
+ try {
1355
+ ptySessionId = sessions.create({
1356
+ name: `${req.body.name || 'Copilot'} Terminal`,
1357
+ shell: config.defaultShell,
1358
+ cwd: sessionCwd,
1359
+ type: 'terminal',
1360
+ hidden: true,
1361
+ });
1362
+ } catch (ptyErr) {
1363
+ log.warn('Failed to create companion PTY for resumed copilot session:', ptyErr.message);
1364
+ }
1365
+
1366
+ const sessionId = await copilotService.resumeSession(sdkSessionId, {
1367
+ name: req.body.name,
1368
+ model: req.body.model,
1369
+ ptySessionId,
1370
+ cwd: sessionCwd,
1371
+ });
1372
+ res.status(201).json({ id: sessionId, type: 'copilot', ptySessionId });
1373
+ } catch (err) {
1374
+ // Clean up companion PTY if SDK session resume failed
1375
+ if (ptySessionId) {
1376
+ try {
1377
+ sessions.delete(ptySessionId);
1378
+ } catch {
1379
+ /* ignore */
1380
+ }
1381
+ }
1382
+ log.error('Failed to resume Copilot SDK session:', err.message);
1383
+ res.status(500).json({ error: err.message });
1384
+ }
1385
+ },
1386
+ );
1387
+
1388
+ app.get('/api/copilot/sdk/sessions', apiRateLimit, auth.middleware, async (_req, res) => {
1389
+ try {
1390
+ const sessions = await copilotService.listSdkSessions();
1391
+ res.json({ sessions });
1392
+ } catch (err) {
1393
+ res.json({ sessions: [] });
1394
+ }
1395
+ });
1396
+ }
1397
+
1398
+ app.get('/api/copilot/active', apiRateLimit, auth.middleware, async (_req, res) => {
1399
+ try {
1400
+ let entries;
1401
+ try {
1402
+ entries = await fs.promises.readdir(copilotSessionsDir, { withFileTypes: true });
1403
+ } catch {
1404
+ return res.json({ sessionId: null });
1405
+ }
1406
+
1407
+ const dirs = entries
1408
+ .filter((e) => e.isDirectory() && UUID_RE.test(e.name))
1409
+ .map((e) => e.name);
1410
+
1411
+ const now = Date.now();
1412
+ let bestId = null;
1413
+ let bestMtime = 0;
1414
+
1415
+ await Promise.all(
1416
+ dirs.map(async (id) => {
1417
+ const eventsPath = path.join(copilotSessionsDir, id, 'events.jsonl');
1418
+ try {
1419
+ const stat = await fs.promises.stat(eventsPath);
1420
+ const age = now - stat.mtimeMs;
1421
+ if (age < 30000 && stat.mtimeMs > bestMtime) {
1422
+ bestMtime = stat.mtimeMs;
1423
+ bestId = id;
1424
+ }
1425
+ } catch {
1426
+ // no events.jsonl
1427
+ }
1428
+ }),
1429
+ );
1430
+
1431
+ res.json({ sessionId: bestId });
1432
+ } catch (err) {
1433
+ log.warn(`Failed to detect active Copilot session: ${err.message}`);
1434
+ res.json({ sessionId: null });
1435
+ }
1436
+ });
1437
+
1438
+ app.get('/api/copilot/sessions', apiRateLimit, auth.middleware, async (_req, res) => {
1439
+ try {
1440
+ const sessions = await readCopilotSessions();
1441
+ res.json({ sessions });
1442
+ } catch (err) {
1443
+ log.warn(`Failed to read Copilot sessions: ${err.message}`);
1444
+ res.json({ sessions: [] });
1445
+ }
1446
+ });
1447
+
1448
+ app.get(
1449
+ '/api/copilot/sessions/:sessionId/events',
1450
+ apiRateLimit,
1451
+ auth.middleware,
1452
+ async (req, res) => {
1453
+ const { sessionId } = req.params;
1454
+ if (!UUID_RE.test(sessionId)) {
1455
+ return res.status(400).json({ error: 'Invalid session ID' });
1456
+ }
1457
+
1458
+ const sessionDir = safePath(copilotSessionsDir, sessionId);
1459
+ if (!sessionDir) {
1460
+ return res.status(400).json({ error: 'Invalid session path' });
1461
+ }
1462
+ let eventsPath;
1463
+ try {
1464
+ eventsPath = fs.realpathSync(path.join(sessionDir, 'events.jsonl'));
1465
+ if (!eventsPath.startsWith(fs.realpathSync(copilotSessionsDir))) {
1466
+ return res.status(400).json({ error: 'Invalid session path' });
1467
+ }
1468
+ } catch {
1469
+ return res.status(404).json({ error: 'Session not found' });
1470
+ }
1471
+ let content;
1472
+ try {
1473
+ content = await fs.promises.readFile(eventsPath, 'utf8');
1474
+ } catch {
1475
+ return res.status(404).json({ error: 'Session not found' });
1476
+ }
1477
+
1478
+ const sinceIndex = parseInt(req.query.since, 10);
1479
+ const hasSince = !isNaN(sinceIndex) && sinceIndex >= 0;
1480
+ const typesParam = req.query.types;
1481
+ const typeFilter = typesParam ? new Set(typesParam.split(',').map((t) => t.trim())) : null;
1482
+
1483
+ const lines = content.split('\n').filter((l) => l.trim());
1484
+ const total = lines.length;
1485
+
1486
+ const startIndex = hasSince ? Math.min(sinceIndex, total) : 0;
1487
+ const events = [];
1488
+ for (let i = startIndex; i < total; i++) {
1489
+ try {
1490
+ const event = JSON.parse(lines[i]);
1491
+ // Filter out hook events by default unless explicitly requested
1492
+ if (!typeFilter && HOOK_TYPES.has(event.type)) continue;
1493
+ if (typeFilter && !typeFilter.has(event.type)) continue;
1494
+ events.push(event);
1495
+ } catch {
1496
+ // skip malformed lines
1497
+ }
1498
+ }
1499
+
1500
+ res.json({ events, total, hasMore: false });
1501
+ },
1502
+ );
1073
1503
  }
1074
1504
 
1075
1505
  function cleanupUploadedFiles() {
@@ -147,6 +147,8 @@ class SessionManager {
147
147
  color = null,
148
148
  cols = 120,
149
149
  rows = 30,
150
+ type = 'terminal',
151
+ hidden = false,
150
152
  }) {
151
153
  // Defense-in-depth: reject shells with dangerous characters or relative paths
152
154
  if (
@@ -194,6 +196,8 @@ class SessionManager {
194
196
  shell,
195
197
  cwd,
196
198
  color,
199
+ type,
200
+ hidden,
197
201
  createdAt: new Date().toISOString(),
198
202
  lastActivity: Date.now(),
199
203
  clients: new Set(),
@@ -440,6 +444,8 @@ class SessionManager {
440
444
  clients: s.clients.size,
441
445
  createdAt: s.createdAt,
442
446
  color: s.color,
447
+ type: s.type,
448
+ hidden: s.hidden || false,
443
449
  lastActivity: s.lastActivity,
444
450
  git,
445
451
  });