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 +111 -0
- package/apps/desktop/main.js +120 -12
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +1 -1
- package/bin/cli-subcommand.mjs +7 -0
- package/bin/hj.mjs +15 -1
- package/bin/tosijs-dev.mjs +65 -12
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +1 -1
- package/dist/component.js +1 -1
- package/dist/focus-ambiguity.d.ts +47 -0
- package/dist/hj.js +14 -3
- package/dist/index.js +154 -19
- package/dist/server.js +154 -19
- package/dist/tab-liveness.d.ts +35 -0
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
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
|
package/apps/desktop/main.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
// '
|
|
60
|
-
// '
|
|
61
|
-
// '
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
871
|
-
|
|
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
package/dist/component.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/component.esm.js
CHANGED
package/dist/component.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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)
|
|
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);
|