haltija 1.13.0-beta.5 → 1.13.1

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,119 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.1 (2026-11-01)
4
+
5
+ ### Changed
6
+
7
+ - **Page text is neutralized by floorplan's own `neutralizeProvenance`** (tosijs-floorplan 0.7.0,
8
+ virta #3387), replacing haltija's hand copy of that policy. Output is identical.
9
+ - **Schematics use tosijs-floorplan 0.6.0** (virta #3388). Floorplan now draws haltija's ref pill
10
+ itself (`refStyle: 'pill'`), so haltija's own pill plugin is gone; the picture is unchanged.
11
+ - **A schematic can carry floorplan's `note`**, which says what the drawing can't be trusted to say:
12
+ nothing could be drawn for the region, or (on the tosi-agent tier) no record carries affordance
13
+ evidence. haltija now tells floorplan that its DOM map checked every element (`introspected`), so
14
+ a page with no controls is reported as having none rather than as a blind spot.
15
+
16
+ ### Fixed
17
+
18
+ - **MCP tools now tell the agent their argument types.** Every optional argument is typed
19
+ `["string", "null"]` in the API schema, and the MCP server's converter matched none of those, so
20
+ `click`'s `selector`, `ref`, `text` and every other optional field reached the agent untyped.
21
+ - **The "SCHEMATIC" banner is no longer cut off on narrow viewports** (virta #3381). The geometric
22
+ frame was sized to the page content alone; it is now at least as wide as the banner, as the
23
+ stacked layout's always was.
24
+
25
+ ### Security
26
+
27
+ - **The bundled MCP server (`dist/mcp.js`) carried two vulnerable libraries.** Affected: haltija
28
+ **1.13.0-beta.4 to 1.13.0**, the releases that ship that bundle. `fast-uri` 3.1.8 (via the MCP
29
+ SDK's `ajv`; GHSA-333x-w4r6-6crp, GHSA-w74x-v6f9-j3xg, GHSA-wjr9-j795-jxcr, GHSA-xhq7-xmqj-769h)
30
+ is now 3.1.9, and `zod` 3.25.76 (GHSA-mrhx-c7xc-m3w5, memory exhaustion on oversized
31
+ collections; no 3.x fix) is now 4.6.7. The MCP server's only caller is the local MCP client over
32
+ stdio, so neither was reachable from the network.
33
+ - **Development installs only, nothing shipped:** the desktop app's `electron` 43.8.6
34
+ (GHSA-jr5w-w6x2-84p9, compromised eMudhra intermediate certificates), its `undici` 7.30.1
35
+ (GHSA-xjj6-f33q-929c), and `body-parser` 2.4.0 (GHSA-27r3-jxmh-p828, via the MCP SDK's express,
36
+ which the bundle does not include). Lockfile updates; no other dependency moved.
37
+
38
+ ## 1.13.0 (2026-10-18)
39
+
40
+ The 1.13 betas, released. Read **Security** and **Upgrading from 1.12** before moving off 1.12.x.
41
+ The beta sections below have the detail and the reasoning; this section is the list. The changes
42
+ made after beta.5 are at the end.
43
+
44
+ ### Security
45
+
46
+ - **Through 1.12.9, anything on your network could drive your browser.** The server listens on
47
+ every interface and, without `--token`, answered any machine, `/eval` included. Affected: every
48
+ release through **1.12.9** (the previous `latest`) and **1.13.0-beta.1 to beta.4**. In 1.13.0 a
49
+ connection from another machine is refused (403) unless the server has a token (virta #815,
50
+ GitHub #44; first shipped in beta.5). If you stay on 1.12.x, start the server with `--token
51
+ <secret>` on any network you don't trust.
52
+ - **Pages on this machine can still call the API, and that is by design.** beta.1 listed this as a
53
+ known issue with "an origin gate planned before 1.13.0". That gate is not coming: the widget is
54
+ injected into arbitrary pages, so the REST surface must answer any origin, and an origin check
55
+ would break exactly the pages haltija exists for. `--token` closes it, as it closes a tunnel or
56
+ reverse proxy running on this machine (which connects from localhost). The README's Security
57
+ section says what is and isn't covered.
58
+
59
+ ### Upgrading from 1.12
60
+
61
+ Each of these changes something that worked before. Before → after:
62
+
63
+ - **Other machines need a token.** Phones, headsets, other computers, and browsers in a container,
64
+ VM or WSL2 that reach haltija through a forwarded port: they connected freely, and now get a 403
65
+ until the server has `--token <secret>` and the client sends it (beta.5).
66
+ - **Both transports by default.** `DEV_CHANNEL_MODE` was `http`; it is `both`, so HTTPS 8701 comes
67
+ up too. `--http` opts out, and says what that costs other projects (beta.1).
68
+ - **The certificate lives in `~/.haltija/certs`.** It was per-install; an existing one is copied
69
+ forward, so trust you already granted survives (beta.1).
70
+ - **The desktop app's internal server moved from 8701 to 8710.** `HALTIJA_PORT=8701 hj tree`
71
+ (inspecting the app's own UI) is now `HALTIJA_PORT=8710 hj tree` (beta.1).
72
+ - **Only the server on 8700 takes HTTPS 8701.** A `--port` / `--name` server's HTTPS is now
73
+ ephemeral, shown in its banner and `/status`, unless you pass `--https-port` (beta.1).
74
+ - **`server` from `haltija/server` is the HTTP listener**, and `server`, `httpServer` and
75
+ `httpsServer` are typed `ListeningServer` (`port`, `hostname`, `url`, `stop()`) instead of
76
+ Bun's server type. Code that used other Bun server methods on them needs a cast (beta.1).
77
+ - **Navigate and refresh wait for the page's widget.** They returned at once; now they block until
78
+ the widget reconnects, up to `--timeout` (default 10 s). A page that doesn't load the widget
79
+ itself (a bookmarklet tab) waits that long every time: pass `--no-wait` there. `navigate()` and
80
+ `refresh()` in `DevChannelClient` / `haltija/test` now return `{ reconnected, warning? }` (beta.2).
81
+ - **The widget writes `window.name` while navigating**, and restores the page's own value after.
82
+ An app that keeps state in `window.name` can see the marker briefly (beta.2).
83
+ - **`press` acts at the focused element.** In `haltija/test` and `DevChannelClient.press` it was one
84
+ `keydown` at `body`; now it is the full key sequence at the focused element, like `hj key`
85
+ (beta.4).
86
+ - **Schematics are drawn by tosijs-floorplan.** Same response fields; the picture looks different
87
+ (beta.5).
88
+
89
+ ### Fixed after beta.5
90
+
91
+ From the 1.13.0 release review (`reviews/1.13.0-*.md`):
92
+
93
+ - **A refused connection from another machine is now logged on the server**, once per address, with
94
+ the `--token` remedy. A browser cannot read why a WebSocket handshake failed, so a widget in a
95
+ container or VM retried forever while the server said nothing.
96
+ - **`DevChannelClient` and `haltija/test` send `HALTIJA_TOKEN`.** `hj` and the MCP bridge always
97
+ did; these two never did, so a test suite could not reach a `--token` server at all, which since
98
+ beta.5 is the setup for any runner on another machine.
99
+ - **`haltija --token=<secret>` sets a token.** The launcher read only `--token <secret>`, so the `=`
100
+ form, like a misspelled flag, started a server with no token and no complaint. `--flag=value` now
101
+ works for every launcher flag that takes a value, an unknown flag is reported, and the banner has
102
+ an `AUTH:` line saying whether a token is in force.
103
+ - **`/drag`, `hj drag` and the `drag` step move pointer-driven controls.** They sent bare mouse
104
+ events (some of them `CustomEvent`s with no coordinates), so a slider, resize handle or orbit
105
+ camera that listens for pointer events didn't move while the drag reported success. They now use
106
+ the same pointer-and-mouse sequence as `dragFrom`, with `buttons` set while pressed.
107
+ - **MCP setup replaces a stale entry.** Before 1.13 setup wrote `node …/apps/mcp/build/index.js`,
108
+ which no longer exists. `--check` reported such an entry as configured, and re-running setup said
109
+ "already configured". Both now call it stale, and setup rewrites it. The help named
110
+ `npx haltija-mcp-setup`, a package that does not exist; it is `npx -p haltija haltija-mcp-setup`.
111
+ - **The `test:integration` lane runs its tests.** It needed a server it was never given, so every
112
+ 1.13 beta attested "0 pass, 11 skip" as a pass. It now starts its own private headless instance,
113
+ and a lane that only skipped fails.
114
+ - Docs: the README no longer shows a demo image that doesn't exist, and links the API reference and
115
+ the browser-test guide (`docs/TEST-IN-BROWSER.md`).
116
+
3
117
  ## 1.13.0-beta.5 (2026-10-18)
4
118
 
5
119
  ### Changed
package/README.md CHANGED
@@ -8,9 +8,6 @@
8
8
 
9
9
  Unlike screenshot-based tools, Haltija works with the actual DOM. Unlike Playwright, it connects to your real browser — already logged in, same cookies, same bug you're looking at.
10
10
 
11
- <!-- TODO: Replace with actual GIF showing: 1) bunx haltija launches app, 2) agent queries tree, 3) agent clicks something, 4) highlight shows result -->
12
- ![Haltija in action](docs/assets/demo.gif)
13
-
14
11
  > *Haltija* (Finnish): a guardian spirit that watches over a place. In this case, your DOM.
15
12
 
16
13
  ---
@@ -337,7 +334,9 @@ app also exists but isn't required for either.
337
334
  8701 by default), but without a token it refuses every connection that doesn't come from this
338
335
  machine. To use haltija from another device (a phone, a headset, a second computer), start it
339
336
  with `--token <secret>` (or `HALTIJA_TOKEN`) and send the same value as `X-Haltija-Token`,
340
- `?token=`, or the widget's `token` option.
337
+ `?token=`, or the widget's `token` option. A browser in a container, VM or WSL2 that reaches
338
+ haltija through a forwarded port also counts as another machine. The server's terminal names
339
+ each refused address once.
341
340
  - **On this machine, any page you visit can call it.** The widget is injected into arbitrary
342
341
  pages, so the REST API answers any origin, `/eval` in your connected tabs included. That is by
343
342
  design for a local dev tool, and `--token` closes it too. A tunnel or reverse proxy on this
@@ -411,10 +410,11 @@ hj api # Full API reference (markdown)
411
410
  hj --help # CLI subcommand reference
412
411
  ```
413
412
 
414
- - [Full API Reference](./API.md) (auto-generated from schema)
413
+ - [Full API Reference](https://github.com/tonioloewald/haltija/blob/main/API.md) (auto-generated from schema)
415
414
  - [Agent Prompt](docs/agent-prompt.md) — Copy-paste prompt for any AI agent
416
415
  - [Recipes](docs/recipes.md) — Common workflows
417
416
  - [CI Integration](docs/CI-INTEGRATION.md) — E2E testing with Haltija in GitHub Actions
417
+ - [Browser tests from your test runner](docs/TEST-IN-BROWSER.md) — `createBrowserPage` from `haltija/test`: assertions on the host, probes in a real browser
418
418
  - [Roadmap](docs/ROADMAP.md)
419
419
 
420
420
  ---
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.13.0-beta.5",
3
+ "version": "1.13.1",
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.5";
49
+ var VERSION = "1.13.1";
50
50
 
51
51
  // src/ws-url.ts
52
52
  function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
@@ -237,7 +237,15 @@
237
237
  const at = Math.max(v.lastIndexOf(BOUND_TWO_WAY), v.lastIndexOf(BOUND_TO_DOM));
238
238
  return neutralizeArrows(at >= 0 ? v.slice(0, at).trim() : v);
239
239
  };
240
- var neutralizeArrows = (s) => s.replaceAll(BOUND_TWO_WAY, "<->").replaceAll(BOUND_TO_DOM, "<-");
240
+ var PROVENANCE_REWRITES = [
241
+ [BOUND_TWO_WAY, "<->"],
242
+ [BOUND_TO_DOM, "<-"]
243
+ ];
244
+ var neutralizeArrows = (s) => PROVENANCE_REWRITES.reduce((out, [token, neutral]) => out.replaceAll(token, neutral), s);
245
+ var PROVENANCE_TOKENS = Object.freeze(PROVENANCE_REWRITES.map(([token]) => token));
246
+ function neutralizeProvenance(text) {
247
+ return typeof text === "string" ? neutralizeArrows(text) : text;
248
+ }
241
249
  var boundTwoWay = (v) => {
242
250
  if (typeof v !== "string")
243
251
  return false;
@@ -287,7 +295,6 @@
287
295
  return width < targetSize || height < targetSize ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : null;
288
296
  };
289
297
  var finiteBounds = (b) => Number.isFinite(b.x) && Number.isFinite(b.y) && Number.isFinite(b.width) && Number.isFinite(b.height);
290
- var printable = (value) => value == null || typeof value === "number";
291
298
  var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
292
299
  var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
293
300
  var esc = (s) => typeof s !== "string" ? "" : s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -331,8 +338,8 @@
331
338
  };
332
339
  var schematic = (description, options = {}) => {
333
340
  const {
334
- minLabelHeight = 14,
335
- maxCaption = 36,
341
+ minLabelHeight: minLabelHeightIn = 14,
342
+ maxCaption: maxCaptionIn = 36,
336
343
  within: withinIn,
337
344
  index: showIndex = false,
338
345
  targetSize = TARGET_SIZE_DEFAULT,
@@ -340,9 +347,12 @@
340
347
  decorate
341
348
  } = options;
342
349
  const padIn = options.pad;
343
- const pad = padIn === undefined || !printable(padIn) ? 8 : padIn;
350
+ const pad = padIn === null || typeof padIn === "number" && Number.isFinite(padIn) && padIn >= 0 ? padIn : 8;
344
351
  const fontSizeIn = options.fontSize;
345
- const fontSize = fontSizeIn === undefined || !printable(fontSizeIn) ? 11 : fontSizeIn;
352
+ const fontSize = fontSizeIn === null || typeof fontSizeIn === "number" && Number.isFinite(fontSizeIn) && fontSizeIn > 0 ? fontSizeIn : 11;
353
+ const minLabelHeight = Number.isNaN(minLabelHeightIn) ? 14 : minLabelHeightIn;
354
+ const maxCaption = typeof maxCaptionIn === "number" && (Number.isNaN(maxCaptionIn) || maxCaptionIn < 1) ? 36 : maxCaptionIn;
355
+ const refStyle = options.refStyle === "pill" ? "pill" : "stamp";
346
356
  const legend = [];
347
357
  const within = withinIn == null ? undefined : {
348
358
  x: withinIn.x,
@@ -350,7 +360,7 @@
350
360
  width: withinIn.width,
351
361
  height: withinIn.height
352
362
  };
353
- const withinOk = within == null || printable(within.x) && printable(within.y) && printable(within.width) && printable(within.height);
363
+ const withinOk = within == null || finiteBounds(within);
354
364
  const geometry = new Map;
355
365
  const snapshotOf = (w) => {
356
366
  if (geometry.has(w))
@@ -370,22 +380,38 @@
370
380
  return g.width > 0 && g.height > 0 && (w.viewportFixed === true || g.x + g.width > 0 && g.y + g.height > 0) && (within == null || w.viewportFixed === true || intersects(g, within));
371
381
  });
372
382
  const flow = boxes.filter((w) => w.viewportFixed !== true);
373
- if (boxes.length === 0) {
374
- return {
375
- svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0 0"></svg>',
376
- legend
377
- };
383
+ const emptyMap = (why) => why == null ? {
384
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0 0"></svg>',
385
+ legend
386
+ } : {
387
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0 0">' + `<desc>${esc(why)}</desc></svg>`,
388
+ legend,
389
+ note: why
390
+ };
391
+ if (!withinOk) {
392
+ return emptyMap("nothing drawn: `within` is not a usable region (a field is not a " + "finite number) — this is not an empty region");
378
393
  }
394
+ if (boxes.length === 0)
395
+ return emptyMap();
379
396
  const fitBoxes = flow.length > 0 ? flow : boxes;
380
- const minX = within != null ? within.x - pad : Math.min(...fitBoxes.map((w) => geometry.get(w).x)) - pad;
381
- const minY = within != null ? within.y - pad : Math.min(...fitBoxes.map((w) => geometry.get(w).y)) - pad;
382
- const maxX = within != null ? within.x + within.width + pad : Math.max(...fitBoxes.map((w) => geometry.get(w).x + geometry.get(w).width)) + pad;
383
- const maxY = within != null ? within.y + within.height + pad : Math.max(...fitBoxes.map((w) => geometry.get(w).y + geometry.get(w).height)) + pad;
397
+ const minXRaw = within != null ? within.x - pad : Math.min(...fitBoxes.map((w) => geometry.get(w).x)) - pad;
398
+ const minYRaw = within != null ? within.y - pad : Math.min(...fitBoxes.map((w) => geometry.get(w).y)) - pad;
399
+ const maxXRaw = within != null ? within.x + within.width + pad : Math.max(...fitBoxes.map((w) => geometry.get(w).x + geometry.get(w).width)) + pad;
400
+ const maxYRaw = within != null ? within.y + within.height + pad : Math.max(...fitBoxes.map((w) => geometry.get(w).y + geometry.get(w).height)) + pad;
401
+ const overflow = ![
402
+ minXRaw,
403
+ minYRaw,
404
+ maxXRaw,
405
+ maxYRaw,
406
+ maxXRaw - minXRaw,
407
+ maxYRaw - minYRaw
408
+ ].every(Number.isFinite);
409
+ const [minX, minY, maxX, maxY] = overflow ? [0, 0, 0, 0] : [minXRaw, minYRaw, maxXRaw, maxYRaw];
384
410
  const parts = [
385
411
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
386
412
  ];
387
413
  const drawOrder = [...boxes].sort((a, b) => Number(isGround(b)) - Number(isGround(a)));
388
- const blind = boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive) && !description.wiring.some(hasCapabilityEvidence);
414
+ const blind = description.introspected !== true && boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive) && !description.wiring.some(hasCapabilityEvidence);
389
415
  const note = blind ? "no record carries affordance evidence (on, href, contentEditable, " + "a two-way binding, or an interactive/editable assertion), and none " + "shows the producer can see wiring at all (no handler, assertion, " + 'or provenance arrow anywhere) — "nothing here is actionable" is ' + "NOT established; a producer that cannot introspect handlers " + "should assert `interactive`/`editable` per record (see README)" : undefined;
390
416
  const boxGeometry = boxes.map((b) => geometry.get(b));
391
417
  for (const w of drawOrder) {
@@ -535,9 +561,13 @@
535
561
  if (showIndex || w.ref != null || inLegend) {
536
562
  const shown = w.ref != null ? String(w.ref) : String(index);
537
563
  const indexX = toggle ? x - 3 : x + width - 2;
538
- parts.push(`<rect x="${indexX - shown.length * 5 - 1}" y="${y + 1}" ` + `width="${shown.length * 5 + 2}" height="8" fill="white" ` + `opacity="0.85" data-index-backdrop="true"/>`, `<text x="${indexX}" y="${y + 8}" font-size="8" ` + `text-anchor="end" font-family="monospace" fill="black" ` + `opacity="0.8">${esc(shown)}</text>`);
564
+ if (refStyle === "pill") {
565
+ parts.push(`<rect x="${indexX - shown.length * 5 - 2}" y="${y + 1}" ` + `width="${shown.length * 5 + 3}" height="9" rx="2" ` + `fill="#0f172a" data-index-backdrop="true"/>`, `<text x="${indexX - 1}" y="${y + 8}" font-size="8" ` + `text-anchor="end" font-family="monospace" font-weight="bold" ` + `fill="white">${esc(shown)}</text>`);
566
+ } else {
567
+ parts.push(`<rect x="${indexX - shown.length * 5 - 1}" y="${y + 1}" ` + `width="${shown.length * 5 + 2}" height="8" fill="white" ` + `opacity="0.85" data-index-backdrop="true"/>`, `<text x="${indexX}" y="${y + 8}" font-size="8" ` + `text-anchor="end" font-family="monospace" fill="black" ` + `opacity="0.8">${esc(shown)}</text>`);
568
+ }
539
569
  }
540
- if (decorate != null) {
570
+ if (decorate != null && !overflow) {
541
571
  decorate({
542
572
  record: w,
543
573
  index,
@@ -551,6 +581,9 @@
551
581
  }
552
582
  parts.push("</g>");
553
583
  }
584
+ if (overflow) {
585
+ return emptyMap("nothing drawn: the map's extent overflows a finite number (a " + "coordinate, size or pad near Number.MAX_VALUE); the legend still " + "carries every record's facts");
586
+ }
554
587
  const footerExtra = legendNote && legend.length > 0 ? 14 : 0;
555
588
  if (footerExtra > 0) {
556
589
  parts.push(`<text x="${minX + pad}" y="${maxY + 10}" font-size="8" ` + `font-family="monospace" fill="currentColor" opacity="0.75">` + `${legend.length} element${legend.length === 1 ? "" : "s"} with ` + `details in legend — match by stamped number</text>`);
@@ -570,23 +603,10 @@
570
603
  };
571
604
 
572
605
  // src/floorplan-records.ts
573
- function neutralizeArrows2(s) {
574
- return s.replaceAll("⟷", "<->").replaceAll("⟵", "<-");
575
- }
576
606
  var PAGE_TEXT_FIELDS = ["label", "placeholder", "text", "value"];
577
607
  function isPureLayout(n) {
578
608
  return !!n.children?.length && !n.label && !n.text && n.value === undefined && !n.href && !n.placeholder && !n.contrastFail && !n.interactive && !n.ownBg && !n.ownBorder;
579
609
  }
580
- function refPill(ctx) {
581
- const ref = ctx.record.ref;
582
- if (ref == null)
583
- return;
584
- const shown = String(ref).replace(/[&<>"]/g, "");
585
- const toggle = ctx.record.type === "checkbox" || ctx.record.type === "radio";
586
- const right = toggle ? ctx.x - 3 : ctx.x + ctx.width - 2;
587
- const w = shown.length * 5 + 3;
588
- ctx.emit(`<rect x="${right - w + 1}" y="${ctx.y + 1}" width="${w}" height="9" rx="2" fill="#0f172a" opacity="0.9"/>` + `<text x="${right}" y="${ctx.y + 8.5}" text-anchor="end" font-family="ui-monospace,Menlo,monospace" font-size="8" font-weight="bold" fill="#ffffff">${shown}</text>`);
589
- }
590
610
  var STRUCTURAL_TAGS = new Set([
591
611
  "main",
592
612
  "nav",
@@ -2045,14 +2065,18 @@
2045
2065
  const bannerSvg = (width) => banner ? `<rect x="0" y="0" width="${width}" height="${BANNER_H}" fill="#7f1d1d"/>` + `<text x="${PAD}" y="17" font-family="ui-monospace,Menlo,monospace" font-size="11" font-weight="bold" fill="#fee2e2">${esc(banner)}</text>` : "";
2046
2066
  if (geometric) {
2047
2067
  const within = fullPage ? undefined : { x: window.scrollX, y: window.scrollY, width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
2048
- const result = schematic({ wiring: records }, { within, targetSize: 0, pad: PAD, maxCaption: 200, decorate: refPill });
2068
+ const result = schematic({ wiring: records, ...map.source === "tosi-agent" ? {} : { introspected: true } }, { within, targetSize: 0, pad: PAD, maxCaption: 200, refStyle: "pill" });
2049
2069
  const w = Number(/\bwidth="([\d.]+)"/.exec(result.svg)?.[1] || 0);
2050
2070
  const h = Number(/\bheight="([\d.]+)"/.exec(result.svg)?.[1] || 0);
2051
2071
  if (w > 0 && h > 0) {
2052
- const width = Math.ceil(w);
2072
+ const bannerCtx = banner ? document.createElement("canvas").getContext("2d") : null;
2073
+ if (bannerCtx)
2074
+ bannerCtx.font = "bold 11px ui-monospace, Menlo, monospace";
2075
+ const bannerW = bannerCtx ? Math.ceil(bannerCtx.measureText(banner).width) + PAD * 2 : 0;
2076
+ const width = Math.max(Math.ceil(w), bannerW);
2053
2077
  const height = Math.ceil(h + BANNER_H);
2054
2078
  const svg = `<?xml version="1.0" encoding="UTF-8"?>` + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` + `<rect width="${width}" height="${height}" fill="#ffffff"/>` + bannerSvg(width) + result.svg.replace("<svg ", `<svg x="0" y="${BANNER_H}" `) + `</svg>`;
2055
- return { svg, width, height, layout: "geometric", boundsCoverage };
2079
+ return { svg, width, height, layout: "geometric", boundsCoverage, ...result.note ? { note: result.note } : {} };
2056
2080
  }
2057
2081
  }
2058
2082
  const ROW = 22;
@@ -2070,7 +2094,7 @@
2070
2094
  if (typeof v === "string")
2071
2095
  bound.push(`${k}: ${v}`);
2072
2096
  }
2073
- if (w.text && /[⟷⟵]/.test(w.text))
2097
+ if (typeof w.text === "string" && PROVENANCE_TOKENS.some((t) => w.text.includes(t)))
2074
2098
  bound.push(String(w.text));
2075
2099
  const handlers = w.on ? Object.entries(w.on).map(([ev, path]) => `${ev} → ${Array.isArray(path) ? path.join(",") : path}`) : [];
2076
2100
  return {
@@ -2193,7 +2217,7 @@
2193
2217
  }
2194
2218
  async function buildSchematicResponse(map, payload2, banner) {
2195
2219
  const canvases = collectCanvasThumbnails();
2196
- const { svg, width, height, layout, boundsCoverage } = renderMapSchematic(map, canvases, banner, payload2?.fullPage === true, payload2?.layout || "auto");
2220
+ const { svg, width, height, layout, boundsCoverage, note } = renderMapSchematic(map, canvases, banner, payload2?.fullPage === true, payload2?.layout || "auto");
2197
2221
  const format = payload2?.format || "png";
2198
2222
  const raster = await rasterizeSchematic(svg, width, height, payload2?.scale || 1, {
2199
2223
  maxWidth: payload2?.maxWidth,
@@ -2209,6 +2233,7 @@
2209
2233
  source: "schematic",
2210
2234
  layout,
2211
2235
  boundsCoverage,
2236
+ ...note ? { note } : {},
2212
2237
  legend: buildLegend(map),
2213
2238
  canvasesRendered: canvases.filter((c) => c.image).length,
2214
2239
  map
@@ -2492,10 +2517,9 @@
2492
2517
  h: Math.round(r.height)
2493
2518
  };
2494
2519
  } catch {}
2495
- for (const k of PAGE_TEXT_FIELDS) {
2496
- if (typeof node[k] === "string" && /[⟷⟵]/.test(node[k]))
2497
- node[k] = neutralizeArrows2(node[k]);
2498
- }
2520
+ for (const k of PAGE_TEXT_FIELDS)
2521
+ if (typeof node[k] === "string")
2522
+ node[k] = neutralizeProvenance(node[k]);
2499
2523
  return node;
2500
2524
  };
2501
2525
  const walk = (el) => {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The launcher's own flags, and `--flag=value` support for the ones that take a value.
3
+ *
4
+ * The launcher read flags with `args.indexOf('--token')`, so `--token=secret` and a typo like
5
+ * `--tokn secret` both started a server with NO token, silently (1.13.0 review). With the #815 gate
6
+ * that mostly fails closed for other machines, but a tunnel on this machine connects from loopback,
7
+ * and the README sends tunnel users to `--token`. So: accept the `=` form, and say when a flag is not
8
+ * one the launcher knows.
9
+ */
10
+ export const VALUE_FLAGS = [
11
+ '--port', '--https-port', '--token', '--name', '--port-file',
12
+ '--headless-url', '--snapshots-dir', '--docs-dir',
13
+ ]
14
+ export const BOOLEAN_FLAGS = [
15
+ '--app', '--server', '--headless', '--ci', '--http', '--https', '--both', '--force', '-f',
16
+ '--wait-ready', '--private', '--setup-mcp', '--setup-mcp-check', '--setup-mcp-remove',
17
+ '--help', '-h',
18
+ ]
19
+
20
+ /** `--token=x` becomes `--token x`, for the value flags only. Returns the unknown flags it saw. */
21
+ export function normalizeLauncherArgs(args) {
22
+ const out = []
23
+ const unknown = []
24
+ for (const a of args) {
25
+ const eq = a.indexOf('=')
26
+ const flag = a.startsWith('-') && eq > 0 ? a.slice(0, eq) : a
27
+ if (flag !== a && VALUE_FLAGS.includes(flag)) {
28
+ out.push(flag, a.slice(eq + 1))
29
+ continue
30
+ }
31
+ if (a.startsWith('-') && !VALUE_FLAGS.includes(a) && !BOOLEAN_FLAGS.includes(a)) unknown.push(a)
32
+ out.push(a)
33
+ }
34
+ return { args: out, unknown }
35
+ }
@@ -31,3 +31,20 @@ export function mcpServerCommand(serverPath, root = PACKAGE_ROOT) {
31
31
  }
32
32
  return { command: 'node', args: [serverPath] }
33
33
  }
34
+
35
+ /**
36
+ * Why an existing `haltija` MCP entry can no longer start, or null when it looks runnable.
37
+ *
38
+ * Before 1.13.0 setup wrote `node …/apps/mcp/build/index.js`, a path the package no longer has, and
39
+ * `--check` reported such an entry as configured while re-running setup said "already configured",
40
+ * so nothing short of hand-editing the config could fix it. An entry that names a server file which
41
+ * is not there is stale, whatever else it says; one that runs a command (`npx …`) is left alone.
42
+ */
43
+ export function staleMcpEntry(entry) {
44
+ const args = Array.isArray(entry?.args) ? entry.args : []
45
+ const script = args.find((a) => typeof a === 'string' && /\.(m?js|ts)$/.test(a))
46
+ if (!script) return null
47
+ if (/[\\/]apps[\\/]mcp[\\/]build[\\/]/.test(script)) return `it runs ${script}, the pre-1.13 server location`
48
+ if (!existsSync(script)) return `it runs ${script}, which does not exist`
49
+ return null
50
+ }
package/bin/mcp-setup.mjs CHANGED
@@ -3,18 +3,18 @@
3
3
  * haltija-mcp-setup - Configure Claude Code to use Haltija
4
4
  *
5
5
  * Usage:
6
- * npx haltija-mcp-setup # Create .mcp.json in current directory
7
- * npx haltija-mcp-setup --global # Add to user's Claude Code config
8
- * npx haltija-mcp-setup --check # Check if Haltija MCP is configured
9
- * npx haltija-mcp-setup --help # Show help
6
+ * npx -p haltija haltija-mcp-setup # Create .mcp.json in current directory
7
+ * npx -p haltija haltija-mcp-setup --global # Add to user's Claude Code config
8
+ * npx -p haltija haltija-mcp-setup --check # Check if Haltija MCP is configured
9
+ * npx -p haltija haltija-mcp-setup --help # Show help
10
10
  */
11
11
 
12
12
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
13
13
  import { homedir } from 'os'
14
14
  import { join, dirname } from 'path'
15
15
  import { fileURLToPath } from 'url'
16
- import { execSync } from 'child_process'
17
- import { mcpServerPath, mcpServerCommand } from './mcp-locate.mjs'
16
+ import { execFileSync } from 'child_process'
17
+ import { mcpServerPath, mcpServerCommand, staleMcpEntry } from './mcp-locate.mjs'
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
20
20
  const args = process.argv.slice(2)
@@ -31,11 +31,11 @@ if (args.includes('--help') || args.includes('-h')) {
31
31
  ${bold('haltija-mcp-setup')} - Configure Claude Code to use Haltija
32
32
 
33
33
  ${bold('Usage:')}
34
- npx haltija-mcp-setup Create .mcp.json in current directory (recommended)
35
- npx haltija-mcp-setup --global Add to user's global Claude Code config
36
- npx haltija-mcp-setup --check Check current MCP configuration status
37
- npx haltija-mcp-setup --remove Remove Haltija from MCP configuration
38
- npx haltija-mcp-setup --help Show this help
34
+ npx -p haltija haltija-mcp-setup Create .mcp.json in current directory (recommended)
35
+ npx -p haltija haltija-mcp-setup --global Add to user's global Claude Code config
36
+ npx -p haltija haltija-mcp-setup --check Check current MCP configuration status
37
+ npx -p haltija haltija-mcp-setup --remove Remove Haltija from MCP configuration
38
+ npx -p haltija haltija-mcp-setup --help Show this help
39
39
 
40
40
  ${bold('What this does:')}
41
41
  Configures Claude Code to connect to the Haltija server, giving Claude
@@ -84,7 +84,11 @@ function checkConfig() {
84
84
  if (existsSync(projectMcpPath)) {
85
85
  try {
86
86
  const config = JSON.parse(readFileSync(projectMcpPath, 'utf8'))
87
- if (config.mcpServers?.haltija) {
87
+ const stale = config.mcpServers?.haltija && staleMcpEntry(config.mcpServers.haltija)
88
+ if (stale) {
89
+ console.log(red('✗') + ` Project .mcp.json has a stale Haltija entry: ${stale}`)
90
+ console.log(dim(' Run setup again to replace it: npx -p haltija haltija-mcp-setup'))
91
+ } else if (config.mcpServers?.haltija) {
88
92
  console.log(green('✓') + ` Project .mcp.json has Haltija configured`)
89
93
  console.log(dim(` Path: ${projectMcpPath}`))
90
94
  } else {
@@ -108,9 +112,10 @@ function checkConfig() {
108
112
  }
109
113
 
110
114
  // Check if Haltija server is running
115
+ const port = process.env.HALTIJA_PORT || '8700'
111
116
  try {
112
- execSync('curl -s http://localhost:8700/status', { encoding: 'utf8', timeout: 2000 })
113
- console.log(green('✓') + ' Haltija server is running on port 8700')
117
+ execFileSync('curl', ['-sf', `http://localhost:${port}/status`], { encoding: 'utf8', timeout: 2000 })
118
+ console.log(green('✓') + ` Haltija server is running on port ${port}`)
114
119
  } catch {
115
120
  console.log(yellow('○') + ' Haltija server not running')
116
121
  console.log(dim(' Start with: bunx haltija'))
@@ -145,7 +150,10 @@ function setupProjectConfig() {
145
150
  }
146
151
 
147
152
  // Check if already configured
148
- if (config.mcpServers.haltija) {
153
+ const stale = config.mcpServers.haltija && staleMcpEntry(config.mcpServers.haltija)
154
+ if (stale) {
155
+ console.log(yellow('Replacing a stale Haltija entry in .mcp.json:') + ` ${stale}`)
156
+ } else if (config.mcpServers.haltija) {
149
157
  console.log(yellow('Haltija is already configured in .mcp.json'))
150
158
  console.log(dim('Use --remove to remove it, or delete .mcp.json manually'))
151
159
  process.exit(0)
@@ -178,7 +186,9 @@ function setupGlobalConfig() {
178
186
  const addCommand = `claude mcp add --scope user haltija -- ${[command, ...commandArgs].map((a) => JSON.stringify(a)).join(' ')}`
179
187
  // Try using claude CLI first
180
188
  try {
181
- execSync(addCommand, {
189
+ // An argv, not a shell string: the install path is in the args, and a shell would run any
190
+ // `$()` or backtick in it.
191
+ execFileSync('claude', ['mcp', 'add', '--scope', 'user', 'haltija', '--', command, ...commandArgs], {
182
192
  encoding: 'utf8',
183
193
  stdio: 'inherit'
184
194
  })
@@ -1,6 +1,11 @@
1
1
  /** ⚠️ AUTO-GENERATED FROM src/server-list.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/ports.ts
3
+ var DEFAULT_HTTP_PORT = 8700;
4
+ var DEFAULT_HTTPS_PORT = 8701;
5
+ var PUBLIC_PORTS = [DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT];
6
+
2
7
  // src/server-list.ts
3
- function collectCandidates(instances, resolvedPort, defaults = [8700, 8701]) {
8
+ function collectCandidates(instances, resolvedPort, defaults = [...PUBLIC_PORTS]) {
4
9
  const byPort = new Map;
5
10
  for (const e of instances) {
6
11
  byPort.set(String(e.port), { port: String(e.port), name: e.name, cwd: e.cwd ?? null });
@@ -18,7 +18,7 @@ import { homedir, platform, tmpdir } from 'os'
18
18
  import { fileURLToPath } from 'url'
19
19
  import { dirname, join } from 'path'
20
20
  import { stalePrivateEntries, pidIsAlive } from './private-state.mjs'
21
- import { mcpServerPath, mcpServerCommand } from './mcp-locate.mjs'
21
+ import { mcpServerPath, mcpServerCommand, staleMcpEntry } from './mcp-locate.mjs'
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url))
24
24
  const serverPath = join(__dirname, '../dist/server.js')
@@ -171,7 +171,11 @@ function checkMcpConfig() {
171
171
  if (existsSync(configPath)) {
172
172
  try {
173
173
  const config = JSON.parse(readFileSync(configPath, 'utf8'))
174
- if (config.mcpServers?.haltija) {
174
+ const stale = config.mcpServers?.haltija && staleMcpEntry(config.mcpServers.haltija)
175
+ if (stale) {
176
+ console.log(red('✗') + ` Haltija's Claude Desktop entry is stale: ${stale}`)
177
+ console.log(dim(` Run: haltija --setup-mcp (it replaces the entry)`))
178
+ } else if (config.mcpServers?.haltija) {
175
179
  console.log(green('✓') + ' Haltija is configured in Claude Desktop')
176
180
  console.log(dim(` Command: ${config.mcpServers.haltija.command} ${config.mcpServers.haltija.args?.join(' ') || ''}`))
177
181
  } else {
@@ -248,7 +252,10 @@ function setupMcp() {
248
252
  }
249
253
 
250
254
  // Check if already configured
251
- if (config.mcpServers.haltija) {
255
+ const stale = config.mcpServers.haltija && staleMcpEntry(config.mcpServers.haltija)
256
+ if (stale) {
257
+ console.log(yellow('Replacing a stale Haltija entry:') + ` ${stale}`)
258
+ } else if (config.mcpServers.haltija) {
252
259
  console.log(green('✓') + ' Haltija is already configured in Claude Desktop')
253
260
  console.log(dim(` Config: ${configPath}`))
254
261
  console.log('')
@@ -310,6 +317,7 @@ function removeMcp() {
310
317
  // ============================================
311
318
 
312
319
  import { isSubcommand, runSubcommand } from './cli-subcommand.mjs'
320
+ import { normalizeLauncherArgs } from './launcher-args.mjs'
313
321
 
314
322
  // Check if first positional arg is a subcommand (not a flag, not a port number)
315
323
  const firstNonFlag = args.find(a => !a.startsWith('-'))
@@ -349,6 +357,16 @@ if (args.includes('--setup-mcp-remove')) {
349
357
  process.exit(0)
350
358
  }
351
359
 
360
+ // Launcher mode from here on (subcommands took their own flags above). `--flag=value` is split
361
+ // in place, so every `args.indexOf('--flag')` below sees it; an unknown flag is said out loud.
362
+ {
363
+ const { args: normalized, unknown } = normalizeLauncherArgs(args)
364
+ args.splice(0, args.length, ...normalized)
365
+ for (const flag of unknown) {
366
+ console.error(yellow(`[haltija] unknown option ${flag} is ignored (see haltija --help)`))
367
+ }
368
+ }
369
+
352
370
  // Set up environment from args
353
371
  const env = { ...process.env }
354
372
 
package/bin/version.mjs CHANGED
@@ -3,4 +3,4 @@
3
3
  * ⚠️ To change the version, update package.json and run: bun run build
4
4
  */
5
5
 
6
- export const HJ_VERSION = '1.13.0-beta.5'
6
+ export const HJ_VERSION = '1.13.1'