haltija 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/apps/desktop/main.js +88 -6
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +1 -1
- package/bin/cli-subcommand.mjs +7 -0
- package/bin/hj.mjs +15 -1
- package/bin/tosijs-dev.mjs +5 -8
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +1 -1
- package/dist/component.js +1 -1
- package/dist/focus-ambiguity.d.ts +47 -0
- package/dist/hj.js +14 -3
- package/dist/index.js +67 -3
- package/dist/server.js +67 -3
- package/dist/tab-liveness.d.ts +35 -0
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
Completes the **private-automation** feature (`--private`) begun in 1.4.1 — now for the Electron
|
|
6
|
+
app as well as headless — and adds two **"the instrument must not lie"** guards so a command that
|
|
7
|
+
lands on the wrong or sleeping tab says so instead of returning a plausible-but-wrong answer.
|
|
8
|
+
|
|
9
|
+
### New: `--private --app` — isolated Electron automation ([#1](https://github.com/tonioloewald/haltija/issues/1))
|
|
10
|
+
|
|
11
|
+
`--private` gave headless runs an isolated server + browser on an ephemeral port. `--private --app`
|
|
12
|
+
extends that to the desktop app: it spawns its **own** public and internal servers on ephemeral
|
|
13
|
+
ports (never 8700/8701), drives its **own** browser, writes the public address to `--port-file`,
|
|
14
|
+
and never sees, adopts, registers, or touches the shared interactive channel. The app's port
|
|
15
|
+
constants are now resolved *after* the private servers report their ephemeral ports, so every
|
|
16
|
+
downstream use — widget injection, `/status`, content tabs — follows the ephemeral instance.
|
|
17
|
+
|
|
18
|
+
### New: hidden-tab warning ([#3](https://github.com/tonioloewald/haltija/issues/3))
|
|
19
|
+
|
|
20
|
+
A backgrounded tab **answers** — `hj eval 'document.querySelectorAll("x").length'` returns `0`, not
|
|
21
|
+
a timeout — because browsers stop `requestAnimationFrame` and throttle timers in a hidden tab, so
|
|
22
|
+
anything mounted by rAF/IntersectionObserver never ran. The page looks broken when it's merely
|
|
23
|
+
asleep. When a command is routed to a tab that reported itself hidden, the result now carries a
|
|
24
|
+
warning that the number can be plausible-but-wrong, with how to target a visible tab.
|
|
25
|
+
|
|
26
|
+
### New: focus-ambiguity warning ([#2](https://github.com/tonioloewald/haltija/issues/2))
|
|
27
|
+
|
|
28
|
+
cwd routing gets an untargeted `hj` command to the right shared *server* and then stops — which
|
|
29
|
+
*tab* answers falls back to focus. So two agents each staying in their own project can drive each
|
|
30
|
+
other's pages once both have a tab on the shared server. When a command isn't pinned to a window
|
|
31
|
+
and the server spans more than one origin, the result now warns that *focus*, not your directory,
|
|
32
|
+
chose the tab — and lists the other tabs as `--window` pins. It deliberately does **not** guess
|
|
33
|
+
which tab is "yours" (there's no reliable origin→directory map); ranking waits for one that can
|
|
34
|
+
justify itself.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **`hj --window <id> <cmd>`** — the documented leading form printed the usage banner instead of
|
|
39
|
+
targeting the window (`--window` wasn't pre-parsed like `--port`/`--name`). Both positions work now.
|
|
40
|
+
- **Desktop-spawned servers get their port** via the env the server actually reads
|
|
41
|
+
(`HALTIJA_PORT`/`DEV_CHANNEL_PORT`, not `PORT`) — the app couldn't control its servers' ports before.
|
|
42
|
+
|
|
3
43
|
## 1.4.1
|
|
4
44
|
|
|
5
45
|
Five cross-project bugs, all of the same shape: **haltija reaching out and disrupting a healthy
|
package/apps/desktop/main.js
CHANGED
|
@@ -29,15 +29,27 @@ const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwo
|
|
|
29
29
|
process.stdout.on('error', () => {})
|
|
30
30
|
process.stderr.on('error', () => {})
|
|
31
31
|
|
|
32
|
+
// PRIVATE (isolated automation) mode — the Electron half of issue #1. `bunx haltija --private
|
|
33
|
+
// --app` must be an isolated instance that never sees/adopts/touches the shared servers on
|
|
34
|
+
// 8700/8701: it binds EPHEMERAL ports, discovered after the servers start. So these are `let`,
|
|
35
|
+
// not `const` — reassigned once the private servers report their ports (below), which means every
|
|
36
|
+
// downstream use (widget injection, status checks, help text, content tabs) automatically follows
|
|
37
|
+
// the ephemeral ports with no other edits.
|
|
38
|
+
const IS_PRIVATE = process.env.HALTIJA_PRIVATE === '1'
|
|
39
|
+
// The caller's port-file (from `--port-file`), captured before we repurpose the env for our own
|
|
40
|
+
// per-server discovery. In private mode the app writes the PUBLIC ephemeral address here so the
|
|
41
|
+
// consumer (e.g. a dev-server test lane) can drive this instance.
|
|
42
|
+
const CALLER_PORT_FILE = IS_PRIVATE ? (process.env.HALTIJA_PORT_FILE || null) : null
|
|
43
|
+
|
|
32
44
|
// Haltija server config
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
let HALTIJA_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_PORT || '8700')
|
|
46
|
+
let HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
|
|
35
47
|
|
|
36
48
|
// Internal port for the chrome widget (the haltija UI inspecting itself).
|
|
37
49
|
// Lives on a separate server so it never appears in agent-facing window lists
|
|
38
50
|
// — agents see only content tabs unless they explicitly target this port.
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
let HALTIJA_INTERNAL_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701')
|
|
52
|
+
let HALTIJA_INTERNAL_SERVER = `http://localhost:${HALTIJA_INTERNAL_PORT}`
|
|
41
53
|
|
|
42
54
|
// Unique app instance ID - used to create stable window IDs across navigations
|
|
43
55
|
// Combined with webContents.id to create globally unique tab identifiers
|
|
@@ -1188,8 +1200,34 @@ function checkServerRunning() {
|
|
|
1188
1200
|
* Stdout/stderr are piped to the desktop app's console with a label, and
|
|
1189
1201
|
* `__NEED_WINDOW__` from the public server triggers window recreation.
|
|
1190
1202
|
*/
|
|
1191
|
-
function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir }) {
|
|
1192
|
-
|
|
1203
|
+
function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir, portFile }) {
|
|
1204
|
+
// Pass the port via the env the SERVER ACTUALLY READS. It was `PORT`, which src/server.ts
|
|
1205
|
+
// never reads (it reads HALTIJA_PORT / DEV_CHANNEL_PORT) — so a spawned server ignored the
|
|
1206
|
+
// port it was given and inherited the app's HALTIJA_PORT instead. The internal chrome server
|
|
1207
|
+
// therefore tried to bind the PUBLIC port, collided, and died: verified by launching the app
|
|
1208
|
+
// on high ports and finding nothing on the internal one.
|
|
1209
|
+
const env = {
|
|
1210
|
+
...process.env,
|
|
1211
|
+
PORT: port.toString(), // kept for anything else that may read it
|
|
1212
|
+
HALTIJA_PORT: port.toString(), // what src/server.ts actually reads
|
|
1213
|
+
DEV_CHANNEL_PORT: port.toString(),
|
|
1214
|
+
HALTIJA_DESKTOP: '1',
|
|
1215
|
+
}
|
|
1216
|
+
if (IS_PRIVATE) {
|
|
1217
|
+
// Isolated instance: this child binds an EPHEMERAL port (HALTIJA_PRIVATE forces PORT=0) and
|
|
1218
|
+
// reports it to `portFile` so we can discover it. Each child gets its OWN port-file — never
|
|
1219
|
+
// the caller's, which we write ourselves once with the public address.
|
|
1220
|
+
env.HALTIJA_PRIVATE = '1'
|
|
1221
|
+
env.HALTIJA_NO_RETIRE = '1'
|
|
1222
|
+
env.HALTIJA_NO_INSTALL = '1'
|
|
1223
|
+
env.HALTIJA_PORT_FILE = portFile
|
|
1224
|
+
delete env.HALTIJA_PORT // ephemeral, not the app's port
|
|
1225
|
+
delete env.DEV_CHANNEL_PORT
|
|
1226
|
+
} else {
|
|
1227
|
+
// A non-private child must not inherit a private parent's flags (belt and braces).
|
|
1228
|
+
delete env.HALTIJA_PRIVATE
|
|
1229
|
+
delete env.HALTIJA_PORT_FILE
|
|
1230
|
+
}
|
|
1193
1231
|
let proc
|
|
1194
1232
|
if (serverPath && useCompiledBinary) {
|
|
1195
1233
|
proc = spawn(serverPath, [], {
|
|
@@ -1292,6 +1330,43 @@ async function startEmbeddedServer() {
|
|
|
1292
1330
|
}
|
|
1293
1331
|
}
|
|
1294
1332
|
|
|
1333
|
+
if (IS_PRIVATE) {
|
|
1334
|
+
// Private: both servers bind ephemeral ports we don't know yet. Give each its own port-file,
|
|
1335
|
+
// wait for them to report, then reassign the module ports so everything downstream (injection,
|
|
1336
|
+
// status, tabs, help) follows the ephemeral instance. Never touches 8700/8701.
|
|
1337
|
+
const pubFile = path.join(os.tmpdir(), `haltija-app-pub-${process.pid}.json`)
|
|
1338
|
+
const intFile = path.join(os.tmpdir(), `haltija-app-int-${process.pid}.json`)
|
|
1339
|
+
try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
|
|
1340
|
+
|
|
1341
|
+
spawnHaltijaServer({ port: 0, role: 'public', serverPath, useCompiledBinary, componentDir, portFile: pubFile })
|
|
1342
|
+
spawnHaltijaServer({ port: 0, role: 'internal', serverPath, useCompiledBinary, componentDir, portFile: intFile })
|
|
1343
|
+
|
|
1344
|
+
const readPort = async (file) => {
|
|
1345
|
+
for (let i = 0; i < 50; i++) {
|
|
1346
|
+
try { const d = JSON.parse(fs.readFileSync(file, 'utf8')); if (d && d.port) return d.port } catch {}
|
|
1347
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
1348
|
+
}
|
|
1349
|
+
return null
|
|
1350
|
+
}
|
|
1351
|
+
const pubPort = await readPort(pubFile)
|
|
1352
|
+
const intPort = await readPort(intFile)
|
|
1353
|
+
if (!pubPort) { console.error('[Haltija Desktop] Private public server did not report its port'); return false }
|
|
1354
|
+
|
|
1355
|
+
HALTIJA_PORT = pubPort
|
|
1356
|
+
HALTIJA_SERVER = `http://localhost:${pubPort}`
|
|
1357
|
+
if (intPort) { HALTIJA_INTERNAL_PORT = intPort; HALTIJA_INTERNAL_SERVER = `http://localhost:${intPort}` }
|
|
1358
|
+
try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
|
|
1359
|
+
|
|
1360
|
+
// Hand the PUBLIC address to the consumer that asked for this private instance.
|
|
1361
|
+
if (CALLER_PORT_FILE) {
|
|
1362
|
+
try {
|
|
1363
|
+
fs.writeFileSync(CALLER_PORT_FILE, JSON.stringify({ port: pubPort, url: HALTIJA_SERVER, internalPort: intPort || null, pid: process.pid }))
|
|
1364
|
+
} catch (err) { console.error('[Haltija Desktop] Could not write caller port-file:', err.message) }
|
|
1365
|
+
}
|
|
1366
|
+
console.log(`[Haltija Desktop] Private instance ready — public ${HALTIJA_SERVER}, internal :${intPort || 'n/a'} (8700/8701 untouched)`)
|
|
1367
|
+
return true
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1295
1370
|
spawnHaltijaServer({ port: HALTIJA_PORT, role: 'public', serverPath, useCompiledBinary, componentDir })
|
|
1296
1371
|
spawnHaltijaServer({ port: HALTIJA_INTERNAL_PORT, role: 'internal', serverPath, useCompiledBinary, componentDir })
|
|
1297
1372
|
|
|
@@ -1406,6 +1481,13 @@ async function killZombieServer() {
|
|
|
1406
1481
|
}
|
|
1407
1482
|
|
|
1408
1483
|
async function ensureServer() {
|
|
1484
|
+
// Private is isolated by construction: never look for, adopt, or replace a shared server. It
|
|
1485
|
+
// always starts its own on ephemeral ports. `checkServerRunning` would probe 8700 (which we
|
|
1486
|
+
// must ignore), so skip it entirely.
|
|
1487
|
+
if (IS_PRIVATE) {
|
|
1488
|
+
return await startEmbeddedServer()
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1409
1491
|
const running = await checkServerRunning()
|
|
1410
1492
|
|
|
1411
1493
|
switch (prefs.serverMode) {
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -938,6 +938,13 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
938
938
|
if (contentType.includes('application/json')) {
|
|
939
939
|
const json = await resp.json()
|
|
940
940
|
|
|
941
|
+
// A result can be REAL but MISLEADING — e.g. it came from a hidden tab where rAF-driven
|
|
942
|
+
// content never mounted, so an empty selector means "not mounted", not "broken" (issue #3).
|
|
943
|
+
// Print it on stderr so it can't be mistaken for output, and so --json stdout stays clean.
|
|
944
|
+
if (json && typeof json.warning === 'string' && json.warning) {
|
|
945
|
+
console.error(`hj: warning — ${json.warning}`)
|
|
946
|
+
}
|
|
947
|
+
|
|
941
948
|
// Text format for supported subcommands (unless --json)
|
|
942
949
|
if (!jsonOutput && subcommand === 'tree' && json.success && json.data) {
|
|
943
950
|
console.log(formatTree(json.data, 0, { depth: body?.depth }))
|
package/bin/hj.mjs
CHANGED
|
@@ -308,6 +308,18 @@ if (noLaunchIdx !== -1) {
|
|
|
308
308
|
args.splice(noLaunchIdx, 1)
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// Parse --window <id> HERE so it works BEFORE the subcommand, like --port/--name/--token do.
|
|
312
|
+
// It was only handled after the subcommand, so the documented form `hj --window <id> eval …`
|
|
313
|
+
// died with "Unknown command: '--window'" — i.e. the escape hatch we tell people to use for a
|
|
314
|
+
// hidden/wrong tab didn't work in the shape the docs gave. Pulled out here and re-appended to
|
|
315
|
+
// 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
|
+
}
|
|
322
|
+
|
|
311
323
|
// Did the shell explicitly target a private instance (--port / --name /
|
|
312
324
|
// HALTIJA_PORT / HALTIJA_NAME / DEV_CHANNEL_PORT)? If so, this is a
|
|
313
325
|
// project-owned server with a bring-your-own browser — auto-launching the
|
|
@@ -343,7 +355,9 @@ if (args.length === 1 && !isSubcommand(args[0]) && NOUN_DEFAULTS[args[0]]) {
|
|
|
343
355
|
}
|
|
344
356
|
|
|
345
357
|
const subcommand = args[0]
|
|
346
|
-
|
|
358
|
+
let subArgs = args.slice(1)
|
|
359
|
+
// Re-attach a leading --window so cli-subcommand's existing handling sees it (both positions work).
|
|
360
|
+
if (windowTarget) subArgs = [...subArgs, '--window', windowTarget]
|
|
347
361
|
|
|
348
362
|
// `hj where` — show which haltija server this shell is targeting and what
|
|
349
363
|
// (if anything) is alive there. Pure client-side resolution plus a single
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -565,15 +565,12 @@ function launchApp(desktopDir, port) {
|
|
|
565
565
|
|
|
566
566
|
// Resolve electron binary directly to avoid npx cache race conditions (ENOTEMPTY)
|
|
567
567
|
const electronBinary = resolveElectronBinary()
|
|
568
|
+
// A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
|
|
569
|
+
// 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
|
|
570
|
+
const appEnv = privateMode ? { ...env } : { ...env, DEV_CHANNEL_PORT: String(port) }
|
|
568
571
|
const child = electronBinary
|
|
569
|
-
? spawn(electronBinary, [desktopDir], {
|
|
570
|
-
|
|
571
|
-
stdio: 'inherit'
|
|
572
|
-
})
|
|
573
|
-
: spawn('npx', ['--yes', 'electron', desktopDir], {
|
|
574
|
-
env: { ...env, DEV_CHANNEL_PORT: String(port) },
|
|
575
|
-
stdio: 'inherit'
|
|
576
|
-
})
|
|
572
|
+
? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
573
|
+
: spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
577
574
|
|
|
578
575
|
child.on('error', (err) => {
|
|
579
576
|
console.error(red('Error:') + ` Failed to launch desktop app: ${err.message}`)
|
package/bin/version.mjs
CHANGED
package/dist/component.d.ts
CHANGED
|
@@ -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.
|
|
23
|
+
export declare const VERSION = "1.5.0";
|
|
24
24
|
export declare class DevChannel extends HTMLElement {
|
|
25
25
|
static get tagName(): string;
|
|
26
26
|
static elementCreator(): () => DevChannel;
|
package/dist/component.esm.js
CHANGED
package/dist/component.js
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did *focus* choose this tab, when the caller's *directory* should have?
|
|
3
|
+
*
|
|
4
|
+
* From issue #2. cwd routing (see `sessions.resolveByCwd`) gets an untargeted `hj` command to the
|
|
5
|
+
* right shared **server** — and then stops. Which **tab** on that server answers falls back to
|
|
6
|
+
* whatever is focused. So two agents, each correctly staying in its own project directory, can
|
|
7
|
+
* still drive each other's pages the moment both projects have a tab on the same shared 8700
|
|
8
|
+
* server; a human clicking into a second project's tab causes it solo.
|
|
9
|
+
*
|
|
10
|
+
* The obvious fix — rank tabs by "origin matches the cwd project" — has a trap: there is no
|
|
11
|
+
* reliable map from a tab's origin (a URL like `localhost:8787`) to a project **directory**. The
|
|
12
|
+
* dev-server port that injected the widget isn't always the project (proxies, multiple ports,
|
|
13
|
+
* static previews, `about:blank` mid-navigation). A ranking that's *usually* right would pick
|
|
14
|
+
* confidently and wrongly — reintroducing exactly the silent-misroute class we spent 1.4.0
|
|
15
|
+
* eliminating. A registry entry's `cwd` is per-**server**, not per-tab, so it can't sharpen this
|
|
16
|
+
* either.
|
|
17
|
+
*
|
|
18
|
+
* So we don't claim a mapping we don't have. We warn only about what is honestly knowable: the
|
|
19
|
+
* command was **not** pinned to a window, and this server spans **more than one origin**, so
|
|
20
|
+
* *focus* — not the caller's directory — chose which page answered. That's the honest half of the
|
|
21
|
+
* issue's "warn when the focused tab's origin doesn't match the cwd server's directory", and it
|
|
22
|
+
* composes with the hidden-tab warning (issue #3): one says the tab was asleep, this says the
|
|
23
|
+
* wrong tab may have been picked. Preference/ranking waits for a mapping that can justify itself.
|
|
24
|
+
*/
|
|
25
|
+
/** The bits of a tracked window this decision needs. */
|
|
26
|
+
export interface FocusWindowInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
url?: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
/** 'tab' | 'popup' | 'iframe' (older widgets may omit it — treated as a tab). */
|
|
31
|
+
windowType?: string;
|
|
32
|
+
}
|
|
33
|
+
/** A tab's origin for grouping — `about:blank`, opaque, and unparseable URLs fall back to the raw
|
|
34
|
+
* string so they still form an honest distinct bucket rather than silently collapsing together. */
|
|
35
|
+
export declare function originOf(url: string | undefined | null): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* A warning to attach when an *untargeted* command was answered by the focused tab while the
|
|
38
|
+
* server hosts tabs from more than one origin — i.e. focus, not the caller's directory, chose the
|
|
39
|
+
* page. Returns null when the caller pinned a window, when we don't know what answered, or when
|
|
40
|
+
* every tab shares one origin (no ambiguity to flag).
|
|
41
|
+
*/
|
|
42
|
+
export declare function ambiguousFocusWarning(opts: {
|
|
43
|
+
windows: FocusWindowInfo[];
|
|
44
|
+
sentToId: string | null | undefined;
|
|
45
|
+
/** True when the caller explicitly targeted a window (`--window` / `?window=`). */
|
|
46
|
+
wasTargeted: boolean;
|
|
47
|
+
}): string | null;
|
package/dist/hj.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// haltija-cli:do-not-edit v1.
|
|
2
|
+
// haltija-cli:do-not-edit v1.5.0
|
|
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.
|
|
759
|
+
var HJ_VERSION = "1.5.0";
|
|
760
760
|
|
|
761
761
|
// bin/semver.mjs
|
|
762
762
|
function parseVersion(v) {
|
|
@@ -1628,6 +1628,9 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
1628
1628
|
const contentType = resp.headers.get("content-type") || "";
|
|
1629
1629
|
if (contentType.includes("application/json")) {
|
|
1630
1630
|
const json = await resp.json();
|
|
1631
|
+
if (json && typeof json.warning === "string" && json.warning) {
|
|
1632
|
+
console.error(`hj: warning — ${json.warning}`);
|
|
1633
|
+
}
|
|
1631
1634
|
if (!jsonOutput && subcommand === "tree" && json.success && json.data) {
|
|
1632
1635
|
console.log(formatTree(json.data, 0, { depth: body?.depth }));
|
|
1633
1636
|
} else if (!jsonOutput && subcommand === "events" && (json.events || Array.isArray(json))) {
|
|
@@ -2134,6 +2137,12 @@ if (noLaunchIdx !== -1) {
|
|
|
2134
2137
|
noLaunch = true;
|
|
2135
2138
|
args.splice(noLaunchIdx, 1);
|
|
2136
2139
|
}
|
|
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
|
+
}
|
|
2137
2146
|
var explicitTarget = portSource !== "8700 (default)";
|
|
2138
2147
|
if (args.length >= 2 && isSubcommand(`${args[0]}-${args[1]}`)) {
|
|
2139
2148
|
args.splice(0, 2, `${args[0]}-${args[1]}`);
|
|
@@ -2152,7 +2161,9 @@ if (args.length === 1 && !isSubcommand(args[0]) && NOUN_DEFAULTS[args[0]]) {
|
|
|
2152
2161
|
args[0] = NOUN_DEFAULTS[args[0]];
|
|
2153
2162
|
}
|
|
2154
2163
|
var subcommand = args[0];
|
|
2155
|
-
var subArgs = args.slice(1)
|
|
2164
|
+
var subArgs = args.slice(1);
|
|
2165
|
+
if (windowTarget)
|
|
2166
|
+
subArgs = [...subArgs, "--window", windowTarget];
|
|
2156
2167
|
if (subcommand === "where") {
|
|
2157
2168
|
await runWhere(port, portSource, subArgs.includes("--json"));
|
|
2158
2169
|
process.exit(0);
|
package/dist/index.js
CHANGED
|
@@ -674,7 +674,7 @@ var injectorCode = `
|
|
|
674
674
|
`;
|
|
675
675
|
|
|
676
676
|
// src/version.ts
|
|
677
|
-
var VERSION = "1.
|
|
677
|
+
var VERSION = "1.5.0";
|
|
678
678
|
|
|
679
679
|
// src/embedded-assets.ts
|
|
680
680
|
var APP_MD = `# Haltija App
|
|
@@ -3097,7 +3097,7 @@ var COMPONENT_JS = `(() => {
|
|
|
3097
3097
|
});
|
|
3098
3098
|
|
|
3099
3099
|
// src/version.ts
|
|
3100
|
-
var VERSION = "1.
|
|
3100
|
+
var VERSION = "1.5.0";
|
|
3101
3101
|
|
|
3102
3102
|
// src/text-selector.ts
|
|
3103
3103
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
|
|
@@ -15226,6 +15226,52 @@ function isHaltijaProcess(pid) {
|
|
|
15226
15226
|
}
|
|
15227
15227
|
}
|
|
15228
15228
|
|
|
15229
|
+
// src/tab-liveness.ts
|
|
15230
|
+
function hiddenTabWarning(win) {
|
|
15231
|
+
if (!win)
|
|
15232
|
+
return null;
|
|
15233
|
+
if (win.active !== false)
|
|
15234
|
+
return null;
|
|
15235
|
+
const which = win.title ? `"${win.title}"` : win.id;
|
|
15236
|
+
return `The tab that answered (${which}) reports it is HIDDEN \u2014 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>.`;
|
|
15237
|
+
}
|
|
15238
|
+
|
|
15239
|
+
// src/focus-ambiguity.ts
|
|
15240
|
+
function isTopLevelTab(w) {
|
|
15241
|
+
return w.windowType !== "iframe" && w.windowType !== "popup";
|
|
15242
|
+
}
|
|
15243
|
+
function originOf(url) {
|
|
15244
|
+
if (!url)
|
|
15245
|
+
return null;
|
|
15246
|
+
try {
|
|
15247
|
+
const origin = new URL(url).origin;
|
|
15248
|
+
return origin === "null" ? url : origin;
|
|
15249
|
+
} catch {
|
|
15250
|
+
return url;
|
|
15251
|
+
}
|
|
15252
|
+
}
|
|
15253
|
+
function ambiguousFocusWarning(opts) {
|
|
15254
|
+
const { windows: windows2, sentToId, wasTargeted } = opts;
|
|
15255
|
+
if (wasTargeted)
|
|
15256
|
+
return null;
|
|
15257
|
+
if (!sentToId)
|
|
15258
|
+
return null;
|
|
15259
|
+
const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
|
|
15260
|
+
const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
|
|
15261
|
+
if (distinctOrigins.size < 2)
|
|
15262
|
+
return null;
|
|
15263
|
+
const chosen = withOrigin.find((w) => w.id === sentToId);
|
|
15264
|
+
const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
|
|
15265
|
+
const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
|
|
15266
|
+
const MAX = 4;
|
|
15267
|
+
const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
|
|
15268
|
+
`);
|
|
15269
|
+
const more = others.length > MAX ? `
|
|
15270
|
+
\u2026and ${others.length - MAX} more` : "";
|
|
15271
|
+
return `This command was NOT pinned to a window and this server has tabs from ` + `${distinctOrigins.size} different origins, so *focus* \u2014 not your working directory \u2014 chose ` + `which tab answered (${chosenWhere}). A tab you didn't mean (another project's page on this ` + `shared server, or one a human just clicked into) can silently receive the command. If you ` + `meant a different page, pin it:
|
|
15272
|
+
${pins}${more}`;
|
|
15273
|
+
}
|
|
15274
|
+
|
|
15229
15275
|
// src/server.ts
|
|
15230
15276
|
init_terminal();
|
|
15231
15277
|
|
|
@@ -16396,10 +16442,24 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16396
16442
|
pendingResponses.delete(id);
|
|
16397
16443
|
resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
|
|
16398
16444
|
}, timeoutMs);
|
|
16399
|
-
|
|
16445
|
+
let sentTo = null;
|
|
16446
|
+
const resolveWithLiveness = (res) => {
|
|
16447
|
+
const hidden = hiddenTabWarning(sentTo);
|
|
16448
|
+
const ambiguous = ambiguousFocusWarning({
|
|
16449
|
+
windows: Array.from(windows2.values()),
|
|
16450
|
+
sentToId: sentTo?.id,
|
|
16451
|
+
wasTargeted: !!windowId
|
|
16452
|
+
});
|
|
16453
|
+
const warning = [hidden, ambiguous].filter(Boolean).join(`
|
|
16454
|
+
|
|
16455
|
+
`);
|
|
16456
|
+
resolve(warning ? { ...res, warning } : res);
|
|
16457
|
+
};
|
|
16458
|
+
pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
|
|
16400
16459
|
if (windowId) {
|
|
16401
16460
|
const win = windows2.get(windowId);
|
|
16402
16461
|
if (win) {
|
|
16462
|
+
sentTo = win;
|
|
16403
16463
|
win.ws.send(JSON.stringify(msg));
|
|
16404
16464
|
} else {
|
|
16405
16465
|
clearTimeout(timeout);
|
|
@@ -16408,13 +16468,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16408
16468
|
}
|
|
16409
16469
|
} else if (focusedWindowId && windows2.has(focusedWindowId)) {
|
|
16410
16470
|
const focusedWin = windows2.get(focusedWindowId);
|
|
16471
|
+
sentTo = focusedWin;
|
|
16411
16472
|
focusedWin.ws.send(JSON.stringify(msg));
|
|
16412
16473
|
} else {
|
|
16413
16474
|
const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
16414
16475
|
if (activeWindows.length > 0) {
|
|
16476
|
+
sentTo = activeWindows[0];
|
|
16415
16477
|
activeWindows[0].ws.send(JSON.stringify(msg));
|
|
16416
16478
|
} else if (windows2.size > 0) {
|
|
16417
16479
|
const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
|
|
16480
|
+
sentTo = mostRecent;
|
|
16418
16481
|
mostRecent.ws.send(JSON.stringify(msg));
|
|
16419
16482
|
} else {
|
|
16420
16483
|
clearTimeout(timeout);
|
|
@@ -16798,6 +16861,7 @@ async function handleRest(req) {
|
|
|
16798
16861
|
title: w.title?.slice(0, 50) || "(untitled)",
|
|
16799
16862
|
url: w.url,
|
|
16800
16863
|
focused: w.id === focusedWindowId,
|
|
16864
|
+
hidden: w.active === false,
|
|
16801
16865
|
recording: activeRecordingSessions.has(w.id)
|
|
16802
16866
|
}));
|
|
16803
16867
|
const activeRecordings = activeRecordingSessions.size;
|
package/dist/server.js
CHANGED
|
@@ -674,7 +674,7 @@ var injectorCode = `
|
|
|
674
674
|
`;
|
|
675
675
|
|
|
676
676
|
// src/version.ts
|
|
677
|
-
var VERSION = "1.
|
|
677
|
+
var VERSION = "1.5.0";
|
|
678
678
|
|
|
679
679
|
// src/embedded-assets.ts
|
|
680
680
|
var APP_MD = `# Haltija App
|
|
@@ -3097,7 +3097,7 @@ var COMPONENT_JS = `(() => {
|
|
|
3097
3097
|
});
|
|
3098
3098
|
|
|
3099
3099
|
// src/version.ts
|
|
3100
|
-
var VERSION = "1.
|
|
3100
|
+
var VERSION = "1.5.0";
|
|
3101
3101
|
|
|
3102
3102
|
// src/text-selector.ts
|
|
3103
3103
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
|
|
@@ -15226,6 +15226,52 @@ function isHaltijaProcess(pid) {
|
|
|
15226
15226
|
}
|
|
15227
15227
|
}
|
|
15228
15228
|
|
|
15229
|
+
// src/tab-liveness.ts
|
|
15230
|
+
function hiddenTabWarning(win) {
|
|
15231
|
+
if (!win)
|
|
15232
|
+
return null;
|
|
15233
|
+
if (win.active !== false)
|
|
15234
|
+
return null;
|
|
15235
|
+
const which = win.title ? `"${win.title}"` : win.id;
|
|
15236
|
+
return `The tab that answered (${which}) reports it is HIDDEN \u2014 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>.`;
|
|
15237
|
+
}
|
|
15238
|
+
|
|
15239
|
+
// src/focus-ambiguity.ts
|
|
15240
|
+
function isTopLevelTab(w) {
|
|
15241
|
+
return w.windowType !== "iframe" && w.windowType !== "popup";
|
|
15242
|
+
}
|
|
15243
|
+
function originOf(url) {
|
|
15244
|
+
if (!url)
|
|
15245
|
+
return null;
|
|
15246
|
+
try {
|
|
15247
|
+
const origin = new URL(url).origin;
|
|
15248
|
+
return origin === "null" ? url : origin;
|
|
15249
|
+
} catch {
|
|
15250
|
+
return url;
|
|
15251
|
+
}
|
|
15252
|
+
}
|
|
15253
|
+
function ambiguousFocusWarning(opts) {
|
|
15254
|
+
const { windows: windows2, sentToId, wasTargeted } = opts;
|
|
15255
|
+
if (wasTargeted)
|
|
15256
|
+
return null;
|
|
15257
|
+
if (!sentToId)
|
|
15258
|
+
return null;
|
|
15259
|
+
const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
|
|
15260
|
+
const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
|
|
15261
|
+
if (distinctOrigins.size < 2)
|
|
15262
|
+
return null;
|
|
15263
|
+
const chosen = withOrigin.find((w) => w.id === sentToId);
|
|
15264
|
+
const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
|
|
15265
|
+
const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
|
|
15266
|
+
const MAX = 4;
|
|
15267
|
+
const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
|
|
15268
|
+
`);
|
|
15269
|
+
const more = others.length > MAX ? `
|
|
15270
|
+
\u2026and ${others.length - MAX} more` : "";
|
|
15271
|
+
return `This command was NOT pinned to a window and this server has tabs from ` + `${distinctOrigins.size} different origins, so *focus* \u2014 not your working directory \u2014 chose ` + `which tab answered (${chosenWhere}). A tab you didn't mean (another project's page on this ` + `shared server, or one a human just clicked into) can silently receive the command. If you ` + `meant a different page, pin it:
|
|
15272
|
+
${pins}${more}`;
|
|
15273
|
+
}
|
|
15274
|
+
|
|
15229
15275
|
// src/server.ts
|
|
15230
15276
|
init_terminal();
|
|
15231
15277
|
|
|
@@ -16396,10 +16442,24 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16396
16442
|
pendingResponses.delete(id);
|
|
16397
16443
|
resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
|
|
16398
16444
|
}, timeoutMs);
|
|
16399
|
-
|
|
16445
|
+
let sentTo = null;
|
|
16446
|
+
const resolveWithLiveness = (res) => {
|
|
16447
|
+
const hidden = hiddenTabWarning(sentTo);
|
|
16448
|
+
const ambiguous = ambiguousFocusWarning({
|
|
16449
|
+
windows: Array.from(windows2.values()),
|
|
16450
|
+
sentToId: sentTo?.id,
|
|
16451
|
+
wasTargeted: !!windowId
|
|
16452
|
+
});
|
|
16453
|
+
const warning = [hidden, ambiguous].filter(Boolean).join(`
|
|
16454
|
+
|
|
16455
|
+
`);
|
|
16456
|
+
resolve(warning ? { ...res, warning } : res);
|
|
16457
|
+
};
|
|
16458
|
+
pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
|
|
16400
16459
|
if (windowId) {
|
|
16401
16460
|
const win = windows2.get(windowId);
|
|
16402
16461
|
if (win) {
|
|
16462
|
+
sentTo = win;
|
|
16403
16463
|
win.ws.send(JSON.stringify(msg));
|
|
16404
16464
|
} else {
|
|
16405
16465
|
clearTimeout(timeout);
|
|
@@ -16408,13 +16468,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16408
16468
|
}
|
|
16409
16469
|
} else if (focusedWindowId && windows2.has(focusedWindowId)) {
|
|
16410
16470
|
const focusedWin = windows2.get(focusedWindowId);
|
|
16471
|
+
sentTo = focusedWin;
|
|
16411
16472
|
focusedWin.ws.send(JSON.stringify(msg));
|
|
16412
16473
|
} else {
|
|
16413
16474
|
const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
16414
16475
|
if (activeWindows.length > 0) {
|
|
16476
|
+
sentTo = activeWindows[0];
|
|
16415
16477
|
activeWindows[0].ws.send(JSON.stringify(msg));
|
|
16416
16478
|
} else if (windows2.size > 0) {
|
|
16417
16479
|
const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
|
|
16480
|
+
sentTo = mostRecent;
|
|
16418
16481
|
mostRecent.ws.send(JSON.stringify(msg));
|
|
16419
16482
|
} else {
|
|
16420
16483
|
clearTimeout(timeout);
|
|
@@ -16798,6 +16861,7 @@ async function handleRest(req) {
|
|
|
16798
16861
|
title: w.title?.slice(0, 50) || "(untitled)",
|
|
16799
16862
|
url: w.url,
|
|
16800
16863
|
focused: w.id === focusedWindowId,
|
|
16864
|
+
hidden: w.active === false,
|
|
16801
16865
|
recording: activeRecordingSessions.has(w.id)
|
|
16802
16866
|
}));
|
|
16803
16867
|
const activeRecordings = activeRecordingSessions.size;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the tab we just talked to actually awake?
|
|
3
|
+
*
|
|
4
|
+
* From issue #3, and it is the sharpest failure this tool has: a hidden tab **answers**. Run
|
|
5
|
+
* `hj eval 'document.querySelectorAll("tosi-b3d").length'` against a backgrounded tab and you get
|
|
6
|
+
* `0` — not a timeout, not an error. A confident, wrong number. The custom elements are
|
|
7
|
+
* registered and the static markup rendered, but browsers stop `requestAnimationFrame` and
|
|
8
|
+
* throttle timers in a hidden tab, so anything mounted by rAF / IntersectionObserver never runs.
|
|
9
|
+
* The page looks **broken** when it is merely **asleep**, and the reporter burned several rounds
|
|
10
|
+
* diagnosing a component bug that didn't exist.
|
|
11
|
+
*
|
|
12
|
+
* This is the instrument lying in its most dangerous form: not silence, but a plausible answer.
|
|
13
|
+
* So when we route a command to a tab that has told us it is hidden, we say so alongside the
|
|
14
|
+
* result rather than letting the number speak for itself.
|
|
15
|
+
*
|
|
16
|
+
* **Why not `lastSeen` staleness?** The reporter suggested it, and the data does go stale — but
|
|
17
|
+
* there is no periodic heartbeat: `lastSeen` only advances on navigation/visibility events. So an
|
|
18
|
+
* idle-but-perfectly-healthy tab looks exactly as stale as a sleeping one, and labelling on it
|
|
19
|
+
* would add a NEW false signal to fix a lying one. `active` is the honest signal: the widget sets
|
|
20
|
+
* it from `document.visibilityState` on `visibilitychange`, so `active === false` means the tab
|
|
21
|
+
* itself reported being hidden.
|
|
22
|
+
*/
|
|
23
|
+
/** The bits of a tracked window this decision needs. */
|
|
24
|
+
export interface TabLivenessInfo {
|
|
25
|
+
id: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
/** False when the tab reported itself hidden (visibilitychange → hidden). */
|
|
28
|
+
active?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A warning to attach to a result that came from a hidden tab, or null when the tab is awake
|
|
32
|
+
* (or we have no basis to claim otherwise — `active` undefined means the tab never reported,
|
|
33
|
+
* and we do not invent a warning we can't support).
|
|
34
|
+
*/
|
|
35
|
+
export declare function hiddenTabWarning(win: TabLivenessInfo | null | undefined): string | null;
|
package/dist/types.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface DevResponse {
|
|
|
26
26
|
data?: any;
|
|
27
27
|
error?: string;
|
|
28
28
|
timestamp: number;
|
|
29
|
+
/**
|
|
30
|
+
* Set when the result is real but may be MISLEADING — currently: it came from a tab that
|
|
31
|
+
* reported itself hidden, where rAF/timers are throttled so rAF-driven content may never have
|
|
32
|
+
* mounted. The command succeeded; the number may still be wrong. See src/tab-liveness.ts.
|
|
33
|
+
*/
|
|
34
|
+
warning?: string;
|
|
29
35
|
}
|
|
30
36
|
export interface DomQueryRequest {
|
|
31
37
|
selector?: string;
|
package/dist/version.d.ts
CHANGED