haltija 1.3.0-beta.9 → 1.3.0

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/README.md CHANGED
@@ -76,6 +76,26 @@ Haltija connects to the browser you're already using. The one with the bug, the
76
76
 
77
77
  ---
78
78
 
79
+ ## Haltija vs. Claude in Chrome
80
+
81
+ Both let an agent drive a browser — they're built for different jobs. Because Haltija can either **spawn its own isolated browser** *or* **attach to your real one** (via a script tag / bookmarklet on a port you choose), it covers the axes a live-session-only tool can't.
82
+
83
+ | Axis | Haltija | Claude in Chrome |
84
+ |---|---|---|
85
+ | Setup / auth | ✅ `bunx haltija` — zero auth, self-spawns | ⚠️ Extension + claude.ai login + per-site permissions |
86
+ | How the agent invokes it | ✅ Plain `hj` CLI / REST — always available in a shell | ⚠️ MCP tools; must be loaded + extension connected |
87
+ | Project integration | ✅ *Is* your test harness — JSON tests (`hj test-suite`), record→replay, CI | ❌ None |
88
+ | Determinism / cost | ✅ DOM + `eval` with stable ref IDs — cheap text, scriptable, headless-CI-able | ⚠️ Has structured reads (page text, find-by-text) *and* vision/coordinate actions; the vision path is token-heavy and coordinate clicks less deterministic |
89
+ | Isolation | ✅ Own instance on its own port — never touches your browsing | ❌ Drives tabs in your live session |
90
+ | Real-browser fidelity (WebGL/3D, pixels) | ✅ Attach to a real GPU Chrome or the desktop app *(headless GPU is the one weak spot)* | ✅ Full real Chrome GPU |
91
+ | Authenticated / external sites | ✅ Inject into your logged-in browser on a chosen port *(one-time setup step)* | ✅ Native — already your logged-in profile |
92
+ | Flakiness / contention | ✅ A private port per project = dedicated instance *(a shared instance can still flake under contention)* | ⚠️ Depends on live browser + extension state |
93
+ | Screenshots | ✅ Native (desktop) or WebRTC `getDisplayMedia` (browser) | ✅ Vision-native |
94
+
95
+ **The short version:** reach for Claude in Chrome to glance at the tab you're already looking at; reach for Haltija when you want a browser your agent *controls* — reproducibly, cheaply, in CI, and as the regression harness your project already has.
96
+
97
+ ---
98
+
79
99
  ## How It Works
80
100
 
81
101
  ```
@@ -246,18 +266,25 @@ The server rejects every REST/WebSocket request without a matching `X-Haltija-To
246
266
  ## Installation
247
267
 
248
268
  ```bash
249
- bunx haltija # Desktop app (recommended)
250
- bunx haltija --server # Server only (your browser, CI, remote)
269
+ bunx haltija # Bundled browser + embedded server (easiest start)
270
+ bunx haltija --server # Server only your browser, per-project dev/debug
251
271
  npm install -g haltija # Install globally
252
272
 
253
273
  # Server options
254
274
  haltija --https # HTTPS mode
255
275
  haltija --port 3000 # Custom port
276
+ haltija --name <proj> # Register a per-project instance (hj --name <proj>)
256
277
  haltija --token <secret> # Require X-Haltija-Token on every request
257
- haltija --headless # For CI pipelines
278
+ haltija --headless # For CI pipelines (no desktop app needed)
258
279
  haltija --setup-mcp # Configure Claude Desktop
259
280
  ```
260
281
 
282
+ **Which mode?** For day-to-day dev/debugging, run a per-project server
283
+ (`haltija --server --name <proj>`) and drive it with the `hj` CLI or your coding
284
+ agent — that's the paved path. For CI, `haltija --headless` runs a deterministic
285
+ headless Chromium with no desktop app required. A downloadable notarized desktop
286
+ app also exists but isn't required for either.
287
+
261
288
  ---
262
289
 
263
290
  ## Security
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.3.0-beta.9",
3
+ "version": "1.3.0",
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.3.0-beta.9";
49
+ var VERSION = "1.3.0";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -98,6 +98,47 @@
98
98
  return lower.includes(parsed.searchText);
99
99
  }
100
100
 
101
+ // src/key-codes.ts
102
+ function keyToCode(key) {
103
+ const specialKeys = {
104
+ Enter: "Enter",
105
+ Escape: "Escape",
106
+ Tab: "Tab",
107
+ Backspace: "Backspace",
108
+ Delete: "Delete",
109
+ ArrowUp: "ArrowUp",
110
+ ArrowDown: "ArrowDown",
111
+ ArrowLeft: "ArrowLeft",
112
+ ArrowRight: "ArrowRight",
113
+ Home: "Home",
114
+ End: "End",
115
+ PageUp: "PageUp",
116
+ PageDown: "PageDown",
117
+ " ": "Space",
118
+ Space: "Space",
119
+ ".": "Period",
120
+ ",": "Comma",
121
+ "/": "Slash",
122
+ ";": "Semicolon",
123
+ "'": "Quote",
124
+ "[": "BracketLeft",
125
+ "]": "BracketRight",
126
+ "\\": "Backslash",
127
+ "-": "Minus",
128
+ "=": "Equal",
129
+ "`": "Backquote"
130
+ };
131
+ if (specialKeys[key])
132
+ return specialKeys[key];
133
+ if (/^F\d{1,2}$/.test(key))
134
+ return key;
135
+ if (/^[a-zA-Z]$/.test(key))
136
+ return `Key${key.toUpperCase()}`;
137
+ if (/^[0-9]$/.test(key))
138
+ return `Digit${key}`;
139
+ return key;
140
+ }
141
+
101
142
  // src/component.ts
102
143
  var VERSION2 = VERSION;
103
144
  var PRODUCT_NAME = "Haltija";
@@ -1435,7 +1476,7 @@
1435
1476
  if (htmlEl.disabled) {
1436
1477
  flags.disabled = true;
1437
1478
  }
1438
- if (htmlEl.readOnly) {
1479
+ if ("readOnly" in htmlEl && htmlEl.readOnly) {
1439
1480
  flags.readOnly = true;
1440
1481
  }
1441
1482
  if (!htmlEl.validity.valid) {
@@ -1717,6 +1758,8 @@
1717
1758
  isElectron = false;
1718
1759
  browserId = uid();
1719
1760
  killed = false;
1761
+ displayStream = null;
1762
+ displayVideo = null;
1720
1763
  isActive = true;
1721
1764
  homeLeft = 0;
1722
1765
  homeBottom = 16;
@@ -1918,6 +1961,7 @@
1918
1961
  this.restoreDialogs();
1919
1962
  this.clearEventWatchers();
1920
1963
  this.stopMutationWatch();
1964
+ this.stopScreenCapture();
1921
1965
  }
1922
1966
  attributeChangedCallback(name, _old, value) {
1923
1967
  if (name === "server") {
@@ -2085,6 +2129,10 @@
2085
2129
  color: white;
2086
2130
  animation: pulse 1s infinite;
2087
2131
  }
2132
+ .btn[data-action="screen"].sharing {
2133
+ background: #3b82f6;
2134
+ color: white;
2135
+ }
2088
2136
  .btn[data-action="logs"] {
2089
2137
  font-size: 10px;
2090
2138
  font-weight: 500;
@@ -2485,6 +2533,7 @@
2485
2533
  <div class="controls">
2486
2534
  <button class="btn" data-action="select" title="Click or drag to select elements" aria-label="Select elements">\uD83D\uDC46</button>
2487
2535
  <button class="btn" data-action="record" title="Record test (click to start/stop)" aria-label="Record test">REC</button>
2536
+ <button class="btn" data-action="screen" title="Share screen so the agent can take screenshots (browser only; not needed in Haltija desktop app)" aria-label="Share screen for screenshots">\uD83D\uDDA5</button>
2488
2537
  <button class="btn" data-action="logs" title="Show event log panel" aria-label="Toggle event log">LOG</button>
2489
2538
  <button class="btn info-btn" data-action="stats" title="Copy stats to clipboard" aria-label="Copy stats">i</button>
2490
2539
  <button class="btn" data-action="minimize" title="Minimize widget (⌥Tab)" aria-label="Minimize">─</button>
@@ -2566,6 +2615,8 @@
2566
2615
  this.toggleRecording();
2567
2616
  if (action2 === "select")
2568
2617
  this.startSelection();
2618
+ if (action2 === "screen")
2619
+ this.toggleScreenCapture();
2569
2620
  if (action2 === "stats")
2570
2621
  this.copyStatsToClipboard();
2571
2622
  if (action2 === "close-modal")
@@ -3016,7 +3067,7 @@
3016
3067
  if (options.addAssertions && inputValue) {
3017
3068
  steps.push({
3018
3069
  action: "assert",
3019
- assertion: { type: "value", selector, expected: inputValue },
3070
+ assertion: { type: "value", selector, value: inputValue },
3020
3071
  description: `Verify ${inputLabel} is "${inputValue}"`
3021
3072
  });
3022
3073
  }
@@ -3845,6 +3896,55 @@ ${elementSummary}${moreText}`;
3845
3896
  this.hide();
3846
3897
  }
3847
3898
  }
3899
+ async toggleScreenCapture() {
3900
+ if (this.displayStream) {
3901
+ this.stopScreenCapture();
3902
+ return;
3903
+ }
3904
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
3905
+ console.warn(`${LOG_PREFIX} Screen capture not supported in this browser.`);
3906
+ return;
3907
+ }
3908
+ try {
3909
+ const stream = await navigator.mediaDevices.getDisplayMedia({
3910
+ video: { frameRate: { ideal: 5 } },
3911
+ audio: false
3912
+ });
3913
+ this.displayStream = stream;
3914
+ const video = document.createElement("video");
3915
+ video.srcObject = stream;
3916
+ video.muted = true;
3917
+ video.playsInline = true;
3918
+ await video.play().catch(() => {});
3919
+ this.displayVideo = video;
3920
+ stream.getVideoTracks()[0]?.addEventListener("ended", () => {
3921
+ this.stopScreenCapture();
3922
+ });
3923
+ const btn = this.shadowRoot?.querySelector('.btn[data-action="screen"]');
3924
+ btn?.classList.add("sharing");
3925
+ } catch (err) {
3926
+ console.log(`${LOG_PREFIX} Screen capture not started: ${err.message}`);
3927
+ }
3928
+ }
3929
+ stopScreenCapture() {
3930
+ if (this.displayStream) {
3931
+ for (const track of this.displayStream.getTracks()) {
3932
+ try {
3933
+ track.stop();
3934
+ } catch {}
3935
+ }
3936
+ this.displayStream = null;
3937
+ }
3938
+ if (this.displayVideo) {
3939
+ try {
3940
+ this.displayVideo.pause();
3941
+ } catch {}
3942
+ this.displayVideo.srcObject = null;
3943
+ this.displayVideo = null;
3944
+ }
3945
+ const btn = this.shadowRoot?.querySelector('.btn[data-action="screen"]');
3946
+ btn?.classList.remove("sharing");
3947
+ }
3848
3948
  toggleMinimize() {
3849
3949
  if (this.headless)
3850
3950
  return;
@@ -5336,7 +5436,8 @@ ${elementSummary}${moreText}`;
5336
5436
  scroll: 0,
5337
5437
  mutation: 0,
5338
5438
  console: 0,
5339
- focus: 0
5439
+ focus: 0,
5440
+ recording: 0
5340
5441
  };
5341
5442
  this.statsStartTime = Date.now();
5342
5443
  this.startSemanticEvents();
@@ -5781,7 +5882,37 @@ ${elementSummary}${moreText}`;
5781
5882
  return;
5782
5883
  }
5783
5884
  }
5784
- this.respond(msg2.id, false, null, "Screenshots require the Haltija Desktop app. Run: npx haltija@latest -f");
5885
+ if (this.displayVideo && this.displayStream) {
5886
+ try {
5887
+ const video = this.displayVideo;
5888
+ const w = video.videoWidth;
5889
+ const h = video.videoHeight;
5890
+ if (!w || !h) {
5891
+ this.respond(msg2.id, false, null, "Screen capture stream has no frame data yet — try again in a moment.");
5892
+ return;
5893
+ }
5894
+ const canvas = document.createElement("canvas");
5895
+ canvas.width = w;
5896
+ canvas.height = h;
5897
+ const ctx = canvas.getContext("2d");
5898
+ ctx.drawImage(video, 0, 0, w, h);
5899
+ const dataUrl = canvas.toDataURL("image/png");
5900
+ const converted = await convertFormat(dataUrl);
5901
+ this.respond(msg2.id, true, {
5902
+ image: converted.image,
5903
+ viewport,
5904
+ format,
5905
+ width: converted.width,
5906
+ height: converted.height,
5907
+ source: "getDisplayMedia"
5908
+ });
5909
+ return;
5910
+ } catch (err) {
5911
+ this.respond(msg2.id, false, null, `Screen capture frame failed: ${err.message}`);
5912
+ return;
5913
+ }
5914
+ }
5915
+ this.respond(msg2.id, false, null, "No screenshot capture available. Either run the Haltija Desktop app (npx haltija@latest -f) or click the \uD83D\uDDA5 button in the Haltija widget to share your screen.");
5785
5916
  } catch (err) {
5786
5917
  this.respond(msg2.id, false, null, err.message);
5787
5918
  }
@@ -6193,31 +6324,6 @@ ${elementSummary}${moreText}`;
6193
6324
  el.value = value;
6194
6325
  }
6195
6326
  }
6196
- getKeyCode(char) {
6197
- const upper = char.toUpperCase();
6198
- if (upper >= "A" && upper <= "Z")
6199
- return `Key${upper}`;
6200
- if (char >= "0" && char <= "9")
6201
- return `Digit${char}`;
6202
- const specialKeys = {
6203
- " ": "Space",
6204
- ".": "Period",
6205
- ",": "Comma",
6206
- "/": "Slash",
6207
- ";": "Semicolon",
6208
- "'": "Quote",
6209
- "[": "BracketLeft",
6210
- "]": "BracketRight",
6211
- "\\": "Backslash",
6212
- "-": "Minus",
6213
- "=": "Equal",
6214
- "`": "Backquote",
6215
- Enter: "Enter",
6216
- Tab: "Tab",
6217
- Backspace: "Backspace"
6218
- };
6219
- return specialKeys[char] || `Key${upper}`;
6220
- }
6221
6327
  getAdjacentKeys() {
6222
6328
  return {
6223
6329
  a: ["s", "q", "w", "z"],
@@ -6464,32 +6570,7 @@ ${elementSummary}${moreText}`;
6464
6570
  }
6465
6571
  }
6466
6572
  getKeyCode(key) {
6467
- const specialKeys = {
6468
- Enter: "Enter",
6469
- Escape: "Escape",
6470
- Tab: "Tab",
6471
- Backspace: "Backspace",
6472
- Delete: "Delete",
6473
- ArrowUp: "ArrowUp",
6474
- ArrowDown: "ArrowDown",
6475
- ArrowLeft: "ArrowLeft",
6476
- ArrowRight: "ArrowRight",
6477
- Home: "Home",
6478
- End: "End",
6479
- PageUp: "PageUp",
6480
- PageDown: "PageDown",
6481
- " ": "Space",
6482
- Space: "Space"
6483
- };
6484
- if (specialKeys[key])
6485
- return specialKeys[key];
6486
- if (/^F\d{1,2}$/.test(key))
6487
- return key;
6488
- if (/^[a-zA-Z]$/.test(key))
6489
- return `Key${key.toUpperCase()}`;
6490
- if (/^[0-9]$/.test(key))
6491
- return `Digit${key}`;
6492
- return key;
6573
+ return keyToCode(key);
6493
6574
  }
6494
6575
  getElementLabel(el) {
6495
6576
  const text = el.textContent?.trim().slice(0, 30);
@@ -97,6 +97,7 @@ export const ARG_MAPS = {
97
97
  highlight: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), label: args[1] }),
98
98
  unhighlight: () => ({}),
99
99
  find: (args) => ({ text: args.join(' ') }),
100
+ form: (args) => parseFormArgs(args),
100
101
  wait: (args) => parseWaitArgs(args),
101
102
  call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
102
103
  fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
@@ -106,6 +107,14 @@ export const ARG_MAPS = {
106
107
  for (let i = 0; i < args.length; i++) {
107
108
  const a = args[i]
108
109
  if (a === '--data-url') { body.file = false; continue }
110
+ if (a === '--format') { body.format = args[++i]; continue }
111
+ if (a === '--quality') {
112
+ // Accept both 0–1 (canvas-native) and 0–100 (documented) — normalize
113
+ // anything > 1 down to the 0–1 the widget's toDataURL expects.
114
+ const q = num(args[++i])
115
+ if (q != null && !Number.isNaN(q)) body.quality = q > 1 ? q / 100 : q
116
+ continue
117
+ }
109
118
  if (a === '--scale') { body.scale = num(args[++i]); continue }
110
119
  if (a === '--maxWidth' || a === '--max-width') { body.maxWidth = num(args[++i]); continue }
111
120
  if (a === '--maxHeight' || a === '--max-height') { body.maxHeight = num(args[++i]); continue }
@@ -135,7 +144,6 @@ export const ARG_MAPS = {
135
144
  'events-watch': (args) => ({ preset: args[0] || 'interactive' }),
136
145
  'mutations-watch': (args) => ({ preset: args[0] || 'smart' }),
137
146
  'network-watch': (args) => ({ preset: args[0] || 'standard' }),
138
- form: (args) => parseTargetArgs(args),
139
147
  // send <agent> <message> or send selection/recording
140
148
  // --no-submit flag prevents auto-submit (paste only)
141
149
  'test-run': (args) => {
@@ -270,6 +278,20 @@ export function parseClickArgs(args) {
270
278
  return Object.keys(body).length ? body : {}
271
279
  }
272
280
 
281
+ /** Parse form args: optional form selector + --include-disabled/--include-hidden */
282
+ export function parseFormArgs(args) {
283
+ const body = {}
284
+ const positional = []
285
+ for (let i = 0; i < args.length; i++) {
286
+ const a = args[i]
287
+ if (a === '--include-disabled') { body.includeDisabled = true; continue }
288
+ if (a === '--include-hidden') { body.includeHidden = true; continue }
289
+ if (!a.startsWith('-')) { positional.push(a); continue }
290
+ }
291
+ if (positional.length) body.selector = positional[0]
292
+ return Object.keys(body).length ? body : undefined
293
+ }
294
+
273
295
  /** Parse inspect args: selector/ref + CSS flags */
274
296
  export function parseInspectArgs(args) {
275
297
  const body = {}
@@ -621,10 +643,107 @@ async function ensureBrowserConnected(port) {
621
643
  // Commands that don't need a browser window to be connected
622
644
  const INFO_COMMANDS = new Set(['status', 'windows', 'version', 'help'])
623
645
 
646
+ // Commands whose payload lives in DevResponse.data and should be printed
647
+ // unwrapped: strings verbatim, objects/arrays as pretty JSON, no envelope.
648
+ // Trailing command hint is suppressed for these so agents can pipe stdout
649
+ // directly. Pass --json to get the full DevResponse envelope instead.
650
+ const UNWRAP_DATA_SUBCOMMANDS = new Set([
651
+ 'eval', // JS expression result (any type)
652
+ 'call', // method-call result on an element
653
+ 'fetch', // fetched URL response (body + headers + status)
654
+ 'location', // current URL + title
655
+ 'query', // matched element info (single or array)
656
+ 'inspect', // single-element details
657
+ 'inspectAll', // array of element details
658
+ 'find', // elements located by text
659
+ 'console', // console buffer entries
660
+ 'form', // form field values
661
+ ])
662
+
624
663
  // ============================================
625
664
  // Main subcommand execution
626
665
  // ============================================
627
666
 
667
+ // Flags each flag-oriented subcommand recognizes. Used to (a) accept
668
+ // `--flag=value` as well as `--flag value`, and (b) warn — not fail — when an
669
+ // agent passes a flag the command will otherwise silently ignore. Commands that
670
+ // take free-form text (type, eval, find, snapshot, send…) are intentionally
671
+ // ABSENT: a leading-dash token there is content, not a flag, so we leave them
672
+ // untouched.
673
+ const GLOBAL_FLAGS = ['--json', '--window', '--port', '--name', '--token', '--no-launch', '--help']
674
+ export const KNOWN_FLAGS = {
675
+ tree: ['--depth', '-d', '--selector', '-s', '--compact', '-c', '--interactive', '-i', '--visible', '-v', '--text', '--no-text', '--shadow', '--frames', '--no-frames'],
676
+ click: ['--diff', '--delay'],
677
+ form: ['--include-disabled', '--include-hidden'],
678
+ inspect: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
679
+ inspectAll: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
680
+ key: ['--ctrl', '-c', '--shift', '-s', '--alt', '-a', '--meta', '-m'],
681
+ screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron'],
682
+ 'video-start': ['--maxDuration', '--max-duration'],
683
+ refresh: ['--soft'],
684
+ 'test-run': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
685
+ 'test-validate': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
686
+ 'test-suite': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
687
+ }
688
+
689
+ /** Split `--flag=value` into `--flag`, `value` (first `=` only). Long flags only. */
690
+ export function normalizeEqualsFlags(args) {
691
+ const out = []
692
+ for (const a of args) {
693
+ if (a.startsWith('--') && a.includes('=')) {
694
+ const eq = a.indexOf('=')
695
+ out.push(a.slice(0, eq), a.slice(eq + 1))
696
+ } else {
697
+ out.push(a)
698
+ }
699
+ }
700
+ return out
701
+ }
702
+
703
+ /** Levenshtein distance, for "did you mean" suggestions. */
704
+ function editDistance(a, b) {
705
+ const m = a.length, n = b.length
706
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
707
+ for (let j = 0; j <= n; j++) d[0][j] = j
708
+ for (let i = 1; i <= m; i++) {
709
+ for (let j = 1; j <= n; j++) {
710
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
711
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
712
+ }
713
+ }
714
+ return d[m][n]
715
+ }
716
+
717
+ /** Closest known flag within a small edit distance, or null. */
718
+ function closestFlag(input, candidates) {
719
+ let best = null, bestD = Infinity
720
+ for (const c of candidates) {
721
+ const dist = editDistance(input, c)
722
+ if (dist < bestD) { bestD = dist; best = c }
723
+ }
724
+ return bestD <= 3 ? best : null
725
+ }
726
+
727
+ /**
728
+ * Warn (to stderr, non-fatal) about dashed tokens a flag-oriented command
729
+ * doesn't recognize, so a typo like `--frmat` or an unsupported flag stops
730
+ * silently doing nothing. No-op for free-text commands (not in KNOWN_FLAGS).
731
+ */
732
+ export function warnUnknownFlags(subcommand, args) {
733
+ const known = KNOWN_FLAGS[subcommand]
734
+ if (!known) return
735
+ const allowed = new Set([...known, ...GLOBAL_FLAGS])
736
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
737
+ for (const a of args) {
738
+ if (!a.startsWith('-')) continue // positional or a flag's value
739
+ if (/^-\d/.test(a)) continue // negative number, not a flag
740
+ if (allowed.has(a)) continue
741
+ const suggestion = closestFlag(a, known)
742
+ const hint = suggestion ? ` (did you mean ${suggestion}?)` : ''
743
+ process.stderr.write(dim(`[hj] warning: unknown flag "${a}" ignored${hint}`) + '\n')
744
+ }
745
+ }
746
+
628
747
  export async function runSubcommand(subcommand, subArgs, port = '8700', options = {}) {
629
748
  const baseUrl = `http://localhost:${port}`
630
749
  const jsonOutput = subArgs.includes('--json')
@@ -638,6 +757,13 @@ export async function runSubcommand(subcommand, subArgs, port = '8700', options
638
757
  filteredArgs = [...filteredArgs.slice(0, windowIdx), ...filteredArgs.slice(windowIdx + 2)]
639
758
  }
640
759
 
760
+ // For flag-oriented commands, accept `--flag=value` and surface unknown flags
761
+ // instead of silently dropping them. Free-text commands are left untouched.
762
+ if (KNOWN_FLAGS[subcommand]) {
763
+ filteredArgs = normalizeEqualsFlags(filteredArgs)
764
+ warnUnknownFlags(subcommand, filteredArgs)
765
+ }
766
+
641
767
  // Check if server is running, auto-start if not
642
768
  if (!(await isServerRunning(port))) {
643
769
  // Respect "user manually quit Haltija" before we try to spawn anything.
@@ -765,6 +891,25 @@ async function doRequest(url, method, body, context = {}) {
765
891
  console.log(bold(json.data.path))
766
892
  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(', ')
767
893
  if (meta) console.log(dim(meta))
894
+ } else if (!jsonOutput && UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
895
+ // Print the inner DevResponse.data unwrapped so agents (and humans) can
896
+ // read it directly. Strings go to stdout as-is — no JSON escaping of
897
+ // newlines, quotes, etc. Objects/arrays still pretty-print as JSON.
898
+ // Failures go to stderr with a non-zero exit. Pass --json to get the
899
+ // full DevResponse envelope instead.
900
+ if (json.success === false) {
901
+ console.error(`${subcommand} failed: ${json.error || 'unknown error'}`)
902
+ process.exit(1)
903
+ }
904
+ const result = json.data
905
+ if (result === null || result === undefined) {
906
+ // nothing to print
907
+ } else if (typeof result === 'string') {
908
+ process.stdout.write(result)
909
+ if (!result.endsWith('\n')) process.stdout.write('\n')
910
+ } else {
911
+ console.log(JSON.stringify(result, null, 2))
912
+ }
768
913
  } else {
769
914
  console.log(JSON.stringify(json, null, 2))
770
915
  }
@@ -773,8 +918,10 @@ async function doRequest(url, method, body, context = {}) {
773
918
  console.log(text)
774
919
  }
775
920
 
776
- // Show hint for this command (if available and successful)
777
- if (resp.ok && !jsonOutput) {
921
+ // Show hint for this command (if available and successful).
922
+ // Skip for commands whose stdout is meant to be piped/consumed verbatim
923
+ // — agents shouldn't have to strip a trailing hint line.
924
+ if (resp.ok && !jsonOutput && !UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
778
925
  const hint = COMMAND_HINTS[subcommand]
779
926
  if (hint) {
780
927
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
@@ -798,7 +945,7 @@ async function doRequest(url, method, body, context = {}) {
798
945
 
799
946
  /** Known valid subcommands */
800
947
  export const KNOWN_COMMANDS = new Set([
801
- 'tree', 'query', 'inspect', 'inspectAll', 'styles', 'find',
948
+ 'tree', 'query', 'inspect', 'inspectAll', 'styles', 'find', 'form',
802
949
  'click', 'type', 'key', 'drag', 'scroll', 'call',
803
950
  'navigate', 'refresh', 'location',
804
951
  'events', 'events-watch', 'events-unwatch', 'console',
@@ -812,7 +959,7 @@ export const KNOWN_COMMANDS = new Set([
812
959
  'recording', 'recording-start', 'recording-stop', 'recording-generate', 'recordings',
813
960
  'test-run', 'test-validate', 'test-suite',
814
961
  'send', 'send-message', 'send-selection', 'send-recording',
815
- 'status', 'version', 'docs', 'api', 'stats'
962
+ 'status', 'version', 'docs', 'api', 'stats', 'where'
816
963
  ])
817
964
 
818
965
  /** Common typos/aliases mapped to correct commands */
package/bin/hints.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "events": "events-watch first | see: recording, console, mutations-watch",
13
13
  "eval": "\"code\" (returns result) | see: console, snapshot",
14
14
  "call": "@ref or \"selector\" <method>, --args [...] | see: eval, inspect",
15
- "screenshot": "[selector], --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
15
+ "screenshot": "[selector], --format webp, --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
16
16
  "windows": "--json | see: tabs-open, tabs-close, tabs-focus, status",
17
17
  "tabs-open": "[url] | see: tabs-focus, tabs-close, windows",
18
18
  "tabs-close": "<window-id> | see: windows, tabs-focus, tabs-open",