verikun 0.20.0 → 0.21.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.
@@ -251,10 +251,37 @@ outweigh dozens of `vk ui` calls. When you do, `vk` already downscales the PNG
251
251
  **Two uses of a screenshot — keep them apart.** The cost above is about *reading a
252
252
  screenshot back into context* to decide your next move; that is what to avoid (perceive
253
253
  and verify with the hierarchy instead). A screenshot taken purely as **report evidence
254
- and never read back** costs nothing at runtime. So when you drive a flow to produce a
255
- report, **do** `vk screenshot` around each significant transition (and before a risky or
256
- verification step) — then leave it in the report, don't read the PNG back. A visual trail
257
- makes post-run review far easier, and a failing step already auto-captures its own screen.
254
+ and never read back** costs no tokens. So when you drive a flow to produce a report, **do**
255
+ `vk screenshot` around each significant transition (and before a risky or verification step)
256
+ — then leave it in the report, don't read the PNG back. A visual trail makes post-run review
257
+ far easier, and a failing step already auto-captures its own screen.
258
+
259
+ It is not free in wall clock, though: a capture is ~1.1s on a physical Android phone. That is
260
+ cheap next to a hierarchy read (~2.4s — see below) but it is not zero, so screenshot the
261
+ transitions worth reviewing rather than every step.
262
+
263
+ **Most of a run's time is reading the UI hierarchy.** Every selector command (`tap`, `text`,
264
+ `find`, `assert`, `swipe --on`) costs one read; measured on a physical mid-range Android
265
+ phone, one read is ~2.4s, nearly all of it fixed per-invocation cost inside `uiautomator`
266
+ that does not depend on how complex the screen is. iOS is ~10x cheaper. So prefer one
267
+ `vk assert` over a `vk ui` you have to scan, batch a known flow with `vk batch` rather than
268
+ re-checking between every step, and don't add a redundant `vk ui` just to confirm what an
269
+ `assert` already proved.
270
+
271
+ On Android this is handled for you: verikun keeps an accessibility connection alive on the
272
+ device (the *companion*), which cuts a read to ~0.2s. The first read on a device costs ~5.8s
273
+ to set it up, then every read after is fast. Nothing to enable.
274
+
275
+ It holds the device's single `UiAutomation` connection while it runs, so Appium and Layout
276
+ Inspector cannot attach. If the user needs those, tell them `VERIKUN_COMPANION=0` or
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, 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.
258
285
 
259
286
  **Remember identifiers across runs.** After a flow succeeds, save the selectors
260
287
  you found to memory — the mapping from human intent to selector, plus the screen
package/CHANGELOG.md CHANGED
@@ -6,7 +6,156 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.21.1] - 2026-08-13
10
+
11
+ ### Fixed
12
+ - **`npm link` produced a `vk` that died with "permission denied".** `tsc` writes
13
+ `dist/bin/verikun.js` as 0644 and nothing set the executable bit, so the global symlink
14
+ `npm link` creates pointed straight at a non-executable file. It never showed up for
15
+ `npm install -g verikun`, because npm chmods `bin` targets itself while unpacking a
16
+ tarball — only a source checkout was affected, which is to say only contributors, on a
17
+ fresh clone, with an error that reads like a broken install rather than a missing mode bit.
18
+
19
+ A `postbuild` step (`scripts/chmod-bin.mjs`) now chmods every path named in `package.json`'s
20
+ `bin`, reading them from there so a renamed entry point cannot silently be left behind. It
21
+ is silent on success on purpose: `npm pack --json` runs the `prepare` hook and then expects
22
+ its own JSON on stdout, so a chatty build step is parsed as part of the pack result and
23
+ takes the release gate down with it.
24
+
25
+ `scripts/check-package-contents.mjs` now also asserts the packed tarball's `bin` is
26
+ executable, not merely present — the mode is exactly the kind of thing that regresses
27
+ without anyone noticing, since `npm install -g` keeps working either way.
28
+
29
+ (Shipping in this release rather than one of its own — it landed on `main` unreleased.)
30
+
31
+ - **The companion no longer switches itself off for the rest of a long-lived process** — which
32
+ is why `vk server` got no speedup from 0.21.0 at all ([#77]). It engages there exactly as it
33
+ does locally; it just stopped at the first hiccup and never started again.
34
+
35
+ Measured on a Pixel 3a, one `vk server` process, the same 12-command flow each time:
36
+
37
+ | | assert avg | tap avg | flow total |
38
+ |---|--:|--:|--:|
39
+ | companion healthy | 0.33s | 0.89s | **7.7s** |
40
+ | companion dead — **was** | 3.35s | 3.52s | 42.6s |
41
+ | companion healthy again, same server — **was** | 3.33s | 3.49s | 42.5s |
42
+ | either case — **now** | 0.33s | 0.89s | **7.9s** |
43
+
44
+ The third row is the bug: a separate process had restarted the companion and `vk companion
45
+ status` read `ready app held` throughout, while the server sat next to it on the 2.4s path.
46
+ A stand-down was latched for the life of the process and `dims` was cached, so
47
+ `ensureReady()` — the only path that restarts the companion from its device note — became
48
+ unreachable after the first successful read. That is invisible when every command is its own
49
+ process (a fresh one recovers: measured 3.03s then 0.28s) and permanent in a daemon.
50
+
51
+ Three routine things reached it, all now retried after a minute rather than never:
52
+ the companion's **own 15-minute idle shutdown**, which any idle CI server outlives; a
53
+ **calibration mismatch**, which `calibrate()` already documents as usually just the screen
54
+ moving between two dumps; and a **released connection**, which the stock fallback causes by
55
+ design. Only facts that cannot change while the process runs — the device note saying
56
+ `unsupported`, or no jar to push — still stand it down for good.
57
+
58
+ - **A stale `UiAutomation` connection no longer reports an empty screen forever.** After
59
+ `vk launch` force-stops and restarts an app, the companion could return "null root" for a
60
+ window that was plainly there — measured at 30s+ on a Pixel 3a while a stock `uiautomator
61
+ dump` read the same screen fine. Since a null root is deliberately *not* a stand-down (it is
62
+ normally the device mid-launch, and releasing the connection for it would drop every
63
+ `launch --clear` onto the slow path), nothing recovered it: every selector command burned its
64
+ full auto-wait and exited 1 on a readable screen. This one failed tests rather than slowing
65
+ them.
66
+
67
+ A run of null roots now escalates by duration — propagate, then recycle the connection once
68
+ (release + re-acquire, ~1.05s, the thing measured to clear it), then fall back to the stock
69
+ path if that did not help. Being slow always beats failing a selector on a screen that is
70
+ there. The escalation is per-process, so it recovers inside any command that auto-waits
71
+ (`tap`, `assert`, `text`, `find`, `wait`) and inside `vk server`; a bare single-shot `vk ui`
72
+ has only one read and cannot, so it still reports no window until the next such command.
73
+
74
+ - **`vk server` hands the `UiAutomation` connection back on shutdown.** The companion outlives
75
+ the process that started it, so Ctrl-C used to leave Appium, Layout Inspector and TalkBack
76
+ locked out on that host for up to the full 15-minute idle window — with no obvious cause, and
77
+ no way to stop it from a `--server` client (`vk companion` has no `--server` form).
78
+
9
79
  ### Added
80
+ - **`vk server` says which read path it is using**, on startup (`[server] reads: companion
81
+ (ready app held)`) and as a `reads` field on `/v1/health`; a `--server` client echoes it once
82
+ at run start. Reads execute server-side, so this was the one end of the connection that knew
83
+ — and without it a companion that had silently stood down was indistinguishable from one that
84
+ never engaged, for a whole suite. Requested in [#77]; the field is optional, so an older
85
+ server simply omits it.
86
+ - **A `--server` suite index records the server's verikun version and read path** (`server: {
87
+ url, verikun, reads }` in `index.json`). It previously recorded only the client's version, so
88
+ a remote artifact could not say which verikun actually drove the device — the first thing you
89
+ need to explain a suite that got slower after a server upgrade.
90
+
91
+ [#77]: https://github.com/ddikman/verikun/issues/77
92
+
93
+ ## [0.21.0] - 2026-08-13
94
+
95
+ ### Added
96
+ - **An on-device companion that makes an Android UI-hierarchy read ~12x faster** — `vk ui`
97
+ goes from **2.44s to 0.18s** end to end on a physical SM-A415F, with **identical** output.
98
+ Reading the hierarchy is the dominant cost of any suite, so this moves the whole runtime,
99
+ not a rounding error. Every selector command benefits, not just `vk ui`: `find` 12.3x,
100
+ `assert` 12.0x, `wait` 11.5x, `tap` 8.1x, `text` 3.6x (the remainder is `adb shell input`,
101
+ which does not go through the companion yet).
102
+
103
+ **On by default; `VERIKUN_COMPANION=0` opts out.** It shipped opt-in first and that was
104
+ wrong — nobody discovers an environment variable they were never told about, and the
105
+ people who most need the speedup are the least likely to go looking for it.
106
+
107
+ It also makes auto-wait honour its window: a 5s wait for an element that never appears takes
108
+ ~6.4s rather than ~10.8s, because the stock path's final 2.4s read starts just before the
109
+ deadline and overshoots.
110
+
111
+ The first read on a device costs ~6s to push, start and calibrate. The verdict is then
112
+ remembered **on the device** (`/data/local/tmp/verikun-companion.note`, keyed by verikun
113
+ version), so a restart after the 15-minute idle shutdown reuses it and costs ~2.1s. The
114
+ same note records a device the companion could not start on at all, so that phone falls
115
+ straight through to the stock read instead of paying a doomed startup on every command —
116
+ which is what makes being on by default safe rather than merely fast.
117
+
118
+ Profiling `adb shell uiautomator dump` showed only ~0.10s of its ~2.4s is work. The rest is
119
+ paid fresh every call: ~1.22s starting ART and loading `uiautomator.jar`, and ~1.00s in
120
+ `waitForIdle(1000, 10000)`. That second one is not a flat sleep — it waits for the
121
+ accessibility event stream to have been *quiet* for a second, and a freshly connected
122
+ bridge has no history of quiet, so it must observe one. A long-lived connection already
123
+ does. The companion therefore keeps the full idle semantics and still returns in
124
+ milliseconds; it is not trading safety for speed.
125
+
126
+ It is Java compiled to dex (4KB), pushed to `/data/local/tmp` and run by the phone's own
127
+ runtime via `app_process` — scrcpy's approach. **Nothing is installed**: no APK, no root,
128
+ package list untouched. It shuts itself down after 15 minutes idle. It does **not** cache
129
+ the hierarchy — every read walks the live tree; only the *connection* is kept, so the
130
+ "re-capture fresh every command" rule is intact. It borrows the platform's own serialiser,
131
+ so its XML is byte-identical to `uiautomator dump`'s.
132
+
133
+ **A device has exactly one `UiAutomation` connection and the companion holds it** — while
134
+ it runs, `uiautomator dump` is SIGKILLed, and Appium, Layout Inspector and TalkBack cannot
135
+ attach. That is the one real cost, and the reason for the opt-out: `VERIKUN_COMPANION=0`,
136
+ or `vk companion stop` to hand it back once. `vk companion status` reports it.
137
+
138
+ It cannot fail a test. Every failure route releases the connection *before* falling back,
139
+ because the stock path is not merely slower while the companion holds it — it is
140
+ unavailable. A dead companion frees the connection by dying; one that cannot be asked
141
+ nicely is killed. A fallback read costs ~3.4s against 2.4s if the companion had never
142
+ existed.
143
+
144
+ Before trusting it, verikun takes **one** real `uiautomator dump` and checks the companion
145
+ reproduces it byte for byte, then remembers the answer on the device. The dumper clips node
146
+ bounds to a display size, and **which size the platform uses changed between Android
147
+ versions** — measured on three devices: a Samsung SM-A415F and a Google Pixel 3a, both on
148
+ Android 12, clip to the app window; a Pixel 6 emulator on Android 14 clips to the physical
149
+ display. The boundary is in AOSP itself — `DumpCommand` reads `getSize()` on the
150
+ `android12-release` and `android13-release` branches and `getRealSize()` from
151
+ `android14-release` onward — so it is the platform version, not the vendor, and a
152
+ hard-coded choice is wrong on one side of it whichever side you pick. The gap (44–254px)
153
+ would not fail loudly: it would shift every element near the bottom of the screen and land
154
+ taps elsewhere while still reporting success. If neither candidate
155
+ matches, the companion is declined and the stock path is used.
156
+
157
+ New: `vk companion <status|stop>`, `VERIKUN_COMPANION`, `tools/` for on-device programs,
158
+ and a [companion guide](https://ddikman.github.io/verikun/guides/companion/).
10
159
  - **A `Self-healing in CI` docs page, and a plan-cache step in the reference workflow.** The
11
160
  question it answers came from a team running verikun in CI: *should it be allowed to heal a
12
161
  drifted step, or should that just fail the build?* The answer was spread across five pages,
@@ -44,7 +193,85 @@ All notable changes to this project are documented here. The format is based on
44
193
 
45
194
  No version bump: no CLI behaviour changed.
46
195
 
196
+ ### Changed
197
+ - **Android screenshots are about twice as fast**, and byte-for-byte identical. `screencap -p`
198
+ makes the *phone* PNG-encode a full-resolution image, which we then immediately decode and
199
+ shrink — so the encode was pure waste. Captures now come off the device as raw pixels
200
+ (`screencap`, no `-p`) and are encoded here, at the size we actually keep. Measured on a
201
+ physical SM-A415F: **`vk screenshot` 2.60s → 1.12s**, and `--full` 2.60s → 1.19s. The larger
202
+ transfer is not the problem it looks like — 10MB of RGBA box-downscales in ~10ms and deflates
203
+ in ~110ms on the host, against ~1.4s of on-device encoding avoided. Verified on-device: the
204
+ PNG the old and new paths produce is identical byte-for-byte.
205
+
206
+ This is a new **optional** `Driver.screenshotRaw()`, and `null` is a first-class answer: a
207
+ backend without a raw path (iOS — `simctl` is already ~0.2s, so there is nothing to win) or a
208
+ capture whose header we do not recognise falls back to `screenshot()`. Failure-evidence
209
+ captures take the same route and stay full-resolution. An OEM laying the framebuffer out
210
+ differently gets the old speed, never a wrong image.
211
+
47
212
  ### Fixed
213
+ - **Several `vk` processes starting on one device at once could fail outright.** Calibration
214
+ works by *releasing* the device's single `UiAutomation` connection to take a real
215
+ `uiautomator dump` — so two processes calibrating at once SIGKILL each other's dump.
216
+ MEASURED: five concurrent first-ever reads on one device gave exit codes `[3,3,0,3,3]`,
217
+ each reporting `Killed`.
218
+
219
+ Calibration is now claimed, and the claim is granted by the **companion itself**, whose
220
+ single-threaded accept loop makes it genuinely atomic. A process that does not get the
221
+ claim waits for the verdict and never touches the connection meanwhile — waiting processes
222
+ used to "helpfully" acquire it during the window the holder had deliberately released it,
223
+ killing the very dump they were waiting for. A claim from a process that then dies goes
224
+ stale after 45s, so nothing can wedge a device permanently.
225
+
226
+ The obvious host-side lock does **not** work and is worth recording: Android's toybox
227
+ `mkdir` **succeeds on an existing directory** (exit `0`, unlike POSIX), so a `mkdir` mutex
228
+ silently grants itself to every caller. Five concurrent attempts all reported success.
229
+ After the fix, five concurrent first-ever reads are `[0,0,0,0,0]`.
230
+
231
+ - **A transient or local problem could permanently mark a device as unable to run the
232
+ companion.** That verdict is sticky — every later command on that phone would skip to the
233
+ 2.4s path — and two things could write it wrongly: a checkout that had not built the jar
234
+ (a fault of *this* working copy, not of the device), and a startup collision (transient,
235
+ and usually leaving a perfectly good companion running). It is now only recorded when the
236
+ jar exists *and* a second look still finds nothing. `start()` also re-probes before
237
+ stopping anything, so two racing processes can no longer kill each other's companion.
238
+
239
+ - **A command with a 120-second wait budget could abort after 20, because the app had not
240
+ drawn yet.** `launch` force-stops the app before starting it (and `--clear` wipes its data
241
+ too), leaving a gap of a second or two with no window at all. The platform correctly
242
+ reports a null root for that gap, and the capture layer escalated it to a fatal environment
243
+ error (exit `3`) after three attempts — throwing away a budget the caller had explicitly
244
+ asked for. Measured before the fix: `wait --timeout 120000` aborted at ~20s on roughly half
245
+ of the runs, with ~100 seconds unspent.
246
+
247
+ A null root is now `NoWindowError`: an observation about the screen, not a broken machine.
248
+ Every polling caller — `wait`, `find`, `assert`, `tap`, `text`, and the `vk ai` engine's
249
+ guards — treats it as "nothing on screen yet" and keeps polling to its own deadline. Every
250
+ *other* capture failure still surfaces immediately, because a missing adb or a wedged
251
+ dumper is a machine to fix and polling it for two minutes helps nobody. A caller with no
252
+ wait budget (a bare `vk ui`) still gets exit `3`, unchanged.
253
+
254
+ The companion made this visible rather than causing it: with the stock dump's 2.4s latency
255
+ the three attempts spanned 7-14s and usually outlasted the gap by accident, so the bug was
256
+ being masked by being slow. It also no longer stands the companion *down* for a null root —
257
+ that is the device's state, not a companion fault, and releasing the connection dropped the
258
+ whole process onto the slow path for the rest of its life. After the fix: 0/5 aborts, and
259
+ the companion stays connected across `launch --clear`.
260
+
261
+ - **A failed `uiautomator dump` could silently return the PREVIOUS screen.** `dumpXml` ran
262
+ `uiautomator dump <path>` and then `cat <path>`, accepting anything containing
263
+ `<hierarchy>` — but it never checked that *this* dump had written the file. The dump writes
264
+ to a fixed path and leaves the last successful result there whenever it fails, and it fails
265
+ without saying so in its exit code: AOSP's `DumpCommand` prints `ERROR: could not get idle
266
+ state` and returns normally when `waitForIdle` times out on an animating screen. The stale
267
+ XML is perfectly well-formed, so every check passed and the caller resolved selectors —
268
+ and tapped coordinates — from a screen that could be minutes old. Measured: a dump killed
269
+ at 18:46 happily served the 18:44 hierarchy.
270
+
271
+ The file is now removed in the same device shell immediately before the dump, so `cat` can
272
+ only succeed if this dump produced it; a genuinely failed capture now fails loudly (exit
273
+ `3`) instead of quietly lying. Pre-existing — the companion below made it easy to hit, and
274
+ is how it was found.
48
275
  - **The release gate stopped understanding `npm pack --json`, which blocked the 0.20.0
49
276
  publish.** `scripts/check-package-contents.mjs` read the pack result as
50
277
  `JSON.parse(raw)[0]`, but **npm 12 returns an object keyed by package name** where npm ≤ 11
File without changes
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ // Screen capture: the one place that decides HOW a screenshot is obtained.
3
+ //
4
+ // Sits between the driver (device I/O) and image.ts (pure image math) because it
5
+ // needs both, and lives apart from cli.ts so run.ts's failure-evidence capture can
6
+ // share it without an import cycle.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.capturePng = capturePng;
9
+ const image_1 = require("./image");
10
+ /**
11
+ * Capture the screen as a PNG whose longest edge is at most `maxEdge` px
12
+ * (`null` = full size). `buf` is always the image to write.
13
+ *
14
+ * Prefers the driver's raw path — pixels straight off the device, PNG-encoded here —
15
+ * which avoids an on-device encode of an image we are about to shrink anyway, and is
16
+ * roughly 2x faster end to end on Android. A backend without a raw path, or one whose
17
+ * capture came back in a shape we do not recognize, falls back to `screenshot()`, so
18
+ * the only observable difference is how long it took.
19
+ */
20
+ function capturePng(driver, maxEdge) {
21
+ const raw = driver.screenshotRaw?.() ?? null;
22
+ if (raw)
23
+ return (0, image_1.pngFromRaw)(raw, maxEdge);
24
+ const png = driver.screenshot();
25
+ if (maxEdge === null) {
26
+ return { buf: png, width: 0, height: 0, scaled: false, origWidth: 0, origHeight: 0, reason: 'full size requested' };
27
+ }
28
+ return (0, image_1.downscalePng)(png, maxEdge);
29
+ }
package/dist/cli.js CHANGED
@@ -67,7 +67,8 @@ const format_1 = require("./ui/format");
67
67
  const viewport_1 = require("./ui/viewport");
68
68
  const output_1 = require("./output");
69
69
  const run_1 = require("./run");
70
- const image_1 = require("./image");
70
+ const capture_1 = require("./capture");
71
+ const manager_1 = require("./companion/manager");
71
72
  const engine_1 = require("./agent/engine");
72
73
  const lint_1 = require("./agent/lint");
73
74
  const claude_1 = require("./agent/claude");
@@ -182,6 +183,29 @@ function pollStep(flags, deadline) {
182
183
  const interval = (0, args_1.flagNum)(flags, 'interval') ?? DEFAULT_POLL_MS;
183
184
  return Math.min(interval, Math.max(0, deadline - Date.now()));
184
185
  }
186
+ /**
187
+ * Read the hierarchy for a caller that is polling, treating "no window yet" as "nothing on
188
+ * screen yet" rather than a fatal environment error.
189
+ *
190
+ * A `NoWindowError` means the device genuinely had nothing to show — `launch --clear` and
191
+ * `launch` both leave a gap where the app has been stopped and has not drawn. That clears in
192
+ * a second or two, so a caller that has a wait budget should keep polling; escalating to
193
+ * exit 3 throws away the budget it was explicitly given. MEASURED: a `wait --timeout 120000`
194
+ * used to abort at ~20s with 100 seconds unspent.
195
+ *
196
+ * Every OTHER capture failure still propagates untouched — a missing adb, an unauthorised
197
+ * device or a wedged dumper is a machine to fix, and polling it for two minutes helps nobody.
198
+ */
199
+ function readForPoll(ctx, opts = {}) {
200
+ try {
201
+ return ctx.driver.getElements(opts);
202
+ }
203
+ catch (e) {
204
+ if (e instanceof errors_1.NoWindowError)
205
+ return [];
206
+ throw e;
207
+ }
208
+ }
185
209
  /**
186
210
  * matchElements with auto-wait: re-capture + re-match until at least one element
187
211
  * matches or the window elapses. Returns the final result either way (empty on miss).
@@ -189,7 +213,7 @@ function pollStep(flags, deadline) {
189
213
  async function matchWaiting(ctx, sel, opts = {}) {
190
214
  const deadline = Date.now() + waitWindowMs(ctx.flags);
191
215
  for (;;) {
192
- const res = (0, selector_1.matchElements)(ctx.driver.getElements(opts), sel);
216
+ const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
193
217
  if (res.matches.length > 0 || Date.now() >= deadline)
194
218
  return res;
195
219
  await sleep(pollStep(ctx.flags, deadline));
@@ -205,7 +229,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
205
229
  const start = Date.now();
206
230
  const deadline = start + windowMs;
207
231
  for (;;) {
208
- const els = ctx.driver.getElements(opts);
232
+ const els = readForPoll(ctx, opts);
209
233
  if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
210
234
  const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
211
235
  // The snapshot rides along: scroll-into-view needs the scrollable containers
@@ -731,32 +755,31 @@ function assertSafeAppId(appId) {
731
755
  return appId;
732
756
  }
733
757
  function cmdScreenshot(ctx) {
734
- const raw = ctx.driver.screenshot();
735
758
  // Precedence: --full (original) > --max <px> (explicit) > --more (preset) > default.
736
759
  const maxEdge = (0, args_1.flagNum)(ctx.flags, 'max') ?? ((0, args_1.flagBool)(ctx.flags, 'more') ? MORE_SHOT_MAX_EDGE : shotMaxEdge());
737
- const res = (0, args_1.flagBool)(ctx.flags, 'full') ? null : (0, image_1.downscalePng)(raw, maxEdge);
738
- const buf = res?.scaled ? res.buf : raw;
760
+ const res = (0, capture_1.capturePng)(ctx.driver, (0, args_1.flagBool)(ctx.flags, 'full') ? null : maxEdge);
761
+ const buf = res.buf;
739
762
  const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
740
763
  const path = outFlag ? confineToCwd(outFlag) : (0, output_1.defaultScreenshotPath)();
741
764
  (0, node_fs_1.writeFileSync)(path, buf);
742
765
  ctx.record?.attachImage(buf);
743
- ctx.record?.note({ message: res?.scaled ? `${path} (${res.width}×${res.height})` : path });
766
+ ctx.record?.note({ message: res.scaled ? `${path} (${res.width}×${res.height})` : path });
744
767
  // Surface the one case worth knowing about: we wanted to shrink but couldn't.
745
- if (res && !res.scaled && res.reason?.startsWith('unsupported')) {
768
+ if (!res.scaled && res.reason?.startsWith('unsupported')) {
746
769
  (0, output_1.err)(`screenshot not downscaled: ${res.reason}`);
747
770
  }
748
771
  if ((0, args_1.flagBool)(ctx.flags, 'json')) {
749
772
  (0, output_1.json)({
750
773
  path,
751
774
  bytes: buf.length,
752
- ...(res?.scaled
775
+ ...(res.scaled
753
776
  ? { width: res.width, height: res.height, scaledFrom: { width: res.origWidth, height: res.origHeight } }
754
777
  : {}),
755
778
  });
756
779
  }
757
780
  else {
758
781
  (0, output_1.out)(path);
759
- if (res?.scaled)
782
+ if (res.scaled)
760
783
  (0, output_1.err)(`scaled ${res.origWidth}×${res.origHeight} -> ${res.width}×${res.height} (max edge ${maxEdge}px; --more for detail, --full for original)`);
761
784
  }
762
785
  return 0;
@@ -819,7 +842,7 @@ async function cmdWait(ctx) {
819
842
  const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
820
843
  const deadline = Date.now() + timeout;
821
844
  while (Date.now() < deadline) {
822
- const { matches, tier } = (0, selector_1.matchElements)(ctx.driver.getElements(), sel);
845
+ const { matches, tier } = (0, selector_1.matchElements)(readForPoll(ctx), sel);
823
846
  if (gone ? matches.length === 0 : matches.length > 0) {
824
847
  ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
825
848
  if (gone)
@@ -877,10 +900,10 @@ async function cmdAssert(ctx) {
877
900
  // Auto-wait subsumes the common "wait then assert": poll until the assertion
878
901
  // passes or the window elapses. `--gone` therefore waits for disappearance.
879
902
  const deadline = Date.now() + waitWindowMs(ctx.flags);
880
- let result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
903
+ let result = evalAssert(readForPoll(ctx), sel, ctx.flags);
881
904
  while (!result.pass && Date.now() < deadline) {
882
905
  await sleep(pollStep(ctx.flags, deadline));
883
- result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
906
+ result = evalAssert(readForPoll(ctx), sel, ctx.flags);
884
907
  }
885
908
  const { pass, reason, matches } = result;
886
909
  ctx.record?.note({ selector: sel, element: matches[0], message: `${pass ? 'PASS' : 'FAIL'} — ${reason}` });
@@ -1550,7 +1573,7 @@ async function resolveBackend(platform, device, flags) {
1550
1573
  // vice versa), and neither is allowed to derail recording the failure.
1551
1574
  const out = {};
1552
1575
  try {
1553
- out.png = driver.screenshot();
1576
+ out.png = (0, capture_1.capturePng)(driver, null).buf;
1554
1577
  }
1555
1578
  catch {
1556
1579
  /* device may be gone — that may be why we failed */
@@ -1580,6 +1603,12 @@ async function resolveBackend(platform, device, flags) {
1580
1603
  const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1581
1604
  runCtx = { platform: health.platform, device: health.serial };
1582
1605
  (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
1606
+ // Say the read path once, here. Reads execute server-side, so this is the only end of the
1607
+ // connection that knows it — and without it a companion that had silently stood down was
1608
+ // indistinguishable from one that never engaged, for a whole suite (issue #77). An older
1609
+ // server omits the field; saying nothing is better than guessing.
1610
+ if (health.reads)
1611
+ (0, output_1.err)(`[verikun] server reads: ${health.reads.path} (${health.reads.detail})`);
1583
1612
  const remote = (0, remote_1.createRemoteBackend)(opts, health);
1584
1613
  return {
1585
1614
  backend: {
@@ -1607,7 +1636,7 @@ async function resolveBackend(platform, device, flags) {
1607
1636
  },
1608
1637
  platform: health.platform,
1609
1638
  device: health.serial,
1610
- remote: { url: server, version: health.version },
1639
+ remote: { url: server, version: health.version, reads: health.reads },
1611
1640
  };
1612
1641
  }
1613
1642
  /**
@@ -1911,7 +1940,7 @@ async function cmdSuiteEntry(positionals, flags) {
1911
1940
  throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
1912
1941
  }
1913
1942
  const reqPlatform = platformFromFlags(flags);
1914
- const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
1943
+ const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
1915
1944
  const app = (0, args_1.flagStr)(flags, 'app');
1916
1945
  if (app)
1917
1946
  assertSafeAppId(app);
@@ -1919,6 +1948,9 @@ async function cmdSuiteEntry(positionals, flags) {
1919
1948
  return await (0, suite_1.cmdSuite)(dirArg, flags, {
1920
1949
  platform,
1921
1950
  device,
1951
+ ...(remote
1952
+ ? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
1953
+ : {}),
1922
1954
  runTest: (file) => runAiTest(file, opts, backend, platform, device),
1923
1955
  // Reset app state between tests only when the app id is known; without --app,
1924
1956
  // each test is responsible for its own isolation (e.g. `launch --clear`).
@@ -1933,6 +1965,48 @@ async function cmdSuiteEntry(positionals, flags) {
1933
1965
  }
1934
1966
  // ---------------------------------------------------------------------------
1935
1967
  // Dispatch
1968
+ /**
1969
+ * Inspect or stop the on-device companion (`tools/verikun-companion`).
1970
+ *
1971
+ * `stop` exists because the companion holds the device's ONE UiAutomation connection for as
1972
+ * long as it runs, which locks out Appium, Layout Inspector and a second verikun. Without a
1973
+ * way to hand that back, the only recourse would be `adb shell pkill`.
1974
+ */
1975
+ function cmdCompanion(ctx) {
1976
+ const action = ctx.positionals[0] ?? 'status';
1977
+ if (action !== 'status' && action !== 'stop') {
1978
+ throw new errors_1.CliError(`Usage: verikun companion <status|stop>`, 2);
1979
+ }
1980
+ if (ctx.platform !== 'android') {
1981
+ throw new errors_1.CliError('The companion is Android-only; iOS reads the hierarchy through idb, which is already fast.', 3);
1982
+ }
1983
+ const companion = new manager_1.Companion({
1984
+ adb: process.env.ADB || 'adb',
1985
+ serial: ctx.driver.resolvedSerial(),
1986
+ // `status`/`stop` never calibrate, so this is unreachable — but a throwing stub is
1987
+ // honest about that, where a silent no-op would hide a future miswiring.
1988
+ stockDump: () => {
1989
+ throw new errors_1.CliError('the companion command never calibrates', 3);
1990
+ },
1991
+ });
1992
+ if (action === 'stop') {
1993
+ companion.stop();
1994
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
1995
+ (0, output_1.json)({ companion: 'stopped' });
1996
+ else
1997
+ (0, output_1.out)('companion stopped');
1998
+ return 0;
1999
+ }
2000
+ const state = companion.describe();
2001
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
2002
+ (0, output_1.json)({ companion: state, enabled: (0, manager_1.companionEnabled)() });
2003
+ else {
2004
+ (0, output_1.out)(state);
2005
+ if (!(0, manager_1.companionEnabled)())
2006
+ (0, output_1.err)('note: disabled by VERIKUN_COMPANION — hierarchy reads use the slower stock dump');
2007
+ }
2008
+ return 0;
2009
+ }
1936
2010
  // ---------------------------------------------------------------------------
1937
2011
  async function executeCommand(command, ctx) {
1938
2012
  switch (command) {
@@ -1940,6 +2014,8 @@ async function executeCommand(command, ctx) {
1940
2014
  return cmdDevices(ctx);
1941
2015
  case 'doctor':
1942
2016
  return cmdDoctor(ctx);
2017
+ case 'companion':
2018
+ return cmdCompanion(ctx);
1943
2019
  case 'ui':
1944
2020
  case 'dump':
1945
2021
  return cmdUi(ctx);
@@ -2280,6 +2356,8 @@ ENVIRONMENT
2280
2356
  Claude Code plugin is out of date (a warning only —
2281
2357
  it never changes the exit code). --fix disables
2282
2358
  animations. VERIKUN_NO_UPDATE_CHECK skips the check
2359
+ companion <status|stop> [--json] On-device hierarchy reader (Android, on by default;
2360
+ VERIKUN_COMPANION=0 opts out)
2283
2361
 
2284
2362
  TEST RUNS (actions are recorded; a run auto-starts on first action)
2285
2363
  run start [name] [--force] Begin a named run (else one starts implicitly)