haltija 1.6.0 → 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,40 @@
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
+
3
38
  ## 1.6.0
4
39
 
5
40
  Consolidating release: rolls up everything from 1.5.2–1.5.7 (the last npm-published version was
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.6.0",
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.6.0";
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)\(/;
@@ -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)
package/bin/hj.mjs CHANGED
@@ -191,6 +191,104 @@ async function runServers(resolvedPort) {
191
191
  console.log(dim('\nPick one: ') + `hj --port <n> <cmd>` + dim(' or ') + `hj --name <name> <cmd>`)
192
192
  }
193
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
+
194
292
  /**
195
293
  * Resolve a named haltija instance to its port by reading
196
294
  * ~/.haltija/servers/<name>.json. Returns null if the file is missing,
@@ -280,7 +378,13 @@ ${dim('Overriding that (per-shell):')}
280
378
  ${dim('Lifecycle:')}
281
379
  ${dim('hj where')} # which server this shell targets + what is alive there
282
380
  ${dim('hj servers')} # list ALL live servers (pick one with --port/--name)
381
+ ${dim('hj doctor')} # preflight: drivable + unambiguous? EXITS 1 if not
283
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')}
284
388
  ${listSubcommands()}
285
389
  Run ${dim('hj --help')} for this help.
286
390
  Run ${dim('haltija --help')} for server/app options.
@@ -288,6 +392,23 @@ Run ${dim('haltija --help')} for server/app options.
288
392
  process.exit(0)
289
393
  }
290
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
+
291
412
  // Parse --name option (or HALTIJA_NAME env): resolve to a port via
292
413
  // ~/.haltija/servers/<name>.json, written by `haltija --name <foo>`.
293
414
  let resolvedName = process.env.HALTIJA_NAME || ''
@@ -358,6 +479,13 @@ if (portFlag) {
358
479
  // explicit choice.
359
480
  if (live.length) {
360
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
+ }
361
489
  console.error(`hj: warning — targeting the default port 8700, but these haltija servers are running: ${names}`)
362
490
  console.error(`hj: if you meant one of them, cd into its directory, or use --name/--port. See \`hj where\`.`)
363
491
  }
@@ -444,6 +572,13 @@ if (subcommand === 'servers' || subcommand === 'ls') {
444
572
  process.exit(0)
445
573
  }
446
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
+
447
582
  // `hj shutdown` / `hj quit` — cleanly stop the targeted server. For a private `--app` instance this
448
583
  // tears down the WHOLE thing (Electron + its child servers); for a plain server it stops that
449
584
  // server. Never auto-launches (it's a stop command), so it's handled here before the routing table.
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.6.0'
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>;
@@ -20,7 +20,7 @@
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.6.0";
23
+ export declare const VERSION = "1.6.1";
24
24
  export declare class DevChannel extends HTMLElement {
25
25
  static get tagName(): string;
26
26
  static elementCreator(): () => DevChannel;
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "1.6.0";
2
+ var VERSION = "1.6.1";
3
3
 
4
4
  // src/text-selector.ts
5
5
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
package/dist/component.js CHANGED
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.6.0";
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)\(/;
package/dist/hj.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // haltija-cli:do-not-edit v1.6.0
2
+ // haltija-cli:do-not-edit v1.6.1
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
@@ -756,7 +756,7 @@ function substituteGeneratedVars(text, seed) {
756
756
  }
757
757
 
758
758
  // bin/version.mjs
759
- var HJ_VERSION = "1.6.0";
759
+ var HJ_VERSION = "1.6.1";
760
760
 
761
761
  // bin/semver.mjs
762
762
  function parseVersion(v) {
@@ -1629,7 +1629,13 @@ async function doRequest(url, method, body, context = {}) {
1629
1629
  if (contentType.includes("application/json")) {
1630
1630
  const json = await resp.json();
1631
1631
  if (json && typeof json.warning === "string" && json.warning) {
1632
- console.error(`hj: warning ${json.warning}`);
1632
+ if (process.env.HALTIJA_STRICT === "1") {
1633
+ console.error(`hj: ERROR (strict) — ${json.warning}`);
1634
+ console.error(`hj: refusing to return a result that may be wrong. Fix the condition above, or drop --strict/HALTIJA_STRICT to proceed anyway.`);
1635
+ process.exit(1);
1636
+ }
1637
+ if (!json.warningRepeated)
1638
+ console.error(`hj: warning — ${json.warning}`);
1633
1639
  }
1634
1640
  if (!jsonOutput && subcommand === "tree" && json.success && json.data) {
1635
1641
  console.log(formatTree(json.data, 0, { depth: body?.depth }));
@@ -2063,6 +2069,77 @@ This shell targets :${resolvedPort}, but nothing is listening there.`));
2063
2069
  console.log(dim3(`
2064
2070
  Pick one: `) + `hj --port <n> <cmd>` + dim3(" or ") + `hj --name <name> <cmd>`);
2065
2071
  }
2072
+ async function runDoctor(port, portSource, jsonOutput) {
2073
+ const bold2 = (s) => `\x1B[1m${s}\x1B[0m`;
2074
+ const dim3 = (s) => `\x1B[2m${s}\x1B[0m`;
2075
+ const green2 = (s) => `\x1B[32m${s}\x1B[0m`;
2076
+ const red2 = (s) => `\x1B[31m${s}\x1B[0m`;
2077
+ const yellow2 = (s) => `\x1B[33m${s}\x1B[0m`;
2078
+ const token = process.env.HALTIJA_TOKEN;
2079
+ const problems = [];
2080
+ const notes = [];
2081
+ let status = null;
2082
+ try {
2083
+ const resp = await fetch(`http://localhost:${port}/status`, {
2084
+ headers: token ? { "X-Haltija-Token": token } : {},
2085
+ signal: AbortSignal.timeout(3000)
2086
+ });
2087
+ if (resp.ok)
2088
+ status = await resp.json();
2089
+ else
2090
+ problems.push(`server on port ${port} returned HTTP ${resp.status}`);
2091
+ } catch (err) {
2092
+ const refused = err.code === "ConnectionRefused" || err.cause?.code === "ECONNREFUSED";
2093
+ problems.push(refused ? `no haltija server is listening on port ${port} — start one (bunx haltija) or check the target` : `could not reach the server on port ${port}: ${err.message}`);
2094
+ }
2095
+ if (status) {
2096
+ const tabs = Array.isArray(status.windows) ? status.windows : [];
2097
+ const ready = typeof status.ready === "boolean" ? status.ready : tabs.length > 0;
2098
+ if (!ready) {
2099
+ problems.push(`the server on port ${port} is up but has NO connected browser tab — nothing to drive. ` + `Open a tab in the desktop app, or inject the widget into a page. ` + `("server is up" is not "server is drivable" — that's what this check exists for.)`);
2100
+ }
2101
+ const hidden = tabs.filter((w) => w.hidden);
2102
+ if (ready && hidden.length === tabs.length) {
2103
+ problems.push(`every connected tab reports HIDDEN — results from a backgrounded tab can be ` + `plausible-but-wrong (rAF/timers throttled). Bring one to the front.`);
2104
+ } else if (hidden.length) {
2105
+ notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`);
2106
+ }
2107
+ if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
2108
+ notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`);
2109
+ }
2110
+ }
2111
+ const live = listLiveInstances();
2112
+ const ambiguous = portSource === "8700 (default)" && live.length > 0;
2113
+ if (ambiguous) {
2114
+ problems.push(`targeting the shared default port 8700, but ${live.length} other haltija server(s) are ` + `running and none matches this directory (${process.cwd()}) — the target is ambiguous. ` + `Pick one with --name/--port, or run from the project's directory.`);
2115
+ }
2116
+ const ok = problems.length === 0;
2117
+ if (jsonOutput) {
2118
+ console.log(JSON.stringify({
2119
+ ok,
2120
+ port,
2121
+ portSource,
2122
+ serverVersion: status?.serverVersion ?? null,
2123
+ ready: status ? typeof status.ready === "boolean" ? status.ready : (status.windows?.length ?? 0) > 0 : false,
2124
+ tabs: status?.windows?.length ?? 0,
2125
+ problems,
2126
+ notes
2127
+ }, null, 2));
2128
+ return ok;
2129
+ }
2130
+ console.log(`${bold2("target:")} port ${port} ${dim3(`(${portSource})`)}`);
2131
+ if (status) {
2132
+ const tabCount = status.windows?.length ?? 0;
2133
+ console.log(`${bold2("server:")} haltija ${status.serverVersion || "?"}${status.desktopApp ? dim3(" (desktop app)") : ""}, ${tabCount} tab${tabCount === 1 ? "" : "s"}`);
2134
+ }
2135
+ for (const n of notes)
2136
+ console.log(`${yellow2("!")} ${n}`);
2137
+ for (const p of problems)
2138
+ console.log(`${red2("✗")} ${p}`);
2139
+ if (ok)
2140
+ console.log(`${green2("✓")} ready to drive`);
2141
+ return ok;
2142
+ }
2066
2143
  function lookupNamedInstance(name) {
2067
2144
  const path = join2(REGISTRY_DIR, `${name}.json`);
2068
2145
  if (!existsSync2(path))
@@ -2138,13 +2215,25 @@ ${dim3("Overriding that (per-shell):")}
2138
2215
  ${dim3("Lifecycle:")}
2139
2216
  ${dim3("hj where")} # which server this shell targets + what is alive there
2140
2217
  ${dim3("hj servers")} # list ALL live servers (pick one with --port/--name)
2218
+ ${dim3("hj doctor")} # preflight: drivable + unambiguous? EXITS 1 if not
2141
2219
  ${dim3("hj shutdown")} # stop the targeted server (a private --app: Electron + all)
2220
+
2221
+ ${dim3("For scripts / CI:")}
2222
+ ${dim3("hj --strict <cmd>")} # turn advisory warnings (wrong project, hidden tab)
2223
+ ${dim3("HALTIJA_STRICT=1")} # into non-zero exits, so a lane fails fast on the
2224
+ ${dim3("# real cause instead of a later timeout")}
2142
2225
  ${listSubcommands()}
2143
2226
  Run ${dim3("hj --help")} for this help.
2144
2227
  Run ${dim3("haltija --help")} for server/app options.
2145
2228
  `);
2146
2229
  process.exit(0);
2147
2230
  }
2231
+ var strictIdx = args.indexOf("--strict");
2232
+ if (strictIdx !== -1) {
2233
+ process.env.HALTIJA_STRICT = "1";
2234
+ args.splice(strictIdx, 1);
2235
+ }
2236
+ var STRICT = process.env.HALTIJA_STRICT === "1";
2148
2237
  var resolvedName = process.env.HALTIJA_NAME || "";
2149
2238
  var nameSource = resolvedName ? "HALTIJA_NAME env" : "";
2150
2239
  var nameIdx = args.indexOf("--name");
@@ -2190,6 +2279,12 @@ if (portFlag) {
2190
2279
  portSource = "8700 (default)";
2191
2280
  if (live.length) {
2192
2281
  const names = live.map((e) => `${e.name} (${e.cwd})`).join(", ");
2282
+ if (STRICT) {
2283
+ console.error(`hj: ERROR (strict) — refusing to fall back to the default port 8700 while other haltija servers are running: ${names}`);
2284
+ console.error(`hj: this shell's cwd (${process.cwd()}) matches none of them, so the target is ambiguous.`);
2285
+ console.error(`hj: pick one explicitly with --name/--port (or cd into its directory), or drop --strict to proceed anyway.`);
2286
+ process.exit(1);
2287
+ }
2193
2288
  console.error(`hj: warning — targeting the default port 8700, but these haltija servers are running: ${names}`);
2194
2289
  console.error(`hj: if you meant one of them, cd into its directory, or use --name/--port. See \`hj where\`.`);
2195
2290
  }
@@ -2238,6 +2333,10 @@ if (subcommand === "servers" || subcommand === "ls") {
2238
2333
  await runServers(port);
2239
2334
  process.exit(0);
2240
2335
  }
2336
+ if (subcommand === "doctor") {
2337
+ const ok = await runDoctor(port, portSource, subArgs.includes("--json"));
2338
+ process.exit(ok ? 0 : 1);
2339
+ }
2241
2340
  if (subcommand === "shutdown" || subcommand === "quit") {
2242
2341
  const token = process.env.HALTIJA_TOKEN;
2243
2342
  try {
package/dist/index.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.6.0";
677
+ var VERSION = "1.6.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -1008,9 +1008,13 @@ curl -X POST localhost:8700/click -d '{"selector":"#submit"}'
1008
1008
 
1009
1009
  Returns server info and connected browser count.
1010
1010
 
1011
- Response: { version, uptime, browsers: n, focused?: windowId }
1011
+ Response: { serverVersion, ready, windows: [...], browsers: n, desktopApp, pid, ... }
1012
1012
 
1013
- Use to verify server is running and browsers are connected before testing.
1013
+ Use to verify the server is running \u2014 but gate a test lane on **\`ready\`**, not on the 200. A
1014
+ server can be up with zero connected tabs: /status answers fine and there is still nothing to
1015
+ drive, so a lane that adopts it fails later on a timeout that points at the caller's own code.
1016
+ \`ready\` is true when at least one top-level tab is connected. \`hj doctor\` checks this (plus
1017
+ ambiguous targeting) and exits non-zero, which is the one-command preflight for a lane.
1014
1018
 
1015
1019
  ---
1016
1020
 
@@ -2166,10 +2170,16 @@ Deprecated: Use POST /select {"action":"clear"} instead.
2166
2170
 
2167
2171
  Returns all connected browser windows/tabs with IDs, URLs, and titles.
2168
2172
 
2169
- Response: { windows: [{ id, url, title, focused }] }
2173
+ Response: { windows: [{ id, url, title, focused }], count, ready, hint }
2170
2174
 
2171
2175
  Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
2172
2176
 
2177
+ **\`ready\` is the signal to gate a test lane on, not "is the server up".** A server can be running
2178
+ with zero connected tabs \u2014 it answers /status 200 but there is nothing to drive, and a lane that
2179
+ adopts it fails later on a confusing timeout. \`ready\` is true when at least one top-level tab is
2180
+ connected. (Hidden tabs count as ready \u2014 they're reachable, just possibly stale; see the hidden-tab
2181
+ warning.) \`hj doctor\` checks this and exits non-zero, so a lane can fail fast on the real cause.
2182
+
2173
2183
  ---
2174
2184
 
2175
2185
  ### \`POST /tabs/open\`
@@ -2867,8 +2877,16 @@ hj tree # ...and plain hj reaches it
2867
2877
 
2868
2878
  hj where # which port, WHY, and what is alive there
2869
2879
  hj servers # list ALL live servers; pick with --port/--name
2880
+ hj doctor # preflight for a script: EXITS 1 if not drivable
2881
+ hj --strict <cmd> # warnings (wrong project / hidden tab) become errors
2870
2882
  \`\`\`
2871
2883
 
2884
+ **In a script or CI lane, gate on \`hj doctor\` (or the \`ready\` field), not on "the server
2885
+ answered".** A server can be up with zero connected tabs \u2014 \`/status\` returns 200 and there
2886
+ is still nothing to drive, so a lane that adopts it fails later on a timeout that points at
2887
+ your own code. \`hj doctor\` exits non-zero on exactly that, and \`--strict\` turns the advisory
2888
+ warnings into failures so a suspect result is never consumed.
2889
+
2872
2890
  When several haltijas run at once (e.g. a project server AND the desktop app),
2873
2891
  \`hj servers\` (alias \`hj ls\`) lists them all \u2014 the desktop app is reachable as
2874
2892
  \`hj --name desktop\`. If no server owns your directory, \`hj\` falls back to the shared default port
@@ -3040,6 +3058,11 @@ Two first-party ways to run (plus embedding, below):
3040
3058
  port a shell targets and WHY; override with \`--port\` or \`--name\`. When several servers
3041
3059
  run at once (e.g. a project server AND the desktop app), \`hj servers\` lists them all \u2014
3042
3060
  the desktop app is reachable as \`hj --name desktop\`.
3061
+ - **Scripts/CI:** gate on \`hj doctor\` (exits non-zero when the target is not drivable or is
3062
+ ambiguous) or on the \`ready\` field of \`/status\`/\`/windows\` \u2014 NOT on "the server answered".
3063
+ A server can be up with zero connected tabs, so a lane that adopts it fails later on a
3064
+ confusing timeout. \`hj --strict\` (or HALTIJA_STRICT=1) turns the hidden-tab and
3065
+ cross-project warnings into non-zero exits so a suspect result is never consumed.
3043
3066
  - **CI:** two engines, and the choice matters \u2014 both need one external browser, but
3044
3067
  a *different* one:
3045
3068
  - \`haltija --ci\` (or \`--private --app\` for an isolated instance) drives **Electron**
@@ -3126,7 +3149,7 @@ var COMPONENT_JS = `(() => {
3126
3149
  });
3127
3150
 
3128
3151
  // src/version.ts
3129
- var VERSION = "1.6.0";
3152
+ var VERSION = "1.6.1";
3130
3153
 
3131
3154
  // src/text-selector.ts
3132
3155
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -12906,9 +12929,15 @@ var windows = endpoint({
12906
12929
  summary: "List connected windows",
12907
12930
  description: `Returns all connected browser windows/tabs with IDs, URLs, and titles.
12908
12931
 
12909
- Response: { windows: [{ id, url, title, focused }] }
12932
+ Response: { windows: [{ id, url, title, focused }], count, ready, hint }
12933
+
12934
+ Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
12910
12935
 
12911
- Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.`,
12936
+ **\`ready\` is the signal to gate a test lane on, not "is the server up".** A server can be running
12937
+ with zero connected tabs \u2014 it answers /status 200 but there is nothing to drive, and a lane that
12938
+ adopts it fails later on a confusing timeout. \`ready\` is true when at least one top-level tab is
12939
+ connected. (Hidden tabs count as ready \u2014 they're reachable, just possibly stale; see the hidden-tab
12940
+ warning.) \`hj doctor\` checks this and exits non-zero, so a lane can fail fast on the real cause.`,
12912
12941
  category: "windows",
12913
12942
  hints: "--json | see: tabs-open, tabs-close, tabs-focus, status"
12914
12943
  });
@@ -13379,9 +13408,13 @@ var status = endpoint({
13379
13408
  summary: "Server status",
13380
13409
  description: `Returns server info and connected browser count.
13381
13410
 
13382
- Response: { version, uptime, browsers: n, focused?: windowId }
13411
+ Response: { serverVersion, ready, windows: [...], browsers: n, desktopApp, pid, ... }
13383
13412
 
13384
- Use to verify server is running and browsers are connected before testing.`,
13413
+ Use to verify the server is running \u2014 but gate a test lane on **\`ready\`**, not on the 200. A
13414
+ server can be up with zero connected tabs: /status answers fine and there is still nothing to
13415
+ drive, so a lane that adopts it fails later on a timeout that points at the caller's own code.
13416
+ \`ready\` is true when at least one top-level tab is connected. \`hj doctor\` checks this (plus
13417
+ ambiguous targeting) and exits non-zero, which is the one-command preflight for a lane.`,
13385
13418
  category: "meta",
13386
13419
  hints: "--json | see: windows, stats, console"
13387
13420
  });
@@ -13840,7 +13873,9 @@ function inferSuggestion(step, pageContext) {
13840
13873
 
13841
13874
  // src/api-handlers.ts
13842
13875
  function withWarning(body, response) {
13843
- return response.warning ? { ...body, warning: response.warning } : body;
13876
+ if (!response.warning)
13877
+ return body;
13878
+ return response.warningRepeated ? { ...body, warning: response.warning, warningRepeated: true } : { ...body, warning: response.warning };
13844
13879
  }
13845
13880
  var handlers = new Map;
13846
13881
  function registerHandler(endpoint2, handler) {
@@ -16611,10 +16646,8 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16611
16646
  `);
16612
16647
  if (!warning)
16613
16648
  return res;
16614
- if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16615
- return res;
16616
- }
16617
- return { ...res, warning };
16649
+ const repeated = !shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS);
16650
+ return repeated ? { ...res, warning, warningRepeated: true } : { ...res, warning };
16618
16651
  };
16619
16652
  const timeout = setTimeout(() => {
16620
16653
  pendingResponses.delete(id);
@@ -17041,6 +17074,7 @@ async function handleRest(req) {
17041
17074
  return Response.json({
17042
17075
  ok: allWindows.length > 0,
17043
17076
  windows: windowList,
17077
+ ready: allWindows.filter((w) => (w.windowType || "tab") === "tab").length > 0,
17044
17078
  serverVersion: SERVER_VERSION,
17045
17079
  pid: process.pid,
17046
17080
  recording: activeRecordings > 0,
@@ -18988,11 +19022,14 @@ ${messageText}`;
18988
19022
  label: w.label,
18989
19023
  windowType: w.windowType || "tab"
18990
19024
  }));
19025
+ const drivableTabs = windowList.filter((w) => (w.windowType || "tab") === "tab");
19026
+ const ready = drivableTabs.length > 0;
18991
19027
  const hint = windowList.length > 1 ? "Multiple tabs connected. Use ?window=<id> to target specific tab (e.g., /tree?window=abc123)" : windowList.length === 1 ? "One tab connected. Commands automatically target it." : "No tabs connected. Inject the widget into a browser tab.";
18992
19028
  return Response.json({
18993
19029
  windows: windowList,
18994
19030
  focused: focusedWindowId,
18995
19031
  count: windowList.length,
19032
+ ready,
18996
19033
  hint
18997
19034
  }, { headers });
18998
19035
  }
package/dist/server.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.6.0";
677
+ var VERSION = "1.6.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -1008,9 +1008,13 @@ curl -X POST localhost:8700/click -d '{"selector":"#submit"}'
1008
1008
 
1009
1009
  Returns server info and connected browser count.
1010
1010
 
1011
- Response: { version, uptime, browsers: n, focused?: windowId }
1011
+ Response: { serverVersion, ready, windows: [...], browsers: n, desktopApp, pid, ... }
1012
1012
 
1013
- Use to verify server is running and browsers are connected before testing.
1013
+ Use to verify the server is running \u2014 but gate a test lane on **\`ready\`**, not on the 200. A
1014
+ server can be up with zero connected tabs: /status answers fine and there is still nothing to
1015
+ drive, so a lane that adopts it fails later on a timeout that points at the caller's own code.
1016
+ \`ready\` is true when at least one top-level tab is connected. \`hj doctor\` checks this (plus
1017
+ ambiguous targeting) and exits non-zero, which is the one-command preflight for a lane.
1014
1018
 
1015
1019
  ---
1016
1020
 
@@ -2166,10 +2170,16 @@ Deprecated: Use POST /select {"action":"clear"} instead.
2166
2170
 
2167
2171
  Returns all connected browser windows/tabs with IDs, URLs, and titles.
2168
2172
 
2169
- Response: { windows: [{ id, url, title, focused }] }
2173
+ Response: { windows: [{ id, url, title, focused }], count, ready, hint }
2170
2174
 
2171
2175
  Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
2172
2176
 
2177
+ **\`ready\` is the signal to gate a test lane on, not "is the server up".** A server can be running
2178
+ with zero connected tabs \u2014 it answers /status 200 but there is nothing to drive, and a lane that
2179
+ adopts it fails later on a confusing timeout. \`ready\` is true when at least one top-level tab is
2180
+ connected. (Hidden tabs count as ready \u2014 they're reachable, just possibly stale; see the hidden-tab
2181
+ warning.) \`hj doctor\` checks this and exits non-zero, so a lane can fail fast on the real cause.
2182
+
2173
2183
  ---
2174
2184
 
2175
2185
  ### \`POST /tabs/open\`
@@ -2867,8 +2877,16 @@ hj tree # ...and plain hj reaches it
2867
2877
 
2868
2878
  hj where # which port, WHY, and what is alive there
2869
2879
  hj servers # list ALL live servers; pick with --port/--name
2880
+ hj doctor # preflight for a script: EXITS 1 if not drivable
2881
+ hj --strict <cmd> # warnings (wrong project / hidden tab) become errors
2870
2882
  \`\`\`
2871
2883
 
2884
+ **In a script or CI lane, gate on \`hj doctor\` (or the \`ready\` field), not on "the server
2885
+ answered".** A server can be up with zero connected tabs \u2014 \`/status\` returns 200 and there
2886
+ is still nothing to drive, so a lane that adopts it fails later on a timeout that points at
2887
+ your own code. \`hj doctor\` exits non-zero on exactly that, and \`--strict\` turns the advisory
2888
+ warnings into failures so a suspect result is never consumed.
2889
+
2872
2890
  When several haltijas run at once (e.g. a project server AND the desktop app),
2873
2891
  \`hj servers\` (alias \`hj ls\`) lists them all \u2014 the desktop app is reachable as
2874
2892
  \`hj --name desktop\`. If no server owns your directory, \`hj\` falls back to the shared default port
@@ -3040,6 +3058,11 @@ Two first-party ways to run (plus embedding, below):
3040
3058
  port a shell targets and WHY; override with \`--port\` or \`--name\`. When several servers
3041
3059
  run at once (e.g. a project server AND the desktop app), \`hj servers\` lists them all \u2014
3042
3060
  the desktop app is reachable as \`hj --name desktop\`.
3061
+ - **Scripts/CI:** gate on \`hj doctor\` (exits non-zero when the target is not drivable or is
3062
+ ambiguous) or on the \`ready\` field of \`/status\`/\`/windows\` \u2014 NOT on "the server answered".
3063
+ A server can be up with zero connected tabs, so a lane that adopts it fails later on a
3064
+ confusing timeout. \`hj --strict\` (or HALTIJA_STRICT=1) turns the hidden-tab and
3065
+ cross-project warnings into non-zero exits so a suspect result is never consumed.
3043
3066
  - **CI:** two engines, and the choice matters \u2014 both need one external browser, but
3044
3067
  a *different* one:
3045
3068
  - \`haltija --ci\` (or \`--private --app\` for an isolated instance) drives **Electron**
@@ -3126,7 +3149,7 @@ var COMPONENT_JS = `(() => {
3126
3149
  });
3127
3150
 
3128
3151
  // src/version.ts
3129
- var VERSION = "1.6.0";
3152
+ var VERSION = "1.6.1";
3130
3153
 
3131
3154
  // src/text-selector.ts
3132
3155
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -12906,9 +12929,15 @@ var windows = endpoint({
12906
12929
  summary: "List connected windows",
12907
12930
  description: `Returns all connected browser windows/tabs with IDs, URLs, and titles.
12908
12931
 
12909
- Response: { windows: [{ id, url, title, focused }] }
12932
+ Response: { windows: [{ id, url, title, focused }], count, ready, hint }
12933
+
12934
+ Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
12910
12935
 
12911
- Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.`,
12936
+ **\`ready\` is the signal to gate a test lane on, not "is the server up".** A server can be running
12937
+ with zero connected tabs \u2014 it answers /status 200 but there is nothing to drive, and a lane that
12938
+ adopts it fails later on a confusing timeout. \`ready\` is true when at least one top-level tab is
12939
+ connected. (Hidden tabs count as ready \u2014 they're reachable, just possibly stale; see the hidden-tab
12940
+ warning.) \`hj doctor\` checks this and exits non-zero, so a lane can fail fast on the real cause.`,
12912
12941
  category: "windows",
12913
12942
  hints: "--json | see: tabs-open, tabs-close, tabs-focus, status"
12914
12943
  });
@@ -13379,9 +13408,13 @@ var status = endpoint({
13379
13408
  summary: "Server status",
13380
13409
  description: `Returns server info and connected browser count.
13381
13410
 
13382
- Response: { version, uptime, browsers: n, focused?: windowId }
13411
+ Response: { serverVersion, ready, windows: [...], browsers: n, desktopApp, pid, ... }
13383
13412
 
13384
- Use to verify server is running and browsers are connected before testing.`,
13413
+ Use to verify the server is running \u2014 but gate a test lane on **\`ready\`**, not on the 200. A
13414
+ server can be up with zero connected tabs: /status answers fine and there is still nothing to
13415
+ drive, so a lane that adopts it fails later on a timeout that points at the caller's own code.
13416
+ \`ready\` is true when at least one top-level tab is connected. \`hj doctor\` checks this (plus
13417
+ ambiguous targeting) and exits non-zero, which is the one-command preflight for a lane.`,
13385
13418
  category: "meta",
13386
13419
  hints: "--json | see: windows, stats, console"
13387
13420
  });
@@ -13840,7 +13873,9 @@ function inferSuggestion(step, pageContext) {
13840
13873
 
13841
13874
  // src/api-handlers.ts
13842
13875
  function withWarning(body, response) {
13843
- return response.warning ? { ...body, warning: response.warning } : body;
13876
+ if (!response.warning)
13877
+ return body;
13878
+ return response.warningRepeated ? { ...body, warning: response.warning, warningRepeated: true } : { ...body, warning: response.warning };
13844
13879
  }
13845
13880
  var handlers = new Map;
13846
13881
  function registerHandler(endpoint2, handler) {
@@ -16611,10 +16646,8 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16611
16646
  `);
16612
16647
  if (!warning)
16613
16648
  return res;
16614
- if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16615
- return res;
16616
- }
16617
- return { ...res, warning };
16649
+ const repeated = !shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS);
16650
+ return repeated ? { ...res, warning, warningRepeated: true } : { ...res, warning };
16618
16651
  };
16619
16652
  const timeout = setTimeout(() => {
16620
16653
  pendingResponses.delete(id);
@@ -17041,6 +17074,7 @@ async function handleRest(req) {
17041
17074
  return Response.json({
17042
17075
  ok: allWindows.length > 0,
17043
17076
  windows: windowList,
17077
+ ready: allWindows.filter((w) => (w.windowType || "tab") === "tab").length > 0,
17044
17078
  serverVersion: SERVER_VERSION,
17045
17079
  pid: process.pid,
17046
17080
  recording: activeRecordings > 0,
@@ -18988,11 +19022,14 @@ ${messageText}`;
18988
19022
  label: w.label,
18989
19023
  windowType: w.windowType || "tab"
18990
19024
  }));
19025
+ const drivableTabs = windowList.filter((w) => (w.windowType || "tab") === "tab");
19026
+ const ready = drivableTabs.length > 0;
18991
19027
  const hint = windowList.length > 1 ? "Multiple tabs connected. Use ?window=<id> to target specific tab (e.g., /tree?window=abc123)" : windowList.length === 1 ? "One tab connected. Commands automatically target it." : "No tabs connected. Inject the widget into a browser tab.";
18992
19028
  return Response.json({
18993
19029
  windows: windowList,
18994
19030
  focused: focusedWindowId,
18995
19031
  count: windowList.length,
19032
+ ready,
18996
19033
  hint
18997
19034
  }, { headers });
18998
19035
  }
package/dist/types.d.ts CHANGED
@@ -32,6 +32,12 @@ export interface DevResponse {
32
32
  * mounted. The command succeeded; the number may still be wrong. See src/tab-liveness.ts.
33
33
  */
34
34
  warning?: string;
35
+ /**
36
+ * True when `warning` describes a condition already reported within the recent cooldown. The
37
+ * condition still holds — this only says "you've been told". Clients suppress the repeat for
38
+ * humans; strict/CI consumers must fail on `warning` regardless of this flag.
39
+ */
40
+ warningRepeated?: boolean;
35
41
  }
36
42
  export interface DomQueryRequest {
37
43
  selector?: string;
package/dist/version.d.ts CHANGED
@@ -8,4 +8,4 @@
8
8
  * ⚠️ AUTO-GENERATED FROM package.json - DO NOT EDIT THIS FILE
9
9
  * ⚠️ To change the version, update package.json and run: bun run build
10
10
  */
11
- export declare const VERSION = "1.6.0";
11
+ export declare const VERSION = "1.6.1";
@@ -168,29 +168,46 @@ xvfb-run --auto-servernum npx electron . &
168
168
 
169
169
  ## Waiting for Ready State
170
170
 
171
- Don't use `sleep`use a proper wait loop:
171
+ Don't use `sleep`, and **don't gate on "the server answered"** that is the single most common
172
+ way a lane fails confusingly. A haltija server can be up with **zero connected tabs**: `/status`
173
+ returns 200, your lane decides haltija is available and skips starting its own browser, and then
174
+ `navigate` fails with "no browser reachable" — or worse, times out somewhere that looks like your
175
+ code's fault. *Server up ≠ drivable.*
176
+
177
+ Use the built-in preflight, which exits non-zero on exactly that case:
172
178
 
173
179
  ```bash
174
- # Wait for server
180
+ # Wait until the target is actually drivable (not merely alive)
175
181
  for i in $(seq 1 30); do
176
- if curl -sf http://localhost:8700/status | jq -e '.serverVersion' > /dev/null 2>&1; then
177
- echo "Haltija server ready after ${i}s"
178
- break
179
- fi
182
+ if hj doctor >/dev/null 2>&1; then break; fi
180
183
  sleep 1
181
184
  done
182
185
 
183
- # Wait for browser connection (if using Electron app)
184
- for i in $(seq 1 30); do
185
- WINDOWS=$(curl -sf http://localhost:8700/windows 2>/dev/null | jq '.windows | length' 2>/dev/null || echo 0)
186
- if [ "$WINDOWS" -gt 0 ]; then
187
- echo "Browser connected after ${i}s"
188
- break
189
- fi
190
- sleep 1
191
- done
186
+ hj doctor # print the verdict; exits 1 if it's not drivable or the target is ambiguous
187
+ ```
188
+
189
+ `hj doctor` checks, in the order they bite: server reachable → a tab is actually connected → the
190
+ target isn't ambiguous (your cwd matches, or you chose explicitly) → tabs aren't all hidden →
191
+ versions aligned. Add `--json` for machine-readable output.
192
+
193
+ Checking by hand instead? Gate on the **`ready`** field, not on the HTTP status:
194
+
195
+ ```bash
196
+ curl -sf http://localhost:8700/status | jq -e '.ready' # true only when a tab is connected
197
+ ```
198
+
199
+ And for a lane, add **`--strict`** (or `HALTIJA_STRICT=1`) to your `hj` calls so advisory warnings —
200
+ cross-project targeting, a hidden tab returning stale results — become non-zero exits instead of
201
+ stderr noise your script ignores:
202
+
203
+ ```bash
204
+ hj --strict navigate "$URL"
192
205
  ```
193
206
 
207
+ Best of all, don't share state at all: `haltija --private --app` (or `--private --headless`) gives
208
+ the lane its own isolated server and browser on an ephemeral port, which nothing else can adopt and
209
+ which tears down with the run.
210
+
194
211
  ## The hj CLI
195
212
 
196
213
  The `hj` command is installed to `~/.local/bin` when Haltija starts. Add it to PATH:
package/llms.txt CHANGED
@@ -42,6 +42,11 @@ Two first-party ways to run (plus embedding, below):
42
42
  port a shell targets and WHY; override with `--port` or `--name`. When several servers
43
43
  run at once (e.g. a project server AND the desktop app), `hj servers` lists them all —
44
44
  the desktop app is reachable as `hj --name desktop`.
45
+ - **Scripts/CI:** gate on `hj doctor` (exits non-zero when the target is not drivable or is
46
+ ambiguous) or on the `ready` field of `/status`/`/windows` — NOT on "the server answered".
47
+ A server can be up with zero connected tabs, so a lane that adopts it fails later on a
48
+ confusing timeout. `hj --strict` (or HALTIJA_STRICT=1) turns the hidden-tab and
49
+ cross-project warnings into non-zero exits so a suspect result is never consumed.
45
50
  - **CI:** two engines, and the choice matters — both need one external browser, but
46
51
  a *different* one:
47
52
  - `haltija --ci` (or `--private --app` for an isolated instance) drives **Electron**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Browser control for AI agents - query DOM, click, type, run JS, watch mutations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",