verikun 0.21.1 → 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.
@@ -482,7 +482,7 @@ draft-first flow.
482
482
  - `0` success / found / assertion passed
483
483
  - `1` not found / assertion failed / wait timeout
484
484
  - `2` usage error **or ambiguous selector** (refine it or add `--index N`)
485
- - `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
486
486
  `ai`/`suite`/`install`/`server` the toolchain is verified up front, so this arrives
487
487
  immediately with an install hint rather than mid-flow
488
488
 
@@ -498,7 +498,12 @@ draft-first flow.
498
498
  - **`vk device set` from a plain shell stays applied.** Inside `batch`/`ai`/`suite` it is
499
499
  restored automatically even if the flow dies, but a one-off `vk device set airplane=on`
500
500
  is yours to `vk device reset` — don't strand someone's phone offline.
501
- - **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.
502
507
  - **`vk text` opens the keyboard.** Use `--enter` to submit, or `vk back` to
503
508
  dismiss it before re-inspecting (it can cover elements).
504
509
  - **Unicode/emoji** may not type via `adb input text` (an Android limitation);
package/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ 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
+
9
43
  ## [0.21.1] - 2026-08-13
10
44
 
11
45
  ### Fixed
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
 
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) {
@@ -1855,6 +1935,7 @@ async function cmdAi(positionals, flags) {
1855
1935
  throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--server url] [--show-plan] [--recompile]', 2);
1856
1936
  }
1857
1937
  const opts = parseAiOptions(flags);
1938
+ (0, claims_1.setProcessScoped)(true); // one process for the whole run — see claims.ts's isLive
1858
1939
  // --show-plan: compile (or cache-hit) and print the IR — no device, no backend.
1859
1940
  if ((0, args_1.flagBool)(flags, 'show-plan')) {
1860
1941
  const nl = readAiTest(file);
@@ -1875,6 +1956,7 @@ async function cmdAi(positionals, flags) {
1875
1956
  // otherwise an unattended run leaves the phone offline or in dark mode.
1876
1957
  await restoreDeviceOverrides(backend);
1877
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
1878
1960
  }
1879
1961
  if ((0, args_1.flagBool)(flags, 'json')) {
1880
1962
  (0, output_1.json)({
@@ -1934,6 +2016,7 @@ async function cmdSuiteEntry(positionals, flags) {
1934
2016
  if (!dirArg)
1935
2017
  throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--retries n] [--json]', 2);
1936
2018
  const opts = parseAiOptions(flags);
2019
+ (0, claims_1.setProcessScoped)(true); // one process for the whole suite — see claims.ts's isLive
1937
2020
  // Pre-flight the provider BEFORE touching any device/server: every test needs it
1938
2021
  // to compile (on a cache miss) or to repair at runtime.
1939
2022
  if (!providerAvailable(opts.model)) {
@@ -1961,6 +2044,7 @@ async function cmdSuiteEntry(positionals, flags) {
1961
2044
  finally {
1962
2045
  await restoreDeviceOverrides(backend);
1963
2046
  await backend.close?.();
2047
+ (0, claims_1.releaseOwnClaims)();
1964
2048
  }
1965
2049
  }
1966
2050
  // ---------------------------------------------------------------------------
@@ -2113,6 +2197,12 @@ async function executeOutcome(command, positionals, flags, sharedDriver) {
2113
2197
  catch {
2114
2198
  /* surfaced by the command handler below */
2115
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);
2116
2206
  recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
2117
2207
  }
2118
2208
  }
@@ -2153,6 +2243,10 @@ async function executeForServer(command, positionals, flags, driver, platform) {
2153
2243
  catch {
2154
2244
  /* surfaced by the command handler below */
2155
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);
2156
2250
  // Sample the device clock up front so the caller's run can set logStart (the
2157
2251
  // ephemeral recorder never persists RunState). Best-effort — empty/unavailable
2158
2252
  // just means archive / vk log fall back to last-N.
@@ -2290,6 +2384,9 @@ DEVICE STATE (change the device the app runs on, then put it back)
2290
2384
  equivalent where it doesn't
2291
2385
  Refuses \`airplane=on\` over wireless adb (it would cut
2292
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.
2293
2390
 
2294
2391
  BATCH (script many commands in one process)
2295
2392
  batch [--file path] [--quiet] Run newline-separated commands — from --file,
@@ -2351,7 +2448,8 @@ SERVER (expose a locally-connected device to remote verikun clients)
2351
2448
  VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
2352
2449
 
2353
2450
  ENVIRONMENT
2354
- devices [--json] List attached devices/simulators
2451
+ devices [--json] List attached devices/simulators, and which job is
2452
+ already driving each (USED BY)
2355
2453
  doctor [--fix] Diagnose adb/device, and warn if this CLI or the
2356
2454
  Claude Code plugin is out of date (a warning only —
2357
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
+ }
@@ -19,6 +19,7 @@ const exec_1 = require("../exec");
19
19
  const output_1 = require("../output");
20
20
  const errors_1 = require("../errors");
21
21
  const version_1 = require("../version");
22
+ const dump_match_1 = require("./dump-match");
22
23
  const protocol_1 = require("./protocol");
23
24
  const DEVICE_JAR = '/data/local/tmp/verikun-companion.jar';
24
25
  /** What we learned about this device last time, kept ON the device because that is what the
@@ -471,7 +472,11 @@ class Companion {
471
472
  * shifts every element near the bottom of the screen, and the resulting tap lands
472
473
  * somewhere else while still reporting success, which is the worst failure a testing
473
474
  * tool has. So do not guess: take one real `uiautomator dump` and adopt whichever
474
- * source reproduces it byte for byte.
475
+ * source reproduces it.
476
+ *
477
+ * "Reproduces it" is `dumpsAgree()`, not string equality — see the reasoning there for
478
+ * why equality rejected a companion that was right, and exactly what the relaxed rule
479
+ * still guarantees about anything verikun can tap.
475
480
  *
476
481
  * Costs one stock dump (~2.4s) plus a reconnect (~1.05s), once per companion — not once
477
482
  * per process, because the verdict is stored in the companion itself.
@@ -484,7 +489,7 @@ class Companion {
484
489
  (0, protocol_1.requestSync)(this.port, 'acquire', 20000);
485
490
  for (const dims of ['app', 'real']) {
486
491
  const reply = (0, protocol_1.requestSync)(this.port, (0, protocol_1.dumpCommand)(0, dims));
487
- if ((0, protocol_1.isHierarchy)(reply) && reply.toString('utf8').trim() === stock) {
492
+ if ((0, protocol_1.isHierarchy)(reply) && (0, dump_match_1.dumpsAgree)(stock, reply.toString('utf8')).agree) {
488
493
  (0, protocol_1.requestSync)(this.port, `calibrated ${dims}`, 4000);
489
494
  this.writeDeviceNote(dims);
490
495
  return dims;
@@ -15,10 +15,16 @@ exports.portForSerial = portForSerial;
15
15
  exports.requestSync = requestSync;
16
16
  const node_child_process_1 = require("node:child_process");
17
17
  const node_path_1 = require("node:path");
18
- /** Bumped in lockstep with PROTOCOL_VERSION in CompanionApp.java. A companion left running
19
- * by an older verikun answers `ping` with a different number and is restarted rather than
20
- * talked to, so a changed dump format can never be replayed by a stale process. */
21
- exports.COMPANION_PROTOCOL = '1';
18
+ /** Bumped in lockstep with PROTOCOL_VERSION in CompanionApp.java `tests/companion-protocol
19
+ * .test.ts` reads the Java source and fails if they drift. A companion left running by an
20
+ * older verikun answers `ping` with a different number and is restarted rather than talked
21
+ * to, so a changed dump format can never be replayed by a stale process.
22
+ *
23
+ * 2 — the companion asks for window information, so it tracks a window another app opens on
24
+ * top ([#79]) and its dump omits decor outside the app's own window. MEASURED: without this
25
+ * bump an upgraded verikun reused the running daemon, never pushed the new jar, and went on
26
+ * reading 0 nodes of a permission dialog the stock path saw all 5 of — silently, exit 0. */
27
+ exports.COMPANION_PROTOCOL = '2';
22
28
  class CompanionUnavailableError extends Error {
23
29
  }
24
30
  exports.CompanionUnavailableError = CompanionUnavailableError;
@@ -0,0 +1,594 @@
1
+ "use strict";
2
+ // The device claim store — "which attached device is another job already driving?".
3
+ //
4
+ // Parallel agents (separate worktrees of one repo) share one pool of phones, emulators
5
+ // and simulators. Nothing coordinated the assignment, so two jobs would land on the same
6
+ // device and BOTH die: one `install` of the same package with a different build silently
7
+ // replaces the app under the other's feet, and it presents as an ordinary assertion
8
+ // failure, indistinguishable from a real regression until someone goes digging.
9
+ //
10
+ // A claim is a small JSON file per device under `~/.verikun/devices/`, saying who is
11
+ // driving it and when they were last seen. HOST-global, not workspace-local: run state
12
+ // lives in `./.verikun/` because it describes a working directory, but a device is a fact
13
+ // about the machine, and the jobs that collide are in DIFFERENT directories by definition.
14
+ //
15
+ // Load-bearing properties, each of which has a failure mode behind it:
16
+ //
17
+ // * ONE FILE PER SERIAL, never one shared map. The whole premise is concurrent writers;
18
+ // independent files make every write atomic with no read-modify-write race.
19
+ // * ACQUISITION IS `wx`. An exclusive create is the only way two agents starting at the
20
+ // same instant don't both pick the first free device. Losing that race is not an
21
+ // error — the caller just moves to the next candidate.
22
+ // * READS ARE TOLERANT. An unreadable or corrupt claim reads as UNCLAIMED (same posture
23
+ // as run.ts's loadState and the plan cache): a poisoned file must never be able to
24
+ // brick a device permanently.
25
+ // * LIVENESS IS PID-FIRST WHERE IT CAN BE. See `isLive` — a timeout alone is either too
26
+ // short (steals a device mid-run) or too long (parks a crashed job's phone).
27
+ // * `VERIKUN_NO_CLAIM=1` DISABLES READS AND WRITES, restoring the pre-claim behaviour
28
+ // exactly — including the old exit-2-on-multiple-devices — and making this job
29
+ // invisible to others. That equivalence is the thing to preserve when debugging.
30
+ //
31
+ // Platform-agnostic by design, like `device/settings.ts` and `ui/`: it never touches
32
+ // adb/xcrun. The drivers know which devices exist; this knows which are taken.
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.releaseOwnClaims = releaseOwnClaims;
35
+ exports.setProcessScoped = setProcessScoped;
36
+ exports.claimsEnabled = claimsEnabled;
37
+ exports.claimTtlMs = claimTtlMs;
38
+ exports.claimsDir = claimsDir;
39
+ exports.isMine = isMine;
40
+ exports.isLive = isLive;
41
+ exports.readClaim = readClaim;
42
+ exports.listClaims = listClaims;
43
+ exports.claimDevice = claimDevice;
44
+ exports.touchClaim = touchClaim;
45
+ exports.releaseClaim = releaseClaim;
46
+ exports.describeClaim = describeClaim;
47
+ exports.selectAndClaim = selectAndClaim;
48
+ exports.assertClaimable = assertClaimable;
49
+ exports.summarize = summarize;
50
+ const node_crypto_1 = require("node:crypto");
51
+ const node_fs_1 = require("node:fs");
52
+ const node_os_1 = require("node:os");
53
+ const node_path_1 = require("node:path");
54
+ const errors_1 = require("../errors");
55
+ const output_1 = require("../output");
56
+ const version_1 = require("../version");
57
+ /** Default TTL for a claim whose owner is not a single long-lived process, in minutes. */
58
+ const DEFAULT_TTL_MIN = 5;
59
+ /**
60
+ * Ceiling on trusting a pid, however alive it looks. Pids are recycled, and a
61
+ * process-scoped run heartbeats every step, so a genuinely running job is never anywhere
62
+ * near this old — it only stops a recycled pid resurrecting an abandoned claim forever.
63
+ */
64
+ const PID_TRUST_MAX_MS = 6 * 60 * 60 * 1000;
65
+ // --- process-wide latch -----------------------------------------------------
66
+ let processScoped = false;
67
+ /**
68
+ * Serials this process has successfully claimed, so a teardown can hand them all back
69
+ * without threading a driver handle through every `finally`. `cmdBatch` in particular
70
+ * never holds a Driver — it dispatches through `executeParsed` — so asking the claim
71
+ * store what it took is the only way it can release anything.
72
+ */
73
+ const acquired = new Set();
74
+ /** Give back every device this process claimed. Best-effort; teardown must never throw. */
75
+ function releaseOwnClaims(o = {}) {
76
+ const released = [];
77
+ for (const serial of [...acquired]) {
78
+ if (releaseClaim(serial, { ...o, mineOnly: true }))
79
+ released.push(serial);
80
+ }
81
+ return released;
82
+ }
83
+ /**
84
+ * Declare that this process owns its device for its whole run — `ai`, `suite`, `batch`,
85
+ * `server`. That turns the owning pid into EXACT liveness evidence (see `isLive`), so a
86
+ * `kill -9` frees the device immediately instead of parking it until the TTL expires.
87
+ *
88
+ * A process-wide latch rather than a parameter because the claim is acquired lazily, deep
89
+ * inside `Driver.resolvedSerial()`, long after the command that knows this was dispatched.
90
+ * `setOutputQuiet` in output.ts is the same shape for the same reason.
91
+ */
92
+ function setProcessScoped(v) {
93
+ const prev = processScoped;
94
+ processScoped = v;
95
+ return prev;
96
+ }
97
+ // --- environment ------------------------------------------------------------
98
+ /** Claims are on unless `VERIKUN_NO_CLAIM` is set to anything non-empty (mirrors VERIKUN_NO_RUN). */
99
+ function claimsEnabled(env = process.env) {
100
+ return !env.VERIKUN_NO_CLAIM;
101
+ }
102
+ /** How long a one-off command's claim survives without a heartbeat. `0` disables the TTL. */
103
+ function claimTtlMs(env = process.env) {
104
+ const raw = env.VERIKUN_CLAIM_TTL_MIN;
105
+ if (raw === undefined)
106
+ return DEFAULT_TTL_MIN * 60000;
107
+ const n = Number(raw);
108
+ return (Number.isFinite(n) && n >= 0 ? n : DEFAULT_TTL_MIN) * 60000;
109
+ }
110
+ // --- paths ------------------------------------------------------------------
111
+ function claimsDir(o = {}) {
112
+ return (0, node_path_1.join)(o.home ?? (0, node_os_1.homedir)(), '.verikun', 'devices');
113
+ }
114
+ /**
115
+ * Filename for a serial: readable enough to eyeball the directory, with a hash suffix so
116
+ * two serials that sanitize alike (`192.168.1.5:5555` vs `192.168.1.5_5555`) can never
117
+ * collide onto one file and silently share a claim.
118
+ */
119
+ function fileFor(serial) {
120
+ const safe = serial.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48);
121
+ return `${safe}-${(0, node_crypto_1.createHash)('sha1').update(serial).digest('hex').slice(0, 8)}.json`;
122
+ }
123
+ function pathFor(serial, o) {
124
+ return (0, node_path_1.join)(claimsDir(o), fileFor(serial));
125
+ }
126
+ // --- identity ---------------------------------------------------------------
127
+ /** Session identity, same source `run.ts` uses for rollover. Absent in a fresh shell. */
128
+ function currentSession(env) {
129
+ return env.VERIKUN_SESSION || env.TERM_SESSION_ID || undefined;
130
+ }
131
+ /**
132
+ * Is this claim the current job's?
133
+ *
134
+ * EITHER the session matches OR the working directory does — deliberately forgiving,
135
+ * because the only unsafe error here is falsely accusing your own job. An agent harness
136
+ * may run every command in a fresh shell with no stable session id (which is why session
137
+ * alone will not do), and two terminals deliberately sharing one checkout are one job
138
+ * (which is why cwd alone will not do either).
139
+ */
140
+ function isMine(c, o = {}) {
141
+ const env = o.env ?? process.env;
142
+ const session = currentSession(env);
143
+ if (session && c.session && c.session === session)
144
+ return true;
145
+ return c.cwd === (o.cwd ?? process.cwd());
146
+ }
147
+ /** Whether a pid is running. EPERM means it exists but belongs to someone else — alive. */
148
+ function pidAlive(pid) {
149
+ if (!Number.isInteger(pid) || pid <= 0)
150
+ return false;
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ }
155
+ catch (e) {
156
+ return e.code === 'EPERM';
157
+ }
158
+ }
159
+ function ageMs(c, now) {
160
+ const t = Date.parse(c.heartbeat);
161
+ // An unparseable heartbeat reads as ancient, so a malformed claim is takeable rather
162
+ // than immortal — the same direction every other tolerance in this file leans.
163
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : now - t;
164
+ }
165
+ /**
166
+ * Is somebody still driving this device?
167
+ *
168
+ * Two signals, because a heartbeat can only fire BETWEEN commands and neither signal
169
+ * alone covers both owners:
170
+ *
171
+ * * A LIVE PID always means live. The heartbeat cannot tick during a single long
172
+ * command — a large `install`, a `wait --timeout 600000`, a `vk ai` model repair
173
+ * round-trip — so without this a run would lose its device midway through work it
174
+ * had no opportunity to report. The pid only ever EXTENDS liveness here.
175
+ * * PROCESS-SCOPED owners (`ai`/`suite`/`batch`/`server`) are one process for the whole
176
+ * job, so a dead pid also means DONE: the device comes back the instant a `kill -9`
177
+ * lands, rather than being parked until a timer expires. This is the only case where
178
+ * the pid shortens liveness, and it is sound precisely because the process outlives
179
+ * every command in the job.
180
+ *
181
+ * Everything else falls back to idle time. A one-off command's process is gone the moment
182
+ * it printed, so the TTL covers the only gap left: an agent thinking between two commands.
183
+ * One minute is too tight for that (read a screenshot, decide, tap again); five is enough
184
+ * while still handing a crashed job's phone back quickly.
185
+ */
186
+ function isLive(c, o = {}) {
187
+ const age = ageMs(c, o.now ?? Date.now());
188
+ const ours = c.host === (o.host ?? (0, node_os_1.hostname)()) && c.pid > 0;
189
+ // The ceiling is what stops a RECYCLED pid resurrecting an abandoned claim forever; a
190
+ // job that is genuinely working heartbeats nowhere near this often.
191
+ const running = ours && age <= PID_TRUST_MAX_MS && pidAlive(c.pid);
192
+ if (c.processScoped && ours)
193
+ return running;
194
+ const ttl = claimTtlMs(o.env ?? process.env);
195
+ return running || (ttl > 0 && age <= ttl);
196
+ }
197
+ // --- store ------------------------------------------------------------------
198
+ /** Read one device's claim. Missing, unreadable or malformed all mean UNCLAIMED. */
199
+ function readClaim(serial, o = {}) {
200
+ return readClaimAt(pathFor(serial, o));
201
+ }
202
+ function readClaimAt(p) {
203
+ if (!(0, node_fs_1.existsSync)(p))
204
+ return null;
205
+ try {
206
+ const c = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
207
+ // Guard the two fields every decision reads. A file that parses as JSON but is not a
208
+ // claim must not make `isMine`/`isLive` throw deep inside device resolution.
209
+ if (typeof c?.serial !== 'string' || typeof c?.cwd !== 'string')
210
+ return null;
211
+ return c;
212
+ }
213
+ catch (e) {
214
+ (0, output_1.err)(`[verikun] ignoring unreadable device claim ${p} (${e.message})`);
215
+ return null;
216
+ }
217
+ }
218
+ /** Every claim on this host. Powers `vk devices`, and is the list a parallel scheduler reads. */
219
+ function listClaims(o = {}) {
220
+ const dir = claimsDir(o);
221
+ if (!(0, node_fs_1.existsSync)(dir))
222
+ return [];
223
+ const out = [];
224
+ try {
225
+ for (const name of (0, node_fs_1.readdirSync)(dir)) {
226
+ if (!name.endsWith('.json'))
227
+ continue;
228
+ const c = readClaimAt((0, node_path_1.join)(dir, name));
229
+ if (c)
230
+ out.push(c);
231
+ }
232
+ }
233
+ catch {
234
+ /* an unreadable directory is "nothing claimed", never a failure */
235
+ }
236
+ return out;
237
+ }
238
+ function record(serial, platform, o, since) {
239
+ const env = o.env ?? process.env;
240
+ const iso = new Date(o.now ?? Date.now()).toISOString();
241
+ return {
242
+ serial,
243
+ platform,
244
+ session: currentSession(env),
245
+ cwd: o.cwd ?? process.cwd(),
246
+ pid: process.pid,
247
+ host: o.host ?? (0, node_os_1.hostname)(),
248
+ processScoped,
249
+ since: since ?? iso,
250
+ heartbeat: iso,
251
+ version: version_1.VERSION,
252
+ };
253
+ }
254
+ let tmpSeq = 0;
255
+ /** A scratch path in the store directory. Never ends in `.json`, so `listClaims` and
256
+ * `findSeed`-style scans skip it even if one is ever left behind. */
257
+ function tmpPath(p) {
258
+ return `${p}.tmp-${process.pid}-${(tmpSeq += 1)}`;
259
+ }
260
+ /** Replace a claim we are already entitled to. `rename` is atomic on POSIX, so a
261
+ * concurrent reader sees the old content or the new one, never a half-written file. */
262
+ function writeAtomic(p, c) {
263
+ const tmp = tmpPath(p);
264
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(c, null, 2));
265
+ (0, node_fs_1.renameSync)(tmp, p);
266
+ }
267
+ /**
268
+ * Create a claim ONLY if the device is unclaimed, and make it appear fully-formed.
269
+ *
270
+ * Write-then-link, not `writeFileSync(…, {flag:'wx'})`. Both are exclusive — `link()` fails
271
+ * EEXIST just as `wx` does — but `wx` creates the file EMPTY and fills it a moment later,
272
+ * while `link()` publishes an already-written file in one step. That difference decides who
273
+ * owns the device, because a racer landing in the gap reads zero bytes, the tolerant reader
274
+ * correctly calls that corrupt, and corrupt-is-takeable hands it the device too.
275
+ *
276
+ * The gap is microseconds on an idle box and does not reproduce there — it was observed
277
+ * with `wx` under real load (two e2e suites plus an emulator), where six processes racing
278
+ * for one device produced THREE winners. Load is exactly when parallel agents contend, so
279
+ * closing it structurally beats relying on the scheduler.
280
+ */
281
+ function writeExclusive(p, c) {
282
+ const tmp = tmpPath(p);
283
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(c, null, 2));
284
+ try {
285
+ (0, node_fs_1.linkSync)(tmp, p);
286
+ return true;
287
+ }
288
+ catch (e) {
289
+ if (e.code === 'EEXIST')
290
+ return false;
291
+ throw e;
292
+ }
293
+ finally {
294
+ try {
295
+ (0, node_fs_1.unlinkSync)(tmp);
296
+ }
297
+ catch {
298
+ /* the link succeeded or never happened; either way the scratch file is disposable */
299
+ }
300
+ }
301
+ }
302
+ /**
303
+ * Take the device, or report who has it.
304
+ *
305
+ * The happy path is an exclusive publish (`writeExclusive`): if no claim existed, we now own
306
+ * it, and no concurrent starter can have won the same device. Only when that fails do we
307
+ * look at who is there — and taking over a dead claim goes unlink-then-publish for the same
308
+ * reason, so exactly one of several racing takers wins instead of two overwriting each other
309
+ * and both believing they own it.
310
+ */
311
+ function claimDevice(serial, platform, o = {}) {
312
+ const r = claimDeviceInner(serial, platform, o);
313
+ if (r.ok)
314
+ acquired.add(serial);
315
+ return r;
316
+ }
317
+ /**
318
+ * How many times to re-race for a device whose claim keeps being cleared under us. Each
319
+ * extra round costs one exclusive create against a device nobody live holds, so this is a
320
+ * bound on pathological churn, not a retry budget anyone should hit.
321
+ */
322
+ const CLAIM_ATTEMPTS = 4;
323
+ /**
324
+ * Run `fn` holding the exclusive right to replace this device's claim, or return null if
325
+ * another taker holds it.
326
+ *
327
+ * Why a token at all: POSIX offers no "remove this file only if it is still the one I
328
+ * read". So "the claim is dead, unlink it and publish mine" is unsound on its own — between
329
+ * the read and the unlink another taker can publish, and the unlink then deletes a LIVE
330
+ * claim, handing the device to two jobs. Measured, not theoretical: 16 racers over one dead
331
+ * claim produced two winners.
332
+ *
333
+ * `link()` gives a sound absent-to-present CAS, so it can elect a single taker. Holding that
334
+ * token, the winner is the only writer of `p` and may replace it atomically — no unlink, no
335
+ * window. The token is held across three syscalls and no I/O, so the only way to strand one
336
+ * is to die inside it; liveness is therefore the owning PID alone, which is exact. That is
337
+ * the difference that matters: a dead CLAIM is the routine steady state (every finished job
338
+ * leaves one), whereas a dead TOKEN needs a crash inside a microsecond window.
339
+ */
340
+ function withTakeover(p, serial, platform, o, fn) {
341
+ const tok = `${p}.takeover`;
342
+ const me = record(serial, platform, o);
343
+ if (!writeExclusive(tok, me)) {
344
+ const other = readClaimAt(tok);
345
+ if (other && !tokenAbandoned(other, o))
346
+ return null; // a live taker is mid-takeover
347
+ try {
348
+ (0, node_fs_1.unlinkSync)(tok);
349
+ }
350
+ catch {
351
+ /* another racer cleared it first */
352
+ }
353
+ if (!writeExclusive(tok, me))
354
+ return null;
355
+ }
356
+ try {
357
+ return fn();
358
+ }
359
+ finally {
360
+ try {
361
+ (0, node_fs_1.unlinkSync)(tok);
362
+ }
363
+ catch {
364
+ /* best effort: a stray token is cleared by the next taker */
365
+ }
366
+ }
367
+ }
368
+ /** A takeover token outlives its process only if that process died holding it. Judged on the
369
+ * PID alone — no TTL — because it is never held across anything slow. */
370
+ function tokenAbandoned(t, o) {
371
+ if (t.host !== (o.host ?? (0, node_os_1.hostname)()))
372
+ return false; // not ours to judge; leave it alone
373
+ return !pidAlive(t.pid);
374
+ }
375
+ function claimDeviceInner(serial, platform, o) {
376
+ const p = pathFor(serial, o);
377
+ try {
378
+ (0, node_fs_1.mkdirSync)(claimsDir(o), { recursive: true });
379
+ }
380
+ catch (e) {
381
+ return uncoordinated(serial, platform, p, e, o);
382
+ }
383
+ // PUBLISH FIRST, then judge. The invariant this shape exists to hold is: `ok: true` is
384
+ // returned only when an EXCLUSIVE create actually succeeded, or the claim on disk is
385
+ // already ours. Deciding first and writing afterwards cannot hold it — between the
386
+ // decision and the write another racer can publish, and both would report success.
387
+ for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt++) {
388
+ const fresh = record(serial, platform, o);
389
+ try {
390
+ if (writeExclusive(p, fresh))
391
+ return { ok: true, claim: fresh };
392
+ }
393
+ catch (e) {
394
+ return uncoordinated(serial, platform, p, e, o);
395
+ }
396
+ // Something is there. Note that `held === null` covers BOTH "corrupt" and "vanished a
397
+ // microsecond ago", which is why neither may be treated as ours: the only proof of
398
+ // ownership is a create that succeeded.
399
+ const held = readClaimAt(p);
400
+ if (held && isMine(held, o)) {
401
+ // Keep `since` — how long this job has held the device is the interesting number.
402
+ const refreshed = record(serial, platform, o, held.since);
403
+ try {
404
+ writeAtomic(p, refreshed);
405
+ }
406
+ catch {
407
+ /* the file is ours either way; a failed refresh only ages the heartbeat */
408
+ }
409
+ return { ok: true, claim: refreshed };
410
+ }
411
+ if (held && isLive(held, o))
412
+ return { ok: false, held };
413
+ // Dead, or unreadable (a claim nobody can parse would otherwise hold a device forever).
414
+ // Replacing it must be serialized — see `withTakeover` for why unlink-then-create is
415
+ // not enough. Losing the token is not an error; the next attempt re-reads and finds
416
+ // whoever won.
417
+ const taken = withTakeover(p, serial, platform, o, () => {
418
+ // Re-read INSIDE the token. Another taker may have legitimately claimed the device
419
+ // between our judgement and our turn, and this is the only point at which that
420
+ // answer cannot change under us.
421
+ const now = readClaimAt(p);
422
+ if (now && isMine(now, o))
423
+ return { ok: true, claim: now };
424
+ if (now && isLive(now, o))
425
+ return { ok: false, held: now };
426
+ // We are the sole writer of `p` while the token is held, so an atomic replace is
427
+ // safe here — no unlink, and therefore no window in which it could delete a claim
428
+ // newer than the one we judged dead.
429
+ const mine = record(serial, platform, o);
430
+ writeAtomic(p, mine);
431
+ return { ok: true, claim: mine };
432
+ });
433
+ if (taken)
434
+ return taken;
435
+ }
436
+ // Beaten every round. Refusing is the only safe answer left: reporting success here,
437
+ // without a published claim, is precisely how two jobs end up on one device.
438
+ const last = readClaimAt(p);
439
+ if (last && isMine(last, o))
440
+ return { ok: true, claim: last };
441
+ return { ok: false, held: last };
442
+ }
443
+ /**
444
+ * The store is unusable — unwritable home, full disk, a permissions problem. Coordination
445
+ * is a convenience, so this degrades to "drive the device anyway" rather than becoming a
446
+ * new way for a run to fail. The caller proceeds UNCOORDINATED, which is the honest state:
447
+ * no claim was published, and this job is invisible to others.
448
+ */
449
+ function uncoordinated(serial, platform, p, e, o) {
450
+ (0, output_1.err)(`[verikun] could not write device claim ${p} (${e.message}) — continuing unclaimed`);
451
+ return { ok: true, claim: record(serial, platform, o) };
452
+ }
453
+ /** Refresh my heartbeat. Never throws, never steals: a claim that became someone else's is left alone. */
454
+ function touchClaim(serial, platform, o = {}) {
455
+ if (!claimsEnabled(o.env ?? process.env))
456
+ return;
457
+ try {
458
+ const held = readClaim(serial, o);
459
+ if (held && !isMine(held, o))
460
+ return;
461
+ if (!held) {
462
+ claimDevice(serial, platform, o);
463
+ return;
464
+ }
465
+ writeAtomic(pathFor(serial, o), record(serial, platform, o, held.since));
466
+ }
467
+ catch {
468
+ /* bookkeeping must never fail a command */
469
+ }
470
+ }
471
+ /**
472
+ * Drop a claim. `mineOnly` is what the automatic paths (run archive, the `ai`/`batch`/
473
+ * `suite` teardown) pass, so a job can only ever hand back its own device; `vk device
474
+ * release` omits it and says so on stderr, because that is the deliberate break-glass.
475
+ */
476
+ function releaseClaim(serial, o = {}) {
477
+ try {
478
+ const held = readClaim(serial, o);
479
+ if (!held)
480
+ return null;
481
+ if (!isMine(held, o) && o.mineOnly)
482
+ return null;
483
+ (0, node_fs_1.unlinkSync)(pathFor(serial, o));
484
+ acquired.delete(serial);
485
+ return held;
486
+ }
487
+ catch {
488
+ return null;
489
+ }
490
+ }
491
+ // --- selection --------------------------------------------------------------
492
+ /** Column form for a table: `workspace 'islamabad' · 3m ago`, or `this job` for our own. */
493
+ function describeClaim(c, o = {}) {
494
+ if (isMine(c, o))
495
+ return 'this job';
496
+ return `${holderLabel(c)} · ${fmtAge(ageMs(c, o.now ?? Date.now()))} ago`;
497
+ }
498
+ /** What to say when a device is demonstrably contended but no holder could be read — a
499
+ * claim being cleared and retaken faster than it can be observed. Rare, and honest. */
500
+ const CONTENDED = 'another job (claim contended)';
501
+ /** Sentence form, for a refusal that reads as prose rather than as a cell. */
502
+ function describeHolder(c, o) {
503
+ return `${holderLabel(c)} (last seen ${fmtAge(ageMs(c, o.now ?? Date.now()))} ago)`;
504
+ }
505
+ function holderLabel(c) {
506
+ const dir = (0, node_path_1.basename)(c.cwd || '');
507
+ if (dir && dir !== '/' && dir !== '.')
508
+ return `workspace '${dir}'`;
509
+ return c.session ? `session ${c.session}` : 'another job';
510
+ }
511
+ /** Local copy of run.ts's private age formatter — five lines, and importing run.ts here
512
+ * would drag the whole recorder into the drivers' dependency graph for a string. */
513
+ function fmtAge(ms) {
514
+ if (!Number.isFinite(ms))
515
+ return 'unknown';
516
+ const m = Math.floor(ms / 60000);
517
+ if (m < 1)
518
+ return `${Math.max(0, Math.floor(ms / 1000))}s`;
519
+ if (m < 60)
520
+ return `${m}m`;
521
+ return `${Math.floor(m / 60)}h${m % 60}m`;
522
+ }
523
+ function label(c) {
524
+ return c.model ? `${c.serial} (${c.model})` : c.serial;
525
+ }
526
+ /**
527
+ * Pick a device nobody else is driving, and claim it in the same breath.
528
+ *
529
+ * Candidates are tried in the backend's own listing order, so an unclaimed phone keeps
530
+ * being reused across runs rather than round-robined — a device that worked last time is
531
+ * the one most likely to work now. A lost `wx` race is simply the next candidate's turn.
532
+ *
533
+ * Throws exit 2 when every candidate is held: the shape is "you must choose", the same as
534
+ * its sibling `Multiple devices connected`, and `--device` is the fix. Not exit 3 — the
535
+ * machine is fine, it is just busy.
536
+ */
537
+ function selectAndClaim(candidates, platform, o = {}) {
538
+ const skipped = [];
539
+ for (const candidate of candidates) {
540
+ const r = claimDevice(candidate.serial, platform, o);
541
+ if (r.ok)
542
+ return { serial: candidate.serial, skipped, total: candidates.length };
543
+ skipped.push({ candidate, held: r.held });
544
+ }
545
+ const width = Math.max(...skipped.map((s) => label(s.candidate).length), 0);
546
+ const rows = skipped
547
+ .map((s) => ` ${label(s.candidate).padEnd(width)} ${s.held ? describeClaim(s.held, o) : CONTENDED}`)
548
+ .join('\n');
549
+ throw new errors_1.CliError(`Every attached device is in use:\n${rows}\n` +
550
+ 'Wait for one, free it with `verikun device release <serial>`, ' +
551
+ 'or set VERIKUN_NO_CLAIM=1 to ignore claims.', 2);
552
+ }
553
+ /**
554
+ * Claim the device the caller named explicitly, or refuse.
555
+ *
556
+ * `alternatives` is a THUNK because the Android fast path never lists devices when
557
+ * `--device` is given — it spawns nothing at all — and paying for an `adb devices` round
558
+ * trip only to decorate an error we are not going to raise would tax every run to help
559
+ * the rare one. On the failure path the extra call is free in comparison.
560
+ */
561
+ function assertClaimable(serial, platform, alternatives = () => [], o = {}) {
562
+ const r = claimDevice(serial, platform, o);
563
+ if (r.ok)
564
+ return;
565
+ // Decorating an error must never be able to replace it. Listing alternatives means
566
+ // asking the platform what is attached, which throws its own exit 3 when nothing is
567
+ // (a plausible state here: the device you named is claimed AND now unplugged).
568
+ let free = [];
569
+ try {
570
+ free = alternatives().filter((c) => c.serial !== serial && !isHeldByOther(c.serial, o));
571
+ }
572
+ catch {
573
+ /* no suggestions, then — the refusal below is the message that matters */
574
+ }
575
+ const lines = [
576
+ `${serial} is in use by ${r.held ? describeHolder(r.held, o) : CONTENDED}.`,
577
+ ...(free.length ? [` free now: ${free.map(label).join(', ')}`] : []),
578
+ ` if that job is gone: verikun device release ${serial}`,
579
+ ' to ignore claims: VERIKUN_NO_CLAIM=1',
580
+ ];
581
+ throw new errors_1.CliError(lines.join('\n'), 2);
582
+ }
583
+ function isHeldByOther(serial, o) {
584
+ const c = readClaim(serial, o);
585
+ return !!c && !isMine(c, o) && isLive(c, o);
586
+ }
587
+ /** Resolve a device list into `vk devices` rows: who holds what, and is it me. */
588
+ function summarize(serial, o = {}) {
589
+ const c = readClaim(serial, o);
590
+ if (!c || !isLive(c, o))
591
+ return undefined;
592
+ const mine = isMine(c, o);
593
+ return { ...c, mine, by: mine ? 'this job' : describeClaim(c, o) };
594
+ }
@@ -11,6 +11,7 @@ const exec_1 = require("../exec");
11
11
  const android_parse_1 = require("../ui/android-parse");
12
12
  const viewport_1 = require("../ui/viewport");
13
13
  const settings_1 = require("../device/settings");
14
+ const claims_1 = require("../device/claims");
14
15
  const output_1 = require("../output");
15
16
  const ADB = process.env.ADB || 'adb';
16
17
  const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
@@ -214,9 +215,35 @@ class AdbDriver {
214
215
  if (this.cachedSerial)
215
216
  return this.cachedSerial;
216
217
  if (this.requested) {
218
+ // An explicit --device is trusted verbatim and costs no `adb devices` round trip —
219
+ // so the claim check here is one small file read, and the list of free alternatives
220
+ // for the refusal message is only paid for if we are actually going to refuse.
221
+ if ((0, claims_1.claimsEnabled)())
222
+ (0, claims_1.assertClaimable)(this.requested, 'android', () => this.usableDevices());
217
223
  this.cachedSerial = this.requested;
218
224
  return this.cachedSerial;
219
225
  }
226
+ const usable = this.usableDevices();
227
+ if (!(0, claims_1.claimsEnabled)()) {
228
+ if (usable.length > 1) {
229
+ const list = usable.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
230
+ throw new errors_1.CliError(`Multiple devices connected; pass --device <serial> (or set VERIKUN_DEVICE):\n${list}`, 2);
231
+ }
232
+ this.cachedSerial = usable[0].serial;
233
+ return this.cachedSerial;
234
+ }
235
+ // Claims turn "ambiguous, you decide" into "one is free, take it" — and a single
236
+ // attached device still goes through here, because "someone else is on the only
237
+ // phone" is exactly where being told beats finding out twenty minutes later.
238
+ const picked = (0, claims_1.selectAndClaim)(usable, 'android');
239
+ if (picked.total > 1) {
240
+ (0, output_1.err)(`[verikun] auto-selected ${picked.serial} — ${picked.total} attached, ${picked.skipped.length} held by another job`);
241
+ }
242
+ this.cachedSerial = picked.serial;
243
+ return this.cachedSerial;
244
+ }
245
+ /** Attached devices in a drivable state, or the honest exit-3 explanation of why none is. */
246
+ usableDevices() {
220
247
  const all = this.listDevices();
221
248
  const usable = all.filter((d) => d.state === 'device');
222
249
  if (usable.length === 0) {
@@ -226,12 +253,7 @@ class AdbDriver {
226
253
  }
227
254
  throw new errors_1.CliError('No Android devices/emulators connected. Start one, then `verikun devices`.', 3);
228
255
  }
229
- if (usable.length > 1) {
230
- const list = usable.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
231
- throw new errors_1.CliError(`Multiple devices connected; pass --device <serial> (or set VERIKUN_DEVICE):\n${list}`, 2);
232
- }
233
- this.cachedSerial = usable[0].serial;
234
- return this.cachedSerial;
256
+ return usable;
235
257
  }
236
258
  withSerial(args) {
237
259
  return ['-s', this.resolvedSerial(), ...args];
@@ -12,6 +12,7 @@ const exec_1 = require("../exec");
12
12
  const ios_parse_1 = require("../ui/ios-parse");
13
13
  const viewport_1 = require("../ui/viewport");
14
14
  const settings_1 = require("../device/settings");
15
+ const claims_1 = require("../device/claims");
15
16
  const output_1 = require("../output");
16
17
  // iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
17
18
  // simulator — screenshots, app lifecycle, and logs (no extra install needed).
@@ -217,6 +218,8 @@ class IdbDriver {
217
218
  const sims = this.simulators();
218
219
  const simUdids = new Set(sims.map((d) => d.serial));
219
220
  if (this.requested) {
221
+ if ((0, claims_1.claimsEnabled)())
222
+ (0, claims_1.assertClaimable)(this.requested, 'ios', () => this.claimCandidates(sims));
220
223
  this.cachedSerial = this.requested;
221
224
  this.cachedIsSim = simUdids.has(this.requested);
222
225
  return this.cachedSerial;
@@ -225,19 +228,39 @@ class IdbDriver {
225
228
  // drivable target, so prefer it. Only weigh physical devices when no simulator
226
229
  // is booted — and only genuinely "connected" ones (devicectl also lists paired-
227
230
  // but-idle devices as "available (paired)", which must not count as active).
228
- const bootedSims = sims.filter((d) => d.state === 'booted');
229
- const candidates = bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
231
+ const candidates = this.claimCandidates(sims);
230
232
  if (candidates.length === 0) {
231
233
  throw new errors_1.CliError('No booted iOS simulator or connected device. Boot one (Simulator.app / `xcrun simctl boot`), then `verikun devices`.', 3);
232
234
  }
233
- if (candidates.length > 1) {
234
- const list = candidates.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
235
- throw new errors_1.CliError(`Multiple iOS targets; pass --device <udid> (or set VERIKUN_DEVICE):\n${list}`, 2);
235
+ let serial;
236
+ if (!(0, claims_1.claimsEnabled)()) {
237
+ if (candidates.length > 1) {
238
+ const list = candidates.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
239
+ throw new errors_1.CliError(`Multiple iOS targets; pass --device <udid> (or set VERIKUN_DEVICE):\n${list}`, 2);
240
+ }
241
+ serial = candidates[0].serial;
236
242
  }
237
- this.cachedSerial = candidates[0].serial;
238
- this.cachedIsSim = simUdids.has(candidates[0].serial);
243
+ else {
244
+ const picked = (0, claims_1.selectAndClaim)(candidates, 'ios');
245
+ if (picked.total > 1) {
246
+ (0, output_1.err)(`[verikun] auto-selected ${picked.serial} — ${picked.total} available, ${picked.skipped.length} held by another job`);
247
+ }
248
+ serial = picked.serial;
249
+ }
250
+ this.cachedSerial = serial;
251
+ this.cachedIsSim = simUdids.has(serial);
239
252
  return this.cachedSerial;
240
253
  }
254
+ /**
255
+ * The targets auto-resolution will consider, in preference order. A booted simulator is
256
+ * the first-class, unambiguously drivable target, so prefer it; only weigh physical
257
+ * devices when no simulator is booted — and only genuinely "connected" ones (devicectl
258
+ * also lists paired-but-idle devices as "available (paired)", which must not count).
259
+ */
260
+ claimCandidates(sims) {
261
+ const bootedSims = sims.filter((d) => d.state === 'booted');
262
+ return bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
263
+ }
241
264
  udid() {
242
265
  return this.resolvedSerial();
243
266
  }
package/dist/errors.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // stable, agent-readable exit statuses:
4
4
  // 0 success / found / assertion passed
5
5
  // 1 not found / assertion failed / wait timeout
6
- // 2 usage error or ambiguous selector (caller must refine)
7
- // 3 environment error (adb/simctl missing, no/multiple devices, dump failed)
6
+ // 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
7
+ // 3 environment error (adb/simctl missing, no usable device, dump failed)
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.AmbiguousSelectorError = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
10
10
  exports.isEnvError = isEnvError;
package/dist/run.js CHANGED
@@ -477,6 +477,16 @@ class Recorder {
477
477
  capture(driver, quiet = false) {
478
478
  if (!driver)
479
479
  return;
480
+ // No device, no evidence. When resolution itself is what failed — nothing attached,
481
+ // or another job holds every device — there is no screen to photograph, and both
482
+ // attempts below would re-raise that same error and print it again. Staying silent
483
+ // here is what keeps ONE failure reading as one message instead of three.
484
+ try {
485
+ driver.resolvedSerial();
486
+ }
487
+ catch {
488
+ return;
489
+ }
480
490
  try {
481
491
  // Full resolution on purpose — a human reads this in the report — but via the
482
492
  // raw path, which reaches the same PNG without the device-side encode.
package/dist/server.js CHANGED
@@ -36,6 +36,7 @@ const args_1 = require("./args");
36
36
  const errors_1 = require("./errors");
37
37
  const drivers_1 = require("./drivers");
38
38
  const manager_1 = require("./companion/manager");
39
+ const claims_1 = require("./device/claims");
39
40
  const output_1 = require("./output");
40
41
  const ir_1 = require("./agent/ir");
41
42
  const rpc_1 = require("./rpc");
@@ -376,6 +377,9 @@ async function cmdServer(positionals, flags) {
376
377
  // toolchain can't drive it — before binding a port. preflight() covers resolving the
377
378
  // device AND the tools; without it the server happily listens on a box with no idb
378
379
  // and then 500s every /v1/exec.
380
+ // The server owns its device for as long as it listens, so its pid is exact liveness
381
+ // evidence — set before resolving, since that is where the claim is taken.
382
+ (0, claims_1.setProcessScoped)(true);
379
383
  const driver = (0, drivers_1.getDriver)(platform, device);
380
384
  driver.preflight();
381
385
  const serial = driver.resolvedSerial();
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.1';
6
+ exports.VERSION = '0.22.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.21.1",
3
+ "version": "0.22.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",