haltija 1.5.4 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.1
4
+
5
+ Makes haltija's detection reachable by automation ([#8](https://github.com/tonioloewald/haltija/issues/8),
6
+ [#11](https://github.com/tonioloewald/haltija/issues/11)).
7
+
8
+ haltija already *detects* the situations that wreck a test lane — the wrong project's browser, a
9
+ hidden tab returning stale results, a server with nothing to drive. It only ever **warned on
10
+ stderr**, so scripts consumed plausible-but-wrong results and failed much later, pointing at the
11
+ caller's own code.
12
+
13
+ ### New: `ready` — "server is up" is not "server is drivable"
14
+
15
+ `/status` and `/windows` now return **`ready`**: true when at least one top-level tab is connected.
16
+ A server running with *zero* windows answers `/status` 200, so an adopter's reuse probe skipped
17
+ starting its own browser and then had nothing to navigate. Gate a lane on `ready`, not on the 200.
18
+
19
+ ### New: `hj doctor`
20
+
21
+ One-command preflight that **exits non-zero**: server reachable → a tab is connected → the target
22
+ isn't ambiguous → tabs aren't all hidden → versions aligned. `--json` for machine-readable output.
23
+ Use it as the wait-loop condition in CI.
24
+
25
+ ### New: `hj --strict` / `HALTIJA_STRICT=1`
26
+
27
+ Turns the advisory warnings into **non-zero exits**, and refuses to print a suspect result to
28
+ stdout at all — a script must not consume a value that may be wrong. A warning is the right default
29
+ for a human at a prompt and the wrong one for a lane.
30
+
31
+ ### Fixed
32
+
33
+ - **Warning de-duplication silently defeated strict mode.** The server withheld a repeated warning
34
+ entirely, so the first command in a lane failed and every later one within the cooldown passed.
35
+ The server now always reports the condition and marks repeats (`warningRepeated`); de-dup is a
36
+ presentation concern, so `hj` stays quiet on a repeat while `--strict` fails on any warning.
37
+
38
+ ## 1.6.0
39
+
40
+ Consolidating release: rolls up everything from 1.5.2–1.5.7 (the last npm-published version was
41
+ 1.5.4) and adds the fixes from a full nine-lens pre-release review of the whole span.
42
+
43
+ **Highlights since 1.5.4** (see the per-version entries below for detail):
44
+
45
+ - **`--private --app` teardown** (1.5.5, #7) — private Electron no longer orphans: skips the
46
+ single-instance lock, self-terminates when its spawner dies, `hj shutdown` tears it all down.
47
+ - **Console capture** (1.5.6) — uncaught exceptions, unhandled rejections, and `console.error(Error)`
48
+ messages/stacks are now captured (all were previously dropped).
49
+ - **Server picker** (1.5.7) — `hj servers` lists every live server; the desktop app is reachable as
50
+ `hj --name desktop`.
51
+
52
+ **Review-driven fixes in this release:**
53
+
54
+ - **`hj shutdown` no longer orphans a running desktop app.** It refuses (with a clear message —
55
+ quit the app from its window) instead of killing just the embedded server and leaving the window
56
+ on screen with dead tabs. `--private` instances still tear down fully.
57
+ - **`--name desktop` is reserved** — a normal server can no longer claim it and clobber the desktop
58
+ app's registry entry.
59
+ - The uncaught-error listeners are removed on widget disconnect (no leak across re-injection).
60
+ - `hj ls` is now an alias for `hj servers` (was `hj tree`); the old alias is gone.
61
+ - Added tabs-focus regression tests and `hj servers` / `hj --name desktop` / `hj shutdown` to the
62
+ generated docs and the agent skill.
63
+
64
+ ## 1.5.7
65
+
66
+ Pick between coexisting servers.
67
+
68
+ When a normal haltija server and the Electron desktop app both ran, there was no way to see them or
69
+ target the app — it was unregistered, invisible to `hj where` and the registry.
70
+
71
+ - **`hj servers`** (alias `hj ls`) lists every live server: registry entries, the probed defaults
72
+ 8700/8701, and this shell's target — with port, name, version, tab count, whether it's the
73
+ desktop app, and a `▸` on the one `hj` would drive.
74
+ - **The desktop app is now reachable as `hj --name desktop`.** Its public server registers under
75
+ the reserved name `desktop`, but *cwd-less* — so it's nameable without ever hijacking cwd routing.
76
+ (The internal chrome server stays unregistered; a `--private` app registers nothing.)
77
+
78
+ ## 1.5.6
79
+
80
+ `hj console` now captures the errors that actually matter.
81
+
82
+ It intercepted `console.*` calls, but the most important errors slipped through: an **uncaught
83
+ exception** (`throw`) and an **unhandled promise rejection** were never captured (no `window`
84
+ error / `unhandledrejection` listener), and `console.error(new Error(...))` recorded `{}` because
85
+ `JSON.stringify` drops an Error's message and stack. A page could be throwing on every action while
86
+ `hj console` showed it clean.
87
+
88
+ - Uncaught exceptions and unhandled rejections are now captured as `error` entries, with the real
89
+ stack. The `error` listener uses the capture phase, so failed resource loads (img/script/…) are
90
+ seen too.
91
+ - Error objects serialize to `{name, message, stack}` (at any depth), so the message survives.
92
+ - Note: capture begins when the widget is injected, so errors thrown *before* injection are only
93
+ caught by the desktop app (which injects at document-start).
94
+
95
+ ## 1.5.5
96
+
97
+ `--private` now really is "torn down with the run" ([#7](https://github.com/tonioloewald/haltija/issues/7)).
98
+
99
+ The private Electron instance used to survive teardown — holding Electron's single-instance lock (so
100
+ the **next** `--private` run failed with "Another instance is already running") and leaking a process
101
+ per run. Fixed three ways, all verified with real Electron:
102
+
103
+ - **A private run never takes the single-instance lock.** Private instances are isolated on ephemeral
104
+ ports and meant to run concurrently / back-to-back, so an orphan can no longer block the next run,
105
+ and two private runs coexist.
106
+ - **The private Electron self-terminates** when its spawner dies (even via SIGKILL) or on
107
+ SIGTERM/SIGINT. It reparents to launchd, so it watches the launcher's pid (`HALTIJA_SPAWNER_PID`)
108
+ and calls `app.quit()` — which reaps its own helper processes, unlike an external tree-kill.
109
+ - **`hj shutdown` / `hj quit`** (and `POST /shutdown`) on a private-desktop instance tears down the
110
+ whole thing — Electron and its servers — for a deterministic end-of-run teardown.
111
+
3
112
  ## 1.5.4
4
113
 
5
114
  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.1",
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.1";
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)) {
@@ -942,7 +942,19 @@ async function doRequest(url, method, body, context = {}) {
942
942
  // content never mounted, so an empty selector means "not mounted", not "broken" (issue #3).
943
943
  // Print it on stderr so it can't be mistaken for output, and so --json stdout stays clean.
944
944
  if (json && typeof json.warning === 'string' && json.warning) {
945
- console.error(`hj: warning ${json.warning}`)
945
+ if (process.env.HALTIJA_STRICT === '1') {
946
+ // Strict mode (issue #8): if the result may be wrong, a script must not consume it. Fail
947
+ // fast with the real reason instead of emitting a plausible-but-wrong value that makes the
948
+ // lane fail later, pointing at the caller's own code. stdout stays empty on purpose.
949
+ // NB: fails on `warningRepeated` too — the condition still holds, and suppressing repeats
950
+ // here would let every command after the first silently pass.
951
+ console.error(`hj: ERROR (strict) — ${json.warning}`)
952
+ console.error(`hj: refusing to return a result that may be wrong. Fix the condition above, or drop --strict/HALTIJA_STRICT to proceed anyway.`)
953
+ process.exit(1)
954
+ }
955
+ // Non-strict: stay quiet on a repeat within the cooldown, so a burst of commands doesn't
956
+ // re-print the same block and train the reader to ignore it.
957
+ if (!json.warningRepeated) console.error(`hj: warning — ${json.warning}`)
946
958
  }
947
959
 
948
960
  // Text format for supported subcommands (unless --json)
@@ -1073,7 +1085,7 @@ const COMMAND_ALIASES = {
1073
1085
  'execute': 'eval',
1074
1086
  'shot': 'screenshot',
1075
1087
  'capture': 'screenshot',
1076
- 'ls': 'tree',
1088
+ // NB: `ls` is NOT an alias for `tree` — it's intercepted in hj.mjs as `hj servers` (list servers).
1077
1089
  'list': 'tree',
1078
1090
  'show': 'tree',
1079
1091
  'help': '--help',
package/bin/hj.mjs CHANGED
@@ -126,6 +126,169 @@ 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
+
194
+ /**
195
+ * `hj doctor` — one-command preflight for a test lane: "is the thing I'm about to drive the thing
196
+ * I mean, and can it actually be driven?" (issues #8, #11). Exits NON-ZERO when it isn't, which is
197
+ * the whole point: "is a server up?" is a cheap probe that does NOT predict success, so adopters
198
+ * who used it skipped spawning their own browser and failed much later on a timeout.
199
+ *
200
+ * Checks, in the order they bite: server reachable → drivable (a tab is connected) → targeting is
201
+ * unambiguous (cwd matches, or the choice was explicit) → tabs visible → versions aligned.
202
+ */
203
+ async function runDoctor(port, portSource, jsonOutput) {
204
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
205
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
206
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
207
+ const red = (s) => `\x1b[31m${s}\x1b[0m`
208
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
209
+ const token = process.env.HALTIJA_TOKEN
210
+
211
+ const problems = [] // fatal → exit 1
212
+ const notes = [] // advisory
213
+ let status = null
214
+
215
+ try {
216
+ const resp = await fetch(`http://localhost:${port}/status`, {
217
+ headers: token ? { 'X-Haltija-Token': token } : {},
218
+ signal: AbortSignal.timeout(3000),
219
+ })
220
+ if (resp.ok) status = await resp.json()
221
+ else problems.push(`server on port ${port} returned HTTP ${resp.status}`)
222
+ } catch (err) {
223
+ const refused = err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED'
224
+ problems.push(
225
+ refused
226
+ ? `no haltija server is listening on port ${port} — start one (bunx haltija) or check the target`
227
+ : `could not reach the server on port ${port}: ${err.message}`,
228
+ )
229
+ }
230
+
231
+ if (status) {
232
+ // The signal that actually predicts success. Older servers (<1.6.1) don't send `ready`; fall
233
+ // back to counting tabs rather than inventing a pass.
234
+ const tabs = Array.isArray(status.windows) ? status.windows : []
235
+ const ready = typeof status.ready === 'boolean' ? status.ready : tabs.length > 0
236
+ if (!ready) {
237
+ problems.push(
238
+ `the server on port ${port} is up but has NO connected browser tab — nothing to drive. ` +
239
+ `Open a tab in the desktop app, or inject the widget into a page. ` +
240
+ `("server is up" is not "server is drivable" — that's what this check exists for.)`,
241
+ )
242
+ }
243
+ const hidden = tabs.filter((w) => w.hidden)
244
+ if (ready && hidden.length === tabs.length) {
245
+ problems.push(
246
+ `every connected tab reports HIDDEN — results from a backgrounded tab can be ` +
247
+ `plausible-but-wrong (rAF/timers throttled). Bring one to the front.`,
248
+ )
249
+ } else if (hidden.length) {
250
+ notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`)
251
+ }
252
+ if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
253
+ notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
254
+ }
255
+ }
256
+
257
+ // Ambiguous targeting: we fell back to the shared default while other projects' servers are live.
258
+ const live = listLiveInstances()
259
+ const ambiguous = portSource === '8700 (default)' && live.length > 0
260
+ if (ambiguous) {
261
+ problems.push(
262
+ `targeting the shared default port 8700, but ${live.length} other haltija server(s) are ` +
263
+ `running and none matches this directory (${process.cwd()}) — the target is ambiguous. ` +
264
+ `Pick one with --name/--port, or run from the project's directory.`,
265
+ )
266
+ }
267
+
268
+ const ok = problems.length === 0
269
+
270
+ if (jsonOutput) {
271
+ console.log(JSON.stringify({
272
+ ok, port, portSource,
273
+ serverVersion: status?.serverVersion ?? null,
274
+ ready: status ? (typeof status.ready === 'boolean' ? status.ready : (status.windows?.length ?? 0) > 0) : false,
275
+ tabs: status?.windows?.length ?? 0,
276
+ problems, notes,
277
+ }, null, 2))
278
+ return ok
279
+ }
280
+
281
+ console.log(`${bold('target:')} port ${port} ${dim(`(${portSource})`)}`)
282
+ if (status) {
283
+ const tabCount = status.windows?.length ?? 0
284
+ console.log(`${bold('server:')} haltija ${status.serverVersion || '?'}${status.desktopApp ? dim(' (desktop app)') : ''}, ${tabCount} tab${tabCount === 1 ? '' : 's'}`)
285
+ }
286
+ for (const n of notes) console.log(`${yellow('!')} ${n}`)
287
+ for (const p of problems) console.log(`${red('✗')} ${p}`)
288
+ if (ok) console.log(`${green('✓')} ready to drive`)
289
+ return ok
290
+ }
291
+
129
292
  /**
130
293
  * Resolve a named haltija instance to its port by reading
131
294
  * ~/.haltija/servers/<name>.json. Returns null if the file is missing,
@@ -211,6 +374,17 @@ ${dim('Overriding that (per-shell):')}
211
374
  ${dim('export HALTIJA_TOKEN=secret')} # required when server was started with HALTIJA_TOKEN
212
375
  ${dim('hj --token secret tree')} # one-off token override
213
376
  ${dim('hj --version')} # which hj is this?
377
+
378
+ ${dim('Lifecycle:')}
379
+ ${dim('hj where')} # which server this shell targets + what is alive there
380
+ ${dim('hj servers')} # list ALL live servers (pick one with --port/--name)
381
+ ${dim('hj doctor')} # preflight: drivable + unambiguous? EXITS 1 if not
382
+ ${dim('hj shutdown')} # stop the targeted server (a private --app: Electron + all)
383
+
384
+ ${dim('For scripts / CI:')}
385
+ ${dim('hj --strict <cmd>')} # turn advisory warnings (wrong project, hidden tab)
386
+ ${dim('HALTIJA_STRICT=1')} # into non-zero exits, so a lane fails fast on the
387
+ ${dim('# real cause instead of a later timeout')}
214
388
  ${listSubcommands()}
215
389
  Run ${dim('hj --help')} for this help.
216
390
  Run ${dim('haltija --help')} for server/app options.
@@ -218,6 +392,23 @@ Run ${dim('haltija --help')} for server/app options.
218
392
  process.exit(0)
219
393
  }
220
394
 
395
+ // Parse --strict FIRST — before port resolution, which is itself one of the things strict mode
396
+ // turns from a warning into an error. (A check placed before the input it depends on is a
397
+ // recurring bug shape: parse the flag, then run the code that reads it.) Sets HALTIJA_STRICT so
398
+ // cli-subcommand.mjs sees it too.
399
+ //
400
+ // In strict mode the advisory warnings — cross-project targeting, hidden tab, focus ambiguity —
401
+ // become non-zero exits (issue #8). haltija already DETECTS these precisely; the gap was that
402
+ // detection never reached the exit code, so a lane consumed a plausible-but-wrong result and failed
403
+ // much later pointing at the caller's own code. A warning is right for a human at a prompt and
404
+ // wrong for a script.
405
+ const strictIdx = args.indexOf('--strict')
406
+ if (strictIdx !== -1) {
407
+ process.env.HALTIJA_STRICT = '1'
408
+ args.splice(strictIdx, 1)
409
+ }
410
+ const STRICT = process.env.HALTIJA_STRICT === '1'
411
+
221
412
  // Parse --name option (or HALTIJA_NAME env): resolve to a port via
222
413
  // ~/.haltija/servers/<name>.json, written by `haltija --name <foo>`.
223
414
  let resolvedName = process.env.HALTIJA_NAME || ''
@@ -288,6 +479,13 @@ if (portFlag) {
288
479
  // explicit choice.
289
480
  if (live.length) {
290
481
  const names = live.map((e) => `${e.name} (${e.cwd})`).join(', ')
482
+ if (STRICT) {
483
+ // A lane must not silently drive another project's browser (issue #8, case 1).
484
+ console.error(`hj: ERROR (strict) — refusing to fall back to the default port 8700 while other haltija servers are running: ${names}`)
485
+ console.error(`hj: this shell's cwd (${process.cwd()}) matches none of them, so the target is ambiguous.`)
486
+ console.error(`hj: pick one explicitly with --name/--port (or cd into its directory), or drop --strict to proceed anyway.`)
487
+ process.exit(1)
488
+ }
291
489
  console.error(`hj: warning — targeting the default port 8700, but these haltija servers are running: ${names}`)
292
490
  console.error(`hj: if you meant one of them, cd into its directory, or use --name/--port. See \`hj where\`.`)
293
491
  }
@@ -367,6 +565,50 @@ if (subcommand === 'where') {
367
565
  process.exit(0)
368
566
  }
369
567
 
568
+ // `hj servers` / `hj ls` — list every live haltija server (registry + defaults + this shell's
569
+ // target), so you can pick one when several coexist. Diagnostic; never auto-launches.
570
+ if (subcommand === 'servers' || subcommand === 'ls') {
571
+ await runServers(port)
572
+ process.exit(0)
573
+ }
574
+
575
+ // `hj doctor` — preflight for a test lane. EXITS NON-ZERO when the target isn't drivable or is
576
+ // ambiguous, so a lane can fail fast with the real reason (issues #8, #11). Never auto-launches.
577
+ if (subcommand === 'doctor') {
578
+ const ok = await runDoctor(port, portSource, subArgs.includes('--json'))
579
+ process.exit(ok ? 0 : 1)
580
+ }
581
+
582
+ // `hj shutdown` / `hj quit` — cleanly stop the targeted server. For a private `--app` instance this
583
+ // tears down the WHOLE thing (Electron + its child servers); for a plain server it stops that
584
+ // server. Never auto-launches (it's a stop command), so it's handled here before the routing table.
585
+ if (subcommand === 'shutdown' || subcommand === 'quit') {
586
+ const token = process.env.HALTIJA_TOKEN
587
+ try {
588
+ const resp = await fetch(`http://localhost:${port}/shutdown`, {
589
+ method: 'POST',
590
+ headers: token ? { 'X-Haltija-Token': token } : {},
591
+ signal: AbortSignal.timeout(3000),
592
+ })
593
+ const j = await resp.json().catch(() => ({}))
594
+ if (resp.ok) {
595
+ console.log(j.message || `Shutdown requested on port ${port}.`)
596
+ process.exit(0)
597
+ }
598
+ // Surface the server's explanation (e.g. the desktop-app refusal), not a bare status code.
599
+ console.error(`hj ${subcommand}: ${j.error || `server on port ${port} returned HTTP ${resp.status}`}`)
600
+ process.exit(1)
601
+ } catch (err) {
602
+ // Nothing listening = already stopped; that's success for a stop command.
603
+ if (err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED') {
604
+ console.log(`No server listening on port ${port} (already stopped).`)
605
+ process.exit(0)
606
+ }
607
+ console.error(`hj ${subcommand}: ${err.message}`)
608
+ process.exit(1)
609
+ }
610
+ }
611
+
370
612
  if (!isSubcommand(subcommand)) {
371
613
  const suggestion = getSuggestion(subcommand)
372
614
  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.1'
@@ -18,6 +18,8 @@ export interface DevResponse {
18
18
  timestamp: number;
19
19
  /** Hidden-tab / focus-ambiguity caveat attached by the server (see requestFromBrowser). */
20
20
  warning?: string;
21
+ /** True when this exact warning was already reported within the cooldown (see types.ts). */
22
+ warningRepeated?: boolean;
21
23
  }
22
24
  /** Function to send request to browser widget */
23
25
  export type RequestFromBrowserFn = (channel: string, action: string, payload: any, timeoutMs?: number, windowId?: string) => Promise<DevResponse>;