haltija 1.5.4 → 1.6.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0
4
+
5
+ Consolidating release: rolls up everything from 1.5.2–1.5.7 (the last npm-published version was
6
+ 1.5.4) and adds the fixes from a full nine-lens pre-release review of the whole span.
7
+
8
+ **Highlights since 1.5.4** (see the per-version entries below for detail):
9
+
10
+ - **`--private --app` teardown** (1.5.5, #7) — private Electron no longer orphans: skips the
11
+ single-instance lock, self-terminates when its spawner dies, `hj shutdown` tears it all down.
12
+ - **Console capture** (1.5.6) — uncaught exceptions, unhandled rejections, and `console.error(Error)`
13
+ messages/stacks are now captured (all were previously dropped).
14
+ - **Server picker** (1.5.7) — `hj servers` lists every live server; the desktop app is reachable as
15
+ `hj --name desktop`.
16
+
17
+ **Review-driven fixes in this release:**
18
+
19
+ - **`hj shutdown` no longer orphans a running desktop app.** It refuses (with a clear message —
20
+ quit the app from its window) instead of killing just the embedded server and leaving the window
21
+ on screen with dead tabs. `--private` instances still tear down fully.
22
+ - **`--name desktop` is reserved** — a normal server can no longer claim it and clobber the desktop
23
+ app's registry entry.
24
+ - The uncaught-error listeners are removed on widget disconnect (no leak across re-injection).
25
+ - `hj ls` is now an alias for `hj servers` (was `hj tree`); the old alias is gone.
26
+ - Added tabs-focus regression tests and `hj servers` / `hj --name desktop` / `hj shutdown` to the
27
+ generated docs and the agent skill.
28
+
29
+ ## 1.5.7
30
+
31
+ Pick between coexisting servers.
32
+
33
+ When a normal haltija server and the Electron desktop app both ran, there was no way to see them or
34
+ target the app — it was unregistered, invisible to `hj where` and the registry.
35
+
36
+ - **`hj servers`** (alias `hj ls`) lists every live server: registry entries, the probed defaults
37
+ 8700/8701, and this shell's target — with port, name, version, tab count, whether it's the
38
+ desktop app, and a `▸` on the one `hj` would drive.
39
+ - **The desktop app is now reachable as `hj --name desktop`.** Its public server registers under
40
+ the reserved name `desktop`, but *cwd-less* — so it's nameable without ever hijacking cwd routing.
41
+ (The internal chrome server stays unregistered; a `--private` app registers nothing.)
42
+
43
+ ## 1.5.6
44
+
45
+ `hj console` now captures the errors that actually matter.
46
+
47
+ It intercepted `console.*` calls, but the most important errors slipped through: an **uncaught
48
+ exception** (`throw`) and an **unhandled promise rejection** were never captured (no `window`
49
+ error / `unhandledrejection` listener), and `console.error(new Error(...))` recorded `{}` because
50
+ `JSON.stringify` drops an Error's message and stack. A page could be throwing on every action while
51
+ `hj console` showed it clean.
52
+
53
+ - Uncaught exceptions and unhandled rejections are now captured as `error` entries, with the real
54
+ stack. The `error` listener uses the capture phase, so failed resource loads (img/script/…) are
55
+ seen too.
56
+ - Error objects serialize to `{name, message, stack}` (at any depth), so the message survives.
57
+ - Note: capture begins when the widget is injected, so errors thrown *before* injection are only
58
+ caught by the desktop app (which injects at document-start).
59
+
60
+ ## 1.5.5
61
+
62
+ `--private` now really is "torn down with the run" ([#7](https://github.com/tonioloewald/haltija/issues/7)).
63
+
64
+ The private Electron instance used to survive teardown — holding Electron's single-instance lock (so
65
+ the **next** `--private` run failed with "Another instance is already running") and leaking a process
66
+ per run. Fixed three ways, all verified with real Electron:
67
+
68
+ - **A private run never takes the single-instance lock.** Private instances are isolated on ephemeral
69
+ ports and meant to run concurrently / back-to-back, so an orphan can no longer block the next run,
70
+ and two private runs coexist.
71
+ - **The private Electron self-terminates** when its spawner dies (even via SIGKILL) or on
72
+ SIGTERM/SIGINT. It reparents to launchd, so it watches the launcher's pid (`HALTIJA_SPAWNER_PID`)
73
+ and calls `app.quit()` — which reaps its own helper processes, unlike an external tree-kill.
74
+ - **`hj shutdown` / `hj quit`** (and `POST /shutdown`) on a private-desktop instance tears down the
75
+ whole thing — Electron and its servers — for a deterministic end-of-run teardown.
76
+
3
77
  ## 1.5.4
4
78
 
5
79
  The `hj tabs open` client-less-tab trap now explains itself ([#5](https://github.com/tonioloewald/haltija/issues/5)).
@@ -1212,6 +1212,10 @@ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, compone
1212
1212
  HALTIJA_PORT: port.toString(), // what src/server.ts actually reads
1213
1213
  DEV_CHANNEL_PORT: port.toString(),
1214
1214
  HALTIJA_DESKTOP: '1',
1215
+ // Only the PUBLIC server (the one agents drive) registers under the reserved 'desktop' name so
1216
+ // `hj --name desktop` / `hj servers` can find it; the internal chrome server stays unregistered.
1217
+ // See src/server.ts REGISTRY_NAME. (Ignored for a private run — those never register.)
1218
+ HALTIJA_DESKTOP_PUBLIC: role === 'public' ? '1' : '0',
1215
1219
  }
1216
1220
  if (IS_PRIVATE) {
1217
1221
  // Isolated instance: this child binds an EPHEMERAL port (HALTIJA_PRIVATE forces PORT=0) and
@@ -1532,8 +1536,12 @@ async function ensureServer() {
1532
1536
  }
1533
1537
  }
1534
1538
 
1535
- // Single-instance lock — prevent multiple Electron windows from launching
1536
- const gotTheLock = app.requestSingleInstanceLock()
1539
+ // Single-instance lock — prevent multiple Electron windows from launching.
1540
+ // A PRIVATE run must NOT contend for it (issue #7): private instances are isolated on ephemeral
1541
+ // ports and are meant to run many at once / back-to-back. Taking the shared lock means an orphaned
1542
+ // private Electron blocks the NEXT private run ("Another instance is already running"), and two
1543
+ // concurrent private runs collide. So a private run never requests and never holds the lock.
1544
+ const gotTheLock = IS_PRIVATE ? true : app.requestSingleInstanceLock()
1537
1545
 
1538
1546
  if (!gotTheLock) {
1539
1547
  console.log('[Haltija Desktop] Another instance is already running. Focusing existing window.')
@@ -1548,6 +1556,29 @@ if (!gotTheLock) {
1548
1556
  }
1549
1557
  })
1550
1558
 
1559
+ // "Torn down with the run": a PRIVATE Electron must not outlive its spawner (issue #7). An
1560
+ // orphaned private Electron holds nothing shared anymore (we skip the lock above), but it still
1561
+ // leaks a process + its servers. Electron reparents to launchd shortly after startup, so
1562
+ // process.ppid is useless — the launcher passes its own pid as HALTIJA_SPAWNER_PID and we poll
1563
+ // it. On the spawner's death, or a SIGTERM/SIGINT, quit via app.quit() → 'will-quit' kills the
1564
+ // child servers AND Electron reaps its own helper processes (which an EXTERNAL kill notoriously
1565
+ // fails to do — the reason we self-terminate instead of asking the consumer to hunt the tree).
1566
+ if (IS_PRIVATE) {
1567
+ let quitting = false
1568
+ const quitOnce = () => { if (quitting) return; quitting = true; try { app.quit() } catch {} }
1569
+ process.on('SIGTERM', quitOnce)
1570
+ process.on('SIGINT', quitOnce)
1571
+ const spawnerPid = parseInt(process.env.HALTIJA_SPAWNER_PID || '', 10)
1572
+ if (Number.isFinite(spawnerPid)) {
1573
+ const iv = setInterval(() => {
1574
+ let alive = true
1575
+ try { process.kill(spawnerPid, 0) } catch { alive = false } // signal 0 = existence check
1576
+ if (!alive) { clearInterval(iv); console.log('[Haltija Desktop] spawner gone — tearing down private instance'); quitOnce() }
1577
+ }, 1000)
1578
+ if (iv.unref) iv.unref()
1579
+ }
1580
+ }
1581
+
1551
1582
  // App lifecycle
1552
1583
  app.whenReady().then(async () => {
1553
1584
  console.log('[Haltija Desktop] App ready, starting initialization...')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.5.4",
3
+ "version": "1.6.0",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.5.4";
49
+ var VERSION = "1.6.0";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -1738,6 +1738,7 @@
1738
1738
  ws = null;
1739
1739
  state = "disconnected";
1740
1740
  consoleBuffer = [];
1741
+ errorCaptureInstalled = false;
1741
1742
  eventWatchers = new Map;
1742
1743
  mutationObserver = null;
1743
1744
  shadowObservers = new Map;
@@ -1969,6 +1970,7 @@
1969
1970
  this.killed = true;
1970
1971
  this.disconnect();
1971
1972
  this.restoreConsole();
1973
+ this.uninstallErrorCapture();
1972
1974
  this.restoreDialogs();
1973
1975
  this.clearEventWatchers();
1974
1976
  this.stopMutationWatch();
@@ -7426,6 +7428,26 @@ ${elementSummary}${moreText}`;
7426
7428
  window.prompt = this.originalDialogs.prompt;
7427
7429
  this.originalDialogs = {};
7428
7430
  }
7431
+ serializeConsoleArg(arg) {
7432
+ try {
7433
+ return JSON.parse(JSON.stringify(arg, (_k, v) => v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v));
7434
+ } catch {
7435
+ return String(arg);
7436
+ }
7437
+ }
7438
+ recordConsoleEntry(entry) {
7439
+ try {
7440
+ this.consoleBuffer.push(entry);
7441
+ if (this.consoleBuffer.length > 1000) {
7442
+ this.consoleBuffer = this.consoleBuffer.slice(-500);
7443
+ }
7444
+ if (entry.level === "error") {
7445
+ if (this.state === "connected")
7446
+ this.send("console", "error", entry);
7447
+ this.updateUI();
7448
+ }
7449
+ } catch {}
7450
+ }
7429
7451
  interceptConsole() {
7430
7452
  const levels = [
7431
7453
  "log",
@@ -7441,31 +7463,73 @@ ${elementSummary}${moreText}`;
7441
7463
  try {
7442
7464
  const entry = {
7443
7465
  level,
7444
- args: args.map((arg) => {
7445
- try {
7446
- return JSON.parse(JSON.stringify(arg));
7447
- } catch {
7448
- return String(arg);
7449
- }
7450
- }),
7466
+ args: args.map((arg) => this.serializeConsoleArg(arg)),
7451
7467
  timestamp: Date.now()
7452
7468
  };
7453
7469
  if (level === "error") {
7454
- entry.stack = new Error().stack;
7455
- }
7456
- this.consoleBuffer.push(entry);
7457
- if (this.consoleBuffer.length > 1000) {
7458
- this.consoleBuffer = this.consoleBuffer.slice(-500);
7459
- }
7460
- if (level === "error") {
7461
- if (this.state === "connected") {
7462
- this.send("console", level, entry);
7463
- }
7464
- this.updateUI();
7470
+ const errArg = args.find((a) => a instanceof Error);
7471
+ entry.stack = errArg?.stack || new Error().stack;
7465
7472
  }
7473
+ this.recordConsoleEntry(entry);
7466
7474
  } catch {}
7467
7475
  };
7468
7476
  }
7477
+ this.installErrorCapture();
7478
+ }
7479
+ onWindowError = null;
7480
+ onUnhandledRejection = null;
7481
+ installErrorCapture() {
7482
+ if (typeof window === "undefined" || this.errorCaptureInstalled)
7483
+ return;
7484
+ this.errorCaptureInstalled = true;
7485
+ this.onWindowError = (event) => {
7486
+ try {
7487
+ const err = event.error;
7488
+ let message;
7489
+ let stack;
7490
+ if (err instanceof Error) {
7491
+ message = `Uncaught ${err.name}: ${err.message}`;
7492
+ stack = err.stack;
7493
+ } else if (event.target?.tagName) {
7494
+ const el = event.target;
7495
+ message = `Resource failed to load: <${String(el.tagName).toLowerCase()}> ${el.src || el.href || ""}`.trim();
7496
+ } else {
7497
+ message = `Uncaught error: ${event.message || "unknown"}`;
7498
+ }
7499
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
7500
+ this.recordConsoleEntry({
7501
+ level: "error",
7502
+ args: [message + where],
7503
+ timestamp: Date.now(),
7504
+ stack
7505
+ });
7506
+ } catch {}
7507
+ };
7508
+ window.addEventListener("error", this.onWindowError, true);
7509
+ this.onUnhandledRejection = (event) => {
7510
+ try {
7511
+ const reason = event.reason;
7512
+ const args = reason instanceof Error ? [`Unhandled promise rejection: ${reason.name}: ${reason.message}`] : ["Unhandled promise rejection:", this.serializeConsoleArg(reason)];
7513
+ this.recordConsoleEntry({
7514
+ level: "error",
7515
+ args,
7516
+ timestamp: Date.now(),
7517
+ stack: reason instanceof Error ? reason.stack : undefined
7518
+ });
7519
+ } catch {}
7520
+ };
7521
+ window.addEventListener("unhandledrejection", this.onUnhandledRejection);
7522
+ }
7523
+ uninstallErrorCapture() {
7524
+ if (typeof window !== "undefined") {
7525
+ if (this.onWindowError)
7526
+ window.removeEventListener("error", this.onWindowError, true);
7527
+ if (this.onUnhandledRejection)
7528
+ window.removeEventListener("unhandledrejection", this.onUnhandledRejection);
7529
+ }
7530
+ this.onWindowError = null;
7531
+ this.onUnhandledRejection = null;
7532
+ this.errorCaptureInstalled = false;
7469
7533
  }
7470
7534
  restoreConsole() {
7471
7535
  for (const [level, fn] of Object.entries(this.originalConsole)) {
@@ -1073,7 +1073,7 @@ const COMMAND_ALIASES = {
1073
1073
  'execute': 'eval',
1074
1074
  'shot': 'screenshot',
1075
1075
  'capture': 'screenshot',
1076
- 'ls': 'tree',
1076
+ // NB: `ls` is NOT an alias for `tree` — it's intercepted in hj.mjs as `hj servers` (list servers).
1077
1077
  'list': 'tree',
1078
1078
  'show': 'tree',
1079
1079
  'help': '--help',
package/bin/hj.mjs CHANGED
@@ -126,6 +126,71 @@ async function runWhere(port, portSource, jsonOutput) {
126
126
  }
127
127
  }
128
128
 
129
+ /**
130
+ * `hj servers` — enumerate every live haltija server so you can pick one when several coexist
131
+ * (e.g. a project server + the Electron desktop app). Sources: the registry, the well-known
132
+ * defaults 8700/8701 (to catch anything unregistered), and this shell's resolved target. Marks the
133
+ * one `hj` would drive. Pure probes + registry read; no side effects, never auto-launches.
134
+ */
135
+ async function runServers(resolvedPort) {
136
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
137
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
138
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
139
+ const token = process.env.HALTIJA_TOKEN
140
+
141
+ const byPort = new Map()
142
+ for (const e of listLiveInstances()) {
143
+ byPort.set(String(e.port), { port: String(e.port), name: e.name, cwd: e.cwd })
144
+ }
145
+ for (const p of ['8700', '8701', String(resolvedPort)]) {
146
+ if (!byPort.has(p)) byPort.set(p, { port: p, name: null, cwd: null })
147
+ }
148
+
149
+ const rows = await Promise.all(
150
+ [...byPort.values()].map(async (c) => {
151
+ try {
152
+ const resp = await fetch(`http://localhost:${c.port}/status`, {
153
+ headers: token ? { 'X-Haltija-Token': token } : {},
154
+ signal: AbortSignal.timeout(2000),
155
+ })
156
+ if (!resp.ok) return { ...c, up: false }
157
+ const s = await resp.json()
158
+ return {
159
+ ...c,
160
+ up: true,
161
+ version: s.serverVersion || '?',
162
+ desktopApp: !!s.desktopApp,
163
+ tabs: Array.isArray(s.windows) ? s.windows.length : s.browsers ?? 0,
164
+ }
165
+ } catch {
166
+ return { ...c, up: false }
167
+ }
168
+ }),
169
+ )
170
+
171
+ const up = rows.filter((r) => r.up).sort((a, b) => Number(a.port) - Number(b.port))
172
+ if (!up.length) {
173
+ console.log('No haltija servers are running.')
174
+ console.log(dim('Start one: bunx haltija --server (or the desktop app: bunx haltija)'))
175
+ return
176
+ }
177
+
178
+ console.log(bold('Live haltija servers') + dim(' (▸ = what this shell targets)'))
179
+ for (const r of up) {
180
+ const here = String(r.port) === String(resolvedPort) ? green('▸') : ' '
181
+ const name = r.desktopApp ? 'desktop' : r.name || '(unnamed)'
182
+ const tabs = `${r.tabs} tab${r.tabs === 1 ? '' : 's'}`
183
+ const kind = r.desktopApp ? 'desktop app' : r.cwd || ''
184
+ console.log(
185
+ ` ${here} ${String(r.port).padEnd(6)} ${name.padEnd(14)} v${String(r.version).padEnd(8)} ${tabs.padEnd(9)} ${dim(kind)}`,
186
+ )
187
+ }
188
+ if (!up.some((r) => String(r.port) === String(resolvedPort))) {
189
+ console.log(dim(`\nThis shell targets :${resolvedPort}, but nothing is listening there.`))
190
+ }
191
+ console.log(dim('\nPick one: ') + `hj --port <n> <cmd>` + dim(' or ') + `hj --name <name> <cmd>`)
192
+ }
193
+
129
194
  /**
130
195
  * Resolve a named haltija instance to its port by reading
131
196
  * ~/.haltija/servers/<name>.json. Returns null if the file is missing,
@@ -211,6 +276,11 @@ ${dim('Overriding that (per-shell):')}
211
276
  ${dim('export HALTIJA_TOKEN=secret')} # required when server was started with HALTIJA_TOKEN
212
277
  ${dim('hj --token secret tree')} # one-off token override
213
278
  ${dim('hj --version')} # which hj is this?
279
+
280
+ ${dim('Lifecycle:')}
281
+ ${dim('hj where')} # which server this shell targets + what is alive there
282
+ ${dim('hj servers')} # list ALL live servers (pick one with --port/--name)
283
+ ${dim('hj shutdown')} # stop the targeted server (a private --app: Electron + all)
214
284
  ${listSubcommands()}
215
285
  Run ${dim('hj --help')} for this help.
216
286
  Run ${dim('haltija --help')} for server/app options.
@@ -367,6 +437,43 @@ if (subcommand === 'where') {
367
437
  process.exit(0)
368
438
  }
369
439
 
440
+ // `hj servers` / `hj ls` — list every live haltija server (registry + defaults + this shell's
441
+ // target), so you can pick one when several coexist. Diagnostic; never auto-launches.
442
+ if (subcommand === 'servers' || subcommand === 'ls') {
443
+ await runServers(port)
444
+ process.exit(0)
445
+ }
446
+
447
+ // `hj shutdown` / `hj quit` — cleanly stop the targeted server. For a private `--app` instance this
448
+ // tears down the WHOLE thing (Electron + its child servers); for a plain server it stops that
449
+ // server. Never auto-launches (it's a stop command), so it's handled here before the routing table.
450
+ if (subcommand === 'shutdown' || subcommand === 'quit') {
451
+ const token = process.env.HALTIJA_TOKEN
452
+ try {
453
+ const resp = await fetch(`http://localhost:${port}/shutdown`, {
454
+ method: 'POST',
455
+ headers: token ? { 'X-Haltija-Token': token } : {},
456
+ signal: AbortSignal.timeout(3000),
457
+ })
458
+ const j = await resp.json().catch(() => ({}))
459
+ if (resp.ok) {
460
+ console.log(j.message || `Shutdown requested on port ${port}.`)
461
+ process.exit(0)
462
+ }
463
+ // Surface the server's explanation (e.g. the desktop-app refusal), not a bare status code.
464
+ console.error(`hj ${subcommand}: ${j.error || `server on port ${port} returned HTTP ${resp.status}`}`)
465
+ process.exit(1)
466
+ } catch (err) {
467
+ // Nothing listening = already stopped; that's success for a stop command.
468
+ if (err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED') {
469
+ console.log(`No server listening on port ${port} (already stopped).`)
470
+ process.exit(0)
471
+ }
472
+ console.error(`hj ${subcommand}: ${err.message}`)
473
+ process.exit(1)
474
+ }
475
+ }
476
+
370
477
  if (!isSubcommand(subcommand)) {
371
478
  const suggestion = getSuggestion(subcommand)
372
479
  if (suggestion === '--help') {
@@ -580,7 +580,12 @@ function launchApp(desktopDir, port) {
580
580
  const electronBinary = resolveElectronBinary()
581
581
  // A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
582
582
  // 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
583
- const appEnv = privateMode ? { ...env } : { ...env, DEV_CHANNEL_PORT: String(port) }
583
+ // For a private run we also hand the app OUR pid (HALTIJA_SPAWNER_PID): the Electron process
584
+ // reparents to launchd shortly after startup, so it can't watch us via process.ppid — it polls
585
+ // this pid instead and quits when we're gone (issue #7, "torn down with the run").
586
+ const appEnv = privateMode
587
+ ? { ...env, HALTIJA_SPAWNER_PID: String(process.pid) }
588
+ : { ...env, DEV_CHANNEL_PORT: String(port) }
584
589
  const child = electronBinary
585
590
  ? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
586
591
  : spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
@@ -595,6 +600,12 @@ function launchApp(desktopDir, port) {
595
600
  child.on('exit', code => {
596
601
  process.exit(code || 0)
597
602
  })
603
+
604
+ // Belt-and-suspenders to the app's own spawner-pid poll: if WE get a signal, ask the app to quit
605
+ // before we exit. (If we're SIGKILLed we can't do this — that's exactly what the poll covers.)
606
+ const signalChild = () => { try { child.kill('SIGTERM') } catch {} ; process.exit(0) }
607
+ process.on('SIGTERM', signalChild)
608
+ process.on('SIGINT', signalChild)
598
609
 
599
610
  // In CI/wait-ready mode, wait for server + browser to be ready
600
611
  if (waitReady) {
package/bin/version.mjs CHANGED
@@ -3,4 +3,4 @@
3
3
  * ⚠️ To change the version, update package.json and run: bun run build
4
4
  */
5
5
 
6
- export const HJ_VERSION = '1.5.4'
6
+ export const HJ_VERSION = '1.6.0'
@@ -20,13 +20,14 @@
20
20
  * - Option+Tab toggles visibility (but active state always shows briefly)
21
21
  * - Localhost only by default
22
22
  */
23
- export declare const VERSION = "1.5.4";
23
+ export declare const VERSION = "1.6.0";
24
24
  export declare class DevChannel extends HTMLElement {
25
25
  static get tagName(): string;
26
26
  static elementCreator(): () => DevChannel;
27
27
  private ws;
28
28
  private state;
29
29
  private consoleBuffer;
30
+ private errorCaptureInstalled;
30
31
  private eventWatchers;
31
32
  private mutationObserver;
32
33
  private shadowObservers;
@@ -396,7 +397,27 @@ export declare class DevChannel extends HTMLElement {
396
397
  private replaySession;
397
398
  private interceptDialogs;
398
399
  private restoreDialogs;
400
+ /**
401
+ * Serialize a console argument for capture. `JSON.stringify(new Error(...))` returns `"{}"` —
402
+ * message and stack are non-enumerable — so a plain round-trip silently drops the single most
403
+ * useful thing an agent wants. Convert Errors (at any depth, via the replacer) to a structured
404
+ * `{name, message, stack}` instead.
405
+ */
406
+ private serializeConsoleArg;
407
+ /** Record one console/error entry: buffer it, cap the buffer, and (for errors) push to the
408
+ * server and flag the UI. Shared by console interception AND the uncaught-error listeners. */
409
+ private recordConsoleEntry;
399
410
  private interceptConsole;
411
+ /**
412
+ * Capture what `console.error` interception alone misses: uncaught exceptions and unhandled
413
+ * promise rejections — i.e. the errors that are actual bugs, which never route through
414
+ * `console.error`. Without these, `hj console` shows a page as clean while it's throwing.
415
+ */
416
+ private onWindowError;
417
+ private onUnhandledRejection;
418
+ private installErrorCapture;
419
+ /** Remove the uncaught-error/rejection listeners so they don't accumulate across re-injection. */
420
+ private uninstallErrorCapture;
400
421
  private restoreConsole;
401
422
  }
402
423
  /**
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "1.5.4";
2
+ var VERSION = "1.6.0";
3
3
 
4
4
  // src/text-selector.ts
5
5
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -1691,6 +1691,7 @@ class DevChannel extends HTMLElement {
1691
1691
  ws = null;
1692
1692
  state = "disconnected";
1693
1693
  consoleBuffer = [];
1694
+ errorCaptureInstalled = false;
1694
1695
  eventWatchers = new Map;
1695
1696
  mutationObserver = null;
1696
1697
  shadowObservers = new Map;
@@ -1922,6 +1923,7 @@ class DevChannel extends HTMLElement {
1922
1923
  this.killed = true;
1923
1924
  this.disconnect();
1924
1925
  this.restoreConsole();
1926
+ this.uninstallErrorCapture();
1925
1927
  this.restoreDialogs();
1926
1928
  this.clearEventWatchers();
1927
1929
  this.stopMutationWatch();
@@ -7379,6 +7381,26 @@ ${elementSummary}${moreText}`;
7379
7381
  window.prompt = this.originalDialogs.prompt;
7380
7382
  this.originalDialogs = {};
7381
7383
  }
7384
+ serializeConsoleArg(arg) {
7385
+ try {
7386
+ return JSON.parse(JSON.stringify(arg, (_k, v) => v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v));
7387
+ } catch {
7388
+ return String(arg);
7389
+ }
7390
+ }
7391
+ recordConsoleEntry(entry) {
7392
+ try {
7393
+ this.consoleBuffer.push(entry);
7394
+ if (this.consoleBuffer.length > 1000) {
7395
+ this.consoleBuffer = this.consoleBuffer.slice(-500);
7396
+ }
7397
+ if (entry.level === "error") {
7398
+ if (this.state === "connected")
7399
+ this.send("console", "error", entry);
7400
+ this.updateUI();
7401
+ }
7402
+ } catch {}
7403
+ }
7382
7404
  interceptConsole() {
7383
7405
  const levels = [
7384
7406
  "log",
@@ -7394,31 +7416,73 @@ ${elementSummary}${moreText}`;
7394
7416
  try {
7395
7417
  const entry = {
7396
7418
  level,
7397
- args: args.map((arg) => {
7398
- try {
7399
- return JSON.parse(JSON.stringify(arg));
7400
- } catch {
7401
- return String(arg);
7402
- }
7403
- }),
7419
+ args: args.map((arg) => this.serializeConsoleArg(arg)),
7404
7420
  timestamp: Date.now()
7405
7421
  };
7406
7422
  if (level === "error") {
7407
- entry.stack = new Error().stack;
7408
- }
7409
- this.consoleBuffer.push(entry);
7410
- if (this.consoleBuffer.length > 1000) {
7411
- this.consoleBuffer = this.consoleBuffer.slice(-500);
7412
- }
7413
- if (level === "error") {
7414
- if (this.state === "connected") {
7415
- this.send("console", level, entry);
7416
- }
7417
- this.updateUI();
7423
+ const errArg = args.find((a) => a instanceof Error);
7424
+ entry.stack = errArg?.stack || new Error().stack;
7418
7425
  }
7426
+ this.recordConsoleEntry(entry);
7419
7427
  } catch {}
7420
7428
  };
7421
7429
  }
7430
+ this.installErrorCapture();
7431
+ }
7432
+ onWindowError = null;
7433
+ onUnhandledRejection = null;
7434
+ installErrorCapture() {
7435
+ if (typeof window === "undefined" || this.errorCaptureInstalled)
7436
+ return;
7437
+ this.errorCaptureInstalled = true;
7438
+ this.onWindowError = (event) => {
7439
+ try {
7440
+ const err = event.error;
7441
+ let message;
7442
+ let stack;
7443
+ if (err instanceof Error) {
7444
+ message = `Uncaught ${err.name}: ${err.message}`;
7445
+ stack = err.stack;
7446
+ } else if (event.target?.tagName) {
7447
+ const el = event.target;
7448
+ message = `Resource failed to load: <${String(el.tagName).toLowerCase()}> ${el.src || el.href || ""}`.trim();
7449
+ } else {
7450
+ message = `Uncaught error: ${event.message || "unknown"}`;
7451
+ }
7452
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
7453
+ this.recordConsoleEntry({
7454
+ level: "error",
7455
+ args: [message + where],
7456
+ timestamp: Date.now(),
7457
+ stack
7458
+ });
7459
+ } catch {}
7460
+ };
7461
+ window.addEventListener("error", this.onWindowError, true);
7462
+ this.onUnhandledRejection = (event) => {
7463
+ try {
7464
+ const reason = event.reason;
7465
+ const args = reason instanceof Error ? [`Unhandled promise rejection: ${reason.name}: ${reason.message}`] : ["Unhandled promise rejection:", this.serializeConsoleArg(reason)];
7466
+ this.recordConsoleEntry({
7467
+ level: "error",
7468
+ args,
7469
+ timestamp: Date.now(),
7470
+ stack: reason instanceof Error ? reason.stack : undefined
7471
+ });
7472
+ } catch {}
7473
+ };
7474
+ window.addEventListener("unhandledrejection", this.onUnhandledRejection);
7475
+ }
7476
+ uninstallErrorCapture() {
7477
+ if (typeof window !== "undefined") {
7478
+ if (this.onWindowError)
7479
+ window.removeEventListener("error", this.onWindowError, true);
7480
+ if (this.onUnhandledRejection)
7481
+ window.removeEventListener("unhandledrejection", this.onUnhandledRejection);
7482
+ }
7483
+ this.onWindowError = null;
7484
+ this.onUnhandledRejection = null;
7485
+ this.errorCaptureInstalled = false;
7422
7486
  }
7423
7487
  restoreConsole() {
7424
7488
  for (const [level, fn] of Object.entries(this.originalConsole)) {