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