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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/public/assets/{_basePickBy-D2x9UR-Z.js → _basePickBy-CsEFpeup.js} +1 -1
- package/public/assets/{_baseUniq-C01jsmVS.js → _baseUniq-ZNfm73QS.js} +1 -1
- package/public/assets/{arc-t3uO9VFT.js → arc-89CrHVpy.js} +1 -1
- package/public/assets/{architectureDiagram-Q4EWVU46-DWUIuXit.js → architectureDiagram-Q4EWVU46-oOX9WpjV.js} +1 -1
- package/public/assets/{blockDiagram-DXYQGD6D-DnP4lNOB.js → blockDiagram-DXYQGD6D-Dv7Qlwwa.js} +1 -1
- package/public/assets/{c4Diagram-AHTNJAMY-B29P8b7E.js → c4Diagram-AHTNJAMY-DH6nUY9v.js} +1 -1
- package/public/assets/channel-1hhrLuQG.js +1 -0
- package/public/assets/{chunk-4BX2VUAB-BH7Ixc1K.js → chunk-4BX2VUAB-br_MLFTb.js} +1 -1
- package/public/assets/{chunk-4TB4RGXK-h7uQ9ZtR.js → chunk-4TB4RGXK-D6RT0VpP.js} +1 -1
- package/public/assets/{chunk-55IACEB6-D9ZHEhWx.js → chunk-55IACEB6-B6WHKDLC.js} +1 -1
- package/public/assets/{chunk-EDXVE4YY-BEKltVR7.js → chunk-EDXVE4YY-CfyEh_gx.js} +1 -1
- package/public/assets/{chunk-FMBD7UC4-BPkcv-bj.js → chunk-FMBD7UC4-IxUFwHkQ.js} +1 -1
- package/public/assets/{chunk-OYMX7WX6-C-wnBny1.js → chunk-OYMX7WX6-BuoXiEMH.js} +1 -1
- package/public/assets/{chunk-QZHKN3VN-DBZnU2yp.js → chunk-QZHKN3VN-gwjweP0s.js} +1 -1
- package/public/assets/{chunk-YZCP3GAM-C8GNavGc.js → chunk-YZCP3GAM-AxzesjLV.js} +1 -1
- package/public/assets/classDiagram-6PBFFD2Q-BCCxmX4-.js +1 -0
- package/public/assets/classDiagram-v2-HSJHXN6E-BCCxmX4-.js +1 -0
- package/public/assets/clone-Zlu95tC9.js +1 -0
- package/public/assets/{cose-bilkent-S5V4N54A-BeFh7BYc.js → cose-bilkent-S5V4N54A-DA0VKF4u.js} +1 -1
- package/public/assets/{dagre-KV5264BT-DlsYCBSj.js → dagre-KV5264BT-TROeRxkl.js} +1 -1
- package/public/assets/{diagram-5BDNPKRD-CnTlMSc9.js → diagram-5BDNPKRD-Cy0d3UWt.js} +1 -1
- package/public/assets/{diagram-G4DWMVQ6-CKODi7zI.js → diagram-G4DWMVQ6-DLqyiLD9.js} +1 -1
- package/public/assets/{diagram-MMDJMWI5-DEJGgmOX.js → diagram-MMDJMWI5-COX5ltag.js} +1 -1
- package/public/assets/{diagram-TYMM5635-Dju-tIVS.js → diagram-TYMM5635-jNMi-Wxw.js} +1 -1
- package/public/assets/{erDiagram-SMLLAGMA-CqPQSqot.js → erDiagram-SMLLAGMA-DPNWOmMF.js} +1 -1
- package/public/assets/{flowDiagram-DWJPFMVM-BeIRzZQp.js → flowDiagram-DWJPFMVM-CtEH78aJ.js} +1 -1
- package/public/assets/{ganttDiagram-T4ZO3ILL-B6BnA7VR.js → ganttDiagram-T4ZO3ILL-DnmNU9Yo.js} +1 -1
- package/public/assets/{gitGraphDiagram-UUTBAWPF-BoSi7fJX.js → gitGraphDiagram-UUTBAWPF-BEBkdt6e.js} +1 -1
- package/public/assets/{graph-uVutBrOm.js → graph-BrV-Z5FX.js} +1 -1
- package/public/assets/index-BBM5qWeb.js +455 -0
- package/public/assets/index-C2HbSpIZ.css +32 -0
- package/public/assets/{infoDiagram-42DDH7IO-DD-KdApo.js → infoDiagram-42DDH7IO-5B0oQBTo.js} +1 -1
- package/public/assets/{ishikawaDiagram-UXIWVN3A-D36iFaUH.js → ishikawaDiagram-UXIWVN3A-JvObAFoj.js} +1 -1
- package/public/assets/{journeyDiagram-VCZTEJTY-BMQDm-H-.js → journeyDiagram-VCZTEJTY-B4x5Q9FT.js} +1 -1
- package/public/assets/{kanban-definition-6JOO6SKY-D1FZXkK7.js → kanban-definition-6JOO6SKY-BJnqk1Pz.js} +1 -1
- package/public/assets/{layout-xVUStQT2.js → layout-CFi5eoUP.js} +1 -1
- package/public/assets/{linear-BTv56PNK.js → linear-BdauaB9G.js} +1 -1
- package/public/assets/{mindmap-definition-QFDTVHPH-CvhBJGrR.js → mindmap-definition-QFDTVHPH-B5a5e6e4.js} +1 -1
- package/public/assets/{pieDiagram-DEJITSTG-DcxBOIJ2.js → pieDiagram-DEJITSTG-BasykeiV.js} +1 -1
- package/public/assets/{quadrantDiagram-34T5L4WZ-D79TxdrP.js → quadrantDiagram-34T5L4WZ-BpIn9pue.js} +1 -1
- package/public/assets/{requirementDiagram-MS252O5E-gOOiR6tu.js → requirementDiagram-MS252O5E-bScQOYzc.js} +1 -1
- package/public/assets/{sankeyDiagram-XADWPNL6-YUncdO2g.js → sankeyDiagram-XADWPNL6-CdwsbfFQ.js} +1 -1
- package/public/assets/{sequenceDiagram-FGHM5R23-eoBFRqV1.js → sequenceDiagram-FGHM5R23-BLSeDdFS.js} +1 -1
- package/public/assets/{stateDiagram-FHFEXIEX-DeQeLuN0.js → stateDiagram-FHFEXIEX-DwQJYdc7.js} +1 -1
- package/public/assets/stateDiagram-v2-QKLJ7IA2-BxWrp5cC.js +1 -0
- package/public/assets/{timeline-definition-GMOUNBTQ-CV0p2TOx.js → timeline-definition-GMOUNBTQ-BkLAz5O2.js} +1 -1
- package/public/assets/{vennDiagram-DHZGUBPP-CciIt7hk.js → vennDiagram-DHZGUBPP-DiiYjk-t.js} +1 -1
- package/public/assets/{wardley-RL74JXVD-DpAn0g0p.js → wardley-RL74JXVD-BsijixfH.js} +1 -1
- package/public/assets/{wardleyDiagram-NUSXRM2D-BcEpTQV4.js → wardleyDiagram-NUSXRM2D-DZ7LhIvm.js} +1 -1
- package/public/assets/{xychartDiagram-5P7HB3ND-B-PklpIN.js → xychartDiagram-5P7HB3ND-CJNPkFhM.js} +1 -1
- package/public/index.html +2 -2
- package/public/sw.js +1 -1
- package/src/server/copilot-sdk.js +617 -0
- package/src/server/index.js +11 -2
- package/src/server/routes.js +440 -10
- package/src/server/sessions.js +6 -0
- package/src/server/websocket.js +175 -2
- package/public/assets/channel-Du2155FM.js +0 -1
- package/public/assets/classDiagram-6PBFFD2Q-Dzf6e5xB.js +0 -1
- package/public/assets/classDiagram-v2-HSJHXN6E-Dzf6e5xB.js +0 -1
- package/public/assets/clone-VT9_rs7L.js +0 -1
- package/public/assets/index-C0J_Dxjj.css +0 -32
- package/public/assets/index-NvPavSM9.js +0 -447
- package/public/assets/stateDiagram-v2-QKLJ7IA2-BhqrHPnX.js +0 -1
package/src/server/routes.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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 (${
|
|
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() {
|
package/src/server/sessions.js
CHANGED
|
@@ -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
|
});
|