haltija 1.5.0 → 1.5.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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.2
4
+
5
+ Two follow-ups from the 1.5.0 review, both about the multi-tab experience on a shared server.
6
+
7
+ ### Fixed
8
+
9
+ - **`hj tabs focus <id>` no longer times out** ([#4](https://github.com/tonioloewald/haltija/issues/4)).
10
+ It was dispatching a `focus` command to the browser, routed to the *focused* tab rather than the
11
+ target, so nobody answered — and even routed correctly, a backgrounded tab can't raise itself.
12
+ Focus is now a **server-side** routing change: it validates the tab and points untargeted commands
13
+ at it, returning instantly (unknown tab → a clean error, never a timeout). It does not physically
14
+ raise the tab; to pin a single command use `--window <id>`. "Focus follows the visible tab" still
15
+ applies when you physically switch tabs — that's genuine intent that should win over a stale pin.
16
+
17
+ ### Changed
18
+
19
+ - **The hidden-tab / focus-ambiguity warnings are de-duplicated within a short (15s) cooldown**, so
20
+ a burst of commands from one agent doesn't repeat the same block every time. A *changed* condition
21
+ (different tab, newly-hidden tab, a new origin on the server) always re-warns; the cooldown
22
+ re-arms rather than suppressing forever. Set `HALTIJA_NO_TAB_WARN=1` to silence them entirely.
23
+
24
+ ## 1.5.1
25
+
26
+ Low-risk follow-ups from the 1.5.0 pre-release review — the two new "instrument must not lie"
27
+ warnings now reach more of the surfaces where they matter.
28
+
29
+ ### Fixed
30
+
31
+ - **The hidden-tab / focus-ambiguity warning is no longer dropped on the paths where it's most
32
+ useful.** It's now attached on the **timeout** path — a hidden tab whose rAF-driven `eval` never
33
+ resolves now returns a `Timeout` that *explains* it may be asleep, instead of a bare timeout —
34
+ and preserved by the `hj find` / `hj form` handlers, which previously reshaped the response and
35
+ lost it. (`hj screenshot` already carried it; `hj call` intentionally still returns the raw value
36
+ with no envelope.)
37
+
38
+ ### Internal
39
+
40
+ - Extracted the `hj --window <id>` argument handling into a pure, unit-tested helper
41
+ (`bin/arg-utils.mjs`), covering both the leading and trailing positions — the leading form was
42
+ the escape hatch that broke in 1.4.0, and it now has a regression test.
43
+ - A private-app startup that fails to learn its ephemeral port no longer leaves its temp port-files
44
+ behind.
45
+
3
46
  ## 1.5.0
4
47
 
5
48
  Completes the **private-automation** feature (`--private`) begun in 1.4.1 — now for the Electron
@@ -1350,7 +1350,12 @@ async function startEmbeddedServer() {
1350
1350
  }
1351
1351
  const pubPort = await readPort(pubFile)
1352
1352
  const intPort = await readPort(intFile)
1353
- if (!pubPort) { console.error('[Haltija Desktop] Private public server did not report its port'); return false }
1353
+ if (!pubPort) {
1354
+ // Don't leak the pid-scoped tmp port-files on the failure path (the success path cleans below).
1355
+ try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
1356
+ console.error('[Haltija Desktop] Private public server did not report its port')
1357
+ return false
1358
+ }
1354
1359
 
1355
1360
  HALTIJA_PORT = pubPort
1356
1361
  HALTIJA_SERVER = `http://localhost:${pubPort}`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.5.0",
3
+ "version": "1.5.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.5.0";
49
+ var VERSION = "1.5.2";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pure arg helpers for the `hj` CLI, extracted so they can be unit-tested (see
3
+ * src/hj-args.test.ts) — the leading-`--window` form was fully broken and shipped without a test.
4
+ */
5
+
6
+ /**
7
+ * Pull a `--window <id>` flag out of `args` from ANY position, returning the id and the remaining
8
+ * args (input is not mutated). Mirrors how `--port`/`--name`/`--token` are pre-parsed, so both
9
+ * `hj --window <id> <cmd>` (leading) and `hj <cmd> --window <id>` (trailing) resolve the same tab.
10
+ * A `--window` with no following value is left in place (treated as not-a-target) so it surfaces as
11
+ * a normal unknown-flag rather than silently swallowing the next real arg.
12
+ */
13
+ export function extractWindowTarget(args) {
14
+ const i = args.indexOf('--window')
15
+ if (i === -1 || args[i + 1] === undefined) {
16
+ return { windowTarget: null, args: [...args] }
17
+ }
18
+ const rest = [...args]
19
+ rest.splice(i, 2)
20
+ return { windowTarget: args[i + 1], args: rest }
21
+ }
package/bin/hj.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
14
+ import { extractWindowTarget } from './arg-utils.mjs'
14
15
  import { HJ_VERSION } from './version.mjs'
15
16
  import { differsBeyondPatch } from './semver.mjs'
16
17
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
@@ -313,12 +314,11 @@ if (noLaunchIdx !== -1) {
313
314
  // died with "Unknown command: '--window'" — i.e. the escape hatch we tell people to use for a
314
315
  // hidden/wrong tab didn't work in the shape the docs gave. Pulled out here and re-appended to
315
316
  // the subcommand args below, so BOTH positions work.
316
- let windowTarget = null
317
- const windowIdx = args.indexOf('--window')
318
- if (windowIdx !== -1 && args[windowIdx + 1]) {
319
- windowTarget = args[windowIdx + 1]
320
- args.splice(windowIdx, 2)
321
- }
317
+ const { windowTarget, args: argsWithoutWindow } = extractWindowTarget(args)
318
+ // Preserve the `const args` reference (downstream code mutates it in place) while dropping the
319
+ // consumed --window <id> pair.
320
+ args.length = 0
321
+ args.push(...argsWithoutWindow)
322
322
 
323
323
  // Did the shell explicitly target a private instance (--port / --name /
324
324
  // HALTIJA_PORT / HALTIJA_NAME / DEV_CHANNEL_PORT)? If so, this is a
@@ -88,6 +88,7 @@ Environment Variables:
88
88
  HALTIJA_NO_RETIRE Set to 1: do not stop pre-1.4.0 haltija servers
89
89
  HALTIJA_NO_INSTALL Set to 1: do not install hj into ~/.local/bin
90
90
  HALTIJA_NO_SKEW_WARN Set to 1: silence hj's version-skew warning
91
+ HALTIJA_NO_TAB_WARN Set to 1: silence hidden-tab / focus-ambiguity result warnings
91
92
  HALTIJA_REGISTRY_DIR Instance registry location (default: ~/.haltija/servers)
92
93
  DEV_CHANNEL_PORT Legacy alias for HALTIJA_PORT
93
94
  DEV_CHANNEL_HTTPS_PORT HTTPS port (default: 8701)
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.5.0'
6
+ export const HJ_VERSION = '1.5.2'
@@ -16,6 +16,8 @@ export interface DevResponse {
16
16
  data?: any;
17
17
  error?: string;
18
18
  timestamp: number;
19
+ /** Hidden-tab / focus-ambiguity caveat attached by the server (see requestFromBrowser). */
20
+ warning?: string;
19
21
  }
20
22
  /** Function to send request to browser widget */
21
23
  export type RequestFromBrowserFn = (channel: string, action: string, payload: any, timeoutMs?: number, windowId?: string) => Promise<DevResponse>;
@@ -50,6 +52,18 @@ export interface HandlerContext {
50
52
  headers: Record<string, string>;
51
53
  url: URL;
52
54
  getWindowInfo: (windowId?: string) => WindowInfo | undefined;
55
+ /**
56
+ * Set the server-side focused window — the tab that receives untargeted commands. This is pure
57
+ * server state, NOT a browser roundtrip: it validates the tab exists and returns immediately, so
58
+ * it can never time out the way dispatching a command *to* the (possibly hidden) tab does (#4).
59
+ */
60
+ focusWindow: (windowId: string) => {
61
+ ok: boolean;
62
+ error?: string;
63
+ active?: boolean;
64
+ windowType?: string;
65
+ title?: string;
66
+ };
53
67
  startRecordingSession: (windowId: string, url: string, name?: string) => void;
54
68
  stopRecordingSession: (windowId: string) => RecordingSessionInfo | undefined;
55
69
  getRecordingSession: (windowId: string) => RecordingSessionInfo | 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.5.0";
23
+ export declare const VERSION = "1.5.2";
24
24
  export declare class DevChannel extends HTMLElement {
25
25
  static get tagName(): string;
26
26
  static elementCreator(): () => DevChannel;
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "1.5.0";
2
+ var VERSION = "1.5.2";
3
3
 
4
4
  // src/text-selector.ts
5
5
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
package/dist/component.js CHANGED
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.5.0";
49
+ var VERSION = "1.5.2";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
package/dist/hj.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // haltija-cli:do-not-edit v1.5.0
2
+ // haltija-cli:do-not-edit v1.5.2
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
@@ -756,7 +756,7 @@ function substituteGeneratedVars(text, seed) {
756
756
  }
757
757
 
758
758
  // bin/version.mjs
759
- var HJ_VERSION = "1.5.0";
759
+ var HJ_VERSION = "1.5.2";
760
760
 
761
761
  // bin/semver.mjs
762
762
  function parseVersion(v) {
@@ -1906,6 +1906,17 @@ function dim2(s) {
1906
1906
  return `\x1B[2m${s}\x1B[0m`;
1907
1907
  }
1908
1908
 
1909
+ // bin/arg-utils.mjs
1910
+ function extractWindowTarget(args) {
1911
+ const i = args.indexOf("--window");
1912
+ if (i === -1 || args[i + 1] === undefined) {
1913
+ return { windowTarget: null, args: [...args] };
1914
+ }
1915
+ const rest = [...args];
1916
+ rest.splice(i, 2);
1917
+ return { windowTarget: args[i + 1], args: rest };
1918
+ }
1919
+
1909
1920
  // bin/hj.mjs
1910
1921
  import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "node:fs";
1911
1922
  import { homedir as homedir2 } from "node:os";
@@ -2137,12 +2148,9 @@ if (noLaunchIdx !== -1) {
2137
2148
  noLaunch = true;
2138
2149
  args.splice(noLaunchIdx, 1);
2139
2150
  }
2140
- var windowTarget = null;
2141
- var windowIdx = args.indexOf("--window");
2142
- if (windowIdx !== -1 && args[windowIdx + 1]) {
2143
- windowTarget = args[windowIdx + 1];
2144
- args.splice(windowIdx, 2);
2145
- }
2151
+ var { windowTarget, args: argsWithoutWindow } = extractWindowTarget(args);
2152
+ args.length = 0;
2153
+ args.push(...argsWithoutWindow);
2146
2154
  var explicitTarget = portSource !== "8700 (default)";
2147
2155
  if (args.length >= 2 && isSubcommand(`${args[0]}-${args[1]}`)) {
2148
2156
  args.splice(0, 2, `${args[0]}-${args[1]}`);
package/dist/index.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.5.0";
677
+ var VERSION = "1.5.2";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2224,11 +2224,15 @@ Get window IDs from /windows endpoint.
2224
2224
 
2225
2225
  ### \`POST /tabs/focus\`
2226
2226
 
2227
- **Focus a tab**
2227
+ **Focus a tab (route untargeted commands to it)**
2228
2228
 
2229
- Desktop app only. Brings the specified tab to front.
2230
-
2231
- Useful when working with multiple tabs to ensure the right one is visible.
2229
+ Make the given tab the target of untargeted commands. This is a server-side
2230
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
2231
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
2232
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
2233
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
2234
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
2235
+ can be stale). Returns \`{ success, focused, active, title }\`.
2232
2236
 
2233
2237
  **Parameters:**
2234
2238
 
@@ -2238,7 +2242,7 @@ Useful when working with multiple tabs to ensure the right one is visible.
2238
2242
 
2239
2243
  **Examples:**
2240
2244
 
2241
- - **focus**: Bring tab to front
2245
+ - **focus**: Route untargeted commands to this tab
2242
2246
  \`\`\`json
2243
2247
  {"window":"window-abc123"}
2244
2248
  \`\`\`
@@ -2802,7 +2806,7 @@ hj --help # All commands
2802
2806
  - \`hj windows\` - List connected windows
2803
2807
  - \`hj tabs-open [url]\` - Open a new tab
2804
2808
  - \`hj tabs-close [window]\` - Close a tab
2805
- - \`hj tabs-focus [window]\` - Focus a tab
2809
+ - \`hj tabs-focus [window]\` - Focus a tab (route untargeted commands to it)
2806
2810
 
2807
2811
  ### Record & Replay
2808
2812
 
@@ -3097,7 +3101,7 @@ var COMPONENT_JS = `(() => {
3097
3101
  });
3098
3102
 
3099
3103
  // src/version.ts
3100
- var VERSION = "1.5.0";
3104
+ var VERSION = "1.5.2";
3101
3105
 
3102
3106
  // src/text-selector.ts
3103
3107
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -12857,10 +12861,14 @@ Get window IDs from /windows endpoint.`,
12857
12861
  var tabsFocus = endpoint({
12858
12862
  path: "/tabs/focus",
12859
12863
  method: "POST",
12860
- summary: "Focus a tab",
12861
- description: `Desktop app only. Brings the specified tab to front.
12862
-
12863
- Useful when working with multiple tabs to ensure the right one is visible.`,
12864
+ summary: "Focus a tab (route untargeted commands to it)",
12865
+ description: `Make the given tab the target of untargeted commands. This is a server-side
12866
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
12867
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
12868
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
12869
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
12870
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
12871
+ can be stale). Returns \`{ success, focused, active, title }\`.`,
12864
12872
  category: "windows",
12865
12873
  input: L.object({
12866
12874
  window: L.string.describe("Window ID to focus")
@@ -12869,7 +12877,7 @@ Useful when working with multiple tabs to ensure the right one is visible.`,
12869
12877
  {
12870
12878
  name: "focus",
12871
12879
  input: { window: "window-abc123" },
12872
- description: "Bring tab to front"
12880
+ description: "Route untargeted commands to this tab"
12873
12881
  }
12874
12882
  ],
12875
12883
  hints: "<window-id> | see: windows, tabs-close, tabs-open"
@@ -13727,6 +13735,9 @@ function inferSuggestion(step, pageContext) {
13727
13735
  }
13728
13736
 
13729
13737
  // src/api-handlers.ts
13738
+ function withWarning(body, response) {
13739
+ return response.warning ? { ...body, warning: response.warning } : body;
13740
+ }
13730
13741
  var handlers = new Map;
13731
13742
  function registerHandler(endpoint2, handler) {
13732
13743
  handlers.set(endpoint2.path, handler);
@@ -14249,10 +14260,19 @@ registerHandler(tabsClose, async (body, ctx) => {
14249
14260
  registerHandler(tabsFocus, async (body, ctx) => {
14250
14261
  const windowId = body.window || ctx.targetWindowId;
14251
14262
  if (!windowId) {
14252
- return Response.json({ success: false, error: "window id is required" }, { status: 400, headers: ctx.headers });
14263
+ return Response.json({ success: false, error: "window id is required (run `hj tabs` to list connected tabs)" }, { status: 400, headers: ctx.headers });
14253
14264
  }
14254
- const response = await ctx.requestFromBrowser("tabs", "focus", { windowId });
14255
- return Response.json(response, { headers: ctx.headers });
14265
+ const r = ctx.focusWindow(windowId);
14266
+ if (!r.ok) {
14267
+ return Response.json({ success: false, error: r.error }, { status: 404, headers: ctx.headers });
14268
+ }
14269
+ const result = { success: true, focused: windowId, active: r.active !== false };
14270
+ if (r.title)
14271
+ result.title = r.title;
14272
+ if (r.active === false) {
14273
+ result.warning = "Untargeted commands now route to this tab, but it reports HIDDEN \u2014 browsers freeze " + "requestAnimationFrame and throttle timers in a backgrounded tab, so results can be stale " + '("not mounted yet", not "broken"). Bring it to the front in your browser to wake it.';
14274
+ }
14275
+ return Response.json(result, { headers: ctx.headers });
14256
14276
  });
14257
14277
  registerHandler(mutationsWatch, async (body, ctx) => {
14258
14278
  const response = await ctx.requestFromBrowser("mutations", "watch", {
@@ -14557,7 +14577,7 @@ registerHandler(find, async (body, ctx) => {
14557
14577
  })()`;
14558
14578
  const response = await ctx.requestFromBrowser("eval", "exec", { code: findCode }, 5000, windowId);
14559
14579
  if (response.success && response.data) {
14560
- return Response.json({ success: true, ...response.data }, { headers: ctx.headers });
14580
+ return Response.json(withWarning({ success: true, ...response.data }, response), { headers: ctx.headers });
14561
14581
  }
14562
14582
  return Response.json(response, { headers: ctx.headers });
14563
14583
  });
@@ -14663,7 +14683,7 @@ registerHandler(formData, async (body, ctx) => {
14663
14683
  })()`;
14664
14684
  const response = await ctx.requestFromBrowser("eval", "exec", { code: formCode }, 5000, windowId);
14665
14685
  if (response.success && response.data) {
14666
- return Response.json(response.data, { headers: ctx.headers });
14686
+ return Response.json(withWarning(response.data, response), { headers: ctx.headers });
14667
14687
  }
14668
14688
  return Response.json(response, { headers: ctx.headers });
14669
14689
  });
@@ -15272,6 +15292,22 @@ function ambiguousFocusWarning(opts) {
15272
15292
  ${pins}${more}`;
15273
15293
  }
15274
15294
 
15295
+ // src/warning-dedupe.ts
15296
+ function shouldEmitWarning(warning, cache, now, cooldownMs) {
15297
+ const last = cache.get(warning);
15298
+ if (last !== undefined && now - last < cooldownMs) {
15299
+ return false;
15300
+ }
15301
+ cache.set(warning, now);
15302
+ if (cache.size > 64) {
15303
+ for (const [key2, ts] of cache) {
15304
+ if (now - ts >= cooldownMs)
15305
+ cache.delete(key2);
15306
+ }
15307
+ }
15308
+ return true;
15309
+ }
15310
+
15275
15311
  // src/server.ts
15276
15312
  init_terminal();
15277
15313
 
@@ -16126,6 +16162,9 @@ function getComponentJs() {
16126
16162
  }
16127
16163
  var browsers = new Map;
16128
16164
  var windows2 = new Map;
16165
+ var recentTabWarnings = new Map;
16166
+ var TAB_WARN_COOLDOWN_MS = 15000;
16167
+ var TAB_WARN_DISABLED = process.env.HALTIJA_NO_TAB_WARN === "1";
16129
16168
  var agents = new Set;
16130
16169
  var focusedWindowId = null;
16131
16170
  var isDesktopApp = process.env.HALTIJA_DESKTOP === "1";
@@ -16438,12 +16477,10 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16438
16477
  source: "agent"
16439
16478
  };
16440
16479
  return new Promise((resolve) => {
16441
- const timeout = setTimeout(() => {
16442
- pendingResponses.delete(id);
16443
- resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
16444
- }, timeoutMs);
16445
16480
  let sentTo = null;
16446
- const resolveWithLiveness = (res) => {
16481
+ const attachWarning = (res) => {
16482
+ if (TAB_WARN_DISABLED)
16483
+ return res;
16447
16484
  const hidden = hiddenTabWarning(sentTo);
16448
16485
  const ambiguous = ambiguousFocusWarning({
16449
16486
  windows: Array.from(windows2.values()),
@@ -16453,8 +16490,18 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16453
16490
  const warning = [hidden, ambiguous].filter(Boolean).join(`
16454
16491
 
16455
16492
  `);
16456
- resolve(warning ? { ...res, warning } : res);
16493
+ if (!warning)
16494
+ return res;
16495
+ if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16496
+ return res;
16497
+ }
16498
+ return { ...res, warning };
16457
16499
  };
16500
+ const timeout = setTimeout(() => {
16501
+ pendingResponses.delete(id);
16502
+ resolve(attachWarning({ id, success: false, error: "Timeout", timestamp: Date.now() }));
16503
+ }, timeoutMs);
16504
+ const resolveWithLiveness = (res) => resolve(attachWarning(res));
16458
16505
  pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
16459
16506
  if (windowId) {
16460
16507
  const win = windows2.get(windowId);
@@ -16619,6 +16666,15 @@ var createHandlerContext = (req, url) => {
16619
16666
  headers,
16620
16667
  url,
16621
16668
  getWindowInfo,
16669
+ focusWindow: (windowId) => {
16670
+ const win = windows2.get(windowId);
16671
+ if (!win) {
16672
+ return { ok: false, error: `Tab ${windowId} not found (run \`hj tabs\` to list connected tabs)` };
16673
+ }
16674
+ focusedWindowId = windowId;
16675
+ updateHjStatus();
16676
+ return { ok: true, active: win.active, windowType: win.windowType, title: win.title };
16677
+ },
16622
16678
  startRecordingSession,
16623
16679
  stopRecordingSession,
16624
16680
  getRecordingSession,
@@ -18296,12 +18352,10 @@ Type 'help <topic>' for details.`);
18296
18352
  break;
18297
18353
  }
18298
18354
  case "tabs-focus": {
18299
- const resp = await requestFromBrowser("tabs", "focus", { windowId: step.window }, stepTimeout);
18300
- if (!resp.success) {
18355
+ if (!step.window || !windows2.has(step.window)) {
18301
18356
  stepPassed = false;
18302
- error = resp.error || "Failed to focus tab";
18303
- }
18304
- if (step.window && windows2.has(step.window)) {
18357
+ error = `Tab ${step.window ?? "(none)"} not found`;
18358
+ } else {
18305
18359
  focusedWindowId = step.window;
18306
18360
  }
18307
18361
  break;
package/dist/server.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.5.0";
677
+ var VERSION = "1.5.2";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2224,11 +2224,15 @@ Get window IDs from /windows endpoint.
2224
2224
 
2225
2225
  ### \`POST /tabs/focus\`
2226
2226
 
2227
- **Focus a tab**
2227
+ **Focus a tab (route untargeted commands to it)**
2228
2228
 
2229
- Desktop app only. Brings the specified tab to front.
2230
-
2231
- Useful when working with multiple tabs to ensure the right one is visible.
2229
+ Make the given tab the target of untargeted commands. This is a server-side
2230
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
2231
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
2232
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
2233
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
2234
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
2235
+ can be stale). Returns \`{ success, focused, active, title }\`.
2232
2236
 
2233
2237
  **Parameters:**
2234
2238
 
@@ -2238,7 +2242,7 @@ Useful when working with multiple tabs to ensure the right one is visible.
2238
2242
 
2239
2243
  **Examples:**
2240
2244
 
2241
- - **focus**: Bring tab to front
2245
+ - **focus**: Route untargeted commands to this tab
2242
2246
  \`\`\`json
2243
2247
  {"window":"window-abc123"}
2244
2248
  \`\`\`
@@ -2802,7 +2806,7 @@ hj --help # All commands
2802
2806
  - \`hj windows\` - List connected windows
2803
2807
  - \`hj tabs-open [url]\` - Open a new tab
2804
2808
  - \`hj tabs-close [window]\` - Close a tab
2805
- - \`hj tabs-focus [window]\` - Focus a tab
2809
+ - \`hj tabs-focus [window]\` - Focus a tab (route untargeted commands to it)
2806
2810
 
2807
2811
  ### Record & Replay
2808
2812
 
@@ -3097,7 +3101,7 @@ var COMPONENT_JS = `(() => {
3097
3101
  });
3098
3102
 
3099
3103
  // src/version.ts
3100
- var VERSION = "1.5.0";
3104
+ var VERSION = "1.5.2";
3101
3105
 
3102
3106
  // src/text-selector.ts
3103
3107
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -12857,10 +12861,14 @@ Get window IDs from /windows endpoint.`,
12857
12861
  var tabsFocus = endpoint({
12858
12862
  path: "/tabs/focus",
12859
12863
  method: "POST",
12860
- summary: "Focus a tab",
12861
- description: `Desktop app only. Brings the specified tab to front.
12862
-
12863
- Useful when working with multiple tabs to ensure the right one is visible.`,
12864
+ summary: "Focus a tab (route untargeted commands to it)",
12865
+ description: `Make the given tab the target of untargeted commands. This is a server-side
12866
+ routing change, NOT a browser action: it does not physically raise the tab (a backgrounded browser
12867
+ tab cannot be raised remotely), and because it never dispatches to the tab it can't time out \u2014 even
12868
+ if the tab is hidden. After this, commands without a \`window\`/\`--window\` go to this tab until you
12869
+ focus another or physically switch tabs in the browser. To pin a single command instead, use
12870
+ \`--window <id>\`. If the tab is hidden the response includes a warning (a backgrounded tab's results
12871
+ can be stale). Returns \`{ success, focused, active, title }\`.`,
12864
12872
  category: "windows",
12865
12873
  input: L.object({
12866
12874
  window: L.string.describe("Window ID to focus")
@@ -12869,7 +12877,7 @@ Useful when working with multiple tabs to ensure the right one is visible.`,
12869
12877
  {
12870
12878
  name: "focus",
12871
12879
  input: { window: "window-abc123" },
12872
- description: "Bring tab to front"
12880
+ description: "Route untargeted commands to this tab"
12873
12881
  }
12874
12882
  ],
12875
12883
  hints: "<window-id> | see: windows, tabs-close, tabs-open"
@@ -13727,6 +13735,9 @@ function inferSuggestion(step, pageContext) {
13727
13735
  }
13728
13736
 
13729
13737
  // src/api-handlers.ts
13738
+ function withWarning(body, response) {
13739
+ return response.warning ? { ...body, warning: response.warning } : body;
13740
+ }
13730
13741
  var handlers = new Map;
13731
13742
  function registerHandler(endpoint2, handler) {
13732
13743
  handlers.set(endpoint2.path, handler);
@@ -14249,10 +14260,19 @@ registerHandler(tabsClose, async (body, ctx) => {
14249
14260
  registerHandler(tabsFocus, async (body, ctx) => {
14250
14261
  const windowId = body.window || ctx.targetWindowId;
14251
14262
  if (!windowId) {
14252
- return Response.json({ success: false, error: "window id is required" }, { status: 400, headers: ctx.headers });
14263
+ return Response.json({ success: false, error: "window id is required (run `hj tabs` to list connected tabs)" }, { status: 400, headers: ctx.headers });
14253
14264
  }
14254
- const response = await ctx.requestFromBrowser("tabs", "focus", { windowId });
14255
- return Response.json(response, { headers: ctx.headers });
14265
+ const r = ctx.focusWindow(windowId);
14266
+ if (!r.ok) {
14267
+ return Response.json({ success: false, error: r.error }, { status: 404, headers: ctx.headers });
14268
+ }
14269
+ const result = { success: true, focused: windowId, active: r.active !== false };
14270
+ if (r.title)
14271
+ result.title = r.title;
14272
+ if (r.active === false) {
14273
+ result.warning = "Untargeted commands now route to this tab, but it reports HIDDEN \u2014 browsers freeze " + "requestAnimationFrame and throttle timers in a backgrounded tab, so results can be stale " + '("not mounted yet", not "broken"). Bring it to the front in your browser to wake it.';
14274
+ }
14275
+ return Response.json(result, { headers: ctx.headers });
14256
14276
  });
14257
14277
  registerHandler(mutationsWatch, async (body, ctx) => {
14258
14278
  const response = await ctx.requestFromBrowser("mutations", "watch", {
@@ -14557,7 +14577,7 @@ registerHandler(find, async (body, ctx) => {
14557
14577
  })()`;
14558
14578
  const response = await ctx.requestFromBrowser("eval", "exec", { code: findCode }, 5000, windowId);
14559
14579
  if (response.success && response.data) {
14560
- return Response.json({ success: true, ...response.data }, { headers: ctx.headers });
14580
+ return Response.json(withWarning({ success: true, ...response.data }, response), { headers: ctx.headers });
14561
14581
  }
14562
14582
  return Response.json(response, { headers: ctx.headers });
14563
14583
  });
@@ -14663,7 +14683,7 @@ registerHandler(formData, async (body, ctx) => {
14663
14683
  })()`;
14664
14684
  const response = await ctx.requestFromBrowser("eval", "exec", { code: formCode }, 5000, windowId);
14665
14685
  if (response.success && response.data) {
14666
- return Response.json(response.data, { headers: ctx.headers });
14686
+ return Response.json(withWarning(response.data, response), { headers: ctx.headers });
14667
14687
  }
14668
14688
  return Response.json(response, { headers: ctx.headers });
14669
14689
  });
@@ -15272,6 +15292,22 @@ function ambiguousFocusWarning(opts) {
15272
15292
  ${pins}${more}`;
15273
15293
  }
15274
15294
 
15295
+ // src/warning-dedupe.ts
15296
+ function shouldEmitWarning(warning, cache, now, cooldownMs) {
15297
+ const last = cache.get(warning);
15298
+ if (last !== undefined && now - last < cooldownMs) {
15299
+ return false;
15300
+ }
15301
+ cache.set(warning, now);
15302
+ if (cache.size > 64) {
15303
+ for (const [key2, ts] of cache) {
15304
+ if (now - ts >= cooldownMs)
15305
+ cache.delete(key2);
15306
+ }
15307
+ }
15308
+ return true;
15309
+ }
15310
+
15275
15311
  // src/server.ts
15276
15312
  init_terminal();
15277
15313
 
@@ -16126,6 +16162,9 @@ function getComponentJs() {
16126
16162
  }
16127
16163
  var browsers = new Map;
16128
16164
  var windows2 = new Map;
16165
+ var recentTabWarnings = new Map;
16166
+ var TAB_WARN_COOLDOWN_MS = 15000;
16167
+ var TAB_WARN_DISABLED = process.env.HALTIJA_NO_TAB_WARN === "1";
16129
16168
  var agents = new Set;
16130
16169
  var focusedWindowId = null;
16131
16170
  var isDesktopApp = process.env.HALTIJA_DESKTOP === "1";
@@ -16438,12 +16477,10 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16438
16477
  source: "agent"
16439
16478
  };
16440
16479
  return new Promise((resolve) => {
16441
- const timeout = setTimeout(() => {
16442
- pendingResponses.delete(id);
16443
- resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
16444
- }, timeoutMs);
16445
16480
  let sentTo = null;
16446
- const resolveWithLiveness = (res) => {
16481
+ const attachWarning = (res) => {
16482
+ if (TAB_WARN_DISABLED)
16483
+ return res;
16447
16484
  const hidden = hiddenTabWarning(sentTo);
16448
16485
  const ambiguous = ambiguousFocusWarning({
16449
16486
  windows: Array.from(windows2.values()),
@@ -16453,8 +16490,18 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
16453
16490
  const warning = [hidden, ambiguous].filter(Boolean).join(`
16454
16491
 
16455
16492
  `);
16456
- resolve(warning ? { ...res, warning } : res);
16493
+ if (!warning)
16494
+ return res;
16495
+ if (!shouldEmitWarning(warning, recentTabWarnings, Date.now(), TAB_WARN_COOLDOWN_MS)) {
16496
+ return res;
16497
+ }
16498
+ return { ...res, warning };
16457
16499
  };
16500
+ const timeout = setTimeout(() => {
16501
+ pendingResponses.delete(id);
16502
+ resolve(attachWarning({ id, success: false, error: "Timeout", timestamp: Date.now() }));
16503
+ }, timeoutMs);
16504
+ const resolveWithLiveness = (res) => resolve(attachWarning(res));
16458
16505
  pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
16459
16506
  if (windowId) {
16460
16507
  const win = windows2.get(windowId);
@@ -16619,6 +16666,15 @@ var createHandlerContext = (req, url) => {
16619
16666
  headers,
16620
16667
  url,
16621
16668
  getWindowInfo,
16669
+ focusWindow: (windowId) => {
16670
+ const win = windows2.get(windowId);
16671
+ if (!win) {
16672
+ return { ok: false, error: `Tab ${windowId} not found (run \`hj tabs\` to list connected tabs)` };
16673
+ }
16674
+ focusedWindowId = windowId;
16675
+ updateHjStatus();
16676
+ return { ok: true, active: win.active, windowType: win.windowType, title: win.title };
16677
+ },
16622
16678
  startRecordingSession,
16623
16679
  stopRecordingSession,
16624
16680
  getRecordingSession,
@@ -18296,12 +18352,10 @@ Type 'help <topic>' for details.`);
18296
18352
  break;
18297
18353
  }
18298
18354
  case "tabs-focus": {
18299
- const resp = await requestFromBrowser("tabs", "focus", { windowId: step.window }, stepTimeout);
18300
- if (!resp.success) {
18355
+ if (!step.window || !windows2.has(step.window)) {
18301
18356
  stepPassed = false;
18302
- error = resp.error || "Failed to focus tab";
18303
- }
18304
- if (step.window && windows2.has(step.window)) {
18357
+ error = `Tab ${step.window ?? "(none)"} not found`;
18358
+ } else {
18305
18359
  focusedWindowId = step.window;
18306
18360
  }
18307
18361
  break;
package/dist/version.d.ts CHANGED
@@ -8,4 +8,4 @@
8
8
  * ⚠️ AUTO-GENERATED FROM package.json - DO NOT EDIT THIS FILE
9
9
  * ⚠️ To change the version, update package.json and run: bun run build
10
10
  */
11
- export declare const VERSION = "1.5.0";
11
+ export declare const VERSION = "1.5.2";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * De-duplicate the tab warnings (hidden-tab #3, focus-ambiguity #2) so a burst of untargeted
3
+ * commands from one agent doesn't repeat the same multi-line block on every single one. That
4
+ * repetition is the alarm-fatigue the repo's own skew-warning rationale warns about: a caveat that
5
+ * fires on every command trains agents to ignore it, including the time it matters.
6
+ *
7
+ * **Why a short cooldown and not "once, forever":** the server has many clients and can't tell them
8
+ * apart, so a permanent global suppression would hide the warning from a *second* agent that never
9
+ * saw it — the exact "plausible-but-wrong answer with no caveat" failure these warnings exist to
10
+ * prevent. A short cooldown collapses the one-agent burst (the real fatigue source) while keeping
11
+ * the window in which another client could miss it tiny; the condition persists, so their next
12
+ * command past the cooldown re-warns.
13
+ *
14
+ * **Why key on the whole warning string:** the text already encodes the full condition — which tab
15
+ * answered, whether it's hidden, the exact set of other origins. So a *changed* situation (a
16
+ * different tab, a newly-hidden tab, a new origin on the server) produces different text and is
17
+ * never suppressed. Identical text means identical situation.
18
+ */
19
+ export declare function shouldEmitWarning(warning: string, cache: Map<string, number>, now: number, cooldownMs: number): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Browser control for AI agents - query DOM, click, type, run JS, watch mutations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",