verikun 0.21.0 → 0.22.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.
@@ -275,7 +275,13 @@ to set it up, then every read after is fast. Nothing to enable.
275
275
  It holds the device's single `UiAutomation` connection while it runs, so Appium and Layout
276
276
  Inspector cannot attach. If the user needs those, tell them `VERIKUN_COMPANION=0` or
277
277
  `vk companion stop` — don't disable it pre-emptively. A failure never breaks a run: verikun
278
- falls back to the slower stock read on its own.
278
+ falls back to the slower stock read on its own, and retries the fast path a minute later.
279
+
280
+ Over `--server` the same applies, but the setting lives on the **server**: reads execute
281
+ there, so `VERIKUN_COMPANION` is read in the server's environment and `vk companion` has no
282
+ `--server` form. If a remote run feels slow (~2.4s a step on Android), don't guess — ask:
283
+ `curl -s "$VERIKUN_SERVER/v1/health" | jq .reads` reports the read path and why. Every
284
+ `--server` run also prints it once at start.
279
285
 
280
286
  **Remember identifiers across runs.** After a flow succeeds, save the selectors
281
287
  you found to memory — the mapping from human intent to selector, plus the screen
@@ -476,7 +482,7 @@ draft-first flow.
476
482
  - `0` success / found / assertion passed
477
483
  - `1` not found / assertion failed / wait timeout
478
484
  - `2` usage error **or ambiguous selector** (refine it or add `--index N`)
479
- - `3` environment error (no device, adb/idb missing, hierarchy dump failed) — for
485
+ - `3` environment error (no usable device, adb/idb missing, hierarchy dump failed) — for
480
486
  `ai`/`suite`/`install`/`server` the toolchain is verified up front, so this arrives
481
487
  immediately with an install hint rather than mid-flow
482
488
 
@@ -492,7 +498,12 @@ draft-first flow.
492
498
  - **`vk device set` from a plain shell stays applied.** Inside `batch`/`ai`/`suite` it is
493
499
  restored automatically even if the flow dies, but a one-off `vk device set airplane=on`
494
500
  is yours to `vk device reset` — don't strand someone's phone offline.
495
- - **One device auto-resolves.** Multiple pass `-d <serial>` or set `VERIKUN_DEVICE`.
501
+ - **The device picks itself, and won't collide with another agent.** With several attached,
502
+ `vk` takes one no other job is driving and notes it on stderr. Exit `2` means every device
503
+ is genuinely busy (the message names each holder) — pass `-d <serial>`, wait, or
504
+ `vk device release <serial>` if that job is gone. `vk devices` shows who holds what.
505
+ Naming a busy device with `-d` is refused, on purpose: two jobs on one phone corrupt both
506
+ runs, and it surfaces as a bogus assertion failure. `VERIKUN_NO_CLAIM=1` opts out entirely.
496
507
  - **`vk text` opens the keyboard.** Use `--enter` to submit, or `vk back` to
497
508
  dismiss it before re-inspecting (it can cover elements).
498
509
  - **Unicode/emoji** may not type via `adb input text` (an Android limitation);
package/CHANGELOG.md CHANGED
@@ -6,6 +6,124 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.22.1] - 2026-08-15
10
+
11
+ ### Added
12
+ - **The Flutter fixture raises a real system permission dialog** (`@vk_permission`, Android
13
+ only), so [#79] reproduces on demand.
14
+
15
+ ### Fixed
16
+ - **Hierarchy reads now see a window another app opens on top** — a runtime-permission dialog is
17
+ no longer invisible to the companion. ([#79])
18
+
19
+ [#79]: https://github.com/ddikman/verikun/issues/79
20
+
21
+ ## [0.22.0] - 2026-08-15
22
+
23
+ ### Added
24
+ - **Device claims**: `vk` now picks a device no other job is driving; a busy one is refused
25
+ with exit `2`. Writes `~/.verikun/devices/`; `VERIKUN_NO_CLAIM=1` opts out. ([#49])
26
+ - **`vk devices`**: a `USED BY` column names the job holding each device; `--json` gains
27
+ `claim`. ([#49])
28
+ - **`vk device release [serial]`**: hands a device back without waiting for its claim to go
29
+ stale. ([#49])
30
+
31
+ ### Changed
32
+ - **Several attached devices no longer exits `2`** — a free one is auto-selected. Pass
33
+ `--device` to pin it, as before. ([#49])
34
+ - **Changelog entries are now one line each**, per the new `changelog-entry` skill — root cause,
35
+ benchmarks and rationale move to the PR body and `docs/`.
36
+
37
+ ### Fixed
38
+ - **Failure evidence**: a step that could not resolve a device printed its error three times;
39
+ capture is now skipped when there is no screen to photograph.
40
+
41
+ [#49]: https://github.com/ddikman/verikun/issues/49
42
+
43
+ ## [0.21.1] - 2026-08-13
44
+
45
+ ### Fixed
46
+ - **`npm link` produced a `vk` that died with "permission denied".** `tsc` writes
47
+ `dist/bin/verikun.js` as 0644 and nothing set the executable bit, so the global symlink
48
+ `npm link` creates pointed straight at a non-executable file. It never showed up for
49
+ `npm install -g verikun`, because npm chmods `bin` targets itself while unpacking a
50
+ tarball — only a source checkout was affected, which is to say only contributors, on a
51
+ fresh clone, with an error that reads like a broken install rather than a missing mode bit.
52
+
53
+ A `postbuild` step (`scripts/chmod-bin.mjs`) now chmods every path named in `package.json`'s
54
+ `bin`, reading them from there so a renamed entry point cannot silently be left behind. It
55
+ is silent on success on purpose: `npm pack --json` runs the `prepare` hook and then expects
56
+ its own JSON on stdout, so a chatty build step is parsed as part of the pack result and
57
+ takes the release gate down with it.
58
+
59
+ `scripts/check-package-contents.mjs` now also asserts the packed tarball's `bin` is
60
+ executable, not merely present — the mode is exactly the kind of thing that regresses
61
+ without anyone noticing, since `npm install -g` keeps working either way.
62
+
63
+ (Shipping in this release rather than one of its own — it landed on `main` unreleased.)
64
+
65
+ - **The companion no longer switches itself off for the rest of a long-lived process** — which
66
+ is why `vk server` got no speedup from 0.21.0 at all ([#77]). It engages there exactly as it
67
+ does locally; it just stopped at the first hiccup and never started again.
68
+
69
+ Measured on a Pixel 3a, one `vk server` process, the same 12-command flow each time:
70
+
71
+ | | assert avg | tap avg | flow total |
72
+ |---|--:|--:|--:|
73
+ | companion healthy | 0.33s | 0.89s | **7.7s** |
74
+ | companion dead — **was** | 3.35s | 3.52s | 42.6s |
75
+ | companion healthy again, same server — **was** | 3.33s | 3.49s | 42.5s |
76
+ | either case — **now** | 0.33s | 0.89s | **7.9s** |
77
+
78
+ The third row is the bug: a separate process had restarted the companion and `vk companion
79
+ status` read `ready app held` throughout, while the server sat next to it on the 2.4s path.
80
+ A stand-down was latched for the life of the process and `dims` was cached, so
81
+ `ensureReady()` — the only path that restarts the companion from its device note — became
82
+ unreachable after the first successful read. That is invisible when every command is its own
83
+ process (a fresh one recovers: measured 3.03s then 0.28s) and permanent in a daemon.
84
+
85
+ Three routine things reached it, all now retried after a minute rather than never:
86
+ the companion's **own 15-minute idle shutdown**, which any idle CI server outlives; a
87
+ **calibration mismatch**, which `calibrate()` already documents as usually just the screen
88
+ moving between two dumps; and a **released connection**, which the stock fallback causes by
89
+ design. Only facts that cannot change while the process runs — the device note saying
90
+ `unsupported`, or no jar to push — still stand it down for good.
91
+
92
+ - **A stale `UiAutomation` connection no longer reports an empty screen forever.** After
93
+ `vk launch` force-stops and restarts an app, the companion could return "null root" for a
94
+ window that was plainly there — measured at 30s+ on a Pixel 3a while a stock `uiautomator
95
+ dump` read the same screen fine. Since a null root is deliberately *not* a stand-down (it is
96
+ normally the device mid-launch, and releasing the connection for it would drop every
97
+ `launch --clear` onto the slow path), nothing recovered it: every selector command burned its
98
+ full auto-wait and exited 1 on a readable screen. This one failed tests rather than slowing
99
+ them.
100
+
101
+ A run of null roots now escalates by duration — propagate, then recycle the connection once
102
+ (release + re-acquire, ~1.05s, the thing measured to clear it), then fall back to the stock
103
+ path if that did not help. Being slow always beats failing a selector on a screen that is
104
+ there. The escalation is per-process, so it recovers inside any command that auto-waits
105
+ (`tap`, `assert`, `text`, `find`, `wait`) and inside `vk server`; a bare single-shot `vk ui`
106
+ has only one read and cannot, so it still reports no window until the next such command.
107
+
108
+ - **`vk server` hands the `UiAutomation` connection back on shutdown.** The companion outlives
109
+ the process that started it, so Ctrl-C used to leave Appium, Layout Inspector and TalkBack
110
+ locked out on that host for up to the full 15-minute idle window — with no obvious cause, and
111
+ no way to stop it from a `--server` client (`vk companion` has no `--server` form).
112
+
113
+ ### Added
114
+ - **`vk server` says which read path it is using**, on startup (`[server] reads: companion
115
+ (ready app held)`) and as a `reads` field on `/v1/health`; a `--server` client echoes it once
116
+ at run start. Reads execute server-side, so this was the one end of the connection that knew
117
+ — and without it a companion that had silently stood down was indistinguishable from one that
118
+ never engaged, for a whole suite. Requested in [#77]; the field is optional, so an older
119
+ server simply omits it.
120
+ - **A `--server` suite index records the server's verikun version and read path** (`server: {
121
+ url, verikun, reads }` in `index.json`). It previously recorded only the client's version, so
122
+ a remote artifact could not say which verikun actually drove the device — the first thing you
123
+ need to explain a suite that got slower after a server upgrade.
124
+
125
+ [#77]: https://github.com/ddikman/verikun/issues/77
126
+
9
127
  ## [0.21.0] - 2026-08-13
10
128
 
11
129
  ### Added
package/README.md CHANGED
@@ -62,10 +62,10 @@ There is also no need for an MCP here: verikun runs locally with all its depende
62
62
  |---|---|
63
63
  | **Getting started** | [Installation](https://ddikman.github.io/verikun/getting-started/installation/) · [Your first test](https://ddikman.github.io/verikun/getting-started/your-first-test/) · [Using it from an AI agent](https://ddikman.github.io/verikun/getting-started/using-from-an-agent/) |
64
64
  | **Guides** | [Writing test cases](https://ddikman.github.io/verikun/guides/writing-test-cases/) · [Natural-language tests](https://ddikman.github.io/verikun/guides/natural-language-tests/) · [Suites](https://ddikman.github.io/verikun/guides/suites/) · [Remote devices & CI](https://ddikman.github.io/verikun/guides/remote-devices-and-ci/) · [iOS setup](https://ddikman.github.io/verikun/guides/ios-setup/) · [Platform support](https://ddikman.github.io/verikun/guides/platform-support/) · [Troubleshooting](https://ddikman.github.io/verikun/guides/troubleshooting/) |
65
- | **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
65
+ | **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Device claims](https://ddikman.github.io/verikun/reference/device-claims/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
66
66
  | **Internals** | [Architecture](https://ddikman.github.io/verikun/internals/architecture/) · [Core principles](https://ddikman.github.io/verikun/internals/core-principles/) · [Plan IR & the replay engine](https://ddikman.github.io/verikun/internals/plan-ir-and-engine/) · [Contracts](https://ddikman.github.io/verikun/internals/contracts/) · [Contributing](https://ddikman.github.io/verikun/internals/contributing/) |
67
67
 
68
- **Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error or ambiguous selector · `3` environment error. Data goes to stdout; diagnostics to stderr. [Full contract](https://ddikman.github.io/verikun/reference/exit-codes/).
68
+ **Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error, ambiguous selector, or a device another job is driving · `3` environment error. Data goes to stdout; diagnostics to stderr. [Full contract](https://ddikman.github.io/verikun/reference/exit-codes/). Parallel agents share a host-level [device claim](https://ddikman.github.io/verikun/reference/device-claims/) so two jobs do not silently land on the same phone.
69
69
 
70
70
  ## Feedback — help improve verikun
71
71
 
File without changes
package/dist/cli.js CHANGED
@@ -61,6 +61,7 @@ const exec_1 = require("./exec");
61
61
  const drivers_1 = require("./drivers");
62
62
  const adb_1 = require("./drivers/adb");
63
63
  const settings_1 = require("./device/settings");
64
+ const claims_1 = require("./device/claims");
64
65
  const selector_1 = require("./ui/selector");
65
66
  const state_support_1 = require("./ui/state-support");
66
67
  const format_1 = require("./ui/format");
@@ -404,6 +405,15 @@ function cmdDevices(ctx) {
404
405
  catch (e) {
405
406
  (0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
406
407
  }
408
+ // Who is driving what. Read-only: listing the pool must never claim a device, which is
409
+ // what makes `vk devices` (and `vk doctor`) safe to run while surveying a busy host.
410
+ if ((0, claims_1.claimsEnabled)()) {
411
+ for (const d of allDevices) {
412
+ const claim = (0, claims_1.summarize)(d.serial);
413
+ if (claim)
414
+ d.claim = claim;
415
+ }
416
+ }
407
417
  if ((0, args_1.flagBool)(ctx.flags, 'json')) {
408
418
  (0, output_1.json)(allDevices);
409
419
  return 0;
@@ -431,6 +441,10 @@ function formatDeviceTable(devices) {
431
441
  { header: 'STATE', get: (d) => d.state },
432
442
  { header: 'MODEL', get: (d) => d.model ?? '', optional: true },
433
443
  { header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
444
+ // Optional like the rest, which is load-bearing twice over: nothing changes for a
445
+ // single-user host where no device is ever claimed, and `VERIKUN_NO_CLAIM=1` renders
446
+ // exactly the table it always did.
447
+ { header: 'USED BY', get: (d) => d.claim?.by ?? '', optional: true },
434
448
  { header: 'NOTE', get: (d) => d.note ?? '', optional: true },
435
449
  ];
436
450
  // Drop optional columns that no device populates (e.g. NOTE for an Android-only list).
@@ -493,17 +507,39 @@ async function cmdDoctor(ctx) {
493
507
  return 3;
494
508
  const devices = ctx.driver.listDevices();
495
509
  const usable = devices.filter((d) => d.state === 'device');
510
+ const claims = (0, claims_1.claimsEnabled)();
511
+ // Read-only, like `vk devices`: doctor surveys the host, it never takes a device.
512
+ const heldByOther = (serial) => {
513
+ const c = claims ? (0, claims_1.summarize)(serial) : undefined;
514
+ return c && !c.mine ? c : undefined;
515
+ };
496
516
  (0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
497
- for (const d of devices)
498
- (0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}`);
517
+ for (const d of devices) {
518
+ const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
519
+ (0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}${claim ? ` [${claim.by}]` : ''}`);
520
+ }
499
521
  let ok = true;
500
- if (usable.length !== 1 && !ctx.device) {
501
- (0, output_1.err)(usable.length ? ' -> multiple devices: pass --device for interaction commands' : ' -> no usable device');
522
+ const free = usable.filter((d) => !heldByOther(d.serial));
523
+ if (!usable.length) {
524
+ (0, output_1.err)(' -> no usable device');
525
+ ok = false;
526
+ }
527
+ else if (!ctx.device && !free.length) {
528
+ // Every device is busy. Advisory-shaped but genuinely blocking, so it still fails:
529
+ // an interaction command run right now would exit 2, and doctor exists to say so first.
530
+ (0, output_1.err)(' -> every attached device is claimed by another job — `verikun devices` shows who');
502
531
  ok = false;
503
532
  }
504
- if (usable.length === 1 || ctx.device) {
533
+ else if (!ctx.device && !claims && usable.length > 1) {
534
+ (0, output_1.err)(' -> multiple devices: pass --device for interaction commands');
535
+ ok = false;
536
+ }
537
+ // Check the device an interaction command would actually land on: the one named, else
538
+ // the first FREE one (which is what auto-selection picks), not merely the first attached.
539
+ const target = ctx.device || free[0]?.serial;
540
+ if (target) {
505
541
  try {
506
- const serial = ctx.device || usable[0].serial;
542
+ const serial = target;
507
543
  const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
508
544
  const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
509
545
  const vals = keys.map(get);
@@ -979,7 +1015,7 @@ function cmdClear(ctx) {
979
1015
  // of applying dark mode and then dying — a half-applied device is worse than none.
980
1016
  // - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
981
1017
  // `device reset` can undo it even from a later process.
982
- const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps\n' +
1018
+ const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps | device release [serial]\n' +
983
1019
  `Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
984
1020
  function cmdDevice(ctx) {
985
1021
  const sub = (ctx.positionals[0] ?? '').toLowerCase();
@@ -994,10 +1030,43 @@ function cmdDevice(ctx) {
994
1030
  return deviceReset(ctx, rest);
995
1031
  case 'caps':
996
1032
  return deviceCaps(ctx);
1033
+ case 'release':
1034
+ return deviceRelease(ctx, rest);
997
1035
  default:
998
1036
  throw new errors_1.CliError((sub ? `Unknown 'device' subcommand '${sub}'.\n` : '') + DEVICE_USAGE, 2);
999
1037
  }
1000
1038
  }
1039
+ /**
1040
+ * Hand a device back to the pool. The break-glass named in every "in use by" refusal:
1041
+ * a crashed job's claim expires on its own, but waiting out the TTL is the one thing an
1042
+ * operator should never be forced to do just to get on with their run.
1043
+ *
1044
+ * Deliberately releases someone ELSE's claim too, naming them — you had to type the
1045
+ * serial, and refusing here would leave no way to recover a stuck device at all. Resolves
1046
+ * the device WITHOUT going through the driver, so it works even when the claim being
1047
+ * cleared is the very thing that would refuse the resolve.
1048
+ */
1049
+ function deviceRelease(ctx, args) {
1050
+ const serial = args[0] || ctx.device;
1051
+ if (!serial) {
1052
+ throw new errors_1.CliError('Usage: verikun device release <serial> (or pass --device / set VERIKUN_DEVICE)', 2);
1053
+ }
1054
+ if (!(0, claims_1.claimsEnabled)()) {
1055
+ (0, output_1.err)('device claims are disabled (VERIKUN_NO_CLAIM) — nothing to release');
1056
+ return 0;
1057
+ }
1058
+ const released = (0, claims_1.releaseClaim)(serial);
1059
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
1060
+ (0, output_1.json)({ serial, released: !!released, ...(released ? { was: released } : {}) });
1061
+ }
1062
+ else if (released) {
1063
+ (0, output_1.out)(`released ${serial} (was held by ${(0, claims_1.describeClaim)(released)})`);
1064
+ }
1065
+ else {
1066
+ (0, output_1.out)(`${serial} was not claimed`);
1067
+ }
1068
+ return 0;
1069
+ }
1001
1070
  /** Render a setting value for humans/JSON; a platform that cannot answer says so. */
1002
1071
  const showValue = (v) => v ?? 'n/a';
1003
1072
  function deviceSet(ctx, args) {
@@ -1197,6 +1266,10 @@ function cmdRun(positionals, flags, platform, device) {
1197
1266
  case 'stop':
1198
1267
  case 'discard': {
1199
1268
  const cleared = run_1.Recorder.clear();
1269
+ // The run is over, so the device goes back to the pool now rather than in TTL
1270
+ // minutes. `mineOnly` because a run can only ever hand back its own device.
1271
+ if (cleared?.device)
1272
+ (0, claims_1.releaseClaim)(cleared.device, { mineOnly: true });
1200
1273
  if (asJson)
1201
1274
  (0, output_1.json)({ cleared: cleared?.id ?? null });
1202
1275
  else
@@ -1226,6 +1299,9 @@ function cmdRun(positionals, flags, platform, device) {
1226
1299
  }
1227
1300
  }
1228
1301
  const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive(positionals[1], { noLogs, fetchLogs });
1302
+ // Archived means finished: release after log capture, which still needs the device.
1303
+ if (state.device)
1304
+ (0, claims_1.releaseClaim)(state.device, { mineOnly: true });
1229
1305
  const { passed, failed } = tally(state.steps);
1230
1306
  if (asJson) {
1231
1307
  (0, output_1.json)({
@@ -1374,6 +1450,9 @@ async function cmdBatch(positionals, batchFlags) {
1374
1450
  (0, output_1.err)('[verikun] batch: no commands to run');
1375
1451
  return 0;
1376
1452
  }
1453
+ // One process drives the whole batch, so its pid is exact evidence that the device is
1454
+ // still in use — a `kill -9` hands it straight back instead of parking it for the TTL.
1455
+ (0, claims_1.setProcessScoped)(true);
1377
1456
  // The finally is what makes `device set` safe to use in a batch: a line that fails
1378
1457
  // (or a ^C) still puts the device back, instead of leaving it offline or rotated.
1379
1458
  try {
@@ -1413,6 +1492,7 @@ async function cmdBatch(positionals, batchFlags) {
1413
1492
  /* the device may be exactly why we are unwinding — never mask the real error */
1414
1493
  }
1415
1494
  }
1495
+ (0, claims_1.releaseOwnClaims)();
1416
1496
  }
1417
1497
  }
1418
1498
  function parseAiOptions(flags) {
@@ -1603,6 +1683,12 @@ async function resolveBackend(platform, device, flags) {
1603
1683
  const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1604
1684
  runCtx = { platform: health.platform, device: health.serial };
1605
1685
  (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
1686
+ // Say the read path once, here. Reads execute server-side, so this is the only end of the
1687
+ // connection that knows it — and without it a companion that had silently stood down was
1688
+ // indistinguishable from one that never engaged, for a whole suite (issue #77). An older
1689
+ // server omits the field; saying nothing is better than guessing.
1690
+ if (health.reads)
1691
+ (0, output_1.err)(`[verikun] server reads: ${health.reads.path} (${health.reads.detail})`);
1606
1692
  const remote = (0, remote_1.createRemoteBackend)(opts, health);
1607
1693
  return {
1608
1694
  backend: {
@@ -1630,7 +1716,7 @@ async function resolveBackend(platform, device, flags) {
1630
1716
  },
1631
1717
  platform: health.platform,
1632
1718
  device: health.serial,
1633
- remote: { url: server, version: health.version },
1719
+ remote: { url: server, version: health.version, reads: health.reads },
1634
1720
  };
1635
1721
  }
1636
1722
  /**
@@ -1849,6 +1935,7 @@ async function cmdAi(positionals, flags) {
1849
1935
  throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--server url] [--show-plan] [--recompile]', 2);
1850
1936
  }
1851
1937
  const opts = parseAiOptions(flags);
1938
+ (0, claims_1.setProcessScoped)(true); // one process for the whole run — see claims.ts's isLive
1852
1939
  // --show-plan: compile (or cache-hit) and print the IR — no device, no backend.
1853
1940
  if ((0, args_1.flagBool)(flags, 'show-plan')) {
1854
1941
  const nl = readAiTest(file);
@@ -1869,6 +1956,7 @@ async function cmdAi(positionals, flags) {
1869
1956
  // otherwise an unattended run leaves the phone offline or in dark mode.
1870
1957
  await restoreDeviceOverrides(backend);
1871
1958
  await backend.close?.(); // frees a remote server's device lock for the next command
1959
+ (0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
1872
1960
  }
1873
1961
  if ((0, args_1.flagBool)(flags, 'json')) {
1874
1962
  (0, output_1.json)({
@@ -1928,13 +2016,14 @@ async function cmdSuiteEntry(positionals, flags) {
1928
2016
  if (!dirArg)
1929
2017
  throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--retries n] [--json]', 2);
1930
2018
  const opts = parseAiOptions(flags);
2019
+ (0, claims_1.setProcessScoped)(true); // one process for the whole suite — see claims.ts's isLive
1931
2020
  // Pre-flight the provider BEFORE touching any device/server: every test needs it
1932
2021
  // to compile (on a cache miss) or to repair at runtime.
1933
2022
  if (!providerAvailable(opts.model)) {
1934
2023
  throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
1935
2024
  }
1936
2025
  const reqPlatform = platformFromFlags(flags);
1937
- const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
2026
+ const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
1938
2027
  const app = (0, args_1.flagStr)(flags, 'app');
1939
2028
  if (app)
1940
2029
  assertSafeAppId(app);
@@ -1942,6 +2031,9 @@ async function cmdSuiteEntry(positionals, flags) {
1942
2031
  return await (0, suite_1.cmdSuite)(dirArg, flags, {
1943
2032
  platform,
1944
2033
  device,
2034
+ ...(remote
2035
+ ? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
2036
+ : {}),
1945
2037
  runTest: (file) => runAiTest(file, opts, backend, platform, device),
1946
2038
  // Reset app state between tests only when the app id is known; without --app,
1947
2039
  // each test is responsible for its own isolation (e.g. `launch --clear`).
@@ -1952,6 +2044,7 @@ async function cmdSuiteEntry(positionals, flags) {
1952
2044
  finally {
1953
2045
  await restoreDeviceOverrides(backend);
1954
2046
  await backend.close?.();
2047
+ (0, claims_1.releaseOwnClaims)();
1955
2048
  }
1956
2049
  }
1957
2050
  // ---------------------------------------------------------------------------
@@ -2104,6 +2197,12 @@ async function executeOutcome(command, positionals, flags, sharedDriver) {
2104
2197
  catch {
2105
2198
  /* surfaced by the command handler below */
2106
2199
  }
2200
+ // Keep this job's device claim alive. Deliberately NOT gated on the recorder:
2201
+ // `Recorder.beginStep` returns null under VERIKUN_NO_RUN=1 (which the e2e suite
2202
+ // sets), and hanging the heartbeat off it would silently stop claims from being
2203
+ // refreshed for exactly the runs that exercise them.
2204
+ if (serial)
2205
+ (0, claims_1.touchClaim)(serial, platform);
2107
2206
  recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
2108
2207
  }
2109
2208
  }
@@ -2144,6 +2243,10 @@ async function executeForServer(command, positionals, flags, driver, platform) {
2144
2243
  catch {
2145
2244
  /* surfaced by the command handler below */
2146
2245
  }
2246
+ // A server holds one device for its whole life, but its claim still has to look alive
2247
+ // to everyone else on the host — refresh it per request, the same as a local step.
2248
+ if (serial)
2249
+ (0, claims_1.touchClaim)(serial, platform);
2147
2250
  // Sample the device clock up front so the caller's run can set logStart (the
2148
2251
  // ephemeral recorder never persists RunState). Best-effort — empty/unavailable
2149
2252
  // just means archive / vk log fall back to last-N.
@@ -2281,6 +2384,9 @@ DEVICE STATE (change the device the app runs on, then put it back)
2281
2384
  equivalent where it doesn't
2282
2385
  Refuses \`airplane=on\` over wireless adb (it would cut
2283
2386
  this very connection); --allow-wireless overrides.
2387
+ device release [serial] [--json] Hand a claimed device back to the pool. Claims
2388
+ expire on their own; this is for when you don't
2389
+ want to wait. See \`devices\` for who holds what.
2284
2390
 
2285
2391
  BATCH (script many commands in one process)
2286
2392
  batch [--file path] [--quiet] Run newline-separated commands — from --file,
@@ -2342,7 +2448,8 @@ SERVER (expose a locally-connected device to remote verikun clients)
2342
2448
  VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
2343
2449
 
2344
2450
  ENVIRONMENT
2345
- devices [--json] List attached devices/simulators
2451
+ devices [--json] List attached devices/simulators, and which job is
2452
+ already driving each (USED BY)
2346
2453
  doctor [--fix] Diagnose adb/device, and warn if this CLI or the
2347
2454
  Claude Code plugin is out of date (a warning only —
2348
2455
  it never changes the exit code). --fix disables
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ /**
3
+ * Does the companion's dump agree with the platform's own?
4
+ *
5
+ * This is the standard `calibrate()` holds the companion to before trusting a single one of
6
+ * its reads. It used to be plain string equality, and that is still the fast path — but
7
+ * equality turned out to be stricter than the property it was standing in for, and strictly
8
+ * enough to reject a companion that was right.
9
+ *
10
+ * The property that actually matters is TAP SAFETY: verikun taps the centre of a node the
11
+ * dump reported, so every node the companion serves must be one the platform would have
12
+ * served, with the same bounds. Byte equality implies that. It also demands something extra
13
+ * and unrelated — that the companion reproduce every node the platform emitted, including
14
+ * decor nobody can touch — and that is what broke.
15
+ *
16
+ * MEASURED (issue #79). Asking the accessibility layer for window information is what stops
17
+ * a long-lived connection serving a stale window, but it also makes Android clamp node
18
+ * bounds to the app window rather than the display. On a Pixel 6 emulator (Android 14) that
19
+ * drops exactly one node from the dump, `android:id/navigationBarBackground` at
20
+ * [0,2274][1080,2400] — not clickable, not focusable, and below every pixel the app owns.
21
+ * Every other node, root included, stayed byte-identical. Equality still said no, so the
22
+ * companion was benched and reads went from ~165ms to ~33s.
23
+ *
24
+ * So the rule is a subsequence with two guards:
25
+ *
26
+ * 1. every companion node is byte-identical to a platform node, in document order — this
27
+ * is the tap-safety property, and it is not weakened at all;
28
+ * 2. every platform node the companion lacks is a LEAF and is neither clickable nor
29
+ * focusable — so the companion can never quietly drop something tappable, nor a
30
+ * container whose absence would re-nest the nodes under it.
31
+ *
32
+ * Guard 2 is deliberately stricter than the measurement requires: an unmatched container
33
+ * would still leave its children matchable, but the shapes that produces have not been seen
34
+ * on a real device, and the cost of refusing one is a slow read rather than a wrong tap.
35
+ *
36
+ * What this does NOT still guarantee is that a non-interactive leaf's bounds agree — such a
37
+ * node can differ and be treated as missing. That is the deliberate give: it cannot move a
38
+ * tap, because nothing reads it.
39
+ */
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.dumpsAgree = dumpsAgree;
42
+ const NODE_TAG = /<node\b[^>]*?\/?>/g;
43
+ // A leading \s so `long-clickable="true"` cannot be read as `clickable="true"`.
44
+ const CLICKABLE = /\sclickable="true"/;
45
+ const FOCUSABLE = /\sfocusable="true"/;
46
+ function nodes(xml) {
47
+ const out = [];
48
+ for (const m of xml.matchAll(NODE_TAG)) {
49
+ const raw = m[0];
50
+ out.push({
51
+ raw,
52
+ leaf: raw.endsWith('/>'),
53
+ interactive: CLICKABLE.test(raw) || FOCUSABLE.test(raw),
54
+ });
55
+ }
56
+ return out;
57
+ }
58
+ /** The `<hierarchy …>` element itself — its `rotation` is part of the contract. */
59
+ function header(xml) {
60
+ return /<hierarchy\b[^>]*>/.exec(xml)?.[0] ?? '';
61
+ }
62
+ function dumpsAgree(stock, companion) {
63
+ const a = stock.trim();
64
+ const b = companion.trim();
65
+ if (a === b)
66
+ return { agree: true };
67
+ if (header(a) !== header(b)) {
68
+ return { agree: false, reason: 'the hierarchy header differs (rotation changed mid-capture?)' };
69
+ }
70
+ const stockNodes = nodes(a);
71
+ const compNodes = nodes(b);
72
+ let i = 0; // stock
73
+ for (const want of compNodes) {
74
+ // Skip platform nodes the companion did not emit, proving each is droppable as we go.
75
+ while (i < stockNodes.length && stockNodes[i].raw !== want.raw) {
76
+ const skipped = stockNodes[i];
77
+ if (skipped.interactive) {
78
+ return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
79
+ }
80
+ if (!skipped.leaf) {
81
+ return { agree: false, reason: 'the companion is missing a container the platform reports' };
82
+ }
83
+ i++;
84
+ }
85
+ if (i === stockNodes.length) {
86
+ return { agree: false, reason: 'the companion reports a node the platform does not' };
87
+ }
88
+ i++; // consume the match
89
+ }
90
+ // Anything left over in the platform's dump must clear the same bar.
91
+ for (; i < stockNodes.length; i++) {
92
+ if (stockNodes[i].interactive) {
93
+ return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
94
+ }
95
+ if (!stockNodes[i].leaf) {
96
+ return { agree: false, reason: 'the companion is missing a container the platform reports' };
97
+ }
98
+ }
99
+ return { agree: true };
100
+ }