haltija 1.13.0-beta.2 → 1.13.0-beta.4
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 +136 -0
- package/README.md +3 -1
- package/apps/desktop/main.js +3 -1
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +67 -10
- package/apps/desktop/server-env.js +8 -1
- package/bin/cli-commands.mjs +2 -0
- package/bin/cli-subcommand.mjs +14 -1
- package/bin/hj.mjs +52 -5
- package/bin/mcp-locate.mjs +33 -0
- package/bin/mcp-setup.mjs +13 -41
- package/bin/tosijs-dev.mjs +32 -25
- package/bin/version.mjs +1 -1
- package/dist/cli-commands.d.ts +1 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.js +9 -5
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +67 -10
- package/dist/component.js +67 -10
- package/dist/desktop-server-env.d.ts +7 -0
- package/dist/dialog-policy.d.ts +12 -0
- package/dist/hj.js +51 -8
- package/dist/index.js +169 -65
- package/dist/mcp.js +28149 -0
- package/dist/page-wait.d.ts +8 -0
- package/dist/server.js +160 -60
- package/dist/test-in-browser.d.ts +70 -5
- package/dist/test.d.ts +4 -1
- package/dist/test.js +147 -124
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +11 -7
- package/docs/TEST-IN-BROWSER.md +144 -0
- package/llms.txt +3 -3
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,141 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.13.0-beta.4 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Dialogs can be let through to a person: `hj dialog-configure --alert show`** (virta #3274).
|
|
8
|
+
`show` is a new policy value for `alert`, `confirm` and `prompt` (also `POST /dialog/configure
|
|
9
|
+
{"alert":"show"}`). The dialog reaches the person at the page, and it is still recorded and
|
|
10
|
+
reported with their answer (`shown: true` in `/dialog/history`). It is opt-in: interception stays
|
|
11
|
+
the default, because a native modal blocks the page and an agent driving it cannot see or answer
|
|
12
|
+
one. The policy lives in the tab and resets on reload.
|
|
13
|
+
- `hj dialog-configure` and `hj dialog-history` are now real commands. Both endpoints declared
|
|
14
|
+
those CLI names, but neither was ever invokable.
|
|
15
|
+
- An unknown policy value is refused with the valid ones named. It used to answer success and
|
|
16
|
+
change nothing; for `prompt`, every prompt then returned `undefined`.
|
|
17
|
+
- The documented response shapes of both endpoints are corrected to `{ success, data: … }`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **An intercepted `alert`, `confirm` or `prompt` now says so on the page's console** (virta #3274).
|
|
22
|
+
The dev channel answers native dialogs so an agent driving the page is never blocked, but a person
|
|
23
|
+
using the same page saw `alert()` do nothing, with no hint why. Each intercepted dialog now logs
|
|
24
|
+
one `[haltija] intercepted …` line naming what it answered and how to show dialogs instead. The line
|
|
25
|
+
goes to the real console only; it is not captured as page output in `/console`. Interception
|
|
26
|
+
itself is unchanged.
|
|
27
|
+
|
|
28
|
+
- **`press` in `haltija/test` (and `DevChannelClient.press`) is a real key sequence at the focused
|
|
29
|
+
element** (virta #3121). It goes through `/key`, like `hj key`: the modifier keys go down, then
|
|
30
|
+
keydown → … → keyup for the key, then the modifiers come up. It used to be one `keydown` dispatched
|
|
31
|
+
at `body`, so a handler on a focused input, or one listening for `keyup`, never fired. A test that
|
|
32
|
+
relied on the event's target being `body` will now see the focused element instead.
|
|
33
|
+
|
|
34
|
+
- **With nothing focused, `hj doctor` judges the tab commands actually reach** (virta #3123). It
|
|
35
|
+
picked the first visible tab, where the server picks the most recently seen active one, so its
|
|
36
|
+
`observability:` line could describe a tab no command goes to. `/status` now reports
|
|
37
|
+
`targetWindowId` (the server's own answer) and doctor reads it; against an older server it falls
|
|
38
|
+
back as before.
|
|
39
|
+
|
|
40
|
+
- **`clickAt` and `dragFrom` refuse the shared default 8700 like `click` does** (virta #3120). They
|
|
41
|
+
act through `eval`, which `haltija/test` leaves open on the shared port so reads keep working, so
|
|
42
|
+
with no `HALTIJA_PORT` / `HALTIJA_URL` they dispatched pointer events into whatever tab was focused
|
|
43
|
+
there, possibly another project's. `read` and `hj.eval` are unchanged; `HALTIJA_TEST_ALLOW_SHARED=1`
|
|
44
|
+
still opts in.
|
|
45
|
+
|
|
46
|
+
- **`hj test validate <file>` works** (GitHub #55). It returned HTTP 500 ("undefined is not an
|
|
47
|
+
object (evaluating 'test.steps.length')") for every file, because the CLI sends `{ test }` as the
|
|
48
|
+
schema documents and the server read only a bare test. Both shapes are accepted now; a body with
|
|
49
|
+
no `steps` array is a 400 that says so. The documented response shape is corrected to what the
|
|
50
|
+
endpoint returns: `{ valid, issues, stepCount }`.
|
|
51
|
+
|
|
52
|
+
- **The MCP server works from npm** (GitHub #46, #47). `haltija --setup-mcp` (Claude Desktop) and
|
|
53
|
+
`haltija-mcp-setup` (Claude Code) both exited 1 for every npm user: they looked for
|
|
54
|
+
`apps/mcp/build/index.js`, which the package never contained. Even if it had been shipped, that
|
|
55
|
+
file imported an SDK npm never installs for a nested package. The server now ships as
|
|
56
|
+
`dist/mcp.js` (bin `haltija-mcp`), one bundle with its dependencies inlined, about 170 KB gzipped,
|
|
57
|
+
and adds no dependencies to an install.
|
|
58
|
+
- Both setup commands write a command that starts it. When the package runs from an npx/bunx
|
|
59
|
+
cache, they write a version-pinned `npx -p haltija@<version> haltija-mcp` instead of a path the
|
|
60
|
+
cache will delete.
|
|
61
|
+
- The server sends `HALTIJA_TOKEN`, so it works against a server started with `--token`, and it
|
|
62
|
+
reports haltija's version instead of `0.1.0`.
|
|
63
|
+
- It compiles again: `tsc` failed with TS2589 on handler types inferred from a runtime-built shape.
|
|
64
|
+
In-range updates cleared ten advisories (1 critical, 3 high).
|
|
65
|
+
- Tested the way a user gets it: a new test packs the tarball, extracts it with no `node_modules`
|
|
66
|
+
above it, runs each setup command, and calls a tool through the written config against a live,
|
|
67
|
+
token-protected server.
|
|
68
|
+
|
|
69
|
+
- **The test runner's `wait` with `forWindow: true` no longer stops at an iframe, and returns as
|
|
70
|
+
soon as the window appears** (virta #2468). It accepted any new window, including a frame
|
|
71
|
+
injecting its own widget, and then slept a fixed 300 ms. A window registers on its `connected`
|
|
72
|
+
message, so it already takes commands at that point. The step now waits for a new tab or popup.
|
|
73
|
+
|
|
74
|
+
- **The desktop app says when it ignores your `DEV_CHANNEL_MODE`** (virta #1078). Its public
|
|
75
|
+
server always serves both transports, because it serves other projects' pages too. That is
|
|
76
|
+
unchanged, but an explicit `http` or `https` used to be dropped without a word. It now prints one
|
|
77
|
+
line naming what was ignored and why.
|
|
78
|
+
|
|
79
|
+
## 1.13.0-beta.3 (2026-10-08)
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- **`hj doctor` prints an `observability:` line whenever a tab is connected** (#821, GitHub #50). It
|
|
84
|
+
says whether the tab that commands go to is visible and painting and, for widgets 1.12.6 or
|
|
85
|
+
newer, how long ago it last drew a frame. When
|
|
86
|
+
it isn't, the line says what that invalidates: geometry, screenshots and anything rAF-driven. A
|
|
87
|
+
backgrounded tab still answers, just wrongly, so this matters even when nothing fails. Also in
|
|
88
|
+
`--json` as `observability`.
|
|
89
|
+
|
|
90
|
+
- **`hj map` reads tosijs's shipped agent-surface contract** (#787, GitHub #16). It reads
|
|
91
|
+
`describe().version`, `exposure` and `writable` instead of inferring them, and was verified
|
|
92
|
+
against real tosijs 1.10.6.
|
|
93
|
+
- A read-only surface (a manifest without `write: true`) is reported as read-only. The map used
|
|
94
|
+
to tell agents to call `write()`, which the surface refuses.
|
|
95
|
+
- The callable actions are listed.
|
|
96
|
+
- An empty `'closed'` surface, the tosijs default, comes with a hint on how the app's author
|
|
97
|
+
enables it.
|
|
98
|
+
- A surface version haltija doesn't read produces a warning.
|
|
99
|
+
- The tier is no longer marked experimental, in the skill or in the `/map` API description.
|
|
100
|
+
|
|
101
|
+
- **`playwrightBridge(page)`** in `haltija/test`: runs `testInBrowser` probes in Firefox and WebKit
|
|
102
|
+
through a Playwright page you launch (virta #3088). Tested in all three engines. New
|
|
103
|
+
`docs/TEST-IN-BROWSER.md` covers it, the bridge contract, and which engine each route reaches.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- **A bridge that wraps its `eval` reply is now named as the problem** (virta #2415).
|
|
108
|
+
`BrowserBridge.eval` was typed `Promise<any>` with no stated contract, and a bridge returning
|
|
109
|
+
`{ success: true, value }` made every `read()` fail with a bare "probe threw" although the probe
|
|
110
|
+
had run. The return shape is now typed and documented, and the error says the bridge returned no
|
|
111
|
+
`{ ok }` envelope and lists the keys it did return.
|
|
112
|
+
|
|
113
|
+
- Type change: `BrowserBridge.eval` now resolves to `unknown` rather than `any`. Bridges you
|
|
114
|
+
wrote still compile. Code that calls `.eval()` on a `BrowserBridge`-typed value and reads a
|
|
115
|
+
property off the result needs a cast.
|
|
116
|
+
|
|
117
|
+
- **`haltija --server` no longer leaves its server running when the launcher is stopped.** The
|
|
118
|
+
server is a child process, and nothing passed a signal on, so a process manager (or a closed
|
|
119
|
+
terminal) that stopped the launcher left the server alive and holding its ports. SIGTERM, SIGINT
|
|
120
|
+
and SIGHUP now stop both.
|
|
121
|
+
|
|
122
|
+
- **The docs no longer say `--headless` gives Firefox/WebKit coverage.** It launches Chromium and
|
|
123
|
+
has no engine flag. `--help`, the CI guide, the skill and `/llms.txt` all said to pick it for
|
|
124
|
+
multi-engine coverage.
|
|
125
|
+
|
|
126
|
+
- **`hj doctor` checks visibility on the tab commands actually go to**, not across all tabs. A
|
|
127
|
+
hidden target used to pass with a note as long as some other tab was visible, while every
|
|
128
|
+
command went to the hidden one. For a project with declared origins that is the tab its
|
|
129
|
+
commands are pinned to, whichever tab has focus.
|
|
130
|
+
|
|
131
|
+
- **Freeing a busy HTTPS port is now deliberate.** It used to probe the HTTPS port with plain HTTP,
|
|
132
|
+
which always failed, so the holder was refused as "a server we could not identify". That was the
|
|
133
|
+
right outcome only by accident, and the message misled (#1079).
|
|
134
|
+
- The HTTPS port is freed only when it was itself explicitly requested (`--https-port` /
|
|
135
|
+
`DEV_CHANNEL_HTTPS_PORT`). Being strict about the HTTP port says nothing about the HTTPS one,
|
|
136
|
+
and stopping a server's HTTPS listener stops its whole process, HTTP included.
|
|
137
|
+
- The holder is identified over TLS and asked to stop, under the same rules as the HTTP port.
|
|
138
|
+
|
|
3
139
|
## 1.13.0-beta.2 (2026-09-29)
|
|
4
140
|
|
|
5
141
|
### Changed
|
package/README.md
CHANGED
|
@@ -31,7 +31,9 @@ Paste this into your agent conversation:
|
|
|
31
31
|
|
|
32
32
|
> I have the `hj` browser tool. Run `hj tree` to see the page, `hj click <id>` to interact, and `hj docs` for help.
|
|
33
33
|
|
|
34
|
-
Or
|
|
34
|
+
Or as MCP tools: `npx -p haltija haltija-mcp-setup` in a project (Claude Code, writes `.mcp.json`), or
|
|
35
|
+
`bunx haltija --setup-mcp` (Claude Desktop). Claude Code can load the haltija skill instead:
|
|
36
|
+
`/plugin marketplace add tonioloewald/haltija`, then `/plugin install haltija-skill@haltija`.
|
|
35
37
|
|
|
36
38
|
---
|
|
37
39
|
|
package/apps/desktop/main.js
CHANGED
|
@@ -24,7 +24,7 @@ const os = require('os')
|
|
|
24
24
|
const { spawn } = require('child_process')
|
|
25
25
|
const http = require('http')
|
|
26
26
|
const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork, isMonitoring } = require('./cdp-network.js')
|
|
27
|
-
const { buildServerEnv } = require('./server-env.js')
|
|
27
|
+
const { buildServerEnv, overriddenModeNotice } = require('./server-env.js')
|
|
28
28
|
const { DEFAULT_HTTP_PORT, DEFAULT_INTERNAL_PORT, internalPortConflict } = require('./ports.js')
|
|
29
29
|
// The wire protocol, generated from src/machine-channel.ts — NOT re-declared here. main.js used to
|
|
30
30
|
// hand-roll the prefixes, the line split and the parse; that copy is where the __NEED_WINDOW__
|
|
@@ -1384,6 +1384,8 @@ ipcMain.handle('machine-request', async (_evt, path, init) => {
|
|
|
1384
1384
|
function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir, portFile }) {
|
|
1385
1385
|
// Built by src/desktop-server-env.ts, whose contract is unit-tested without launching Electron —
|
|
1386
1386
|
// getting this wrong is silent (a child that ignores its port, binds the parent's, and dies).
|
|
1387
|
+
const modeNotice = overriddenModeNotice(process.env, role === 'public' ? 'public' : 'internal')
|
|
1388
|
+
if (modeNotice) console.warn(modeNotice)
|
|
1387
1389
|
const env = buildServerEnv(process.env, {
|
|
1388
1390
|
port,
|
|
1389
1391
|
role: role === 'public' ? 'public' : 'internal',
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.13.0-beta.
|
|
49
|
+
var VERSION = "1.13.0-beta.4";
|
|
50
50
|
|
|
51
51
|
// src/ws-url.ts
|
|
52
52
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -228,6 +228,29 @@
|
|
|
228
228
|
return { width: W, height: H };
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// src/dialog-policy.ts
|
|
232
|
+
var DIALOG_POLICY_VALUES = {
|
|
233
|
+
alert: ["dismiss", "show"],
|
|
234
|
+
confirm: ["accept", "dismiss", "show"],
|
|
235
|
+
prompt: ["dismiss", "show", '{ "response": "text" }'],
|
|
236
|
+
beforeunload: ["allow", "block"]
|
|
237
|
+
};
|
|
238
|
+
function dialogPolicyError(payload2) {
|
|
239
|
+
if (!payload2)
|
|
240
|
+
return null;
|
|
241
|
+
for (const key of ["alert", "confirm", "beforeunload"]) {
|
|
242
|
+
const v = payload2[key];
|
|
243
|
+
if (v !== undefined && !DIALOG_POLICY_VALUES[key].includes(v)) {
|
|
244
|
+
return `dialog ${key} must be one of ${DIALOG_POLICY_VALUES[key].map((x) => `"${x}"`).join(", ")}; got ${JSON.stringify(v)}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const p = payload2.prompt;
|
|
248
|
+
if (p !== undefined && p !== "dismiss" && p !== "show" && !(p && typeof p === "object" && typeof p.response === "string")) {
|
|
249
|
+
return `dialog prompt must be "dismiss", "show" or { "response": "text" }; got ${JSON.stringify(p)}`;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
231
254
|
// src/component.ts
|
|
232
255
|
var VERSION2 = VERSION;
|
|
233
256
|
var PRODUCT_NAME = "Haltija";
|
|
@@ -1374,16 +1397,26 @@
|
|
|
1374
1397
|
const description = agent.describe();
|
|
1375
1398
|
const wiring = description?.wiring;
|
|
1376
1399
|
const shapeWarning = wiring === undefined ? `${globalName}.describe() returned no 'wiring' — this haltija understands the agent ` + `surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through ` + `unchanged and may be missing structure; the schematic will be empty. Check the ` + `tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).` : !Array.isArray(wiring) ? `${globalName}.describe().wiring is ${typeof wiring}, expected an array — passing it ` + `through unchanged, but the schematic cannot render it.` : undefined;
|
|
1400
|
+
const d = description;
|
|
1401
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
1402
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
1403
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? `${globalName} reports agent surface ${surfaceVersion.surface}; this haltija reads surface ` + `1.x, so fields may be missing or misread. Update haltija.` : undefined;
|
|
1404
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
1405
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? `The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables ` + `it with expose: { roots } (add write: true to allow write()). Until then, find controls ` + `with hj tree and drive them with hj click / hj type.` : undefined;
|
|
1406
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
1407
|
+
const writeAdvice = d?.writable === false ? `This surface is READ-ONLY (describe().writable is false): ${globalName}.write() will be ` + `refused, so act through actions or realistic input instead.` : d?.writable === true ? `${globalName}.write(path, value) for a ⟷ two-way binding.` : `${globalName}.write(path, value) for a ⟷ two-way binding (writability not reported ` + `by this surface; it may refuse).`;
|
|
1408
|
+
const callAdvice = actions && actions.length === 0 ? `It exposes no actions.` : `${globalName}.call(actionPath) for an action${actions ? ` (one of: ${actions.slice(0, 8).join(", ")}${actions.length > 8 ? ", …" : ""})` : ""}.`;
|
|
1377
1409
|
return {
|
|
1378
1410
|
url: location.href,
|
|
1379
1411
|
title: document.title,
|
|
1380
1412
|
source: "tosi-agent",
|
|
1381
1413
|
global: globalName,
|
|
1382
|
-
agentSurfaceVersion:
|
|
1383
|
-
...
|
|
1414
|
+
agentSurfaceVersion: surfaceVersion,
|
|
1415
|
+
...warning ? { warning } : {},
|
|
1416
|
+
...closedHint ? { hint: closedHint } : {},
|
|
1384
1417
|
...description,
|
|
1385
1418
|
act: {
|
|
1386
|
-
note: `Act through the paths, not synthesized input
|
|
1419
|
+
note: `Act through the paths, not synthesized input. ${writeAdvice} ${callAdvice}` + (actions && actions.length === 0 ? "" : ` Run one with: hj eval "${globalName}.call('some.action')"`),
|
|
1387
1420
|
legend: {
|
|
1388
1421
|
"⟷": "two-way binding — user-writable; writing the path updates the UI and app state",
|
|
1389
1422
|
"⟵": "bound to DOM — display only; it reflects the path, writing the DOM will not stick",
|
|
@@ -6455,6 +6488,11 @@ ${elementSummary}${moreText}`;
|
|
|
6455
6488
|
handleDialogMessage(msg2) {
|
|
6456
6489
|
const { action: action2, payload: payload2 } = msg2;
|
|
6457
6490
|
if (action2 === "configure") {
|
|
6491
|
+
const bad = dialogPolicyError(payload2);
|
|
6492
|
+
if (bad) {
|
|
6493
|
+
this.respond(msg2.id, false, undefined, bad);
|
|
6494
|
+
return;
|
|
6495
|
+
}
|
|
6458
6496
|
if (payload2.alert !== undefined)
|
|
6459
6497
|
this.dialogPolicy.alert = payload2.alert;
|
|
6460
6498
|
if (payload2.confirm !== undefined)
|
|
@@ -8669,22 +8707,32 @@ ${elementSummary}${moreText}`;
|
|
|
8669
8707
|
this.originalDialogs.confirm = window.confirm.bind(window);
|
|
8670
8708
|
this.originalDialogs.prompt = window.prompt.bind(window);
|
|
8671
8709
|
const self = this;
|
|
8710
|
+
const announce = (type, msg2, outcome) => {
|
|
8711
|
+
const info = self.originalConsole.info ?? console.info;
|
|
8712
|
+
const shown = msg2.length > 80 ? msg2.slice(0, 77) + "..." : msg2;
|
|
8713
|
+
info.call(console, `[haltija] intercepted ${type}(${JSON.stringify(shown)}) and ${outcome}: the dev channel answers native dialogs so an agent driving the page is never blocked by one. To show dialogs instead: hj dialog-configure --alert show (or --confirm / --prompt show).`);
|
|
8714
|
+
};
|
|
8672
8715
|
window.alert = function(message) {
|
|
8673
8716
|
if (self._inHaltijaUI)
|
|
8674
8717
|
return self.originalDialogs.alert(message);
|
|
8675
8718
|
const msg2 = String(message ?? "");
|
|
8676
|
-
const
|
|
8719
|
+
const shown = self.dialogPolicy.alert === "show";
|
|
8720
|
+
const entry = { type: "alert", message: msg2, response: undefined, shown, timestamp: Date.now() };
|
|
8677
8721
|
self.dialogHistory.push(entry);
|
|
8678
8722
|
if (self.dialogHistory.length > 50)
|
|
8679
8723
|
self.dialogHistory = self.dialogHistory.slice(-25);
|
|
8680
|
-
self.send("dialog", "opened", { type: "alert", message: msg2, windowId: self.windowId });
|
|
8724
|
+
self.send("dialog", "opened", { type: "alert", message: msg2, shown, windowId: self.windowId });
|
|
8725
|
+
if (shown)
|
|
8726
|
+
return self.originalDialogs.alert(message);
|
|
8727
|
+
announce("alert", msg2, "did not show it");
|
|
8681
8728
|
};
|
|
8682
8729
|
window.confirm = function(message) {
|
|
8683
8730
|
if (self._inHaltijaUI)
|
|
8684
8731
|
return self.originalDialogs.confirm(message);
|
|
8685
8732
|
const msg2 = String(message ?? "");
|
|
8686
|
-
const
|
|
8687
|
-
const
|
|
8733
|
+
const shown = self.dialogPolicy.confirm === "show";
|
|
8734
|
+
const accept = shown ? self.originalDialogs.confirm(message) : self.dialogPolicy.confirm === "accept";
|
|
8735
|
+
const entry = { type: "confirm", message: msg2, response: accept, shown, timestamp: Date.now() };
|
|
8688
8736
|
self.dialogHistory.push(entry);
|
|
8689
8737
|
if (self.dialogHistory.length > 50)
|
|
8690
8738
|
self.dialogHistory = self.dialogHistory.slice(-25);
|
|
@@ -8692,8 +8740,12 @@ ${elementSummary}${moreText}`;
|
|
|
8692
8740
|
type: "confirm",
|
|
8693
8741
|
message: msg2,
|
|
8694
8742
|
response: accept,
|
|
8743
|
+
shown,
|
|
8695
8744
|
windowId: self.windowId
|
|
8696
8745
|
});
|
|
8746
|
+
if (shown)
|
|
8747
|
+
return accept;
|
|
8748
|
+
announce("confirm", msg2, `answered ${accept ? "OK" : "Cancel"} without showing it`);
|
|
8697
8749
|
return accept;
|
|
8698
8750
|
};
|
|
8699
8751
|
window.prompt = function(message, defaultValue) {
|
|
@@ -8701,8 +8753,9 @@ ${elementSummary}${moreText}`;
|
|
|
8701
8753
|
return self.originalDialogs.prompt(message, defaultValue);
|
|
8702
8754
|
const msg2 = String(message ?? "");
|
|
8703
8755
|
const policy = self.dialogPolicy.prompt;
|
|
8704
|
-
const
|
|
8705
|
-
const
|
|
8756
|
+
const shown = policy === "show";
|
|
8757
|
+
const response = shown ? self.originalDialogs.prompt(message, defaultValue) : policy === "dismiss" ? null : policy.response;
|
|
8758
|
+
const entry = { type: "prompt", message: msg2, defaultValue, response, shown, timestamp: Date.now() };
|
|
8706
8759
|
self.dialogHistory.push(entry);
|
|
8707
8760
|
if (self.dialogHistory.length > 50)
|
|
8708
8761
|
self.dialogHistory = self.dialogHistory.slice(-25);
|
|
@@ -8711,8 +8764,12 @@ ${elementSummary}${moreText}`;
|
|
|
8711
8764
|
message: msg2,
|
|
8712
8765
|
defaultValue,
|
|
8713
8766
|
response,
|
|
8767
|
+
shown,
|
|
8714
8768
|
windowId: self.windowId
|
|
8715
8769
|
});
|
|
8770
|
+
if (shown)
|
|
8771
|
+
return response;
|
|
8772
|
+
announce("prompt", msg2, `answered ${response === null ? "Cancel" : JSON.stringify(response)} without showing it`);
|
|
8716
8773
|
return response;
|
|
8717
8774
|
};
|
|
8718
8775
|
}
|
|
@@ -40,9 +40,16 @@ var __export = (target, all) => {
|
|
|
40
40
|
// src/desktop-server-env.ts
|
|
41
41
|
var exports_desktop_server_env = {};
|
|
42
42
|
__export(exports_desktop_server_env, {
|
|
43
|
-
buildServerEnv: () => buildServerEnv
|
|
43
|
+
buildServerEnv: () => buildServerEnv,
|
|
44
|
+
overriddenModeNotice: () => overriddenModeNotice
|
|
44
45
|
});
|
|
45
46
|
module.exports = __toCommonJS(exports_desktop_server_env);
|
|
47
|
+
function overriddenModeNotice(base, role) {
|
|
48
|
+
const asked = base.DEV_CHANNEL_MODE?.trim();
|
|
49
|
+
if (role !== "public" || !asked || asked.toLowerCase() === "both")
|
|
50
|
+
return null;
|
|
51
|
+
return `[haltija] ignoring DEV_CHANNEL_MODE=${asked} for the desktop app's public server: it serves ` + `HTTP and HTTPS ('both'), because it serves other projects' pages too and one project's ` + `transport choice would decide for all of them whether https:// pages can connect (#32). For a ` + `single-transport server of your own, run \`haltija --server\` with that mode instead.`;
|
|
52
|
+
}
|
|
46
53
|
function buildServerEnv(base, opts) {
|
|
47
54
|
const port = String(opts.port);
|
|
48
55
|
const env = {};
|
package/bin/cli-commands.mjs
CHANGED
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -112,12 +112,14 @@ export const COMPOUND_PATHS = {
|
|
|
112
112
|
'network-watch': '/network/watch',
|
|
113
113
|
'network-unwatch': '/network/unwatch',
|
|
114
114
|
'network-stats': '/network/stats',
|
|
115
|
+
'dialog-configure': '/dialog/configure',
|
|
116
|
+
'dialog-history': '/dialog/history',
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
// GET compound endpoints
|
|
118
120
|
export const GET_COMPOUND = new Set([
|
|
119
121
|
'mutations-status', 'events-stats', 'select-status', 'select-result', 'video-status',
|
|
120
|
-
'network-stats'
|
|
122
|
+
'network-stats', 'dialog-history'
|
|
121
123
|
])
|
|
122
124
|
|
|
123
125
|
// How to map positional args to body fields for each endpoint
|
|
@@ -344,6 +346,14 @@ export const ARG_MAPS = {
|
|
|
344
346
|
return body
|
|
345
347
|
},
|
|
346
348
|
'video-stop': () => ({}),
|
|
349
|
+
// `--prompt` takes "dismiss", "show", or the text to answer with — any other word is the answer.
|
|
350
|
+
'dialog-configure': (args) => {
|
|
351
|
+
const { flags } = takeFlags(args, { '--alert': 'str', '--confirm': 'str', '--prompt': 'str', '--beforeunload': 'str' })
|
|
352
|
+
if (typeof flags.prompt === 'string' && flags.prompt !== 'dismiss' && flags.prompt !== 'show') {
|
|
353
|
+
flags.prompt = { response: flags.prompt }
|
|
354
|
+
}
|
|
355
|
+
return flags
|
|
356
|
+
},
|
|
347
357
|
'events-watch': (args) => ({ preset: presetArg(args, 'interactive') }),
|
|
348
358
|
// These two had the ORIGINAL args[0] bug still in them, one line below the comment describing the
|
|
349
359
|
// fix — `hj mutations-watch --preset smart` sent preset:'--preset', `FILTER_PRESETS[preset]` came
|
|
@@ -952,6 +962,7 @@ export const KNOWN_FLAGS = {
|
|
|
952
962
|
refresh: ['--soft', '--no-wait', '--timeout'],
|
|
953
963
|
navigate: ['--no-wait', '--timeout'],
|
|
954
964
|
'test-run': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
965
|
+
'dialog-configure': ['--alert', '--confirm', '--prompt', '--beforeunload'],
|
|
955
966
|
'test-validate': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
956
967
|
'test-suite': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
957
968
|
// Added after an invariant test derived them from the parsers themselves. Missing entries are
|
|
@@ -1623,6 +1634,8 @@ export function listSubcommands() {
|
|
|
1623
1634
|
refresh [--soft] [--no-wait] Reload page; waits for it to come back
|
|
1624
1635
|
tabs ${dim('open|close|focus')} Tab management (default: list)
|
|
1625
1636
|
eval <code> Run JS in browser
|
|
1637
|
+
dialog-configure --alert show Let alert/confirm/prompt reach a person (default: answered)
|
|
1638
|
+
dialog-history Dialogs the page opened, and how each was answered
|
|
1626
1639
|
|
|
1627
1640
|
${bold('Test')}
|
|
1628
1641
|
test ${dim('run|suite|validate')} <file> Run tests (default: run)
|
package/bin/hj.mjs
CHANGED
|
@@ -79,7 +79,7 @@ function describeOrigins(windows) {
|
|
|
79
79
|
return { line: yellow(msg), problem: msg }
|
|
80
80
|
}
|
|
81
81
|
const list = declared.origins.join(', ')
|
|
82
|
-
const routing = routeByDeclaredOrigin(declared.origins, windows || [], null)
|
|
82
|
+
const routing = routeByDeclaredOrigin(declared.origins, windows || [], (windows || []).find((w) => w.focused)?.id ?? null)
|
|
83
83
|
if (routing.kind === 'matched') {
|
|
84
84
|
return { line: `${list} ${dim(`(${declared.source}) → window ${routing.windowId}`)}`, problem: null }
|
|
85
85
|
}
|
|
@@ -331,6 +331,11 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
331
331
|
// this command exists to remove.
|
|
332
332
|
const unchecked = []
|
|
333
333
|
let status = null
|
|
334
|
+
// First-class answer to "can I believe what this tab tells me?" (#821, GitHub #50). Everything
|
|
335
|
+
// else doctor checks is about reaching a tab; this is about whether its answers are trustworthy.
|
|
336
|
+
// A hidden or non-compositing tab still answers — geometry, screenshots and anything rAF-driven
|
|
337
|
+
// just come back wrong — so it is printed on every run, not only when something fails.
|
|
338
|
+
let observability = null
|
|
334
339
|
|
|
335
340
|
try {
|
|
336
341
|
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
@@ -365,6 +370,25 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
365
370
|
// Reads `active` via the shared predicate: /status and /windows disagreed on polarity, and
|
|
366
371
|
// keying on one endpoint's field name is what made this diverge from the origin router.
|
|
367
372
|
const hidden = tabs.filter((w) => !isVisible(w))
|
|
373
|
+
// The tab commands go to, resolved the way a command resolves it. Judging visibility over ALL
|
|
374
|
+
// tabs passed a lane whose target was hidden as long as some OTHER tab was visible — every
|
|
375
|
+
// command still went to the hidden one.
|
|
376
|
+
//
|
|
377
|
+
// A project with declared origins has its commands PINNED to the matching tab, wherever focus
|
|
378
|
+
// is. The first version of this looked only at focus, so for exactly the projects that
|
|
379
|
+
// declared their tabs it described another project's tab: a false pass when ours was hidden
|
|
380
|
+
// behind their visible one, a false failure the other way round (1.13.0-beta.3 review).
|
|
381
|
+
const focusedTab = tabs.find((w) => w.focused) ?? null
|
|
382
|
+
const declaredOrigins = findProjectOrigins(process.cwd(), process.env)
|
|
383
|
+
const routed = declaredOrigins?.origins.length
|
|
384
|
+
? routeByDeclaredOrigin(declaredOrigins.origins, tabs, focusedTab?.id ?? null)
|
|
385
|
+
: null
|
|
386
|
+
const pinned = routed?.kind === 'matched' ? tabs.find((w) => w.id === routed.windowId) ?? null : null
|
|
387
|
+
// Unpinned, it is whatever the server says it would target (#3123); the local fallback is only
|
|
388
|
+
// for servers older than 1.13.0-beta.4, which don't report it.
|
|
389
|
+
const serverTarget = status.targetWindowId ? tabs.find((w) => w.id === status.targetWindowId) ?? null : null
|
|
390
|
+
const target = pinned ?? serverTarget ?? focusedTab ?? tabs.find((w) => isVisible(w)) ?? tabs[0] ?? null
|
|
391
|
+
const targetLabel = target ? `${target.title && target.title !== '(untitled)' ? `"${target.title}" ` : ''}${target.url || target.id}` : ''
|
|
368
392
|
// A tab that reported NEITHER field hasn't told us anything; `isVisible` had to assume. Say so
|
|
369
393
|
// rather than counting the assumption as a passed check.
|
|
370
394
|
const silent = tabs.filter((w) => !visibilityKnown(w))
|
|
@@ -378,14 +402,16 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
378
402
|
`Upgrade the server to make this checkable.`,
|
|
379
403
|
)
|
|
380
404
|
}
|
|
381
|
-
if (ready &&
|
|
405
|
+
if (ready && target && !isVisible(target)) {
|
|
382
406
|
problems.push(
|
|
383
|
-
`
|
|
384
|
-
`
|
|
407
|
+
`the tab commands go to (${targetLabel}) reports HIDDEN — it still answers, but geometry, ` +
|
|
408
|
+
`screenshots and anything rAF-driven WILL be wrong (timers and rendering are throttled). ` +
|
|
409
|
+
`Bring it to the front, or target a visible tab with --window <id>.`,
|
|
385
410
|
)
|
|
386
411
|
} else if (hidden.length) {
|
|
387
412
|
notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`)
|
|
388
413
|
}
|
|
414
|
+
if (ready && target) observability = { target: target.id, label: targetLabel, visible: isVisible(target), painting: null, paintAgeMs: null }
|
|
389
415
|
if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
|
|
390
416
|
notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
|
|
391
417
|
}
|
|
@@ -413,7 +439,8 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
413
439
|
// socket that never answers `/eval` (a widget mid-teardown, or a test harness holding an
|
|
414
440
|
// open WebSocket) made `hj doctor` block for the server's full browser timeout.
|
|
415
441
|
const cancel = AbortSignal.timeout(3000)
|
|
416
|
-
|
|
442
|
+
// Asked of the TARGET tab. Untargeted, it measured whichever tab had focus.
|
|
443
|
+
const r = await fetch(`http://localhost:${port}/eval${target ? `?window=${encodeURIComponent(target.id)}` : ''}`, {
|
|
417
444
|
method: 'POST',
|
|
418
445
|
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Haltija-Token': token } : {}) },
|
|
419
446
|
body: JSON.stringify({ code: probe }),
|
|
@@ -435,6 +462,10 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
435
462
|
} catch {
|
|
436
463
|
// Fall through to the unchecked branch — never a pass.
|
|
437
464
|
}
|
|
465
|
+
if (observability && raf !== null) {
|
|
466
|
+
observability.painting = !!raf.fired
|
|
467
|
+
observability.paintAgeMs = raf.measured ? raf.ms : null
|
|
468
|
+
}
|
|
438
469
|
if (raf === null) {
|
|
439
470
|
unchecked.push(
|
|
440
471
|
`could not run the requestAnimationFrame probe — whether this tab actually paints is ` +
|
|
@@ -484,6 +515,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
484
515
|
ready: status ? (typeof status.ready === 'boolean' ? status.ready : (status.windows?.length ?? 0) > 0) : false,
|
|
485
516
|
tabs: status?.windows?.length ?? 0,
|
|
486
517
|
problems, notes,
|
|
518
|
+
observability,
|
|
487
519
|
origins: (() => {
|
|
488
520
|
const d = findProjectOrigins(process.cwd(), process.env)
|
|
489
521
|
return d ? { declared: d.origins, source: d.source } : null
|
|
@@ -501,6 +533,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
501
533
|
console.log(`${bold('server:')} haltija ${status.serverVersion || '?'}${status.desktopApp ? dim(' (desktop app)') : ''}, ${tabCount} tab${tabCount === 1 ? '' : 's'}`)
|
|
502
534
|
}
|
|
503
535
|
if (status) console.log(`${bold('origins:')} ${describeOrigins(status.windows || []).line}`)
|
|
536
|
+
if (observability) console.log(`${bold('observability:')} ${describeObservability(observability)}`)
|
|
504
537
|
for (const n of notes) console.log(`${yellow('!')} ${n}`)
|
|
505
538
|
// `?` — its own glyph, because the whole point is that this is neither a pass nor a failure.
|
|
506
539
|
for (const u of unchecked) console.log(`${dim('?')} ${u}`)
|
|
@@ -517,6 +550,20 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
517
550
|
return ok
|
|
518
551
|
}
|
|
519
552
|
|
|
553
|
+
/**
|
|
554
|
+
* The `observability:` line: whether the tab commands go to will tell the truth (#821). Says what a
|
|
555
|
+
* bad state invalidates rather than only naming the state — the recorded failures were agents who
|
|
556
|
+
* knew a tab was "hidden" and still trusted its geometry.
|
|
557
|
+
*/
|
|
558
|
+
function describeObservability(o) {
|
|
559
|
+
const where = dim(`(${o.label})`)
|
|
560
|
+
if (!o.visible) return `${red('✗ hidden')} — geometry, screenshots and rAF-driven rendering will be wrong ${where}`
|
|
561
|
+
if (o.painting === false) return `${red('✗ not painting')} — visible but not compositing; missing elements prove nothing ${where}`
|
|
562
|
+
if (o.painting === null) return `${dim('? visible, painting unknown')} ${where}`
|
|
563
|
+
const age = o.paintAgeMs === null ? '' : dim(` (last frame ${(o.paintAgeMs / 1000).toFixed(1)}s ago)`)
|
|
564
|
+
return `${green('✓ visible and painting')}${age} ${where}`
|
|
565
|
+
}
|
|
566
|
+
|
|
520
567
|
/**
|
|
521
568
|
* Resolve a named haltija instance to its port by reading
|
|
522
569
|
* ~/.haltija/servers/<name>.json. Returns null if the file is missing,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the MCP server is, and the command an MCP client should run to start it. ONE copy, used by
|
|
3
|
+
* both setup paths (`haltija --setup-mcp` for Claude Desktop, `haltija-mcp-setup` for Claude Code).
|
|
4
|
+
* They had a finder each, both looking for `apps/mcp/build/index.js`, which the npm package never
|
|
5
|
+
* contained — so setup failed for every npm user (GitHub #47).
|
|
6
|
+
*
|
|
7
|
+
* The server is `dist/mcp.js`, a single bundle with its dependencies inlined: npm never installs a
|
|
8
|
+
* nested package's dependencies, so an unbundled server could not have imported its SDK.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
|
+
import { dirname, join } from 'path'
|
|
12
|
+
import { fileURLToPath } from 'url'
|
|
13
|
+
|
|
14
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
|
+
|
|
16
|
+
/** Absolute path of the bundled MCP server in this package, or null (a checkout that never built). */
|
|
17
|
+
export function mcpServerPath(root = PACKAGE_ROOT) {
|
|
18
|
+
const p = join(root, 'dist', 'mcp.js')
|
|
19
|
+
return existsSync(p) ? p : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `{ command, args }` an MCP client config should hold. A package run through `npx` / `bunx`
|
|
24
|
+
* lives in a cache that gets cleaned, so an absolute path there works today and breaks later with
|
|
25
|
+
* nothing to say why; those get the version-pinned `npx` form instead.
|
|
26
|
+
*/
|
|
27
|
+
export function mcpServerCommand(serverPath, root = PACKAGE_ROOT) {
|
|
28
|
+
if (/[\\/](_npx|bunx-[^\\/]*|\.bun[\\/]install[\\/]cache)[\\/]/.test(serverPath)) {
|
|
29
|
+
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
30
|
+
return { command: 'npx', args: ['-y', '-p', `haltija@${version}`, 'haltija-mcp'] }
|
|
31
|
+
}
|
|
32
|
+
return { command: 'node', args: [serverPath] }
|
|
33
|
+
}
|