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.
- package/apps/desktop/cdp-network.js +379 -0
- package/apps/desktop/main.js +87 -11
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/preload.js +5 -0
- package/apps/desktop/renderer/agent-status.js +25 -1
- package/apps/desktop/renderer/tabs.js +118 -9
- package/apps/desktop/renderer/widget-status.js +224 -0
- package/apps/desktop/renderer.js +56 -2
- package/apps/desktop/resources/component.js +201 -16
- package/apps/desktop/styles.css +101 -6
- package/apps/desktop/terminal.html +811 -43
- 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 +201 -16
- package/dist/hj.js +208 -122
- package/dist/index.js +368 -27
- package/dist/server.js +368 -27
- package/docs/AGENTIC-IDE.md +307 -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', () => {})
|
|
@@ -384,14 +385,28 @@ function setupMenu() {
|
|
|
384
385
|
},
|
|
385
386
|
{ type: 'separator' },
|
|
386
387
|
{
|
|
387
|
-
label: 'Developer Tools
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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)
|
package/apps/desktop/preload.js
CHANGED
|
@@ -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)
|
|
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
|
}
|