haltija 1.5.2 → 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,111 @@
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
+
77
+ ## 1.5.4
78
+
79
+ The `hj tabs open` client-less-tab trap now explains itself ([#5](https://github.com/tonioloewald/haltija/issues/5)).
80
+
81
+ Outside the desktop app, `hj tabs open <url>` has no tab API, so it falls back to `window.open()` —
82
+ and that new tab has **no haltija widget** unless its page injects one. So the server never hears
83
+ from it, it doesn't appear in `hj tabs`, and commands can't reach it (they go to the focused widget
84
+ tab). It presents as "the tab opened fine but every command goes somewhere else" — indistinguishable
85
+ from a routing bug.
86
+
87
+ - The fallback response now carries a `reason`, promoted to a top-level `warning` that `hj` prints
88
+ on stderr — at the one moment the client-less tab is created.
89
+ - The `/tabs/open` schema and `SKILL.md` now state plainly that only widget-injected tabs are
90
+ controllable and appear in `hj tabs`, so a non-responding tab is the first thing to check.
91
+
92
+ ## 1.5.3
93
+
94
+ Discoverability fix for the two CI browser engines ([#6](https://github.com/tonioloewald/haltija/issues/6)).
95
+
96
+ Both `--headless` and `--ci` said "for CI" with no hint that they drive **different** engines:
97
+ `--headless` is Playwright Chromium (and needs the `playwright` package), while `--ci` / `--app` /
98
+ `--private --app` drive Electron and need no Playwright. An agent picked `--headless`, hit
99
+ "Playwright not installed", and wrongly concluded haltija's CI mode is just a Playwright wrapper.
100
+
101
+ - `hj --help` now names the engine per mode and adds a "Choosing a CI engine" block; `--private` is
102
+ clarified as an *isolation* modifier that pairs with either engine (not "pair with `--headless`").
103
+ - The "Playwright not installed" error points at the Electron path (`--ci` / `--private --app`) as
104
+ the no-Playwright alternative.
105
+ - `llms.txt`, `docs/CI-INTEGRATION.md`, and `SKILL.md` get an honest "which engine?" framing:
106
+ it's Electron vs Playwright (neither is bundled), and the real reason to choose Playwright is
107
+ multi-engine coverage (Firefox/WebKit), not the words "for CI".
108
+
3
109
  ## 1.5.2
4
110
 
5
111
  Two follow-ups from the 1.5.0 review, both about the multi-tab experience on a shared server.
@@ -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.2",
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.2";
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();
@@ -5637,7 +5639,11 @@ ${elementSummary}${moreText}`;
5637
5639
  });
5638
5640
  } else {
5639
5641
  window.open(payload2.url, "_blank");
5640
- this.respond(msg2.id, true, { opened: true, fallback: true });
5642
+ this.respond(msg2.id, true, {
5643
+ opened: true,
5644
+ fallback: true,
5645
+ reason: "Opened a plain browser tab via window.open() (this is not the Haltija desktop app). " + "That tab has NO Haltija widget unless its page injects one, so it will NOT appear in " + "`hj tabs` and hj commands cannot reach it — untargeted commands go to the focused " + "widget tab instead. To control the new tab: inject the widget on that page (e.g. " + "HALTIJA_DEV=1 / haltijaDev:true in that project), or use the Haltija desktop app, " + "which auto-injects into every tab."
5646
+ });
5641
5647
  }
5642
5648
  } else if (action2 === "close") {
5643
5649
  if (haltija?.closeTab) {
@@ -7422,6 +7428,26 @@ ${elementSummary}${moreText}`;
7422
7428
  window.prompt = this.originalDialogs.prompt;
7423
7429
  this.originalDialogs = {};
7424
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
+ }
7425
7451
  interceptConsole() {
7426
7452
  const levels = [
7427
7453
  "log",
@@ -7437,31 +7463,73 @@ ${elementSummary}${moreText}`;
7437
7463
  try {
7438
7464
  const entry = {
7439
7465
  level,
7440
- args: args.map((arg) => {
7441
- try {
7442
- return JSON.parse(JSON.stringify(arg));
7443
- } catch {
7444
- return String(arg);
7445
- }
7446
- }),
7466
+ args: args.map((arg) => this.serializeConsoleArg(arg)),
7447
7467
  timestamp: Date.now()
7448
7468
  };
7449
7469
  if (level === "error") {
7450
- entry.stack = new Error().stack;
7451
- }
7452
- this.consoleBuffer.push(entry);
7453
- if (this.consoleBuffer.length > 1000) {
7454
- this.consoleBuffer = this.consoleBuffer.slice(-500);
7455
- }
7456
- if (level === "error") {
7457
- if (this.state === "connected") {
7458
- this.send("console", level, entry);
7459
- }
7460
- this.updateUI();
7470
+ const errArg = args.find((a) => a instanceof Error);
7471
+ entry.stack = errArg?.stack || new Error().stack;
7461
7472
  }
7473
+ this.recordConsoleEntry(entry);
7462
7474
  } catch {}
7463
7475
  };
7464
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;
7465
7533
  }
7466
7534
  restoreConsole() {
7467
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') {
@@ -37,12 +37,12 @@ ${bold('haltija')} - Browser control for AI agents
37
37
  Usage:
38
38
  haltija [options]
39
39
 
40
- Modes:
40
+ Modes: ${dim('(the browser ENGINE differs — see "Choosing a CI engine" below)')}
41
41
  ${dim('(default)')} Launch desktop app if electron available, otherwise server
42
- --app Explicitly launch desktop app (Electron)
43
- --server Server only (for CI, headless, or bookmarklet usage)
44
- --headless Start with headless Chromium browser (for CI)
45
- --ci CI mode: Electron app + wait for ready + sandbox disabled
42
+ --app Explicitly launch desktop app (Electron — Chromium)
43
+ --server Server only; bring your own browser (inject the widget). For bookmarklets/CI-lite
44
+ --headless Headless Chromium via ${bold('Playwright')} (needs the 'playwright' package) — for CI
45
+ --ci CI mode: ${bold('Electron')} app + wait-ready + sandbox off. No Playwright. Recommended for CI
46
46
 
47
47
  Options:
48
48
  --http HTTP only on port 8700 (default protocol)
@@ -62,7 +62,9 @@ Options:
62
62
  --private Isolated automation instance: own server on an EPHEMERAL port (never
63
63
  8700), own browser, torn down with the run. Never sees/adopts/navigates
64
64
  the shared interactive browser. Reports its address on stdout
65
- (HALTIJA_PRIVATE_READY {json}) and to --port-file. Pair with --headless.
65
+ (HALTIJA_PRIVATE_READY {json}) and to --port-file. This is an ISOLATION
66
+ modifier, not an engine: pair with --app (Electron, no Playwright) or
67
+ --headless (Playwright Chromium).
66
68
  --port-file <p> Write the (private) server's bound address as JSON to <p> when ready.
67
69
  --setup-mcp Configure Claude Desktop MCP integration
68
70
  --setup-mcp-check Check MCP configuration status
@@ -106,13 +108,23 @@ Subcommands:
106
108
 
107
109
  Use 'hj' as a short alias: hj tree, hj click @42, etc.
108
110
 
111
+ Choosing a CI engine ${dim('(both need one external browser, but a DIFFERENT one):')}
112
+ ${bold('Electron')} (Chromium, same engine the desktop app uses) — no Playwright:
113
+ haltija --ci # recommended: Electron + wait-ready + sandbox off
114
+ haltija --private --app # + isolation (own ephemeral port, adopts nothing)
115
+ ${dim('Electron is fetched via npx if not already installed.')}
116
+ ${bold('Playwright')} Chromium — needs the 'playwright' package:
117
+ npm i playwright && npx playwright install chromium
118
+ haltija --headless # single-engine; or --private --headless for isolation
119
+ ${dim("Pick this for MULTI-ENGINE coverage (Firefox/WebKit) — not just because it says \"CI\".")}
120
+
109
121
  Examples:
110
122
  haltija # Desktop app (or server fallback)
111
123
  haltija --app # Desktop app explicitly
112
- haltija --server # Server only
124
+ haltija --server # Server only (bring your own browser)
113
125
  haltija --server --https # HTTPS server only
114
- haltija --headless # Headless browser for CI
115
- haltija --ci # CI mode (headless + wait + sandbox disabled)
126
+ haltija --ci # CI: Electron (no Playwright) + wait + sandbox off
127
+ haltija --headless # CI: Playwright Chromium (needs playwright)
116
128
  haltija --setup-mcp # Configure Claude Desktop integration
117
129
  `)
118
130
  process.exit(0)
@@ -568,7 +580,12 @@ function launchApp(desktopDir, port) {
568
580
  const electronBinary = resolveElectronBinary()
569
581
  // A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
570
582
  // 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
571
- 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) }
572
589
  const child = electronBinary
573
590
  ? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
574
591
  : spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
@@ -583,6 +600,12 @@ function launchApp(desktopDir, port) {
583
600
  child.on('exit', code => {
584
601
  process.exit(code || 0)
585
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)
586
609
 
587
610
  // In CI/wait-ready mode, wait for server + browser to be ready
588
611
  if (waitReady) {
@@ -729,8 +752,12 @@ const startHeadlessBrowser = async (port) => {
729
752
 
730
753
  } catch (err) {
731
754
  if (err.code === 'ERR_MODULE_NOT_FOUND') {
732
- console.error('[tosijs-dev] Playwright not installed. Run: npm install playwright')
733
- console.error('[tosijs-dev] Then: npx playwright install chromium')
755
+ console.error('[tosijs-dev] --headless uses Playwright Chromium, which is not installed.')
756
+ console.error('[tosijs-dev] To use Playwright: npm install playwright && npx playwright install chromium')
757
+ console.error('[tosijs-dev] Or skip Playwright entirely and use the ELECTRON engine instead:')
758
+ console.error('[tosijs-dev] haltija --ci (Electron, waits for ready, sandbox off)')
759
+ console.error('[tosijs-dev] haltija --private --app (Electron + isolated ephemeral instance)')
760
+ console.error('[tosijs-dev] Reach for --headless (Playwright) when you need Firefox/WebKit coverage.')
734
761
  } else {
735
762
  console.error('[tosijs-dev] Failed to start headless browser:', err.message)
736
763
  }
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.2'
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.2";
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
  /**