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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.61",
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 when the CSS theme changes (data-theme attribute)
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' && this.terminal) {
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.socket = new WebSocket(wsUrl);
1648
- this.socket.binaryType = 'arraybuffer';
1649
-
1650
- this.socket.onopen = () => {
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
- this.socket.onmessage = (event) => {
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
- this.socket.onclose = (event) => {
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
- setTimeout(() => this.reconnect(), restartBackoff);
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
- setTimeout(() => this.reconnect(), Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000) * (0.7 + Math.random() * 0.6));
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
- this.socket.onerror = (error) => {
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
- }, 1000);
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
- if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
3524
- this._heartbeatTimer = setInterval(() => {
3525
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
3526
- this.send({ type: 'ping' });
3527
- }
3528
- }, 30000);
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
- toggleFileBrowser() {
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
- if (this._fileBrowserPanel) {
3541
- this._fileBrowserPanel.toggle();
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
- if (!this._fileBrowserPanel && window.fileBrowser) {
3588
- this._fileBrowserPanel = new window.fileBrowser.FileBrowserPanel({
3589
- app: this,
3590
- authFetch: (url, opts) => this.authFetch(url, opts),
3591
- initialPath: this.getCurrentWorkingDir(),
3592
- });
3593
- }
3594
- if (this._fileBrowserPanel) {
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
 
@@ -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('?') ? '&' : '?';