haltija 1.2.7 → 1.3.0-beta.1
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/apps/desktop/cdp-network.js +379 -0
- package/apps/desktop/main.js +59 -2
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/renderer/agent-status.js +25 -1
- package/apps/desktop/renderer/tabs.js +8 -0
- package/apps/desktop/renderer.js +18 -2
- package/apps/desktop/resources/component.js +62 -1
- package/apps/desktop/terminal.html +698 -40
- package/apps/desktop/webview-preload.js +6 -0
- package/bin/cli-subcommand.mjs +99 -104
- package/bin/format-network.mjs +90 -0
- package/bin/hj.mjs +33 -7
- package/dist/codemirror-raw.js +29 -0
- package/dist/codemirror.js +30 -0
- package/dist/component.js +62 -1
- package/dist/hj.js +208 -122
- package/dist/index.js +327 -4
- package/dist/server.js +327 -4
- package/docs/AGENTIC-IDE.md +228 -0
- package/package.json +12 -1
|
@@ -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 }
|
package/apps/desktop/main.js
CHANGED
|
@@ -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', () => {})
|
|
@@ -700,6 +701,19 @@ function setupWebContentsInjection(wc) {
|
|
|
700
701
|
})
|
|
701
702
|
}
|
|
702
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Find the webview webContents for a given IPC sender.
|
|
706
|
+
* If sender IS a webview, return it directly.
|
|
707
|
+
* If sender is the renderer (main window), return null (caller should use explicit wcId).
|
|
708
|
+
*/
|
|
709
|
+
function findWebContentsForSender(sender) {
|
|
710
|
+
if (!sender) return null
|
|
711
|
+
// webview-preload.js IPC: sender is the webview itself
|
|
712
|
+
if (sender.getType() === 'webview') return sender
|
|
713
|
+
// renderer preload.js IPC: sender is the main window, not useful for targeting a specific webview
|
|
714
|
+
return null
|
|
715
|
+
}
|
|
716
|
+
|
|
703
717
|
async function injectWidget(webContents) {
|
|
704
718
|
const url = webContents.getURL()
|
|
705
719
|
console.log('[Haltija Desktop] Injecting widget into:', url)
|
|
@@ -881,12 +895,13 @@ function setupScreenCapture() {
|
|
|
881
895
|
|
|
882
896
|
// Navigate URL with smart fallback (called from widget in webview)
|
|
883
897
|
// Routes through renderer's navigate() which has https->http fallback
|
|
898
|
+
// Passes the sender's webContentsId so the renderer navigates the correct tab
|
|
884
899
|
ipcMain.handle('navigate-url', async (event, url) => {
|
|
885
900
|
if (!mainWindow) return { success: false, error: 'No window' }
|
|
886
901
|
|
|
887
902
|
try {
|
|
888
|
-
|
|
889
|
-
mainWindow.webContents.send('navigate-url', { url })
|
|
903
|
+
const senderWcId = event.sender.id
|
|
904
|
+
mainWindow.webContents.send('navigate-url', { url, webContentsId: senderWcId })
|
|
890
905
|
return { success: true }
|
|
891
906
|
} catch (err) {
|
|
892
907
|
return { success: false, error: err.message }
|
|
@@ -913,6 +928,48 @@ function setupScreenCapture() {
|
|
|
913
928
|
return true
|
|
914
929
|
})
|
|
915
930
|
|
|
931
|
+
// CDP Network monitoring — manages per-webContents debugger sessions
|
|
932
|
+
// Accepts explicit wcId for renderer-routed calls, falls back to sender for direct webview calls
|
|
933
|
+
const resolveNetworkWc = (event, wcId) => {
|
|
934
|
+
if (wcId) {
|
|
935
|
+
const { webContents: wcModule } = require('electron')
|
|
936
|
+
return wcModule.fromId(wcId)
|
|
937
|
+
}
|
|
938
|
+
return findWebContentsForSender(event.sender)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
ipcMain.handle('network-watch', async (event, opts = {}) => {
|
|
942
|
+
const wc = resolveNetworkWc(event, opts.wcId)
|
|
943
|
+
if (!wc) return { success: false, error: 'No webview found' }
|
|
944
|
+
return attachNetwork(wc, opts)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
ipcMain.handle('network-unwatch', async (event, wcId) => {
|
|
948
|
+
const wc = resolveNetworkWc(event, wcId)
|
|
949
|
+
if (!wc) return { success: false, error: 'No webview found' }
|
|
950
|
+
detachNetwork(wc)
|
|
951
|
+
return { success: true }
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
ipcMain.handle('network-log', async (event, opts = {}) => {
|
|
955
|
+
const wc = resolveNetworkWc(event, opts.wcId)
|
|
956
|
+
if (!wc) return { entries: [], summary: 'no webview' }
|
|
957
|
+
return getNetworkLog(wc.id, opts)
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
ipcMain.handle('network-stats', async (event, wcId) => {
|
|
961
|
+
const wc = resolveNetworkWc(event, wcId)
|
|
962
|
+
if (!wc) return { watching: false }
|
|
963
|
+
return getNetworkStats(wc.id)
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
ipcMain.handle('network-clear', async (event, wcId) => {
|
|
967
|
+
const wc = resolveNetworkWc(event, wcId)
|
|
968
|
+
if (!wc) return { success: false }
|
|
969
|
+
clearNetwork(wc.id)
|
|
970
|
+
return { success: true }
|
|
971
|
+
})
|
|
972
|
+
|
|
916
973
|
// Video capture — forwarded to renderer where MediaRecorder runs
|
|
917
974
|
// Widget (webview) → main → renderer → main → widget
|
|
918
975
|
// Main also provides media source ID from webContents (not available on webview DOM element)
|
|
@@ -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)
|
|
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
|
}
|
|
@@ -258,6 +258,14 @@ export function navigate(url, tabId = activeTabId) {
|
|
|
258
258
|
const tab = tabs.find((t) => t.id === tabId)
|
|
259
259
|
if (!tab) return
|
|
260
260
|
|
|
261
|
+
// Never navigate terminal/agent iframes — they aren't browser tabs
|
|
262
|
+
if (tab.isTerminal) {
|
|
263
|
+
console.warn('[Haltija] Refusing to navigate terminal tab, finding content tab instead')
|
|
264
|
+
const contentTab = tabs.find(t => !t.isTerminal)
|
|
265
|
+
if (contentTab) return navigate(url, contentTab.id)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
261
269
|
let addedHttps = false
|
|
262
270
|
|
|
263
271
|
if (url && !url.match(/^(https?|blob|data|file|about|javascript):\/?\/?/i)) {
|
package/apps/desktop/renderer.js
CHANGED
|
@@ -202,8 +202,24 @@ if (window.haltija) {
|
|
|
202
202
|
})
|
|
203
203
|
|
|
204
204
|
window.haltija.onNavigateUrl?.((data) => {
|
|
205
|
-
console.log('[Haltija Desktop] Navigate request from widget:', data.url)
|
|
206
|
-
navigate
|
|
205
|
+
console.log('[Haltija Desktop] Navigate request from widget:', data.url, 'wcId:', data.webContentsId)
|
|
206
|
+
// Find the tab that owns the webview that sent the navigate request
|
|
207
|
+
// so we navigate the correct tab, not the active (possibly terminal) tab
|
|
208
|
+
let targetTabId = undefined
|
|
209
|
+
if (data.webContentsId) {
|
|
210
|
+
const match = tabs.find(t => {
|
|
211
|
+
try {
|
|
212
|
+
return t.webview?.getWebContentsId && t.webview.getWebContentsId() === data.webContentsId
|
|
213
|
+
} catch { return false }
|
|
214
|
+
})
|
|
215
|
+
if (match) targetTabId = match.id
|
|
216
|
+
}
|
|
217
|
+
// Fallback: navigate the first non-terminal tab (never navigate a terminal/agent iframe)
|
|
218
|
+
if (!targetTabId) {
|
|
219
|
+
const contentTab = tabs.find(t => !t.isTerminal)
|
|
220
|
+
if (contentTab) targetTabId = contentTab.id
|
|
221
|
+
}
|
|
222
|
+
navigate(data.url, targetTabId)
|
|
207
223
|
})
|
|
208
224
|
}
|
|
209
225
|
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.
|
|
49
|
+
var VERSION = "1.3.0-beta.1";
|
|
50
50
|
|
|
51
51
|
// src/text-selector.ts
|
|
52
52
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
|
|
@@ -5164,6 +5164,9 @@ ${elementSummary}${moreText}`;
|
|
|
5164
5164
|
case "video":
|
|
5165
5165
|
this.handleVideoMessage(msg2);
|
|
5166
5166
|
break;
|
|
5167
|
+
case "network":
|
|
5168
|
+
this.handleNetworkMessage(msg2);
|
|
5169
|
+
break;
|
|
5167
5170
|
}
|
|
5168
5171
|
this.render();
|
|
5169
5172
|
}
|
|
@@ -5232,6 +5235,64 @@ ${elementSummary}${moreText}`;
|
|
|
5232
5235
|
this.respond(msg2.id, false, undefined, `Unknown video action: ${action2}`);
|
|
5233
5236
|
}
|
|
5234
5237
|
}
|
|
5238
|
+
handleNetworkMessage(msg2) {
|
|
5239
|
+
const { action: action2, payload: payload2 } = msg2;
|
|
5240
|
+
const haltija = window.haltija;
|
|
5241
|
+
const notAvailable = "Network monitoring requires the Haltija Desktop app (CDP access)";
|
|
5242
|
+
if (action2 === "watch") {
|
|
5243
|
+
if (!haltija?.networkWatch) {
|
|
5244
|
+
this.respond(msg2.id, false, undefined, notAvailable);
|
|
5245
|
+
return;
|
|
5246
|
+
}
|
|
5247
|
+
haltija.networkWatch(payload2 || {}).then((result2) => {
|
|
5248
|
+
this.respond(msg2.id, result2.success !== false, result2);
|
|
5249
|
+
}).catch((err) => {
|
|
5250
|
+
this.respond(msg2.id, false, undefined, err.message);
|
|
5251
|
+
});
|
|
5252
|
+
} else if (action2 === "unwatch") {
|
|
5253
|
+
if (!haltija?.networkUnwatch) {
|
|
5254
|
+
this.respond(msg2.id, false, undefined, notAvailable);
|
|
5255
|
+
return;
|
|
5256
|
+
}
|
|
5257
|
+
haltija.networkUnwatch().then((result2) => {
|
|
5258
|
+
this.respond(msg2.id, true, result2);
|
|
5259
|
+
}).catch((err) => {
|
|
5260
|
+
this.respond(msg2.id, false, undefined, err.message);
|
|
5261
|
+
});
|
|
5262
|
+
} else if (action2 === "get") {
|
|
5263
|
+
if (!haltija?.networkLog) {
|
|
5264
|
+
this.respond(msg2.id, false, undefined, notAvailable);
|
|
5265
|
+
return;
|
|
5266
|
+
}
|
|
5267
|
+
haltija.networkLog(payload2 || {}).then((result2) => {
|
|
5268
|
+
this.respond(msg2.id, true, result2);
|
|
5269
|
+
}).catch((err) => {
|
|
5270
|
+
this.respond(msg2.id, false, undefined, err.message);
|
|
5271
|
+
});
|
|
5272
|
+
} else if (action2 === "stats") {
|
|
5273
|
+
if (!haltija?.networkStats) {
|
|
5274
|
+
this.respond(msg2.id, false, undefined, notAvailable);
|
|
5275
|
+
return;
|
|
5276
|
+
}
|
|
5277
|
+
haltija.networkStats().then((result2) => {
|
|
5278
|
+
this.respond(msg2.id, true, result2);
|
|
5279
|
+
}).catch((err) => {
|
|
5280
|
+
this.respond(msg2.id, false, undefined, err.message);
|
|
5281
|
+
});
|
|
5282
|
+
} else if (action2 === "clear") {
|
|
5283
|
+
if (!haltija?.networkClear) {
|
|
5284
|
+
this.respond(msg2.id, false, undefined, notAvailable);
|
|
5285
|
+
return;
|
|
5286
|
+
}
|
|
5287
|
+
haltija.networkClear().then((result2) => {
|
|
5288
|
+
this.respond(msg2.id, true, result2);
|
|
5289
|
+
}).catch((err) => {
|
|
5290
|
+
this.respond(msg2.id, false, undefined, err.message);
|
|
5291
|
+
});
|
|
5292
|
+
} else {
|
|
5293
|
+
this.respond(msg2.id, false, undefined, `Unknown network action: ${action2}`);
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
5235
5296
|
handleSemanticMessage(msg2) {
|
|
5236
5297
|
const { action: action2, payload: payload2 } = msg2;
|
|
5237
5298
|
if (action2 === "start" || action2 === "watch") {
|