termbeam 1.15.1 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +2 -1
- package/public/assets/{_basePickBy-DDga1sgN.js → _basePickBy-CYR9pyOe.js} +1 -1
- package/public/assets/{_baseUniq-CKfoLvLE.js → _baseUniq-DeFSIx-P.js} +1 -1
- package/public/assets/{arc-DbSCVdo8.js → arc-CUEX1fu7.js} +1 -1
- package/public/assets/{architectureDiagram-2XIMDMQ5-Ce5knFNR.js → architectureDiagram-2XIMDMQ5-BHhXPzZJ.js} +1 -1
- package/public/assets/{blockDiagram-WCTKOSBZ-DJiZx7DH.js → blockDiagram-WCTKOSBZ-RsOwF2Ow.js} +1 -1
- package/public/assets/{c4Diagram-IC4MRINW-kOHNvx7n.js → c4Diagram-IC4MRINW-B7KKaZ1J.js} +1 -1
- package/public/assets/channel-CBJEzKmm.js +1 -0
- package/public/assets/{chunk-4BX2VUAB-DxmUWf39.js → chunk-4BX2VUAB-DOUcZxxl.js} +1 -1
- package/public/assets/{chunk-55IACEB6-BdvL648G.js → chunk-55IACEB6-bPgkuqF0.js} +1 -1
- package/public/assets/{chunk-FMBD7UC4-Bp3FkcH2.js → chunk-FMBD7UC4-BWT_ExWr.js} +1 -1
- package/public/assets/{chunk-JSJVCQXG-DOtbuVd2.js → chunk-JSJVCQXG-Df0AgfkZ.js} +1 -1
- package/public/assets/{chunk-KX2RTZJC-b_RAN48_.js → chunk-KX2RTZJC-DnYuhgK5.js} +1 -1
- package/public/assets/{chunk-NQ4KR5QH-CKHEKES_.js → chunk-NQ4KR5QH-Dge50UUS.js} +1 -1
- package/public/assets/{chunk-QZHKN3VN-Ce3Cy8iK.js → chunk-QZHKN3VN-BT0knyhA.js} +1 -1
- package/public/assets/{chunk-WL4C6EOR-CYlFnkd_.js → chunk-WL4C6EOR-DutXGT-d.js} +1 -1
- package/public/assets/classDiagram-VBA2DB6C-C-rOD9EU.js +1 -0
- package/public/assets/classDiagram-v2-RAHNMMFH-C-rOD9EU.js +1 -0
- package/public/assets/clone-DIyhZC23.js +1 -0
- package/public/assets/{cose-bilkent-S5V4N54A-Curtohg5.js → cose-bilkent-S5V4N54A-doAicD_V.js} +1 -1
- package/public/assets/{dagre-KLK3FWXG-CuZNu96V.js → dagre-KLK3FWXG-O4cFm_hK.js} +1 -1
- package/public/assets/{diagram-E7M64L7V-CunKBx6l.js → diagram-E7M64L7V-BifAzLVq.js} +1 -1
- package/public/assets/{diagram-IFDJBPK2-BN7aHVm4.js → diagram-IFDJBPK2-BfnxORJG.js} +1 -1
- package/public/assets/{diagram-P4PSJMXO-B4lVLdoW.js → diagram-P4PSJMXO-DTr1JYXb.js} +1 -1
- package/public/assets/{erDiagram-INFDFZHY-EenQr3uP.js → erDiagram-INFDFZHY-l1N_y881.js} +1 -1
- package/public/assets/{flowDiagram-PKNHOUZH-CvFkNm_6.js → flowDiagram-PKNHOUZH-CLGWYVco.js} +1 -1
- package/public/assets/{ganttDiagram-A5KZAMGK-DY9bVz9l.js → ganttDiagram-A5KZAMGK-9ERk2sFV.js} +1 -1
- package/public/assets/{gitGraphDiagram-K3NZZRJ6-BAd-taYp.js → gitGraphDiagram-K3NZZRJ6-BxK3Z85E.js} +1 -1
- package/public/assets/{graph-B5Mupk4w.js → graph-PwJPsvsO.js} +1 -1
- package/public/assets/index-0a9Qn-A1.js +394 -0
- package/public/assets/index-Z_lybSmO.css +32 -0
- package/public/assets/{infoDiagram-LFFYTUFH-vhp46Ys0.js → infoDiagram-LFFYTUFH-DygGOypU.js} +1 -1
- package/public/assets/{ishikawaDiagram-PHBUUO56-GppOId5G.js → ishikawaDiagram-PHBUUO56-CySga9vu.js} +1 -1
- package/public/assets/{journeyDiagram-4ABVD52K-Bf8IH4_E.js → journeyDiagram-4ABVD52K-ZIZNkXyJ.js} +1 -1
- package/public/assets/{kanban-definition-K7BYSVSG-CsV7UppO.js → kanban-definition-K7BYSVSG-IxWUQjiQ.js} +1 -1
- package/public/assets/{layout-TFe_JtAk.js → layout-DbFs-9Gp.js} +1 -1
- package/public/assets/{linear-jRuCITkz.js → linear-F1crC_h8.js} +1 -1
- package/public/assets/{mindmap-definition-YRQLILUH-Drqk-jqT.js → mindmap-definition-YRQLILUH-BwXWnIOB.js} +1 -1
- package/public/assets/{pieDiagram-SKSYHLDU-zhtwIYVW.js → pieDiagram-SKSYHLDU-CTKX_qGt.js} +1 -1
- package/public/assets/{quadrantDiagram-337W2JSQ-CgighrLO.js → quadrantDiagram-337W2JSQ-C9hYwyla.js} +1 -1
- package/public/assets/{requirementDiagram-Z7DCOOCP-Blze02a-.js → requirementDiagram-Z7DCOOCP-eOJ_I7lS.js} +1 -1
- package/public/assets/{sankeyDiagram-WA2Y5GQK-DP2pOuJP.js → sankeyDiagram-WA2Y5GQK-CNYzy0Z-.js} +1 -1
- package/public/assets/{sequenceDiagram-2WXFIKYE-2ZQZVVEw.js → sequenceDiagram-2WXFIKYE-ChHor0a9.js} +1 -1
- package/public/assets/{stateDiagram-RAJIS63D-CxBDjO6s.js → stateDiagram-RAJIS63D-CF2B1sp8.js} +1 -1
- package/public/assets/stateDiagram-v2-FVOUBMTO-Doxped-1.js +1 -0
- package/public/assets/{timeline-definition-YZTLITO2-Du6zdjZw.js → timeline-definition-YZTLITO2-H72vBjMX.js} +1 -1
- package/public/assets/{treemap-KZPCXAKY-Duew5oqq.js → treemap-KZPCXAKY-CWEZDi_m.js} +1 -1
- package/public/assets/{vennDiagram-LZ73GAT5-D9zfCBm9.js → vennDiagram-LZ73GAT5-BEqSLv2W.js} +1 -1
- package/public/assets/{xychartDiagram-JWTSCODW-BJuwWw4_.js → xychartDiagram-JWTSCODW-enJM4ByX.js} +1 -1
- package/public/index.html +2 -2
- package/public/sw.js +2 -2
- package/src/server/index.js +22 -3
- package/src/server/push.js +118 -0
- package/src/server/routes.js +227 -1
- package/src/server/sessions.js +144 -0
- package/src/server/websocket.js +21 -2
- package/src/utils/git.js +338 -1
- package/src/utils/vapid.js +45 -0
- package/public/assets/channel-Ccb6hGZz.js +0 -1
- package/public/assets/classDiagram-VBA2DB6C-BIrAPXFF.js +0 -1
- package/public/assets/classDiagram-v2-RAHNMMFH-BIrAPXFF.js +0 -1
- package/public/assets/clone-D5RGMzJC.js +0 -1
- package/public/assets/index-BEOqWnh5.js +0 -391
- package/public/assets/index-D_1GL6a5.css +0 -32
- package/public/assets/stateDiagram-v2-FVOUBMTO-3AffBMDC.js +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const webpush = require('web-push');
|
|
2
|
+
const { getOrCreateVapidKeys } = require('../utils/vapid');
|
|
3
|
+
const log = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
class PushManager {
|
|
6
|
+
constructor(configDir) {
|
|
7
|
+
this.configDir = configDir;
|
|
8
|
+
this.subscriptions = new Map(); // endpoint -> subscription object
|
|
9
|
+
this.vapidKeys = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize VAPID keys and configure web-push.
|
|
14
|
+
* Call once during server start.
|
|
15
|
+
*/
|
|
16
|
+
async init() {
|
|
17
|
+
this.vapidKeys = getOrCreateVapidKeys(this.configDir);
|
|
18
|
+
webpush.setVapidDetails(
|
|
19
|
+
this.vapidKeys.subject,
|
|
20
|
+
this.vapidKeys.publicKey,
|
|
21
|
+
this.vapidKeys.privateKey,
|
|
22
|
+
);
|
|
23
|
+
log.info('Push notification manager initialized');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a push subscription.
|
|
28
|
+
* @param {{ endpoint: string, keys: { p256dh: string, auth: string } }} subscription
|
|
29
|
+
*/
|
|
30
|
+
subscribe(subscription) {
|
|
31
|
+
if (!this.vapidKeys) {
|
|
32
|
+
log.warn('Push subscription rejected — VAPID not initialized');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.subscriptions.set(subscription.endpoint, subscription);
|
|
36
|
+
log.info(`Push subscription registered (${this.subscriptions.size} total)`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Remove a push subscription by endpoint.
|
|
41
|
+
* @param {string} endpoint
|
|
42
|
+
*/
|
|
43
|
+
unsubscribe(endpoint) {
|
|
44
|
+
const removed = this.subscriptions.delete(endpoint);
|
|
45
|
+
if (removed) {
|
|
46
|
+
log.debug(`Push subscription removed (${this.subscriptions.size} total)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send a push notification to all registered subscriptions.
|
|
52
|
+
* Removes subscriptions that return 410 Gone or 404 Not Found.
|
|
53
|
+
* @param {{ title: string, body: string, tag?: string, sessionId?: string }} payload
|
|
54
|
+
*/
|
|
55
|
+
async notify(payload) {
|
|
56
|
+
if (this.subscriptions.size === 0) {
|
|
57
|
+
log.debug('Push: no subscriptions registered, skipping notification');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!this.vapidKeys) {
|
|
62
|
+
log.debug('Push: VAPID not initialized, skipping');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log.info(`Push: sending to ${this.subscriptions.size} subscription(s): ${payload.title}`);
|
|
67
|
+
|
|
68
|
+
const body = JSON.stringify(payload);
|
|
69
|
+
const stale = [];
|
|
70
|
+
|
|
71
|
+
const results = await Promise.allSettled(
|
|
72
|
+
[...this.subscriptions.entries()].map(async ([endpoint, sub]) => {
|
|
73
|
+
try {
|
|
74
|
+
await webpush.sendNotification(sub, body, {
|
|
75
|
+
TTL: 300,
|
|
76
|
+
urgency: 'normal',
|
|
77
|
+
});
|
|
78
|
+
log.debug(`Push sent successfully to ${endpoint.slice(0, 50)}...`);
|
|
79
|
+
sub._failCount = 0;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.statusCode === 410 || err.statusCode === 404) {
|
|
82
|
+
stale.push(endpoint);
|
|
83
|
+
log.debug(`Removing stale push subscription (${err.statusCode})`);
|
|
84
|
+
} else {
|
|
85
|
+
sub._failCount = (sub._failCount || 0) + 1;
|
|
86
|
+
log.warn(
|
|
87
|
+
`Push notification failed (attempt ${sub._failCount}): ${err.message}` +
|
|
88
|
+
(err.statusCode ? ` (HTTP ${err.statusCode})` : '') +
|
|
89
|
+
(err.body ? ` — ${String(err.body).slice(0, 200)}` : ''),
|
|
90
|
+
);
|
|
91
|
+
if (sub._failCount >= 5) {
|
|
92
|
+
stale.push(endpoint);
|
|
93
|
+
log.warn(`Push subscription removed after ${sub._failCount} consecutive failures`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
for (const endpoint of stale) {
|
|
101
|
+
this.subscriptions.delete(endpoint);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log.debug(
|
|
105
|
+
`Push notifications sent: ${results.length} attempted, ${stale.length} stale removed`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return the VAPID public key for frontend subscription.
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
getPublicKey() {
|
|
114
|
+
return this.vapidKeys ? this.vapidKeys.publicKey : null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { PushManager };
|
package/src/server/routes.js
CHANGED
|
@@ -34,7 +34,7 @@ function validateMagicBytes(buffer, contentType) {
|
|
|
34
34
|
return true;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function setupRoutes(app, { auth, sessions, config, state }) {
|
|
37
|
+
function setupRoutes(app, { auth, sessions, config, state, pushManager }) {
|
|
38
38
|
const pageRateLimit = rateLimit({
|
|
39
39
|
windowMs: 1 * 60 * 1000,
|
|
40
40
|
max: 120,
|
|
@@ -149,6 +149,9 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
149
149
|
app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
150
150
|
res.sendFile('index.html', { root: PUBLIC_DIR }),
|
|
151
151
|
);
|
|
152
|
+
app.get('/code/:sessionId', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
153
|
+
res.sendFile('index.html', { root: PUBLIC_DIR }),
|
|
154
|
+
);
|
|
152
155
|
|
|
153
156
|
// Share token — generates a temporary share token for the share button
|
|
154
157
|
app.get('/api/share-token', auth.middleware, (req, res) => {
|
|
@@ -517,6 +520,105 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
517
520
|
}
|
|
518
521
|
});
|
|
519
522
|
|
|
523
|
+
// Recursive file tree for a session's CWD
|
|
524
|
+
app.get('/api/sessions/:id/file-tree', apiRateLimit, auth.middleware, (req, res) => {
|
|
525
|
+
const session = sessions.get(req.params.id);
|
|
526
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
527
|
+
|
|
528
|
+
const MAX_DEPTH = 5;
|
|
529
|
+
const MAX_ENTRIES = 2000;
|
|
530
|
+
const EXCLUDED = new Set([
|
|
531
|
+
'node_modules',
|
|
532
|
+
'.git',
|
|
533
|
+
'__pycache__',
|
|
534
|
+
'coverage',
|
|
535
|
+
'.next',
|
|
536
|
+
'dist',
|
|
537
|
+
'build',
|
|
538
|
+
]);
|
|
539
|
+
|
|
540
|
+
let depth = 3;
|
|
541
|
+
if (typeof req.query.depth !== 'undefined') {
|
|
542
|
+
const parsedDepth = parseInt(req.query.depth, 10);
|
|
543
|
+
if (Number.isNaN(parsedDepth)) {
|
|
544
|
+
return res.status(400).json({ error: 'Invalid depth' });
|
|
545
|
+
}
|
|
546
|
+
depth = parsedDepth;
|
|
547
|
+
}
|
|
548
|
+
depth = Math.min(Math.max(depth, 1), MAX_DEPTH);
|
|
549
|
+
const rootDir = path.resolve(session.cwd);
|
|
550
|
+
let totalEntries = 0;
|
|
551
|
+
|
|
552
|
+
function buildTree(dir, currentDepth) {
|
|
553
|
+
let dirents;
|
|
554
|
+
try {
|
|
555
|
+
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
556
|
+
} catch {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const entries = [];
|
|
561
|
+
const filtered = dirents
|
|
562
|
+
.filter((e) => {
|
|
563
|
+
if (e.name.startsWith('.')) return false;
|
|
564
|
+
if (EXCLUDED.has(e.name)) return false;
|
|
565
|
+
try {
|
|
566
|
+
return !fs.lstatSync(path.join(dir, e.name)).isSymbolicLink();
|
|
567
|
+
} catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
})
|
|
571
|
+
.sort((a, b) => {
|
|
572
|
+
const aDir = a.isDirectory();
|
|
573
|
+
const bDir = b.isDirectory();
|
|
574
|
+
if (aDir !== bDir) return aDir ? -1 : 1;
|
|
575
|
+
return a.name.localeCompare(b.name);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
for (const e of filtered) {
|
|
579
|
+
if (totalEntries >= MAX_ENTRIES) break;
|
|
580
|
+
totalEntries++;
|
|
581
|
+
|
|
582
|
+
const fullPath = path.join(dir, e.name);
|
|
583
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
584
|
+
const isDir = e.isDirectory();
|
|
585
|
+
|
|
586
|
+
if (isDir) {
|
|
587
|
+
const children = currentDepth < depth ? buildTree(fullPath, currentDepth + 1) : [];
|
|
588
|
+
entries.push({
|
|
589
|
+
name: e.name,
|
|
590
|
+
type: 'directory',
|
|
591
|
+
path: relativePath.replace(/\\/g, '/'),
|
|
592
|
+
children,
|
|
593
|
+
});
|
|
594
|
+
} else {
|
|
595
|
+
let size = 0;
|
|
596
|
+
try {
|
|
597
|
+
size = fs.statSync(fullPath).size;
|
|
598
|
+
} catch {
|
|
599
|
+
// ignore stat errors
|
|
600
|
+
}
|
|
601
|
+
entries.push({
|
|
602
|
+
name: e.name,
|
|
603
|
+
type: 'file',
|
|
604
|
+
path: relativePath.replace(/\\/g, '/'),
|
|
605
|
+
size,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return entries;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const tree = buildTree(rootDir, 1);
|
|
615
|
+
res.json({ root: rootDir, tree });
|
|
616
|
+
} catch (err) {
|
|
617
|
+
log.warn(`File tree failed: ${err.message}`);
|
|
618
|
+
res.status(500).json({ error: 'Failed to build file tree' });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
520
622
|
// Download a file from within a session's CWD
|
|
521
623
|
app.get('/api/sessions/:id/download', apiRateLimit, auth.middleware, (req, res) => {
|
|
522
624
|
const session = sessions.get(req.params.id);
|
|
@@ -610,6 +712,95 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
610
712
|
}
|
|
611
713
|
});
|
|
612
714
|
|
|
715
|
+
// --- Git change endpoints ---
|
|
716
|
+
|
|
717
|
+
const { getDetailedStatus, getFileDiff, getFileBlame, getGitLog } = require('../utils/git');
|
|
718
|
+
|
|
719
|
+
function validateFilePath(file) {
|
|
720
|
+
if (!file || typeof file !== 'string') return false;
|
|
721
|
+
if (path.isAbsolute(file)) return false;
|
|
722
|
+
const normalized = path.normalize(file);
|
|
723
|
+
if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false;
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
app.get('/api/sessions/:id/git/status', apiRateLimit, auth.middleware, async (req, res) => {
|
|
728
|
+
const session = sessions.get(req.params.id);
|
|
729
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const status = await getDetailedStatus(session.cwd);
|
|
733
|
+
res.json(status);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
log.warn(`Git status failed: ${err.message}`);
|
|
736
|
+
res.status(500).json({ error: 'Failed to get git status' });
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
app.get('/api/sessions/:id/git/diff', apiRateLimit, auth.middleware, async (req, res) => {
|
|
741
|
+
const session = sessions.get(req.params.id);
|
|
742
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
743
|
+
|
|
744
|
+
const file = req.query.file;
|
|
745
|
+
if (!validateFilePath(file)) {
|
|
746
|
+
return res.status(400).json({ error: 'Invalid or missing file parameter' });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const staged = req.query.staged === 'true';
|
|
750
|
+
const untracked = req.query.untracked === 'true';
|
|
751
|
+
let context;
|
|
752
|
+
if (req.query.context !== undefined) {
|
|
753
|
+
const parsed = parseInt(req.query.context, 10);
|
|
754
|
+
if (Number.isFinite(parsed)) {
|
|
755
|
+
context = Math.min(Math.max(parsed, 0), 99999);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const diff = await getFileDiff(session.cwd, file, { staged, untracked, context });
|
|
760
|
+
res.json(diff);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
log.warn(`Git diff failed: ${err.message}`);
|
|
763
|
+
res.status(500).json({ error: 'Failed to get diff' });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
app.get('/api/sessions/:id/git/blame', apiRateLimit, auth.middleware, async (req, res) => {
|
|
768
|
+
const session = sessions.get(req.params.id);
|
|
769
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
770
|
+
|
|
771
|
+
const file = req.query.file;
|
|
772
|
+
if (!validateFilePath(file)) {
|
|
773
|
+
return res.status(400).json({ error: 'Invalid or missing file parameter' });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
const blame = await getFileBlame(session.cwd, file);
|
|
778
|
+
res.json(blame);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
log.warn(`Git blame failed: ${err.message}`);
|
|
781
|
+
res.status(500).json({ error: 'Failed to get blame' });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
app.get('/api/sessions/:id/git/log', apiRateLimit, auth.middleware, async (req, res) => {
|
|
786
|
+
const session = sessions.get(req.params.id);
|
|
787
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
788
|
+
|
|
789
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
|
|
790
|
+
const file = req.query.file;
|
|
791
|
+
if (file && !validateFilePath(file)) {
|
|
792
|
+
return res.status(400).json({ error: 'Invalid file parameter' });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
const logResult = await getGitLog(session.cwd, { limit, file: file || null });
|
|
797
|
+
res.json(logResult);
|
|
798
|
+
} catch (err) {
|
|
799
|
+
log.warn(`Git log failed: ${err.message}`);
|
|
800
|
+
res.status(500).json({ error: 'Failed to get git log' });
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
613
804
|
// Directory listing for folder browser
|
|
614
805
|
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
615
806
|
log.debug(`Directory listing requested: ${req.query.q || config.cwd}`);
|
|
@@ -631,6 +822,41 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
631
822
|
res.json({ base: dir, dirs: [], truncated: false });
|
|
632
823
|
}
|
|
633
824
|
});
|
|
825
|
+
|
|
826
|
+
// --- Push notification endpoints ---
|
|
827
|
+
if (pushManager) {
|
|
828
|
+
app.get('/api/push/vapid-key', apiRateLimit, auth.middleware, (_req, res) => {
|
|
829
|
+
const publicKey = pushManager.getPublicKey();
|
|
830
|
+
if (!publicKey) {
|
|
831
|
+
return res.status(503).json({ error: 'Push notifications not configured' });
|
|
832
|
+
}
|
|
833
|
+
res.json({ publicKey });
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
app.post('/api/push/subscribe', apiRateLimit, auth.middleware, (req, res) => {
|
|
837
|
+
const { subscription } = req.body || {};
|
|
838
|
+
if (
|
|
839
|
+
!subscription ||
|
|
840
|
+
!subscription.endpoint ||
|
|
841
|
+
!subscription.keys ||
|
|
842
|
+
!subscription.keys.p256dh ||
|
|
843
|
+
!subscription.keys.auth
|
|
844
|
+
) {
|
|
845
|
+
return res.status(400).json({ error: 'Invalid subscription object' });
|
|
846
|
+
}
|
|
847
|
+
pushManager.subscribe(subscription);
|
|
848
|
+
res.json({ ok: true });
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
app.delete('/api/push/unsubscribe', apiRateLimit, auth.middleware, (req, res) => {
|
|
852
|
+
const { endpoint } = req.body || {};
|
|
853
|
+
if (!endpoint) {
|
|
854
|
+
return res.status(400).json({ error: 'Missing endpoint' });
|
|
855
|
+
}
|
|
856
|
+
pushManager.unsubscribe(endpoint);
|
|
857
|
+
res.json({ ok: true });
|
|
858
|
+
});
|
|
859
|
+
}
|
|
634
860
|
}
|
|
635
861
|
|
|
636
862
|
function cleanupUploadedFiles() {
|
package/src/server/sessions.js
CHANGED
|
@@ -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}`);
|
package/src/server/websocket.js
CHANGED
|
@@ -82,10 +82,22 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const pingInterval = setInterval(() => {
|
|
85
|
-
if (ws.readyState === 1)
|
|
86
|
-
|
|
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
|
}
|