haltija 1.5.2 → 1.6.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 +106 -0
- package/apps/desktop/main.js +33 -2
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +88 -20
- package/bin/cli-subcommand.mjs +1 -1
- package/bin/hj.mjs +107 -0
- package/bin/tosijs-dev.mjs +39 -12
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +22 -1
- package/dist/component.esm.js +88 -20
- package/dist/component.js +88 -20
- package/dist/hj.js +89 -3
- package/dist/index.js +177 -44
- package/dist/server.js +177 -44
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +25 -2
- package/llms.txt +14 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,111 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0
|
|
4
|
+
|
|
5
|
+
Consolidating release: rolls up everything from 1.5.2–1.5.7 (the last npm-published version was
|
|
6
|
+
1.5.4) and adds the fixes from a full nine-lens pre-release review of the whole span.
|
|
7
|
+
|
|
8
|
+
**Highlights since 1.5.4** (see the per-version entries below for detail):
|
|
9
|
+
|
|
10
|
+
- **`--private --app` teardown** (1.5.5, #7) — private Electron no longer orphans: skips the
|
|
11
|
+
single-instance lock, self-terminates when its spawner dies, `hj shutdown` tears it all down.
|
|
12
|
+
- **Console capture** (1.5.6) — uncaught exceptions, unhandled rejections, and `console.error(Error)`
|
|
13
|
+
messages/stacks are now captured (all were previously dropped).
|
|
14
|
+
- **Server picker** (1.5.7) — `hj servers` lists every live server; the desktop app is reachable as
|
|
15
|
+
`hj --name desktop`.
|
|
16
|
+
|
|
17
|
+
**Review-driven fixes in this release:**
|
|
18
|
+
|
|
19
|
+
- **`hj shutdown` no longer orphans a running desktop app.** It refuses (with a clear message —
|
|
20
|
+
quit the app from its window) instead of killing just the embedded server and leaving the window
|
|
21
|
+
on screen with dead tabs. `--private` instances still tear down fully.
|
|
22
|
+
- **`--name desktop` is reserved** — a normal server can no longer claim it and clobber the desktop
|
|
23
|
+
app's registry entry.
|
|
24
|
+
- The uncaught-error listeners are removed on widget disconnect (no leak across re-injection).
|
|
25
|
+
- `hj ls` is now an alias for `hj servers` (was `hj tree`); the old alias is gone.
|
|
26
|
+
- Added tabs-focus regression tests and `hj servers` / `hj --name desktop` / `hj shutdown` to the
|
|
27
|
+
generated docs and the agent skill.
|
|
28
|
+
|
|
29
|
+
## 1.5.7
|
|
30
|
+
|
|
31
|
+
Pick between coexisting servers.
|
|
32
|
+
|
|
33
|
+
When a normal haltija server and the Electron desktop app both ran, there was no way to see them or
|
|
34
|
+
target the app — it was unregistered, invisible to `hj where` and the registry.
|
|
35
|
+
|
|
36
|
+
- **`hj servers`** (alias `hj ls`) lists every live server: registry entries, the probed defaults
|
|
37
|
+
8700/8701, and this shell's target — with port, name, version, tab count, whether it's the
|
|
38
|
+
desktop app, and a `▸` on the one `hj` would drive.
|
|
39
|
+
- **The desktop app is now reachable as `hj --name desktop`.** Its public server registers under
|
|
40
|
+
the reserved name `desktop`, but *cwd-less* — so it's nameable without ever hijacking cwd routing.
|
|
41
|
+
(The internal chrome server stays unregistered; a `--private` app registers nothing.)
|
|
42
|
+
|
|
43
|
+
## 1.5.6
|
|
44
|
+
|
|
45
|
+
`hj console` now captures the errors that actually matter.
|
|
46
|
+
|
|
47
|
+
It intercepted `console.*` calls, but the most important errors slipped through: an **uncaught
|
|
48
|
+
exception** (`throw`) and an **unhandled promise rejection** were never captured (no `window`
|
|
49
|
+
error / `unhandledrejection` listener), and `console.error(new Error(...))` recorded `{}` because
|
|
50
|
+
`JSON.stringify` drops an Error's message and stack. A page could be throwing on every action while
|
|
51
|
+
`hj console` showed it clean.
|
|
52
|
+
|
|
53
|
+
- Uncaught exceptions and unhandled rejections are now captured as `error` entries, with the real
|
|
54
|
+
stack. The `error` listener uses the capture phase, so failed resource loads (img/script/…) are
|
|
55
|
+
seen too.
|
|
56
|
+
- Error objects serialize to `{name, message, stack}` (at any depth), so the message survives.
|
|
57
|
+
- Note: capture begins when the widget is injected, so errors thrown *before* injection are only
|
|
58
|
+
caught by the desktop app (which injects at document-start).
|
|
59
|
+
|
|
60
|
+
## 1.5.5
|
|
61
|
+
|
|
62
|
+
`--private` now really is "torn down with the run" ([#7](https://github.com/tonioloewald/haltija/issues/7)).
|
|
63
|
+
|
|
64
|
+
The private Electron instance used to survive teardown — holding Electron's single-instance lock (so
|
|
65
|
+
the **next** `--private` run failed with "Another instance is already running") and leaking a process
|
|
66
|
+
per run. Fixed three ways, all verified with real Electron:
|
|
67
|
+
|
|
68
|
+
- **A private run never takes the single-instance lock.** Private instances are isolated on ephemeral
|
|
69
|
+
ports and meant to run concurrently / back-to-back, so an orphan can no longer block the next run,
|
|
70
|
+
and two private runs coexist.
|
|
71
|
+
- **The private Electron self-terminates** when its spawner dies (even via SIGKILL) or on
|
|
72
|
+
SIGTERM/SIGINT. It reparents to launchd, so it watches the launcher's pid (`HALTIJA_SPAWNER_PID`)
|
|
73
|
+
and calls `app.quit()` — which reaps its own helper processes, unlike an external tree-kill.
|
|
74
|
+
- **`hj shutdown` / `hj quit`** (and `POST /shutdown`) on a private-desktop instance tears down the
|
|
75
|
+
whole thing — Electron and its servers — for a deterministic end-of-run teardown.
|
|
76
|
+
|
|
77
|
+
## 1.5.4
|
|
78
|
+
|
|
79
|
+
The `hj tabs open` client-less-tab trap now explains itself ([#5](https://github.com/tonioloewald/haltija/issues/5)).
|
|
80
|
+
|
|
81
|
+
Outside the desktop app, `hj tabs open <url>` has no tab API, so it falls back to `window.open()` —
|
|
82
|
+
and that new tab has **no haltija widget** unless its page injects one. So the server never hears
|
|
83
|
+
from it, it doesn't appear in `hj tabs`, and commands can't reach it (they go to the focused widget
|
|
84
|
+
tab). It presents as "the tab opened fine but every command goes somewhere else" — indistinguishable
|
|
85
|
+
from a routing bug.
|
|
86
|
+
|
|
87
|
+
- The fallback response now carries a `reason`, promoted to a top-level `warning` that `hj` prints
|
|
88
|
+
on stderr — at the one moment the client-less tab is created.
|
|
89
|
+
- The `/tabs/open` schema and `SKILL.md` now state plainly that only widget-injected tabs are
|
|
90
|
+
controllable and appear in `hj tabs`, so a non-responding tab is the first thing to check.
|
|
91
|
+
|
|
92
|
+
## 1.5.3
|
|
93
|
+
|
|
94
|
+
Discoverability fix for the two CI browser engines ([#6](https://github.com/tonioloewald/haltija/issues/6)).
|
|
95
|
+
|
|
96
|
+
Both `--headless` and `--ci` said "for CI" with no hint that they drive **different** engines:
|
|
97
|
+
`--headless` is Playwright Chromium (and needs the `playwright` package), while `--ci` / `--app` /
|
|
98
|
+
`--private --app` drive Electron and need no Playwright. An agent picked `--headless`, hit
|
|
99
|
+
"Playwright not installed", and wrongly concluded haltija's CI mode is just a Playwright wrapper.
|
|
100
|
+
|
|
101
|
+
- `hj --help` now names the engine per mode and adds a "Choosing a CI engine" block; `--private` is
|
|
102
|
+
clarified as an *isolation* modifier that pairs with either engine (not "pair with `--headless`").
|
|
103
|
+
- The "Playwright not installed" error points at the Electron path (`--ci` / `--private --app`) as
|
|
104
|
+
the no-Playwright alternative.
|
|
105
|
+
- `llms.txt`, `docs/CI-INTEGRATION.md`, and `SKILL.md` get an honest "which engine?" framing:
|
|
106
|
+
it's Electron vs Playwright (neither is bundled), and the real reason to choose Playwright is
|
|
107
|
+
multi-engine coverage (Firefox/WebKit), not the words "for CI".
|
|
108
|
+
|
|
3
109
|
## 1.5.2
|
|
4
110
|
|
|
5
111
|
Two follow-ups from the 1.5.0 review, both about the multi-tab experience on a shared server.
|
package/apps/desktop/main.js
CHANGED
|
@@ -1212,6 +1212,10 @@ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, compone
|
|
|
1212
1212
|
HALTIJA_PORT: port.toString(), // what src/server.ts actually reads
|
|
1213
1213
|
DEV_CHANNEL_PORT: port.toString(),
|
|
1214
1214
|
HALTIJA_DESKTOP: '1',
|
|
1215
|
+
// Only the PUBLIC server (the one agents drive) registers under the reserved 'desktop' name so
|
|
1216
|
+
// `hj --name desktop` / `hj servers` can find it; the internal chrome server stays unregistered.
|
|
1217
|
+
// See src/server.ts REGISTRY_NAME. (Ignored for a private run — those never register.)
|
|
1218
|
+
HALTIJA_DESKTOP_PUBLIC: role === 'public' ? '1' : '0',
|
|
1215
1219
|
}
|
|
1216
1220
|
if (IS_PRIVATE) {
|
|
1217
1221
|
// Isolated instance: this child binds an EPHEMERAL port (HALTIJA_PRIVATE forces PORT=0) and
|
|
@@ -1532,8 +1536,12 @@ async function ensureServer() {
|
|
|
1532
1536
|
}
|
|
1533
1537
|
}
|
|
1534
1538
|
|
|
1535
|
-
// Single-instance lock — prevent multiple Electron windows from launching
|
|
1536
|
-
|
|
1539
|
+
// Single-instance lock — prevent multiple Electron windows from launching.
|
|
1540
|
+
// A PRIVATE run must NOT contend for it (issue #7): private instances are isolated on ephemeral
|
|
1541
|
+
// ports and are meant to run many at once / back-to-back. Taking the shared lock means an orphaned
|
|
1542
|
+
// private Electron blocks the NEXT private run ("Another instance is already running"), and two
|
|
1543
|
+
// concurrent private runs collide. So a private run never requests and never holds the lock.
|
|
1544
|
+
const gotTheLock = IS_PRIVATE ? true : app.requestSingleInstanceLock()
|
|
1537
1545
|
|
|
1538
1546
|
if (!gotTheLock) {
|
|
1539
1547
|
console.log('[Haltija Desktop] Another instance is already running. Focusing existing window.')
|
|
@@ -1548,6 +1556,29 @@ if (!gotTheLock) {
|
|
|
1548
1556
|
}
|
|
1549
1557
|
})
|
|
1550
1558
|
|
|
1559
|
+
// "Torn down with the run": a PRIVATE Electron must not outlive its spawner (issue #7). An
|
|
1560
|
+
// orphaned private Electron holds nothing shared anymore (we skip the lock above), but it still
|
|
1561
|
+
// leaks a process + its servers. Electron reparents to launchd shortly after startup, so
|
|
1562
|
+
// process.ppid is useless — the launcher passes its own pid as HALTIJA_SPAWNER_PID and we poll
|
|
1563
|
+
// it. On the spawner's death, or a SIGTERM/SIGINT, quit via app.quit() → 'will-quit' kills the
|
|
1564
|
+
// child servers AND Electron reaps its own helper processes (which an EXTERNAL kill notoriously
|
|
1565
|
+
// fails to do — the reason we self-terminate instead of asking the consumer to hunt the tree).
|
|
1566
|
+
if (IS_PRIVATE) {
|
|
1567
|
+
let quitting = false
|
|
1568
|
+
const quitOnce = () => { if (quitting) return; quitting = true; try { app.quit() } catch {} }
|
|
1569
|
+
process.on('SIGTERM', quitOnce)
|
|
1570
|
+
process.on('SIGINT', quitOnce)
|
|
1571
|
+
const spawnerPid = parseInt(process.env.HALTIJA_SPAWNER_PID || '', 10)
|
|
1572
|
+
if (Number.isFinite(spawnerPid)) {
|
|
1573
|
+
const iv = setInterval(() => {
|
|
1574
|
+
let alive = true
|
|
1575
|
+
try { process.kill(spawnerPid, 0) } catch { alive = false } // signal 0 = existence check
|
|
1576
|
+
if (!alive) { clearInterval(iv); console.log('[Haltija Desktop] spawner gone — tearing down private instance'); quitOnce() }
|
|
1577
|
+
}, 1000)
|
|
1578
|
+
if (iv.unref) iv.unref()
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1551
1582
|
// App lifecycle
|
|
1552
1583
|
app.whenReady().then(async () => {
|
|
1553
1584
|
console.log('[Haltija Desktop] App ready, starting initialization...')
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.
|
|
49
|
+
var VERSION = "1.6.0";
|
|
50
50
|
|
|
51
51
|
// src/text-selector.ts
|
|
52
52
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
|
|
@@ -1738,6 +1738,7 @@
|
|
|
1738
1738
|
ws = null;
|
|
1739
1739
|
state = "disconnected";
|
|
1740
1740
|
consoleBuffer = [];
|
|
1741
|
+
errorCaptureInstalled = false;
|
|
1741
1742
|
eventWatchers = new Map;
|
|
1742
1743
|
mutationObserver = null;
|
|
1743
1744
|
shadowObservers = new Map;
|
|
@@ -1969,6 +1970,7 @@
|
|
|
1969
1970
|
this.killed = true;
|
|
1970
1971
|
this.disconnect();
|
|
1971
1972
|
this.restoreConsole();
|
|
1973
|
+
this.uninstallErrorCapture();
|
|
1972
1974
|
this.restoreDialogs();
|
|
1973
1975
|
this.clearEventWatchers();
|
|
1974
1976
|
this.stopMutationWatch();
|
|
@@ -5637,7 +5639,11 @@ ${elementSummary}${moreText}`;
|
|
|
5637
5639
|
});
|
|
5638
5640
|
} else {
|
|
5639
5641
|
window.open(payload2.url, "_blank");
|
|
5640
|
-
this.respond(msg2.id, true, {
|
|
5642
|
+
this.respond(msg2.id, true, {
|
|
5643
|
+
opened: true,
|
|
5644
|
+
fallback: true,
|
|
5645
|
+
reason: "Opened a plain browser tab via window.open() (this is not the Haltija desktop app). " + "That tab has NO Haltija widget unless its page injects one, so it will NOT appear in " + "`hj tabs` and hj commands cannot reach it — untargeted commands go to the focused " + "widget tab instead. To control the new tab: inject the widget on that page (e.g. " + "HALTIJA_DEV=1 / haltijaDev:true in that project), or use the Haltija desktop app, " + "which auto-injects into every tab."
|
|
5646
|
+
});
|
|
5641
5647
|
}
|
|
5642
5648
|
} else if (action2 === "close") {
|
|
5643
5649
|
if (haltija?.closeTab) {
|
|
@@ -7422,6 +7428,26 @@ ${elementSummary}${moreText}`;
|
|
|
7422
7428
|
window.prompt = this.originalDialogs.prompt;
|
|
7423
7429
|
this.originalDialogs = {};
|
|
7424
7430
|
}
|
|
7431
|
+
serializeConsoleArg(arg) {
|
|
7432
|
+
try {
|
|
7433
|
+
return JSON.parse(JSON.stringify(arg, (_k, v) => v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v));
|
|
7434
|
+
} catch {
|
|
7435
|
+
return String(arg);
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
recordConsoleEntry(entry) {
|
|
7439
|
+
try {
|
|
7440
|
+
this.consoleBuffer.push(entry);
|
|
7441
|
+
if (this.consoleBuffer.length > 1000) {
|
|
7442
|
+
this.consoleBuffer = this.consoleBuffer.slice(-500);
|
|
7443
|
+
}
|
|
7444
|
+
if (entry.level === "error") {
|
|
7445
|
+
if (this.state === "connected")
|
|
7446
|
+
this.send("console", "error", entry);
|
|
7447
|
+
this.updateUI();
|
|
7448
|
+
}
|
|
7449
|
+
} catch {}
|
|
7450
|
+
}
|
|
7425
7451
|
interceptConsole() {
|
|
7426
7452
|
const levels = [
|
|
7427
7453
|
"log",
|
|
@@ -7437,31 +7463,73 @@ ${elementSummary}${moreText}`;
|
|
|
7437
7463
|
try {
|
|
7438
7464
|
const entry = {
|
|
7439
7465
|
level,
|
|
7440
|
-
args: args.map((arg) =>
|
|
7441
|
-
try {
|
|
7442
|
-
return JSON.parse(JSON.stringify(arg));
|
|
7443
|
-
} catch {
|
|
7444
|
-
return String(arg);
|
|
7445
|
-
}
|
|
7446
|
-
}),
|
|
7466
|
+
args: args.map((arg) => this.serializeConsoleArg(arg)),
|
|
7447
7467
|
timestamp: Date.now()
|
|
7448
7468
|
};
|
|
7449
7469
|
if (level === "error") {
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
this.consoleBuffer.push(entry);
|
|
7453
|
-
if (this.consoleBuffer.length > 1000) {
|
|
7454
|
-
this.consoleBuffer = this.consoleBuffer.slice(-500);
|
|
7455
|
-
}
|
|
7456
|
-
if (level === "error") {
|
|
7457
|
-
if (this.state === "connected") {
|
|
7458
|
-
this.send("console", level, entry);
|
|
7459
|
-
}
|
|
7460
|
-
this.updateUI();
|
|
7470
|
+
const errArg = args.find((a) => a instanceof Error);
|
|
7471
|
+
entry.stack = errArg?.stack || new Error().stack;
|
|
7461
7472
|
}
|
|
7473
|
+
this.recordConsoleEntry(entry);
|
|
7462
7474
|
} catch {}
|
|
7463
7475
|
};
|
|
7464
7476
|
}
|
|
7477
|
+
this.installErrorCapture();
|
|
7478
|
+
}
|
|
7479
|
+
onWindowError = null;
|
|
7480
|
+
onUnhandledRejection = null;
|
|
7481
|
+
installErrorCapture() {
|
|
7482
|
+
if (typeof window === "undefined" || this.errorCaptureInstalled)
|
|
7483
|
+
return;
|
|
7484
|
+
this.errorCaptureInstalled = true;
|
|
7485
|
+
this.onWindowError = (event) => {
|
|
7486
|
+
try {
|
|
7487
|
+
const err = event.error;
|
|
7488
|
+
let message;
|
|
7489
|
+
let stack;
|
|
7490
|
+
if (err instanceof Error) {
|
|
7491
|
+
message = `Uncaught ${err.name}: ${err.message}`;
|
|
7492
|
+
stack = err.stack;
|
|
7493
|
+
} else if (event.target?.tagName) {
|
|
7494
|
+
const el = event.target;
|
|
7495
|
+
message = `Resource failed to load: <${String(el.tagName).toLowerCase()}> ${el.src || el.href || ""}`.trim();
|
|
7496
|
+
} else {
|
|
7497
|
+
message = `Uncaught error: ${event.message || "unknown"}`;
|
|
7498
|
+
}
|
|
7499
|
+
const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
|
|
7500
|
+
this.recordConsoleEntry({
|
|
7501
|
+
level: "error",
|
|
7502
|
+
args: [message + where],
|
|
7503
|
+
timestamp: Date.now(),
|
|
7504
|
+
stack
|
|
7505
|
+
});
|
|
7506
|
+
} catch {}
|
|
7507
|
+
};
|
|
7508
|
+
window.addEventListener("error", this.onWindowError, true);
|
|
7509
|
+
this.onUnhandledRejection = (event) => {
|
|
7510
|
+
try {
|
|
7511
|
+
const reason = event.reason;
|
|
7512
|
+
const args = reason instanceof Error ? [`Unhandled promise rejection: ${reason.name}: ${reason.message}`] : ["Unhandled promise rejection:", this.serializeConsoleArg(reason)];
|
|
7513
|
+
this.recordConsoleEntry({
|
|
7514
|
+
level: "error",
|
|
7515
|
+
args,
|
|
7516
|
+
timestamp: Date.now(),
|
|
7517
|
+
stack: reason instanceof Error ? reason.stack : undefined
|
|
7518
|
+
});
|
|
7519
|
+
} catch {}
|
|
7520
|
+
};
|
|
7521
|
+
window.addEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
7522
|
+
}
|
|
7523
|
+
uninstallErrorCapture() {
|
|
7524
|
+
if (typeof window !== "undefined") {
|
|
7525
|
+
if (this.onWindowError)
|
|
7526
|
+
window.removeEventListener("error", this.onWindowError, true);
|
|
7527
|
+
if (this.onUnhandledRejection)
|
|
7528
|
+
window.removeEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
7529
|
+
}
|
|
7530
|
+
this.onWindowError = null;
|
|
7531
|
+
this.onUnhandledRejection = null;
|
|
7532
|
+
this.errorCaptureInstalled = false;
|
|
7465
7533
|
}
|
|
7466
7534
|
restoreConsole() {
|
|
7467
7535
|
for (const [level, fn] of Object.entries(this.originalConsole)) {
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -1073,7 +1073,7 @@ const COMMAND_ALIASES = {
|
|
|
1073
1073
|
'execute': 'eval',
|
|
1074
1074
|
'shot': 'screenshot',
|
|
1075
1075
|
'capture': 'screenshot',
|
|
1076
|
-
|
|
1076
|
+
// NB: `ls` is NOT an alias for `tree` — it's intercepted in hj.mjs as `hj servers` (list servers).
|
|
1077
1077
|
'list': 'tree',
|
|
1078
1078
|
'show': 'tree',
|
|
1079
1079
|
'help': '--help',
|
package/bin/hj.mjs
CHANGED
|
@@ -126,6 +126,71 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* `hj servers` — enumerate every live haltija server so you can pick one when several coexist
|
|
131
|
+
* (e.g. a project server + the Electron desktop app). Sources: the registry, the well-known
|
|
132
|
+
* defaults 8700/8701 (to catch anything unregistered), and this shell's resolved target. Marks the
|
|
133
|
+
* one `hj` would drive. Pure probes + registry read; no side effects, never auto-launches.
|
|
134
|
+
*/
|
|
135
|
+
async function runServers(resolvedPort) {
|
|
136
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
137
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
138
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
139
|
+
const token = process.env.HALTIJA_TOKEN
|
|
140
|
+
|
|
141
|
+
const byPort = new Map()
|
|
142
|
+
for (const e of listLiveInstances()) {
|
|
143
|
+
byPort.set(String(e.port), { port: String(e.port), name: e.name, cwd: e.cwd })
|
|
144
|
+
}
|
|
145
|
+
for (const p of ['8700', '8701', String(resolvedPort)]) {
|
|
146
|
+
if (!byPort.has(p)) byPort.set(p, { port: p, name: null, cwd: null })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = await Promise.all(
|
|
150
|
+
[...byPort.values()].map(async (c) => {
|
|
151
|
+
try {
|
|
152
|
+
const resp = await fetch(`http://localhost:${c.port}/status`, {
|
|
153
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
154
|
+
signal: AbortSignal.timeout(2000),
|
|
155
|
+
})
|
|
156
|
+
if (!resp.ok) return { ...c, up: false }
|
|
157
|
+
const s = await resp.json()
|
|
158
|
+
return {
|
|
159
|
+
...c,
|
|
160
|
+
up: true,
|
|
161
|
+
version: s.serverVersion || '?',
|
|
162
|
+
desktopApp: !!s.desktopApp,
|
|
163
|
+
tabs: Array.isArray(s.windows) ? s.windows.length : s.browsers ?? 0,
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return { ...c, up: false }
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const up = rows.filter((r) => r.up).sort((a, b) => Number(a.port) - Number(b.port))
|
|
172
|
+
if (!up.length) {
|
|
173
|
+
console.log('No haltija servers are running.')
|
|
174
|
+
console.log(dim('Start one: bunx haltija --server (or the desktop app: bunx haltija)'))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(bold('Live haltija servers') + dim(' (▸ = what this shell targets)'))
|
|
179
|
+
for (const r of up) {
|
|
180
|
+
const here = String(r.port) === String(resolvedPort) ? green('▸') : ' '
|
|
181
|
+
const name = r.desktopApp ? 'desktop' : r.name || '(unnamed)'
|
|
182
|
+
const tabs = `${r.tabs} tab${r.tabs === 1 ? '' : 's'}`
|
|
183
|
+
const kind = r.desktopApp ? 'desktop app' : r.cwd || ''
|
|
184
|
+
console.log(
|
|
185
|
+
` ${here} ${String(r.port).padEnd(6)} ${name.padEnd(14)} v${String(r.version).padEnd(8)} ${tabs.padEnd(9)} ${dim(kind)}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (!up.some((r) => String(r.port) === String(resolvedPort))) {
|
|
189
|
+
console.log(dim(`\nThis shell targets :${resolvedPort}, but nothing is listening there.`))
|
|
190
|
+
}
|
|
191
|
+
console.log(dim('\nPick one: ') + `hj --port <n> <cmd>` + dim(' or ') + `hj --name <name> <cmd>`)
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
/**
|
|
130
195
|
* Resolve a named haltija instance to its port by reading
|
|
131
196
|
* ~/.haltija/servers/<name>.json. Returns null if the file is missing,
|
|
@@ -211,6 +276,11 @@ ${dim('Overriding that (per-shell):')}
|
|
|
211
276
|
${dim('export HALTIJA_TOKEN=secret')} # required when server was started with HALTIJA_TOKEN
|
|
212
277
|
${dim('hj --token secret tree')} # one-off token override
|
|
213
278
|
${dim('hj --version')} # which hj is this?
|
|
279
|
+
|
|
280
|
+
${dim('Lifecycle:')}
|
|
281
|
+
${dim('hj where')} # which server this shell targets + what is alive there
|
|
282
|
+
${dim('hj servers')} # list ALL live servers (pick one with --port/--name)
|
|
283
|
+
${dim('hj shutdown')} # stop the targeted server (a private --app: Electron + all)
|
|
214
284
|
${listSubcommands()}
|
|
215
285
|
Run ${dim('hj --help')} for this help.
|
|
216
286
|
Run ${dim('haltija --help')} for server/app options.
|
|
@@ -367,6 +437,43 @@ if (subcommand === 'where') {
|
|
|
367
437
|
process.exit(0)
|
|
368
438
|
}
|
|
369
439
|
|
|
440
|
+
// `hj servers` / `hj ls` — list every live haltija server (registry + defaults + this shell's
|
|
441
|
+
// target), so you can pick one when several coexist. Diagnostic; never auto-launches.
|
|
442
|
+
if (subcommand === 'servers' || subcommand === 'ls') {
|
|
443
|
+
await runServers(port)
|
|
444
|
+
process.exit(0)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// `hj shutdown` / `hj quit` — cleanly stop the targeted server. For a private `--app` instance this
|
|
448
|
+
// tears down the WHOLE thing (Electron + its child servers); for a plain server it stops that
|
|
449
|
+
// server. Never auto-launches (it's a stop command), so it's handled here before the routing table.
|
|
450
|
+
if (subcommand === 'shutdown' || subcommand === 'quit') {
|
|
451
|
+
const token = process.env.HALTIJA_TOKEN
|
|
452
|
+
try {
|
|
453
|
+
const resp = await fetch(`http://localhost:${port}/shutdown`, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
456
|
+
signal: AbortSignal.timeout(3000),
|
|
457
|
+
})
|
|
458
|
+
const j = await resp.json().catch(() => ({}))
|
|
459
|
+
if (resp.ok) {
|
|
460
|
+
console.log(j.message || `Shutdown requested on port ${port}.`)
|
|
461
|
+
process.exit(0)
|
|
462
|
+
}
|
|
463
|
+
// Surface the server's explanation (e.g. the desktop-app refusal), not a bare status code.
|
|
464
|
+
console.error(`hj ${subcommand}: ${j.error || `server on port ${port} returned HTTP ${resp.status}`}`)
|
|
465
|
+
process.exit(1)
|
|
466
|
+
} catch (err) {
|
|
467
|
+
// Nothing listening = already stopped; that's success for a stop command.
|
|
468
|
+
if (err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED') {
|
|
469
|
+
console.log(`No server listening on port ${port} (already stopped).`)
|
|
470
|
+
process.exit(0)
|
|
471
|
+
}
|
|
472
|
+
console.error(`hj ${subcommand}: ${err.message}`)
|
|
473
|
+
process.exit(1)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
370
477
|
if (!isSubcommand(subcommand)) {
|
|
371
478
|
const suggestion = getSuggestion(subcommand)
|
|
372
479
|
if (suggestion === '--help') {
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -37,12 +37,12 @@ ${bold('haltija')} - Browser control for AI agents
|
|
|
37
37
|
Usage:
|
|
38
38
|
haltija [options]
|
|
39
39
|
|
|
40
|
-
Modes:
|
|
40
|
+
Modes: ${dim('(the browser ENGINE differs — see "Choosing a CI engine" below)')}
|
|
41
41
|
${dim('(default)')} Launch desktop app if electron available, otherwise server
|
|
42
|
-
--app Explicitly launch desktop app (Electron)
|
|
43
|
-
--server Server only
|
|
44
|
-
--headless
|
|
45
|
-
--ci CI mode: Electron app + wait
|
|
42
|
+
--app Explicitly launch desktop app (Electron — Chromium)
|
|
43
|
+
--server Server only; bring your own browser (inject the widget). For bookmarklets/CI-lite
|
|
44
|
+
--headless Headless Chromium via ${bold('Playwright')} (needs the 'playwright' package) — for CI
|
|
45
|
+
--ci CI mode: ${bold('Electron')} app + wait-ready + sandbox off. No Playwright. Recommended for CI
|
|
46
46
|
|
|
47
47
|
Options:
|
|
48
48
|
--http HTTP only on port 8700 (default protocol)
|
|
@@ -62,7 +62,9 @@ Options:
|
|
|
62
62
|
--private Isolated automation instance: own server on an EPHEMERAL port (never
|
|
63
63
|
8700), own browser, torn down with the run. Never sees/adopts/navigates
|
|
64
64
|
the shared interactive browser. Reports its address on stdout
|
|
65
|
-
(HALTIJA_PRIVATE_READY {json}) and to --port-file.
|
|
65
|
+
(HALTIJA_PRIVATE_READY {json}) and to --port-file. This is an ISOLATION
|
|
66
|
+
modifier, not an engine: pair with --app (Electron, no Playwright) or
|
|
67
|
+
--headless (Playwright Chromium).
|
|
66
68
|
--port-file <p> Write the (private) server's bound address as JSON to <p> when ready.
|
|
67
69
|
--setup-mcp Configure Claude Desktop MCP integration
|
|
68
70
|
--setup-mcp-check Check MCP configuration status
|
|
@@ -106,13 +108,23 @@ Subcommands:
|
|
|
106
108
|
|
|
107
109
|
Use 'hj' as a short alias: hj tree, hj click @42, etc.
|
|
108
110
|
|
|
111
|
+
Choosing a CI engine ${dim('(both need one external browser, but a DIFFERENT one):')}
|
|
112
|
+
${bold('Electron')} (Chromium, same engine the desktop app uses) — no Playwright:
|
|
113
|
+
haltija --ci # recommended: Electron + wait-ready + sandbox off
|
|
114
|
+
haltija --private --app # + isolation (own ephemeral port, adopts nothing)
|
|
115
|
+
${dim('Electron is fetched via npx if not already installed.')}
|
|
116
|
+
${bold('Playwright')} Chromium — needs the 'playwright' package:
|
|
117
|
+
npm i playwright && npx playwright install chromium
|
|
118
|
+
haltija --headless # single-engine; or --private --headless for isolation
|
|
119
|
+
${dim("Pick this for MULTI-ENGINE coverage (Firefox/WebKit) — not just because it says \"CI\".")}
|
|
120
|
+
|
|
109
121
|
Examples:
|
|
110
122
|
haltija # Desktop app (or server fallback)
|
|
111
123
|
haltija --app # Desktop app explicitly
|
|
112
|
-
haltija --server # Server only
|
|
124
|
+
haltija --server # Server only (bring your own browser)
|
|
113
125
|
haltija --server --https # HTTPS server only
|
|
114
|
-
haltija --
|
|
115
|
-
haltija --
|
|
126
|
+
haltija --ci # CI: Electron (no Playwright) + wait + sandbox off
|
|
127
|
+
haltija --headless # CI: Playwright Chromium (needs playwright)
|
|
116
128
|
haltija --setup-mcp # Configure Claude Desktop integration
|
|
117
129
|
`)
|
|
118
130
|
process.exit(0)
|
|
@@ -568,7 +580,12 @@ function launchApp(desktopDir, port) {
|
|
|
568
580
|
const electronBinary = resolveElectronBinary()
|
|
569
581
|
// A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
|
|
570
582
|
// 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
|
|
571
|
-
|
|
583
|
+
// For a private run we also hand the app OUR pid (HALTIJA_SPAWNER_PID): the Electron process
|
|
584
|
+
// reparents to launchd shortly after startup, so it can't watch us via process.ppid — it polls
|
|
585
|
+
// this pid instead and quits when we're gone (issue #7, "torn down with the run").
|
|
586
|
+
const appEnv = privateMode
|
|
587
|
+
? { ...env, HALTIJA_SPAWNER_PID: String(process.pid) }
|
|
588
|
+
: { ...env, DEV_CHANNEL_PORT: String(port) }
|
|
572
589
|
const child = electronBinary
|
|
573
590
|
? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
574
591
|
: spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
@@ -583,6 +600,12 @@ function launchApp(desktopDir, port) {
|
|
|
583
600
|
child.on('exit', code => {
|
|
584
601
|
process.exit(code || 0)
|
|
585
602
|
})
|
|
603
|
+
|
|
604
|
+
// Belt-and-suspenders to the app's own spawner-pid poll: if WE get a signal, ask the app to quit
|
|
605
|
+
// before we exit. (If we're SIGKILLed we can't do this — that's exactly what the poll covers.)
|
|
606
|
+
const signalChild = () => { try { child.kill('SIGTERM') } catch {} ; process.exit(0) }
|
|
607
|
+
process.on('SIGTERM', signalChild)
|
|
608
|
+
process.on('SIGINT', signalChild)
|
|
586
609
|
|
|
587
610
|
// In CI/wait-ready mode, wait for server + browser to be ready
|
|
588
611
|
if (waitReady) {
|
|
@@ -729,8 +752,12 @@ const startHeadlessBrowser = async (port) => {
|
|
|
729
752
|
|
|
730
753
|
} catch (err) {
|
|
731
754
|
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
732
|
-
console.error('[tosijs-dev] Playwright not installed.
|
|
733
|
-
console.error('[tosijs-dev]
|
|
755
|
+
console.error('[tosijs-dev] --headless uses Playwright Chromium, which is not installed.')
|
|
756
|
+
console.error('[tosijs-dev] To use Playwright: npm install playwright && npx playwright install chromium')
|
|
757
|
+
console.error('[tosijs-dev] Or skip Playwright entirely and use the ELECTRON engine instead:')
|
|
758
|
+
console.error('[tosijs-dev] haltija --ci (Electron, waits for ready, sandbox off)')
|
|
759
|
+
console.error('[tosijs-dev] haltija --private --app (Electron + isolated ephemeral instance)')
|
|
760
|
+
console.error('[tosijs-dev] Reach for --headless (Playwright) when you need Firefox/WebKit coverage.')
|
|
734
761
|
} else {
|
|
735
762
|
console.error('[tosijs-dev] Failed to start headless browser:', err.message)
|
|
736
763
|
}
|
package/bin/version.mjs
CHANGED
package/dist/component.d.ts
CHANGED
|
@@ -20,13 +20,14 @@
|
|
|
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.6.0";
|
|
24
24
|
export declare class DevChannel extends HTMLElement {
|
|
25
25
|
static get tagName(): string;
|
|
26
26
|
static elementCreator(): () => DevChannel;
|
|
27
27
|
private ws;
|
|
28
28
|
private state;
|
|
29
29
|
private consoleBuffer;
|
|
30
|
+
private errorCaptureInstalled;
|
|
30
31
|
private eventWatchers;
|
|
31
32
|
private mutationObserver;
|
|
32
33
|
private shadowObservers;
|
|
@@ -396,7 +397,27 @@ export declare class DevChannel extends HTMLElement {
|
|
|
396
397
|
private replaySession;
|
|
397
398
|
private interceptDialogs;
|
|
398
399
|
private restoreDialogs;
|
|
400
|
+
/**
|
|
401
|
+
* Serialize a console argument for capture. `JSON.stringify(new Error(...))` returns `"{}"` —
|
|
402
|
+
* message and stack are non-enumerable — so a plain round-trip silently drops the single most
|
|
403
|
+
* useful thing an agent wants. Convert Errors (at any depth, via the replacer) to a structured
|
|
404
|
+
* `{name, message, stack}` instead.
|
|
405
|
+
*/
|
|
406
|
+
private serializeConsoleArg;
|
|
407
|
+
/** Record one console/error entry: buffer it, cap the buffer, and (for errors) push to the
|
|
408
|
+
* server and flag the UI. Shared by console interception AND the uncaught-error listeners. */
|
|
409
|
+
private recordConsoleEntry;
|
|
399
410
|
private interceptConsole;
|
|
411
|
+
/**
|
|
412
|
+
* Capture what `console.error` interception alone misses: uncaught exceptions and unhandled
|
|
413
|
+
* promise rejections — i.e. the errors that are actual bugs, which never route through
|
|
414
|
+
* `console.error`. Without these, `hj console` shows a page as clean while it's throwing.
|
|
415
|
+
*/
|
|
416
|
+
private onWindowError;
|
|
417
|
+
private onUnhandledRejection;
|
|
418
|
+
private installErrorCapture;
|
|
419
|
+
/** Remove the uncaught-error/rejection listeners so they don't accumulate across re-injection. */
|
|
420
|
+
private uninstallErrorCapture;
|
|
400
421
|
private restoreConsole;
|
|
401
422
|
}
|
|
402
423
|
/**
|