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 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 for Claude Code with MCP: `bunx haltija --setup-mcp`
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
 
@@ -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',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.13.0-beta.2",
3
+ "version": "1.13.0-beta.4",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.13.0-beta.2";
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: agent.version ?? description?.version,
1383
- ...shapeWarning ? { warning: shapeWarning } : {},
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: ${globalName}.write(path, value) for a ` + `⟷ two-way binding, ${globalName}.call(actionPath) for an action. ` + `Run them with: hj eval "${globalName}.write('some.path', 'value')"`,
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 entry = { type: "alert", message: msg2, response: undefined, timestamp: Date.now() };
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 accept = self.dialogPolicy.confirm === "accept";
8687
- const entry = { type: "confirm", message: msg2, response: accept, timestamp: Date.now() };
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 response = policy === "dismiss" ? null : policy.response;
8705
- const entry = { type: "prompt", message: msg2, defaultValue, response, timestamp: Date.now() };
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 = {};
@@ -47,6 +47,8 @@ var ROUTED_COMMANDS = [
47
47
  "network-watch",
48
48
  "network-unwatch",
49
49
  "network-stats",
50
+ "dialog-configure",
51
+ "dialog-history",
50
52
  "recording",
51
53
  "recording-start",
52
54
  "recording-stop",
@@ -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 && hidden.length === tabs.length) {
405
+ if (ready && target && !isVisible(target)) {
382
406
  problems.push(
383
- `every connected tab reports HIDDEN — results from a backgrounded tab can be ` +
384
- `plausible-but-wrong (rAF/timers throttled). Bring one to the front.`,
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
- const r = await fetch(`http://localhost:${port}/eval`, {
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
+ }