haltija 1.2.7 → 1.3.0-beta.10

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.
Files changed (54) hide show
  1. package/README.md +80 -2
  2. package/apps/desktop/cdp-network.js +379 -0
  3. package/apps/desktop/main.js +224 -115
  4. package/apps/desktop/package.json +3 -4
  5. package/apps/desktop/preload.js +9 -1
  6. package/apps/desktop/renderer/agent-status.js +25 -1
  7. package/apps/desktop/renderer/tabs.js +123 -10
  8. package/apps/desktop/renderer/widget-status.js +224 -0
  9. package/apps/desktop/renderer.js +58 -3
  10. package/apps/desktop/resources/component.js +253 -131
  11. package/apps/desktop/styles.css +101 -6
  12. package/apps/desktop/terminal.html +811 -43
  13. package/apps/desktop/webview-preload.js +6 -0
  14. package/bin/cli-subcommand.mjs +156 -137
  15. package/bin/format-network.mjs +90 -0
  16. package/bin/hj.mjs +93 -14
  17. package/bin/tosijs-dev.mjs +27 -5
  18. package/dist/agent-message-format.d.ts +56 -0
  19. package/dist/agent-shell.d.ts +161 -0
  20. package/dist/api-handlers.d.ts +107 -0
  21. package/dist/api-router.d.ts +29 -0
  22. package/dist/api-schema.d.ts +894 -0
  23. package/dist/bookmarklet.d.ts +21 -0
  24. package/dist/browser-tests.d.ts +49 -0
  25. package/dist/client.d.ts +168 -0
  26. package/dist/codemirror-bundle.d.ts +16 -0
  27. package/dist/codemirror-raw.js +29 -0
  28. package/dist/codemirror.js +30 -0
  29. package/dist/component.d.ts +391 -0
  30. package/dist/component.esm.js +7456 -0
  31. package/dist/component.js +253 -131
  32. package/dist/embedded-assets.d.ts +18 -0
  33. package/dist/hj.js +302 -151
  34. package/dist/index.d.ts +46 -0
  35. package/dist/index.js +8229 -311
  36. package/dist/key-codes.d.ts +12 -0
  37. package/dist/mcp-config.d.ts +71 -0
  38. package/dist/server.d.ts +23 -0
  39. package/dist/server.js +8229 -311
  40. package/dist/sessions.d.ts +49 -0
  41. package/dist/task-board.d.ts +45 -0
  42. package/dist/tasks.d.ts +77 -0
  43. package/dist/terminal.d.ts +154 -0
  44. package/dist/test-data.d.ts +37 -0
  45. package/dist/test-formatters.d.ts +68 -0
  46. package/dist/test-generator.d.ts +37 -0
  47. package/dist/test-page.d.ts +8 -0
  48. package/dist/test.d.ts +136 -0
  49. package/dist/text-selector.d.ts +30 -0
  50. package/dist/types.d.ts +1054 -0
  51. package/dist/version.d.ts +11 -0
  52. package/docs/AGENTIC-IDE.md +309 -0
  53. package/llms.txt +47 -0
  54. package/package.json +15 -2
package/README.md CHANGED
@@ -141,9 +141,9 @@ Raw DOM events are noise. Haltija aggregates them into intent:
141
141
 
142
142
  Screenshots include a chyron (title, URL, timestamp) so agents always know what they're looking at. Disable with `chyron: false` for clean captures.
143
143
 
144
- ### Multi-Window & Session Affinity
144
+ ### Multi-Window
145
145
 
146
- Control multiple tabs. Session headers (`X-Haltija-Session`) give agents sticky window targeting no need to specify window ID every call.
146
+ Control multiple tabs. The focused tab receives untargeted commands; pass `?window=<id>` (REST) or `--window <id>` (CLI) to target a specific tab.
147
147
 
148
148
  ### Selection Tool
149
149
 
@@ -190,6 +190,59 @@ Your agent uses the same `hj` commands either way — it doesn't know or care wh
190
190
 
191
191
  ---
192
192
 
193
+ ## Embed Haltija in Your Own App
194
+
195
+ Building a tool, dev environment, or product that wants an agent eye built in? Run a haltija server on a port you choose and import the widget directly. Two flavours:
196
+
197
+ ```js
198
+ // Visible — widget renders its own UI in the corner
199
+ import { inject } from 'haltija/component'
200
+ inject('ws://localhost:9123/ws/browser')
201
+
202
+ // Headless — widget is present but invisible; agent still has full control
203
+ inject('ws://localhost:9123/ws/browser', { mode: 'headless' })
204
+ ```
205
+
206
+ Or via HTML, no JS required:
207
+
208
+ ```html
209
+ <haltija-dev server="ws://localhost:9123/ws/browser"></haltija-dev>
210
+ ```
211
+
212
+ Tell `hj` which server to talk to (per-shell):
213
+
214
+ ```bash
215
+ # Named instance — recommended, no port juggling
216
+ haltija --name dashboard --server # in one shell: register as "dashboard"
217
+ export HALTIJA_NAME=dashboard # in your other shells
218
+ hj tree # finds dashboard via ~/.haltija/servers/
219
+
220
+ # Port-based — if you'd rather pin a number
221
+ haltija --port 9123 --server
222
+ export HALTIJA_PORT=9123
223
+ hj tree
224
+ ```
225
+
226
+ If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port — `--name` records whichever port it ends up on so `hj` can find it. A different shell can target a different project; there's no global state, just one named instance per haltija server.
227
+
228
+ **Production embedding.** When haltija is reachable beyond loopback, gate it with a shared-secret token:
229
+
230
+ ```bash
231
+ haltija --port 9123 --token $(openssl rand -hex 16) --server
232
+ ```
233
+
234
+ ```js
235
+ inject('ws://your-host:9123/ws/browser', { token: 'same-secret' })
236
+ ```
237
+
238
+ ```bash
239
+ HALTIJA_TOKEN=same-secret hj tree
240
+ ```
241
+
242
+ The server rejects every REST/WebSocket request without a matching `X-Haltija-Token` (or `?token=` for WebSockets). This is a stub — provide your own TLS, key rotation, and per-agent identity if you need them.
243
+
244
+ ---
245
+
193
246
  ## Installation
194
247
 
195
248
  ```bash
@@ -200,6 +253,7 @@ npm install -g haltija # Install globally
200
253
  # Server options
201
254
  haltija --https # HTTPS mode
202
255
  haltija --port 3000 # Custom port
256
+ haltija --token <secret> # Require X-Haltija-Token on every request
203
257
  haltija --headless # For CI pipelines
204
258
  haltija --setup-mcp # Configure Claude Desktop
205
259
  ```
@@ -241,6 +295,30 @@ hj --help # CLI subcommand reference
241
295
 
242
296
  ---
243
297
 
298
+ ## 1.3.0-beta.8 — change of direction
299
+
300
+ Earlier 1.3 betas tried to support multiple agents on a single haltija server by issuing each widget a *session token* and routing requests by `X-Haltija-Session`. The model was load-bearing but leaky — six of the last fifteen commits before this release were firefighting session-isolation regressions.
301
+
302
+ beta.8 deletes the entire mechanism and replaces it with **process boundaries**: each project runs its own haltija server, and the agent talks to the right one by **port** or by **name**.
303
+
304
+ ```bash
305
+ haltija --name dashboard --server # one project, registers itself in ~/.haltija/servers/
306
+ HALTIJA_NAME=dashboard hj tree # any shell can address it by name
307
+ ```
308
+
309
+ What this means for you, depending on how you used 1.3.0-beta.7:
310
+
311
+ - **`bunx haltija` desktop app** — works the same; no migration needed. The outer "chrome" widget that lets the app inspect itself now lives on a separate internal port (8701) so it never appears in agent-facing window listings.
312
+ - **`HALTIJA_SESSION` / `--session` / `--secure` / the click-to-copy session badge** — gone. If you were setting `HALTIJA_SESSION` in your shell, replace it with `HALTIJA_NAME` (and start the server with `--name <foo>`) or `HALTIJA_PORT`.
313
+ - **`inject(url, { session })`** — the `session` option is removed. If you need auth, use `inject(url, { token })` (matches `haltija --token <secret>`); for embedding without auth, just `inject(url)` or `inject(url, { mode: 'headless' })`.
314
+ - **`hj-chrome` exclusion logic** — gone from the public REST API. To inspect the desktop app's outer UI from `hj`, target the internal port directly: `HALTIJA_PORT=8701 hj tree`.
315
+
316
+ Net code change: ~830 lines removed, ~150 added back for the simpler model. Test count went up (we now have integration coverage for the token stub, named instances, and auto-port fallback that the previous betas lacked).
317
+
318
+ The pre-revert state is preserved on the `multi-user-isolation` branch in case the multi-agent-on-one-server design ever needs revisiting.
319
+
320
+ ---
321
+
244
322
  ## License
245
323
 
246
324
  Apache 2.0
@@ -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 }