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 +114 -0
- package/README.md +5 -5
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +66 -42
- package/bin/launcher-args.mjs +35 -0
- package/bin/mcp-locate.mjs +17 -0
- package/bin/mcp-setup.mjs +26 -16
- package/bin/server-list.mjs +6 -1
- package/bin/tosijs-dev.mjs +21 -3
- package/bin/version.mjs +1 -1
- package/dist/api-schema.d.ts +3 -3
- package/dist/client-auth.d.ts +11 -0
- package/dist/client.js +35 -24
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +66 -42
- package/dist/component.js +66 -42
- package/dist/drag.d.ts +4 -3
- package/dist/floorplan-records.d.ts +1 -25
- package/dist/hj.js +6 -3
- package/dist/index.js +184 -93
- package/dist/mcp.js +293 -4027
- package/dist/pointer-kit.d.ts +12 -0
- package/dist/remote-gate.d.ts +10 -0
- package/dist/server.js +148 -68
- package/dist/test-in-browser.d.ts +1 -1
- package/dist/test.js +68 -55
- package/dist/transports.d.ts +13 -0
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +9 -0
- package/docs/README.md +1 -0
- package/package.json +5 -5
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
|
-

|
|
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](
|
|
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
|
---
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.13.
|
|
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
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -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 ===
|
|
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 ===
|
|
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 ||
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 &&
|
|
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"
|
|
2497
|
-
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
|
+
}
|
package/bin/mcp-locate.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
console.log(green('✓') +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/bin/server-list.mjs
CHANGED
|
@@ -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 = [
|
|
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 });
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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