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 +87 -0
- package/apps/desktop/package.json +6 -7
- package/apps/desktop/resources/component.js +57 -10
- package/bin/cli-commands.mjs +4 -0
- package/bin/cli-subcommand.mjs +29 -0
- package/bin/hints.json +4 -0
- package/bin/hints.mjs +8 -0
- package/bin/hj.mjs +55 -2
- package/bin/tab-liveness.mjs +27 -0
- package/bin/tmux-session.mjs +131 -0
- package/bin/version.mjs +1 -1
- package/dist/.metadata_never_index +0 -0
- package/dist/api-schema.d.ts +43 -1
- package/dist/cli-commands.d.ts +1 -1
- package/dist/component.d.ts +33 -1
- package/dist/component.esm.js +57 -10
- package/dist/component.js +57 -10
- package/dist/hj.js +241 -7
- package/dist/idle-timeout.d.ts +99 -0
- package/dist/index.js +581 -65
- package/dist/server.js +581 -65
- package/dist/tab-liveness.d.ts +23 -0
- package/dist/test-actions.d.ts +16 -0
- package/dist/tmux-session.d.ts +125 -0
- package/dist/types.d.ts +16 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-url.d.ts +17 -0
- package/docs/CI-INTEGRATION.md +19 -19
- package/llms.txt +4 -3
- package/package.json +1 -1
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.
|
|
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": "^
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/bin/cli-commands.mjs
CHANGED
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
File without changes
|