haltija 1.12.5 → 1.12.6

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,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.6
4
+
5
+ Security updates, three silent-failure fixes in the path from "server tells page how to reach me",
6
+ a new way to watch — and talk to — the agent driving your browser, and both halves of the
7
+ shared-browser trade: instances that never died, and tabs that answer while asleep.
8
+
9
+ ### Security
10
+
11
+ - **Electron 40.6.1 → 43.4.1.** 40.6.1 sat inside the range for **two context-isolation bypasses**,
12
+ and context isolation is haltija's security boundary — the desktop app strips CSP and injects into
13
+ arbitrary pages. One bump clears eight advisories.
14
+ - **`apps/mcp` re-locked** off a cross-client data leak in `@modelcontextprotocol/sdk` 1.25.2
15
+ (→ 1.30.0), plus `hono`, `@hono/node-server` and `fast-uri`.
16
+ - **`electron-builder` is no longer a dependency.** It was pulling **271 transitive packages** into
17
+ `apps/desktop` — 285 before, 14 after — and supplied five advisories including a critical `tar`
18
+ one, all for DMG packaging nothing exercises. The `build:*` scripts invoke it on demand instead.
19
+
20
+ ### Three ways the page could not reach the server, all silent
21
+
22
+ Each of these ended with no widget, `hj where` reporting `0 tabs`, and the reasonable conclusion
23
+ that your own setup was wrong.
24
+
25
+ - **Access over LAN or Bonjour never worked.** `/inject.js` and `/dev.js` handed the browser URLs
26
+ built as `localhost:<port>` — and `localhost` in a served script means the *browser's* machine. A
27
+ page opened from another device was told to connect to itself. Both now derive the host from the
28
+ request, which is exactly "how the client reached us", so LAN hostnames, `.local` names, fixed IPs
29
+ and tunnels all work. The generated embed snippet had the same bug and now uses
30
+ `location.hostname`.
31
+ - **Server-side recording was broken for every `wss://` configuration**, including ordinary HTTPS
32
+ localhost. `"wss://host/…".replace("ws:", "http:")` is a no-op — the substring is `wss:` — so the
33
+ derived URL stayed `wss://`, which `fetch` cannot use. Three places derived an HTTP base from a WS
34
+ URL and two were wrong; there is one function now.
35
+ - **The widget never sent its token.** Eight `fetch` call sites, zero `X-Haltija-Token`, so on a
36
+ `--token` server every page-side feature returned 401. That made "require a token" and "the page
37
+ can talk to the server" mutually exclusive — which matters most over a tunnel, exactly where a
38
+ token matters most.
39
+
40
+ ### Two halves of the shared-browser trade (#39, #41)
41
+
42
+ Moving to shared tabs and servers stopped per-run Electron instances proliferating and stopped a
43
+ launch killing a peer's channel. It also swapped in two problems, reported together.
44
+
45
+ - **A `--private` instance now has a lifetime bound.** It exits after 8h with no client activity,
46
+ saying why on the way out — an instance that vanishes silently is indistinguishable from a crash.
47
+ The only previous bound was spawner-pid polling, which cannot help when teardown never *runs*: a
48
+ SIGKILLed session, a sleeping laptop, a crashed harness. #39 found one twelve days old at 5.7 GB
49
+ and ~150% CPU on a machine at load average 212, with the slowdown blamed on unrelated code.
50
+ Shared servers stay unbounded — their stickiness is the feature. `HALTIJA_IDLE_TIMEOUT_HOURS`
51
+ overrides in both directions; `0` disables.
52
+
53
+ Polling does **not** count as activity. The desktop app's own renderer hits `/status` every five
54
+ seconds forever, so under the obvious implementation a `--private --app` instance would have
55
+ refreshed its idle clock from its own UI and never expired — leaving exactly the configuration
56
+ #39 reported as the one case still immune.
57
+
58
+ - **A tab can report `visible` and not be painting, and now says so.** `visibilityState` means "is
59
+ this tab selected", not "is it being composited"; they diverge for occluded windows, offscreen
60
+ windows and a sleeping display. In that gap the tab answers confidently about content that never
61
+ rendered, and a screenshot shows a stale frame the compositor is holding. Every result now carries
62
+ `paintAgeMs`, and a stale one attaches a warning naming the cause. `hj doctor` reads the same
63
+ number rather than keeping its own opinion.
64
+
65
+ Not fixed: getting a foreground window cheaply, which is what #41 actually asks for. That needs a
66
+ shape decision and is tracked in `TODO.md`.
67
+
68
+ ### `hj session` — watch the agent, and talk back
69
+
70
+ ```bash
71
+ hj session attach <tmux-session> # opt in (read-only)
72
+ hj session read --follow # watch it work
73
+ hj session write "this is wrong" # needs --allow-input at attach
74
+ ```
75
+
76
+ Mirrors an agent running in tmux into the browser channel, so a **page** — including one on a VR
77
+ headset over a tunnel — can watch the agent work and say something to it. `capture-pane` returns
78
+ already-rendered text, so the page view is a `<pre>` rather than a terminal emulator, and tmux can
79
+ attach to a session that is **already running**.
80
+
81
+ Writing uses `send-keys`, so your sentence arrives on the agent's stdin as a normal turn — no queue,
82
+ no message API, no polling primitive.
83
+
84
+ **Reading and writing are separate grants**, because they are different risks: reading leaks what the
85
+ agent *prints*, writing decides what the agent *does* — including answering a permission prompt,
86
+ where this is indistinguishable from the operator typing. A mirror attached for watching refuses
87
+ writes; detaching revokes the grant. Attaching an **unauthenticated** server warns, because a mirror
88
+ turns "can reach this port" into "can read everything the agent prints".
89
+
3
90
  ## 1.12.5
4
91
 
5
92
  **Installing this delivers 1.12.3 and 1.12.4 as well** — both were tagged but never published, so
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.12.5",
3
+ "version": "1.12.6",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -18,15 +18,14 @@
18
18
  "compile:server": "cd ../.. && bun run build && bun build --compile --target=bun-darwin-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64 && bun build --compile --target=bun-darwin-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && cp dist/component.js apps/desktop/resources/component.js && cp haltija-icon.svg apps/desktop/resources/icon.svg",
19
19
  "compile:server:win": "cd ../.. && bun build --compile --target=bun-windows-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64.exe",
20
20
  "compile:server:linux": "cd ../.. && bun build --compile --target=bun-linux-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && bun build --compile --target=bun-linux-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64",
21
- "build": "electron-builder",
22
- "build:mac": "npm run sync-version && npm run icons && npm run compile:server && electron-builder --mac",
23
- "build:win": "npm run sync-version && npm run compile:server:win && electron-builder --win",
24
- "build:linux": "npm run sync-version && npm run compile:server:linux && electron-builder --linux"
21
+ "build": "npx --yes electron-builder@26",
22
+ "build:mac": "npm run sync-version && npm run icons && npm run compile:server && npx --yes electron-builder@26 --mac",
23
+ "build:win": "npm run sync-version && npm run compile:server:win && npx --yes electron-builder@26 --win",
24
+ "build:linux": "npm run sync-version && npm run compile:server:linux && npx --yes electron-builder@26 --linux"
25
25
  },
26
26
  "license": "Apache-2.0",
27
27
  "devDependencies": {
28
- "electron": "^40.6.1",
29
- "electron-builder": "^24.9.1"
28
+ "electron": "^43.4.1"
30
29
  },
31
30
  "build": {
32
31
  "appId": "com.haltija.desktop",
@@ -46,7 +46,14 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.12.5";
49
+ var VERSION = "1.12.6";
50
+
51
+ // src/ws-url.ts
52
+ function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
53
+ if (!wsUrl2)
54
+ return fallback;
55
+ return wsUrl2.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace("/ws/browser", "");
56
+ }
50
57
 
51
58
  // src/text-selector.ts
52
59
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -1358,6 +1365,10 @@
1358
1365
  ...buildDomAffordances(opts.maxNodes)
1359
1366
  };
1360
1367
  }
1368
+ function serverHeaders(extra = {}) {
1369
+ const token = window.__haltija_config__?.token;
1370
+ return token ? { ...extra, "X-Haltija-Token": String(token) } : { ...extra };
1371
+ }
1361
1372
  function isOwnWidget(el) {
1362
1373
  if (el.tagName === TAG_NAME.toUpperCase())
1363
1374
  return true;
@@ -3000,6 +3011,7 @@
3000
3011
  };
3001
3012
  document.addEventListener("visibilitychange", this.visibilityHandler);
3002
3013
  }
3014
+ this.startPaintHeartbeat();
3003
3015
  this.connect();
3004
3016
  }
3005
3017
  disconnectedCallback() {
@@ -3015,6 +3027,7 @@
3015
3027
  document.removeEventListener("visibilitychange", this.visibilityHandler);
3016
3028
  this.visibilityHandler = null;
3017
3029
  }
3030
+ this.stopPaintHeartbeat();
3018
3031
  }
3019
3032
  attributeChangedCallback(name, _old, value) {
3020
3033
  if (name === "server") {
@@ -3594,7 +3607,7 @@
3594
3607
  </div>
3595
3608
  </div>
3596
3609
  <div class="body"${this.isElectron ? ' style="display:none"' : ""}>
3597
- <a href="javascript:(function(){fetch('${this.serverUrl.replace("ws:", "http:").replace("wss:", "https:").replace("/ws/browser", "")}/inject.js').then(r=>r.text()).then(eval).catch(e=>alert('${PRODUCT_NAME}: Cannot reach server'))})();"
3610
+ <a href="javascript:(function(){fetch('${httpBaseFromWsUrl(this.serverUrl)}/inject.js').then(r=>r.text()).then(eval).catch(e=>alert('${PRODUCT_NAME}: Cannot reach server'))})();"
3598
3611
  style="color: #6366f1; text-decoration: none;"
3599
3612
  title="Drag to bookmarks bar"
3600
3613
  class="bookmark-link">\uD83E\uDDDD bookmark</a>
@@ -3902,10 +3915,10 @@
3902
3915
  async startRecordingViaServer() {
3903
3916
  try {
3904
3917
  const config = window.__haltija_config__;
3905
- const serverUrl2 = config?.serverUrl?.replace("ws:", "http:").replace("/ws/browser", "") || "http://localhost:8700";
3918
+ const serverUrl2 = httpBaseFromWsUrl(config?.serverUrl);
3906
3919
  const response = await fetch(`${serverUrl2}/recording`, {
3907
3920
  method: "POST",
3908
- headers: { "Content-Type": "application/json" },
3921
+ headers: serverHeaders({ "Content-Type": "application/json" }),
3909
3922
  body: JSON.stringify({ action: "start", window: this.windowId })
3910
3923
  });
3911
3924
  const result = await response.json();
@@ -3930,10 +3943,10 @@
3930
3943
  async stopRecordingViaServer() {
3931
3944
  try {
3932
3945
  const config = window.__haltija_config__;
3933
- const serverUrl2 = config?.serverUrl?.replace("ws:", "http:").replace("/ws/browser", "") || "http://localhost:8700";
3946
+ const serverUrl2 = httpBaseFromWsUrl(config?.serverUrl);
3934
3947
  const response = await fetch(`${serverUrl2}/recording`, {
3935
3948
  method: "POST",
3936
- headers: { "Content-Type": "application/json" },
3949
+ headers: serverHeaders({ "Content-Type": "application/json" }),
3937
3950
  body: JSON.stringify({ action: "stop", window: this.windowId })
3938
3951
  });
3939
3952
  const result = await response.json();
@@ -4245,7 +4258,7 @@
4245
4258
  const serverUrl2 = this.serverUrl.replace("ws://", "http://").replace("wss://", "https://").replace("/ws/browser", "");
4246
4259
  const response = await fetch(`${serverUrl2}/recording/${this.lastRecordingId}/send`, {
4247
4260
  method: "POST",
4248
- headers: { "Content-Type": "application/json" },
4261
+ headers: serverHeaders({ "Content-Type": "application/json" }),
4249
4262
  body: JSON.stringify({ description, agentId })
4250
4263
  });
4251
4264
  const result = await response.json();
@@ -4685,7 +4698,7 @@
4685
4698
  const serverUrl2 = this.serverUrl.replace("ws://", "http://").replace("wss://", "https://").replace("/ws/browser", "");
4686
4699
  let agents = [];
4687
4700
  try {
4688
- const response = await fetch(`${serverUrl2}/terminal/agents`);
4701
+ const response = await fetch(`${serverUrl2}/terminal/agents`, { headers: serverHeaders() });
4689
4702
  const data = await response.json();
4690
4703
  agents = data.agents || [];
4691
4704
  } catch (err) {
@@ -4855,7 +4868,7 @@ ${elementSummary}${moreText}`;
4855
4868
  try {
4856
4869
  const response = await fetch(`${serverUrl2}/selection/send`, {
4857
4870
  method: "POST",
4858
- headers: { "Content-Type": "application/json" },
4871
+ headers: serverHeaders({ "Content-Type": "application/json" }),
4859
4872
  body: JSON.stringify({
4860
4873
  agentId,
4861
4874
  message: messageText,
@@ -6244,6 +6257,29 @@ ${elementSummary}${moreText}`;
6244
6257
  });
6245
6258
  this.render();
6246
6259
  }
6260
+ lastPaintAt = Date.now();
6261
+ paintTimer = null;
6262
+ paintAgeOnArrival = new Map;
6263
+ startPaintHeartbeat() {
6264
+ if (typeof requestAnimationFrame !== "function" || typeof setTimeout !== "function")
6265
+ return;
6266
+ const tick = () => {
6267
+ requestAnimationFrame(() => {
6268
+ this.lastPaintAt = Date.now();
6269
+ });
6270
+ this.paintTimer = setTimeout(tick, 1000);
6271
+ };
6272
+ tick();
6273
+ }
6274
+ stopPaintHeartbeat() {
6275
+ if (this.paintTimer !== null) {
6276
+ clearTimeout(this.paintTimer);
6277
+ this.paintTimer = null;
6278
+ }
6279
+ }
6280
+ paintAgeMs() {
6281
+ return Date.now() - this.lastPaintAt;
6282
+ }
6247
6283
  send(channel, action2, payload2, id) {
6248
6284
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
6249
6285
  return;
@@ -6258,6 +6294,8 @@ ${elementSummary}${moreText}`;
6258
6294
  this.ws.send(JSON.stringify(msg2));
6259
6295
  }
6260
6296
  respond(requestId, success, data, error) {
6297
+ const arrivalPaintAge = this.paintAgeOnArrival.get(requestId);
6298
+ this.paintAgeOnArrival.delete(requestId);
6261
6299
  const local = this.localPending.get(requestId);
6262
6300
  if (local) {
6263
6301
  this.localPending.delete(requestId);
@@ -6273,7 +6311,8 @@ ${elementSummary}${moreText}`;
6273
6311
  success,
6274
6312
  data,
6275
6313
  error,
6276
- timestamp: Date.now()
6314
+ timestamp: Date.now(),
6315
+ paintAgeMs: arrivalPaintAge ?? this.paintAgeMs()
6277
6316
  };
6278
6317
  this.ws?.send(JSON.stringify(response));
6279
6318
  }
@@ -6292,6 +6331,14 @@ ${elementSummary}${moreText}`;
6292
6331
  });
6293
6332
  }
6294
6333
  handleMessage(msg2) {
6334
+ if (msg2.id) {
6335
+ if (this.paintAgeOnArrival.size > 200) {
6336
+ const oldest = this.paintAgeOnArrival.keys().next().value;
6337
+ if (oldest !== undefined)
6338
+ this.paintAgeOnArrival.delete(oldest);
6339
+ }
6340
+ this.paintAgeOnArrival.set(msg2.id, this.paintAgeMs());
6341
+ }
6295
6342
  if (msg2.source === "agent" || msg2.source === "server") {
6296
6343
  this.show();
6297
6344
  }
@@ -55,6 +55,10 @@ var ROUTED_COMMANDS = [
55
55
  "test-run",
56
56
  "test-validate",
57
57
  "test-suite",
58
+ "session-attach",
59
+ "session-read",
60
+ "session-write",
61
+ "session-detach",
58
62
  "send",
59
63
  "send-message",
60
64
  "send-selection",
@@ -102,6 +102,10 @@ export const COMPOUND_PATHS = {
102
102
  'test-run': '/test/run',
103
103
  'test-suite': '/test/suite',
104
104
  'test-validate': '/test/validate',
105
+ 'session-attach': '/session/attach',
106
+ 'session-read': '/session/read',
107
+ 'session-write': '/session/write',
108
+ 'session-detach': '/session/detach',
105
109
  'send-message': '/send/message',
106
110
  'send-selection': '/send/selection',
107
111
  'send-recording': '/send/recording',
@@ -192,6 +196,27 @@ export function takeFlags(args, spec) {
192
196
  }
193
197
 
194
198
  export const ARG_MAPS = {
199
+ // `hj session attach <name>` / `hj session read --lines N`. Compound form handled in hj.mjs.
200
+ 'session-attach': (args) => ({
201
+ target: args.find((a) => !a.startsWith('-')),
202
+ allowInput: args.includes('--allow-input') || undefined,
203
+ }),
204
+ 'session-write': (args) => {
205
+ const keyIdx = args.indexOf('--key')
206
+ const key = keyIdx !== -1 ? args[keyIdx + 1] : process.env.HALTIJA_SESSION_KEY
207
+ const text = args
208
+ .filter((a, i) => !a.startsWith('-') && i !== keyIdx + 1)
209
+ .join(' ')
210
+ return { text, submit: args.includes('--no-submit') ? false : undefined, writeKey: key }
211
+ },
212
+ 'session-read': (args) => {
213
+ const body = {}
214
+ for (let i = 0; i < args.length; i++) {
215
+ if (args[i] === '--lines') { body.lines = Number(args[++i]); continue }
216
+ }
217
+ return body
218
+ },
219
+ 'session-detach': () => ({}),
195
220
  click: (args) => parseClickArgs(args),
196
221
  type: (args) => {
197
222
  // Flags first, so an advertised `--clear` stops being typed as literal text.
@@ -925,6 +950,9 @@ export const KNOWN_FLAGS = {
925
950
  // not cosmetic: BOTH `normalizeEqualsFlags` and `warnUnknownFlags` are gated on an entry
926
951
  // existing, so `hj map --scale=3` parsed to `{}` and warned about nothing.
927
952
  map: ['--global', '--max-nodes', '--image', '--png', '--data-url', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--format', '--quality', '--full-page', '--layout'],
953
+ 'session-read': ['--lines', '--follow'],
954
+ 'session-attach': ['--allow-input'],
955
+ 'session-write': ['--no-submit', '--key'],
928
956
  'events-watch': ['--preset'],
929
957
  'mutations-watch': ['--preset'],
930
958
  'network-watch': ['--preset'],
@@ -974,6 +1002,7 @@ export function normalizeEqualsFlags(args, known) {
974
1002
  export const FREE_TEXT_COMMANDS = new Set([
975
1003
  'type', 'highlight', 'call', 'eval', 'find', 'snapshot',
976
1004
  'send', 'send-message', 'send-selection', 'send-recording',
1005
+ 'session-write',
977
1006
  ])
978
1007
 
979
1008
 
package/bin/hints.json CHANGED
@@ -1,4 +1,8 @@
1
1
  {
2
+ "session-attach": "--allow-input | see: session-read, session-write, session-detach",
3
+ "session-read": "--lines N, --follow | see: session-attach",
4
+ "session-write": "--no-submit | see: session-attach",
5
+ "session-detach": "see: session-attach",
2
6
  "tree": "-d 3 (shallow), -i (interactive only), --visible, --compact | see: inspect, query, click",
3
7
  "query": "@ref or \"selector\", --all | see: tree, inspect",
4
8
  "inspect": "@ref or \"selector\", --styles, --rules | see: tree (for ancestors), query",
package/bin/hints.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  /** ⚠️ AUTO-GENERATED FROM src/api-schema.ts — DO NOT EDIT. Run: bun run build */
2
2
  export const COMMAND_HINTS = {
3
+ "session-attach": "--allow-input | see: session-read, session-write, session-detach",
4
+ "session-read": "--lines N, --follow | see: session-attach",
5
+ "session-write": "--no-submit | see: session-attach",
6
+ "session-detach": "see: session-attach",
3
7
  "tree": "-d 3 (shallow), -i (interactive only), --visible, --compact | see: inspect, query, click",
4
8
  "query": "@ref or \"selector\", --all | see: tree, inspect",
5
9
  "inspect": "@ref or \"selector\", --styles, --rules | see: tree (for ancestors), query",
@@ -27,6 +31,10 @@ export const COMMAND_HINTS = {
27
31
  }
28
32
 
29
33
  export const COMMAND_SUMMARIES = {
34
+ "session-attach": "Mirror a tmux session into the channel (opt-in, read-only)",
35
+ "session-read": "Read the mirrored terminal session",
36
+ "session-write": "Type into the mirrored session (requires the input grant)",
37
+ "session-detach": "Stop mirroring the terminal session",
30
38
  "tree": "Get DOM tree structure",
31
39
  "query": "Query DOM elements by selector",
32
40
  "inspect": "Deep inspection of an element",
package/bin/hj.mjs CHANGED
@@ -15,6 +15,7 @@ import { extractWindowTarget } from './arg-utils.mjs'
15
15
  import { findProjectOrigins, routeByDeclaredOrigin, ORIGINS_FILE } from './project-origins.mjs'
16
16
  import { collectCandidates, describeServer, sortRows, labelFor, isAmbiguousTarget } from './server-list.mjs'
17
17
  import { isDrivable, isVisible, visibilityKnown } from './window-state.mjs'
18
+ import { PAINT_STALE_MS } from './tab-liveness.mjs'
18
19
  import { LOCAL_COMMANDS, LOCAL_COMMAND_HELP } from './cli-commands.mjs'
19
20
  import { HJ_VERSION } from './version.mjs'
20
21
  import { differsBeyondPatch } from './semver.mjs'
@@ -416,7 +417,16 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
416
417
  })
417
418
  if (r.ok) {
418
419
  const j = await r.json()
419
- if (j && j.success && j.data && typeof j.data.fired === 'boolean') raf = j.data
420
+ // Prefer the widget's OWN measurement when it sends one. It answers the same question
421
+ // this probe does — is the tab compositing — but it is the signal the server already
422
+ // attaches to every result, so doctor and the result warnings cannot disagree about a
423
+ // borderline tab. The probe stays as the fallback for a widget older than 1.12.6, which
424
+ // reports no paintAgeMs at all; without it, an old widget would silently go unchecked.
425
+ if (typeof j?.paintAgeMs === 'number' && Number.isFinite(j.paintAgeMs)) {
426
+ raf = { fired: j.paintAgeMs < PAINT_STALE_MS, ms: j.paintAgeMs, measured: true }
427
+ } else if (j && j.success && j.data && typeof j.data.fired === 'boolean') {
428
+ raf = j.data
429
+ }
420
430
  }
421
431
  } catch {
422
432
  // Fall through to the unchecked branch — never a pass.
@@ -428,7 +438,9 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
428
438
  )
429
439
  } else if (!raf.fired) {
430
440
  problems.push(
431
- `requestAnimationFrame DID NOT FIRE within 2s — this tab is not compositing, even though ` +
441
+ (raf.measured
442
+ ? `this tab HAS NOT PAINTED A FRAME IN ${(raf.ms / 1000).toFixed(1)}s — it is not compositing, even though `
443
+ : `requestAnimationFrame DID NOT FIRE within 2s — this tab is not compositing, even though `) +
432
444
  `it reports visibilityState "visible". Anything rAF-driven (React's scheduler, tosijs ` +
433
445
  `queueRender, animations, virtual scrollers) will never render, so a missing element ` +
434
446
  `is NOT evidence of an application bug. Bring a window to the front, or wake the ` +
@@ -776,11 +788,52 @@ const NOUN_DEFAULTS = {
776
788
  'tabs': 'windows', // show tab list
777
789
  'video': 'video-status',
778
790
  'send': 'send', // already a command
791
+ 'session': 'session-read',// bare `hj session` shows the mirror
779
792
  }
780
793
  if (args.length === 1 && !isSubcommand(args[0]) && Object.hasOwn(NOUN_DEFAULTS, args[0])) {
781
794
  args[0] = NOUN_DEFAULTS[args[0]]
782
795
  }
783
796
 
797
+ // `hj session read --follow` — poll the mirror and print only what is new.
798
+ //
799
+ // Client-side rather than a streaming endpoint: the page in the headset polls the same endpoint, so
800
+ // a second transport would be a second thing to keep working. newTailOnly() is shared with the
801
+ // server module and unit-tested, including the case where the pane has scrolled past what we saw.
802
+ if ((args[0] === 'session-read' || (args[0] === 'session' && args[1] === 'read')) && args.includes('--follow')) {
803
+ const { newTailOnly } = await import('./tmux-session.mjs')
804
+ const linesIdx = args.indexOf('--lines')
805
+ const lines = linesIdx !== -1 ? Number(args[linesIdx + 1]) : 200
806
+ let seen = ''
807
+ let firstPass = true
808
+ process.stderr.write(`[hj] following the mirrored session — Ctrl-C to stop\n`)
809
+ for (;;) {
810
+ let json
811
+ try {
812
+ const resp = await fetch(`http://localhost:${port}/session/read`, {
813
+ method: 'POST',
814
+ headers: { 'Content-Type': 'application/json', ...(process.env.HALTIJA_TOKEN ? { 'X-Haltija-Token': process.env.HALTIJA_TOKEN } : {}) },
815
+ body: JSON.stringify({ lines }),
816
+ })
817
+ json = await resp.json()
818
+ } catch (err) {
819
+ console.error(`hj: ${err.message}`)
820
+ process.exit(1)
821
+ }
822
+ if (!json.success) {
823
+ // Do not spin silently on a session that has gone away — say it once and stop, so a dead
824
+ // mirror cannot look like an idle agent.
825
+ console.error(`hj: ${json.error}`)
826
+ process.exit(1)
827
+ }
828
+ const text = json.text || ''
829
+ const fresh = firstPass ? text : newTailOnly(seen, text)
830
+ if (fresh) process.stdout.write(fresh.endsWith('\n') ? fresh : fresh + '\n')
831
+ seen = text
832
+ firstPass = false
833
+ await new Promise((r) => setTimeout(r, 1000))
834
+ }
835
+ }
836
+
784
837
  const subcommand = args[0]
785
838
  let subArgs = args.slice(1)
786
839
  // Re-attach a leading --window so cli-subcommand's existing handling sees it (both positions work).
@@ -0,0 +1,27 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/tab-liveness.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/tab-liveness.ts
3
+ function hiddenTabWarning(win) {
4
+ if (!win)
5
+ return null;
6
+ if (win.active !== false)
7
+ return null;
8
+ const which = win.title ? `"${win.title}"` : win.id;
9
+ return `The tab that answered (${which}) reports it is HIDDEN — backgrounded, minimized, ` + `behind another window, or the display is asleep. Browsers stop requestAnimationFrame and ` + `throttle timers in a hidden tab, so anything mounted by rAF/IntersectionObserver may never ` + `have run: THIS RESULT CAN BE PLAUSIBLE BUT WRONG (an empty selector here means "not mounted ` + `yet", not "broken"). Bring the tab to the front, or target a visible one with --window <id>.`;
10
+ }
11
+ var PAINT_STALE_MS = 3000;
12
+ function stalePaintWarning(win, paintAgeMs) {
13
+ if (typeof paintAgeMs !== "number" || !Number.isFinite(paintAgeMs))
14
+ return null;
15
+ if (paintAgeMs < PAINT_STALE_MS)
16
+ return null;
17
+ if (win?.active === false)
18
+ return null;
19
+ const which = win?.title ? `"${win.title}"` : win?.id ?? "the answering tab";
20
+ const seconds = (paintAgeMs / 1000).toFixed(1);
21
+ return `The tab that answered (${which}) says it is VISIBLE but HAS NOT PAINTED A FRAME IN ` + `${seconds}s — it is not being composited. That happens when a window is occluded by another, ` + `is offscreen, or the display is asleep, and it is not something visibilityState reports. ` + `Anything driven by requestAnimationFrame (React's scheduler, tosijs queueRender, animations, ` + `virtual scrollers) is NOT RUNNING, so a missing element is not evidence of an application ` + `bug and a screenshot may show a stale frame the compositor is still holding. Raise the ` + `window (or wake the display) and re-run before believing this result.`;
22
+ }
23
+ export {
24
+ PAINT_STALE_MS,
25
+ hiddenTabWarning,
26
+ stalePaintWarning
27
+ };
@@ -0,0 +1,131 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/tmux-session.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/tmux-session.ts
3
+ var state = { target: null, attachedAt: null, allowInput: false, writeKey: null };
4
+ function sessionState() {
5
+ return { ...state };
6
+ }
7
+ async function listSessions(run) {
8
+ const res = await run(["list-sessions", "-F", "#{session_name}"]);
9
+ if (!res.ok)
10
+ return [];
11
+ return res.stdout.split(`
12
+ `).map((l) => l.trim()).filter(Boolean);
13
+ }
14
+ function tokenAdvisory(hasToken) {
15
+ if (hasToken)
16
+ return;
17
+ return "this server has no token, so anything that can reach its port can now read the agent's " + "terminal — not just drive the browser. Fine on a laptop; NOT fine over a tunnel. Start the " + "server with `--token <secret>` (and set HALTIJA_TOKEN for clients) if the port is reachable " + "from anywhere else.";
18
+ }
19
+ async function attachSession(run, target, hasToken = false, allowInput = false) {
20
+ const available = await listSessions(run);
21
+ if (!available.length) {
22
+ return {
23
+ ok: false,
24
+ error: "no tmux sessions are running. Start your agent inside one — `tmux new -s agent` then run " + "it there — or attach to an existing session by name.",
25
+ available: []
26
+ };
27
+ }
28
+ if (!available.includes(target)) {
29
+ return {
30
+ ok: false,
31
+ error: `no tmux session named "${target}"`,
32
+ available
33
+ };
34
+ }
35
+ state.target = target;
36
+ state.attachedAt = Date.now();
37
+ state.allowInput = allowInput;
38
+ state.writeKey = allowInput ? mintKey() : null;
39
+ return { ok: true, target, available, allowInput, writeKey: state.writeKey ?? undefined, warning: tokenAdvisory(hasToken) };
40
+ }
41
+ function detachSession() {
42
+ state.target = null;
43
+ state.attachedAt = null;
44
+ state.allowInput = false;
45
+ state.writeKey = null;
46
+ }
47
+ async function readSession(run, lines = 200) {
48
+ if (!state.target) {
49
+ return {
50
+ ok: false,
51
+ error: "no session attached. `hj session attach <tmux-session>` first — mirroring is opt-in because " + "it exposes everything the agent prints."
52
+ };
53
+ }
54
+ const safeLines = Math.max(1, Math.min(1e4, Math.floor(lines) || 200));
55
+ const res = await run(["capture-pane", "-t", state.target, "-p", "-S", `-${safeLines}`]);
56
+ if (!res.ok) {
57
+ return {
58
+ ok: false,
59
+ target: state.target,
60
+ error: `could not read tmux session "${state.target}": ${res.stderr.trim() || "unknown error"}`
61
+ };
62
+ }
63
+ return { ok: true, target: state.target, text: res.stdout.replace(/\s+$/, "") };
64
+ }
65
+ function newTailOnly(previous, current) {
66
+ if (!previous)
67
+ return current;
68
+ if (current.startsWith(previous))
69
+ return current.slice(previous.length);
70
+ const lastLine = previous.trimEnd().split(`
71
+ `).pop() || "";
72
+ if (lastLine) {
73
+ const idx = current.lastIndexOf(lastLine);
74
+ if (idx !== -1)
75
+ return current.slice(idx + lastLine.length);
76
+ }
77
+ return current;
78
+ }
79
+ function mintKey() {
80
+ const bytes = new Uint8Array(24);
81
+ crypto.getRandomValues(bytes);
82
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
83
+ }
84
+ function keyMatches(provided, expected) {
85
+ if (provided.length !== expected.length)
86
+ return false;
87
+ let diff = 0;
88
+ for (let i = 0;i < provided.length; i++)
89
+ diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
90
+ return diff === 0;
91
+ }
92
+ async function writeSession(run, text, submit = true, writeKey = "") {
93
+ if (!state.target) {
94
+ return { ok: false, error: "no session attached. `hj session attach <tmux-session>` first." };
95
+ }
96
+ if (!state.allowInput || !state.writeKey) {
97
+ return {
98
+ ok: false,
99
+ target: state.target,
100
+ error: "this session was attached for reading only; writing is a separate grant made at attach time."
101
+ };
102
+ }
103
+ if (!keyMatches(writeKey, state.writeKey)) {
104
+ return {
105
+ ok: false,
106
+ target: state.target,
107
+ error: "missing or invalid write key. The handle is returned once, to the caller that attached " + "with input permitted; pass it as `writeKey`."
108
+ };
109
+ }
110
+ if (!text)
111
+ return { ok: false, target: state.target, error: "nothing to send" };
112
+ const args = ["send-keys", "-t", state.target, "-l", "--", text];
113
+ const res = await run(args);
114
+ if (res.ok && submit) {
115
+ const nl = await run(["send-keys", "-t", state.target, "Enter"]);
116
+ if (!nl.ok) {
117
+ return { ok: false, target: state.target, error: nl.stderr.trim() || "send-keys Enter failed" };
118
+ }
119
+ }
120
+ return res.ok ? { ok: true, target: state.target } : { ok: false, target: state.target, error: res.stderr.trim() || "send-keys failed" };
121
+ }
122
+ export {
123
+ attachSession,
124
+ detachSession,
125
+ listSessions,
126
+ newTailOnly,
127
+ readSession,
128
+ sessionState,
129
+ tokenAdvisory,
130
+ writeSession
131
+ };
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.12.5'
6
+ export const HJ_VERSION = '1.12.6'
File without changes