haltija 1.5.1 → 1.5.4

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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.4
4
+
5
+ The `hj tabs open` client-less-tab trap now explains itself ([#5](https://github.com/tonioloewald/haltija/issues/5)).
6
+
7
+ Outside the desktop app, `hj tabs open <url>` has no tab API, so it falls back to `window.open()` —
8
+ and that new tab has **no haltija widget** unless its page injects one. So the server never hears
9
+ from it, it doesn't appear in `hj tabs`, and commands can't reach it (they go to the focused widget
10
+ tab). It presents as "the tab opened fine but every command goes somewhere else" — indistinguishable
11
+ from a routing bug.
12
+
13
+ - The fallback response now carries a `reason`, promoted to a top-level `warning` that `hj` prints
14
+ on stderr — at the one moment the client-less tab is created.
15
+ - The `/tabs/open` schema and `SKILL.md` now state plainly that only widget-injected tabs are
16
+ controllable and appear in `hj tabs`, so a non-responding tab is the first thing to check.
17
+
18
+ ## 1.5.3
19
+
20
+ Discoverability fix for the two CI browser engines ([#6](https://github.com/tonioloewald/haltija/issues/6)).
21
+
22
+ Both `--headless` and `--ci` said "for CI" with no hint that they drive **different** engines:
23
+ `--headless` is Playwright Chromium (and needs the `playwright` package), while `--ci` / `--app` /
24
+ `--private --app` drive Electron and need no Playwright. An agent picked `--headless`, hit
25
+ "Playwright not installed", and wrongly concluded haltija's CI mode is just a Playwright wrapper.
26
+
27
+ - `hj --help` now names the engine per mode and adds a "Choosing a CI engine" block; `--private` is
28
+ clarified as an *isolation* modifier that pairs with either engine (not "pair with `--headless`").
29
+ - The "Playwright not installed" error points at the Electron path (`--ci` / `--private --app`) as
30
+ the no-Playwright alternative.
31
+ - `llms.txt`, `docs/CI-INTEGRATION.md`, and `SKILL.md` get an honest "which engine?" framing:
32
+ it's Electron vs Playwright (neither is bundled), and the real reason to choose Playwright is
33
+ multi-engine coverage (Firefox/WebKit), not the words "for CI".
34
+
35
+ ## 1.5.2
36
+
37
+ Two follow-ups from the 1.5.0 review, both about the multi-tab experience on a shared server.
38
+
39
+ ### Fixed
40
+
41
+ - **`hj tabs focus <id>` no longer times out** ([#4](https://github.com/tonioloewald/haltija/issues/4)).
42
+ It was dispatching a `focus` command to the browser, routed to the *focused* tab rather than the
43
+ target, so nobody answered — and even routed correctly, a backgrounded tab can't raise itself.
44
+ Focus is now a **server-side** routing change: it validates the tab and points untargeted commands
45
+ at it, returning instantly (unknown tab → a clean error, never a timeout). It does not physically
46
+ raise the tab; to pin a single command use `--window <id>`. "Focus follows the visible tab" still
47
+ applies when you physically switch tabs — that's genuine intent that should win over a stale pin.
48
+
49
+ ### Changed
50
+
51
+ - **The hidden-tab / focus-ambiguity warnings are de-duplicated within a short (15s) cooldown**, so
52
+ a burst of commands from one agent doesn't repeat the same block every time. A *changed* condition
53
+ (different tab, newly-hidden tab, a new origin on the server) always re-warns; the cooldown
54
+ re-arms rather than suppressing forever. Set `HALTIJA_NO_TAB_WARN=1` to silence them entirely.
55
+
3
56
  ## 1.5.1
4
57
 
5
58
  Low-risk follow-ups from the 1.5.0 pre-release review — the two new "instrument must not lie"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.5.1",
3
+ "version": "1.5.4",
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.1";
49
+ var VERSION = "1.5.4";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -5637,7 +5637,11 @@ ${elementSummary}${moreText}`;
5637
5637
  });
5638
5638
  } else {
5639
5639
  window.open(payload2.url, "_blank");
5640
- this.respond(msg2.id, true, { opened: true, fallback: true });
5640
+ this.respond(msg2.id, true, {
5641
+ opened: true,
5642
+ fallback: true,
5643
+ 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."
5644
+ });
5641
5645
  }
5642
5646
  } else if (action2 === "close") {
5643
5647
  if (haltija?.closeTab) {
@@ -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
@@ -88,6 +90,7 @@ Environment Variables:
88
90
  HALTIJA_NO_RETIRE Set to 1: do not stop pre-1.4.0 haltija servers
89
91
  HALTIJA_NO_INSTALL Set to 1: do not install hj into ~/.local/bin
90
92
  HALTIJA_NO_SKEW_WARN Set to 1: silence hj's version-skew warning
93
+ HALTIJA_NO_TAB_WARN Set to 1: silence hidden-tab / focus-ambiguity result warnings
91
94
  HALTIJA_REGISTRY_DIR Instance registry location (default: ~/.haltija/servers)
92
95
  DEV_CHANNEL_PORT Legacy alias for HALTIJA_PORT
93
96
  DEV_CHANNEL_HTTPS_PORT HTTPS port (default: 8701)
@@ -105,13 +108,23 @@ Subcommands:
105
108
 
106
109
  Use 'hj' as a short alias: hj tree, hj click @42, etc.
107
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
+
108
121
  Examples:
109
122
  haltija # Desktop app (or server fallback)
110
123
  haltija --app # Desktop app explicitly
111
- haltija --server # Server only
124
+ haltija --server # Server only (bring your own browser)
112
125
  haltija --server --https # HTTPS server only
113
- haltija --headless # Headless browser for CI
114
- 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)
115
128
  haltija --setup-mcp # Configure Claude Desktop integration
116
129
  `)
117
130
  process.exit(0)
@@ -728,8 +741,12 @@ const startHeadlessBrowser = async (port) => {
728
741
 
729
742
  } catch (err) {
730
743
  if (err.code === 'ERR_MODULE_NOT_FOUND') {
731
- console.error('[tosijs-dev] Playwright not installed. Run: npm install playwright')
732
- console.error('[tosijs-dev] Then: npx playwright install chromium')
744
+ console.error('[tosijs-dev] --headless uses Playwright Chromium, which is not installed.')
745
+ console.error('[tosijs-dev] To use Playwright: npm install playwright && npx playwright install chromium')
746
+ console.error('[tosijs-dev] Or skip Playwright entirely and use the ELECTRON engine instead:')
747
+ console.error('[tosijs-dev] haltija --ci (Electron, waits for ready, sandbox off)')
748
+ console.error('[tosijs-dev] haltija --private --app (Electron + isolated ephemeral instance)')
749
+ console.error('[tosijs-dev] Reach for --headless (Playwright) when you need Firefox/WebKit coverage.')
733
750
  } else {
734
751
  console.error('[tosijs-dev] Failed to start headless browser:', err.message)
735
752
  }
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.1'
6
+ export const HJ_VERSION = '1.5.4'
@@ -52,6 +52,18 @@ export interface HandlerContext {
52
52
  headers: Record<string, string>;
53
53
  url: URL;
54
54
  getWindowInfo: (windowId?: string) => WindowInfo | undefined;
55
+ /**
56
+ * Set the server-side focused window — the tab that receives untargeted commands. This is pure
57
+ * server state, NOT a browser roundtrip: it validates the tab exists and returns immediately, so
58
+ * it can never time out the way dispatching a command *to* the (possibly hidden) tab does (#4).
59
+ */
60
+ focusWindow: (windowId: string) => {
61
+ ok: boolean;
62
+ error?: string;
63
+ active?: boolean;
64
+ windowType?: string;
65
+ title?: string;
66
+ };
55
67
  startRecordingSession: (windowId: string, url: string, name?: string) => void;
56
68
  stopRecordingSession: (windowId: string) => RecordingSessionInfo | undefined;
57
69
  getRecordingSession: (windowId: string) => RecordingSessionInfo | undefined;
@@ -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.5.1";
23
+ export declare const VERSION = "1.5.4";
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.5.1";
2
+ var VERSION = "1.5.4";
3
3
 
4
4
  // src/text-selector.ts
5
5
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -5590,7 +5590,11 @@ ${elementSummary}${moreText}`;
5590
5590
  });
5591
5591
  } else {
5592
5592
  window.open(payload2.url, "_blank");
5593
- this.respond(msg2.id, true, { opened: true, fallback: true });
5593
+ this.respond(msg2.id, true, {
5594
+ opened: true,
5595
+ fallback: true,
5596
+ 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."
5597
+ });
5594
5598
  }
5595
5599
  } else if (action2 === "close") {
5596
5600
  if (haltija?.closeTab) {
package/dist/component.js CHANGED
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.5.1";
49
+ var VERSION = "1.5.4";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -5637,7 +5637,11 @@ ${elementSummary}${moreText}`;
5637
5637
  });
5638
5638
  } else {
5639
5639
  window.open(payload2.url, "_blank");
5640
- this.respond(msg2.id, true, { opened: true, fallback: true });
5640
+ this.respond(msg2.id, true, {
5641
+ opened: true,
5642
+ fallback: true,
5643
+ 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."
5644
+ });
5641
5645
  }
5642
5646
  } else if (action2 === "close") {
5643
5647
  if (haltija?.closeTab) {
package/dist/hj.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // haltija-cli:do-not-edit v1.5.1
2
+ // haltija-cli:do-not-edit v1.5.4
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.5.1";
759
+ var HJ_VERSION = "1.5.4";
760
760
 
761
761
  // bin/semver.mjs
762
762
  function parseVersion(v) {
package/dist/index.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.5.1";
677
+ var VERSION = "1.5.4";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2176,9 +2176,15 @@ Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
2176
2176
 
2177
2177
  **Open a new tab**
2178
2178
 
2179
- Desktop app only. Opens a new tab with optional URL.
2179
+ Opens a new tab with optional URL. If url is omitted, opens a blank tab.
2180
2180
 
2181
- If url is omitted, opens a blank tab. The new tab gets the widget auto-injected.
2181
+ **In the Haltija desktop app** the new tab gets the widget auto-injected, so it's immediately
2182
+ controllable. **Anywhere else** (widget injected into a normal browser via bookmarklet/dev-server)
2183
+ there is no tab API, so this falls back to \`window.open()\` \u2014 the new tab is a plain browser tab
2184
+ with NO widget unless its own page injects one. In that case the response has \`fallback: true\`
2185
+ plus a \`reason\`, and a top-level \`warning\`: the tab will NOT appear in \`hj tabs\` and hj commands
2186
+ cannot reach it (they go to the focused widget tab). To control it, inject the widget on that page
2187
+ (e.g. HALTIJA_DEV / haltijaDev) or use the desktop app.
2182
2188
 
2183
2189
  **Parameters:**
2184
2190
 
@@ -2224,11 +2230,15 @@ Get window IDs from /windows endpoint.
2224
2230
 
2225
2231
  ### \`POST /tabs/focus\`
2226
2232
 
2227
- **Focus a tab**
2233
+ **Focus a tab (route untargeted commands to it)**
2228
2234
 
2229
- Desktop app only. Brings the specified tab to front.
2230
-
2231
- Useful when working with multiple tabs to ensure the right one is visible.
2235
+ Make the given tab the target of untargeted commands. This is a server-side
2236
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
2237
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
2238
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
2239
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
2240
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
2241
+ can be stale). Returns \`{ success, focused, active, title }\`.
2232
2242
 
2233
2243
  **Parameters:**
2234
2244
 
@@ -2238,7 +2248,7 @@ Useful when working with multiple tabs to ensure the right one is visible.
2238
2248
 
2239
2249
  **Examples:**
2240
2250
 
2241
- - **focus**: Bring tab to front
2251
+ - **focus**: Route untargeted commands to this tab
2242
2252
  \`\`\`json
2243
2253
  {"window":"window-abc123"}
2244
2254
  \`\`\`
@@ -2802,7 +2812,7 @@ hj --help # All commands
2802
2812
  - \`hj windows\` - List connected windows
2803
2813
  - \`hj tabs-open [url]\` - Open a new tab
2804
2814
  - \`hj tabs-close [window]\` - Close a tab
2805
- - \`hj tabs-focus [window]\` - Focus a tab
2815
+ - \`hj tabs-focus [window]\` - Focus a tab (route untargeted commands to it)
2806
2816
 
2807
2817
  ### Record & Replay
2808
2818
 
@@ -3020,8 +3030,17 @@ Two first-party ways to run (plus embedding, below):
3020
3030
  \`hj tree\` inside a project reaches that project's server with no flags. Falling
3021
3031
  back to the shared default port 8700 warns on stderr. Use \`hj where\` to see which
3022
3032
  port a shell targets and WHY; override with \`--port\` or \`--name\`.
3023
- - **CI:** \`haltija --headless\` (or \`--ci\`) runs a headless Chromium with the widget
3024
- auto-injected \u2014 deterministic, no desktop app required. See the CI guide below.
3033
+ - **CI:** two engines, and the choice matters \u2014 both need one external browser, but
3034
+ a *different* one:
3035
+ - \`haltija --ci\` (or \`--private --app\` for an isolated instance) drives **Electron**
3036
+ (Chromium \u2014 the same engine the Haltija desktop app uses). Electron is fetched via
3037
+ \`npx\` if not already present; **no \`playwright\` needed.** This is the default CI path.
3038
+ - \`haltija --headless\` drives **Playwright Chromium**, which requires the \`playwright\`
3039
+ package (\`npm i playwright && npx playwright install chromium\`). Reach for it when you
3040
+ want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run \u2014 not just
3041
+ because it says "for CI". \`--private\` is an *isolation* modifier (ephemeral port), and
3042
+ pairs with either engine: \`--private --app\` (Electron) or \`--private --headless\`
3043
+ (Playwright). See the CI guide below.
3025
3044
 
3026
3045
  ## Embed in your app
3027
3046
 
@@ -3097,7 +3116,7 @@ var COMPONENT_JS = `(() => {
3097
3116
  });
3098
3117
 
3099
3118
  // src/version.ts
3100
- var VERSION = "1.5.1";
3119
+ var VERSION = "1.5.4";
3101
3120
 
3102
3121
  // src/text-selector.ts
3103
3122
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -8688,7 +8707,11 @@ var COMPONENT_JS = `(() => {
8688
8707
  });
8689
8708
  } else {
8690
8709
  window.open(payload2.url, "_blank");
8691
- this.respond(msg2.id, true, { opened: true, fallback: true });
8710
+ this.respond(msg2.id, true, {
8711
+ opened: true,
8712
+ fallback: true,
8713
+ 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 \u2014 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."
8714
+ });
8692
8715
  }
8693
8716
  } else if (action2 === "close") {
8694
8717
  if (haltija?.closeTab) {
@@ -12814,9 +12837,15 @@ var tabsOpen = endpoint({
12814
12837
  path: "/tabs/open",
12815
12838
  method: "POST",
12816
12839
  summary: "Open a new tab",
12817
- description: `Desktop app only. Opens a new tab with optional URL.
12818
-
12819
- If url is omitted, opens a blank tab. The new tab gets the widget auto-injected.`,
12840
+ description: `Opens a new tab with optional URL. If url is omitted, opens a blank tab.
12841
+
12842
+ **In the Haltija desktop app** the new tab gets the widget auto-injected, so it's immediately
12843
+ controllable. **Anywhere else** (widget injected into a normal browser via bookmarklet/dev-server)
12844
+ there is no tab API, so this falls back to \`window.open()\` \u2014 the new tab is a plain browser tab
12845
+ with NO widget unless its own page injects one. In that case the response has \`fallback: true\`
12846
+ plus a \`reason\`, and a top-level \`warning\`: the tab will NOT appear in \`hj tabs\` and hj commands
12847
+ cannot reach it (they go to the focused widget tab). To control it, inject the widget on that page
12848
+ (e.g. HALTIJA_DEV / haltijaDev) or use the desktop app.`,
12820
12849
  category: "windows",
12821
12850
  input: L.object({
12822
12851
  url: L.string.describe("URL to open").optional
@@ -12857,10 +12886,14 @@ Get window IDs from /windows endpoint.`,
12857
12886
  var tabsFocus = endpoint({
12858
12887
  path: "/tabs/focus",
12859
12888
  method: "POST",
12860
- summary: "Focus a tab",
12861
- description: `Desktop app only. Brings the specified tab to front.
12862
-
12863
- Useful when working with multiple tabs to ensure the right one is visible.`,
12889
+ summary: "Focus a tab (route untargeted commands to it)",
12890
+ description: `Make the given tab the target of untargeted commands. This is a server-side
12891
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
12892
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
12893
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
12894
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
12895
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
12896
+ can be stale). Returns \`{ success, focused, active, title }\`.`,
12864
12897
  category: "windows",
12865
12898
  input: L.object({
12866
12899
  window: L.string.describe("Window ID to focus")
@@ -12869,7 +12902,7 @@ Useful when working with multiple tabs to ensure the right one is visible.`,
12869
12902
  {
12870
12903
  name: "focus",
12871
12904
  input: { window: "window-abc123" },
12872
- description: "Bring tab to front"
12905
+ description: "Route untargeted commands to this tab"
12873
12906
  }
12874
12907
  ],
12875
12908
  hints: "<window-id> | see: windows, tabs-close, tabs-open"
@@ -14239,6 +14272,9 @@ registerHandler(screenshot, async (body, ctx) => {
14239
14272
  });
14240
14273
  registerHandler(tabsOpen, async (body, ctx) => {
14241
14274
  const response = await ctx.requestFromBrowser("tabs", "open", { url: body.url });
14275
+ if (response?.data?.fallback && response.data.reason && !response.warning) {
14276
+ response.warning = response.data.reason;
14277
+ }
14242
14278
  return Response.json(response, { headers: ctx.headers });
14243
14279
  });
14244
14280
  registerHandler(tabsClose, async (body, ctx) => {
@@ -14252,10 +14288,19 @@ registerHandler(tabsClose, async (body, ctx) => {
14252
14288
  registerHandler(tabsFocus, async (body, ctx) => {
14253
14289
  const windowId = body.window || ctx.targetWindowId;
14254
14290
  if (!windowId) {
14255
- return Response.json({ success: false, error: "window id is required" }, { status: 400, headers: ctx.headers });
14291
+ return Response.json({ success: false, error: "window id is required (run `hj tabs` to list connected tabs)" }, { status: 400, headers: ctx.headers });
14256
14292
  }
14257
- const response = await ctx.requestFromBrowser("tabs", "focus", { windowId });
14258
- return Response.json(response, { headers: ctx.headers });
14293
+ const r = ctx.focusWindow(windowId);
14294
+ if (!r.ok) {
14295
+ return Response.json({ success: false, error: r.error }, { status: 404, headers: ctx.headers });
14296
+ }
14297
+ const result = { success: true, focused: windowId, active: r.active !== false };
14298
+ if (r.title)
14299
+ result.title = r.title;
14300
+ if (r.active === false) {
14301
+ result.warning = "Untargeted commands now route to this tab, but it reports HIDDEN \u2014 browsers freeze " + "requestAnimationFrame and throttle timers in a backgrounded tab, so results can be stale " + '("not mounted yet", not "broken"). Bring it to the front in your browser to wake it.';
14302
+ }
14303
+ return Response.json(result, { headers: ctx.headers });
14259
14304
  });
14260
14305
  registerHandler(mutationsWatch, async (body, ctx) => {
14261
14306
  const response = await ctx.requestFromBrowser("mutations", "watch", {
@@ -15275,6 +15320,22 @@ function ambiguousFocusWarning(opts) {
15275
15320
  ${pins}${more}`;
15276
15321
  }
15277
15322
 
15323
+ // src/warning-dedupe.ts
15324
+ function shouldEmitWarning(warning, cache, now, cooldownMs) {
15325
+ const last = cache.get(warning);
15326
+ if (last !== undefined && now - last < cooldownMs) {
15327
+ return false;
15328
+ }
15329
+ cache.set(warning, now);
15330
+ if (cache.size > 64) {
15331
+ for (const [key2, ts] of cache) {
15332
+ if (now - ts >= cooldownMs)
15333
+ cache.delete(key2);
15334
+ }
15335
+ }
15336
+ return true;
15337
+ }
15338
+
15278
15339
  // src/server.ts
15279
15340
  init_terminal();
15280
15341
 
@@ -16129,6 +16190,9 @@ function getComponentJs() {
16129
16190
  }
16130
16191
  var browsers = new Map;
16131
16192
  var windows2 = new Map;
16193
+ var recentTabWarnings = new Map;
16194
+ var TAB_WARN_COOLDOWN_MS = 15000;
16195
+ var TAB_WARN_DISABLED = process.env.HALTIJA_NO_TAB_WARN === "1";
16132
16196
  var agents = new Set;
16133
16197
  var focusedWindowId = null;
16134
16198
  var isDesktopApp = process.env.HALTIJA_DESKTOP === "1";
@@ -16443,6 +16507,8 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16443
16507
  return new Promise((resolve) => {
16444
16508
  let sentTo = null;
16445
16509
  const attachWarning = (res) => {
16510
+ if (TAB_WARN_DISABLED)
16511
+ return res;
16446
16512
  const hidden = hiddenTabWarning(sentTo);
16447
16513
  const ambiguous = ambiguousFocusWarning({
16448
16514
  windows: Array.from(windows2.values()),
@@ -16452,7 +16518,12 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16452
16518
  const warning = [hidden, ambiguous].filter(Boolean).join(`
16453
16519
 
16454
16520
  `);
16455
- return warning ? { ...res, warning } : res;
16521
+ if (!warning)
16522
+ return res;
16523
+ if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16524
+ return res;
16525
+ }
16526
+ return { ...res, warning };
16456
16527
  };
16457
16528
  const timeout = setTimeout(() => {
16458
16529
  pendingResponses.delete(id);
@@ -16623,6 +16694,15 @@ var createHandlerContext = (req, url) => {
16623
16694
  headers,
16624
16695
  url,
16625
16696
  getWindowInfo,
16697
+ focusWindow: (windowId) => {
16698
+ const win = windows2.get(windowId);
16699
+ if (!win) {
16700
+ return { ok: false, error: `Tab ${windowId} not found (run \`hj tabs\` to list connected tabs)` };
16701
+ }
16702
+ focusedWindowId = windowId;
16703
+ updateHjStatus();
16704
+ return { ok: true, active: win.active, windowType: win.windowType, title: win.title };
16705
+ },
16626
16706
  startRecordingSession,
16627
16707
  stopRecordingSession,
16628
16708
  getRecordingSession,
@@ -18300,12 +18380,10 @@ Type 'help <topic>' for details.`);
18300
18380
  break;
18301
18381
  }
18302
18382
  case "tabs-focus": {
18303
- const resp = await requestFromBrowser("tabs", "focus", { windowId: step.window }, stepTimeout);
18304
- if (!resp.success) {
18383
+ if (!step.window || !windows2.has(step.window)) {
18305
18384
  stepPassed = false;
18306
- error = resp.error || "Failed to focus tab";
18307
- }
18308
- if (step.window && windows2.has(step.window)) {
18385
+ error = `Tab ${step.window ?? "(none)"} not found`;
18386
+ } else {
18309
18387
  focusedWindowId = step.window;
18310
18388
  }
18311
18389
  break;
package/dist/server.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.5.1";
677
+ var VERSION = "1.5.4";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2176,9 +2176,15 @@ Use window IDs in other endpoints (e.g., /click, /tree) to target specific tabs.
2176
2176
 
2177
2177
  **Open a new tab**
2178
2178
 
2179
- Desktop app only. Opens a new tab with optional URL.
2179
+ Opens a new tab with optional URL. If url is omitted, opens a blank tab.
2180
2180
 
2181
- If url is omitted, opens a blank tab. The new tab gets the widget auto-injected.
2181
+ **In the Haltija desktop app** the new tab gets the widget auto-injected, so it's immediately
2182
+ controllable. **Anywhere else** (widget injected into a normal browser via bookmarklet/dev-server)
2183
+ there is no tab API, so this falls back to \`window.open()\` \u2014 the new tab is a plain browser tab
2184
+ with NO widget unless its own page injects one. In that case the response has \`fallback: true\`
2185
+ plus a \`reason\`, and a top-level \`warning\`: the tab will NOT appear in \`hj tabs\` and hj commands
2186
+ cannot reach it (they go to the focused widget tab). To control it, inject the widget on that page
2187
+ (e.g. HALTIJA_DEV / haltijaDev) or use the desktop app.
2182
2188
 
2183
2189
  **Parameters:**
2184
2190
 
@@ -2224,11 +2230,15 @@ Get window IDs from /windows endpoint.
2224
2230
 
2225
2231
  ### \`POST /tabs/focus\`
2226
2232
 
2227
- **Focus a tab**
2233
+ **Focus a tab (route untargeted commands to it)**
2228
2234
 
2229
- Desktop app only. Brings the specified tab to front.
2230
-
2231
- Useful when working with multiple tabs to ensure the right one is visible.
2235
+ Make the given tab the target of untargeted commands. This is a server-side
2236
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
2237
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
2238
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
2239
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
2240
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
2241
+ can be stale). Returns \`{ success, focused, active, title }\`.
2232
2242
 
2233
2243
  **Parameters:**
2234
2244
 
@@ -2238,7 +2248,7 @@ Useful when working with multiple tabs to ensure the right one is visible.
2238
2248
 
2239
2249
  **Examples:**
2240
2250
 
2241
- - **focus**: Bring tab to front
2251
+ - **focus**: Route untargeted commands to this tab
2242
2252
  \`\`\`json
2243
2253
  {"window":"window-abc123"}
2244
2254
  \`\`\`
@@ -2802,7 +2812,7 @@ hj --help # All commands
2802
2812
  - \`hj windows\` - List connected windows
2803
2813
  - \`hj tabs-open [url]\` - Open a new tab
2804
2814
  - \`hj tabs-close [window]\` - Close a tab
2805
- - \`hj tabs-focus [window]\` - Focus a tab
2815
+ - \`hj tabs-focus [window]\` - Focus a tab (route untargeted commands to it)
2806
2816
 
2807
2817
  ### Record & Replay
2808
2818
 
@@ -3020,8 +3030,17 @@ Two first-party ways to run (plus embedding, below):
3020
3030
  \`hj tree\` inside a project reaches that project's server with no flags. Falling
3021
3031
  back to the shared default port 8700 warns on stderr. Use \`hj where\` to see which
3022
3032
  port a shell targets and WHY; override with \`--port\` or \`--name\`.
3023
- - **CI:** \`haltija --headless\` (or \`--ci\`) runs a headless Chromium with the widget
3024
- auto-injected \u2014 deterministic, no desktop app required. See the CI guide below.
3033
+ - **CI:** two engines, and the choice matters \u2014 both need one external browser, but
3034
+ a *different* one:
3035
+ - \`haltija --ci\` (or \`--private --app\` for an isolated instance) drives **Electron**
3036
+ (Chromium \u2014 the same engine the Haltija desktop app uses). Electron is fetched via
3037
+ \`npx\` if not already present; **no \`playwright\` needed.** This is the default CI path.
3038
+ - \`haltija --headless\` drives **Playwright Chromium**, which requires the \`playwright\`
3039
+ package (\`npm i playwright && npx playwright install chromium\`). Reach for it when you
3040
+ want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run \u2014 not just
3041
+ because it says "for CI". \`--private\` is an *isolation* modifier (ephemeral port), and
3042
+ pairs with either engine: \`--private --app\` (Electron) or \`--private --headless\`
3043
+ (Playwright). See the CI guide below.
3025
3044
 
3026
3045
  ## Embed in your app
3027
3046
 
@@ -3097,7 +3116,7 @@ var COMPONENT_JS = `(() => {
3097
3116
  });
3098
3117
 
3099
3118
  // src/version.ts
3100
- var VERSION = "1.5.1";
3119
+ var VERSION = "1.5.4";
3101
3120
 
3102
3121
  // src/text-selector.ts
3103
3122
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -8688,7 +8707,11 @@ var COMPONENT_JS = `(() => {
8688
8707
  });
8689
8708
  } else {
8690
8709
  window.open(payload2.url, "_blank");
8691
- this.respond(msg2.id, true, { opened: true, fallback: true });
8710
+ this.respond(msg2.id, true, {
8711
+ opened: true,
8712
+ fallback: true,
8713
+ 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 \u2014 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."
8714
+ });
8692
8715
  }
8693
8716
  } else if (action2 === "close") {
8694
8717
  if (haltija?.closeTab) {
@@ -12814,9 +12837,15 @@ var tabsOpen = endpoint({
12814
12837
  path: "/tabs/open",
12815
12838
  method: "POST",
12816
12839
  summary: "Open a new tab",
12817
- description: `Desktop app only. Opens a new tab with optional URL.
12818
-
12819
- If url is omitted, opens a blank tab. The new tab gets the widget auto-injected.`,
12840
+ description: `Opens a new tab with optional URL. If url is omitted, opens a blank tab.
12841
+
12842
+ **In the Haltija desktop app** the new tab gets the widget auto-injected, so it's immediately
12843
+ controllable. **Anywhere else** (widget injected into a normal browser via bookmarklet/dev-server)
12844
+ there is no tab API, so this falls back to \`window.open()\` \u2014 the new tab is a plain browser tab
12845
+ with NO widget unless its own page injects one. In that case the response has \`fallback: true\`
12846
+ plus a \`reason\`, and a top-level \`warning\`: the tab will NOT appear in \`hj tabs\` and hj commands
12847
+ cannot reach it (they go to the focused widget tab). To control it, inject the widget on that page
12848
+ (e.g. HALTIJA_DEV / haltijaDev) or use the desktop app.`,
12820
12849
  category: "windows",
12821
12850
  input: L.object({
12822
12851
  url: L.string.describe("URL to open").optional
@@ -12857,10 +12886,14 @@ Get window IDs from /windows endpoint.`,
12857
12886
  var tabsFocus = endpoint({
12858
12887
  path: "/tabs/focus",
12859
12888
  method: "POST",
12860
- summary: "Focus a tab",
12861
- description: `Desktop app only. Brings the specified tab to front.
12862
-
12863
- Useful when working with multiple tabs to ensure the right one is visible.`,
12889
+ summary: "Focus a tab (route untargeted commands to it)",
12890
+ description: `Make the given tab the target of untargeted commands. This is a server-side
12891
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
12892
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
12893
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
12894
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
12895
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
12896
+ can be stale). Returns \`{ success, focused, active, title }\`.`,
12864
12897
  category: "windows",
12865
12898
  input: L.object({
12866
12899
  window: L.string.describe("Window ID to focus")
@@ -12869,7 +12902,7 @@ Useful when working with multiple tabs to ensure the right one is visible.`,
12869
12902
  {
12870
12903
  name: "focus",
12871
12904
  input: { window: "window-abc123" },
12872
- description: "Bring tab to front"
12905
+ description: "Route untargeted commands to this tab"
12873
12906
  }
12874
12907
  ],
12875
12908
  hints: "<window-id> | see: windows, tabs-close, tabs-open"
@@ -14239,6 +14272,9 @@ registerHandler(screenshot, async (body, ctx) => {
14239
14272
  });
14240
14273
  registerHandler(tabsOpen, async (body, ctx) => {
14241
14274
  const response = await ctx.requestFromBrowser("tabs", "open", { url: body.url });
14275
+ if (response?.data?.fallback && response.data.reason && !response.warning) {
14276
+ response.warning = response.data.reason;
14277
+ }
14242
14278
  return Response.json(response, { headers: ctx.headers });
14243
14279
  });
14244
14280
  registerHandler(tabsClose, async (body, ctx) => {
@@ -14252,10 +14288,19 @@ registerHandler(tabsClose, async (body, ctx) => {
14252
14288
  registerHandler(tabsFocus, async (body, ctx) => {
14253
14289
  const windowId = body.window || ctx.targetWindowId;
14254
14290
  if (!windowId) {
14255
- return Response.json({ success: false, error: "window id is required" }, { status: 400, headers: ctx.headers });
14291
+ return Response.json({ success: false, error: "window id is required (run `hj tabs` to list connected tabs)" }, { status: 400, headers: ctx.headers });
14256
14292
  }
14257
- const response = await ctx.requestFromBrowser("tabs", "focus", { windowId });
14258
- return Response.json(response, { headers: ctx.headers });
14293
+ const r = ctx.focusWindow(windowId);
14294
+ if (!r.ok) {
14295
+ return Response.json({ success: false, error: r.error }, { status: 404, headers: ctx.headers });
14296
+ }
14297
+ const result = { success: true, focused: windowId, active: r.active !== false };
14298
+ if (r.title)
14299
+ result.title = r.title;
14300
+ if (r.active === false) {
14301
+ result.warning = "Untargeted commands now route to this tab, but it reports HIDDEN \u2014 browsers freeze " + "requestAnimationFrame and throttle timers in a backgrounded tab, so results can be stale " + '("not mounted yet", not "broken"). Bring it to the front in your browser to wake it.';
14302
+ }
14303
+ return Response.json(result, { headers: ctx.headers });
14259
14304
  });
14260
14305
  registerHandler(mutationsWatch, async (body, ctx) => {
14261
14306
  const response = await ctx.requestFromBrowser("mutations", "watch", {
@@ -15275,6 +15320,22 @@ function ambiguousFocusWarning(opts) {
15275
15320
  ${pins}${more}`;
15276
15321
  }
15277
15322
 
15323
+ // src/warning-dedupe.ts
15324
+ function shouldEmitWarning(warning, cache, now, cooldownMs) {
15325
+ const last = cache.get(warning);
15326
+ if (last !== undefined && now - last < cooldownMs) {
15327
+ return false;
15328
+ }
15329
+ cache.set(warning, now);
15330
+ if (cache.size > 64) {
15331
+ for (const [key2, ts] of cache) {
15332
+ if (now - ts >= cooldownMs)
15333
+ cache.delete(key2);
15334
+ }
15335
+ }
15336
+ return true;
15337
+ }
15338
+
15278
15339
  // src/server.ts
15279
15340
  init_terminal();
15280
15341
 
@@ -16129,6 +16190,9 @@ function getComponentJs() {
16129
16190
  }
16130
16191
  var browsers = new Map;
16131
16192
  var windows2 = new Map;
16193
+ var recentTabWarnings = new Map;
16194
+ var TAB_WARN_COOLDOWN_MS = 15000;
16195
+ var TAB_WARN_DISABLED = process.env.HALTIJA_NO_TAB_WARN === "1";
16132
16196
  var agents = new Set;
16133
16197
  var focusedWindowId = null;
16134
16198
  var isDesktopApp = process.env.HALTIJA_DESKTOP === "1";
@@ -16443,6 +16507,8 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16443
16507
  return new Promise((resolve) => {
16444
16508
  let sentTo = null;
16445
16509
  const attachWarning = (res) => {
16510
+ if (TAB_WARN_DISABLED)
16511
+ return res;
16446
16512
  const hidden = hiddenTabWarning(sentTo);
16447
16513
  const ambiguous = ambiguousFocusWarning({
16448
16514
  windows: Array.from(windows2.values()),
@@ -16452,7 +16518,12 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16452
16518
  const warning = [hidden, ambiguous].filter(Boolean).join(`
16453
16519
 
16454
16520
  `);
16455
- return warning ? { ...res, warning } : res;
16521
+ if (!warning)
16522
+ return res;
16523
+ if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16524
+ return res;
16525
+ }
16526
+ return { ...res, warning };
16456
16527
  };
16457
16528
  const timeout = setTimeout(() => {
16458
16529
  pendingResponses.delete(id);
@@ -16623,6 +16694,15 @@ var createHandlerContext = (req, url) => {
16623
16694
  headers,
16624
16695
  url,
16625
16696
  getWindowInfo,
16697
+ focusWindow: (windowId) => {
16698
+ const win = windows2.get(windowId);
16699
+ if (!win) {
16700
+ return { ok: false, error: `Tab ${windowId} not found (run \`hj tabs\` to list connected tabs)` };
16701
+ }
16702
+ focusedWindowId = windowId;
16703
+ updateHjStatus();
16704
+ return { ok: true, active: win.active, windowType: win.windowType, title: win.title };
16705
+ },
16626
16706
  startRecordingSession,
16627
16707
  stopRecordingSession,
16628
16708
  getRecordingSession,
@@ -18300,12 +18380,10 @@ Type 'help <topic>' for details.`);
18300
18380
  break;
18301
18381
  }
18302
18382
  case "tabs-focus": {
18303
- const resp = await requestFromBrowser("tabs", "focus", { windowId: step.window }, stepTimeout);
18304
- if (!resp.success) {
18383
+ if (!step.window || !windows2.has(step.window)) {
18305
18384
  stepPassed = false;
18306
- error = resp.error || "Failed to focus tab";
18307
- }
18308
- if (step.window && windows2.has(step.window)) {
18385
+ error = `Tab ${step.window ?? "(none)"} not found`;
18386
+ } else {
18309
18387
  focusedWindowId = step.window;
18310
18388
  }
18311
18389
  break;
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.5.1";
11
+ export declare const VERSION = "1.5.4";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * De-duplicate the tab warnings (hidden-tab #3, focus-ambiguity #2) so a burst of untargeted
3
+ * commands from one agent doesn't repeat the same multi-line block on every single one. That
4
+ * repetition is the alarm-fatigue the repo's own skew-warning rationale warns about: a caveat that
5
+ * fires on every command trains agents to ignore it, including the time it matters.
6
+ *
7
+ * **Why a short cooldown and not "once, forever":** the server has many clients and can't tell them
8
+ * apart, so a permanent global suppression would hide the warning from a *second* agent that never
9
+ * saw it — the exact "plausible-but-wrong answer with no caveat" failure these warnings exist to
10
+ * prevent. A short cooldown collapses the one-agent burst (the real fatigue source) while keeping
11
+ * the window in which another client could miss it tiny; the condition persists, so their next
12
+ * command past the cooldown re-warns.
13
+ *
14
+ * **Why key on the whole warning string:** the text already encodes the full condition — which tab
15
+ * answered, whether it's hidden, the exact set of other origins. So a *changed* situation (a
16
+ * different tab, a newly-hidden tab, a new origin on the server) produces different text and is
17
+ * never suppressed. Identical text means identical situation.
18
+ */
19
+ export declare function shouldEmitWarning(warning: string, cache: Map<string, number>, now: number, cooldownMs: number): boolean;
@@ -95,6 +95,24 @@ run: |
95
95
 
96
96
  ## Launch Options
97
97
 
98
+ ### Which engine? (read this first)
99
+
100
+ Haltija can drive one of two browser engines in CI, and they need **different** external browsers —
101
+ picking by the words "for CI" alone leads people to the wrong one:
102
+
103
+ | You want | Use | Engine | External dependency |
104
+ | --- | --- | --- | --- |
105
+ | The default CI path | `--ci` | **Electron** (Chromium) | Electron (auto-fetched via `npx`) — **no Playwright** |
106
+ | Isolated CI instance, own ephemeral port | `--private --app` | **Electron** (Chromium) | Electron (as above) |
107
+ | Multi-engine coverage (Firefox/WebKit) or a lighter single-engine run | `--headless` | **Playwright** Chromium | the `playwright` package (`npm i playwright && npx playwright install`) |
108
+
109
+ Neither engine is bundled in the npm package, so *something* is downloaded either way — the choice
110
+ is **Electron vs. Playwright**, not "deps vs. no deps". The one genuine reason to reach for
111
+ `--headless`/Playwright is engines Electron can't give you (Firefox, WebKit). For a straight
112
+ Chromium CI lane, `--ci` (Electron) needs no separate `playwright` install. `--private` is an
113
+ *isolation* modifier — it pairs with either engine (`--private --app` = Electron, `--private
114
+ --headless` = Playwright).
115
+
98
116
  ### Option 1: CI mode (recommended)
99
117
 
100
118
  ```bash
@@ -115,13 +133,18 @@ xvfb-run --auto-servernum bunx haltija@latest --app &
115
133
 
116
134
  Same as `--ci` but doesn't wait for ready state. You'll need to poll `/status` yourself.
117
135
 
118
- ### Option 3: Playwright headless (lighter weight)
136
+ ### Option 3: Playwright headless (multi-engine)
119
137
 
120
138
  ```bash
139
+ npm install playwright && npx playwright install chromium # required — not bundled
121
140
  bunx haltija@latest --headless &
122
141
  ```
123
142
 
124
- Uses Playwright's Chromium instead of Electron. Lighter weight but slightly different browser engine.
143
+ Uses **Playwright's** Chromium instead of Electron, so it needs the `playwright` package installed
144
+ (the command above; without it, `--headless` exits with a "Playwright not installed" error that
145
+ points you back to `--ci`). Choose this when you want a lighter single-engine run or, the real
146
+ reason, **multi-engine coverage** — Playwright can also drive Firefox and WebKit, which Electron
147
+ cannot. For a plain Chromium lane with no extra install, prefer Option 1 (`--ci`).
125
148
 
126
149
  ### Option 4: Server-only mode (lighter weight)
127
150
 
package/llms.txt CHANGED
@@ -40,8 +40,17 @@ Two first-party ways to run (plus embedding, below):
40
40
  `hj tree` inside a project reaches that project's server with no flags. Falling
41
41
  back to the shared default port 8700 warns on stderr. Use `hj where` to see which
42
42
  port a shell targets and WHY; override with `--port` or `--name`.
43
- - **CI:** `haltija --headless` (or `--ci`) runs a headless Chromium with the widget
44
- auto-injected deterministic, no desktop app required. See the CI guide below.
43
+ - **CI:** two engines, and the choice matters both need one external browser, but
44
+ a *different* one:
45
+ - `haltija --ci` (or `--private --app` for an isolated instance) drives **Electron**
46
+ (Chromium — the same engine the Haltija desktop app uses). Electron is fetched via
47
+ `npx` if not already present; **no `playwright` needed.** This is the default CI path.
48
+ - `haltija --headless` drives **Playwright Chromium**, which requires the `playwright`
49
+ package (`npm i playwright && npx playwright install chromium`). Reach for it when you
50
+ want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run — not just
51
+ because it says "for CI". `--private` is an *isolation* modifier (ephemeral port), and
52
+ pairs with either engine: `--private --app` (Electron) or `--private --headless`
53
+ (Playwright). See the CI guide below.
45
54
 
46
55
  ## Embed in your app
47
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.5.1",
3
+ "version": "1.5.4",
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",