ai-or-die 0.1.61 → 0.1.63
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/package.json +4 -1
- package/sea-bootstrap.js +27 -1
- package/src/public/app.js +255 -33
- package/src/public/auth.js +22 -0
- package/src/public/components/file-browser.css +756 -3
- package/src/public/file-browser.js +2065 -136
- package/src/public/file-diff.js +406 -0
- package/src/public/file-editor.js +418 -135
- package/src/public/file-pdf-viewer.js +473 -0
- package/src/public/file-search.js +522 -0
- package/src/public/file-tabs.js +1087 -0
- package/src/public/file-viewer-monaco.js +410 -0
- package/src/public/file-watcher-client.js +422 -0
- package/src/public/heartbeat-watchdog.js +124 -0
- package/src/public/index.html +13 -1
- package/src/public/markdown-render.js +576 -0
- package/src/public/notebook-render.js +259 -0
- package/src/public/splits.js +112 -16
- package/src/public/vendor/monaco-worker-shim.js +82 -0
- package/src/public/vendor/panzoom.min.js +6 -0
- package/src/public/vendor/pdfjs/LICENSE +177 -0
- package/src/public/vendor/pdfjs/pdf.min.mjs +21 -0
- package/src/public/vendor/pdfjs/pdf.worker.min.mjs +21 -0
- package/src/server.js +713 -15
- package/src/utils/file-watcher.js +531 -0
- package/src/utils/search.js +538 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
4
4
|
"description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@lydell/node-pty": "1.2.0-beta.10",
|
|
38
|
+
"@vscode/ripgrep": "^1.18.0",
|
|
39
|
+
"chokidar": "^5.0.0",
|
|
38
40
|
"commander": "^12.1.0",
|
|
39
41
|
"cors": "^2.8.5",
|
|
40
42
|
"express": "^4.19.2",
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@playwright/test": "^1.58.2",
|
|
57
59
|
"esbuild": "^0.24.0",
|
|
60
|
+
"jsdom": "^24.1.3",
|
|
58
61
|
"mocha": "^11.7.1",
|
|
59
62
|
"postject": "^1.0.0-alpha.6"
|
|
60
63
|
}
|
package/sea-bootstrap.js
CHANGED
|
@@ -28,18 +28,44 @@ if (isSea) {
|
|
|
28
28
|
const ptyPkg = `node-pty-${process.platform}-${process.arch}`;
|
|
29
29
|
const sherpaPlatform = process.platform === 'win32' ? 'win' : process.platform;
|
|
30
30
|
const sherpaPkg = `sherpa-onnx-${sherpaPlatform}-${process.arch}`;
|
|
31
|
+
// Bundled ripgrep (ADR-0018) — see scripts/build-sea.js for the asset
|
|
32
|
+
// prefix scheme. Mirror it here for the extraction predicate AND the
|
|
33
|
+
// post-extract path computation that feeds global.__SEA_RG_PATH__.
|
|
34
|
+
const rgAssetPrefix = `vscode-ripgrep-${process.platform}-${process.arch}`;
|
|
35
|
+
const rgBinName = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
|
31
36
|
const assetKeys = sea.getAssetKeys();
|
|
32
37
|
|
|
33
38
|
for (const key of assetKeys) {
|
|
34
39
|
if (key.startsWith(ptyPkg + '/') ||
|
|
35
40
|
key.startsWith(sherpaPkg + '/') ||
|
|
36
|
-
key.startsWith('sherpa-onnx-node/')
|
|
41
|
+
key.startsWith('sherpa-onnx-node/') ||
|
|
42
|
+
key.startsWith(rgAssetPrefix + '/')) {
|
|
37
43
|
const targetPath = path.join(tempDir, key);
|
|
38
44
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
39
45
|
fs.writeFileSync(targetPath, new Uint8Array(sea.getRawAsset(key)));
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
// chmod +x the extracted ripgrep binary on POSIX. fs.writeFileSync
|
|
50
|
+
// doesn't preserve the executable bit; without this chmod, the
|
|
51
|
+
// search backend's fs.accessSync(X_OK) liveness check would reject
|
|
52
|
+
// the binary on macOS / Linux SEA builds even though the bytes are
|
|
53
|
+
// intact. Windows ignores file mode bits — the .exe extension
|
|
54
|
+
// already conveys executability.
|
|
55
|
+
const rgExtractedPath = path.join(tempDir, rgAssetPrefix, 'bin', rgBinName);
|
|
56
|
+
if (fs.existsSync(rgExtractedPath)) {
|
|
57
|
+
if (process.platform !== 'win32') {
|
|
58
|
+
try { fs.chmodSync(rgExtractedPath, 0o755); }
|
|
59
|
+
catch (e) {
|
|
60
|
+
console.warn('[SEA] failed to chmod +x bundled ripgrep:', e.message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Surface the resolved path to the search backend (src/utils/search.js)
|
|
64
|
+
// via a global, since the bundled module's require('@vscode/ripgrep')
|
|
65
|
+
// can't resolve at SEA runtime (no node_modules on disk).
|
|
66
|
+
global.__SEA_RG_PATH__ = rgExtractedPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
// Store references for pty-sea-shim.js and server.js to use
|
|
44
70
|
global.__SEA_MODE__ = true;
|
|
45
71
|
global.__SEA_TEMP_DIR__ = tempDir;
|
package/src/public/app.js
CHANGED
|
@@ -11,6 +11,10 @@ class ClaudeCodeWebInterface {
|
|
|
11
11
|
this.maxReconnectAttempts = 10;
|
|
12
12
|
this.reconnectDelay = 1000;
|
|
13
13
|
this._reconnecting = false;
|
|
14
|
+
this._socketGeneration = 0;
|
|
15
|
+
this._pongTimer = null;
|
|
16
|
+
this._heartbeatTimer = null;
|
|
17
|
+
this._reconnectTimer = null;
|
|
14
18
|
this._fitting = false;
|
|
15
19
|
this.folderMode = true; // Always use folder mode
|
|
16
20
|
this.currentFolderPath = null;
|
|
@@ -283,6 +287,14 @@ class ClaudeCodeWebInterface {
|
|
|
283
287
|
// Reconnect if the socket dropped while the tab was hidden
|
|
284
288
|
if (this.socket && this.socket.readyState === WebSocket.CLOSED) {
|
|
285
289
|
this.reconnect();
|
|
290
|
+
} else if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
291
|
+
// Socket appears OPEN but may be a zombie (NAT rebind during
|
|
292
|
+
// hidden tab). Re-arm the heartbeat so we ping immediately —
|
|
293
|
+
// the standard 10s pong window will catch a dead socket.
|
|
294
|
+
// Do NOT use a tighter probe window: cellular radio wake-up
|
|
295
|
+
// is 1.5–3s, and a separate timer races with any in-flight
|
|
296
|
+
// pong from before the tab was hidden.
|
|
297
|
+
this.startHeartbeat();
|
|
286
298
|
}
|
|
287
299
|
}
|
|
288
300
|
});
|
|
@@ -297,6 +309,19 @@ class ClaudeCodeWebInterface {
|
|
|
297
309
|
if (window.feedback) window.feedback.warning('Connection lost — you are offline', { duration: 0 });
|
|
298
310
|
});
|
|
299
311
|
|
|
312
|
+
// bfcache restore (mobile back/forward swipe). The page may have been
|
|
313
|
+
// frozen with a stale socket; force a reconnect when restored.
|
|
314
|
+
// IMPORTANT: only act on `e.persisted === true`. `pageshow` ALSO fires
|
|
315
|
+
// on every normal page load with `e.persisted === false`, and in that
|
|
316
|
+
// case init() is already establishing the WebSocket — calling
|
|
317
|
+
// reconnect() here would race with init's connect and tear down the
|
|
318
|
+
// in-flight socket before it opens (caught by CI golden-path test).
|
|
319
|
+
window.addEventListener('pageshow', (e) => {
|
|
320
|
+
if (e.persisted) {
|
|
321
|
+
this.reconnect();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
300
325
|
window.addEventListener('resize', () => {
|
|
301
326
|
this.fitTerminal();
|
|
302
327
|
});
|
|
@@ -647,6 +672,7 @@ class ClaudeCodeWebInterface {
|
|
|
647
672
|
|
|
648
673
|
this.setupTerminalSearch();
|
|
649
674
|
this.setupTerminalContextMenu();
|
|
675
|
+
this._setupTerminalLinking(this.terminal);
|
|
650
676
|
|
|
651
677
|
this.terminal.onData((data) => {
|
|
652
678
|
if (this._ctrlModifierPending) {
|
|
@@ -689,11 +715,20 @@ class ClaudeCodeWebInterface {
|
|
|
689
715
|
}
|
|
690
716
|
});
|
|
691
717
|
|
|
692
|
-
// Sync terminal colors
|
|
718
|
+
// Sync terminal colors AND any live Monaco instances when the CSS
|
|
719
|
+
// theme changes (data-theme attribute on documentElement).
|
|
693
720
|
const themeObserver = new MutationObserver((mutations) => {
|
|
694
721
|
for (const m of mutations) {
|
|
695
|
-
if (m.attributeName === 'data-theme'
|
|
696
|
-
this.syncTerminalTheme();
|
|
722
|
+
if (m.attributeName === 'data-theme') {
|
|
723
|
+
if (this.terminal) this.syncTerminalTheme();
|
|
724
|
+
// Editor pane + read-only code preview re-theme live so
|
|
725
|
+
// they don't get stuck on whatever theme was active when
|
|
726
|
+
// they were created. Loader reads the new data-theme via
|
|
727
|
+
// resolveMonacoTheme() internally.
|
|
728
|
+
if (window.fileViewerMonaco &&
|
|
729
|
+
typeof window.fileViewerMonaco.applyThemeToAll === 'function') {
|
|
730
|
+
try { window.fileViewerMonaco.applyThemeToAll(); } catch (_) { /* swallow */ }
|
|
731
|
+
}
|
|
697
732
|
}
|
|
698
733
|
}
|
|
699
734
|
});
|
|
@@ -1030,6 +1065,34 @@ class ClaudeCodeWebInterface {
|
|
|
1030
1065
|
}
|
|
1031
1066
|
});
|
|
1032
1067
|
|
|
1068
|
+
// Ctrl/Cmd+Shift+F → toggle the file browser's cross-file search panel.
|
|
1069
|
+
// Opens the file browser (if closed) and the search panel together so
|
|
1070
|
+
// the user can hit one shortcut from anywhere in the app and start
|
|
1071
|
+
// typing a query immediately. Mirrors the VS Code/JetBrains binding.
|
|
1072
|
+
document.addEventListener('keydown', (e) => {
|
|
1073
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
|
|
1074
|
+
// Don't steal the shortcut from a focused editor that owns
|
|
1075
|
+
// its own find — Monaco's find-in-files for the editor pane
|
|
1076
|
+
// is handled inside the editor surface; the file-browser
|
|
1077
|
+
// search is for the project-wide case.
|
|
1078
|
+
const tag = (e.target && e.target.tagName) || '';
|
|
1079
|
+
const isEditableField = (tag === 'INPUT' || tag === 'TEXTAREA' ||
|
|
1080
|
+
(e.target && e.target.isContentEditable));
|
|
1081
|
+
// Allow the shortcut when typing in our OWN search panel
|
|
1082
|
+
// input (it'll just refocus); skip for any other text field.
|
|
1083
|
+
const insideSearchPanel = e.target &&
|
|
1084
|
+
typeof e.target.closest === 'function' &&
|
|
1085
|
+
e.target.closest('.fb-search-panel');
|
|
1086
|
+
if (isEditableField && !insideSearchPanel) return;
|
|
1087
|
+
|
|
1088
|
+
e.preventDefault();
|
|
1089
|
+
const panel = this._ensureFileBrowser ? this._ensureFileBrowser() : this._fileBrowserPanel;
|
|
1090
|
+
if (!panel) return;
|
|
1091
|
+
if (!panel.isOpen()) panel.open();
|
|
1092
|
+
if (typeof panel.toggleSearchPanel === 'function') panel.toggleSearchPanel();
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1033
1096
|
// Header overflow menu (tablet/mobile three-dot button)
|
|
1034
1097
|
this._setupOverflowMenu();
|
|
1035
1098
|
|
|
@@ -1644,10 +1707,18 @@ class ClaudeCodeWebInterface {
|
|
|
1644
1707
|
}
|
|
1645
1708
|
|
|
1646
1709
|
try {
|
|
1647
|
-
this.
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1710
|
+
this._socketGeneration += 1;
|
|
1711
|
+
const gen = this._socketGeneration;
|
|
1712
|
+
const ws = new WebSocket(wsUrl);
|
|
1713
|
+
ws.binaryType = 'arraybuffer';
|
|
1714
|
+
this.socket = ws;
|
|
1715
|
+
const isCurrent = () => ws === this.socket && gen === this._socketGeneration;
|
|
1716
|
+
|
|
1717
|
+
ws.onopen = () => {
|
|
1718
|
+
if (!isCurrent()) return;
|
|
1719
|
+
// Arm heartbeat BEFORE any awaited work so liveness detection
|
|
1720
|
+
// is in place even if loadSessions stalls.
|
|
1721
|
+
this.startHeartbeat();
|
|
1651
1722
|
this.reconnectAttempts = 0;
|
|
1652
1723
|
// Clear server restart state if we were reconnecting after a restart
|
|
1653
1724
|
if (this._serverRestarting) {
|
|
@@ -1678,10 +1749,23 @@ class ClaudeCodeWebInterface {
|
|
|
1678
1749
|
// Request app tunnel status
|
|
1679
1750
|
this.send({ type: 'app_tunnel_status' });
|
|
1680
1751
|
|
|
1752
|
+
// Auto-rejoin the previously active session. After a
|
|
1753
|
+
// reconnect the new WebSocket is alive but server-side
|
|
1754
|
+
// it isn't joined to anything — without this the
|
|
1755
|
+
// terminal sits frozen until the user manually clicks a
|
|
1756
|
+
// tab. On the very first connect, currentClaudeSessionId
|
|
1757
|
+
// is null (it's set later by session_joined arriving
|
|
1758
|
+
// from the explicit init-time join), so this only fires
|
|
1759
|
+
// on RE-connects.
|
|
1760
|
+
if (this.currentClaudeSessionId) {
|
|
1761
|
+
this.send({ type: 'join_session', sessionId: this.currentClaudeSessionId });
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1681
1764
|
resolve();
|
|
1682
1765
|
};
|
|
1683
1766
|
|
|
1684
|
-
|
|
1767
|
+
ws.onmessage = (event) => {
|
|
1768
|
+
if (!isCurrent()) return;
|
|
1685
1769
|
if (event.data instanceof ArrayBuffer) {
|
|
1686
1770
|
// Binary frame — pass raw ArrayBuffer to handleBinaryOutput
|
|
1687
1771
|
this.handleBinaryOutput(event.data);
|
|
@@ -1691,17 +1775,45 @@ class ClaudeCodeWebInterface {
|
|
|
1691
1775
|
}
|
|
1692
1776
|
};
|
|
1693
1777
|
|
|
1694
|
-
|
|
1778
|
+
ws.onclose = (event) => {
|
|
1779
|
+
// Stale-socket fence: an old socket's onclose firing after we've
|
|
1780
|
+
// already moved on must NOT schedule another reconnect.
|
|
1781
|
+
if (!isCurrent()) return;
|
|
1782
|
+
if (this._heartbeat) { this._heartbeat.stop(); this._heartbeat = null; }
|
|
1783
|
+
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
|
|
1784
|
+
if (this._pongTimer) { clearTimeout(this._pongTimer); this._pongTimer = null; }
|
|
1695
1785
|
// During server restart, don't count failures against reconnect budget
|
|
1696
1786
|
// but still use backoff to avoid thundering herd
|
|
1697
1787
|
if (this._serverRestarting) {
|
|
1698
1788
|
this.updateStatus('Restarting \u2014 reconnecting\u2026');
|
|
1699
1789
|
const restartBackoff = Math.min(2000 * Math.pow(1.5, this._restartReconnectAttempts || 0), 15000) * (0.7 + Math.random() * 0.6);
|
|
1700
1790
|
this._restartReconnectAttempts = (this._restartReconnectAttempts || 0) + 1;
|
|
1701
|
-
|
|
1791
|
+
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
1792
|
+
const restartGen = this._socketGeneration;
|
|
1793
|
+
this._reconnectTimer = setTimeout(() => {
|
|
1794
|
+
this._reconnectTimer = null;
|
|
1795
|
+
if (restartGen !== this._socketGeneration) return;
|
|
1796
|
+
this.reconnect();
|
|
1797
|
+
}, restartBackoff);
|
|
1702
1798
|
} else if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
1703
1799
|
this.updateStatus('Reconnecting (' + (this.reconnectAttempts + 1) + '/' + this.maxReconnectAttempts + ')...');
|
|
1704
|
-
|
|
1800
|
+
// First attempt is fast (250ms covers a server-process restart window);
|
|
1801
|
+
// subsequent attempts use exponential backoff with jitter.
|
|
1802
|
+
const delay = this.reconnectAttempts === 0
|
|
1803
|
+
? 250
|
|
1804
|
+
: Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000) * (0.7 + Math.random() * 0.6);
|
|
1805
|
+
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
1806
|
+
// Fence the deferred reconnect with the current generation: a
|
|
1807
|
+
// user-initiated connect (setSession etc.) will increment the
|
|
1808
|
+
// generation, so a stale timer firing later must be a no-op
|
|
1809
|
+
// \u2014 otherwise it spawns a parallel WebSocket and orphans the
|
|
1810
|
+
// active one. (Caught by gemini-3.1-pro-preview review.)
|
|
1811
|
+
const onCloseGen = this._socketGeneration;
|
|
1812
|
+
this._reconnectTimer = setTimeout(() => {
|
|
1813
|
+
this._reconnectTimer = null;
|
|
1814
|
+
if (onCloseGen !== this._socketGeneration) return;
|
|
1815
|
+
this.reconnect();
|
|
1816
|
+
}, delay);
|
|
1705
1817
|
this.reconnectAttempts++;
|
|
1706
1818
|
} else {
|
|
1707
1819
|
this.updateStatus('Disconnected');
|
|
@@ -1709,7 +1821,8 @@ class ClaudeCodeWebInterface {
|
|
|
1709
1821
|
}
|
|
1710
1822
|
};
|
|
1711
1823
|
|
|
1712
|
-
|
|
1824
|
+
ws.onerror = (error) => {
|
|
1825
|
+
if (!isCurrent()) return;
|
|
1713
1826
|
console.error('WebSocket error:', error);
|
|
1714
1827
|
this.showError('Failed to connect to the server.\n\n\u2022 Check that the server is running\n\u2022 Verify your network connection\n\u2022 Try refreshing the page');
|
|
1715
1828
|
reject(error);
|
|
@@ -1724,6 +1837,17 @@ class ClaudeCodeWebInterface {
|
|
|
1724
1837
|
}
|
|
1725
1838
|
|
|
1726
1839
|
disconnect() {
|
|
1840
|
+
// Cancel any deferred reconnect from a prior onclose so it cannot fire
|
|
1841
|
+
// after a user-initiated reconnect/setSession (which advances the
|
|
1842
|
+
// socket generation) and spawn a parallel socket.
|
|
1843
|
+
if (this._reconnectTimer) {
|
|
1844
|
+
clearTimeout(this._reconnectTimer);
|
|
1845
|
+
this._reconnectTimer = null;
|
|
1846
|
+
}
|
|
1847
|
+
if (this._heartbeat) {
|
|
1848
|
+
this._heartbeat.stop();
|
|
1849
|
+
this._heartbeat = null;
|
|
1850
|
+
}
|
|
1727
1851
|
if (this.socket) {
|
|
1728
1852
|
this.socket.close();
|
|
1729
1853
|
this.socket = null;
|
|
@@ -1736,6 +1860,10 @@ class ClaudeCodeWebInterface {
|
|
|
1736
1860
|
clearInterval(this._heartbeatTimer);
|
|
1737
1861
|
this._heartbeatTimer = null;
|
|
1738
1862
|
}
|
|
1863
|
+
if (this._pongTimer) {
|
|
1864
|
+
clearTimeout(this._pongTimer);
|
|
1865
|
+
this._pongTimer = null;
|
|
1866
|
+
}
|
|
1739
1867
|
if (this.usageUpdateTimer) {
|
|
1740
1868
|
clearInterval(this.usageUpdateTimer);
|
|
1741
1869
|
this.usageUpdateTimer = null;
|
|
@@ -1776,7 +1904,7 @@ class ClaudeCodeWebInterface {
|
|
|
1776
1904
|
console.error('Reconnection failed synchronously:', err);
|
|
1777
1905
|
this._reconnecting = false;
|
|
1778
1906
|
}
|
|
1779
|
-
},
|
|
1907
|
+
}, 0);
|
|
1780
1908
|
}
|
|
1781
1909
|
|
|
1782
1910
|
send(data) {
|
|
@@ -2117,6 +2245,7 @@ class ClaudeCodeWebInterface {
|
|
|
2117
2245
|
}
|
|
2118
2246
|
|
|
2119
2247
|
case 'pong':
|
|
2248
|
+
if (this._heartbeat) this._heartbeat.onPong();
|
|
2120
2249
|
break;
|
|
2121
2250
|
|
|
2122
2251
|
case 'image_upload_complete': {
|
|
@@ -2823,6 +2952,81 @@ class ClaudeCodeWebInterface {
|
|
|
2823
2952
|
});
|
|
2824
2953
|
}
|
|
2825
2954
|
|
|
2955
|
+
/**
|
|
2956
|
+
* Wire up clickable file paths in a terminal:
|
|
2957
|
+
* 1. xterm registerLinkProvider for hover-underline + click-to-open
|
|
2958
|
+
* (synchronous regex only — NO network I/O on render).
|
|
2959
|
+
* 2. TerminalPathDetector for the right-click "Open in File Viewer /
|
|
2960
|
+
* Edit / Download" context menu (selection-driven).
|
|
2961
|
+
*
|
|
2962
|
+
* Safe to call multiple times for the same terminal — each call returns a
|
|
2963
|
+
* disposable that we track on the terminal object so we can clean up if
|
|
2964
|
+
* the terminal is destroyed.
|
|
2965
|
+
*/
|
|
2966
|
+
_setupTerminalLinking(terminal) {
|
|
2967
|
+
if (!terminal || !window.fileBrowser) return;
|
|
2968
|
+
|
|
2969
|
+
// 1) Link provider — clickable links for paths/filenames in output.
|
|
2970
|
+
if (typeof window.fileBrowser.attachLinkProvider === 'function' &&
|
|
2971
|
+
typeof terminal.registerLinkProvider === 'function' &&
|
|
2972
|
+
!terminal._fbLinkProvider) {
|
|
2973
|
+
try {
|
|
2974
|
+
terminal._fbLinkProvider = window.fileBrowser.attachLinkProvider({
|
|
2975
|
+
terminal: terminal,
|
|
2976
|
+
authFetch: (url, opts) => this.authFetch(url, opts),
|
|
2977
|
+
openInViewer: (path, line, col) => this.openFileInViewer(path, line, col),
|
|
2978
|
+
getCwd: () => this.getCurrentWorkingDir(),
|
|
2979
|
+
feedback: window.feedback,
|
|
2980
|
+
});
|
|
2981
|
+
} catch (err) {
|
|
2982
|
+
console.warn('[file-browser] link provider attach failed:', err);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// 2) Right-click selection-based context menu (lazy panel via getter).
|
|
2987
|
+
if (typeof window.fileBrowser.TerminalPathDetector === 'function' &&
|
|
2988
|
+
!terminal._fbPathDetector) {
|
|
2989
|
+
try {
|
|
2990
|
+
terminal._fbPathDetector = new window.fileBrowser.TerminalPathDetector({
|
|
2991
|
+
getFileBrowserPanel: () => this._ensureFileBrowser(),
|
|
2992
|
+
authFetch: (url, opts) => this.authFetch(url, opts),
|
|
2993
|
+
terminal: terminal,
|
|
2994
|
+
app: this,
|
|
2995
|
+
});
|
|
2996
|
+
terminal._fbPathDetector.init();
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
console.warn('[file-browser] path detector init failed:', err);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// 3) Lifecycle: on terminal disposal, release the link provider, the
|
|
3003
|
+
// path-detector handlers, and the floating menu DOM node. Without
|
|
3004
|
+
// this, splitting + closing terminals leaks document-level
|
|
3005
|
+
// listeners and orphaned <div class="fb-terminal-context-menu">
|
|
3006
|
+
// nodes (peer-review MEDIUM-1 on commit 9a05963).
|
|
3007
|
+
if (typeof terminal.onDispose === 'function' && !terminal._fbLinkingDisposeWired) {
|
|
3008
|
+
terminal._fbLinkingDisposeWired = true;
|
|
3009
|
+
try {
|
|
3010
|
+
terminal.onDispose(() => {
|
|
3011
|
+
try {
|
|
3012
|
+
if (terminal._fbLinkProvider && typeof terminal._fbLinkProvider.dispose === 'function') {
|
|
3013
|
+
terminal._fbLinkProvider.dispose();
|
|
3014
|
+
}
|
|
3015
|
+
} catch (_) {}
|
|
3016
|
+
try {
|
|
3017
|
+
if (terminal._fbPathDetector && typeof terminal._fbPathDetector.destroy === 'function') {
|
|
3018
|
+
terminal._fbPathDetector.destroy();
|
|
3019
|
+
}
|
|
3020
|
+
} catch (_) {}
|
|
3021
|
+
terminal._fbLinkProvider = null;
|
|
3022
|
+
terminal._fbPathDetector = null;
|
|
3023
|
+
});
|
|
3024
|
+
} catch (err) {
|
|
3025
|
+
console.warn('[file-browser] onDispose wiring failed:', err);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
|
|
2826
3030
|
setupTerminalContextMenu() {
|
|
2827
3031
|
const menu = document.getElementById('termContextMenu');
|
|
2828
3032
|
if (!menu) return;
|
|
@@ -3520,26 +3724,45 @@ class ClaudeCodeWebInterface {
|
|
|
3520
3724
|
}
|
|
3521
3725
|
|
|
3522
3726
|
startHeartbeat() {
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3727
|
+
// Delegate to HeartbeatWatchdog (loaded via index.html before app.js).
|
|
3728
|
+
// The watchdog encapsulates ping cadence, pong-timeout, per-socket
|
|
3729
|
+
// fencing, and idempotent restart — see src/public/heartbeat-watchdog.js.
|
|
3730
|
+
if (this._heartbeat) this._heartbeat.stop();
|
|
3731
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
3732
|
+
this._heartbeat = new HeartbeatWatchdog({
|
|
3733
|
+
socket: this.socket,
|
|
3734
|
+
generation: this._socketGeneration,
|
|
3735
|
+
currentGeneration: () => this._socketGeneration,
|
|
3736
|
+
currentSocket: () => this.socket,
|
|
3737
|
+
log: (m) => console.warn('[heartbeat]', m),
|
|
3738
|
+
});
|
|
3739
|
+
this._heartbeat.start();
|
|
3740
|
+
// Keep _heartbeatTimer/_pongTimer references in sync for legacy code
|
|
3741
|
+
// (disconnect() still nulls them defensively); the watchdog owns the
|
|
3742
|
+
// real timer lifecycle via stop().
|
|
3743
|
+
this._heartbeatTimer = null;
|
|
3744
|
+
this._pongTimer = null;
|
|
3529
3745
|
}
|
|
3530
3746
|
|
|
3531
3747
|
// File Browser Methods
|
|
3532
|
-
|
|
3748
|
+
_ensureFileBrowser() {
|
|
3533
3749
|
if (!this._fileBrowserPanel && window.fileBrowser) {
|
|
3534
3750
|
this._fileBrowserPanel = new window.fileBrowser.FileBrowserPanel({
|
|
3535
3751
|
app: this,
|
|
3536
3752
|
authFetch: (url, opts) => this.authFetch(url, opts),
|
|
3753
|
+
// initialPath kept as a back-compat fallback; getCwd is the
|
|
3754
|
+
// source of truth on each open() so a session switch between
|
|
3755
|
+
// opens picks up the new cwd (per ADR-0016 / task #14).
|
|
3537
3756
|
initialPath: this.getCurrentWorkingDir(),
|
|
3757
|
+
getCwd: () => this.getCurrentWorkingDir(),
|
|
3538
3758
|
});
|
|
3539
3759
|
}
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3760
|
+
return this._fileBrowserPanel;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
toggleFileBrowser() {
|
|
3764
|
+
const panel = this._ensureFileBrowser();
|
|
3765
|
+
if (panel) panel.toggle();
|
|
3543
3766
|
}
|
|
3544
3767
|
|
|
3545
3768
|
// VS Code Tunnel Methods
|
|
@@ -3583,16 +3806,15 @@ class ClaudeCodeWebInterface {
|
|
|
3583
3806
|
}
|
|
3584
3807
|
}
|
|
3585
3808
|
|
|
3586
|
-
openFileInViewer(filePath) {
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
this._fileBrowserPanel.openToFile(filePath);
|
|
3809
|
+
openFileInViewer(filePath, line, col) {
|
|
3810
|
+
const panel = this._ensureFileBrowser();
|
|
3811
|
+
if (panel) {
|
|
3812
|
+
panel.openToFile(filePath);
|
|
3813
|
+
// Future: pass {line, col} to Monaco viewer for cursor placement
|
|
3814
|
+
// (Stage 1.5 work — for now we just open the file).
|
|
3815
|
+
if (line && this._fileBrowserPanel) {
|
|
3816
|
+
this._fileBrowserPanel._pendingJumpTo = { line: line, col: col || 1 };
|
|
3817
|
+
}
|
|
3596
3818
|
}
|
|
3597
3819
|
}
|
|
3598
3820
|
|
package/src/public/auth.js
CHANGED
|
@@ -195,6 +195,28 @@ class AuthManager {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Append the auth token as a `?token=` query param.
|
|
200
|
+
*
|
|
201
|
+
* Use this for asset URLs that the browser fetches WITHOUT being able
|
|
202
|
+
* to attach custom headers — `<img src>`, `<iframe src>`, PDF.js
|
|
203
|
+
* `getDocument({url})`, and any other browser-driven fetch where
|
|
204
|
+
* `getAuthHeaders()` can't be threaded through. The auth middleware
|
|
205
|
+
* accepts both `Authorization: Bearer <t>` and `?token=<t>` so this is
|
|
206
|
+
* the canonical fallback when the header path isn't available.
|
|
207
|
+
*
|
|
208
|
+
* Trade-off: query-param tokens can leak into server access logs and
|
|
209
|
+
* `Referer` headers. The download endpoint mitigates with
|
|
210
|
+
* `Cache-Control: no-store` + `X-Content-Type-Options: nosniff`. A
|
|
211
|
+
* future hardening pass should migrate to short-lived HMAC-signed
|
|
212
|
+
* file-scoped tokens; tracked separately.
|
|
213
|
+
*/
|
|
214
|
+
appendAuthToUrl(url) {
|
|
215
|
+
if (!this.token) return url;
|
|
216
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
217
|
+
return `${url}${separator}token=${encodeURIComponent(this.token)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
getWebSocketUrl(baseUrl) {
|
|
199
221
|
if (!this.token) return baseUrl;
|
|
200
222
|
const separator = baseUrl.includes('?') ? '&' : '?';
|