haltija 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ Completes the **private-automation** feature (`--private`) begun in 1.4.1 — now for the Electron
6
+ app as well as headless — and adds two **"the instrument must not lie"** guards so a command that
7
+ lands on the wrong or sleeping tab says so instead of returning a plausible-but-wrong answer.
8
+
9
+ ### New: `--private --app` — isolated Electron automation ([#1](https://github.com/tonioloewald/haltija/issues/1))
10
+
11
+ `--private` gave headless runs an isolated server + browser on an ephemeral port. `--private --app`
12
+ extends that to the desktop app: it spawns its **own** public and internal servers on ephemeral
13
+ ports (never 8700/8701), drives its **own** browser, writes the public address to `--port-file`,
14
+ and never sees, adopts, registers, or touches the shared interactive channel. The app's port
15
+ constants are now resolved *after* the private servers report their ephemeral ports, so every
16
+ downstream use — widget injection, `/status`, content tabs — follows the ephemeral instance.
17
+
18
+ ### New: hidden-tab warning ([#3](https://github.com/tonioloewald/haltija/issues/3))
19
+
20
+ A backgrounded tab **answers** — `hj eval 'document.querySelectorAll("x").length'` returns `0`, not
21
+ a timeout — because browsers stop `requestAnimationFrame` and throttle timers in a hidden tab, so
22
+ anything mounted by rAF/IntersectionObserver never ran. The page looks broken when it's merely
23
+ asleep. When a command is routed to a tab that reported itself hidden, the result now carries a
24
+ warning that the number can be plausible-but-wrong, with how to target a visible tab.
25
+
26
+ ### New: focus-ambiguity warning ([#2](https://github.com/tonioloewald/haltija/issues/2))
27
+
28
+ cwd routing gets an untargeted `hj` command to the right shared *server* and then stops — which
29
+ *tab* answers falls back to focus. So two agents each staying in their own project can drive each
30
+ other's pages once both have a tab on the shared server. When a command isn't pinned to a window
31
+ and the server spans more than one origin, the result now warns that *focus*, not your directory,
32
+ chose the tab — and lists the other tabs as `--window` pins. It deliberately does **not** guess
33
+ which tab is "yours" (there's no reliable origin→directory map); ranking waits for one that can
34
+ justify itself.
35
+
36
+ ### Fixed
37
+
38
+ - **`hj --window <id> <cmd>`** — the documented leading form printed the usage banner instead of
39
+ targeting the window (`--window` wasn't pre-parsed like `--port`/`--name`). Both positions work now.
40
+ - **Desktop-spawned servers get their port** via the env the server actually reads
41
+ (`HALTIJA_PORT`/`DEV_CHANNEL_PORT`, not `PORT`) — the app couldn't control its servers' ports before.
42
+
3
43
  ## 1.4.1
4
44
 
5
45
  Five cross-project bugs, all of the same shape: **haltija reaching out and disrupting a healthy
@@ -29,15 +29,27 @@ const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwo
29
29
  process.stdout.on('error', () => {})
30
30
  process.stderr.on('error', () => {})
31
31
 
32
+ // PRIVATE (isolated automation) mode — the Electron half of issue #1. `bunx haltija --private
33
+ // --app` must be an isolated instance that never sees/adopts/touches the shared servers on
34
+ // 8700/8701: it binds EPHEMERAL ports, discovered after the servers start. So these are `let`,
35
+ // not `const` — reassigned once the private servers report their ports (below), which means every
36
+ // downstream use (widget injection, status checks, help text, content tabs) automatically follows
37
+ // the ephemeral ports with no other edits.
38
+ const IS_PRIVATE = process.env.HALTIJA_PRIVATE === '1'
39
+ // The caller's port-file (from `--port-file`), captured before we repurpose the env for our own
40
+ // per-server discovery. In private mode the app writes the PUBLIC ephemeral address here so the
41
+ // consumer (e.g. a dev-server test lane) can drive this instance.
42
+ const CALLER_PORT_FILE = IS_PRIVATE ? (process.env.HALTIJA_PORT_FILE || null) : null
43
+
32
44
  // Haltija server config
33
- const HALTIJA_PORT = parseInt(process.env.HALTIJA_PORT || '8700')
34
- const HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
45
+ let HALTIJA_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_PORT || '8700')
46
+ let HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
35
47
 
36
48
  // Internal port for the chrome widget (the haltija UI inspecting itself).
37
49
  // Lives on a separate server so it never appears in agent-facing window lists
38
50
  // — agents see only content tabs unless they explicitly target this port.
39
- const HALTIJA_INTERNAL_PORT = parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701')
40
- const HALTIJA_INTERNAL_SERVER = `http://localhost:${HALTIJA_INTERNAL_PORT}`
51
+ let HALTIJA_INTERNAL_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701')
52
+ let HALTIJA_INTERNAL_SERVER = `http://localhost:${HALTIJA_INTERNAL_PORT}`
41
53
 
42
54
  // Unique app instance ID - used to create stable window IDs across navigations
43
55
  // Combined with webContents.id to create globally unique tab identifiers
@@ -1188,8 +1200,34 @@ function checkServerRunning() {
1188
1200
  * Stdout/stderr are piped to the desktop app's console with a label, and
1189
1201
  * `__NEED_WINDOW__` from the public server triggers window recreation.
1190
1202
  */
1191
- function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir }) {
1192
- const env = { ...process.env, PORT: port.toString(), HALTIJA_DESKTOP: '1' }
1203
+ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir, portFile }) {
1204
+ // Pass the port via the env the SERVER ACTUALLY READS. It was `PORT`, which src/server.ts
1205
+ // never reads (it reads HALTIJA_PORT / DEV_CHANNEL_PORT) — so a spawned server ignored the
1206
+ // port it was given and inherited the app's HALTIJA_PORT instead. The internal chrome server
1207
+ // therefore tried to bind the PUBLIC port, collided, and died: verified by launching the app
1208
+ // on high ports and finding nothing on the internal one.
1209
+ const env = {
1210
+ ...process.env,
1211
+ PORT: port.toString(), // kept for anything else that may read it
1212
+ HALTIJA_PORT: port.toString(), // what src/server.ts actually reads
1213
+ DEV_CHANNEL_PORT: port.toString(),
1214
+ HALTIJA_DESKTOP: '1',
1215
+ }
1216
+ if (IS_PRIVATE) {
1217
+ // Isolated instance: this child binds an EPHEMERAL port (HALTIJA_PRIVATE forces PORT=0) and
1218
+ // reports it to `portFile` so we can discover it. Each child gets its OWN port-file — never
1219
+ // the caller's, which we write ourselves once with the public address.
1220
+ env.HALTIJA_PRIVATE = '1'
1221
+ env.HALTIJA_NO_RETIRE = '1'
1222
+ env.HALTIJA_NO_INSTALL = '1'
1223
+ env.HALTIJA_PORT_FILE = portFile
1224
+ delete env.HALTIJA_PORT // ephemeral, not the app's port
1225
+ delete env.DEV_CHANNEL_PORT
1226
+ } else {
1227
+ // A non-private child must not inherit a private parent's flags (belt and braces).
1228
+ delete env.HALTIJA_PRIVATE
1229
+ delete env.HALTIJA_PORT_FILE
1230
+ }
1193
1231
  let proc
1194
1232
  if (serverPath && useCompiledBinary) {
1195
1233
  proc = spawn(serverPath, [], {
@@ -1292,6 +1330,43 @@ async function startEmbeddedServer() {
1292
1330
  }
1293
1331
  }
1294
1332
 
1333
+ if (IS_PRIVATE) {
1334
+ // Private: both servers bind ephemeral ports we don't know yet. Give each its own port-file,
1335
+ // wait for them to report, then reassign the module ports so everything downstream (injection,
1336
+ // status, tabs, help) follows the ephemeral instance. Never touches 8700/8701.
1337
+ const pubFile = path.join(os.tmpdir(), `haltija-app-pub-${process.pid}.json`)
1338
+ const intFile = path.join(os.tmpdir(), `haltija-app-int-${process.pid}.json`)
1339
+ try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
1340
+
1341
+ spawnHaltijaServer({ port: 0, role: 'public', serverPath, useCompiledBinary, componentDir, portFile: pubFile })
1342
+ spawnHaltijaServer({ port: 0, role: 'internal', serverPath, useCompiledBinary, componentDir, portFile: intFile })
1343
+
1344
+ const readPort = async (file) => {
1345
+ for (let i = 0; i < 50; i++) {
1346
+ try { const d = JSON.parse(fs.readFileSync(file, 'utf8')); if (d && d.port) return d.port } catch {}
1347
+ await new Promise((r) => setTimeout(r, 200))
1348
+ }
1349
+ return null
1350
+ }
1351
+ const pubPort = await readPort(pubFile)
1352
+ const intPort = await readPort(intFile)
1353
+ if (!pubPort) { console.error('[Haltija Desktop] Private public server did not report its port'); return false }
1354
+
1355
+ HALTIJA_PORT = pubPort
1356
+ HALTIJA_SERVER = `http://localhost:${pubPort}`
1357
+ if (intPort) { HALTIJA_INTERNAL_PORT = intPort; HALTIJA_INTERNAL_SERVER = `http://localhost:${intPort}` }
1358
+ try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
1359
+
1360
+ // Hand the PUBLIC address to the consumer that asked for this private instance.
1361
+ if (CALLER_PORT_FILE) {
1362
+ try {
1363
+ fs.writeFileSync(CALLER_PORT_FILE, JSON.stringify({ port: pubPort, url: HALTIJA_SERVER, internalPort: intPort || null, pid: process.pid }))
1364
+ } catch (err) { console.error('[Haltija Desktop] Could not write caller port-file:', err.message) }
1365
+ }
1366
+ console.log(`[Haltija Desktop] Private instance ready — public ${HALTIJA_SERVER}, internal :${intPort || 'n/a'} (8700/8701 untouched)`)
1367
+ return true
1368
+ }
1369
+
1295
1370
  spawnHaltijaServer({ port: HALTIJA_PORT, role: 'public', serverPath, useCompiledBinary, componentDir })
1296
1371
  spawnHaltijaServer({ port: HALTIJA_INTERNAL_PORT, role: 'internal', serverPath, useCompiledBinary, componentDir })
1297
1372
 
@@ -1406,6 +1481,13 @@ async function killZombieServer() {
1406
1481
  }
1407
1482
 
1408
1483
  async function ensureServer() {
1484
+ // Private is isolated by construction: never look for, adopt, or replace a shared server. It
1485
+ // always starts its own on ephemeral ports. `checkServerRunning` would probe 8700 (which we
1486
+ // must ignore), so skip it entirely.
1487
+ if (IS_PRIVATE) {
1488
+ return await startEmbeddedServer()
1489
+ }
1490
+
1409
1491
  const running = await checkServerRunning()
1410
1492
 
1411
1493
  switch (prefs.serverMode) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.4.1";
49
+ var VERSION = "1.5.0";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -938,6 +938,13 @@ async function doRequest(url, method, body, context = {}) {
938
938
  if (contentType.includes('application/json')) {
939
939
  const json = await resp.json()
940
940
 
941
+ // A result can be REAL but MISLEADING — e.g. it came from a hidden tab where rAF-driven
942
+ // content never mounted, so an empty selector means "not mounted", not "broken" (issue #3).
943
+ // Print it on stderr so it can't be mistaken for output, and so --json stdout stays clean.
944
+ if (json && typeof json.warning === 'string' && json.warning) {
945
+ console.error(`hj: warning — ${json.warning}`)
946
+ }
947
+
941
948
  // Text format for supported subcommands (unless --json)
942
949
  if (!jsonOutput && subcommand === 'tree' && json.success && json.data) {
943
950
  console.log(formatTree(json.data, 0, { depth: body?.depth }))
package/bin/hj.mjs CHANGED
@@ -308,6 +308,18 @@ if (noLaunchIdx !== -1) {
308
308
  args.splice(noLaunchIdx, 1)
309
309
  }
310
310
 
311
+ // Parse --window <id> HERE so it works BEFORE the subcommand, like --port/--name/--token do.
312
+ // It was only handled after the subcommand, so the documented form `hj --window <id> eval …`
313
+ // died with "Unknown command: '--window'" — i.e. the escape hatch we tell people to use for a
314
+ // hidden/wrong tab didn't work in the shape the docs gave. Pulled out here and re-appended to
315
+ // the subcommand args below, so BOTH positions work.
316
+ let windowTarget = null
317
+ const windowIdx = args.indexOf('--window')
318
+ if (windowIdx !== -1 && args[windowIdx + 1]) {
319
+ windowTarget = args[windowIdx + 1]
320
+ args.splice(windowIdx, 2)
321
+ }
322
+
311
323
  // Did the shell explicitly target a private instance (--port / --name /
312
324
  // HALTIJA_PORT / HALTIJA_NAME / DEV_CHANNEL_PORT)? If so, this is a
313
325
  // project-owned server with a bring-your-own browser — auto-launching the
@@ -343,7 +355,9 @@ if (args.length === 1 && !isSubcommand(args[0]) && NOUN_DEFAULTS[args[0]]) {
343
355
  }
344
356
 
345
357
  const subcommand = args[0]
346
- const subArgs = args.slice(1).filter(a => a !== '--window' || true) // keep all args
358
+ let subArgs = args.slice(1)
359
+ // Re-attach a leading --window so cli-subcommand's existing handling sees it (both positions work).
360
+ if (windowTarget) subArgs = [...subArgs, '--window', windowTarget]
347
361
 
348
362
  // `hj where` — show which haltija server this shell is targeting and what
349
363
  // (if anything) is alive there. Pure client-side resolution plus a single
@@ -565,15 +565,12 @@ function launchApp(desktopDir, port) {
565
565
 
566
566
  // Resolve electron binary directly to avoid npx cache race conditions (ENOTEMPTY)
567
567
  const electronBinary = resolveElectronBinary()
568
+ // A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
569
+ // 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
570
+ const appEnv = privateMode ? { ...env } : { ...env, DEV_CHANNEL_PORT: String(port) }
568
571
  const child = electronBinary
569
- ? spawn(electronBinary, [desktopDir], {
570
- env: { ...env, DEV_CHANNEL_PORT: String(port) },
571
- stdio: 'inherit'
572
- })
573
- : spawn('npx', ['--yes', 'electron', desktopDir], {
574
- env: { ...env, DEV_CHANNEL_PORT: String(port) },
575
- stdio: 'inherit'
576
- })
572
+ ? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
573
+ : spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
577
574
 
578
575
  child.on('error', (err) => {
579
576
  console.error(red('Error:') + ` Failed to launch desktop app: ${err.message}`)
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.4.1'
6
+ export const HJ_VERSION = '1.5.0'
@@ -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.4.1";
23
+ export declare const VERSION = "1.5.0";
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.4.1";
2
+ var VERSION = "1.5.0";
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.4.1";
49
+ var VERSION = "1.5.0";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Did *focus* choose this tab, when the caller's *directory* should have?
3
+ *
4
+ * From issue #2. cwd routing (see `sessions.resolveByCwd`) gets an untargeted `hj` command to the
5
+ * right shared **server** — and then stops. Which **tab** on that server answers falls back to
6
+ * whatever is focused. So two agents, each correctly staying in its own project directory, can
7
+ * still drive each other's pages the moment both projects have a tab on the same shared 8700
8
+ * server; a human clicking into a second project's tab causes it solo.
9
+ *
10
+ * The obvious fix — rank tabs by "origin matches the cwd project" — has a trap: there is no
11
+ * reliable map from a tab's origin (a URL like `localhost:8787`) to a project **directory**. The
12
+ * dev-server port that injected the widget isn't always the project (proxies, multiple ports,
13
+ * static previews, `about:blank` mid-navigation). A ranking that's *usually* right would pick
14
+ * confidently and wrongly — reintroducing exactly the silent-misroute class we spent 1.4.0
15
+ * eliminating. A registry entry's `cwd` is per-**server**, not per-tab, so it can't sharpen this
16
+ * either.
17
+ *
18
+ * So we don't claim a mapping we don't have. We warn only about what is honestly knowable: the
19
+ * command was **not** pinned to a window, and this server spans **more than one origin**, so
20
+ * *focus* — not the caller's directory — chose which page answered. That's the honest half of the
21
+ * issue's "warn when the focused tab's origin doesn't match the cwd server's directory", and it
22
+ * composes with the hidden-tab warning (issue #3): one says the tab was asleep, this says the
23
+ * wrong tab may have been picked. Preference/ranking waits for a mapping that can justify itself.
24
+ */
25
+ /** The bits of a tracked window this decision needs. */
26
+ export interface FocusWindowInfo {
27
+ id: string;
28
+ url?: string;
29
+ title?: string;
30
+ /** 'tab' | 'popup' | 'iframe' (older widgets may omit it — treated as a tab). */
31
+ windowType?: string;
32
+ }
33
+ /** A tab's origin for grouping — `about:blank`, opaque, and unparseable URLs fall back to the raw
34
+ * string so they still form an honest distinct bucket rather than silently collapsing together. */
35
+ export declare function originOf(url: string | undefined | null): string | null;
36
+ /**
37
+ * A warning to attach when an *untargeted* command was answered by the focused tab while the
38
+ * server hosts tabs from more than one origin — i.e. focus, not the caller's directory, chose the
39
+ * page. Returns null when the caller pinned a window, when we don't know what answered, or when
40
+ * every tab shares one origin (no ambiguity to flag).
41
+ */
42
+ export declare function ambiguousFocusWarning(opts: {
43
+ windows: FocusWindowInfo[];
44
+ sentToId: string | null | undefined;
45
+ /** True when the caller explicitly targeted a window (`--window` / `?window=`). */
46
+ wasTargeted: boolean;
47
+ }): string | null;
package/dist/hj.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // haltija-cli:do-not-edit v1.4.1
2
+ // haltija-cli:do-not-edit v1.5.0
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.4.1";
759
+ var HJ_VERSION = "1.5.0";
760
760
 
761
761
  // bin/semver.mjs
762
762
  function parseVersion(v) {
@@ -1628,6 +1628,9 @@ async function doRequest(url, method, body, context = {}) {
1628
1628
  const contentType = resp.headers.get("content-type") || "";
1629
1629
  if (contentType.includes("application/json")) {
1630
1630
  const json = await resp.json();
1631
+ if (json && typeof json.warning === "string" && json.warning) {
1632
+ console.error(`hj: warning — ${json.warning}`);
1633
+ }
1631
1634
  if (!jsonOutput && subcommand === "tree" && json.success && json.data) {
1632
1635
  console.log(formatTree(json.data, 0, { depth: body?.depth }));
1633
1636
  } else if (!jsonOutput && subcommand === "events" && (json.events || Array.isArray(json))) {
@@ -2134,6 +2137,12 @@ if (noLaunchIdx !== -1) {
2134
2137
  noLaunch = true;
2135
2138
  args.splice(noLaunchIdx, 1);
2136
2139
  }
2140
+ var windowTarget = null;
2141
+ var windowIdx = args.indexOf("--window");
2142
+ if (windowIdx !== -1 && args[windowIdx + 1]) {
2143
+ windowTarget = args[windowIdx + 1];
2144
+ args.splice(windowIdx, 2);
2145
+ }
2137
2146
  var explicitTarget = portSource !== "8700 (default)";
2138
2147
  if (args.length >= 2 && isSubcommand(`${args[0]}-${args[1]}`)) {
2139
2148
  args.splice(0, 2, `${args[0]}-${args[1]}`);
@@ -2152,7 +2161,9 @@ if (args.length === 1 && !isSubcommand(args[0]) && NOUN_DEFAULTS[args[0]]) {
2152
2161
  args[0] = NOUN_DEFAULTS[args[0]];
2153
2162
  }
2154
2163
  var subcommand = args[0];
2155
- var subArgs = args.slice(1).filter((a) => a !== "--window" || true);
2164
+ var subArgs = args.slice(1);
2165
+ if (windowTarget)
2166
+ subArgs = [...subArgs, "--window", windowTarget];
2156
2167
  if (subcommand === "where") {
2157
2168
  await runWhere(port, portSource, subArgs.includes("--json"));
2158
2169
  process.exit(0);
package/dist/index.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.4.1";
677
+ var VERSION = "1.5.0";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -3097,7 +3097,7 @@ var COMPONENT_JS = `(() => {
3097
3097
  });
3098
3098
 
3099
3099
  // src/version.ts
3100
- var VERSION = "1.4.1";
3100
+ var VERSION = "1.5.0";
3101
3101
 
3102
3102
  // src/text-selector.ts
3103
3103
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -15226,6 +15226,52 @@ function isHaltijaProcess(pid) {
15226
15226
  }
15227
15227
  }
15228
15228
 
15229
+ // src/tab-liveness.ts
15230
+ function hiddenTabWarning(win) {
15231
+ if (!win)
15232
+ return null;
15233
+ if (win.active !== false)
15234
+ return null;
15235
+ const which = win.title ? `"${win.title}"` : win.id;
15236
+ return `The tab that answered (${which}) reports it is HIDDEN \u2014 backgrounded, minimized, ` + `behind another window, or the display is asleep. Browsers stop requestAnimationFrame and ` + `throttle timers in a hidden tab, so anything mounted by rAF/IntersectionObserver may never ` + `have run: THIS RESULT CAN BE PLAUSIBLE BUT WRONG (an empty selector here means "not mounted ` + `yet", not "broken"). Bring the tab to the front, or target a visible one with --window <id>.`;
15237
+ }
15238
+
15239
+ // src/focus-ambiguity.ts
15240
+ function isTopLevelTab(w) {
15241
+ return w.windowType !== "iframe" && w.windowType !== "popup";
15242
+ }
15243
+ function originOf(url) {
15244
+ if (!url)
15245
+ return null;
15246
+ try {
15247
+ const origin = new URL(url).origin;
15248
+ return origin === "null" ? url : origin;
15249
+ } catch {
15250
+ return url;
15251
+ }
15252
+ }
15253
+ function ambiguousFocusWarning(opts) {
15254
+ const { windows: windows2, sentToId, wasTargeted } = opts;
15255
+ if (wasTargeted)
15256
+ return null;
15257
+ if (!sentToId)
15258
+ return null;
15259
+ const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
15260
+ const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
15261
+ if (distinctOrigins.size < 2)
15262
+ return null;
15263
+ const chosen = withOrigin.find((w) => w.id === sentToId);
15264
+ const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
15265
+ const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
15266
+ const MAX = 4;
15267
+ const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
15268
+ `);
15269
+ const more = others.length > MAX ? `
15270
+ \u2026and ${others.length - MAX} more` : "";
15271
+ return `This command was NOT pinned to a window and this server has tabs from ` + `${distinctOrigins.size} different origins, so *focus* \u2014 not your working directory \u2014 chose ` + `which tab answered (${chosenWhere}). A tab you didn't mean (another project's page on this ` + `shared server, or one a human just clicked into) can silently receive the command. If you ` + `meant a different page, pin it:
15272
+ ${pins}${more}`;
15273
+ }
15274
+
15229
15275
  // src/server.ts
15230
15276
  init_terminal();
15231
15277
 
@@ -16396,10 +16442,24 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16396
16442
  pendingResponses.delete(id);
16397
16443
  resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
16398
16444
  }, timeoutMs);
16399
- pendingResponses.set(id, { resolve, timeout });
16445
+ let sentTo = null;
16446
+ const resolveWithLiveness = (res) => {
16447
+ const hidden = hiddenTabWarning(sentTo);
16448
+ const ambiguous = ambiguousFocusWarning({
16449
+ windows: Array.from(windows2.values()),
16450
+ sentToId: sentTo?.id,
16451
+ wasTargeted: !!windowId
16452
+ });
16453
+ const warning = [hidden, ambiguous].filter(Boolean).join(`
16454
+
16455
+ `);
16456
+ resolve(warning ? { ...res, warning } : res);
16457
+ };
16458
+ pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
16400
16459
  if (windowId) {
16401
16460
  const win = windows2.get(windowId);
16402
16461
  if (win) {
16462
+ sentTo = win;
16403
16463
  win.ws.send(JSON.stringify(msg));
16404
16464
  } else {
16405
16465
  clearTimeout(timeout);
@@ -16408,13 +16468,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16408
16468
  }
16409
16469
  } else if (focusedWindowId && windows2.has(focusedWindowId)) {
16410
16470
  const focusedWin = windows2.get(focusedWindowId);
16471
+ sentTo = focusedWin;
16411
16472
  focusedWin.ws.send(JSON.stringify(msg));
16412
16473
  } else {
16413
16474
  const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
16414
16475
  if (activeWindows.length > 0) {
16476
+ sentTo = activeWindows[0];
16415
16477
  activeWindows[0].ws.send(JSON.stringify(msg));
16416
16478
  } else if (windows2.size > 0) {
16417
16479
  const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
16480
+ sentTo = mostRecent;
16418
16481
  mostRecent.ws.send(JSON.stringify(msg));
16419
16482
  } else {
16420
16483
  clearTimeout(timeout);
@@ -16798,6 +16861,7 @@ async function handleRest(req) {
16798
16861
  title: w.title?.slice(0, 50) || "(untitled)",
16799
16862
  url: w.url,
16800
16863
  focused: w.id === focusedWindowId,
16864
+ hidden: w.active === false,
16801
16865
  recording: activeRecordingSessions.has(w.id)
16802
16866
  }));
16803
16867
  const activeRecordings = activeRecordingSessions.size;
package/dist/server.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.4.1";
677
+ var VERSION = "1.5.0";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -3097,7 +3097,7 @@ var COMPONENT_JS = `(() => {
3097
3097
  });
3098
3098
 
3099
3099
  // src/version.ts
3100
- var VERSION = "1.4.1";
3100
+ var VERSION = "1.5.0";
3101
3101
 
3102
3102
  // src/text-selector.ts
3103
3103
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -15226,6 +15226,52 @@ function isHaltijaProcess(pid) {
15226
15226
  }
15227
15227
  }
15228
15228
 
15229
+ // src/tab-liveness.ts
15230
+ function hiddenTabWarning(win) {
15231
+ if (!win)
15232
+ return null;
15233
+ if (win.active !== false)
15234
+ return null;
15235
+ const which = win.title ? `"${win.title}"` : win.id;
15236
+ return `The tab that answered (${which}) reports it is HIDDEN \u2014 backgrounded, minimized, ` + `behind another window, or the display is asleep. Browsers stop requestAnimationFrame and ` + `throttle timers in a hidden tab, so anything mounted by rAF/IntersectionObserver may never ` + `have run: THIS RESULT CAN BE PLAUSIBLE BUT WRONG (an empty selector here means "not mounted ` + `yet", not "broken"). Bring the tab to the front, or target a visible one with --window <id>.`;
15237
+ }
15238
+
15239
+ // src/focus-ambiguity.ts
15240
+ function isTopLevelTab(w) {
15241
+ return w.windowType !== "iframe" && w.windowType !== "popup";
15242
+ }
15243
+ function originOf(url) {
15244
+ if (!url)
15245
+ return null;
15246
+ try {
15247
+ const origin = new URL(url).origin;
15248
+ return origin === "null" ? url : origin;
15249
+ } catch {
15250
+ return url;
15251
+ }
15252
+ }
15253
+ function ambiguousFocusWarning(opts) {
15254
+ const { windows: windows2, sentToId, wasTargeted } = opts;
15255
+ if (wasTargeted)
15256
+ return null;
15257
+ if (!sentToId)
15258
+ return null;
15259
+ const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
15260
+ const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
15261
+ if (distinctOrigins.size < 2)
15262
+ return null;
15263
+ const chosen = withOrigin.find((w) => w.id === sentToId);
15264
+ const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
15265
+ const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
15266
+ const MAX = 4;
15267
+ const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
15268
+ `);
15269
+ const more = others.length > MAX ? `
15270
+ \u2026and ${others.length - MAX} more` : "";
15271
+ return `This command was NOT pinned to a window and this server has tabs from ` + `${distinctOrigins.size} different origins, so *focus* \u2014 not your working directory \u2014 chose ` + `which tab answered (${chosenWhere}). A tab you didn't mean (another project's page on this ` + `shared server, or one a human just clicked into) can silently receive the command. If you ` + `meant a different page, pin it:
15272
+ ${pins}${more}`;
15273
+ }
15274
+
15229
15275
  // src/server.ts
15230
15276
  init_terminal();
15231
15277
 
@@ -16396,10 +16442,24 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16396
16442
  pendingResponses.delete(id);
16397
16443
  resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
16398
16444
  }, timeoutMs);
16399
- pendingResponses.set(id, { resolve, timeout });
16445
+ let sentTo = null;
16446
+ const resolveWithLiveness = (res) => {
16447
+ const hidden = hiddenTabWarning(sentTo);
16448
+ const ambiguous = ambiguousFocusWarning({
16449
+ windows: Array.from(windows2.values()),
16450
+ sentToId: sentTo?.id,
16451
+ wasTargeted: !!windowId
16452
+ });
16453
+ const warning = [hidden, ambiguous].filter(Boolean).join(`
16454
+
16455
+ `);
16456
+ resolve(warning ? { ...res, warning } : res);
16457
+ };
16458
+ pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
16400
16459
  if (windowId) {
16401
16460
  const win = windows2.get(windowId);
16402
16461
  if (win) {
16462
+ sentTo = win;
16403
16463
  win.ws.send(JSON.stringify(msg));
16404
16464
  } else {
16405
16465
  clearTimeout(timeout);
@@ -16408,13 +16468,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16408
16468
  }
16409
16469
  } else if (focusedWindowId && windows2.has(focusedWindowId)) {
16410
16470
  const focusedWin = windows2.get(focusedWindowId);
16471
+ sentTo = focusedWin;
16411
16472
  focusedWin.ws.send(JSON.stringify(msg));
16412
16473
  } else {
16413
16474
  const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
16414
16475
  if (activeWindows.length > 0) {
16476
+ sentTo = activeWindows[0];
16415
16477
  activeWindows[0].ws.send(JSON.stringify(msg));
16416
16478
  } else if (windows2.size > 0) {
16417
16479
  const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
16480
+ sentTo = mostRecent;
16418
16481
  mostRecent.ws.send(JSON.stringify(msg));
16419
16482
  } else {
16420
16483
  clearTimeout(timeout);
@@ -16798,6 +16861,7 @@ async function handleRest(req) {
16798
16861
  title: w.title?.slice(0, 50) || "(untitled)",
16799
16862
  url: w.url,
16800
16863
  focused: w.id === focusedWindowId,
16864
+ hidden: w.active === false,
16801
16865
  recording: activeRecordingSessions.has(w.id)
16802
16866
  }));
16803
16867
  const activeRecordings = activeRecordingSessions.size;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Is the tab we just talked to actually awake?
3
+ *
4
+ * From issue #3, and it is the sharpest failure this tool has: a hidden tab **answers**. Run
5
+ * `hj eval 'document.querySelectorAll("tosi-b3d").length'` against a backgrounded tab and you get
6
+ * `0` — not a timeout, not an error. A confident, wrong number. The custom elements are
7
+ * registered and the static markup rendered, but browsers stop `requestAnimationFrame` and
8
+ * throttle timers in a hidden tab, so anything mounted by rAF / IntersectionObserver never runs.
9
+ * The page looks **broken** when it is merely **asleep**, and the reporter burned several rounds
10
+ * diagnosing a component bug that didn't exist.
11
+ *
12
+ * This is the instrument lying in its most dangerous form: not silence, but a plausible answer.
13
+ * So when we route a command to a tab that has told us it is hidden, we say so alongside the
14
+ * result rather than letting the number speak for itself.
15
+ *
16
+ * **Why not `lastSeen` staleness?** The reporter suggested it, and the data does go stale — but
17
+ * there is no periodic heartbeat: `lastSeen` only advances on navigation/visibility events. So an
18
+ * idle-but-perfectly-healthy tab looks exactly as stale as a sleeping one, and labelling on it
19
+ * would add a NEW false signal to fix a lying one. `active` is the honest signal: the widget sets
20
+ * it from `document.visibilityState` on `visibilitychange`, so `active === false` means the tab
21
+ * itself reported being hidden.
22
+ */
23
+ /** The bits of a tracked window this decision needs. */
24
+ export interface TabLivenessInfo {
25
+ id: string;
26
+ title?: string;
27
+ /** False when the tab reported itself hidden (visibilitychange → hidden). */
28
+ active?: boolean;
29
+ }
30
+ /**
31
+ * A warning to attach to a result that came from a hidden tab, or null when the tab is awake
32
+ * (or we have no basis to claim otherwise — `active` undefined means the tab never reported,
33
+ * and we do not invent a warning we can't support).
34
+ */
35
+ export declare function hiddenTabWarning(win: TabLivenessInfo | null | undefined): string | null;
package/dist/types.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface DevResponse {
26
26
  data?: any;
27
27
  error?: string;
28
28
  timestamp: number;
29
+ /**
30
+ * Set when the result is real but may be MISLEADING — currently: it came from a tab that
31
+ * reported itself hidden, where rAF/timers are throttled so rAF-driven content may never have
32
+ * mounted. The command succeeded; the number may still be wrong. See src/tab-liveness.ts.
33
+ */
34
+ warning?: string;
29
35
  }
30
36
  export interface DomQueryRequest {
31
37
  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.4.1";
11
+ export declare const VERSION = "1.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
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",