haltija 1.3.4 → 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 ADDED
@@ -0,0 +1,135 @@
1
+ # Changelog
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
+
74
+ ## 1.4.0
75
+
76
+ **`hj` now routes to the server that owns your current directory.** If you run more than one
77
+ project, this changes where your commands go — for the better, but read the first section.
78
+
79
+ ### Fixed: `hj` drove the wrong browser across projects
80
+
81
+ `hj` never looked at your working directory. Every invocation, in every project, fell back to
82
+ port 8700 and drove whatever browser was focused there — silently, with no error. The only way
83
+ to target a project's own server was per-shell environment variables that agents spawning fresh
84
+ shells routinely lose.
85
+
86
+ Servers now record the directory they were started in, and `hj` picks the live server whose
87
+ directory is the nearest ancestor of your cwd. Inside a project with its own server, plain
88
+ `hj tree` just works — no flags, no env vars.
89
+
90
+ - Falling back to the shared default port while other servers are running now **warns** on
91
+ stderr instead of quietly misrouting.
92
+ - **`hj where`** tells you which port you're targeting, *why* it was chosen, and what's alive
93
+ there. Reach for it first when a command seems to hit the wrong page.
94
+ - Precedence is unchanged and still wins: `--port` > `--name`/`HALTIJA_NAME` > `HALTIJA_PORT` >
95
+ `DEV_CHANNEL_PORT` > cwd match > port 8700.
96
+
97
+ ### Fixed: a stale server could hand every project an old `hj`
98
+
99
+ `hj` is a single binary on your `PATH`, and every haltija server used to overwrite it on
100
+ startup — so the last server to boot decided which `hj` *every project on the machine* ran. One
101
+ forgotten `bunx haltija@beta` could silently downgrade the CLI for an unrelated, up-to-date
102
+ project.
103
+
104
+ - A **symlinked `hj` is never touched.** Point it at your own build and it stays put.
105
+ - Servers **only bootstrap or repair** `hj` — they write it when nothing is there, or when
106
+ what's there is strictly older. They never downgrade it, and never rewrite it just because
107
+ the bytes differ. To find out what's installed they ask it (`hj --version`).
108
+ - **`hj --version`**, and `hj` now warns when its version differs from the server it's driving.
109
+ - `HALTIJA_NO_INSTALL=1` opts out of the install entirely.
110
+
111
+ ### New: pre-1.4.0 servers are retired on startup
112
+
113
+ Older servers have none of the guards above and cannot be fixed in code that already shipped, so
114
+ a 1.4.0+ server **asks** any haltija server **below 1.4.0** to stop when it starts, and says what
115
+ it did. Retirement is `POST /shutdown` — an endpoint every haltija has understood since 0.1.7 —
116
+ so it needs no process IDs and does no killing.
117
+
118
+ This is deliberately narrow: it never stops a peer (1.4.0 and 1.4.1 coexist, and once 1.3.x is
119
+ gone it never fires again), never touches a running desktop app, and never touches anything it
120
+ cannot identify as haltija. When it can't stop a server, it complains rather than failing
121
+ silently.
122
+
123
+ `HALTIJA_NO_RETIRE=1` opts out. See "Housekeeping" in the README.
124
+
125
+ ### Also
126
+
127
+ - HTTPS-only servers no longer advertise an HTTP port they aren't listening on.
128
+ - Every REST response carries `X-Haltija-Version`.
129
+ - **`hj` exits non-zero when an operation fails** (`success:false`) — not just with `--json`, but on action commands too (`hj click`, `navigate`, `key`, …). A click that didn't land, or any command with no browser connected, now exits 1 instead of 0, so an agent checking the exit code can't read a failed step as success. (Commands with their own human formatting were already this way; this closes the gap for the rest.)
130
+ - `HALTIJA_REGISTRY_DIR` overrides the instance-registry location.
131
+ - `hj` no longer auto-spawns a server against an **explicitly targeted** port (`--port`/`--name`/`HALTIJA_PORT`) or under `--no-launch` — a read-only command against a server you manage will not start a colliding one; it errors instead. Auto-spawn remains only for the bare default port.
132
+
133
+ ### Platform
134
+
135
+ macOS and Linux. Native Windows is not supported — use WSL, where all of this works unmodified.
package/README.md CHANGED
@@ -249,7 +249,11 @@ export HALTIJA_PORT=9123
249
249
  hj tree
250
250
  ```
251
251
 
252
- If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port — `--name` records whichever port it ends up on so `hj` can find it. A different shell can target a different project; there's no global state, just one named instance per haltija server.
252
+ If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port.
253
+
254
+ **You usually don't need `--name` at all.** A server records the directory it was started in, so **plain `hj` inside a project reaches that project's server** — no flags, no environment variables. `--name` and `--port` are overrides for when you want to address a server from *outside* its directory. `hj where` shows which server a shell is targeting and why.
255
+
256
+ Haltija does keep a little state outside your project — a shared `hj` on your PATH and a registry of running servers. That's deliberate, and it's all logged: see [Housekeeping](#housekeeping--what-haltija-does-to-your-machine) below.
253
257
 
254
258
  **Production embedding.** When haltija is reachable beyond loopback, gate it with a shared-secret token:
255
259
 
@@ -302,6 +306,50 @@ app also exists but isn't required for either.
302
306
 
303
307
  ---
304
308
 
309
+ ## Housekeeping — what Haltija does to your machine
310
+
311
+ Haltija acts at **machine** scope on purpose. "Which `hj` does every shell on this box
312
+ run?" is not a question a per-project fix can answer — so a few things live outside your
313
+ project directory. All of it is opt-out, and **all of it leaves a receipt.**
314
+
315
+ **Everything Haltija does outside its own project is logged to
316
+ `~/.haltija/machine-actions.log`** (timestamped, with the version and the *directory of the
317
+ project that triggered it*) and announced on **stderr**. That matters because Haltija is
318
+ often a transitive dependency: if some other project's `test-browser` script spawned it,
319
+ you may not know what Haltija even is. The receipt tells you what happened, when, and which
320
+ project caused it.
321
+
322
+ **It installs `hj` into `~/.local/bin`.** One CLI, shared by every project. It will
323
+ **never overwrite a symlink** (if you point `hj` at your own build, that's yours) and
324
+ **never downgrade** a newer `hj` than the one it carries. `HALTIJA_NO_INSTALL=1` disables it.
325
+
326
+ **It registers itself in `~/.haltija/servers/`,** recording the directory it was started
327
+ in. That's what lets plain `hj` inside a project reach *that project's* server instead of
328
+ whichever browser happens to be focused somewhere else. The entry is removed on shutdown.
329
+ `hj where` shows what your shell is targeting and why.
330
+
331
+ **It stops haltija servers older than 1.4.0.** Those versions overwrite the shared
332
+ `~/.local/bin/hj` on every boot, so one stale server left running quietly hands every
333
+ project on your machine an old CLI. A 1.4.0+ server **asks** them to stop on startup
334
+ (`POST /shutdown`, which every haltija has understood since 0.1.7) and says so. It does
335
+ not go hunting for processes to kill. It is deliberately narrow:
336
+
337
+ - It only stops servers **below 1.4.0** — never a peer. Two projects on 1.4.0 and 1.4.1
338
+ coexist; nothing kills anything once 1.3.x is gone.
339
+ - It **will not touch a running desktop app** (that would orphan a window you can see) —
340
+ it tells you to quit and update it instead.
341
+ - It **asks rather than kills.** Retirement is an HTTP request to a server that already
342
+ told us what it is — no process IDs, no `kill`. (An earlier draft resolved a port to a
343
+ pid with `lsof` and signalled it; `lsof -i :PORT` also matches connected *clients*, so
344
+ that would have killed your **browser**. Asking makes that whole class of mistake
345
+ impossible.)
346
+ - On the rare fallback paths that must free a port without waiting, it signals only
347
+ **listeners** that `ps` confirms are haltija — never something it cannot identify.
348
+
349
+ `HALTIJA_NO_RETIRE=1` disables it.
350
+
351
+ ---
352
+
305
353
  ## Use Cases
306
354
 
307
355
  - **AI pair programming** — Agent sees your actual app, not a description of it
@@ -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()
@@ -1292,39 +1312,90 @@ async function startEmbeddedServer() {
1292
1312
  /**
1293
1313
  * Ensure Haltija server is available
1294
1314
  */
1295
- async function killZombieOnPort(port) {
1296
- if (os.platform() === 'win32') return true
1297
-
1315
+ /** Is anything listening on this port? Listeners only — never connected clients. */
1316
+ function listenerPidsOnPort(port) {
1298
1317
  const { execSync } = require('child_process')
1318
+ try {
1319
+ // `-sTCP:LISTEN` is load-bearing. `lsof -i :PORT` matches sockets whose local OR
1320
+ // REMOTE port is PORT, so without it this also returns every connected CLIENT —
1321
+ // i.e. the user's browser, holding a WebSocket to this very server. Browsers open
1322
+ // since login have the lower pid and sort first, so killing what this returns used
1323
+ // to kill the browser and leave the server running.
1324
+ const out = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: 'utf-8' }).trim()
1325
+ if (!out) return []
1326
+ return out.split('\n').filter(Boolean).map(Number)
1327
+ .filter(pid => Number.isFinite(pid) && pid !== process.pid)
1328
+ } catch {
1329
+ return []
1330
+ }
1331
+ }
1299
1332
 
1300
- for (let attempt = 1; attempt <= 3; attempt++) {
1301
- try {
1302
- const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' }).trim()
1303
- if (!pids) {
1304
- console.log(`[Haltija Desktop] Port ${port} is free`)
1305
- return true
1306
- }
1307
-
1308
- console.log(`[Haltija Desktop] Attempt ${attempt}: Killing process(es) on port ${port}: ${pids.replace(/\n/g, ', ')}`)
1333
+ /** Only signal something we can positively identify as haltija. */
1334
+ function isHaltijaProcess(pid) {
1335
+ const { execSync } = require('child_process')
1336
+ try {
1337
+ const cmd = execSync(`ps -p ${pid} -o command= 2>/dev/null`, { encoding: 'utf-8' }).trim()
1338
+ return !!cmd && /haltija|tosijs-dev/i.test(cmd)
1339
+ } catch {
1340
+ return false
1341
+ }
1342
+ }
1309
1343
 
1310
- const signal = attempt < 3 ? '' : '-9'
1311
- execSync(`lsof -ti:${port} | xargs kill ${signal} 2>/dev/null`, { encoding: 'utf-8' })
1344
+ /**
1345
+ * Free a port held by an old haltija server.
1346
+ *
1347
+ * ASK FIRST. `POST /shutdown` has shipped since 0.1.7, so any haltija server on this
1348
+ * port can stop itself — cleanly, and with no pid involved. That matters: this used to
1349
+ * run `lsof -ti:PORT | xargs kill -9`, which kills every pid matching the port
1350
+ * INCLUDING connected clients, i.e. the user's browser. It ran on the default launch
1351
+ * path, whenever a server was already up — exactly when browsers are attached.
1352
+ *
1353
+ * Signalling is now the fallback only, restricted to LISTENERS that `ps` confirms are
1354
+ * haltija, and it never escalates to -9 against something it could not identify.
1355
+ */
1356
+ async function killZombieOnPort(port) {
1357
+ if (os.platform() === 'win32') return true
1312
1358
 
1313
- await new Promise(r => setTimeout(r, 500 * attempt))
1359
+ if (listenerPidsOnPort(port).length === 0) {
1360
+ console.log(`[Haltija Desktop] Port ${port} is free`)
1361
+ return true
1362
+ }
1314
1363
 
1315
- try {
1316
- execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' })
1317
- } catch {
1318
- console.log(`[Haltija Desktop] Port ${port} freed successfully`)
1319
- return true
1320
- }
1321
- } catch {
1322
- console.log(`[Haltija Desktop] Port ${port} is free`)
1364
+ // 1. Ask.
1365
+ try {
1366
+ await fetch(`http://localhost:${port}/shutdown`, {
1367
+ method: 'POST',
1368
+ signal: AbortSignal.timeout(1500),
1369
+ })
1370
+ } catch {
1371
+ // Dying mid-response is a success; the liveness check below decides.
1372
+ }
1373
+ for (let i = 0; i < 20; i++) {
1374
+ if (listenerPidsOnPort(port).length === 0) {
1375
+ console.log(`[Haltija Desktop] Server on port ${port} shut down cleanly`)
1323
1376
  return true
1324
1377
  }
1378
+ await new Promise(r => setTimeout(r, 100))
1379
+ }
1380
+
1381
+ // 2. It didn't answer. Signal only what we can identify.
1382
+ for (const pid of listenerPidsOnPort(port)) {
1383
+ if (!isHaltijaProcess(pid)) {
1384
+ console.error(`[Haltija Desktop] Port ${port} is held by pid ${pid}, which is not a haltija server — leaving it alone`)
1385
+ continue
1386
+ }
1387
+ try {
1388
+ process.kill(pid, 'SIGTERM')
1389
+ console.log(`[Haltija Desktop] Stopped unresponsive haltija (pid ${pid}) on port ${port}`)
1390
+ } catch {}
1325
1391
  }
1392
+ await new Promise(r => setTimeout(r, 500))
1326
1393
 
1327
- console.error(`[Haltija Desktop] Warning: Could not free port ${port} after 3 attempts`)
1394
+ if (listenerPidsOnPort(port).length === 0) {
1395
+ console.log(`[Haltija Desktop] Port ${port} freed`)
1396
+ return true
1397
+ }
1398
+ console.error(`[Haltija Desktop] Warning: could not free port ${port}`)
1328
1399
  return false
1329
1400
  }
1330
1401
 
@@ -1348,9 +1419,15 @@ async function ensureServer() {
1348
1419
  return false
1349
1420
 
1350
1421
  case 'auto':
1351
- // 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.
1352
1424
  if (running) {
1353
- 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
1354
1431
  return true
1355
1432
  }
1356
1433
  console.log('[Haltija Desktop] No server found, starting embedded')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.3.4",
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",
@@ -10,12 +10,12 @@
10
10
  },
11
11
  "main": "main.js",
12
12
  "scripts": {
13
- "start": "cd ../.. && bun run build && cd apps/desktop && electron .",
13
+ "start": "cd ../.. && bun run build && rm -f apps/desktop/resources/haltija-server-* && cd apps/desktop && electron .",
14
14
  "start:quick": "electron .",
15
15
  "icons": "electron scripts/generate-icons.js",
16
16
  "sync-version": "node -e \"const root = require('../../package.json'); const pkg = require('./package.json'); pkg.version = root.version; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n')\"",
17
17
  "prebuild": "npm run sync-version && npm run icons && npm run compile:server",
18
- "compile:server": "cd ../.. && bun run build && bun build --compile --target=bun-darwin-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64 && bun build --compile --target=bun-darwin-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && bun build --compile --target=bun-darwin-arm64 bin/hj.mjs --outfile apps/desktop/resources/hj-arm64 && bun build --compile --target=bun-darwin-x64 bin/hj.mjs --outfile apps/desktop/resources/hj-x64 && cp dist/component.js apps/desktop/resources/component.js && cp haltija-icon.svg apps/desktop/resources/icon.svg",
18
+ "compile:server": "cd ../.. && bun run build && bun build --compile --target=bun-darwin-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64 && bun build --compile --target=bun-darwin-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && cp dist/component.js apps/desktop/resources/component.js && cp haltija-icon.svg apps/desktop/resources/icon.svg",
19
19
  "compile:server:win": "cd ../.. && bun build --compile --target=bun-windows-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64.exe",
20
20
  "compile:server:linux": "cd ../.. && bun build --compile --target=bun-linux-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && bun build --compile --target=bun-linux-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64",
21
21
  "build": "electron-builder",
@@ -94,8 +94,8 @@
94
94
  "to": "haltija-server-${arch}"
95
95
  },
96
96
  {
97
- "from": "resources/hj-${arch}",
98
- "to": "hj-${arch}"
97
+ "from": "resources/hj.mjs",
98
+ "to": "hj.mjs"
99
99
  },
100
100
  {
101
101
  "from": "resources/component.js",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.3.4";
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)\(/;
@@ -26,6 +26,8 @@ import { formatEvents } from './format-events.mjs'
26
26
  import { formatTestResult, formatSuiteResult } from './format-test.mjs'
27
27
  import { formatNetwork, formatNetworkStats } from './format-network.mjs'
28
28
  import { substituteGeneratedVars } from './test-data.mjs'
29
+ import { HJ_VERSION } from './version.mjs'
30
+ import { differsBeyondPatch } from './semver.mjs'
29
31
 
30
32
  const __dirname = dirname(fileURLToPath(import.meta.url))
31
33
 
@@ -34,6 +36,37 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
34
36
  const hintsPath = join(__dirname, 'hints.json')
35
37
  export const COMMAND_HINTS = existsSync(hintsPath) ? JSON.parse(readFileSync(hintsPath, 'utf-8')) : {}
36
38
 
39
+ let warnedAboutSkew = false
40
+
41
+ /**
42
+ * Warn when this hj is meaningfully out of step with the server it just talked to.
43
+ *
44
+ * `hj` is ONE global binary driving MANY per-project servers, so some version skew is
45
+ * the normal steady state, not a fault: a project pinned to haltija 1.4.0 while the
46
+ * CLI is 1.4.2 is fine, and no action the user takes can make those numbers match.
47
+ *
48
+ * So warn only when the versions differ by more than a patch — that's when hj can
49
+ * actually lack a resolution rule or response shape the server assumes. Warning on
50
+ * exact mismatch would fire forever, on every command, with a remedy
51
+ * (`bun install -g haltija@latest`) that cannot fix it. And a warning that always
52
+ * fires is one that gets ignored — including the times it's real. SKILL.md tells
53
+ * agents to *trust* this warning, so it has to be worth trusting.
54
+ *
55
+ * Once per process, on stderr (so `--json` stdout stays machine-readable).
56
+ * `HALTIJA_NO_SKEW_WARN=1` silences it.
57
+ */
58
+ function warnOnVersionSkew(resp) {
59
+ if (warnedAboutSkew) return
60
+ if (process.env.HALTIJA_NO_SKEW_WARN === '1') return
61
+ const serverVersion = resp.headers?.get?.('X-Haltija-Version')
62
+ if (!serverVersion) return
63
+ if (!differsBeyondPatch(serverVersion, HJ_VERSION)) return
64
+ warnedAboutSkew = true
65
+ console.error(`hj: warning — hj ${HJ_VERSION} is driving haltija server ${serverVersion}.`)
66
+ console.error(`hj: that gap is wide enough to route or format wrongly. This hj is ${process.argv[1]}`)
67
+ console.error(`hj: silence with HALTIJA_NO_SKEW_WARN=1`)
68
+ }
69
+
37
70
  // Endpoints that use GET (everything else is POST)
38
71
  export const GET_ENDPOINTS = new Set([
39
72
  'location', 'events', 'console', 'windows', 'recordings',
@@ -778,14 +811,38 @@ export async function runSubcommand(subcommand, subArgs, port = '8700', options
778
811
  warnUnknownFlags(subcommand, filteredArgs)
779
812
  }
780
813
 
781
- // Check if server is running, auto-start if not
814
+ // Check if a server is answering; auto-start one only if it's safe to.
782
815
  if (!(await isServerRunning(port))) {
816
+ // NEVER auto-spawn a server against a port the shell EXPLICITLY targeted (--port /
817
+ // --name / HALTIJA_PORT / …), and never under --no-launch.
818
+ //
819
+ // Spawning a server is a machine-topology mutation, and doing it from a read-only-ish
820
+ // command (`hj eval`, `hj tree`) against a targeted port is the sharpest edge in the CLI:
821
+ // the target is a server the USER manages — very often their dev server, embedded on a
822
+ // port they chose. If it's momentarily not answering (mid-restart, or HTTPS-only so the
823
+ // HTTP /status probe fails), spawning a generic server that BINDS that port collides with
824
+ // their setup and knocks it offline. That's a bug report, and it was: `hj --no-launch
825
+ // --port 8700 eval` bound its own listener on 8700 and took the dev channel down.
826
+ //
827
+ // So auto-spawn is only for the bare, unconfigured 8700 default — the zero-config "I just
828
+ // want it to work" path. This mirrors the Electron auto-launch rule (hj.mjs), which the
829
+ // server spawn had drifted out of sync with. An explicit target that isn't answering is an
830
+ // actionable error, not license to spawn.
831
+ if (noLaunch || explicitTarget) {
832
+ console.error(`Error: nothing is answering on the haltija server you targeted (port ${port}).`)
833
+ console.error(explicitTarget
834
+ ? 'That port is yours to manage — haltija will not spawn a server against a target you named.'
835
+ : 'Start it yourself: `haltija --server` (or drop --no-launch to let hj start one on the default port).')
836
+ console.error('`hj where` shows what a shell is targeting and why.')
837
+ process.exit(1)
838
+ }
839
+
783
840
  // Respect "user manually quit Haltija" before we try to spawn anything.
784
841
  // The marker is dropped by the desktop app on will-quit and cleared on
785
842
  // its next launch — agent calls in between should not bring it back.
786
843
  try {
787
844
  const quitMarker = join(homedir(), '.haltija', 'last-quit')
788
- if (!noLaunch && existsSync(quitMarker)) {
845
+ if (existsSync(quitMarker)) {
789
846
  console.error('Haltija was quit by user; not auto-launching.')
790
847
  console.error('Open Haltija manually to resume — or run `hj --no-launch` to bypass this check.')
791
848
  process.exit(1)
@@ -875,6 +932,7 @@ async function doRequest(url, method, body, context = {}) {
875
932
  }
876
933
 
877
934
  const resp = await fetch(url, opts)
935
+ warnOnVersionSkew(resp)
878
936
  const contentType = resp.headers.get('content-type') || ''
879
937
 
880
938
  if (contentType.includes('application/json')) {
@@ -925,7 +983,18 @@ async function doRequest(url, method, body, context = {}) {
925
983
  console.log(JSON.stringify(result, null, 2))
926
984
  }
927
985
  } else {
986
+ // The fall-through for BOTH `--json` and plain action commands with no special
987
+ // formatter (hj click / navigate / key / type / scroll …): print the envelope,
988
+ // then EXIT NON-ZERO IF IT SAYS FAILURE.
989
+ //
990
+ // This used to print `{"success": false, "error": "No browser connected…"}` (or an
991
+ // element-not-found) and exit 0. An agent that checks the exit code — which is how a
992
+ // harness decides whether a step worked — saw success while the payload said failure.
993
+ // That is the instrument lying, and for a debugging tool a lying instrument is worse
994
+ // than none: you can't tell "the page is broken" from "my probe is broken". A click
995
+ // that didn't click, or a probe with no browser, is a failure and now exits 1.
928
996
  console.log(JSON.stringify(json, null, 2))
997
+ if (json && json.success === false) process.exit(1)
929
998
  }
930
999
  } else {
931
1000
  const text = await resp.text()