haltija 1.4.0 → 1.4.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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1
4
+
5
+ Five cross-project bugs, all of the same shape: **haltija reaching out and disrupting a healthy
6
+ peer.** If you run more than one project on a machine, this is the release that stops your
7
+ browser channel vanishing.
8
+
9
+ ### Behavior changes (no API breaks)
10
+
11
+ Nothing was removed or renamed — no endpoint, export, or flag — so nothing should fail to compile
12
+ or resolve. Two *runtime* behaviors changed, and in both the old behavior was the bug:
13
+
14
+ - **The desktop app attaches to an existing server instead of replacing it.** If you relied on a
15
+ launch always giving you a pristine embedded server, set `HALTIJA_SERVER_MODE=builtin`.
16
+ - **`--https` (https-only) now exits if it cannot bind its port**, instead of silently starting on
17
+ an ephemeral one. The old "success" produced a channel no widget could reach.
18
+
19
+ ### New: `--private` — isolated automation instances ([#1](https://github.com/tonioloewald/haltija/issues/1))
20
+
21
+ haltija plays two roles that were conflated. A **shared interactive** browser on the default port
22
+ is a feature — whatever window is focused is what `hj` drives, across projects. But **ephemeral
23
+ automation** (a test lane that spawns a browser, drives fixed pages, and exits) was consulting
24
+ that shared server and, if any was reachable, *adopting and navigating it* — so one project's
25
+ doc-test lane yanked another project's live browser to different pages, and then failed on a
26
+ timeout. Intermittent and baffling, because it only bit when a foreign haltija happened to be up.
27
+
28
+ `haltija --private` (pair with `--headless`) is isolated by construction:
29
+
30
+ - binds an **ephemeral port, never 8700** — it can't collide with or be mistaken for the shared server;
31
+ - is **not registered** in the shared registry, so interactive `hj` / cwd-routing can't adopt it;
32
+ - **never reaches out** — it retires nothing and touches no other server;
33
+ - **reports its address** on stdout (`HALTIJA_PRIVATE_READY {json}`) and to `--port-file` — since
34
+ it's not in the registry, that's how you find it.
35
+
36
+ A consumer's test lane should request a private instance and drive *that* by the port it reports,
37
+ instead of an unscoped `hj windows` check that races whatever else is on the machine.
38
+
39
+ ### Fixed: the desktop app killed other projects' channels
40
+
41
+ Its default was to stop any server on 8700/8701 and start fresh — so launching the app (`bunx
42
+ haltija`, an `hj` auto-launch, `--ci`, the integration test) silently took down a live channel
43
+ another project was using, and made its widget vanish. It now **attaches to a healthy existing
44
+ server and says so**. Force the old behavior with `HALTIJA_SERVER_MODE=builtin`.
45
+
46
+ ### Fixed: a half-dead `--both` channel (HTTPS silently on the wrong port)
47
+
48
+ When the HTTPS port was busy (a fast restart racing the previous server), the HTTPS side quietly
49
+ fell back to an **ephemeral** port. But a widget on an https page connects to the *known* port —
50
+ so 8701 sat empty, the page couldn't connect, and the server looked healthy because HTTP was fine.
51
+ HTTPS now retries its intended port and, failing that, **fails loudly** rather than relocating;
52
+ the startup banner never advertises a port it didn't bind.
53
+
54
+ ### Fixed: silent HTTP port relocation
55
+
56
+ When the wanted HTTP port was taken, the server bound an ephemeral one without a word — so a
57
+ caller probing a fixed port had no idea why nothing was there. It now says
58
+ `<port> was taken; bound HTTP on <n> instead. Find it with \`hj where\`.`
59
+
60
+ ### Fixed: the test suite disrupted other servers
61
+
62
+ `bun test` bound fixed 87xx ports — the range real servers live in — and on a collision would
63
+ `POST /shutdown` whatever was there, including another project's channel. The suite now uses
64
+ high, per-process-unique ports and can never stop a server it didn't start.
65
+
66
+ ### Docs
67
+
68
+ A "tab that reads as unreachable" troubleshooting section in `DOCS.md` and `llms.txt`: a hidden,
69
+ backgrounded, minimized, or occluded tab (and an active WebXR session) suspends
70
+ `requestAnimationFrame` and throttles timers, so the tab can stop answering even though the page
71
+ is fine. Bring it forward, or target it explicitly with `hj --window <id>`.
72
+
73
+
3
74
  ## 1.4.0
4
75
 
5
76
  **`hj` now routes to the server that owns your current directory.** If you run more than one
@@ -56,10 +56,17 @@ let navigateRequestId = 0
56
56
 
57
57
  const DEFAULT_PREFS = {
58
58
  // Server startup behavior:
59
- // 'builtin' - Always kill existing server and start fresh (default)
60
- // 'external' - Never start server, expect one running externally
61
- // 'auto' - Use existing if found, else start embedded
62
- serverMode: 'builtin',
59
+ // 'auto' - Use an existing healthy server if one is on the port, else start embedded (DEFAULT)
60
+ // 'builtin' - Always stop any existing server and start fresh (the old default)
61
+ // 'external' - Never start a server, expect one running externally
62
+ //
63
+ // Default is 'auto', NOT 'builtin'. On a machine running more than one project, 8700/8701 are
64
+ // shared: another project may legitimately have a live channel there (e.g. `haltija --server
65
+ // --both`). 'builtin' treated that channel as a "zombie" and killed it to start fresh — so
66
+ // launching the desktop app (or `bunx haltija`, or an `hj` auto-launch) silently took down
67
+ // another project's channel and made its widget vanish. 'auto' reuses a healthy server instead.
68
+ // Force the old behavior with HALTIJA_SERVER_MODE=builtin when you specifically want your own.
69
+ serverMode: process.env.HALTIJA_SERVER_MODE || 'auto',
63
70
  }
64
71
 
65
72
  // Active prefs (will be overwritten by persisted prefs when IPC is wired up)
@@ -67,6 +74,10 @@ const prefs = { ...DEFAULT_PREFS }
67
74
 
68
75
  let mainWindow = null
69
76
  const embeddedServers = []
77
+ // True when this app attached to a server it did not start (auto mode found one already on the
78
+ // port). Surfaced to the window so the user knows they're driving a reused, possibly-foreign server.
79
+ let reusedExternalServer = false
80
+ let reusedServerBanner = ''
70
81
 
71
82
  // ============================================
72
83
  // MCP Setup for Claude Desktop
@@ -299,6 +310,15 @@ function createWindow() {
299
310
  // Load the shell UI
300
311
  mainWindow.loadFile('index.html')
301
312
 
313
+ // If we attached to a server we didn't start, tell the renderer so it can surface it in the
314
+ // UI. Best-effort: harmless if the renderer doesn't handle 'server-reused' yet (that UI is a
315
+ // filed follow-up); the reuse is also announced on the app's console output regardless.
316
+ if (reusedExternalServer) {
317
+ mainWindow.webContents.once('did-finish-load', () => {
318
+ try { mainWindow.webContents.send('server-reused', reusedServerBanner) } catch {}
319
+ })
320
+ }
321
+
302
322
  // Open DevTools in development
303
323
  if (process.env.NODE_ENV === 'development') {
304
324
  mainWindow.webContents.openDevTools()
@@ -1399,9 +1419,15 @@ async function ensureServer() {
1399
1419
  return false
1400
1420
 
1401
1421
  case 'auto':
1402
- // Use existing if found, else start embedded
1422
+ // Use existing if found, else start embedded. Reusing is the whole point: it means the
1423
+ // app does NOT kill a channel another project may be running on 8700/8701.
1403
1424
  if (running) {
1404
- console.log('[Haltija Desktop] Using existing server at', HALTIJA_SERVER)
1425
+ // Surface this prominently — the user should know the app attached to a server it did
1426
+ // NOT start (possibly a different version/config), rather than silently assuming its own.
1427
+ const banner = `Attached to an existing haltija server at ${HALTIJA_SERVER} (did not start my own; set HALTIJA_SERVER_MODE=builtin to force a fresh one)`
1428
+ console.log(`[Haltija Desktop] ${banner}`)
1429
+ reusedExternalServer = true
1430
+ reusedServerBanner = banner
1405
1431
  return true
1406
1432
  }
1407
1433
  console.log('[Haltija Desktop] No server found, starting embedded')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.4.0",
3
+ "version": "1.4.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.4.0";
49
+ var VERSION = "1.4.1";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -59,6 +59,11 @@ Options:
59
59
  HALTIJA_NAME=foo hj <cmd> can resolve back to its port
60
60
  --force, -f Restart even if server already running
61
61
  --wait-ready Block until server + browser are ready (for scripts)
62
+ --private Isolated automation instance: own server on an EPHEMERAL port (never
63
+ 8700), own browser, torn down with the run. Never sees/adopts/navigates
64
+ the shared interactive browser. Reports its address on stdout
65
+ (HALTIJA_PRIVATE_READY {json}) and to --port-file. Pair with --headless.
66
+ --port-file <p> Write the (private) server's bound address as JSON to <p> when ready.
62
67
  --setup-mcp Configure Claude Desktop MCP integration
63
68
  --setup-mcp-check Check MCP configuration status
64
69
  --setup-mcp-remove Remove Haltija from Claude Desktop config
@@ -408,6 +413,43 @@ const explicitServer = args.includes('--server')
408
413
  const explicitApp = args.includes('--app') || ciMode // CI mode uses Electron app
409
414
  const waitReady = args.includes('--wait-ready') || ciMode
410
415
 
416
+ // --private: the "ephemeral automation" role (issue #1). An isolated instance — own server on an
417
+ // EPHEMERAL port (never 8700), its own browser, torn down with the run — that must never see,
418
+ // adopt, or navigate the shared interactive browser. It reports its address on stdout
419
+ // (HALTIJA_PRIVATE_READY {json}) and to --port-file, so the caller drives THAT, with no `hj
420
+ // windows` race against whatever else is on the machine. Sets the isolation env for the server.
421
+ const privateMode = args.includes('--private')
422
+ const portFileIdx = args.indexOf('--port-file')
423
+ let privatePortFile = null
424
+ if (privateMode) {
425
+ // Mutate `env` (the object passed to the spawned server), NOT process.env — `env` was already
426
+ // snapshotted above, so setting process.env here would never reach the server.
427
+ env.HALTIJA_PRIVATE = '1'
428
+ env.HALTIJA_NO_RETIRE = '1' // never touch another server
429
+ env.HALTIJA_NO_INSTALL = '1' // never rewrite the shared ~/.local/bin/hj
430
+ delete env.HALTIJA_PORT // force ephemeral — never bind the shared default
431
+ delete env.DEV_CHANNEL_PORT
432
+ // The server writes its actual (ephemeral) address here on ready; we read it back to point our
433
+ // own browser at it, and the caller can read it too. Default to a temp file if none was given.
434
+ privatePortFile = (portFileIdx !== -1 && args[portFileIdx + 1])
435
+ ? args[portFileIdx + 1]
436
+ : join(tmpdir(), `haltija-private-${process.pid}.json`)
437
+ env.HALTIJA_PORT_FILE = privatePortFile
438
+ }
439
+
440
+ /** Poll the private instance's port-file until it reports its ephemeral port. */
441
+ async function discoverPrivatePort(portFile, timeout = 30000) {
442
+ const start = Date.now()
443
+ while (Date.now() - start < timeout) {
444
+ try {
445
+ const data = JSON.parse(readFileSync(portFile, 'utf-8'))
446
+ if (data && data.port) return data.port
447
+ } catch {}
448
+ await new Promise((r) => setTimeout(r, 200))
449
+ }
450
+ return null
451
+ }
452
+
411
453
  // CI mode sets ELECTRON_DISABLE_SANDBOX automatically (needed for containerized CI)
412
454
  if (ciMode && !process.env.ELECTRON_DISABLE_SANDBOX) {
413
455
  process.env.ELECTRON_DISABLE_SANDBOX = '1'
@@ -717,7 +759,13 @@ const tryBun = () => {
717
759
  // Start headless browser after server is ready
718
760
  if (headlessMode) {
719
761
  (async () => {
720
- const serverPort = env.DEV_CHANNEL_PORT || 8700
762
+ const serverPort = privateMode
763
+ ? await discoverPrivatePort(privatePortFile)
764
+ : (env.DEV_CHANNEL_PORT || 8700)
765
+ if (!serverPort) {
766
+ console.error('[tosijs-dev] Private server did not report its port within timeout')
767
+ process.exit(1)
768
+ }
721
769
  console.log('[tosijs-dev] Waiting for server to be ready...')
722
770
  const serverReady = await waitForServer(serverPort)
723
771
  if (serverReady) {
@@ -750,7 +798,13 @@ const tryNode = () => {
750
798
  // Start headless browser after server is ready
751
799
  if (headlessMode) {
752
800
  (async () => {
753
- const serverPort = env.DEV_CHANNEL_PORT || 8700
801
+ const serverPort = privateMode
802
+ ? await discoverPrivatePort(privatePortFile)
803
+ : (env.DEV_CHANNEL_PORT || 8700)
804
+ if (!serverPort) {
805
+ console.error('[tosijs-dev] Private server did not report its port within timeout')
806
+ process.exit(1)
807
+ }
754
808
  console.log('[tosijs-dev] Waiting for server to be ready...')
755
809
  const serverReady = await waitForServer(serverPort)
756
810
  if (serverReady) {
@@ -867,8 +921,10 @@ function killOnPort(port) {
867
921
  const port = env.DEV_CHANNEL_PORT || '8700'
868
922
  const forceRestart = args.includes('--force') || args.includes('-f')
869
923
 
870
- // Check for existing server before launching
871
- const existingServer = await checkExistingServer(port)
924
+ // A PRIVATE instance NEVER consults or adopts the shared server that adoption is the exact
925
+ // bug issue #1 is about, and it lived right here in our own launcher (it saw the shared 8700
926
+ // server and exited, "already running"). Private always starts its own on an ephemeral port.
927
+ const existingServer = privateMode ? { running: false } : await checkExistingServer(port)
872
928
  if (existingServer.running && !forceRestart) {
873
929
  console.log('')
874
930
  console.log(green('✓') + ` Haltija is already running on port ${port}` +
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.0'
6
+ export const HJ_VERSION = '1.4.1'
@@ -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.0";
23
+ export declare const VERSION = "1.4.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.4.0";
2
+ var VERSION = "1.4.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.4.0";
49
+ var VERSION = "1.4.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.4.0
2
+ // haltija-cli:do-not-edit v1.4.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.4.0";
759
+ var HJ_VERSION = "1.4.1";
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.4.0";
677
+ var VERSION = "1.4.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2897,6 +2897,18 @@ breaking; do not chase a page bug that isn't there. Known causes:
2897
2897
  e.g. write the values you care about somewhere your test/agent can read them (an
2898
2898
  in-page debug source, an in-headset stats panel), rather than issuing \`hj eval\` that
2899
2899
  round-trips through the suspended heartbeat.
2900
+ - **A tab that isn't visible on screen** \u2014 the common everyday case, and the one
2901
+ people hit without realizing. A backgrounded tab, a minimized window, or a window
2902
+ that is *maximized on another Space or fully occluded* (e.g. on macOS) all count as
2903
+ **hidden** (\`document.visibilityState === "hidden"\`). While hidden, browsers stop
2904
+ \`requestAnimationFrame\` entirely and throttle timers \u2014 and, on top of that, the
2905
+ Haltija widget deliberately **deactivates** a hidden tab so untargeted commands
2906
+ route to whichever tab is actually in front (focus-follows-visible-tab). So a page
2907
+ you can't see may not answer an untargeted \`hj eval\`/\`hj tree\`, and any
2908
+ \`rAF\`-driven state (scroll progress, animation frames) is frozen even if it does.
2909
+ Fixes: **bring the tab to the front**, or **target it explicitly** \u2014
2910
+ \`hj --window <id> eval \u2026\` (get the id from \`hj windows\`) reaches a specific tab
2911
+ regardless of visibility. Don't rely on rAF-driven values while a tab is hidden.
2900
2912
  - **A heavily throttled background tab, or a long blocking task** on the main thread \u2014
2901
2913
  same shape: the heartbeat can't run, so the tab looks dead until the thread frees up.
2902
2914
 
@@ -2925,6 +2937,13 @@ hj tree --no-launch # Skip auto-launch
2925
2937
  hj status # Info commands never trigger auto-launch
2926
2938
  \`\`\`
2927
2939
 
2940
+ **The desktop app REUSES an existing server rather than replacing it.** If a
2941
+ haltija server is already running on the port (e.g. another project started one
2942
+ with \`haltija --server --both\`), the app attaches to it and says so, instead of
2943
+ killing it to start its own \u2014 so launching the app (or an \`hj\` auto-launch) no
2944
+ longer takes down another project's channel. Set \`HALTIJA_SERVER_MODE=builtin\` to
2945
+ force the app to run its own fresh server instead.
2946
+
2928
2947
  ## CI / Headless
2929
2948
 
2930
2949
  \`\`\`bash
@@ -3020,6 +3039,7 @@ Or via a script tag (auto-injecting IIFE bundle served by the running server):
3020
3039
 
3021
3040
  ## Gotcha: a tab can read as unreachable while its main thread is suspended
3022
3041
  - The widget proves liveness with a heartbeat. If the page's main thread or its \`requestAnimationFrame\` loop is suspended, the heartbeat stalls and commands time out or report "no browser connected" \u2014 even though the PAGE is fine. It's the instrument going quiet, not a page bug.
3042
+ - **A tab that is not visible on screen** \u2014 backgrounded, minimized, or maximized on another Space / fully occluded (macOS) \u2014 counts as \`hidden\`. Browsers stop \`requestAnimationFrame\` and throttle timers while hidden, AND the widget deliberately deactivates a hidden tab so untargeted commands go to the front tab. So a page you can't see may not answer an untargeted \`hj eval\`/\`hj tree\`, and rAF-driven state (scroll/animation) is frozen. Fix: bring it to the front, or **target it explicitly** \u2014 \`hj --window <id> eval \u2026\` (id from \`hj windows\`) reaches a specific tab regardless of visibility.
3023
3043
  - **WebXR / immersive sessions suspend \`window.requestAnimationFrame\`** (the XR compositor drives frames instead), so a tab is unreachable *while immersive*. Drive state out through your own channel \u2014 write the values you care about somewhere your agent can read (a debug source, an in-headset stats panel) \u2014 rather than \`hj eval\` round-trips through the suspended heartbeat. A heavily throttled background tab or a long blocking task has the same shape.
3024
3044
  - A command hitting the WRONG page (not a dead one) is targeting, not liveness: run \`hj where\`.
3025
3045
 
@@ -3077,7 +3097,7 @@ var COMPONENT_JS = `(() => {
3077
3097
  });
3078
3098
 
3079
3099
  // src/version.ts
3080
- var VERSION = "1.4.0";
3100
+ var VERSION = "1.4.1";
3081
3101
 
3082
3102
  // src/text-selector.ts
3083
3103
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -16000,10 +16020,11 @@ function getMcpStatus() {
16000
16020
  var PRODUCT_NAME2 = "Haltija";
16001
16021
  var TAG_NAME = "haltija-dev";
16002
16022
  var LOG_PREFIX = "[haltija]";
16023
+ var IS_PRIVATE = process.env.HALTIJA_PRIVATE === "1";
16003
16024
  var PORT_PREFERENCE = process.env.HALTIJA_PORT || process.env.DEV_CHANNEL_PORT;
16004
- var PORT_IS_STRICT = !!PORT_PREFERENCE;
16005
- var PORT = parseInt(PORT_PREFERENCE || "8700");
16006
- var HTTPS_PORT = parseInt(process.env.DEV_CHANNEL_HTTPS_PORT || "8701");
16025
+ var PORT_IS_STRICT = !!PORT_PREFERENCE && !IS_PRIVATE;
16026
+ var PORT = IS_PRIVATE ? 0 : parseInt(PORT_PREFERENCE || "8700");
16027
+ var HTTPS_PORT = IS_PRIVATE ? 0 : parseInt(process.env.DEV_CHANNEL_HTTPS_PORT || "8701");
16007
16028
  var INSTANCE_NAME = process.env.HALTIJA_NAME || "";
16008
16029
  var SNAPSHOTS_DIR = process.env.DEV_CHANNEL_SNAPSHOTS_DIR || null;
16009
16030
  var DOCS_DIR = process.env.DEV_CHANNEL_DOCS_DIR || null;
@@ -18993,6 +19014,8 @@ var serverConfig = {
18993
19014
  }
18994
19015
  };
18995
19016
  async function freePort(port) {
19017
+ if (process.env.HALTIJA_NO_RETIRE === "1")
19018
+ return false;
18996
19019
  if (listenerPidsOnPort(port).length === 0)
18997
19020
  return false;
18998
19021
  const probe = await probePort(port);
@@ -19080,6 +19103,8 @@ async function probePort(port) {
19080
19103
  async function retireLegacyServers() {
19081
19104
  if (process.env.HALTIJA_NO_RETIRE === "1")
19082
19105
  return;
19106
+ if (IS_PRIVATE)
19107
+ return;
19083
19108
  const registryPorts = list().map((e) => e.port);
19084
19109
  const ports = candidatePorts({ defaults: [8700, 8701, PORT], registryPorts });
19085
19110
  let stopped = false;
@@ -19118,14 +19143,20 @@ if (USE_HTTP) {
19118
19143
  });
19119
19144
  } catch (err) {
19120
19145
  if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
19146
+ const wantedHttpPort = PORT;
19121
19147
  if (PORT_IS_STRICT) {
19122
19148
  if (await freePort(PORT)) {
19123
19149
  httpServer = Bun.serve({ port: PORT, ...serverConfig });
19124
19150
  } else {
19151
+ console.error("");
19152
+ console.error(` \u26A0\uFE0F Could not bind the HTTP port ${wantedHttpPort} you asked for \u2014 it is in use.`);
19153
+ console.error(` Free it, or start on another port (--port N / HALTIJA_PORT).`);
19154
+ console.error("");
19125
19155
  throw err;
19126
19156
  }
19127
19157
  } else {
19128
19158
  httpServer = Bun.serve({ port: 0, ...serverConfig });
19159
+ console.warn(` [port] ${wantedHttpPort} was taken; bound HTTP on ${httpServer.port} instead. Find it with \`hj where\` (or read ~/.haltija/servers/).`);
19129
19160
  }
19130
19161
  } else {
19131
19162
  throw err;
@@ -19138,29 +19169,63 @@ if (USE_HTTPS) {
19138
19169
  cert: readFileSync5(certPath),
19139
19170
  key: readFileSync5(keyPath)
19140
19171
  };
19172
+ const wantedHttpsPort = HTTPS_PORT;
19173
+ const bindHttps = () => Bun.serve({ port: wantedHttpsPort, tls, ...serverConfig });
19141
19174
  try {
19142
- httpsServer = Bun.serve({ port: HTTPS_PORT, tls, ...serverConfig });
19175
+ httpsServer = bindHttps();
19143
19176
  } catch (err) {
19144
19177
  if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
19145
- if (PORT_IS_STRICT) {
19146
- if (await freePort(HTTPS_PORT)) {
19147
- httpsServer = Bun.serve({ port: HTTPS_PORT, tls, ...serverConfig });
19148
- } else {
19149
- throw err;
19150
- }
19178
+ if (PORT_IS_STRICT && await freePort(wantedHttpsPort)) {
19179
+ httpsServer = bindHttps();
19151
19180
  } else {
19152
- httpsServer = Bun.serve({ port: 0, tls, ...serverConfig });
19181
+ for (let attempt = 0;attempt < 5 && !httpsServer; attempt++) {
19182
+ Bun.sleepSync(400);
19183
+ try {
19184
+ httpsServer = bindHttps();
19185
+ } catch {}
19186
+ }
19187
+ if (!httpsServer) {
19188
+ console.error("");
19189
+ console.error(` \u26A0\uFE0F HTTPS could not bind its port ${wantedHttpsPort} \u2014 it is still in use`);
19190
+ console.error(` (a previous server in TIME_WAIT, or another process holding it).`);
19191
+ console.error(` A widget on an https page connects to :${wantedHttpsPort}, so it will`);
19192
+ console.error(` NOT connect until this is free. Haltija is NOT silently relocating it.`);
19193
+ console.error(` Fix: wait a few seconds and restart, or free port ${wantedHttpsPort}.`);
19194
+ console.error("");
19195
+ if (!USE_HTTP) {
19196
+ throw err;
19197
+ }
19198
+ }
19153
19199
  }
19154
19200
  } else {
19155
19201
  throw err;
19156
19202
  }
19157
19203
  }
19158
- HTTPS_PORT = httpsServer.port ?? HTTPS_PORT;
19204
+ if (httpsServer)
19205
+ HTTPS_PORT = httpsServer.port ?? HTTPS_PORT;
19159
19206
  }
19160
- var httpUrl = USE_HTTP ? `http://localhost:${PORT}` : null;
19161
- var httpsUrl = USE_HTTPS ? `https://localhost:${HTTPS_PORT}` : null;
19207
+ var httpUrl = httpServer ? `http://localhost:${PORT}` : null;
19208
+ var httpsUrl = httpsServer ? `https://localhost:${HTTPS_PORT}` : null;
19162
19209
  var primaryUrl = httpsUrl || httpUrl;
19163
- var CAN_BE_REGISTERED = USE_HTTP;
19210
+ if (IS_PRIVATE) {
19211
+ const ready = {
19212
+ port: PORT,
19213
+ httpsPort: httpsServer ? HTTPS_PORT : null,
19214
+ url: httpUrl,
19215
+ httpsUrl,
19216
+ pid: process.pid
19217
+ };
19218
+ console.log(`HALTIJA_PRIVATE_READY ${JSON.stringify(ready)}`);
19219
+ const portFile = process.env.HALTIJA_PORT_FILE;
19220
+ if (portFile) {
19221
+ try {
19222
+ writeFileSync3(portFile, JSON.stringify(ready));
19223
+ } catch (err) {
19224
+ console.error(`${LOG_PREFIX} could not write HALTIJA_PORT_FILE ${portFile}: ${err instanceof Error ? err.message : err}`);
19225
+ }
19226
+ }
19227
+ }
19228
+ var CAN_BE_REGISTERED = USE_HTTP && !IS_PRIVATE;
19164
19229
  var REGISTRY_NAME = !CAN_BE_REGISTERED ? "" : INSTANCE_NAME || (isDesktopApp ? "" : autoNameFor(PORT));
19165
19230
  if (!CAN_BE_REGISTERED && (INSTANCE_NAME || !isDesktopApp)) {
19166
19231
  console.log(`${LOG_PREFIX} HTTPS-only: not registering an instance (hj speaks HTTP; cwd routing and --name need an HTTP port)`);
@@ -19367,8 +19432,14 @@ if (USE_HTTPS && !httpUrl) {
19367
19432
  `);
19368
19433
  }
19369
19434
  if (MODE === "both") {
19370
- console.log(` Mode: both - Use HTTP (${PORT}) for HTTP sites, HTTPS (${HTTPS_PORT}) for HTTPS sites.
19435
+ if (httpsServer) {
19436
+ console.log(` Mode: both - Use HTTP (${PORT}) for HTTP sites, HTTPS (${HTTPS_PORT}) for HTTPS sites.
19371
19437
  `);
19438
+ } else {
19439
+ console.log(` Mode: both - \u26A0\uFE0F HTTP (${PORT}) is up but HTTPS is DOWN (see the warning above).`);
19440
+ console.log(` Pages served over https will not connect until HTTPS binds.
19441
+ `);
19442
+ }
19372
19443
  }
19373
19444
  var server = httpsServer || httpServer;
19374
19445
  // src/client.ts
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.0";
677
+ var VERSION = "1.4.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2897,6 +2897,18 @@ breaking; do not chase a page bug that isn't there. Known causes:
2897
2897
  e.g. write the values you care about somewhere your test/agent can read them (an
2898
2898
  in-page debug source, an in-headset stats panel), rather than issuing \`hj eval\` that
2899
2899
  round-trips through the suspended heartbeat.
2900
+ - **A tab that isn't visible on screen** \u2014 the common everyday case, and the one
2901
+ people hit without realizing. A backgrounded tab, a minimized window, or a window
2902
+ that is *maximized on another Space or fully occluded* (e.g. on macOS) all count as
2903
+ **hidden** (\`document.visibilityState === "hidden"\`). While hidden, browsers stop
2904
+ \`requestAnimationFrame\` entirely and throttle timers \u2014 and, on top of that, the
2905
+ Haltija widget deliberately **deactivates** a hidden tab so untargeted commands
2906
+ route to whichever tab is actually in front (focus-follows-visible-tab). So a page
2907
+ you can't see may not answer an untargeted \`hj eval\`/\`hj tree\`, and any
2908
+ \`rAF\`-driven state (scroll progress, animation frames) is frozen even if it does.
2909
+ Fixes: **bring the tab to the front**, or **target it explicitly** \u2014
2910
+ \`hj --window <id> eval \u2026\` (get the id from \`hj windows\`) reaches a specific tab
2911
+ regardless of visibility. Don't rely on rAF-driven values while a tab is hidden.
2900
2912
  - **A heavily throttled background tab, or a long blocking task** on the main thread \u2014
2901
2913
  same shape: the heartbeat can't run, so the tab looks dead until the thread frees up.
2902
2914
 
@@ -2925,6 +2937,13 @@ hj tree --no-launch # Skip auto-launch
2925
2937
  hj status # Info commands never trigger auto-launch
2926
2938
  \`\`\`
2927
2939
 
2940
+ **The desktop app REUSES an existing server rather than replacing it.** If a
2941
+ haltija server is already running on the port (e.g. another project started one
2942
+ with \`haltija --server --both\`), the app attaches to it and says so, instead of
2943
+ killing it to start its own \u2014 so launching the app (or an \`hj\` auto-launch) no
2944
+ longer takes down another project's channel. Set \`HALTIJA_SERVER_MODE=builtin\` to
2945
+ force the app to run its own fresh server instead.
2946
+
2928
2947
  ## CI / Headless
2929
2948
 
2930
2949
  \`\`\`bash
@@ -3020,6 +3039,7 @@ Or via a script tag (auto-injecting IIFE bundle served by the running server):
3020
3039
 
3021
3040
  ## Gotcha: a tab can read as unreachable while its main thread is suspended
3022
3041
  - The widget proves liveness with a heartbeat. If the page's main thread or its \`requestAnimationFrame\` loop is suspended, the heartbeat stalls and commands time out or report "no browser connected" \u2014 even though the PAGE is fine. It's the instrument going quiet, not a page bug.
3042
+ - **A tab that is not visible on screen** \u2014 backgrounded, minimized, or maximized on another Space / fully occluded (macOS) \u2014 counts as \`hidden\`. Browsers stop \`requestAnimationFrame\` and throttle timers while hidden, AND the widget deliberately deactivates a hidden tab so untargeted commands go to the front tab. So a page you can't see may not answer an untargeted \`hj eval\`/\`hj tree\`, and rAF-driven state (scroll/animation) is frozen. Fix: bring it to the front, or **target it explicitly** \u2014 \`hj --window <id> eval \u2026\` (id from \`hj windows\`) reaches a specific tab regardless of visibility.
3023
3043
  - **WebXR / immersive sessions suspend \`window.requestAnimationFrame\`** (the XR compositor drives frames instead), so a tab is unreachable *while immersive*. Drive state out through your own channel \u2014 write the values you care about somewhere your agent can read (a debug source, an in-headset stats panel) \u2014 rather than \`hj eval\` round-trips through the suspended heartbeat. A heavily throttled background tab or a long blocking task has the same shape.
3024
3044
  - A command hitting the WRONG page (not a dead one) is targeting, not liveness: run \`hj where\`.
3025
3045
 
@@ -3077,7 +3097,7 @@ var COMPONENT_JS = `(() => {
3077
3097
  });
3078
3098
 
3079
3099
  // src/version.ts
3080
- var VERSION = "1.4.0";
3100
+ var VERSION = "1.4.1";
3081
3101
 
3082
3102
  // src/text-selector.ts
3083
3103
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -16000,10 +16020,11 @@ function getMcpStatus() {
16000
16020
  var PRODUCT_NAME2 = "Haltija";
16001
16021
  var TAG_NAME = "haltija-dev";
16002
16022
  var LOG_PREFIX = "[haltija]";
16023
+ var IS_PRIVATE = process.env.HALTIJA_PRIVATE === "1";
16003
16024
  var PORT_PREFERENCE = process.env.HALTIJA_PORT || process.env.DEV_CHANNEL_PORT;
16004
- var PORT_IS_STRICT = !!PORT_PREFERENCE;
16005
- var PORT = parseInt(PORT_PREFERENCE || "8700");
16006
- var HTTPS_PORT = parseInt(process.env.DEV_CHANNEL_HTTPS_PORT || "8701");
16025
+ var PORT_IS_STRICT = !!PORT_PREFERENCE && !IS_PRIVATE;
16026
+ var PORT = IS_PRIVATE ? 0 : parseInt(PORT_PREFERENCE || "8700");
16027
+ var HTTPS_PORT = IS_PRIVATE ? 0 : parseInt(process.env.DEV_CHANNEL_HTTPS_PORT || "8701");
16007
16028
  var INSTANCE_NAME = process.env.HALTIJA_NAME || "";
16008
16029
  var SNAPSHOTS_DIR = process.env.DEV_CHANNEL_SNAPSHOTS_DIR || null;
16009
16030
  var DOCS_DIR = process.env.DEV_CHANNEL_DOCS_DIR || null;
@@ -18993,6 +19014,8 @@ var serverConfig = {
18993
19014
  }
18994
19015
  };
18995
19016
  async function freePort(port) {
19017
+ if (process.env.HALTIJA_NO_RETIRE === "1")
19018
+ return false;
18996
19019
  if (listenerPidsOnPort(port).length === 0)
18997
19020
  return false;
18998
19021
  const probe = await probePort(port);
@@ -19080,6 +19103,8 @@ async function probePort(port) {
19080
19103
  async function retireLegacyServers() {
19081
19104
  if (process.env.HALTIJA_NO_RETIRE === "1")
19082
19105
  return;
19106
+ if (IS_PRIVATE)
19107
+ return;
19083
19108
  const registryPorts = list().map((e) => e.port);
19084
19109
  const ports = candidatePorts({ defaults: [8700, 8701, PORT], registryPorts });
19085
19110
  let stopped = false;
@@ -19118,14 +19143,20 @@ if (USE_HTTP) {
19118
19143
  });
19119
19144
  } catch (err) {
19120
19145
  if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
19146
+ const wantedHttpPort = PORT;
19121
19147
  if (PORT_IS_STRICT) {
19122
19148
  if (await freePort(PORT)) {
19123
19149
  httpServer = Bun.serve({ port: PORT, ...serverConfig });
19124
19150
  } else {
19151
+ console.error("");
19152
+ console.error(` \u26A0\uFE0F Could not bind the HTTP port ${wantedHttpPort} you asked for \u2014 it is in use.`);
19153
+ console.error(` Free it, or start on another port (--port N / HALTIJA_PORT).`);
19154
+ console.error("");
19125
19155
  throw err;
19126
19156
  }
19127
19157
  } else {
19128
19158
  httpServer = Bun.serve({ port: 0, ...serverConfig });
19159
+ console.warn(` [port] ${wantedHttpPort} was taken; bound HTTP on ${httpServer.port} instead. Find it with \`hj where\` (or read ~/.haltija/servers/).`);
19129
19160
  }
19130
19161
  } else {
19131
19162
  throw err;
@@ -19138,29 +19169,63 @@ if (USE_HTTPS) {
19138
19169
  cert: readFileSync5(certPath),
19139
19170
  key: readFileSync5(keyPath)
19140
19171
  };
19172
+ const wantedHttpsPort = HTTPS_PORT;
19173
+ const bindHttps = () => Bun.serve({ port: wantedHttpsPort, tls, ...serverConfig });
19141
19174
  try {
19142
- httpsServer = Bun.serve({ port: HTTPS_PORT, tls, ...serverConfig });
19175
+ httpsServer = bindHttps();
19143
19176
  } catch (err) {
19144
19177
  if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
19145
- if (PORT_IS_STRICT) {
19146
- if (await freePort(HTTPS_PORT)) {
19147
- httpsServer = Bun.serve({ port: HTTPS_PORT, tls, ...serverConfig });
19148
- } else {
19149
- throw err;
19150
- }
19178
+ if (PORT_IS_STRICT && await freePort(wantedHttpsPort)) {
19179
+ httpsServer = bindHttps();
19151
19180
  } else {
19152
- httpsServer = Bun.serve({ port: 0, tls, ...serverConfig });
19181
+ for (let attempt = 0;attempt < 5 && !httpsServer; attempt++) {
19182
+ Bun.sleepSync(400);
19183
+ try {
19184
+ httpsServer = bindHttps();
19185
+ } catch {}
19186
+ }
19187
+ if (!httpsServer) {
19188
+ console.error("");
19189
+ console.error(` \u26A0\uFE0F HTTPS could not bind its port ${wantedHttpsPort} \u2014 it is still in use`);
19190
+ console.error(` (a previous server in TIME_WAIT, or another process holding it).`);
19191
+ console.error(` A widget on an https page connects to :${wantedHttpsPort}, so it will`);
19192
+ console.error(` NOT connect until this is free. Haltija is NOT silently relocating it.`);
19193
+ console.error(` Fix: wait a few seconds and restart, or free port ${wantedHttpsPort}.`);
19194
+ console.error("");
19195
+ if (!USE_HTTP) {
19196
+ throw err;
19197
+ }
19198
+ }
19153
19199
  }
19154
19200
  } else {
19155
19201
  throw err;
19156
19202
  }
19157
19203
  }
19158
- HTTPS_PORT = httpsServer.port ?? HTTPS_PORT;
19204
+ if (httpsServer)
19205
+ HTTPS_PORT = httpsServer.port ?? HTTPS_PORT;
19159
19206
  }
19160
- var httpUrl = USE_HTTP ? `http://localhost:${PORT}` : null;
19161
- var httpsUrl = USE_HTTPS ? `https://localhost:${HTTPS_PORT}` : null;
19207
+ var httpUrl = httpServer ? `http://localhost:${PORT}` : null;
19208
+ var httpsUrl = httpsServer ? `https://localhost:${HTTPS_PORT}` : null;
19162
19209
  var primaryUrl = httpsUrl || httpUrl;
19163
- var CAN_BE_REGISTERED = USE_HTTP;
19210
+ if (IS_PRIVATE) {
19211
+ const ready = {
19212
+ port: PORT,
19213
+ httpsPort: httpsServer ? HTTPS_PORT : null,
19214
+ url: httpUrl,
19215
+ httpsUrl,
19216
+ pid: process.pid
19217
+ };
19218
+ console.log(`HALTIJA_PRIVATE_READY ${JSON.stringify(ready)}`);
19219
+ const portFile = process.env.HALTIJA_PORT_FILE;
19220
+ if (portFile) {
19221
+ try {
19222
+ writeFileSync3(portFile, JSON.stringify(ready));
19223
+ } catch (err) {
19224
+ console.error(`${LOG_PREFIX} could not write HALTIJA_PORT_FILE ${portFile}: ${err instanceof Error ? err.message : err}`);
19225
+ }
19226
+ }
19227
+ }
19228
+ var CAN_BE_REGISTERED = USE_HTTP && !IS_PRIVATE;
19164
19229
  var REGISTRY_NAME = !CAN_BE_REGISTERED ? "" : INSTANCE_NAME || (isDesktopApp ? "" : autoNameFor(PORT));
19165
19230
  if (!CAN_BE_REGISTERED && (INSTANCE_NAME || !isDesktopApp)) {
19166
19231
  console.log(`${LOG_PREFIX} HTTPS-only: not registering an instance (hj speaks HTTP; cwd routing and --name need an HTTP port)`);
@@ -19367,8 +19432,14 @@ if (USE_HTTPS && !httpUrl) {
19367
19432
  `);
19368
19433
  }
19369
19434
  if (MODE === "both") {
19370
- console.log(` Mode: both - Use HTTP (${PORT}) for HTTP sites, HTTPS (${HTTPS_PORT}) for HTTPS sites.
19435
+ if (httpsServer) {
19436
+ console.log(` Mode: both - Use HTTP (${PORT}) for HTTP sites, HTTPS (${HTTPS_PORT}) for HTTPS sites.
19371
19437
  `);
19438
+ } else {
19439
+ console.log(` Mode: both - \u26A0\uFE0F HTTP (${PORT}) is up but HTTPS is DOWN (see the warning above).`);
19440
+ console.log(` Pages served over https will not connect until HTTPS binds.
19441
+ `);
19442
+ }
19372
19443
  }
19373
19444
  var server = httpsServer || httpServer;
19374
19445
  export {
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.0";
11
+ export declare const VERSION = "1.4.1";
package/llms.txt CHANGED
@@ -59,6 +59,7 @@ Or via a script tag (auto-injecting IIFE bundle served by the running server):
59
59
 
60
60
  ## Gotcha: a tab can read as unreachable while its main thread is suspended
61
61
  - The widget proves liveness with a heartbeat. If the page's main thread or its `requestAnimationFrame` loop is suspended, the heartbeat stalls and commands time out or report "no browser connected" — even though the PAGE is fine. It's the instrument going quiet, not a page bug.
62
+ - **A tab that is not visible on screen** — backgrounded, minimized, or maximized on another Space / fully occluded (macOS) — counts as `hidden`. Browsers stop `requestAnimationFrame` and throttle timers while hidden, AND the widget deliberately deactivates a hidden tab so untargeted commands go to the front tab. So a page you can't see may not answer an untargeted `hj eval`/`hj tree`, and rAF-driven state (scroll/animation) is frozen. Fix: bring it to the front, or **target it explicitly** — `hj --window <id> eval …` (id from `hj windows`) reaches a specific tab regardless of visibility.
62
63
  - **WebXR / immersive sessions suspend `window.requestAnimationFrame`** (the XR compositor drives frames instead), so a tab is unreachable *while immersive*. Drive state out through your own channel — write the values you care about somewhere your agent can read (a debug source, an in-headset stats panel) — rather than `hj eval` round-trips through the suspended heartbeat. A heavily throttled background tab or a long blocking task has the same shape.
63
64
  - A command hitting the WRONG page (not a dead one) is targeting, not liveness: run `hj where`.
64
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.4.0",
3
+ "version": "1.4.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",