haltija 1.4.1 → 1.5.1
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 +62 -0
- package/apps/desktop/main.js +93 -6
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +1 -1
- package/bin/arg-utils.mjs +21 -0
- 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/api-handlers.d.ts +2 -0
- 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 +22 -3
- package/dist/index.js +74 -6
- package/dist/server.js +74 -6
- 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,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.1
|
|
4
|
+
|
|
5
|
+
Low-risk follow-ups from the 1.5.0 pre-release review — the two new "instrument must not lie"
|
|
6
|
+
warnings now reach more of the surfaces where they matter.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **The hidden-tab / focus-ambiguity warning is no longer dropped on the paths where it's most
|
|
11
|
+
useful.** It's now attached on the **timeout** path — a hidden tab whose rAF-driven `eval` never
|
|
12
|
+
resolves now returns a `Timeout` that *explains* it may be asleep, instead of a bare timeout —
|
|
13
|
+
and preserved by the `hj find` / `hj form` handlers, which previously reshaped the response and
|
|
14
|
+
lost it. (`hj screenshot` already carried it; `hj call` intentionally still returns the raw value
|
|
15
|
+
with no envelope.)
|
|
16
|
+
|
|
17
|
+
### Internal
|
|
18
|
+
|
|
19
|
+
- Extracted the `hj --window <id>` argument handling into a pure, unit-tested helper
|
|
20
|
+
(`bin/arg-utils.mjs`), covering both the leading and trailing positions — the leading form was
|
|
21
|
+
the escape hatch that broke in 1.4.0, and it now has a regression test.
|
|
22
|
+
- A private-app startup that fails to learn its ephemeral port no longer leaves its temp port-files
|
|
23
|
+
behind.
|
|
24
|
+
|
|
25
|
+
## 1.5.0
|
|
26
|
+
|
|
27
|
+
Completes the **private-automation** feature (`--private`) begun in 1.4.1 — now for the Electron
|
|
28
|
+
app as well as headless — and adds two **"the instrument must not lie"** guards so a command that
|
|
29
|
+
lands on the wrong or sleeping tab says so instead of returning a plausible-but-wrong answer.
|
|
30
|
+
|
|
31
|
+
### New: `--private --app` — isolated Electron automation ([#1](https://github.com/tonioloewald/haltija/issues/1))
|
|
32
|
+
|
|
33
|
+
`--private` gave headless runs an isolated server + browser on an ephemeral port. `--private --app`
|
|
34
|
+
extends that to the desktop app: it spawns its **own** public and internal servers on ephemeral
|
|
35
|
+
ports (never 8700/8701), drives its **own** browser, writes the public address to `--port-file`,
|
|
36
|
+
and never sees, adopts, registers, or touches the shared interactive channel. The app's port
|
|
37
|
+
constants are now resolved *after* the private servers report their ephemeral ports, so every
|
|
38
|
+
downstream use — widget injection, `/status`, content tabs — follows the ephemeral instance.
|
|
39
|
+
|
|
40
|
+
### New: hidden-tab warning ([#3](https://github.com/tonioloewald/haltija/issues/3))
|
|
41
|
+
|
|
42
|
+
A backgrounded tab **answers** — `hj eval 'document.querySelectorAll("x").length'` returns `0`, not
|
|
43
|
+
a timeout — because browsers stop `requestAnimationFrame` and throttle timers in a hidden tab, so
|
|
44
|
+
anything mounted by rAF/IntersectionObserver never ran. The page looks broken when it's merely
|
|
45
|
+
asleep. When a command is routed to a tab that reported itself hidden, the result now carries a
|
|
46
|
+
warning that the number can be plausible-but-wrong, with how to target a visible tab.
|
|
47
|
+
|
|
48
|
+
### New: focus-ambiguity warning ([#2](https://github.com/tonioloewald/haltija/issues/2))
|
|
49
|
+
|
|
50
|
+
cwd routing gets an untargeted `hj` command to the right shared *server* and then stops — which
|
|
51
|
+
*tab* answers falls back to focus. So two agents each staying in their own project can drive each
|
|
52
|
+
other's pages once both have a tab on the shared server. When a command isn't pinned to a window
|
|
53
|
+
and the server spans more than one origin, the result now warns that *focus*, not your directory,
|
|
54
|
+
chose the tab — and lists the other tabs as `--window` pins. It deliberately does **not** guess
|
|
55
|
+
which tab is "yours" (there's no reliable origin→directory map); ranking waits for one that can
|
|
56
|
+
justify itself.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **`hj --window <id> <cmd>`** — the documented leading form printed the usage banner instead of
|
|
61
|
+
targeting the window (`--window` wasn't pre-parsed like `--port`/`--name`). Both positions work now.
|
|
62
|
+
- **Desktop-spawned servers get their port** via the env the server actually reads
|
|
63
|
+
(`HALTIJA_PORT`/`DEV_CHANNEL_PORT`, not `PORT`) — the app couldn't control its servers' ports before.
|
|
64
|
+
|
|
3
65
|
## 1.4.1
|
|
4
66
|
|
|
5
67
|
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,48 @@ 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) {
|
|
1354
|
+
// Don't leak the pid-scoped tmp port-files on the failure path (the success path cleans below).
|
|
1355
|
+
try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
|
|
1356
|
+
console.error('[Haltija Desktop] Private public server did not report its port')
|
|
1357
|
+
return false
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
HALTIJA_PORT = pubPort
|
|
1361
|
+
HALTIJA_SERVER = `http://localhost:${pubPort}`
|
|
1362
|
+
if (intPort) { HALTIJA_INTERNAL_PORT = intPort; HALTIJA_INTERNAL_SERVER = `http://localhost:${intPort}` }
|
|
1363
|
+
try { fs.rmSync(pubFile, { force: true }); fs.rmSync(intFile, { force: true }) } catch {}
|
|
1364
|
+
|
|
1365
|
+
// Hand the PUBLIC address to the consumer that asked for this private instance.
|
|
1366
|
+
if (CALLER_PORT_FILE) {
|
|
1367
|
+
try {
|
|
1368
|
+
fs.writeFileSync(CALLER_PORT_FILE, JSON.stringify({ port: pubPort, url: HALTIJA_SERVER, internalPort: intPort || null, pid: process.pid }))
|
|
1369
|
+
} catch (err) { console.error('[Haltija Desktop] Could not write caller port-file:', err.message) }
|
|
1370
|
+
}
|
|
1371
|
+
console.log(`[Haltija Desktop] Private instance ready — public ${HALTIJA_SERVER}, internal :${intPort || 'n/a'} (8700/8701 untouched)`)
|
|
1372
|
+
return true
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1295
1375
|
spawnHaltijaServer({ port: HALTIJA_PORT, role: 'public', serverPath, useCompiledBinary, componentDir })
|
|
1296
1376
|
spawnHaltijaServer({ port: HALTIJA_INTERNAL_PORT, role: 'internal', serverPath, useCompiledBinary, componentDir })
|
|
1297
1377
|
|
|
@@ -1406,6 +1486,13 @@ async function killZombieServer() {
|
|
|
1406
1486
|
}
|
|
1407
1487
|
|
|
1408
1488
|
async function ensureServer() {
|
|
1489
|
+
// Private is isolated by construction: never look for, adopt, or replace a shared server. It
|
|
1490
|
+
// always starts its own on ephemeral ports. `checkServerRunning` would probe 8700 (which we
|
|
1491
|
+
// must ignore), so skip it entirely.
|
|
1492
|
+
if (IS_PRIVATE) {
|
|
1493
|
+
return await startEmbeddedServer()
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1409
1496
|
const running = await checkServerRunning()
|
|
1410
1497
|
|
|
1411
1498
|
switch (prefs.serverMode) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure arg helpers for the `hj` CLI, extracted so they can be unit-tested (see
|
|
3
|
+
* src/hj-args.test.ts) — the leading-`--window` form was fully broken and shipped without a test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pull a `--window <id>` flag out of `args` from ANY position, returning the id and the remaining
|
|
8
|
+
* args (input is not mutated). Mirrors how `--port`/`--name`/`--token` are pre-parsed, so both
|
|
9
|
+
* `hj --window <id> <cmd>` (leading) and `hj <cmd> --window <id>` (trailing) resolve the same tab.
|
|
10
|
+
* A `--window` with no following value is left in place (treated as not-a-target) so it surfaces as
|
|
11
|
+
* a normal unknown-flag rather than silently swallowing the next real arg.
|
|
12
|
+
*/
|
|
13
|
+
export function extractWindowTarget(args) {
|
|
14
|
+
const i = args.indexOf('--window')
|
|
15
|
+
if (i === -1 || args[i + 1] === undefined) {
|
|
16
|
+
return { windowTarget: null, args: [...args] }
|
|
17
|
+
}
|
|
18
|
+
const rest = [...args]
|
|
19
|
+
rest.splice(i, 2)
|
|
20
|
+
return { windowTarget: args[i + 1], args: rest }
|
|
21
|
+
}
|
package/bin/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
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
|
|
14
|
+
import { extractWindowTarget } from './arg-utils.mjs'
|
|
14
15
|
import { HJ_VERSION } from './version.mjs'
|
|
15
16
|
import { differsBeyondPatch } from './semver.mjs'
|
|
16
17
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
@@ -308,6 +309,17 @@ if (noLaunchIdx !== -1) {
|
|
|
308
309
|
args.splice(noLaunchIdx, 1)
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// Parse --window <id> HERE so it works BEFORE the subcommand, like --port/--name/--token do.
|
|
313
|
+
// It was only handled after the subcommand, so the documented form `hj --window <id> eval …`
|
|
314
|
+
// died with "Unknown command: '--window'" — i.e. the escape hatch we tell people to use for a
|
|
315
|
+
// hidden/wrong tab didn't work in the shape the docs gave. Pulled out here and re-appended to
|
|
316
|
+
// the subcommand args below, so BOTH positions work.
|
|
317
|
+
const { windowTarget, args: argsWithoutWindow } = extractWindowTarget(args)
|
|
318
|
+
// Preserve the `const args` reference (downstream code mutates it in place) while dropping the
|
|
319
|
+
// consumed --window <id> pair.
|
|
320
|
+
args.length = 0
|
|
321
|
+
args.push(...argsWithoutWindow)
|
|
322
|
+
|
|
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/api-handlers.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface DevResponse {
|
|
|
16
16
|
data?: any;
|
|
17
17
|
error?: string;
|
|
18
18
|
timestamp: number;
|
|
19
|
+
/** Hidden-tab / focus-ambiguity caveat attached by the server (see requestFromBrowser). */
|
|
20
|
+
warning?: string;
|
|
19
21
|
}
|
|
20
22
|
/** Function to send request to browser widget */
|
|
21
23
|
export type RequestFromBrowserFn = (channel: string, action: string, payload: any, timeoutMs?: number, windowId?: string) => Promise<DevResponse>;
|
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.1";
|
|
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.1
|
|
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.1";
|
|
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))) {
|
|
@@ -1903,6 +1906,17 @@ function dim2(s) {
|
|
|
1903
1906
|
return `\x1B[2m${s}\x1B[0m`;
|
|
1904
1907
|
}
|
|
1905
1908
|
|
|
1909
|
+
// bin/arg-utils.mjs
|
|
1910
|
+
function extractWindowTarget(args) {
|
|
1911
|
+
const i = args.indexOf("--window");
|
|
1912
|
+
if (i === -1 || args[i + 1] === undefined) {
|
|
1913
|
+
return { windowTarget: null, args: [...args] };
|
|
1914
|
+
}
|
|
1915
|
+
const rest = [...args];
|
|
1916
|
+
rest.splice(i, 2);
|
|
1917
|
+
return { windowTarget: args[i + 1], args: rest };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1906
1920
|
// bin/hj.mjs
|
|
1907
1921
|
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "node:fs";
|
|
1908
1922
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -2134,6 +2148,9 @@ if (noLaunchIdx !== -1) {
|
|
|
2134
2148
|
noLaunch = true;
|
|
2135
2149
|
args.splice(noLaunchIdx, 1);
|
|
2136
2150
|
}
|
|
2151
|
+
var { windowTarget, args: argsWithoutWindow } = extractWindowTarget(args);
|
|
2152
|
+
args.length = 0;
|
|
2153
|
+
args.push(...argsWithoutWindow);
|
|
2137
2154
|
var explicitTarget = portSource !== "8700 (default)";
|
|
2138
2155
|
if (args.length >= 2 && isSubcommand(`${args[0]}-${args[1]}`)) {
|
|
2139
2156
|
args.splice(0, 2, `${args[0]}-${args[1]}`);
|
|
@@ -2152,7 +2169,9 @@ if (args.length === 1 && !isSubcommand(args[0]) && NOUN_DEFAULTS[args[0]]) {
|
|
|
2152
2169
|
args[0] = NOUN_DEFAULTS[args[0]];
|
|
2153
2170
|
}
|
|
2154
2171
|
var subcommand = args[0];
|
|
2155
|
-
var subArgs = args.slice(1)
|
|
2172
|
+
var subArgs = args.slice(1);
|
|
2173
|
+
if (windowTarget)
|
|
2174
|
+
subArgs = [...subArgs, "--window", windowTarget];
|
|
2156
2175
|
if (subcommand === "where") {
|
|
2157
2176
|
await runWhere(port, portSource, subArgs.includes("--json"));
|
|
2158
2177
|
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.1";
|
|
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.1";
|
|
3101
3101
|
|
|
3102
3102
|
// src/text-selector.ts
|
|
3103
3103
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
|
|
@@ -13727,6 +13727,9 @@ function inferSuggestion(step, pageContext) {
|
|
|
13727
13727
|
}
|
|
13728
13728
|
|
|
13729
13729
|
// src/api-handlers.ts
|
|
13730
|
+
function withWarning(body, response) {
|
|
13731
|
+
return response.warning ? { ...body, warning: response.warning } : body;
|
|
13732
|
+
}
|
|
13730
13733
|
var handlers = new Map;
|
|
13731
13734
|
function registerHandler(endpoint2, handler) {
|
|
13732
13735
|
handlers.set(endpoint2.path, handler);
|
|
@@ -14557,7 +14560,7 @@ registerHandler(find, async (body, ctx) => {
|
|
|
14557
14560
|
})()`;
|
|
14558
14561
|
const response = await ctx.requestFromBrowser("eval", "exec", { code: findCode }, 5000, windowId);
|
|
14559
14562
|
if (response.success && response.data) {
|
|
14560
|
-
return Response.json({ success: true, ...response.data }, { headers: ctx.headers });
|
|
14563
|
+
return Response.json(withWarning({ success: true, ...response.data }, response), { headers: ctx.headers });
|
|
14561
14564
|
}
|
|
14562
14565
|
return Response.json(response, { headers: ctx.headers });
|
|
14563
14566
|
});
|
|
@@ -14663,7 +14666,7 @@ registerHandler(formData, async (body, ctx) => {
|
|
|
14663
14666
|
})()`;
|
|
14664
14667
|
const response = await ctx.requestFromBrowser("eval", "exec", { code: formCode }, 5000, windowId);
|
|
14665
14668
|
if (response.success && response.data) {
|
|
14666
|
-
return Response.json(response.data, { headers: ctx.headers });
|
|
14669
|
+
return Response.json(withWarning(response.data, response), { headers: ctx.headers });
|
|
14667
14670
|
}
|
|
14668
14671
|
return Response.json(response, { headers: ctx.headers });
|
|
14669
14672
|
});
|
|
@@ -15226,6 +15229,52 @@ function isHaltijaProcess(pid) {
|
|
|
15226
15229
|
}
|
|
15227
15230
|
}
|
|
15228
15231
|
|
|
15232
|
+
// src/tab-liveness.ts
|
|
15233
|
+
function hiddenTabWarning(win) {
|
|
15234
|
+
if (!win)
|
|
15235
|
+
return null;
|
|
15236
|
+
if (win.active !== false)
|
|
15237
|
+
return null;
|
|
15238
|
+
const which = win.title ? `"${win.title}"` : win.id;
|
|
15239
|
+
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>.`;
|
|
15240
|
+
}
|
|
15241
|
+
|
|
15242
|
+
// src/focus-ambiguity.ts
|
|
15243
|
+
function isTopLevelTab(w) {
|
|
15244
|
+
return w.windowType !== "iframe" && w.windowType !== "popup";
|
|
15245
|
+
}
|
|
15246
|
+
function originOf(url) {
|
|
15247
|
+
if (!url)
|
|
15248
|
+
return null;
|
|
15249
|
+
try {
|
|
15250
|
+
const origin = new URL(url).origin;
|
|
15251
|
+
return origin === "null" ? url : origin;
|
|
15252
|
+
} catch {
|
|
15253
|
+
return url;
|
|
15254
|
+
}
|
|
15255
|
+
}
|
|
15256
|
+
function ambiguousFocusWarning(opts) {
|
|
15257
|
+
const { windows: windows2, sentToId, wasTargeted } = opts;
|
|
15258
|
+
if (wasTargeted)
|
|
15259
|
+
return null;
|
|
15260
|
+
if (!sentToId)
|
|
15261
|
+
return null;
|
|
15262
|
+
const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
|
|
15263
|
+
const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
|
|
15264
|
+
if (distinctOrigins.size < 2)
|
|
15265
|
+
return null;
|
|
15266
|
+
const chosen = withOrigin.find((w) => w.id === sentToId);
|
|
15267
|
+
const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
|
|
15268
|
+
const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
|
|
15269
|
+
const MAX = 4;
|
|
15270
|
+
const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
|
|
15271
|
+
`);
|
|
15272
|
+
const more = others.length > MAX ? `
|
|
15273
|
+
\u2026and ${others.length - MAX} more` : "";
|
|
15274
|
+
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:
|
|
15275
|
+
${pins}${more}`;
|
|
15276
|
+
}
|
|
15277
|
+
|
|
15229
15278
|
// src/server.ts
|
|
15230
15279
|
init_terminal();
|
|
15231
15280
|
|
|
@@ -16392,14 +16441,29 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16392
16441
|
source: "agent"
|
|
16393
16442
|
};
|
|
16394
16443
|
return new Promise((resolve) => {
|
|
16444
|
+
let sentTo = null;
|
|
16445
|
+
const attachWarning = (res) => {
|
|
16446
|
+
const hidden = hiddenTabWarning(sentTo);
|
|
16447
|
+
const ambiguous = ambiguousFocusWarning({
|
|
16448
|
+
windows: Array.from(windows2.values()),
|
|
16449
|
+
sentToId: sentTo?.id,
|
|
16450
|
+
wasTargeted: !!windowId
|
|
16451
|
+
});
|
|
16452
|
+
const warning = [hidden, ambiguous].filter(Boolean).join(`
|
|
16453
|
+
|
|
16454
|
+
`);
|
|
16455
|
+
return warning ? { ...res, warning } : res;
|
|
16456
|
+
};
|
|
16395
16457
|
const timeout = setTimeout(() => {
|
|
16396
16458
|
pendingResponses.delete(id);
|
|
16397
|
-
resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
|
|
16459
|
+
resolve(attachWarning({ id, success: false, error: "Timeout", timestamp: Date.now() }));
|
|
16398
16460
|
}, timeoutMs);
|
|
16399
|
-
|
|
16461
|
+
const resolveWithLiveness = (res) => resolve(attachWarning(res));
|
|
16462
|
+
pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
|
|
16400
16463
|
if (windowId) {
|
|
16401
16464
|
const win = windows2.get(windowId);
|
|
16402
16465
|
if (win) {
|
|
16466
|
+
sentTo = win;
|
|
16403
16467
|
win.ws.send(JSON.stringify(msg));
|
|
16404
16468
|
} else {
|
|
16405
16469
|
clearTimeout(timeout);
|
|
@@ -16408,13 +16472,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16408
16472
|
}
|
|
16409
16473
|
} else if (focusedWindowId && windows2.has(focusedWindowId)) {
|
|
16410
16474
|
const focusedWin = windows2.get(focusedWindowId);
|
|
16475
|
+
sentTo = focusedWin;
|
|
16411
16476
|
focusedWin.ws.send(JSON.stringify(msg));
|
|
16412
16477
|
} else {
|
|
16413
16478
|
const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
16414
16479
|
if (activeWindows.length > 0) {
|
|
16480
|
+
sentTo = activeWindows[0];
|
|
16415
16481
|
activeWindows[0].ws.send(JSON.stringify(msg));
|
|
16416
16482
|
} else if (windows2.size > 0) {
|
|
16417
16483
|
const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
|
|
16484
|
+
sentTo = mostRecent;
|
|
16418
16485
|
mostRecent.ws.send(JSON.stringify(msg));
|
|
16419
16486
|
} else {
|
|
16420
16487
|
clearTimeout(timeout);
|
|
@@ -16798,6 +16865,7 @@ async function handleRest(req) {
|
|
|
16798
16865
|
title: w.title?.slice(0, 50) || "(untitled)",
|
|
16799
16866
|
url: w.url,
|
|
16800
16867
|
focused: w.id === focusedWindowId,
|
|
16868
|
+
hidden: w.active === false,
|
|
16801
16869
|
recording: activeRecordingSessions.has(w.id)
|
|
16802
16870
|
}));
|
|
16803
16871
|
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.1";
|
|
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.1";
|
|
3101
3101
|
|
|
3102
3102
|
// src/text-selector.ts
|
|
3103
3103
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
|
|
@@ -13727,6 +13727,9 @@ function inferSuggestion(step, pageContext) {
|
|
|
13727
13727
|
}
|
|
13728
13728
|
|
|
13729
13729
|
// src/api-handlers.ts
|
|
13730
|
+
function withWarning(body, response) {
|
|
13731
|
+
return response.warning ? { ...body, warning: response.warning } : body;
|
|
13732
|
+
}
|
|
13730
13733
|
var handlers = new Map;
|
|
13731
13734
|
function registerHandler(endpoint2, handler) {
|
|
13732
13735
|
handlers.set(endpoint2.path, handler);
|
|
@@ -14557,7 +14560,7 @@ registerHandler(find, async (body, ctx) => {
|
|
|
14557
14560
|
})()`;
|
|
14558
14561
|
const response = await ctx.requestFromBrowser("eval", "exec", { code: findCode }, 5000, windowId);
|
|
14559
14562
|
if (response.success && response.data) {
|
|
14560
|
-
return Response.json({ success: true, ...response.data }, { headers: ctx.headers });
|
|
14563
|
+
return Response.json(withWarning({ success: true, ...response.data }, response), { headers: ctx.headers });
|
|
14561
14564
|
}
|
|
14562
14565
|
return Response.json(response, { headers: ctx.headers });
|
|
14563
14566
|
});
|
|
@@ -14663,7 +14666,7 @@ registerHandler(formData, async (body, ctx) => {
|
|
|
14663
14666
|
})()`;
|
|
14664
14667
|
const response = await ctx.requestFromBrowser("eval", "exec", { code: formCode }, 5000, windowId);
|
|
14665
14668
|
if (response.success && response.data) {
|
|
14666
|
-
return Response.json(response.data, { headers: ctx.headers });
|
|
14669
|
+
return Response.json(withWarning(response.data, response), { headers: ctx.headers });
|
|
14667
14670
|
}
|
|
14668
14671
|
return Response.json(response, { headers: ctx.headers });
|
|
14669
14672
|
});
|
|
@@ -15226,6 +15229,52 @@ function isHaltijaProcess(pid) {
|
|
|
15226
15229
|
}
|
|
15227
15230
|
}
|
|
15228
15231
|
|
|
15232
|
+
// src/tab-liveness.ts
|
|
15233
|
+
function hiddenTabWarning(win) {
|
|
15234
|
+
if (!win)
|
|
15235
|
+
return null;
|
|
15236
|
+
if (win.active !== false)
|
|
15237
|
+
return null;
|
|
15238
|
+
const which = win.title ? `"${win.title}"` : win.id;
|
|
15239
|
+
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>.`;
|
|
15240
|
+
}
|
|
15241
|
+
|
|
15242
|
+
// src/focus-ambiguity.ts
|
|
15243
|
+
function isTopLevelTab(w) {
|
|
15244
|
+
return w.windowType !== "iframe" && w.windowType !== "popup";
|
|
15245
|
+
}
|
|
15246
|
+
function originOf(url) {
|
|
15247
|
+
if (!url)
|
|
15248
|
+
return null;
|
|
15249
|
+
try {
|
|
15250
|
+
const origin = new URL(url).origin;
|
|
15251
|
+
return origin === "null" ? url : origin;
|
|
15252
|
+
} catch {
|
|
15253
|
+
return url;
|
|
15254
|
+
}
|
|
15255
|
+
}
|
|
15256
|
+
function ambiguousFocusWarning(opts) {
|
|
15257
|
+
const { windows: windows2, sentToId, wasTargeted } = opts;
|
|
15258
|
+
if (wasTargeted)
|
|
15259
|
+
return null;
|
|
15260
|
+
if (!sentToId)
|
|
15261
|
+
return null;
|
|
15262
|
+
const withOrigin = windows2.filter(isTopLevelTab).map((w) => ({ ...w, origin: originOf(w.url) })).filter((w) => w.origin !== null);
|
|
15263
|
+
const distinctOrigins = new Set(withOrigin.map((w) => w.origin));
|
|
15264
|
+
if (distinctOrigins.size < 2)
|
|
15265
|
+
return null;
|
|
15266
|
+
const chosen = withOrigin.find((w) => w.id === sentToId);
|
|
15267
|
+
const chosenWhere = chosen ? `${chosen.origin}${chosen.title ? ` \u2014 "${chosen.title}"` : ""}` : sentToId;
|
|
15268
|
+
const others = withOrigin.filter((w) => !chosen || w.origin !== chosen.origin);
|
|
15269
|
+
const MAX = 4;
|
|
15270
|
+
const pins = others.slice(0, MAX).map((w) => ` --window ${w.id} \u2192 ${w.origin}${w.title ? ` ("${w.title}")` : ""}`).join(`
|
|
15271
|
+
`);
|
|
15272
|
+
const more = others.length > MAX ? `
|
|
15273
|
+
\u2026and ${others.length - MAX} more` : "";
|
|
15274
|
+
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:
|
|
15275
|
+
${pins}${more}`;
|
|
15276
|
+
}
|
|
15277
|
+
|
|
15229
15278
|
// src/server.ts
|
|
15230
15279
|
init_terminal();
|
|
15231
15280
|
|
|
@@ -16392,14 +16441,29 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16392
16441
|
source: "agent"
|
|
16393
16442
|
};
|
|
16394
16443
|
return new Promise((resolve) => {
|
|
16444
|
+
let sentTo = null;
|
|
16445
|
+
const attachWarning = (res) => {
|
|
16446
|
+
const hidden = hiddenTabWarning(sentTo);
|
|
16447
|
+
const ambiguous = ambiguousFocusWarning({
|
|
16448
|
+
windows: Array.from(windows2.values()),
|
|
16449
|
+
sentToId: sentTo?.id,
|
|
16450
|
+
wasTargeted: !!windowId
|
|
16451
|
+
});
|
|
16452
|
+
const warning = [hidden, ambiguous].filter(Boolean).join(`
|
|
16453
|
+
|
|
16454
|
+
`);
|
|
16455
|
+
return warning ? { ...res, warning } : res;
|
|
16456
|
+
};
|
|
16395
16457
|
const timeout = setTimeout(() => {
|
|
16396
16458
|
pendingResponses.delete(id);
|
|
16397
|
-
resolve({ id, success: false, error: "Timeout", timestamp: Date.now() });
|
|
16459
|
+
resolve(attachWarning({ id, success: false, error: "Timeout", timestamp: Date.now() }));
|
|
16398
16460
|
}, timeoutMs);
|
|
16399
|
-
|
|
16461
|
+
const resolveWithLiveness = (res) => resolve(attachWarning(res));
|
|
16462
|
+
pendingResponses.set(id, { resolve: resolveWithLiveness, timeout });
|
|
16400
16463
|
if (windowId) {
|
|
16401
16464
|
const win = windows2.get(windowId);
|
|
16402
16465
|
if (win) {
|
|
16466
|
+
sentTo = win;
|
|
16403
16467
|
win.ws.send(JSON.stringify(msg));
|
|
16404
16468
|
} else {
|
|
16405
16469
|
clearTimeout(timeout);
|
|
@@ -16408,13 +16472,16 @@ async function requestFromBrowser(channel, action, payload, timeoutMs = 5000, wi
|
|
|
16408
16472
|
}
|
|
16409
16473
|
} else if (focusedWindowId && windows2.has(focusedWindowId)) {
|
|
16410
16474
|
const focusedWin = windows2.get(focusedWindowId);
|
|
16475
|
+
sentTo = focusedWin;
|
|
16411
16476
|
focusedWin.ws.send(JSON.stringify(msg));
|
|
16412
16477
|
} else {
|
|
16413
16478
|
const activeWindows = Array.from(windows2.values()).filter((w) => w.active).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
16414
16479
|
if (activeWindows.length > 0) {
|
|
16480
|
+
sentTo = activeWindows[0];
|
|
16415
16481
|
activeWindows[0].ws.send(JSON.stringify(msg));
|
|
16416
16482
|
} else if (windows2.size > 0) {
|
|
16417
16483
|
const mostRecent = Array.from(windows2.values()).sort((a, b) => b.lastSeen - a.lastSeen)[0];
|
|
16484
|
+
sentTo = mostRecent;
|
|
16418
16485
|
mostRecent.ws.send(JSON.stringify(msg));
|
|
16419
16486
|
} else {
|
|
16420
16487
|
clearTimeout(timeout);
|
|
@@ -16798,6 +16865,7 @@ async function handleRest(req) {
|
|
|
16798
16865
|
title: w.title?.slice(0, 50) || "(untitled)",
|
|
16799
16866
|
url: w.url,
|
|
16800
16867
|
focused: w.id === focusedWindowId,
|
|
16868
|
+
hidden: w.active === false,
|
|
16801
16869
|
recording: activeRecordingSessions.has(w.id)
|
|
16802
16870
|
}));
|
|
16803
16871
|
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