verikun 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -50,6 +50,8 @@ exports.chooseLogOpts = chooseLogOpts;
50
50
  exports.evalAssert = evalAssert;
51
51
  exports.tokenizeLine = tokenizeLine;
52
52
  exports.withBatchGlobals = withBatchGlobals;
53
+ exports.serverFromFlags = serverFromFlags;
54
+ exports.ensureDeviceTarget = ensureDeviceTarget;
53
55
  exports.terminalFailure = terminalFailure;
54
56
  exports.executeForServer = executeForServer;
55
57
  exports.run = run;
@@ -60,8 +62,10 @@ const errors_1 = require("./errors");
60
62
  const exec_1 = require("./exec");
61
63
  const drivers_1 = require("./drivers");
62
64
  const adb_1 = require("./drivers/adb");
65
+ const lifecycle_1 = require("./drivers/lifecycle");
63
66
  const settings_1 = require("./device/settings");
64
67
  const claims_1 = require("./device/claims");
68
+ const prep_1 = require("./device/prep");
65
69
  const selector_1 = require("./ui/selector");
66
70
  const state_support_1 = require("./ui/state-support");
67
71
  const format_1 = require("./ui/format");
@@ -79,9 +83,9 @@ const cache_1 = require("./agent/cache");
79
83
  const cost_1 = require("./agent/cost");
80
84
  const remote_1 = require("./agent/remote");
81
85
  const suite_1 = require("./suite");
86
+ const wait_1 = require("./wait");
82
87
  const version_1 = require("./version");
83
88
  const update_check_1 = require("./update-check");
84
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
89
  // Exported for src/server.ts (which resolves its own platform/device at startup).
86
90
  function platformFromFlags(flags) {
87
91
  if ((0, args_1.flagBool)(flags, 'ios'))
@@ -217,7 +221,7 @@ async function matchWaiting(ctx, sel, opts = {}) {
217
221
  const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
218
222
  if (res.matches.length > 0 || Date.now() >= deadline)
219
223
  return res;
220
- await sleep(pollStep(ctx.flags, deadline));
224
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
221
225
  }
222
226
  }
223
227
  /**
@@ -242,7 +246,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
242
246
  const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
243
247
  throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
244
248
  }
245
- await sleep(pollStep(ctx.flags, deadline));
249
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
246
250
  }
247
251
  }
248
252
  // --- Auto scroll-into-view --------------------------------------------------
@@ -299,7 +303,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
299
303
  break; // in view, or no swipe big enough to be worth making
300
304
  ctx.driver.swipe(plan.from.x, plan.from.y, plan.to.x, plan.to.y, (0, viewport_1.swipeDurationMs)(plan.distance));
301
305
  swipes++;
302
- await sleep(SCROLL_SETTLE_MS);
306
+ await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
303
307
  const before = current.bounds;
304
308
  snapshot = ctx.driver.getElements(opts);
305
309
  // An empty tree is a bad read, not a screen (the device returns partial dumps
@@ -312,7 +316,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
312
316
  // (half of it, to land between the two) and look once more before giving up.
313
317
  const mid = { x: Math.round((plan.from.x + plan.to.x) / 2), y: Math.round((plan.from.y + plan.to.y) / 2) };
314
318
  ctx.driver.swipe(plan.to.x, plan.to.y, mid.x, mid.y, (0, viewport_1.swipeDurationMs)(plan.distance / 2));
315
- await sleep(SCROLL_SETTLE_MS);
319
+ await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
316
320
  snapshot = ctx.driver.getElements(opts);
317
321
  if ((0, selector_1.matchElements)(snapshot, sel).matches.length === 0) {
318
322
  throw new errors_1.SelectorNotFoundError(`'${sel.raw}' left the hierarchy while being scrolled into view (after ${swipes} swipe(s)) — ` +
@@ -388,10 +392,28 @@ async function resolveTappable(ctx, sel, opts = {}) {
388
392
  // ---------------------------------------------------------------------------
389
393
  // Commands
390
394
  // ---------------------------------------------------------------------------
391
- function cmdDevices(ctx) {
395
+ function cmdDevices(flags) {
396
+ const all = (0, args_1.flagBool)(flags, 'all');
392
397
  const allDevices = [];
393
398
  try {
394
- allDevices.push(...new drivers_1.AdbDriver().listDevices());
399
+ const android = new drivers_1.AdbDriver().listDevices();
400
+ // Name each running emulator by its AVD, so the listing shows the token you'd
401
+ // pass to `vk devices stop|restart`. Best-effort: the console may not answer.
402
+ for (const d of android) {
403
+ if (d.kind === 'emulator')
404
+ d.name = (0, adb_1.avdNameOf)(d.serial) || undefined;
405
+ }
406
+ allDevices.push(...android);
407
+ if (all) {
408
+ // Startable-but-not-running AVDs have no adb address yet — they are addressed
409
+ // by name, so `serial` stays empty rather than being invented.
410
+ const running = new Set(android.map((d) => (d.name ?? '').toLowerCase()).filter(Boolean));
411
+ for (const name of (0, adb_1.listAvds)()) {
412
+ if (running.has(name.toLowerCase()))
413
+ continue;
414
+ allDevices.push({ serial: '', state: 'shutdown', platform: 'android', kind: 'emulator', name });
415
+ }
416
+ }
395
417
  }
396
418
  catch (e) {
397
419
  // adb not on PATH is the common (expected) case, but surface anything else so a real
@@ -399,8 +421,10 @@ function cmdDevices(ctx) {
399
421
  (0, output_1.err)(`devices: adb backend unavailable (${e.message})`);
400
422
  }
401
423
  try {
402
- // Only include booted simulators; always include physical devices (they carry a note)
403
- allDevices.push(...new drivers_1.IdbDriver().listDevices().filter((d) => d.state === 'booted' || d.note));
424
+ // By default only booted simulators (a shutdown one isn't drivable); physical
425
+ // devices always show (they carry a note). --all lists everything startable.
426
+ const ios = new drivers_1.IdbDriver().listDevices();
427
+ allDevices.push(...(all ? ios : ios.filter((d) => d.state === 'booted' || d.note)));
404
428
  }
405
429
  catch (e) {
406
430
  (0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
@@ -414,22 +438,140 @@ function cmdDevices(ctx) {
414
438
  d.claim = claim;
415
439
  }
416
440
  }
417
- if ((0, args_1.flagBool)(ctx.flags, 'json')) {
441
+ if ((0, args_1.flagBool)(flags, 'json')) {
418
442
  (0, output_1.json)(allDevices);
419
443
  return 0;
420
444
  }
421
445
  if (!allDevices.length) {
422
- (0, output_1.err)('No devices found.');
446
+ (0, output_1.err)(all ? 'No devices or startable AVDs/simulators found.' : 'No devices found.');
423
447
  return 0;
424
448
  }
425
449
  for (const line of formatDeviceTable(allDevices))
426
450
  (0, output_1.out)(line);
427
451
  return 0;
428
452
  }
453
+ /** Aliases for the lifecycle verbs, mirroring the top-level command aliases. */
454
+ const DEVICE_VERBS = {
455
+ start: 'start',
456
+ boot: 'start',
457
+ stop: 'stop',
458
+ shutdown: 'stop',
459
+ kill: 'stop',
460
+ restart: 'restart',
461
+ reboot: 'restart',
462
+ };
463
+ /**
464
+ * `vk devices [list|start|stop|restart]`. Dispatched as a META-command (before the
465
+ * recording machinery) whenever a subcommand is present: lifecycle needs no local
466
+ * driver — it is what you run when there ISN'T one — and a `--server` invocation
467
+ * must not build one either. Bare `vk devices` keeps the ordinary path.
468
+ */
469
+ async function cmdDevicesEntry(positionals, flags) {
470
+ const sub = String(positionals[0] ?? 'list').toLowerCase();
471
+ const server = serverFromFlags(flags);
472
+ if (sub === 'list')
473
+ return server ? cmdDevicesRemoteList(server, flags) : cmdDevices(flags);
474
+ const verb = DEVICE_VERBS[sub];
475
+ if (!verb) {
476
+ throw new errors_1.CliError(`Unknown 'devices' subcommand '${sub}'. Use: list | start | stop | restart.`, 2);
477
+ }
478
+ return server
479
+ ? cmdDevicesRemote(verb, positionals[1], server, flags)
480
+ : cmdDeviceLifecycle(verb, positionals[1], flags);
481
+ }
482
+ /**
483
+ * Refuse to power-cycle a device another job is actively driving.
484
+ *
485
+ * The local counterpart of the server's `409`: you may restart YOUR OWN device, never
486
+ * someone else's. Without this, device claims and device lifecycle would each work and
487
+ * together be a hole — one job could reboot the phone another job is mid-suite on,
488
+ * which is precisely the collision claims exist to prevent.
489
+ *
490
+ * `start` is exempt: it only ever brings up something that is not running, so there is
491
+ * nothing to pull out from under anyone.
492
+ */
493
+ function assertNotClaimedByOthers(target, verb) {
494
+ if (verb === 'start' || !target.serial || !(0, claims_1.claimsEnabled)())
495
+ return;
496
+ const claim = (0, claims_1.summarize)(target.serial);
497
+ if (!claim || claim.mine)
498
+ return;
499
+ throw new errors_1.CliError(`${(0, lifecycle_1.targetLabel)(target)} (${target.serial}) is in use by ${claim.by} — ` +
500
+ `${verb} would pull the device out from under it.\n` +
501
+ `If that job is gone, hand the device back first: vk device release ${target.serial}`, 2);
502
+ }
503
+ async function cmdDeviceLifecycle(verb, rawTarget, flags) {
504
+ const wipe = (0, args_1.flagBool)(flags, 'wipe');
505
+ const wait = !(0, args_1.flagBool)(flags, 'no-wait');
506
+ const timeoutFlag = (0, args_1.flagStr)(flags, 'timeout');
507
+ const timeoutMs = timeoutFlag
508
+ ? parseDuration(timeoutFlag, 'timeout')
509
+ : verb === 'stop'
510
+ ? wait_1.DEFAULT_STOP_TIMEOUT_MS
511
+ : wait_1.DEFAULT_BOOT_TIMEOUT_MS;
512
+ // An explicit --ios/--android/--platform narrows the search; otherwise probe both
513
+ // backends, exactly as bare `vk devices` does, so `devices start Pixel_6_API_34`
514
+ // works without also naming the platform. A name that hits BOTH is still exit 2.
515
+ const explicitPlatform = (0, args_1.flagBool)(flags, 'ios') || (0, args_1.flagBool)(flags, 'android') || (0, args_1.flagStr)(flags, 'platform') !== undefined;
516
+ const lifecycles = explicitPlatform ? [(0, lifecycle_1.lifecycleFor)(platformFromFlags(flags))] : (0, lifecycle_1.allLifecycles)();
517
+ const targets = lifecycles.flatMap((l) => l.targets());
518
+ if (!rawTarget) {
519
+ const startable = targets.filter((t) => t.kind !== 'physical').map(lifecycle_1.targetLabel).filter(Boolean);
520
+ const hint = startable.length
521
+ ? `\nStartable: ${[...new Set(startable)].join(', ')}`
522
+ : '\nRun `vk devices --all` to list startable devices.';
523
+ throw new errors_1.CliError(`Usage: verikun devices ${verb} <name|serial> [--timeout <dur>]${hint}`, 2);
524
+ }
525
+ // `start` wants the one that ISN'T up; `stop`/`restart` want the live one.
526
+ const chosen = (0, lifecycle_1.chooseTarget)(targets, rawTarget, { prefer: verb === 'start' ? 'startable' : 'running' });
527
+ (0, lifecycle_1.assertActionable)(chosen, verb, { wipe });
528
+ assertNotClaimedByOthers(chosen, verb);
529
+ const lc = (0, lifecycle_1.lifecycleFor)(chosen.platform);
530
+ const onProgress = (m) => (0, output_1.err)(`[verikun] ${m}`);
531
+ const asJson = (0, args_1.flagBool)(flags, 'json');
532
+ if (verb === 'stop') {
533
+ const r = await lc.shutdown(chosen, { timeoutMs, onProgress });
534
+ if (asJson) {
535
+ (0, output_1.json)({ action: 'stop', requested: rawTarget, platform: chosen.platform, kind: chosen.kind, serial: r.serial, name: chosen.name, stopped: r.stopped });
536
+ }
537
+ else {
538
+ (0, output_1.err)(r.stopped ? `stopped ${(0, lifecycle_1.targetLabel)(chosen)}` : `${(0, lifecycle_1.targetLabel)(chosen)} was not running`);
539
+ (0, output_1.out)(r.serial);
540
+ }
541
+ return 0;
542
+ }
543
+ const opts = { timeoutMs, wait, wipe, onProgress };
544
+ const r = verb === 'restart'
545
+ ? await (0, lifecycle_1.restartTarget)(lc, chosen, opts)
546
+ : { ...(await lc.boot(chosen, opts)), stopped: false };
547
+ if (asJson) {
548
+ (0, output_1.json)({
549
+ action: verb,
550
+ requested: rawTarget,
551
+ platform: chosen.platform,
552
+ kind: chosen.kind,
553
+ serial: r.serial,
554
+ name: chosen.name,
555
+ started: r.started,
556
+ ready: r.ready,
557
+ waitedMs: r.waitedMs,
558
+ ...(r.logPath ? { logPath: r.logPath } : {}),
559
+ });
560
+ }
561
+ else {
562
+ if (verb === 'start' && !r.started)
563
+ (0, output_1.err)(`${(0, lifecycle_1.targetLabel)(chosen)} is already running`);
564
+ // stdout is the one machine-usable datum: the serial, so
565
+ // `vk -d "$(vk devices start X)" ui` composes. Under --no-wait on Android there
566
+ // is genuinely no serial yet, so print nothing rather than invent one.
567
+ (0, output_1.out)(r.serial ?? '');
568
+ }
569
+ return 0;
570
+ }
429
571
  /**
430
572
  * Render the device list as an aligned, headed table (header line first, then one
431
- * line per device). Optional columns (MODEL/PRODUCT/NOTE) are dropped when no device
432
- * populates them; every shown cell is padded to its column width so columns line up
573
+ * line per device). Optional columns (KIND/NAME/MODEL/PRODUCT/NOTE) are dropped when no
574
+ * device populates them; every shown cell is padded to its column width so columns line up
433
575
  * regardless of which cells are empty — the previous `.filter(Boolean).join('\t')`
434
576
  * dropped empty cells, sliding later cells into earlier tab stops. Exported for unit
435
577
  * testing.
@@ -437,8 +579,10 @@ function cmdDevices(ctx) {
437
579
  function formatDeviceTable(devices) {
438
580
  const columns = [
439
581
  { header: 'PLATFORM', get: (d) => d.platform },
582
+ { header: 'KIND', get: (d) => d.kind ?? '', optional: true },
440
583
  { header: 'SERIAL', get: (d) => d.serial },
441
584
  { header: 'STATE', get: (d) => d.state },
585
+ { header: 'NAME', get: (d) => d.name ?? '', optional: true },
442
586
  { header: 'MODEL', get: (d) => d.model ?? '', optional: true },
443
587
  { header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
444
588
  // Optional like the rest, which is load-bearing twice over: nothing changes for a
@@ -513,10 +657,38 @@ async function cmdDoctor(ctx) {
513
657
  const c = claims ? (0, claims_1.summarize)(serial) : undefined;
514
658
  return c && !c.mine ? c : undefined;
515
659
  };
660
+ // Blank lines separate doctor's three blocks — toolchain, device inventory, target detail.
661
+ // Without them the target's indented lines read as a continuation of the last device row.
662
+ (0, output_1.out)('');
516
663
  (0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
517
- for (const d of devices) {
664
+ // Probe each device, not just the one an interaction command would land on: "is there a
665
+ // screen lock" and "is this prepared" are facts about a PHONE, and a host driving three of
666
+ // them cannot act on an answer that does not say which. The lock read is one `dumpsys` per
667
+ // device and the prep read is a local file, which is affordable in an explicit setup check.
668
+ const locks = new Map();
669
+ const rows = devices.map((d) => {
670
+ const facts = [];
671
+ if (d.state === 'device') {
672
+ facts.push((0, prep_1.readPrep)(d.serial) ? 'prepared' : 'not prepared');
673
+ const lock = (0, adb_1.lockKindOf)(d.serial);
674
+ locks.set(d.serial, lock);
675
+ if (lock !== 'none' && lock !== 'unknown')
676
+ facts.push(`screen lock: ${lock}`);
677
+ }
518
678
  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}]` : ''}`);
679
+ return {
680
+ serial: d.serial,
681
+ what: `${d.state}${d.model ? ` (${d.model})` : ''}`,
682
+ facts: facts.join(' · '),
683
+ claim: claim ? `[${claim.by}]` : '',
684
+ };
685
+ });
686
+ // Pad each column to its widest cell so the per-device facts line up and can be scanned
687
+ // down; trimEnd so a device with no facts and no claim leaves no trailing whitespace.
688
+ const w = (pick) => Math.max(...rows.map((r) => pick(r).length), 0);
689
+ const [wSerial, wWhat, wFacts] = [w((r) => r.serial), w((r) => r.what), w((r) => r.facts)];
690
+ for (const r of rows) {
691
+ (0, output_1.out)(` ${r.serial.padEnd(wSerial)} ${r.what.padEnd(wWhat)} ${r.facts.padEnd(wFacts)} ${r.claim}`.trimEnd());
520
692
  }
521
693
  let ok = true;
522
694
  const free = usable.filter((d) => !heldByOther(d.serial));
@@ -538,25 +710,57 @@ async function cmdDoctor(ctx) {
538
710
  // the first FREE one (which is what auto-selection picks), not merely the first attached.
539
711
  const target = ctx.device || free[0]?.serial;
540
712
  if (target) {
541
- try {
542
- const serial = target;
543
- const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
544
- const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
545
- const vals = keys.map(get);
546
- const off = vals.every((v) => v === '0' || v === '0.0');
547
- (0, output_1.out)(`animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun doctor --fix`)'}`);
548
- if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
549
- for (const k of keys)
550
- (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
551
- (0, output_1.out)('animations: disabled (good)');
552
- }
553
- }
554
- catch {
555
- (0, output_1.err)('animations: could not read device settings');
713
+ reportTargetStatus(target, adb, locks.get(target) ?? 'unknown');
714
+ // `--fix` is now an alias for `device prep`, so it inherits prep's gate: on a physical
715
+ // device it refuses unless the serial was named. That refusal is the point — the old
716
+ // `--fix` wrote permanently to whichever phone happened to be unclaimed, with no undo.
717
+ if ((0, args_1.flagBool)(ctx.flags, 'fix')) {
718
+ const prepFlags = (0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle') ? { 'no-sleep-when-idle': true } : {};
719
+ return devicePrep({ ...ctx, flags: prepFlags });
556
720
  }
557
721
  }
558
722
  return ok ? 0 : 3;
559
723
  }
724
+ /**
725
+ * The extra detail for the ONE device an interaction command would land on. Every line names
726
+ * that serial, because the listing above may have shown several and an advisory you cannot
727
+ * attribute to a phone is not actionable.
728
+ *
729
+ * Reads NOTHING through `ctx.driver`, deliberately — resolving a driver claims the device, and
730
+ * doctor surveys the host without taking anything (see the note at the device listing above).
731
+ * The prep record is a plain file read; the animation probe goes through `adb -s <serial>`
732
+ * directly. `lock` is passed in rather than re-probed, so the listing's read is the only one.
733
+ */
734
+ function reportTargetStatus(serial, adb, lock) {
735
+ (0, output_1.out)('');
736
+ (0, output_1.out)(`target: ${serial} — the device an interaction command would use`);
737
+ const rec = (0, prep_1.readPrep)(serial);
738
+ const when = rec?.preparedAt ? ` (${rec.preparedAt.slice(0, 10)})` : '';
739
+ (0, output_1.out)(rec
740
+ ? ` prepared${when} — undo with \`verikun device prep --revert --device ${serial}\``
741
+ : ` not prepared — run \`verikun device prep --device ${serial}\``);
742
+ // Animations are still called out by name rather than folded into "not prepared": they are
743
+ // the single most common cause of a flaky dump, and a device can be prepped and then drift.
744
+ try {
745
+ const vals = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'].map((k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim());
746
+ const off = vals.every((v) => Number(v) === 0);
747
+ (0, output_1.out)(` animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun device prep`)'}`);
748
+ }
749
+ catch {
750
+ (0, output_1.err)(` animations: could not read settings on ${serial}`);
751
+ }
752
+ // Advisory, never a failure: a screen lock is a property of someone's phone, not a broken
753
+ // machine. It is worth saying because of what a slept device actually does — the read
754
+ // SUCCEEDS and returns the keyguard, so selectors miss for a reason unrelated to the app.
755
+ if (lock !== 'none' && lock !== 'unknown') {
756
+ // Indented to sit with the target's other lines, but it still NAMES the serial: this goes
757
+ // to stderr (it is a diagnostic, not data), so under `vk doctor > out.txt` it appears on
758
+ // its own with no `target:` header above it to say which phone it means.
759
+ (0, output_1.err)(` screen lock on ${serial} (${lock}) — while it is up, a read returns the lock screen ` +
760
+ 'rather than the app.\n' +
761
+ ' Remove it on a test device (Settings > Security). verikun never asks for a PIN.');
762
+ }
763
+ }
560
764
  function cmdUi(ctx) {
561
765
  const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
562
766
  if ((0, args_1.flagBool)(ctx.flags, 'json')) {
@@ -650,13 +854,13 @@ async function cmdText(ctx) {
650
854
  });
651
855
  ctx.driver.tap(point.x, point.y);
652
856
  // Wait for field to be focused after tap
653
- await sleep(100);
857
+ await (0, wait_1.sleep)(100);
654
858
  if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
655
859
  ctx.driver.pressKey('move_end');
656
860
  for (let i = 0; i < target.text.length + 2; i++)
657
861
  ctx.driver.pressKey('del');
658
862
  // Wait for field to settle after clearing before typing
659
- await sleep(200);
863
+ await (0, wait_1.sleep)(200);
660
864
  }
661
865
  // Prime the input method with a space, then delete it, to avoid losing first character
662
866
  // (workaround for adb input text behavior where first char is sometimes lost)
@@ -887,7 +1091,7 @@ async function cmdWait(ctx) {
887
1091
  (0, output_1.out)((0, format_1.formatCompact)(matches));
888
1092
  return 0;
889
1093
  }
890
- await sleep(interval);
1094
+ await (0, wait_1.sleep)(interval);
891
1095
  }
892
1096
  ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
893
1097
  (0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
@@ -938,7 +1142,7 @@ async function cmdAssert(ctx) {
938
1142
  const deadline = Date.now() + waitWindowMs(ctx.flags);
939
1143
  let result = evalAssert(readForPoll(ctx), sel, ctx.flags);
940
1144
  while (!result.pass && Date.now() < deadline) {
941
- await sleep(pollStep(ctx.flags, deadline));
1145
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
942
1146
  result = evalAssert(readForPoll(ctx), sel, ctx.flags);
943
1147
  }
944
1148
  const { pass, reason, matches } = result;
@@ -1015,7 +1219,8 @@ function cmdClear(ctx) {
1015
1219
  // of applying dark mode and then dying — a half-applied device is worse than none.
1016
1220
  // - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
1017
1221
  // `device reset` can undo it even from a later process.
1018
- const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps | device release [serial]\n' +
1222
+ const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | ' +
1223
+ 'device prep [--revert] [--dry-run] | device caps | device release [serial]\n' +
1019
1224
  `Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
1020
1225
  function cmdDevice(ctx) {
1021
1226
  const sub = (ctx.positionals[0] ?? '').toLowerCase();
@@ -1028,6 +1233,8 @@ function cmdDevice(ctx) {
1028
1233
  return deviceGet(ctx, rest);
1029
1234
  case 'reset':
1030
1235
  return deviceReset(ctx, rest);
1236
+ case 'prep':
1237
+ return devicePrep(ctx);
1031
1238
  case 'caps':
1032
1239
  return deviceCaps(ctx);
1033
1240
  case 'release':
@@ -1168,6 +1375,143 @@ function deviceReset(ctx, args) {
1168
1375
  (0, output_1.out)(summary ? `device reset ${summary}` : 'device reset: nothing to restore');
1169
1376
  return 0;
1170
1377
  }
1378
+ /**
1379
+ * `vk device prep` — establish the test-device knob set, stickily.
1380
+ *
1381
+ * The whole point is that this OUTLIVES the run, so unlike `deviceSet` the snapshot does not
1382
+ * go into the run file (which `ai`/`suite`/`batch` auto-restore from a `finally`) but into the
1383
+ * host-global prep store. See device/prep.ts for why the two stores exist.
1384
+ *
1385
+ * Unsupported and no-op knobs are SKIPPED with a note rather than failing the command, which
1386
+ * is the difference between prep and `device set`: `set` was told which key to change and must
1387
+ * refuse if it cannot, while prep means "establish what this platform can" — so on iOS, where
1388
+ * every knob is a noop or unsupported, it honestly reports doing nothing instead of erroring.
1389
+ */
1390
+ function devicePrep(ctx) {
1391
+ const asJson = (0, args_1.flagBool)(ctx.flags, 'json');
1392
+ const dryRun = (0, args_1.flagBool)(ctx.flags, 'dry-run');
1393
+ const serial = ctx.driver.resolvedSerial();
1394
+ if ((0, args_1.flagBool)(ctx.flags, 'revert'))
1395
+ return devicePrepRevert(ctx, serial, dryRun, asJson);
1396
+ // Partition by what this platform can actually do, so the gate below counts real writes.
1397
+ const skipped = [];
1398
+ const applicable = [];
1399
+ for (const knob of prep_1.PREP_KNOBS) {
1400
+ const spec = settings_1.SETTINGS[knob.key];
1401
+ const support = spec.support[ctx.platform];
1402
+ if (support === 'unsupported') {
1403
+ skipped.push({ key: knob.key, reason: spec.manual[ctx.platform] ?? `not supported on ${ctx.platform}` });
1404
+ continue;
1405
+ }
1406
+ if (support === 'noop') {
1407
+ skipped.push({ key: knob.key, reason: spec.note[ctx.platform] ?? 'already satisfied on this platform' });
1408
+ continue;
1409
+ }
1410
+ // parse() so the target compares equal to a readback (`max` -> the millisecond string).
1411
+ applicable.push({
1412
+ key: knob.key,
1413
+ target: spec.parse(knob.value),
1414
+ current: ctx.driver.getDeviceSetting(knob.key),
1415
+ why: knob.why,
1416
+ });
1417
+ }
1418
+ const changes = applicable.filter((k) => k.current !== k.target);
1419
+ // `--dry-run` is deliberately NOT gated: it writes nothing, and refusing it would mean you
1420
+ // could not find out what prep would do to a phone without first asserting it is a test one.
1421
+ if (dryRun) {
1422
+ if (asJson) {
1423
+ (0, output_1.json)({ serial, prepared: (0, prep_1.isPrepared)(serial), dryRun: true, changes, skipped });
1424
+ }
1425
+ else {
1426
+ (0, output_1.out)(`device prep ${serial} (dry run — nothing written)`);
1427
+ for (const k of applicable) {
1428
+ const state = k.current === k.target ? 'already' : `${showValue(k.current)} -> ${k.target}`;
1429
+ (0, output_1.out)(` ${k.key.padEnd(15)} ${state}`);
1430
+ if (k.current !== k.target)
1431
+ (0, output_1.out)(` ${' '.repeat(15)} ${k.why}`);
1432
+ }
1433
+ for (const s of skipped)
1434
+ (0, output_1.out)(` ${s.key.padEnd(15)} skipped: ${s.reason.replace(/\n/g, ' ')}`);
1435
+ }
1436
+ return 0;
1437
+ }
1438
+ // The gate, immediately before the first write. `!!ctx.device` is true for --device AND
1439
+ // VERIKUN_DEVICE: both are a deliberate act naming one phone, which is the property asked for.
1440
+ const kind = ctx.driver.listDevices().find((d) => d.serial === serial)?.kind ?? 'physical';
1441
+ (0, prep_1.assertPreppable)(kind, serial, !!ctx.device, applicable.length);
1442
+ const original = {};
1443
+ for (const k of applicable) {
1444
+ // Snapshot even a knob already at target: `--revert` has to put back what the device
1445
+ // held before prep, and "it was already off" is exactly as much a fact as a change.
1446
+ if (k.current !== null)
1447
+ original[k.key] = k.current;
1448
+ if (k.current !== k.target)
1449
+ ctx.driver.setDeviceSetting(k.key, k.target);
1450
+ }
1451
+ // Earliest wins across re-preps, or `--revert` would restore the device to prepped.
1452
+ const prior = (0, prep_1.readPrep)(serial);
1453
+ const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
1454
+ (0, prep_1.writePrep)((0, prep_1.newPrepRecord)(serial, ctx.platform, (0, prep_1.mergeOriginals)(prior?.original ?? {}, original), sleepWhenIdle));
1455
+ const applied = Object.fromEntries(applicable.map((k) => [k.key, k.target]));
1456
+ ctx.record?.note({ message: `device prep ${serial} (${changes.length} changed)` });
1457
+ if (asJson) {
1458
+ (0, output_1.json)({ serial, applied, changed: changes.map((c) => c.key), skipped, sleepWhenIdle });
1459
+ }
1460
+ else {
1461
+ (0, output_1.out)(`device prep ${serial}: ${changes.length ? changes.map((c) => `${c.key}=${c.target}`).join(' ') : 'already prepared'}`);
1462
+ for (const s of skipped)
1463
+ (0, output_1.err)(`note: ${s.key} skipped — ${s.reason.replace(/\n/g, ' ')}`);
1464
+ if (sleepWhenIdle)
1465
+ (0, output_1.err)('note: this device will be put to sleep when a run using it finishes');
1466
+ (0, output_1.err)(`undo with: verikun device prep --revert --device ${serial}`);
1467
+ }
1468
+ return 0;
1469
+ }
1470
+ /** Put a prepared device back the way it was found, and forget it. */
1471
+ function devicePrepRevert(ctx, serial, dryRun, asJson) {
1472
+ const rec = (0, prep_1.readPrep)(serial);
1473
+ if (!rec) {
1474
+ if (asJson)
1475
+ (0, output_1.json)({ serial, prepared: false, restored: {} });
1476
+ else
1477
+ (0, output_1.out)(`device prep --revert ${serial}: not prepared, nothing to restore`);
1478
+ return 0;
1479
+ }
1480
+ const entries = Object.entries(rec.original);
1481
+ if (dryRun) {
1482
+ if (asJson)
1483
+ (0, output_1.json)({ serial, prepared: true, dryRun: true, wouldRestore: rec.original });
1484
+ else {
1485
+ (0, output_1.out)(`device prep --revert ${serial} (dry run — nothing written)`);
1486
+ for (const [key, value] of entries)
1487
+ (0, output_1.out)(` ${key.padEnd(15)} ${showValue(ctx.driver.getDeviceSetting(key))} -> ${value}`);
1488
+ }
1489
+ return 0;
1490
+ }
1491
+ const restored = {};
1492
+ const failed = [];
1493
+ for (const [key, value] of entries) {
1494
+ try {
1495
+ ctx.driver.setDeviceSetting(key, value);
1496
+ restored[key] = value;
1497
+ }
1498
+ catch (e) {
1499
+ // Best-effort, like `device reset`: one stubborn knob must not strand the rest.
1500
+ failed.push(`${key} (${e instanceof Error ? e.message.split('\n')[0] : String(e)})`);
1501
+ }
1502
+ }
1503
+ // Forget the device even if a knob refused. Keeping the record would leave `vk doctor`
1504
+ // reporting it as prepped forever, and the values that DID go back are already back.
1505
+ (0, prep_1.clearPrep)(serial);
1506
+ ctx.record?.note({ message: `device prep --revert ${serial}` });
1507
+ if (failed.length)
1508
+ (0, output_1.err)(`warning: could not restore ${failed.join(', ')}`);
1509
+ if (asJson)
1510
+ (0, output_1.json)({ serial, restored, ...(failed.length ? { failed } : {}) });
1511
+ else
1512
+ (0, output_1.out)(`device prep --revert ${serial}: ${Object.entries(restored).map(([k, v]) => `${k}=${v}`).join(' ') || 'nothing to restore'}`);
1513
+ return 0;
1514
+ }
1171
1515
  function deviceCaps(ctx) {
1172
1516
  const rows = settings_1.SETTING_KEYS.map((key) => {
1173
1517
  const spec = settings_1.SETTINGS[key];
@@ -1212,6 +1556,35 @@ function requireSettingKey(v) {
1212
1556
  * same code. (Remote is a known gap: the overrides live in the *server's* run file, so
1213
1557
  * a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
1214
1558
  */
1559
+ /**
1560
+ * Park prepared devices when the flow that used them ends — #97's "in sleep mode when they're
1561
+ * not [in use]".
1562
+ *
1563
+ * Three properties worth keeping:
1564
+ *
1565
+ * * It can only ever touch a device you explicitly PREPPED, and only if that prep did not
1566
+ * pass `--no-sleep-when-idle`. A borrowed phone that was never prepped is never slept.
1567
+ * * It goes through the driver rather than the command dispatcher, so it does not appear as
1568
+ * a `key sleep` testcase in the report. Parking is host hygiene, not a test step.
1569
+ * * `ownClaimedSerials()` is empty in `--server` mode (the local process never resolves a
1570
+ * device), so the remote path degrades to doing nothing on its own, with no branch here.
1571
+ * Managing a remote device's power is `--allow-device-control`'s job.
1572
+ *
1573
+ * Must run BEFORE `releaseOwnClaims()`, which is what it reads its serials from.
1574
+ */
1575
+ function parkPreparedDevices(platform) {
1576
+ for (const serial of (0, claims_1.ownClaimedSerials)()) {
1577
+ if (!(0, prep_1.readPrep)(serial)?.sleepWhenIdle)
1578
+ continue;
1579
+ try {
1580
+ (0, output_1.err)(`[verikun] parking prepared device ${serial} (sleep)`);
1581
+ (0, drivers_1.getDriver)(platform, serial).pressKey('sleep');
1582
+ }
1583
+ catch {
1584
+ /* teardown must never throw — the device may be exactly why we are unwinding */
1585
+ }
1586
+ }
1587
+ }
1215
1588
  async function restoreDeviceOverrides(backend) {
1216
1589
  if (!run_1.Recorder.hasDeviceOverrides())
1217
1590
  return;
@@ -1492,6 +1865,7 @@ async function cmdBatch(positionals, batchFlags) {
1492
1865
  /* the device may be exactly why we are unwinding — never mask the real error */
1493
1866
  }
1494
1867
  }
1868
+ parkPreparedDevices(platformFromFlags(batchFlags));
1495
1869
  (0, claims_1.releaseOwnClaims)();
1496
1870
  }
1497
1871
  }
@@ -1550,8 +1924,9 @@ function providerRequirement(model) {
1550
1924
  return id === 'openai' ? 'OPENAI_API_KEY is not set' : 'ANTHROPIC_API_KEY is not set';
1551
1925
  }
1552
1926
  /** Route the model to its backend. HTTP providers read their own key; a CLI provider shells
1553
- * out to its logged-in binary. Unavailable → null (compile/repair off), the same graceful
1554
- * degradation as before: a cached plan can still replay for free without any provider.
1927
+ * out to its logged-in binary. Unavailable → null (compile/repair off). That is NOT a free
1928
+ * replay path: runAiTest turns a null provider into exit 3 even at a 100% cache hit, because
1929
+ * it must be able to repair a drifted step at runtime — only --show-plan degrades gracefully.
1555
1930
  * CLI providers get no `model`, so the CLI picks its own default — the "I just have a
1556
1931
  * subscription" path; --effort is likewise inapplicable to them. */
1557
1932
  function makeProvider(opts) {
@@ -1624,9 +1999,136 @@ async function obtainPlan(key, file, opts, cost, provider) {
1624
1999
  }
1625
2000
  return { plan: compiled.plan, cached: false };
1626
2001
  }
2002
+ /** The `--server` URL, or VERIKUN_SERVER. Exported-shape helper so `resolveBackend`
2003
+ * and `vk devices --server` can never disagree about what "remote" means. */
2004
+ function serverFromFlags(flags) {
2005
+ return (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
2006
+ }
2007
+ function remoteOptsFrom(url, flags) {
2008
+ return { url, authKey: (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined };
2009
+ }
2010
+ /**
2011
+ * `--ensure-device[=<target>]` — the requested target, or `undefined` for the bare
2012
+ * form, or `null` when the flag is absent. Exported for unit tests.
2013
+ */
2014
+ function ensureDeviceTarget(flags) {
2015
+ const raw = flags['ensure-device'];
2016
+ if (raw === undefined || raw === false)
2017
+ return null;
2018
+ if (raw === true || raw === 'true')
2019
+ return undefined;
2020
+ return String(raw);
2021
+ }
2022
+ /** The one target that is startable without guessing, else exit 2 with the options. */
2023
+ function soleStartable(targets) {
2024
+ const startable = targets.filter((t) => t.kind !== 'physical' && !(0, lifecycle_1.isRunning)(t));
2025
+ if (startable.length === 1)
2026
+ return startable[0];
2027
+ const names = [...new Set(startable.map(lifecycle_1.targetLabel).filter(Boolean))];
2028
+ throw new errors_1.CliError(startable.length === 0
2029
+ ? '--ensure-device: no startable device found. Run `vk devices --all` to check the toolchain.'
2030
+ : `--ensure-device: ${startable.length} devices could be started — name one, ` +
2031
+ `e.g. --ensure-device="${names[0]}"\n ${names.join('\n ')}`, 2);
2032
+ }
2033
+ /**
2034
+ * Boot a device before the first step, when `--ensure-device` asks for it. Runs here
2035
+ * — inside resolveBackend, before runCtx is fixed — rather than inside `vk suite`,
2036
+ * so the booted serial is the one every recorded step is attributed to. It is
2037
+ * structurally unable to turn a red test green: it happens before any step executes,
2038
+ * never between tests and never mid-run.
2039
+ */
2040
+ async function ensureLocalDevice(platform, device, flags) {
2041
+ const want = ensureDeviceTarget(flags);
2042
+ if (want === null)
2043
+ return;
2044
+ try {
2045
+ const serial = (0, drivers_1.getDriver)(platform, device).resolvedSerial();
2046
+ // `--ensure-device=X` means "boot X if nothing is usable", not "make X the
2047
+ // device" — so say so plainly when a different device is already serving,
2048
+ // rather than reporting a serial the caller didn't ask for.
2049
+ (0, output_1.err)(`[verikun] --ensure-device: ${serial} is already available${want ? ` — not booting '${want}'` : ''}`);
2050
+ return;
2051
+ }
2052
+ catch (e) {
2053
+ // Ambiguity (exit 2) is an operator error — booting another device makes it worse.
2054
+ if (!(e instanceof errors_1.CliError) || e.exitCode !== 3)
2055
+ throw e;
2056
+ }
2057
+ const lc = (0, lifecycle_1.lifecycleFor)(platform);
2058
+ const targets = lc.targets();
2059
+ const chosen = want ? (0, lifecycle_1.chooseTarget)(targets, want, { prefer: 'startable' }) : soleStartable(targets);
2060
+ (0, lifecycle_1.assertActionable)(chosen, 'start');
2061
+ (0, output_1.err)(`[verikun] --ensure-device: booting ${(0, lifecycle_1.targetLabel)(chosen)}…`);
2062
+ await lc.boot(chosen, {
2063
+ timeoutMs: wait_1.DEFAULT_BOOT_TIMEOUT_MS,
2064
+ wait: true,
2065
+ wipe: false,
2066
+ onProgress: (m) => (0, output_1.err)(`[verikun] ${m}`),
2067
+ });
2068
+ }
2069
+ /** The remote half of `--ensure-device`: ask the server to boot, then re-ping so the
2070
+ * run context carries the serial the device actually came up as. */
2071
+ async function ensureRemoteDevice(health, opts, url, flags) {
2072
+ const want = ensureDeviceTarget(flags);
2073
+ if (want === null)
2074
+ return health;
2075
+ if (health.serial) {
2076
+ (0, output_1.err)(`[verikun] --ensure-device: ${health.serial} is already bound on ${url}${want ? ` — not booting '${want}'` : ''}`);
2077
+ return health;
2078
+ }
2079
+ assertDeviceControl(health, url, want);
2080
+ (0, output_1.err)(`[verikun] --ensure-device: asking ${url} to boot a device…`);
2081
+ await (0, remote_1.remoteDeviceOp)(opts, 'start', want ? { target: want } : {});
2082
+ return (0, remote_1.pingServer)(opts);
2083
+ }
2084
+ /** Feature-detect device control from /v1/health. Never compare versions: a client
2085
+ * cannot tell "old server" from "new server, flag off", and both need this answer. */
2086
+ function assertDeviceControl(health, url, target) {
2087
+ if (!health.deviceControlEnabled) {
2088
+ throw new errors_1.CliError(`device control is not available on the verikun server at ${url} (verikun ${health.version}). ` +
2089
+ 'Restart it with --allow-device-control.', 3);
2090
+ }
2091
+ if (target && !health.deviceNamingEnabled) {
2092
+ throw new errors_1.CliError(`the server at ${url} was started with a bare --allow-device-control — it can only act on its own ` +
2093
+ `device, not '${target}'. Restart it with --allow-device-control=<names>.`, 2);
2094
+ }
2095
+ }
2096
+ async function cmdDevicesRemote(verb, target, url, flags) {
2097
+ const opts = remoteOptsFrom(url, flags);
2098
+ const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
2099
+ assertDeviceControl(health, url, target);
2100
+ const wipe = (0, args_1.flagBool)(flags, 'wipe');
2101
+ (0, output_1.err)(`[verikun] ${verb} device on ${url}${target ? ` (${target})` : ''}${wipe ? ' with WIPE' : ''}…`);
2102
+ const r = await (0, remote_1.remoteDeviceOp)(opts, verb, { ...(target ? { target } : {}), ...(wipe ? { wipe } : {}) });
2103
+ (0, output_1.err)(`[verikun] ${r.changed ? `${verb} ok` : 'already running'} · ${r.serial ?? '(none)'} · ` +
2104
+ `${(r.durationMs / 1000).toFixed(1)}s`);
2105
+ if ((0, args_1.flagBool)(flags, 'json'))
2106
+ (0, output_1.json)({ action: verb, server: url, ...r });
2107
+ else
2108
+ (0, output_1.out)(r.serial ?? '');
2109
+ return 0;
2110
+ }
2111
+ async function cmdDevicesRemoteList(url, flags) {
2112
+ const opts = remoteOptsFrom(url, flags);
2113
+ const health = await (0, remote_1.pingServer)(opts);
2114
+ assertDeviceControl(health, url);
2115
+ const r = await (0, remote_1.remoteDeviceList)(opts);
2116
+ if ((0, args_1.flagBool)(flags, 'json')) {
2117
+ (0, output_1.json)({ server: url, ...r });
2118
+ return 0;
2119
+ }
2120
+ if (r.devices.length)
2121
+ for (const line of formatDeviceTable(r.devices))
2122
+ (0, output_1.out)(line);
2123
+ else
2124
+ (0, output_1.err)('No devices visible on the server.');
2125
+ (0, output_1.err)(`[verikun] bound: ${r.bound ?? '(none)'}${r.startable.length ? ` · startable: ${r.startable.join(', ')}` : ''}`);
2126
+ return 0;
2127
+ }
1627
2128
  async function resolveBackend(platform, device, flags) {
1628
- const server = (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
2129
+ const server = serverFromFlags(flags);
1629
2130
  if (!server) {
2131
+ await ensureLocalDevice(platform, device, flags);
1630
2132
  const driver = (0, drivers_1.getDriver)(platform, device);
1631
2133
  // Fail fast on a broken toolchain BEFORE any model spend — the mirror of the
1632
2134
  // remote branch's pingServer below. Without this, a missing `idb` isn't noticed
@@ -1680,9 +2182,13 @@ async function resolveBackend(platform, device, flags) {
1680
2182
  // so archive-time / vk log scoping works without a local driver.
1681
2183
  onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
1682
2184
  };
1683
- const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1684
- runCtx = { platform: health.platform, device: health.serial };
1685
- (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
2185
+ let health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
2186
+ // `--ensure-device` boots BEFORE runCtx is fixed: resolveBackend bakes the serial
2187
+ // into the run context and the returned device, so booting afterwards would
2188
+ // attribute every spliced step to a device that didn't exist yet.
2189
+ health = await ensureRemoteDevice(health, opts, server, flags);
2190
+ runCtx = { platform: health.platform, device: health.serial ?? undefined };
2191
+ (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}`);
1686
2192
  // Say the read path once, here. Reads execute server-side, so this is the only end of the
1687
2193
  // connection that knows it — and without it a companion that had silently stood down was
1688
2194
  // indistinguishable from one that never engaged, for a whole suite (issue #77). An older
@@ -1715,7 +2221,7 @@ async function resolveBackend(platform, device, flags) {
1715
2221
  },
1716
2222
  },
1717
2223
  platform: health.platform,
1718
- device: health.serial,
2224
+ device: health.serial ?? undefined,
1719
2225
  remote: { url: server, version: health.version, reads: health.reads },
1720
2226
  };
1721
2227
  }
@@ -1956,6 +2462,7 @@ async function cmdAi(positionals, flags) {
1956
2462
  // otherwise an unattended run leaves the phone offline or in dark mode.
1957
2463
  await restoreDeviceOverrides(backend);
1958
2464
  await backend.close?.(); // frees a remote server's device lock for the next command
2465
+ parkPreparedDevices(platform); // a prepped device goes back to sleep between runs
1959
2466
  (0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
1960
2467
  }
1961
2468
  if ((0, args_1.flagBool)(flags, 'json')) {
@@ -2044,6 +2551,7 @@ async function cmdSuiteEntry(positionals, flags) {
2044
2551
  finally {
2045
2552
  await restoreDeviceOverrides(backend);
2046
2553
  await backend.close?.();
2554
+ parkPreparedDevices(platform);
2047
2555
  (0, claims_1.releaseOwnClaims)();
2048
2556
  }
2049
2557
  }
@@ -2094,8 +2602,6 @@ function cmdCompanion(ctx) {
2094
2602
  // ---------------------------------------------------------------------------
2095
2603
  async function executeCommand(command, ctx) {
2096
2604
  switch (command) {
2097
- case 'devices':
2098
- return cmdDevices(ctx);
2099
2605
  case 'doctor':
2100
2606
  return cmdDoctor(ctx);
2101
2607
  case 'companion':
@@ -2280,6 +2786,19 @@ async function executeParsed(command, positionals, flags) {
2280
2786
  return cmdRun(positionals, flags, platform, device);
2281
2787
  if (command === 'batch')
2282
2788
  return cmdBatch(positionals, flags);
2789
+ // `devices` is wholly a meta-command: it must not build a local driver. Lifecycle is
2790
+ // what you run when there ISN'T one, a `--server` call needs no local device at all,
2791
+ // and even the bare listing probes both backends itself rather than using ctx.driver.
2792
+ // Dispatched in exactly one place on purpose — a command split across the switch AND
2793
+ // this chain reads as two commands to anything parsing the dispatch (tests/docs-coverage).
2794
+ if (command === 'devices') {
2795
+ try {
2796
+ return await cmdDevicesEntry(positionals, flags);
2797
+ }
2798
+ catch (e) {
2799
+ return mapError(e, flags);
2800
+ }
2801
+ }
2283
2802
  // `ai`/`suite`/`install`/`server` orchestrate their own steps; map their thrown
2284
2803
  // CliErrors to exit codes here (usage 2 / env 3 / …) so they honor the exit-code
2285
2804
  // contract instead of escaping to the top-level "Fatal" handler (exit 3).
@@ -2380,6 +2899,13 @@ DEVICE STATE (change the device the app runs on, then put it back)
2380
2899
  device reset [key ...] Restore what this run changed. batch/ai/suite also
2381
2900
  do this automatically when the flow ends OR fails,
2382
2901
  so a dead test can't leave the phone offline.
2902
+ device prep [--dry-run] [--json] Prepare a TEST device once, stickily: animations off,
2903
+ display kept awake, Do Not Disturb on, battery idle off.
2904
+ Survives the run (unlike \`device set\`), so it is undone
2905
+ only by \`--revert\`. A PHYSICAL device must be named with
2906
+ --device — prep must never land on a personal phone.
2907
+ --no-sleep-when-idle keeps the screen on after a run.
2908
+ device prep --revert [--dry-run] Put a prepared device back the way it was found
2383
2909
  device caps [--json] What this platform supports, and the manual
2384
2910
  equivalent where it doesn't
2385
2911
  Refuses \`airplane=on\` over wireless adb (it would cut
@@ -2435,6 +2961,7 @@ SUITE (run a directory of natural-language tests as one gated suite)
2435
2961
 
2436
2962
  SERVER (expose a locally-connected device to remote verikun clients)
2437
2963
  server [--bind addr] [--port n] [--auth-key k] [--allow-install]
2964
+ [--allow-device-control[=names]]
2438
2965
  [--allow-unsafe-anonymous] Serve THIS machine's device over HTTP+JSON for
2439
2966
  \`vk ai/suite/install --server <url>\`. Only
2440
2967
  verikun's validated action grammar is runnable
@@ -2446,16 +2973,31 @@ SERVER (expose a locally-connected device to remote verikun clients)
2446
2973
  Env: VERIKUN_SERVER_AUTH_KEY (keeps it off argv).
2447
2974
  Clients: pass --server <url> (or VERIKUN_SERVER) + --auth-key (or
2448
2975
  VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
2976
+ --allow-device-control lets a client restart/stop the server's OWN device (for a
2977
+ device gone flaky mid-suite); --allow-device-control=<avd,sim> additionally lets it
2978
+ START one of those named targets — and, with it, ERASE the device (--wipe). With
2979
+ the flag the server also starts even when no device is attached, so a client can
2980
+ boot one: \`vk devices start|stop|restart [name] --server <url>\`, or add
2981
+ --ensure-device[=name] to ai/suite/install to boot once before the first step.
2449
2982
 
2450
2983
  ENVIRONMENT
2451
- devices [--json] List attached devices/simulators, and which job is
2452
- already driving each (USED BY)
2984
+ devices [--all] [--json] List attached devices/simulators, and which job is
2985
+ already driving each (USED BY); --all also lists
2986
+ startable (not-yet-booted) AVDs/simulators
2987
+ devices start <name> [--wipe] Boot an AVD/simulator; prints its serial. Already
2988
+ [--timeout dur] [--no-wait] running = a no-op. --wipe erases its data first
2989
+ devices stop <name|serial> Shut a running emulator/simulator down
2990
+ devices restart <name> [--wipe] Stop then boot (use for a wedged/flaky device)
2453
2991
  doctor [--fix] Diagnose adb/device, and warn if this CLI or the
2454
2992
  Claude Code plugin is out of date (a warning only —
2455
2993
  it never changes the exit code). --fix disables
2456
2994
  animations. VERIKUN_NO_UPDATE_CHECK skips the check
2457
2995
  companion <status|stop> [--json] On-device hierarchy reader (Android, on by default;
2458
2996
  VERIKUN_COMPANION=0 opts out)
2997
+ Note: \`devices stop\` powers a DEVICE off; \`stop <appId>\` force-stops an APP, and
2998
+ \`device set\` (singular) changes settings on the device you are driving.
2999
+ Physical devices are never power-cycled. Env: VERIKUN_EMULATOR (path to the SDK's
3000
+ \`emulator\` binary, if it isn't on PATH or under \$ANDROID_HOME).
2459
3001
 
2460
3002
  TEST RUNS (actions are recorded; a run auto-starts on first action)
2461
3003
  run start [name] [--force] Begin a named run (else one starts implicitly)