haltija 1.11.0 → 1.11.2

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,88 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.2
4
+
5
+ Patch. Four field reports from tosijs-3d and tosijs-ui, all confirmed.
6
+
7
+ ### Fixed: `--canvas` couldn't reach a canvas in a shadow root ([#15](https://github.com/tonioloewald/haltija/issues/15))
8
+
9
+ Which is where every component-based renderer puts it — so the exact-pixels escape hatch failed on
10
+ exactly the pages where pixels are the only thing worth looking at. Canvas resolution now pierces
11
+ shadow DOM and accepts every shape someone would reasonably write:
12
+
13
+ ```bash
14
+ hj screenshot --canvas "tosi-b3d canvas" # descendant, crossing the boundary
15
+ hj screenshot --canvas "tosi-b3d >>> canvas" # explicit piercing form
16
+ hj screenshot --canvas "canvas" # found inside shadow roots too
17
+ hj screenshot --canvas # no selector: the largest canvas on the page
18
+ ```
19
+
20
+ A genuine miss now lists the canvases that *do* exist, with working selectors. The **schematic**
21
+ embeds shadow-root canvases as well — previously it silently showed none on these pages.
22
+
23
+ ### Fixed: advisory hints were printed to stdout ([#14](https://github.com/tonioloewald/haltija/issues/14))
24
+
25
+ A dim hint line was appended to **stdout** after JSON output, so `JSON.parse(await $\`hj windows\`)`
26
+ threw — and an adopter's readiness probe fell into an open catch and silently did nothing. All
27
+ advisory text is on stderr now; stdout is the data channel.
28
+
29
+ Two related fixes from the same report:
30
+
31
+ - **`hj <cmd> --help` now describes that command** instead of falling through to global help, which
32
+ read exactly like "unknown command" (a reporter concluded `doctor` and `map` didn't exist in their
33
+ build). haltija's own error messages recommend this form, so the remedy we printed was broken.
34
+ - **The standalone `hj` bundle now carries its hints.** They were read from a sibling `hints.json`,
35
+ which doesn't exist next to `~/.local/bin/hj` — so two distributions reporting the same version
36
+ produced different output. Hints are compiled in, like the version and semver helpers.
37
+
38
+ ### Fixed: contrast false positives on text-less ancestors ([#13](https://github.com/tonioloewald/haltija/issues/13))
39
+
40
+ A container propagates `color`/`background` but has no font size, so `large` is *unknowable* —
41
+ defaulting it to false held it to 4.5:1 and manufactured failures for text that passes as large on
42
+ the child that actually renders it. About half the findings on a typical page. Only elements with
43
+ their own direct text are graded now.
44
+
45
+ ## 1.11.1
46
+
47
+ Patch, per the rule that a minor bump waits for a cleared backlog and a nine-lens review.
48
+
49
+ ### Fixed: contrast audit noise
50
+
51
+ Two sources of false findings, fixed before anyone acts on a long list of them:
52
+
53
+ - **Containers with no text of their own were flagged.** A `<div>`/`<form>` inherits a colour but
54
+ displays nothing, so a "failure" there is noise. Only elements that actually render text, a label
55
+ or a value get a verdict now.
56
+ - **Text over a `background-image`** (gradient, photo) was judged against whatever background-*color*
57
+ sat beneath it — which can be wrong in either direction. Those are now reported as
58
+ `colors.uncertain` rather than asserted as pass or fail.
59
+
60
+ An audit people learn to skim is worth nothing, so the bar is: only claim what can be justified.
61
+
62
+ ### Per-tab routing, by declaration ([#1](https://github.com/tonioloewald/haltija/issues/1), [#2](https://github.com/tonioloewald/haltija/issues/2))
63
+
64
+ cwd routing gets a command to the right *server*; which **tab** answers then fell back to focus, so
65
+ two projects on a shared server could drive each other's pages. Ranking tabs by "this origin looks
66
+ like your project" was rejected twice — there's no reliable origin→directory map, and a
67
+ usually-right guess reintroduces the silent misroute cwd routing exists to prevent.
68
+
69
+ So the project declares it. A `.haltija.json` at the project root:
70
+
71
+ ```json
72
+ { "origins": ["https://localhost:8030", "http://localhost:3000"] }
73
+ ```
74
+
75
+ …and `hj`, run anywhere inside that project, pins commands to a connected tab on one of those
76
+ origins regardless of focus. **Entirely opt-in** (no file = unchanged behaviour) and it never
77
+ guesses: if you declared origins and no connected tab matches, `hj` says so loudly instead of
78
+ quietly driving another project's page — and refuses outright under `--strict`. `HALTIJA_ORIGINS`
79
+ overrides for one-off shells and CI.
80
+
81
+ ### `hj screenshot --schematic`
82
+
83
+ Ask for the schematic even when real capture *is* available — it's cheaper, deterministic, and
84
+ carries the contrast audit. Canvases are still embedded as real pixels.
85
+
3
86
  ## 1.11.0
4
87
 
5
88
  ### Errors now tell you what to do
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
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.11.0";
49
+ var VERSION = "1.11.2";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -1294,9 +1294,90 @@
1294
1294
  ...buildDomAffordances(opts.maxNodes)
1295
1295
  };
1296
1296
  }
1297
+ function findCanvasesDeep(root = document) {
1298
+ const found = [];
1299
+ const visit = (node) => {
1300
+ for (const el of Array.from(node.querySelectorAll("*"))) {
1301
+ if (el.tagName === "CANVAS")
1302
+ found.push(el);
1303
+ const sr = el.shadowRoot;
1304
+ if (sr)
1305
+ visit(sr);
1306
+ }
1307
+ };
1308
+ visit(root);
1309
+ return found;
1310
+ }
1311
+ function resolveCanvasDeep(selector) {
1312
+ const all = findCanvasesDeep();
1313
+ if (!selector || !String(selector).trim()) {
1314
+ if (!all.length)
1315
+ return { canvas: null };
1316
+ const largest = all.slice().sort((a, b) => b.width * b.height - a.width * a.height)[0];
1317
+ return {
1318
+ canvas: largest,
1319
+ note: all.length > 1 ? `page has ${all.length} canvases; captured the largest (${largest.width}×${largest.height}). Pass a selector to choose another.` : undefined
1320
+ };
1321
+ }
1322
+ const sel = String(selector).trim();
1323
+ if (sel.includes(">>>")) {
1324
+ const parts = sel.split(">>>").map((p) => p.trim()).filter(Boolean);
1325
+ let scope = document;
1326
+ let el = null;
1327
+ for (const part of parts) {
1328
+ if (!scope)
1329
+ return { canvas: null };
1330
+ el = scope.querySelector(part);
1331
+ if (!el)
1332
+ return { canvas: null };
1333
+ scope = el.shadowRoot || el;
1334
+ }
1335
+ return { canvas: el && el.tagName === "CANVAS" ? el : scope?.querySelector?.("canvas") || null };
1336
+ }
1337
+ const light = resolveSelector(sel);
1338
+ if (light && light.tagName === "CANVAS")
1339
+ return { canvas: light };
1340
+ if (light) {
1341
+ const inHost = light.shadowRoot?.querySelector("canvas");
1342
+ if (inHost)
1343
+ return { canvas: inHost };
1344
+ }
1345
+ const tokens = sel.split(/\s+/).filter(Boolean);
1346
+ if (tokens.length > 1) {
1347
+ const descend = (scope, i) => {
1348
+ if (i >= tokens.length)
1349
+ return null;
1350
+ const rest = tokens.slice(i).join(" ");
1351
+ const direct = scope.querySelector(rest);
1352
+ if (direct && direct.tagName === "CANVAS")
1353
+ return direct;
1354
+ for (const cand of Array.from(scope.querySelectorAll(tokens[i]))) {
1355
+ const sr = cand.shadowRoot;
1356
+ if (!sr)
1357
+ continue;
1358
+ const hit = descend(sr, i + 1);
1359
+ if (hit)
1360
+ return hit;
1361
+ }
1362
+ return null;
1363
+ };
1364
+ const crossed = descend(document, 0);
1365
+ if (crossed)
1366
+ return { canvas: crossed };
1367
+ }
1368
+ for (const el of Array.from(document.querySelectorAll("*"))) {
1369
+ const sr = el.shadowRoot;
1370
+ if (!sr)
1371
+ continue;
1372
+ const hit = sr.querySelector(sel);
1373
+ if (hit && hit.tagName === "CANVAS")
1374
+ return { canvas: hit };
1375
+ }
1376
+ return { canvas: null };
1377
+ }
1297
1378
  function collectCanvasThumbnails(maxEdge = 320) {
1298
1379
  const out = [];
1299
- for (const el of Array.from(document.querySelectorAll("canvas"))) {
1380
+ for (const el of findCanvasesDeep()) {
1300
1381
  const c = el;
1301
1382
  if (!c.width || !c.height)
1302
1383
  continue;
@@ -1472,8 +1553,12 @@
1472
1553
  let node = el;
1473
1554
  let acc = { r: 255, g: 255, b: 255 };
1474
1555
  const stack = [];
1556
+ let imaged = false;
1475
1557
  while (node) {
1476
- const c = parseCssColor(getComputedStyle(node).backgroundColor);
1558
+ const cs = getComputedStyle(node);
1559
+ if (cs.backgroundImage && cs.backgroundImage !== "none")
1560
+ imaged = true;
1561
+ const c = parseCssColor(cs.backgroundColor);
1477
1562
  if (c && c.a > 0) {
1478
1563
  stack.push(c);
1479
1564
  if (c.a === 1)
@@ -1483,7 +1568,7 @@
1483
1568
  }
1484
1569
  for (let i = stack.length - 1;i >= 0; i--)
1485
1570
  acc = over(stack[i], acc);
1486
- return acc;
1571
+ return { ...acc, imaged };
1487
1572
  }
1488
1573
  var relLuminance = (c) => {
1489
1574
  const ch = (v) => {
@@ -1516,7 +1601,8 @@
1516
1601
  border,
1517
1602
  contrast: Math.round(ratio * 10) / 10,
1518
1603
  passes: ratio >= (large ? 3 : 4.5),
1519
- large
1604
+ large,
1605
+ ...bg.imaged ? { uncertain: true } : {}
1520
1606
  };
1521
1607
  }
1522
1608
  function elementNotFoundMessage(target) {
@@ -1560,8 +1646,11 @@
1560
1646
  try {
1561
1647
  const c = probeColors(el);
1562
1648
  node.colors = c;
1563
- if (!c.passes)
1649
+ const hasOwnText = Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent || "").trim().length > 0);
1650
+ const hasReadableText = hasOwnText || !!(node.label || node.value);
1651
+ if (!c.passes && hasReadableText && !c.uncertain) {
1564
1652
  node.contrastFail = `${c.contrast}:1 (needs ${c.large ? 3 : 4.5}:1)`;
1653
+ }
1565
1654
  } catch {}
1566
1655
  return node;
1567
1656
  };
@@ -6261,10 +6350,34 @@ ${elementSummary}${moreText}`;
6261
6350
  targetSelector = element.id ? `#${element.id}` : element.getAttribute("data-testid") ? `[data-testid="${element.getAttribute("data-testid")}"]` : undefined;
6262
6351
  }
6263
6352
  }
6264
- if (payload2?.canvas) {
6265
- const el = resolveSelector(payload2.canvas);
6353
+ if (payload2?.schematic) {
6354
+ const map = buildAffordanceMap({});
6355
+ const canvases = collectCanvasThumbnails();
6356
+ const { svg, width, height } = renderMapSchematic(map, canvases, "SCHEMATIC — requested (not a screenshot)");
6357
+ const image = await rasterizeSchematic(svg, width, height, payload2?.scale || 2);
6358
+ this.respond(msg2.id, true, {
6359
+ image,
6360
+ viewport,
6361
+ format: "png",
6362
+ width,
6363
+ height,
6364
+ source: "schematic",
6365
+ requested: true,
6366
+ canvasesRendered: canvases.filter((c) => c.image).length,
6367
+ map
6368
+ });
6369
+ return;
6370
+ }
6371
+ if (payload2?.canvas !== undefined) {
6372
+ const resolved = resolveCanvasDeep(payload2.canvas);
6373
+ const el = resolved.canvas;
6266
6374
  if (!el) {
6267
- this.respond(msg2.id, false, null, `Canvas not found: ${payload2.canvas}`);
6375
+ const all = findCanvasesDeep();
6376
+ const inventory = all.length ? `Found ${all.length} canvas element(s): ` + all.map((c) => {
6377
+ const host = c.getRootNode()?.host;
6378
+ return `${host ? host.tagName.toLowerCase() + " >>> " : ""}canvas${c.id ? "#" + c.id : ""} (${c.width}×${c.height})`;
6379
+ }).join(", ") + `. Try one of those, or omit the selector to capture the largest.` : `No <canvas> exists on this page (shadow roots included). \`hj map\` shows what is here.`;
6380
+ this.respond(msg2.id, false, null, `Canvas not found: ${payload2.canvas || "(largest)"}. ${inventory}`);
6268
6381
  return;
6269
6382
  }
6270
6383
  if (typeof el.toDataURL !== "function") {
@@ -6323,9 +6436,11 @@ ${elementSummary}${moreText}`;
6323
6436
  height: targetH,
6324
6437
  source: "canvas",
6325
6438
  canvas: {
6326
- selector: payload2.canvas,
6439
+ selector: payload2.canvas || "(largest)",
6327
6440
  intrinsic: { width: el.width, height: el.height },
6328
- displayed: { width: el.clientWidth, height: el.clientHeight }
6441
+ displayed: { width: el.clientWidth, height: el.clientHeight },
6442
+ inShadowRoot: el.getRootNode()?.host ? el.getRootNode().host.tagName.toLowerCase() : undefined,
6443
+ ...resolved.note ? { note: resolved.note } : {}
6329
6444
  },
6330
6445
  ...warning ? { warning } : {}
6331
6446
  });
@@ -31,10 +31,12 @@ import { differsBeyondPatch } from './semver.mjs'
31
31
 
32
32
  const __dirname = dirname(fileURLToPath(import.meta.url))
33
33
 
34
- // Command hints - generated from api-schema.ts during build
35
- // Use readFileSync instead of JSON import to avoid Node.js ExperimentalWarning
36
- const hintsPath = join(__dirname, 'hints.json')
37
- export const COMMAND_HINTS = existsSync(hintsPath) ? JSON.parse(readFileSync(hintsPath, 'utf-8')) : {}
34
+ // Command hints generated from api-schema.ts during build and IMPORTED, not read from disk.
35
+ // Reading a sibling hints.json works for the npm package but not for dist/hj.js installed as a lone
36
+ // file in ~/.local/bin, so the standalone CLI silently had no hints while claiming the same version
37
+ // (issue #14). An import is inlined by the bundler, so both distributions behave identically.
38
+ export { COMMAND_HINTS } from './hints.mjs'
39
+ import { COMMAND_HINTS as COMMAND_HINTS_LOCAL } from './hints.mjs'
38
40
 
39
41
  let warnedAboutSkew = false
40
42
 
@@ -155,9 +157,17 @@ export const ARG_MAPS = {
155
157
  if (a === '--no-chyron') { body.chyron = false; continue }
156
158
  // Read a <canvas>'s own pixels (WebGL/2D) instead of capturing the screen: exact pixels, no
157
159
  // screen-share grant, works off-screen. The route for 3D scenes / render-to-texture UI.
158
- if (a === '--canvas') { body.canvas = args[++i]; continue }
160
+ if (a === '--canvas') {
161
+ // Bare `--canvas` (no selector) captures the largest canvas on the page — which is
162
+ // unambiguous when there's one interesting canvas, the common case.
163
+ const next = args[i + 1]
164
+ body.canvas = next && !next.startsWith('-') ? args[++i] : ''
165
+ continue
166
+ }
159
167
  // Hard-fail instead of returning a labelled schematic when pixels aren't capturable.
160
168
  if (a === '--no-fallback') { body.fallback = false; continue }
169
+ // Prefer the schematic outright: cheaper, deterministic, and it carries the contrast audit.
170
+ if (a === '--schematic') { body.schematic = true; continue }
161
171
  if (!a.startsWith('-')) { positional.push(a) }
162
172
  }
163
173
  return { ...body, ...parseTargetArgs(positional) }
@@ -742,7 +752,7 @@ export const KNOWN_FLAGS = {
742
752
  inspect: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
743
753
  inspectAll: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
744
754
  key: ['--ctrl', '-c', '--shift', '-s', '--alt', '-a', '--meta', '-m'],
745
- screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron', '--canvas', '--no-fallback'],
755
+ screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron', '--canvas', '--no-fallback', '--schematic'],
746
756
  'video-start': ['--maxDuration', '--max-duration'],
747
757
  refresh: ['--soft'],
748
758
  'test-run': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
@@ -995,7 +1005,7 @@ async function doRequest(url, method, body, context = {}) {
995
1005
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
996
1006
  console.log(bold(json.data.path))
997
1007
  const meta = [json.data.width && json.data.height ? `${json.data.width}×${json.data.height}` : null, json.data.format, json.data.source].filter(Boolean).join(', ')
998
- if (meta) console.log(dim(meta))
1008
+ if (meta) console.error(dim(meta))
999
1009
  } else if (!jsonOutput && (subcommand === 'network' || subcommand === 'network-watch') && (json.entries || json.data?.entries || json.summary || json.data?.summary)) {
1000
1010
  console.log(formatNetwork(json))
1001
1011
  } else if (!jsonOutput && subcommand === 'network-stats') {
@@ -1005,7 +1015,7 @@ async function doRequest(url, method, body, context = {}) {
1005
1015
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
1006
1016
  console.log(bold(json.data.path))
1007
1017
  const meta = [json.data.duration ? `${json.data.duration.toFixed(1)}s` : null, json.data.size ? `${(json.data.size / 1024).toFixed(0)}KB` : null, json.data.format].filter(Boolean).join(', ')
1008
- if (meta) console.log(dim(meta))
1018
+ if (meta) console.error(dim(meta))
1009
1019
  } else if (!jsonOutput && UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
1010
1020
  // Print the inner DevResponse.data unwrapped so agents (and humans) can
1011
1021
  // read it directly. Strings go to stdout as-is — no JSON escaping of
@@ -1048,10 +1058,13 @@ async function doRequest(url, method, body, context = {}) {
1048
1058
  // Skip for commands whose stdout is meant to be piped/consumed verbatim
1049
1059
  // — agents shouldn't have to strip a trailing hint line.
1050
1060
  if (resp.ok && !jsonOutput && !UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
1051
- const hint = COMMAND_HINTS[subcommand]
1061
+ const hint = COMMAND_HINTS_LOCAL[subcommand]
1052
1062
  if (hint) {
1053
1063
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
1054
- console.log(dim(`\nhj ${subcommand} : ${hint}`))
1064
+ // stderr, NOT stdout. A hint appended to stdout turns parseable JSON into garbage —
1065
+ // an adopter's `JSON.parse(await $`hj windows`)` threw, their catch fell open, and the
1066
+ // readiness probe silently did nothing (issue #14). Advisory text belongs on stderr.
1067
+ console.error(dim(`hj ${subcommand} : ${hint}`))
1055
1068
  }
1056
1069
  }
1057
1070
 
package/bin/hints.mjs ADDED
@@ -0,0 +1,27 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/api-schema.ts — DO NOT EDIT. Run: bun run build */
2
+ export const COMMAND_HINTS = {
3
+ "tree": "-d 3 (shallow), -i (interactive only), --visible, --compact | see: inspect, query, click",
4
+ "query": "@ref or \"selector\", --all | see: tree, inspect",
5
+ "inspect": "@ref or \"selector\", --styles, --rules, --ancestors | see: tree, query",
6
+ "click": "@ref or \"selector\", :text(Button), --diff | see: tree, wait, type",
7
+ "type": "@ref, --clear, --humanlike false (fast) | see: click, key",
8
+ "key": "<key> --ctrl --shift --alt --meta, --repeat 3 | see: type, click",
9
+ "drag": "@ref or \"selector\" <deltaX> <deltaY>, --duration 500 | see: click, scroll",
10
+ "highlight": "@ref or \"selector\", --label \"text\", --color #f00, --duration 3000 | see: unhighlight, screenshot",
11
+ "scroll": "@ref or \"selector\" or <deltaY>, --duration 500 | see: click, wait",
12
+ "wait": "\"selector\", --text \"content\", --timeout 5000 | see: click, navigate",
13
+ "events": "events-watch first | see: recording, console, mutations-watch",
14
+ "eval": "\"code\" (returns result) | see: console, snapshot",
15
+ "call": "@ref or \"selector\" <method>, --args [...] | see: eval, inspect",
16
+ "screenshot": "[selector], --format webp, --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
17
+ "windows": "--json | see: tabs-open, tabs-close, tabs-focus, status",
18
+ "map": "--json | see: tree, query, inspect",
19
+ "tabs-open": "[url] | see: tabs-focus, tabs-close, windows",
20
+ "tabs-close": "<window-id> | see: windows, tabs-focus, tabs-open",
21
+ "tabs-focus": "<window-id> | see: windows, tabs-close, tabs-open",
22
+ "recording": "start, stop, list, replay <id|index> | see: test-run, events",
23
+ "video-start": "--maxDuration 120 | see: video-stop, video-status, screenshot",
24
+ "video-stop": "| see: video-start, video-status",
25
+ "video-status": "| see: video-start, video-stop",
26
+ "status": "--json | see: windows, stats, console"
27
+ }
package/bin/hj.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
14
14
  import { extractWindowTarget } from './arg-utils.mjs'
15
+ import { findProjectOrigins, routeByDeclaredOrigin } from './project-origins.mjs'
15
16
  import { HJ_VERSION } from './version.mjs'
16
17
  import { differsBeyondPatch } from './semver.mjs'
17
18
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
@@ -350,6 +351,15 @@ function resolveByCwd(cwd, instances) {
350
351
  return candidates[0]
351
352
  }
352
353
 
354
+ // `hj <cmd> --help` shows help for THAT command. It used to fall into the global help below, which
355
+ // reads exactly like "unknown command" — a reporter reasonably concluded `doctor` and `map` didn't
356
+ // exist in their build (issue #14). Worse, haltija's own error messages tell you to run
357
+ // `hj <cmd> --help`, so the remedy we print was broken: a printed remedy is a testable claim.
358
+ if ((args.includes('--help') || args.includes('-h')) && args[0] && !args[0].startsWith('-')) {
359
+ filterHelp(args[0])
360
+ process.exit(0)
361
+ }
362
+
353
363
  if (!args.length || args.includes('--help') || args.includes('-h')) {
354
364
  const bold = (s) => `\x1b[1m${s}\x1b[0m`
355
365
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
@@ -609,6 +619,50 @@ if (subcommand === 'shutdown' || subcommand === 'quit') {
609
619
  }
610
620
  }
611
621
 
622
+ // Declared-origin routing (issues #1/#2). cwd routing found the right SERVER; if this project has
623
+ // declared which origins are its pages, pin the command to the matching TAB instead of letting focus
624
+ // decide. Purely opt-in — no `.haltija.json` (or HALTIJA_ORIGINS) means nothing changes.
625
+ //
626
+ // Skipped when the caller already pinned a window (they own the choice), and for the diagnostic
627
+ // commands, which must describe the world rather than act on one tab.
628
+ const DIAGNOSTIC = new Set(['where', 'servers', 'ls', 'doctor', 'shutdown', 'quit', 'status', 'windows', 'version'])
629
+ if (!windowTarget && !DIAGNOSTIC.has(subcommand) && isSubcommand(subcommand)) {
630
+ const declared = findProjectOrigins(process.cwd(), process.env)
631
+ if (declared && declared.origins.length) {
632
+ try {
633
+ const token = process.env.HALTIJA_TOKEN
634
+ const resp = await fetch(`http://localhost:${port}/windows`, {
635
+ headers: token ? { 'X-Haltija-Token': token } : {},
636
+ signal: AbortSignal.timeout(2500),
637
+ })
638
+ if (resp.ok) {
639
+ const { windows: tabs = [], focused } = await resp.json()
640
+ const routed = routeByDeclaredOrigin(declared.origins, tabs, focused)
641
+ if (routed.kind === 'matched') {
642
+ subArgs = [...subArgs, '--window', routed.windowId]
643
+ } else if (routed.kind === 'no-match' && tabs.length) {
644
+ // NEVER fall through silently: this project said which pages are its own, and none is
645
+ // connected. Driving whatever happens to be focused is the exact bug the declaration was
646
+ // added to prevent.
647
+ const saw = routed.sawOrigins.length ? routed.sawOrigins.join(', ') : '(none with a readable origin)'
648
+ const msg =
649
+ `declared origins ${declared.origins.join(', ')} (from ${declared.source}) match no connected tab. ` +
650
+ `Connected: ${saw}.`
651
+ if (STRICT) {
652
+ console.error(`hj: ERROR (strict) — ${msg}`)
653
+ console.error(`hj: open one of your declared origins, fix .haltija.json, or pass --window <id> to choose explicitly.`)
654
+ process.exit(1)
655
+ }
656
+ console.error(`hj: warning — ${msg}`)
657
+ console.error(`hj: proceeding against the FOCUSED tab, which may be another project's page. Pass --window <id> to be sure.`)
658
+ }
659
+ }
660
+ } catch {
661
+ // Routing is an enhancement; never let a probe failure block the command.
662
+ }
663
+ }
664
+ }
665
+
612
666
  if (!isSubcommand(subcommand)) {
613
667
  const suggestion = getSuggestion(subcommand)
614
668
  if (suggestion === '--help') {
@@ -0,0 +1,77 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/project-origins.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/project-origins.ts
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { dirname, join, parse as parsePath } from "path";
5
+ function normalizeOrigin(value) {
6
+ const v = String(value || "").trim();
7
+ if (!v)
8
+ return null;
9
+ const parse = (candidate) => {
10
+ try {
11
+ const origin = new URL(candidate).origin;
12
+ return origin && origin !== "null" ? origin : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+ return parse(v) ?? parse(`http://${v}`);
18
+ }
19
+ function findProjectOrigins(cwd, env = process.env) {
20
+ const fromEnv = env.HALTIJA_ORIGINS;
21
+ if (fromEnv && fromEnv.trim()) {
22
+ const origins = fromEnv.split(",").map(normalizeOrigin).filter((o) => !!o);
23
+ if (origins.length)
24
+ return { origins, source: "HALTIJA_ORIGINS env" };
25
+ }
26
+ let dir = cwd;
27
+ const { root } = parsePath(cwd);
28
+ for (let depth = 0;depth < 64; depth++) {
29
+ const file = join(dir, ".haltija.json");
30
+ if (existsSync(file)) {
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
33
+ const raw = Array.isArray(parsed?.origins) ? parsed.origins : [];
34
+ const origins = raw.map(normalizeOrigin).filter((o) => !!o);
35
+ return { origins, source: file };
36
+ } catch {
37
+ return { origins: [], source: `${file} (unreadable or invalid JSON)` };
38
+ }
39
+ }
40
+ if (dir === root)
41
+ break;
42
+ const parent = dirname(dir);
43
+ if (parent === dir)
44
+ break;
45
+ dir = parent;
46
+ }
47
+ return null;
48
+ }
49
+ function routeByDeclaredOrigin(declared, tabs, focusedWindowId) {
50
+ if (!declared.length)
51
+ return { kind: "no-declaration" };
52
+ const wanted = new Set(declared);
53
+ const topLevel = tabs.filter((t) => (t.windowType || "tab") === "tab");
54
+ const matches = topLevel.filter((t) => {
55
+ const o = normalizeOrigin(t.url || "");
56
+ return o !== null && wanted.has(o);
57
+ });
58
+ if (!matches.length) {
59
+ const sawOrigins = [...new Set(topLevel.map((t) => normalizeOrigin(t.url || "")).filter((o) => !!o))];
60
+ return { kind: "no-match", declared, sawOrigins };
61
+ }
62
+ const visible = matches.filter((t) => t.active !== false);
63
+ const pool = visible.length ? visible : matches;
64
+ const focused = pool.find((t) => t.id === focusedWindowId);
65
+ const chosen = focused || pool[0];
66
+ return {
67
+ kind: "matched",
68
+ windowId: chosen.id,
69
+ origin: normalizeOrigin(chosen.url || ""),
70
+ candidates: matches.length
71
+ };
72
+ }
73
+ export {
74
+ routeByDeclaredOrigin,
75
+ normalizeOrigin,
76
+ findProjectOrigins
77
+ };
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.11.0'
6
+ export const HJ_VERSION = '1.11.2'
@@ -244,6 +244,7 @@ export declare const screenshot: EndpointDef<{
244
244
  maxHeight?: number | undefined;
245
245
  chyron?: boolean | undefined;
246
246
  file?: boolean | undefined;
247
+ schematic?: boolean | undefined;
247
248
  fallback?: boolean | undefined;
248
249
  }>;
249
250
  export declare const select: EndpointDef<{
@@ -546,6 +547,7 @@ export declare const endpoints: {
546
547
  maxHeight?: number | undefined;
547
548
  chyron?: boolean | undefined;
548
549
  file?: boolean | undefined;
550
+ schematic?: boolean | undefined;
549
551
  fallback?: boolean | undefined;
550
552
  }>;
551
553
  readonly select: EndpointDef<{
@@ -816,6 +818,7 @@ export declare const ALL_ENDPOINTS: (EndpointDef<{
816
818
  maxHeight?: number | undefined;
817
819
  chyron?: boolean | undefined;
818
820
  file?: boolean | undefined;
821
+ schematic?: boolean | undefined;
819
822
  fallback?: boolean | undefined;
820
823
  }> | EndpointDef<{
821
824
  window?: string | undefined;
@@ -20,7 +20,7 @@
20
20
  * - Option+Tab toggles visibility (but active state always shows briefly)
21
21
  * - Localhost only by default
22
22
  */
23
- export declare const VERSION = "1.11.0";
23
+ export declare const VERSION = "1.11.2";
24
24
  export declare class DevChannel extends HTMLElement {
25
25
  static get tagName(): string;
26
26
  static elementCreator(): () => DevChannel;