verikun 0.21.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.
@@ -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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,90 @@ 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
+
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
+
9
93
  ## [0.21.0] - 2026-08-13
10
94
 
11
95
  ### Added
File without changes
package/dist/cli.js CHANGED
@@ -1603,6 +1603,12 @@ async function resolveBackend(platform, device, flags) {
1603
1603
  const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1604
1604
  runCtx = { platform: health.platform, device: health.serial };
1605
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})`);
1606
1612
  const remote = (0, remote_1.createRemoteBackend)(opts, health);
1607
1613
  return {
1608
1614
  backend: {
@@ -1630,7 +1636,7 @@ async function resolveBackend(platform, device, flags) {
1630
1636
  },
1631
1637
  platform: health.platform,
1632
1638
  device: health.serial,
1633
- remote: { url: server, version: health.version },
1639
+ remote: { url: server, version: health.version, reads: health.reads },
1634
1640
  };
1635
1641
  }
1636
1642
  /**
@@ -1934,7 +1940,7 @@ async function cmdSuiteEntry(positionals, flags) {
1934
1940
  throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
1935
1941
  }
1936
1942
  const reqPlatform = platformFromFlags(flags);
1937
- const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
1943
+ const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
1938
1944
  const app = (0, args_1.flagStr)(flags, 'app');
1939
1945
  if (app)
1940
1946
  assertSafeAppId(app);
@@ -1942,6 +1948,9 @@ async function cmdSuiteEntry(positionals, flags) {
1942
1948
  return await (0, suite_1.cmdSuite)(dirArg, flags, {
1943
1949
  platform,
1944
1950
  device,
1951
+ ...(remote
1952
+ ? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
1953
+ : {}),
1945
1954
  runTest: (file) => runAiTest(file, opts, backend, platform, device),
1946
1955
  // Reset app state between tests only when the app id is known; without --app,
1947
1956
  // each test is responsible for its own isolation (e.g. `launch --clear`).
@@ -9,6 +9,7 @@
9
9
  // therefore gets the companion off the connection BEFORE the caller falls back.
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.Companion = void 0;
12
+ exports.nullRootAction = nullRootAction;
12
13
  exports.companionJarPath = companionJarPath;
13
14
  exports.companionEnabled = companionEnabled;
14
15
  exports.releaseCompanionOn = releaseCompanionOn;
@@ -42,6 +43,64 @@ const START_TIMEOUT_MS = 12000;
42
43
  /** The companion's way of saying getRootInActiveWindow() returned null. */
43
44
  const NULL_ROOT_REPLY = /null root node/i;
44
45
  const START_POLL_MS = 150;
46
+ /**
47
+ * How long a TRANSIENT stand-down suppresses the companion before it is retried.
48
+ *
49
+ * The point of a stand-down is that re-probing on every read would cost more than the stock
50
+ * dump it avoids — but "never again" is only correct for a process that exits after one
51
+ * command. `vk server` runs for days, and the companion shuts ITSELF down after 15 minutes
52
+ * idle, so a daemon that latched off permanently spent the rest of its life on the 2.4s path
53
+ * next to a perfectly healthy companion (issue #77). One retry a minute is cheap enough to be
54
+ * invisible and frequent enough that a suite recovers within its first test.
55
+ */
56
+ const TRANSIENT_RETRY_MS = 60000;
57
+ /**
58
+ * How long a run of null-root replies must last before the connection is suspected of being
59
+ * wedged rather than the screen being genuinely blank.
60
+ *
61
+ * A real mid-launch gap resolves in well under this; a stale UiAutomation connection never
62
+ * does. MEASURED on a Pixel 3a: after `vk launch` force-stops and restarts the app, the
63
+ * companion returned a null root for 30s+ while a stock dump read the same screen fine.
64
+ */
65
+ const WEDGE_AFTER_MS = 3000;
66
+ /**
67
+ * How long a null-root fallback suppresses the companion — much shorter than a companion
68
+ * failure, because the cause is different in kind.
69
+ *
70
+ * A fallback here means a freshly re-acquired connection still saw no window, which is most
71
+ * often an app that genuinely has not drawn yet. That clears the moment it does, so the 60s
72
+ * used for a real companion fault would spend the rest of a launch on the 2.4s path for no
73
+ * reason. Long enough to let the stock dump answer without immediately re-taking the
74
+ * connection from under it; short enough that the companion is back within one auto-wait.
75
+ */
76
+ const NULL_ROOT_RETRY_MS = 2000;
77
+ const NOT_RUNNING = 'not running';
78
+ /**
79
+ * What to do about a null root, given how long the current run of them has lasted.
80
+ *
81
+ * Pure so the escalation can be unit-tested without a device. The order matters and each step
82
+ * is deliberately conservative:
83
+ * - `propagate` — the normal case. A null root is the DEVICE having no window (mid-launch,
84
+ * force-stopped), not a companion fault, so it goes up to the caller's auto-wait.
85
+ * - `recycle` — long enough to be suspicious. Release and re-acquire the connection, which
86
+ * is exactly what unwedges it, and cheap (~1.05s) relative to being wrong.
87
+ * - `fallback` — recycling did not help. Hand the connection back and let the stock dump
88
+ * answer. It can read screens the wedged companion cannot, and a slow read beats a
89
+ * selector command failing on a screen that is plainly there.
90
+ *
91
+ * Once a recycle has happened, the NEXT null root falls back immediately rather than waiting
92
+ * out another interval. The recycle IS the test — a connection that was just released and
93
+ * re-acquired has no staleness left to explain the answer — so more elapsed time tells us
94
+ * nothing new, and waiting for it is not free: selector commands default to a 5s auto-wait,
95
+ * so any second threshold beyond the ~1.05s recycle risks the window closing first. That
96
+ * would fail the command with "no window" on a readable screen without ever trying the stock
97
+ * dump, which is the exact failure this escalation exists to prevent.
98
+ */
99
+ function nullRootAction(runMs, alreadyRecycled) {
100
+ if (alreadyRecycled)
101
+ return 'fallback';
102
+ return runMs < WEDGE_AFTER_MS ? 'propagate' : 'recycle';
103
+ }
45
104
  /** The packaged companion jar: shipped in the npm tarball, and present in a source checkout
46
105
  * once `tools/verikun-companion/build.sh` has run. Absent means "no companion available",
47
106
  * which is a normal state, not an error. */
@@ -95,15 +154,31 @@ function releaseCompanionOn(serial) {
95
154
  class Companion {
96
155
  deps;
97
156
  port;
98
- /** Latched off for the rest of this process once anything goes wrong: a companion that
99
- * has already failed is not worth re-probing per read, since the retry would cost more
100
- * than the stock dump it is trying to avoid. */
101
- unusable = false;
157
+ /** Off for the rest of this process, for a reason that CANNOT change while it runs: this
158
+ * device's note says the companion does not work here, or there is no jar to push. */
159
+ declined = false;
160
+ /** Off until this timestamp, for a reason that might. Re-probing per read would cost more
161
+ * than the stock dump it avoids, but never retrying is only right for a process that
162
+ * exits after one command — see TRANSIENT_RETRY_MS. */
163
+ retryAfter = 0;
102
164
  dims;
165
+ /** When the current unbroken run of null-root replies started (0 = not in one), and
166
+ * whether this run has already spent its one connection recycle. See nullRootAction. */
167
+ nullRootSince = 0;
168
+ nullRootRecycled = false;
103
169
  constructor(deps) {
104
170
  this.deps = deps;
105
171
  this.port = (0, protocol_1.portForSerial)(deps.serial);
106
172
  }
173
+ /** Why the companion is not serving reads right now, for `/v1/health` and `vk companion
174
+ * status`. Null when it is available (or would be on the next read). */
175
+ suppressedReason() {
176
+ if (this.declined)
177
+ return 'declined on this device';
178
+ if (Date.now() < this.retryAfter)
179
+ return 'standing by after a failure';
180
+ return null;
181
+ }
107
182
  adb(args, timeout = 15000) {
108
183
  return (0, exec_1.runText)(this.deps.adb, ['-s', this.deps.serial, ...args], { timeout });
109
184
  }
@@ -135,37 +210,97 @@ class Companion {
135
210
  * work rather than being SIGKILLed.
136
211
  */
137
212
  dump(idleMs) {
138
- if (this.unusable)
213
+ if (this.declined || Date.now() < this.retryAfter)
139
214
  return null;
140
- try {
141
- if (!this.dims) {
142
- this.dims = this.ensureReady();
143
- if (!this.dims)
144
- return null;
215
+ // TWO attempts, because a dead companion is routine rather than exceptional: it shuts
216
+ // itself down after 15 minutes idle, which any long-lived process (`vk server`, a slow
217
+ // suite) will outlive. The first attempt can therefore fail purely because `dims` is
218
+ // cached from before that shutdown; clearing it sends the second attempt through
219
+ // ensureReady(), which restarts from the device note in ~2.1s. Without this the process
220
+ // stood down permanently and never touched the companion again (issue #77).
221
+ for (let attempt = 0; attempt < 2; attempt++) {
222
+ try {
223
+ return this.attemptDump(idleMs);
145
224
  }
146
- const reply = (0, protocol_1.requestSync)(this.port, (0, protocol_1.dumpCommand)(idleMs, this.dims));
147
- if (!(0, protocol_1.isHierarchy)(reply)) {
148
- // The companion reports its own failures as plain text (`ERROR …`, `released —
149
- // call acquire first`). Those must never reach the XML parser as though they were
150
- // a screen: an unparseable "hierarchy" reads as zero elements, which is the
151
- // "absent" lie that silently skips a guard.
152
- const detail = reply.toString('utf8').trim().slice(0, 200) || 'empty reply';
153
- // A null root is the DEVICE having no window, not the companion malfunctioning.
154
- // Standing down for it would release a perfectly healthy connection and drop the
155
- // whole process onto the 2.4s path — measured after every `launch --clear`, which
156
- // leaves exactly this gap.
157
- if (NULL_ROOT_REPLY.test(detail)) {
158
- throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch).');
225
+ catch (e) {
226
+ if (e instanceof errors_1.NoWindowError)
227
+ throw e; // the screen's state, not the companion's
228
+ if (attempt === 0 && this.dims) {
229
+ this.dims = undefined;
230
+ continue;
159
231
  }
160
- throw new Error(detail);
232
+ this.standDown(`companion dump failed (${e.message})`, 'transient');
233
+ return null;
161
234
  }
162
- return reply.toString('utf8');
163
235
  }
164
- catch (e) {
165
- if (e instanceof errors_1.NoWindowError)
166
- throw e; // transient screen state the companion is fine
167
- this.standDown(`companion dump failed (${e.message})`);
168
- return null;
236
+ return null;
237
+ }
238
+ /** One read attempt. Throws NoWindowError for a blank screen, anything else for a
239
+ * companion that could not serve the read. */
240
+ attemptDump(idleMs) {
241
+ if (!this.dims) {
242
+ this.dims = this.ensureReady();
243
+ if (!this.dims)
244
+ return null;
245
+ }
246
+ const reply = (0, protocol_1.requestSync)(this.port, (0, protocol_1.dumpCommand)(idleMs, this.dims));
247
+ if (!(0, protocol_1.isHierarchy)(reply)) {
248
+ // The companion reports its own failures as plain text (`ERROR …`, `released —
249
+ // call acquire first`). Those must never reach the XML parser as though they were
250
+ // a screen: an unparseable "hierarchy" reads as zero elements, which is the
251
+ // "absent" lie that silently skips a guard.
252
+ const detail = reply.toString('utf8').trim().slice(0, 200) || 'empty reply';
253
+ if (NULL_ROOT_REPLY.test(detail))
254
+ return this.onNullRoot(idleMs);
255
+ throw new Error(detail);
256
+ }
257
+ // A hierarchy came back, so whatever run of null roots preceded it is over.
258
+ this.nullRootSince = 0;
259
+ this.nullRootRecycled = false;
260
+ return reply.toString('utf8');
261
+ }
262
+ /**
263
+ * A null root is USUALLY the device having no window — mid-launch, or force-stopped — and
264
+ * standing down for it would release a healthy connection and drop the whole process onto
265
+ * the 2.4s path after every `launch --clear`.
266
+ *
267
+ * But it is not always. A long-lived UiAutomation connection can go stale and return null
268
+ * forever for a window that is plainly there: MEASURED on a Pixel 3a, 30s+ of null roots
269
+ * after `vk launch` while a stock dump read the screen fine. Because this error propagates
270
+ * rather than standing down, that state never self-healed and every selector command burned
271
+ * its full auto-wait and failed on a readable screen. So escalate by duration.
272
+ */
273
+ onNullRoot(idleMs) {
274
+ const now = Date.now();
275
+ if (!this.nullRootSince)
276
+ this.nullRootSince = now;
277
+ switch (nullRootAction(now - this.nullRootSince, this.nullRootRecycled)) {
278
+ case 'recycle':
279
+ this.nullRootRecycled = true;
280
+ (0, output_1.err)('[verikun] companion has reported no window for a while; recycling the connection');
281
+ if (this.recycleConnection())
282
+ return this.attemptDump(idleMs);
283
+ break;
284
+ case 'fallback':
285
+ // Recycling did not help and the stock dump may well read this screen. Being slow is
286
+ // always better than failing a selector on a screen that is there.
287
+ this.standDown('companion still reports no window after a recycle; using the stock path', 'screen');
288
+ return null;
289
+ case 'propagate':
290
+ break;
291
+ }
292
+ throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch).');
293
+ }
294
+ /** Hand the UiAutomation connection back and take it again. The one thing measured to clear
295
+ * a stale connection — it is what the stock path's releaseCompanionOn() does by accident. */
296
+ recycleConnection() {
297
+ try {
298
+ (0, protocol_1.requestSync)(this.port, 'release', 5000);
299
+ (0, protocol_1.requestSync)(this.port, 'acquire', 20000);
300
+ return true;
301
+ }
302
+ catch {
303
+ return false;
169
304
  }
170
305
  }
171
306
  /** Get a calibrated companion running, or give up for this process. */
@@ -189,7 +324,10 @@ class Companion {
189
324
  // single command a doomed start attempt now that this is on by default.
190
325
  const note = this.readDeviceNote();
191
326
  if (note === 'unsupported') {
192
- this.unusable = true;
327
+ // TERMINAL: this is a recorded fact about the phone, and it cannot become false while
328
+ // this process runs. Retrying it would be the doomed start attempt per command that the
329
+ // note exists to prevent.
330
+ this.declined = true;
193
331
  return undefined;
194
332
  }
195
333
  return this.startAndCalibrate(note, live);
@@ -218,9 +356,17 @@ class Companion {
218
356
  // - the jar is missing, which is a fault of THIS checkout, not of the device;
219
357
  // - several `vk` processes cold-started at once and collided, which is transient and
220
358
  // usually leaves a perfectly good companion running (started by whoever won).
221
- if (companionJarPath() && !this.probeState().usable)
359
+ const jar = companionJarPath();
360
+ const deviceIsAtFault = jar && !this.probeState().usable;
361
+ if (deviceIsAtFault)
222
362
  this.writeDeviceNote('unsupported');
223
- this.unusable = true;
363
+ // Only the two facts that cannot change under a running process are terminal. A start
364
+ // that failed for any OTHER reason — most often the cold-start collision above — must
365
+ // stay retryable, or one unlucky moment costs a daemon every read it will ever do.
366
+ if (deviceIsAtFault || !jar)
367
+ this.declined = true;
368
+ else
369
+ this.retryAfter = Date.now() + TRANSIENT_RETRY_MS;
224
370
  return undefined;
225
371
  }
226
372
  // A remembered verdict skips calibration entirely — worth ~4.7s of the cold start, and
@@ -262,7 +408,7 @@ class Companion {
262
408
  return true;
263
409
  }
264
410
  catch (e) {
265
- this.standDown(`companion could not reacquire the connection (${e.message})`);
411
+ this.standDown(`companion could not reacquire the connection (${e.message})`, 'transient');
266
412
  return false;
267
413
  }
268
414
  }
@@ -347,17 +493,37 @@ class Companion {
347
493
  // Neither matched. Usually the screen simply moved between the two dumps, but it
348
494
  // could equally be a device whose bounds we would get wrong — and being slow is
349
495
  // strictly better than tapping the wrong pixel, so decline rather than pick one.
350
- this.standDown('companion output did not match the platform dump; using the stock path');
496
+ // TRANSIENT precisely because "the screen moved" is the usual cause: a daemon that
497
+ // treated one moving screen as a permanent verdict spent its whole life on the stock
498
+ // path, which is the likeliest origin of issue #77's "no speedup at all".
499
+ this.standDown('companion output did not match the platform dump; using the stock path', 'transient');
351
500
  return undefined;
352
501
  }
353
502
  catch (e) {
354
- this.standDown(`companion calibration failed (${e.message})`);
503
+ this.standDown(`companion calibration failed (${e.message})`, 'transient');
355
504
  return undefined;
356
505
  }
357
506
  }
358
- /** Hand the UiAutomation connection back, and stop using the companion in this process. */
359
- standDown(reason) {
360
- this.unusable = true;
507
+ /**
508
+ * Hand the UiAutomation connection back, and stop using the companion — for as long as the
509
+ * cause plausibly lasts:
510
+ * - `terminal` — for the rest of the process; the cause cannot change while it runs.
511
+ * - `transient` — a minute; the companion itself failed.
512
+ * - `screen` — a couple of seconds; the *screen* had no window, which clears on its own.
513
+ *
514
+ * Releasing FIRST is the load-bearing half: while the companion holds the connection the
515
+ * stock dump is not slower, it is SIGKILLed, and the caller is about to depend on it.
516
+ */
517
+ standDown(reason, kind) {
518
+ if (kind === 'terminal')
519
+ this.declined = true;
520
+ else
521
+ this.retryAfter = Date.now() + (kind === 'screen' ? NULL_ROOT_RETRY_MS : TRANSIENT_RETRY_MS);
522
+ // Force a fresh ensureReady() on the retry: whatever `dims` said is exactly what stopped
523
+ // working, and the note-based restart is what recovers it.
524
+ this.dims = undefined;
525
+ this.nullRootSince = 0;
526
+ this.nullRootRecycled = false;
361
527
  (0, output_1.err)(`[verikun] ${reason}`);
362
528
  try {
363
529
  (0, protocol_1.requestSync)(this.port, 'release', 4000);
@@ -385,12 +551,17 @@ class Companion {
385
551
  }
386
552
  /** For `vk companion status`. */
387
553
  describe() {
554
+ const state = this.stateSummary();
555
+ return state === NOT_RUNNING ? state : `running on port ${this.port} (${state})`;
556
+ }
557
+ /** Just the device-side state (`ready app held`, or `not running`), for embedding in a
558
+ * one-line read-path report. */
559
+ stateSummary() {
388
560
  try {
389
- const state = (0, protocol_1.requestSync)(this.port, 'state', 4000).toString('utf8').trim();
390
- return `running on port ${this.port} (${state})`;
561
+ return (0, protocol_1.requestSync)(this.port, 'state', 4000).toString('utf8').trim();
391
562
  }
392
563
  catch {
393
- return 'not running';
564
+ return NOT_RUNNING;
394
565
  }
395
566
  }
396
567
  }
@@ -279,6 +279,18 @@ class AdbDriver {
279
279
  }
280
280
  return this.companion;
281
281
  }
282
+ /** Which read path the next getElements() will take. See `Driver.hierarchySource`. */
283
+ hierarchySource() {
284
+ if (!(0, manager_1.companionEnabled)())
285
+ return { path: 'stock', detail: 'companion off (VERIKUN_COMPANION)' };
286
+ const companion = this.companionOrNull();
287
+ if (!companion)
288
+ return { path: 'stock', detail: 'companion unavailable' };
289
+ const suppressed = companion.suppressedReason();
290
+ if (suppressed)
291
+ return { path: 'stock', detail: `companion ${suppressed}` };
292
+ return { path: 'companion', detail: companion.stateSummary() };
293
+ }
282
294
  dumpXml() {
283
295
  // The companion answers in ~40ms against ~2400ms for the stock path. A null here means
284
296
  // it could not serve the read AND has already handed the UiAutomation connection back —
package/dist/server.js CHANGED
@@ -35,11 +35,28 @@ const promises_1 = require("node:stream/promises");
35
35
  const args_1 = require("./args");
36
36
  const errors_1 = require("./errors");
37
37
  const drivers_1 = require("./drivers");
38
+ const manager_1 = require("./companion/manager");
38
39
  const output_1 = require("./output");
39
40
  const ir_1 = require("./agent/ir");
40
41
  const rpc_1 = require("./rpc");
41
42
  const cli_1 = require("./cli");
42
43
  const version_1 = require("./version");
44
+ /**
45
+ * A driver's read path, or null when the backend has no opinion (iOS reads through idb, one
46
+ * way only) or the probe failed.
47
+ *
48
+ * Best-effort on purpose. It is reported on `/v1/health`, which is also how a client checks
49
+ * the server is reachable at all — a companion probe must never be the reason that answer
50
+ * cannot be given.
51
+ */
52
+ function safeHierarchySource(driver) {
53
+ try {
54
+ return driver.hierarchySource?.() ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
43
60
  const DEFAULT_PORT = 8391;
44
61
  const EXEC_BODY_CAP = 1024 * 1024; // 1 MB of JSON is far beyond any leaf command
45
62
  const INSTALL_BODY_CAP = 512 * 1024 * 1024; // 512 MB app build
@@ -262,12 +279,14 @@ function buildServer(config) {
262
279
  if (config.authKey && req.headers.authorization && !authorized(req)) {
263
280
  throw new HttpError(401, 'invalid auth key');
264
281
  }
282
+ const reads = safeHierarchySource(config.driver);
265
283
  const health = {
266
284
  ok: true,
267
285
  version: version_1.VERSION,
268
286
  platform: config.platform,
269
287
  serial: config.serial,
270
288
  installEnabled: config.allowInstall,
289
+ ...(reads ? { reads } : {}),
271
290
  };
272
291
  sendJson(res, 200, health);
273
292
  return;
@@ -369,6 +388,11 @@ async function cmdServer(positionals, flags) {
369
388
  server.listen(port, bind, () => {
370
389
  (0, output_1.err)(`[server] verikun ${version_1.VERSION} listening on http://${bind}:${port}`);
371
390
  (0, output_1.err)(`[server] device: ${platform} · ${serial}`);
391
+ // Say the read path out loud. It is the difference between a suite that takes 8s and
392
+ // one that takes 43s, and it used to be invisible from both ends (issue #77).
393
+ const reads = safeHierarchySource(driver);
394
+ if (reads)
395
+ (0, output_1.err)(`[server] reads: ${reads.path} (${reads.detail})`);
372
396
  (0, output_1.err)(`[server] install endpoint: ${allowInstall ? 'ENABLED (--allow-install)' : 'disabled (pass --allow-install to accept builds)'}`);
373
397
  if (generated) {
374
398
  (0, output_1.err)('[server] auth key generated for this session — clients pass it via VERIKUN_SERVER_AUTH_KEY or --auth-key:');
@@ -386,6 +410,11 @@ async function cmdServer(positionals, flags) {
386
410
  });
387
411
  const close = () => {
388
412
  (0, output_1.err)('[server] shutting down');
413
+ // Hand the device's ONE UiAutomation connection back. The companion outlives the
414
+ // process that started it and would otherwise keep the connection for up to its full
415
+ // 15-minute idle window, blocking Appium, Layout Inspector and TalkBack on this host —
416
+ // with no obvious cause, and no way to stop it from a `--server` client.
417
+ (0, manager_1.releaseCompanionOn)(serial);
389
418
  server.close();
390
419
  resolve(0);
391
420
  };
package/dist/suite.js CHANGED
@@ -310,6 +310,7 @@ async function cmdSuite(dirArg, flags, deps) {
310
310
  platform: deps.platform,
311
311
  device: deps.device,
312
312
  verikun: version_1.VERSION,
313
+ ...(deps.server ? { server: deps.server } : {}),
313
314
  totals: (0, report_1.suiteTotals)(results),
314
315
  tests: results,
315
316
  ...(aborted ? { aborted } : {}),
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.21.0';
6
+ exports.VERSION = '0.21.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",
@@ -36,6 +36,7 @@
36
36
  "scripts": {
37
37
  "prebuild": "node scripts/gen-version.mjs",
38
38
  "build": "tsc",
39
+ "postbuild": "node scripts/chmod-bin.mjs",
39
40
  "dev": "tsc --watch",
40
41
  "verikun": "node dist/bin/verikun.js",
41
42
  "test": "tsc -p tsconfig.test.json && node --test --test-reporter=spec .test-build/tests/*.test.js",