haltija 1.4.0 → 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,116 @@
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
+
43
+ ## 1.4.1
44
+
45
+ Five cross-project bugs, all of the same shape: **haltija reaching out and disrupting a healthy
46
+ peer.** If you run more than one project on a machine, this is the release that stops your
47
+ browser channel vanishing.
48
+
49
+ ### Behavior changes (no API breaks)
50
+
51
+ Nothing was removed or renamed — no endpoint, export, or flag — so nothing should fail to compile
52
+ or resolve. Two *runtime* behaviors changed, and in both the old behavior was the bug:
53
+
54
+ - **The desktop app attaches to an existing server instead of replacing it.** If you relied on a
55
+ launch always giving you a pristine embedded server, set `HALTIJA_SERVER_MODE=builtin`.
56
+ - **`--https` (https-only) now exits if it cannot bind its port**, instead of silently starting on
57
+ an ephemeral one. The old "success" produced a channel no widget could reach.
58
+
59
+ ### New: `--private` — isolated automation instances ([#1](https://github.com/tonioloewald/haltija/issues/1))
60
+
61
+ haltija plays two roles that were conflated. A **shared interactive** browser on the default port
62
+ is a feature — whatever window is focused is what `hj` drives, across projects. But **ephemeral
63
+ automation** (a test lane that spawns a browser, drives fixed pages, and exits) was consulting
64
+ that shared server and, if any was reachable, *adopting and navigating it* — so one project's
65
+ doc-test lane yanked another project's live browser to different pages, and then failed on a
66
+ timeout. Intermittent and baffling, because it only bit when a foreign haltija happened to be up.
67
+
68
+ `haltija --private` (pair with `--headless`) is isolated by construction:
69
+
70
+ - binds an **ephemeral port, never 8700** — it can't collide with or be mistaken for the shared server;
71
+ - is **not registered** in the shared registry, so interactive `hj` / cwd-routing can't adopt it;
72
+ - **never reaches out** — it retires nothing and touches no other server;
73
+ - **reports its address** on stdout (`HALTIJA_PRIVATE_READY {json}`) and to `--port-file` — since
74
+ it's not in the registry, that's how you find it.
75
+
76
+ A consumer's test lane should request a private instance and drive *that* by the port it reports,
77
+ instead of an unscoped `hj windows` check that races whatever else is on the machine.
78
+
79
+ ### Fixed: the desktop app killed other projects' channels
80
+
81
+ Its default was to stop any server on 8700/8701 and start fresh — so launching the app (`bunx
82
+ haltija`, an `hj` auto-launch, `--ci`, the integration test) silently took down a live channel
83
+ another project was using, and made its widget vanish. It now **attaches to a healthy existing
84
+ server and says so**. Force the old behavior with `HALTIJA_SERVER_MODE=builtin`.
85
+
86
+ ### Fixed: a half-dead `--both` channel (HTTPS silently on the wrong port)
87
+
88
+ When the HTTPS port was busy (a fast restart racing the previous server), the HTTPS side quietly
89
+ fell back to an **ephemeral** port. But a widget on an https page connects to the *known* port —
90
+ so 8701 sat empty, the page couldn't connect, and the server looked healthy because HTTP was fine.
91
+ HTTPS now retries its intended port and, failing that, **fails loudly** rather than relocating;
92
+ the startup banner never advertises a port it didn't bind.
93
+
94
+ ### Fixed: silent HTTP port relocation
95
+
96
+ When the wanted HTTP port was taken, the server bound an ephemeral one without a word — so a
97
+ caller probing a fixed port had no idea why nothing was there. It now says
98
+ `<port> was taken; bound HTTP on <n> instead. Find it with \`hj where\`.`
99
+
100
+ ### Fixed: the test suite disrupted other servers
101
+
102
+ `bun test` bound fixed 87xx ports — the range real servers live in — and on a collision would
103
+ `POST /shutdown` whatever was there, including another project's channel. The suite now uses
104
+ high, per-process-unique ports and can never stop a server it didn't start.
105
+
106
+ ### Docs
107
+
108
+ A "tab that reads as unreachable" troubleshooting section in `DOCS.md` and `llms.txt`: a hidden,
109
+ backgrounded, minimized, or occluded tab (and an active WebXR session) suspends
110
+ `requestAnimationFrame` and throttles timers, so the tab can stop answering even though the page
111
+ is fine. Bring it forward, or target it explicitly with `hj --window <id>`.
112
+
113
+
3
114
  ## 1.4.0
4
115
 
5
116
  **`hj` now routes to the server that owns your current directory.** If you run more than one
@@ -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
@@ -56,10 +68,17 @@ let navigateRequestId = 0
56
68
 
57
69
  const DEFAULT_PREFS = {
58
70
  // 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',
71
+ // 'auto' - Use an existing healthy server if one is on the port, else start embedded (DEFAULT)
72
+ // 'builtin' - Always stop any existing server and start fresh (the old default)
73
+ // 'external' - Never start a server, expect one running externally
74
+ //
75
+ // Default is 'auto', NOT 'builtin'. On a machine running more than one project, 8700/8701 are
76
+ // shared: another project may legitimately have a live channel there (e.g. `haltija --server
77
+ // --both`). 'builtin' treated that channel as a "zombie" and killed it to start fresh — so
78
+ // launching the desktop app (or `bunx haltija`, or an `hj` auto-launch) silently took down
79
+ // another project's channel and made its widget vanish. 'auto' reuses a healthy server instead.
80
+ // Force the old behavior with HALTIJA_SERVER_MODE=builtin when you specifically want your own.
81
+ serverMode: process.env.HALTIJA_SERVER_MODE || 'auto',
63
82
  }
64
83
 
65
84
  // Active prefs (will be overwritten by persisted prefs when IPC is wired up)
@@ -67,6 +86,10 @@ const prefs = { ...DEFAULT_PREFS }
67
86
 
68
87
  let mainWindow = null
69
88
  const embeddedServers = []
89
+ // True when this app attached to a server it did not start (auto mode found one already on the
90
+ // port). Surfaced to the window so the user knows they're driving a reused, possibly-foreign server.
91
+ let reusedExternalServer = false
92
+ let reusedServerBanner = ''
70
93
 
71
94
  // ============================================
72
95
  // MCP Setup for Claude Desktop
@@ -299,6 +322,15 @@ function createWindow() {
299
322
  // Load the shell UI
300
323
  mainWindow.loadFile('index.html')
301
324
 
325
+ // If we attached to a server we didn't start, tell the renderer so it can surface it in the
326
+ // UI. Best-effort: harmless if the renderer doesn't handle 'server-reused' yet (that UI is a
327
+ // filed follow-up); the reuse is also announced on the app's console output regardless.
328
+ if (reusedExternalServer) {
329
+ mainWindow.webContents.once('did-finish-load', () => {
330
+ try { mainWindow.webContents.send('server-reused', reusedServerBanner) } catch {}
331
+ })
332
+ }
333
+
302
334
  // Open DevTools in development
303
335
  if (process.env.NODE_ENV === 'development') {
304
336
  mainWindow.webContents.openDevTools()
@@ -1168,8 +1200,34 @@ function checkServerRunning() {
1168
1200
  * Stdout/stderr are piped to the desktop app's console with a label, and
1169
1201
  * `__NEED_WINDOW__` from the public server triggers window recreation.
1170
1202
  */
1171
- function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir }) {
1172
- 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
+ }
1173
1231
  let proc
1174
1232
  if (serverPath && useCompiledBinary) {
1175
1233
  proc = spawn(serverPath, [], {
@@ -1272,6 +1330,43 @@ async function startEmbeddedServer() {
1272
1330
  }
1273
1331
  }
1274
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
+
1275
1370
  spawnHaltijaServer({ port: HALTIJA_PORT, role: 'public', serverPath, useCompiledBinary, componentDir })
1276
1371
  spawnHaltijaServer({ port: HALTIJA_INTERNAL_PORT, role: 'internal', serverPath, useCompiledBinary, componentDir })
1277
1372
 
@@ -1386,6 +1481,13 @@ async function killZombieServer() {
1386
1481
  }
1387
1482
 
1388
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
+
1389
1491
  const running = await checkServerRunning()
1390
1492
 
1391
1493
  switch (prefs.serverMode) {
@@ -1399,9 +1501,15 @@ async function ensureServer() {
1399
1501
  return false
1400
1502
 
1401
1503
  case 'auto':
1402
- // Use existing if found, else start embedded
1504
+ // Use existing if found, else start embedded. Reusing is the whole point: it means the
1505
+ // app does NOT kill a channel another project may be running on 8700/8701.
1403
1506
  if (running) {
1404
- console.log('[Haltija Desktop] Using existing server at', HALTIJA_SERVER)
1507
+ // Surface this prominently — the user should know the app attached to a server it did
1508
+ // NOT start (possibly a different version/config), rather than silently assuming its own.
1509
+ 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)`
1510
+ console.log(`[Haltija Desktop] ${banner}`)
1511
+ reusedExternalServer = true
1512
+ reusedServerBanner = banner
1405
1513
  return true
1406
1514
  }
1407
1515
  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.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.0";
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
@@ -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'
@@ -523,15 +565,12 @@ function launchApp(desktopDir, port) {
523
565
 
524
566
  // Resolve electron binary directly to avoid npx cache race conditions (ENOTEMPTY)
525
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) }
526
571
  const child = electronBinary
527
- ? spawn(electronBinary, [desktopDir], {
528
- env: { ...env, DEV_CHANNEL_PORT: String(port) },
529
- stdio: 'inherit'
530
- })
531
- : spawn('npx', ['--yes', 'electron', desktopDir], {
532
- env: { ...env, DEV_CHANNEL_PORT: String(port) },
533
- stdio: 'inherit'
534
- })
572
+ ? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
573
+ : spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
535
574
 
536
575
  child.on('error', (err) => {
537
576
  console.error(red('Error:') + ` Failed to launch desktop app: ${err.message}`)
@@ -717,7 +756,13 @@ const tryBun = () => {
717
756
  // Start headless browser after server is ready
718
757
  if (headlessMode) {
719
758
  (async () => {
720
- const serverPort = env.DEV_CHANNEL_PORT || 8700
759
+ const serverPort = privateMode
760
+ ? await discoverPrivatePort(privatePortFile)
761
+ : (env.DEV_CHANNEL_PORT || 8700)
762
+ if (!serverPort) {
763
+ console.error('[tosijs-dev] Private server did not report its port within timeout')
764
+ process.exit(1)
765
+ }
721
766
  console.log('[tosijs-dev] Waiting for server to be ready...')
722
767
  const serverReady = await waitForServer(serverPort)
723
768
  if (serverReady) {
@@ -750,7 +795,13 @@ const tryNode = () => {
750
795
  // Start headless browser after server is ready
751
796
  if (headlessMode) {
752
797
  (async () => {
753
- const serverPort = env.DEV_CHANNEL_PORT || 8700
798
+ const serverPort = privateMode
799
+ ? await discoverPrivatePort(privatePortFile)
800
+ : (env.DEV_CHANNEL_PORT || 8700)
801
+ if (!serverPort) {
802
+ console.error('[tosijs-dev] Private server did not report its port within timeout')
803
+ process.exit(1)
804
+ }
754
805
  console.log('[tosijs-dev] Waiting for server to be ready...')
755
806
  const serverReady = await waitForServer(serverPort)
756
807
  if (serverReady) {
@@ -867,8 +918,10 @@ function killOnPort(port) {
867
918
  const port = env.DEV_CHANNEL_PORT || '8700'
868
919
  const forceRestart = args.includes('--force') || args.includes('-f')
869
920
 
870
- // Check for existing server before launching
871
- const existingServer = await checkExistingServer(port)
921
+ // A PRIVATE instance NEVER consults or adopts the shared server that adoption is the exact
922
+ // bug issue #1 is about, and it lived right here in our own launcher (it saw the shared 8700
923
+ // server and exited, "already running"). Private always starts its own on an ephemeral port.
924
+ const existingServer = privateMode ? { running: false } : await checkExistingServer(port)
872
925
  if (existingServer.running && !forceRestart) {
873
926
  console.log('')
874
927
  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.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.0";
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.0";
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.0";
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.0
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.0";
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);