haltija 1.2.7 → 1.3.0-beta.3

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.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * CDP Network Monitor — Captures network traffic via Chrome DevTools Protocol.
3
+ *
4
+ * Uses Electron's webContents.debugger API to attach to webviews and
5
+ * capture request/response data. Output is token-optimized for AI agents.
6
+ *
7
+ * Usage from main process:
8
+ * const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork } = require('./cdp-network.js')
9
+ * attachNetwork(webContents) // Start monitoring
10
+ * getNetworkLog(webContents.id) // Get buffered entries
11
+ */
12
+
13
+ // Per-webContents state
14
+ const monitors = new Map()
15
+
16
+ // Default noise patterns — URLs matching these are filtered in standard/minimal presets
17
+ const NOISE_PATTERNS = [
18
+ /google-analytics\.com/,
19
+ /googletagmanager\.com/,
20
+ /facebook\.net\/tr/,
21
+ /doubleclick\.net/,
22
+ /hotjar\.com/,
23
+ /sentry\.io\/api/,
24
+ /clarity\.ms/,
25
+ /segment\.io/,
26
+ /mixpanel\.com/,
27
+ /amplitude\.com/,
28
+ /intercom\.io/,
29
+ /fullstory\.com/,
30
+ /newrelic\.com/,
31
+ /datadoghq\.com/,
32
+ ]
33
+
34
+ // Resource types considered "noise" for minimal preset
35
+ const ASSET_TYPES = new Set(['Image', 'Font', 'Stylesheet', 'Media'])
36
+
37
+ const PRESETS = {
38
+ errors: { showAssets: false, showNoise: false, errorsOnly: true },
39
+ minimal: { showAssets: false, showNoise: false, errorsOnly: false },
40
+ standard: { showAssets: true, showNoise: false, errorsOnly: false },
41
+ verbose: { showAssets: true, showNoise: true, errorsOnly: false },
42
+ }
43
+
44
+ /**
45
+ * Attach CDP Network monitoring to a webContents.
46
+ * @param {Electron.WebContents} wc
47
+ * @param {object} opts
48
+ * @param {string} opts.preset - 'errors' | 'minimal' | 'standard' | 'verbose'
49
+ * @param {number} opts.maxBuffer - Max entries to keep (default 200)
50
+ * @param {string[]} opts.includePatterns - URL patterns to include (overrides noise filter)
51
+ * @param {string[]} opts.excludePatterns - Additional URL patterns to exclude
52
+ */
53
+ function attachNetwork(wc, opts = {}) {
54
+ const wcId = wc.id
55
+ if (monitors.has(wcId)) {
56
+ // Already attached — update options
57
+ const mon = monitors.get(wcId)
58
+ mon.preset = opts.preset || mon.preset
59
+ mon.maxBuffer = opts.maxBuffer || mon.maxBuffer
60
+ return { success: true, alreadyAttached: true }
61
+ }
62
+
63
+ const monitor = {
64
+ wc,
65
+ preset: opts.preset || 'standard',
66
+ maxBuffer: opts.maxBuffer || 200,
67
+ includePatterns: (opts.includePatterns || []).map(p => new RegExp(p)),
68
+ excludePatterns: (opts.excludePatterns || []).map(p => new RegExp(p)),
69
+ entries: [], // circular buffer of NetworkEntry
70
+ pending: new Map(), // requestId → partial entry (waiting for response)
71
+ attached: false,
72
+ }
73
+
74
+ try {
75
+ wc.debugger.attach('1.3')
76
+ monitor.attached = true
77
+ } catch (err) {
78
+ // Debugger may already be attached by DevTools
79
+ if (err.message?.includes('Already attached')) {
80
+ monitor.attached = true
81
+ } else {
82
+ return { success: false, error: `CDP attach failed: ${err.message}` }
83
+ }
84
+ }
85
+
86
+ // Enable Network domain
87
+ wc.debugger.sendCommand('Network.enable', {}).catch(err => {
88
+ console.error(`[CDP Network] Network.enable failed for wc ${wcId}:`, err.message)
89
+ })
90
+
91
+ // Listen for CDP events
92
+ const handler = (event, method, params) => {
93
+ handleCdpEvent(monitor, method, params)
94
+ }
95
+ wc.debugger.on('message', handler)
96
+ monitor._handler = handler
97
+
98
+ // Clean up when webContents is destroyed
99
+ const destroyHandler = () => {
100
+ detachNetwork(wc)
101
+ }
102
+ wc.once('destroyed', destroyHandler)
103
+ monitor._destroyHandler = destroyHandler
104
+
105
+ monitors.set(wcId, monitor)
106
+ return { success: true }
107
+ }
108
+
109
+ /**
110
+ * Detach CDP Network monitoring from a webContents.
111
+ */
112
+ function detachNetwork(wc) {
113
+ const wcId = wc.id
114
+ const monitor = monitors.get(wcId)
115
+ if (!monitor) return
116
+
117
+ try {
118
+ if (monitor._handler) {
119
+ wc.debugger.removeListener('message', monitor._handler)
120
+ }
121
+ if (monitor.attached) {
122
+ wc.debugger.sendCommand('Network.disable', {}).catch(() => {})
123
+ wc.debugger.detach()
124
+ }
125
+ } catch {
126
+ // Already detached or destroyed
127
+ }
128
+
129
+ monitors.delete(wcId)
130
+ }
131
+
132
+ /**
133
+ * Get buffered network entries for a webContents, filtered by preset.
134
+ * @param {number} wcId - webContents.id
135
+ * @param {object} opts
136
+ * @param {string} opts.preset - Override the monitor's preset
137
+ * @param {number} opts.since - Only entries after this timestamp
138
+ * @param {number} opts.limit - Max entries to return
139
+ * @returns {{ entries: NetworkEntry[], summary: string }}
140
+ */
141
+ function getNetworkLog(wcId, opts = {}) {
142
+ const monitor = monitors.get(wcId)
143
+ if (!monitor) return { entries: [], summary: 'not watching' }
144
+
145
+ const preset = PRESETS[opts.preset || monitor.preset] || PRESETS.standard
146
+ const since = opts.since || 0
147
+ const limit = opts.limit || 100
148
+
149
+ let filtered = monitor.entries.filter(e => e.ts >= since)
150
+
151
+ // Apply preset filtering
152
+ if (preset.errorsOnly) {
153
+ filtered = filtered.filter(e => e.s >= 400 || e.s === -1 || e.err)
154
+ }
155
+ if (!preset.showAssets) {
156
+ filtered = filtered.filter(e => !ASSET_TYPES.has(e.type))
157
+ }
158
+ if (!preset.showNoise) {
159
+ filtered = filtered.filter(e => !isNoise(e.url, monitor))
160
+ }
161
+
162
+ // Apply custom include/exclude
163
+ if (monitor.includePatterns.length > 0) {
164
+ // Include patterns override noise filter
165
+ filtered = monitor.entries.filter(e =>
166
+ e.ts >= since && monitor.includePatterns.some(p => p.test(e.url))
167
+ )
168
+ }
169
+ if (monitor.excludePatterns.length > 0) {
170
+ filtered = filtered.filter(e => !monitor.excludePatterns.some(p => p.test(e.url)))
171
+ }
172
+
173
+ // Most recent first, limited
174
+ filtered = filtered.slice(-limit)
175
+
176
+ const summary = buildSummary(monitor.entries.filter(e => e.ts >= since))
177
+
178
+ return { entries: filtered, summary }
179
+ }
180
+
181
+ /**
182
+ * Get summary statistics.
183
+ */
184
+ function getNetworkStats(wcId) {
185
+ const monitor = monitors.get(wcId)
186
+ if (!monitor) return { watching: false }
187
+
188
+ const entries = monitor.entries
189
+ const total = entries.length
190
+ const failed = entries.filter(e => e.s >= 400 || e.s === -1 || e.err).length
191
+ const pending = monitor.pending.size
192
+ const totalBytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0)
193
+ const avgTime = total > 0 ? Math.round(entries.reduce((sum, e) => sum + (e.t || 0), 0) / total) : 0
194
+
195
+ return {
196
+ watching: true,
197
+ preset: monitor.preset,
198
+ total,
199
+ failed,
200
+ pending,
201
+ totalBytes,
202
+ avgTime,
203
+ summary: buildSummary(entries),
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Clear the network buffer.
209
+ */
210
+ function clearNetwork(wcId) {
211
+ const monitor = monitors.get(wcId)
212
+ if (!monitor) return
213
+ monitor.entries = []
214
+ monitor.pending.clear()
215
+ }
216
+
217
+ /**
218
+ * Check if a webContents is being monitored.
219
+ */
220
+ function isMonitoring(wcId) {
221
+ return monitors.has(wcId)
222
+ }
223
+
224
+ // ============================================
225
+ // Internal: CDP event handling
226
+ // ============================================
227
+
228
+ function handleCdpEvent(monitor, method, params) {
229
+ switch (method) {
230
+ case 'Network.requestWillBeSent':
231
+ handleRequestStart(monitor, params)
232
+ break
233
+ case 'Network.responseReceived':
234
+ handleResponse(monitor, params)
235
+ break
236
+ case 'Network.loadingFinished':
237
+ handleLoadingFinished(monitor, params)
238
+ break
239
+ case 'Network.loadingFailed':
240
+ handleLoadingFailed(monitor, params)
241
+ break
242
+ }
243
+ }
244
+
245
+ function handleRequestStart(monitor, params) {
246
+ const { requestId, request, redirectResponse, type, timestamp } = params
247
+
248
+ // If this is a redirect, update the existing entry
249
+ if (redirectResponse && monitor.pending.has(requestId)) {
250
+ const existing = monitor.pending.get(requestId)
251
+ existing.redirects = (existing.redirects || 0) + 1
252
+ existing.url = trimUrl(request.url)
253
+ existing.m = request.method
254
+ return
255
+ }
256
+
257
+ const entry = {
258
+ id: requestId.slice(0, 8),
259
+ m: request.method,
260
+ url: trimUrl(request.url),
261
+ fullUrl: request.url,
262
+ s: 0, // pending
263
+ t: 0,
264
+ sz: '',
265
+ bytes: 0,
266
+ type: type || 'Other',
267
+ ts: Math.round(timestamp * 1000),
268
+ _startTime: timestamp,
269
+ }
270
+
271
+ monitor.pending.set(requestId, entry)
272
+ }
273
+
274
+ function handleResponse(monitor, params) {
275
+ const { requestId, response } = params
276
+ const entry = monitor.pending.get(requestId)
277
+ if (!entry) return
278
+
279
+ entry.s = response.status
280
+ entry.mimeType = response.mimeType
281
+
282
+ // Detect CORS errors
283
+ if (response.status === 0 && response.headers?.['access-control-allow-origin'] === undefined) {
284
+ entry.cors = true
285
+ entry.err = 'CORS'
286
+ }
287
+ }
288
+
289
+ function handleLoadingFinished(monitor, params) {
290
+ const { requestId, encodedDataLength, timestamp } = params
291
+ const entry = monitor.pending.get(requestId)
292
+ if (!entry) return
293
+
294
+ entry.bytes = encodedDataLength || 0
295
+ entry.sz = humanSize(entry.bytes)
296
+ entry.t = Math.round((timestamp - entry._startTime) * 1000)
297
+ delete entry._startTime
298
+ delete entry.fullUrl
299
+
300
+ finishEntry(monitor, requestId, entry)
301
+ }
302
+
303
+ function handleLoadingFailed(monitor, params) {
304
+ const { requestId, errorText, canceled, timestamp } = params
305
+ const entry = monitor.pending.get(requestId)
306
+ if (!entry) return
307
+
308
+ entry.s = -1
309
+ entry.err = canceled ? 'canceled' : (errorText || 'failed')
310
+ entry.t = Math.round((timestamp - entry._startTime) * 1000)
311
+ delete entry._startTime
312
+ delete entry.fullUrl
313
+
314
+ finishEntry(monitor, requestId, entry)
315
+ }
316
+
317
+ function finishEntry(monitor, requestId, entry) {
318
+ monitor.pending.delete(requestId)
319
+ monitor.entries.push(entry)
320
+
321
+ // Trim buffer
322
+ while (monitor.entries.length > monitor.maxBuffer) {
323
+ monitor.entries.shift()
324
+ }
325
+ }
326
+
327
+ // ============================================
328
+ // Internal: formatting helpers
329
+ // ============================================
330
+
331
+ function trimUrl(url) {
332
+ try {
333
+ const u = new URL(url)
334
+ let path = u.pathname
335
+ // Collapse long paths
336
+ if (path.length > 60) {
337
+ const parts = path.split('/')
338
+ if (parts.length > 4) {
339
+ path = '/' + parts[1] + '/.../' + parts[parts.length - 1]
340
+ }
341
+ }
342
+ // Trim query
343
+ let query = u.search
344
+ if (query.length > 30) {
345
+ query = query.slice(0, 27) + '...'
346
+ }
347
+ // Omit localhost origin
348
+ const origin = (u.hostname === 'localhost' || u.hostname === '127.0.0.1')
349
+ ? '' : u.host
350
+ return (origin ? origin : '') + path + query
351
+ } catch {
352
+ return url.length > 80 ? url.slice(0, 77) + '...' : url
353
+ }
354
+ }
355
+
356
+ function humanSize(bytes) {
357
+ if (bytes === 0) return '0B'
358
+ if (bytes < 1024) return bytes + 'B'
359
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/, '') + 'K'
360
+ return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + 'M'
361
+ }
362
+
363
+ function isNoise(url, monitor) {
364
+ return NOISE_PATTERNS.some(p => p.test(url))
365
+ }
366
+
367
+ function buildSummary(entries) {
368
+ const total = entries.length
369
+ const failed = entries.filter(e => e.s >= 400 || e.s === -1 || e.err).length
370
+ const totalBytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0)
371
+ const avgTime = total > 0 ? Math.round(entries.reduce((sum, e) => sum + (e.t || 0), 0) / total) : 0
372
+ const parts = [`${total} req`]
373
+ if (failed > 0) parts.push(`${failed} failed`)
374
+ parts.push(`${avgTime}ms avg`)
375
+ parts.push(humanSize(totalBytes))
376
+ return parts.join(', ')
377
+ }
378
+
379
+ module.exports = { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork, isMonitoring, humanSize }
@@ -23,6 +23,7 @@ const fs = require('fs')
23
23
  const os = require('os')
24
24
  const { spawn } = require('child_process')
25
25
  const http = require('http')
26
+ const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork, isMonitoring } = require('./cdp-network.js')
26
27
 
27
28
  // Suppress EIO errors when stdout/stderr pipes break during shutdown
28
29
  process.stdout.on('error', () => {})
@@ -384,14 +385,28 @@ function setupMenu() {
384
385
  },
385
386
  { type: 'separator' },
386
387
  {
387
- label: 'Developer Tools (Shell)',
388
- accelerator: isMac ? 'Alt+Cmd+I' : 'Ctrl+Shift+I',
389
- click: () => mainWindow?.webContents.toggleDevTools(),
390
- },
391
- {
392
- label: 'Developer Tools (Tab)',
393
- accelerator: isMac ? 'Alt+Cmd+J' : 'Ctrl+Shift+J',
394
- click: () => mainWindow?.webContents.send('menu-devtools-tab'),
388
+ label: 'Developer Tools',
389
+ submenu: [
390
+ {
391
+ label: 'Shell DevTools',
392
+ accelerator: isMac ? 'Alt+Cmd+I' : 'Ctrl+Shift+I',
393
+ click: () => mainWindow?.webContents.toggleDevTools(),
394
+ },
395
+ {
396
+ label: 'Tab DevTools',
397
+ accelerator: isMac ? 'Alt+Cmd+J' : 'Ctrl+Shift+J',
398
+ click: () => mainWindow?.webContents.send('menu-devtools-tab'),
399
+ },
400
+ { type: 'separator' },
401
+ {
402
+ label: 'Chrome Console',
403
+ click: () => mainWindow?.webContents.send('menu-chrome-console'),
404
+ },
405
+ {
406
+ label: 'Chrome DOM Tree',
407
+ click: () => mainWindow?.webContents.send('menu-chrome-tree'),
408
+ },
409
+ ],
395
410
  },
396
411
  { type: 'separator' },
397
412
  { role: 'togglefullscreen' },
@@ -700,6 +715,19 @@ function setupWebContentsInjection(wc) {
700
715
  })
701
716
  }
702
717
 
718
+ /**
719
+ * Find the webview webContents for a given IPC sender.
720
+ * If sender IS a webview, return it directly.
721
+ * If sender is the renderer (main window), return null (caller should use explicit wcId).
722
+ */
723
+ function findWebContentsForSender(sender) {
724
+ if (!sender) return null
725
+ // webview-preload.js IPC: sender is the webview itself
726
+ if (sender.getType() === 'webview') return sender
727
+ // renderer preload.js IPC: sender is the main window, not useful for targeting a specific webview
728
+ return null
729
+ }
730
+
703
731
  async function injectWidget(webContents) {
704
732
  const url = webContents.getURL()
705
733
  console.log('[Haltija Desktop] Injecting widget into:', url)
@@ -745,7 +773,7 @@ async function injectWidget(webContents) {
745
773
  // This is stable across navigations (even cross-origin) enabling cross-page recording
746
774
  const wsUrl = HALTIJA_SERVER.replace('http:', 'ws:') + '/ws/browser'
747
775
  const windowId = `hj-${APP_INSTANCE_ID}-${webContents.id}`
748
- const configObj = { serverUrl: wsUrl, windowId }
776
+ const configObj = { serverUrl: wsUrl, windowId, mode: 'headless' }
749
777
  // Session priority: pending session from agent's tabs/open > env var
750
778
  if (pendingTabSession) {
751
779
  configObj.session = pendingTabSession
@@ -879,14 +907,20 @@ function setupScreenCapture() {
879
907
  return dialog.showOpenDialog(mainWindow, options)
880
908
  })
881
909
 
910
+ ipcMain.handle('open-renderer-devtools', async () => {
911
+ if (mainWindow) mainWindow.webContents.openDevTools({ mode: 'detach' })
912
+ return true
913
+ })
914
+
882
915
  // Navigate URL with smart fallback (called from widget in webview)
883
916
  // Routes through renderer's navigate() which has https->http fallback
917
+ // Passes the sender's webContentsId so the renderer navigates the correct tab
884
918
  ipcMain.handle('navigate-url', async (event, url) => {
885
919
  if (!mainWindow) return { success: false, error: 'No window' }
886
920
 
887
921
  try {
888
- // Send to renderer which has the smart navigate function
889
- mainWindow.webContents.send('navigate-url', { url })
922
+ const senderWcId = event.sender.id
923
+ mainWindow.webContents.send('navigate-url', { url, webContentsId: senderWcId })
890
924
  return { success: true }
891
925
  } catch (err) {
892
926
  return { success: false, error: err.message }
@@ -913,6 +947,48 @@ function setupScreenCapture() {
913
947
  return true
914
948
  })
915
949
 
950
+ // CDP Network monitoring — manages per-webContents debugger sessions
951
+ // Accepts explicit wcId for renderer-routed calls, falls back to sender for direct webview calls
952
+ const resolveNetworkWc = (event, wcId) => {
953
+ if (wcId) {
954
+ const { webContents: wcModule } = require('electron')
955
+ return wcModule.fromId(wcId)
956
+ }
957
+ return findWebContentsForSender(event.sender)
958
+ }
959
+
960
+ ipcMain.handle('network-watch', async (event, opts = {}) => {
961
+ const wc = resolveNetworkWc(event, opts.wcId)
962
+ if (!wc) return { success: false, error: 'No webview found' }
963
+ return attachNetwork(wc, opts)
964
+ })
965
+
966
+ ipcMain.handle('network-unwatch', async (event, wcId) => {
967
+ const wc = resolveNetworkWc(event, wcId)
968
+ if (!wc) return { success: false, error: 'No webview found' }
969
+ detachNetwork(wc)
970
+ return { success: true }
971
+ })
972
+
973
+ ipcMain.handle('network-log', async (event, opts = {}) => {
974
+ const wc = resolveNetworkWc(event, opts.wcId)
975
+ if (!wc) return { entries: [], summary: 'no webview' }
976
+ return getNetworkLog(wc.id, opts)
977
+ })
978
+
979
+ ipcMain.handle('network-stats', async (event, wcId) => {
980
+ const wc = resolveNetworkWc(event, wcId)
981
+ if (!wc) return { watching: false }
982
+ return getNetworkStats(wc.id)
983
+ })
984
+
985
+ ipcMain.handle('network-clear', async (event, wcId) => {
986
+ const wc = resolveNetworkWc(event, wcId)
987
+ if (!wc) return { success: false }
988
+ clearNetwork(wc.id)
989
+ return { success: true }
990
+ })
991
+
916
992
  // Video capture — forwarded to renderer where MediaRecorder runs
917
993
  // Widget (webview) → main → renderer → main → widget
918
994
  // Main also provides media source ID from webContents (not available on webview DOM element)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.2.7",
3
+ "version": "1.3.0-beta.3",
4
4
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
5
5
  "homepage": "https://github.com/tonioloewald/haltija",
6
6
  "author": {
@@ -44,6 +44,8 @@ contextBridge.exposeInMainWorld('haltija', {
44
44
  onMenuReloadTab: (callback) => ipcRenderer.on('menu-reload-tab', callback),
45
45
  onMenuForceReloadTab: (callback) => ipcRenderer.on('menu-force-reload-tab', callback),
46
46
  onMenuDevToolsTab: (callback) => ipcRenderer.on('menu-devtools-tab', callback),
47
+ onMenuChromeConsole: (callback) => ipcRenderer.on('menu-chrome-console', callback),
48
+ onMenuChromeTree: (callback) => ipcRenderer.on('menu-chrome-tree', callback),
47
49
  onMenuBack: (callback) => ipcRenderer.on('menu-back', callback),
48
50
  onMenuForward: (callback) => ipcRenderer.on('menu-forward', callback),
49
51
  onMenuFocusUrl: (callback) => ipcRenderer.on('menu-focus-url', callback),
@@ -80,6 +82,9 @@ contextBridge.exposeInMainWorld('haltija', {
80
82
 
81
83
  // Window management
82
84
  closeWindow: () => ipcRenderer.send('close-window'),
85
+
86
+ // DevTools
87
+ openRendererDevTools: () => ipcRenderer.invoke('open-renderer-devtools'),
83
88
  })
84
89
 
85
90
  console.log('[Haltija] Renderer preload complete, exposed:', Object.keys(window.haltija || {}))
@@ -52,7 +52,9 @@ function handleAgentStatusMessage(msg) {
52
52
  break
53
53
  case 'shell-renamed':
54
54
  if (connectedShells.has(msg.shellId)) {
55
- connectedShells.get(msg.shellId).name = msg.name
55
+ const info = connectedShells.get(msg.shellId)
56
+ info.name = msg.name
57
+ info.isAgent = msg.name?.includes('agent')
56
58
  updateAgentSelector()
57
59
  }
58
60
  break
@@ -186,6 +188,12 @@ function updateAgentSelector() {
186
188
  const agents = Array.from(connectedShells.entries())
187
189
  .filter(([_, info]) => info.isAgent)
188
190
 
191
+ // Hide the selector unless there are multiple agents to choose between
192
+ const selectorEl = el.agentSelect?.parentElement
193
+ if (selectorEl) {
194
+ selectorEl.style.display = agents.length > 1 ? '' : 'none'
195
+ }
196
+
189
197
  if (agents.length === 0) {
190
198
  el.agentSelect.innerHTML = '<option value="">No agents</option>'
191
199
  } else {
@@ -196,6 +204,10 @@ function updateAgentSelector() {
196
204
  }
197
205
 
198
206
  export async function initAgentStatusBar() {
207
+ // Hide agent selector by default (only shown when multiple agents connected)
208
+ const selectorEl = el.agentSelect?.parentElement
209
+ if (selectorEl) selectorEl.style.display = 'none'
210
+
199
211
  try {
200
212
  const response = await fetch(`${getServerUrl()}/terminal/status`)
201
213
  if (response.ok) {
@@ -206,5 +218,17 @@ export async function initAgentStatusBar() {
206
218
  console.log('[Agent Status] Could not fetch initial status:', err.message)
207
219
  }
208
220
 
221
+ // Seed agent list from existing sessions (may have connected before this WS)
222
+ try {
223
+ const res = await fetch(`${getServerUrl()}/terminal/agents`)
224
+ if (res.ok) {
225
+ const data = await res.json()
226
+ for (const agent of data.agents || []) {
227
+ connectedShells.set(agent.id, { name: agent.name, isAgent: true })
228
+ }
229
+ updateAgentSelector()
230
+ }
231
+ } catch {}
232
+
209
233
  connectAgentStatusWs()
210
234
  }