@ishlabs/cli 0.26.0 → 0.26.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.
@@ -23,4 +23,20 @@
23
23
  * build.
24
24
  */
25
25
  import type { Command } from "commander";
26
+ export type CheckStatus = "pass" | "warn" | "fail" | "skip";
27
+ export interface Check {
28
+ /** Stable machine key (for --json). */
29
+ key: string;
30
+ /** Human label. */
31
+ name: string;
32
+ /** Coarse grouping for the human checklist. */
33
+ group: "iOS" | "Android" | "Web" | "Account";
34
+ status: CheckStatus;
35
+ message?: string;
36
+ /** One-line remediation hint shown on warn/fail. */
37
+ fix?: string;
38
+ }
39
+ export declare function runChecks(): Promise<Check[]>;
40
+ export declare function scopeChecks(checks: Check[], platform?: string): Check[];
41
+ export declare function overall(checks: Check[]): CheckStatus;
26
42
  export declare function registerDoctorCommands(program: Command): void;
@@ -28,9 +28,10 @@ import { existsSync, readdirSync } from "node:fs";
28
28
  import * as path from "node:path";
29
29
  import { runInline } from "../lib/command-helpers.js";
30
30
  import { output } from "../lib/output.js";
31
+ import { reportReadiness } from "../lib/report-readiness.js";
31
32
  import { loadConfig } from "../config.js";
32
33
  import { decodeJwtClaims, isTokenExpired } from "../auth.js";
33
- import { wdaDir } from "../lib/paths.js";
34
+ import { wdaDir, adbBin } from "../lib/paths.js";
34
35
  import pkg from "../../package.json" with { type: "json" };
35
36
  const execFileAsync = promisify(execFile);
36
37
  const XCRUN = "/usr/bin/xcrun";
@@ -52,7 +53,7 @@ async function tryExec(cmd, args, timeoutMs = 8000) {
52
53
  };
53
54
  }
54
55
  }
55
- /** adb path, mirroring the device layer's resolution (ISH_ADB → ANDROID_HOME → PATH). */
56
+ /** adb path, mirroring the device layer's resolution (ISH_ADB → SDKHomebrew → our download cache → PATH). */
56
57
  function resolveAdb() {
57
58
  for (const v of [process.env.ISH_ADB, process.env.ADB]) {
58
59
  if (v && existsSync(v))
@@ -65,6 +66,10 @@ function resolveAdb() {
65
66
  return p;
66
67
  }
67
68
  }
69
+ if (existsSync("/opt/homebrew/bin/adb"))
70
+ return "/opt/homebrew/bin/adb";
71
+ if (existsSync(adbBin()))
72
+ return adbBin();
68
73
  return "adb";
69
74
  }
70
75
  // ── Individual checks ──────────────────────────────────────────────────────
@@ -156,7 +161,7 @@ async function checkAdb() {
156
161
  const adbPath = resolveAdb();
157
162
  const r = await tryExec(adbPath, ["devices"]);
158
163
  if (!r.ok) {
159
- const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "Install Android platform-tools" };
164
+ const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "ish setup (fetches adb), or install Android platform-tools" };
160
165
  const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
161
166
  return { adb, emulator };
162
167
  }
@@ -188,7 +193,7 @@ async function checkChromium() {
188
193
  function checkCli() {
189
194
  return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
190
195
  }
191
- async function runChecks() {
196
+ export async function runChecks() {
192
197
  const { adb, emulator } = await checkAdb();
193
198
  return [
194
199
  await checkXcode(),
@@ -218,13 +223,13 @@ const PLATFORM_GROUPS = {
218
223
  android: ["Android", "Account"],
219
224
  web: ["Web", "Account"],
220
225
  };
221
- function scopeChecks(checks, platform) {
226
+ export function scopeChecks(checks, platform) {
222
227
  if (!platform)
223
228
  return checks;
224
229
  const groups = PLATFORM_GROUPS[platform];
225
230
  return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
226
231
  }
227
- function overall(checks) {
232
+ export function overall(checks) {
228
233
  if (checks.some((c) => c.status === "fail"))
229
234
  return "fail";
230
235
  if (checks.some((c) => c.status === "warn"))
@@ -274,17 +279,28 @@ export function registerDoctorCommands(program) {
274
279
  throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
275
280
  }
276
281
  const checks = scopeChecks(await runChecks(), scope);
282
+ const aggregate = overall(checks);
277
283
  if (globals.json) {
278
- output({ checks, overall: overall(checks) }, true);
284
+ output({ checks, overall: aggregate }, true);
279
285
  }
280
286
  else {
281
287
  renderHuman(checks);
282
288
  }
289
+ // Best-effort: when scoped to a native platform, report readiness to
290
+ // the backend so the web app can render a live native-readiness panel
291
+ // (the native analog of `ish connect` registering a tunnel). Never for
292
+ // the no-arg `ish check` (no single platform) or `web`. Awaited so a
293
+ // short-lived `ish check` doesn't drop the in-flight request before
294
+ // exit — `reportReadiness` is fully best-effort and never throws or
295
+ // blocks meaningfully (5s ceiling).
296
+ if (scope === "ios" || scope === "android") {
297
+ await reportReadiness(scope, checks, aggregate, globals);
298
+ }
283
299
  });
284
300
  });
285
301
  program
286
302
  .command("setup")
287
- .description("Fetch/install local-simulation dependencies (Chromium, iOS runner) and show what's left to do")
303
+ .description("Fetch/install local-simulation dependencies (Chromium, iOS runner, adb) and show what's left to do")
288
304
  .action(async (_opts, cmd) => {
289
305
  await runInline(cmd, async (globals) => {
290
306
  const quiet = globals.json;
@@ -320,7 +336,16 @@ export function registerDoctorCommands(program) {
320
336
  log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
321
337
  }
322
338
  }
323
- // 3. Re-run checks so the user sees the resulting state, and hand back
339
+ // 3. Android adb auto-fetchable (Google's standalone platform-tools).
340
+ try {
341
+ const { ensureAdb } = await import("../lib/local-sim/adb.js");
342
+ await ensureAdb();
343
+ log("Android adb: ready.");
344
+ }
345
+ catch (e) {
346
+ log(`Android adb: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
347
+ }
348
+ // 4. Re-run checks so the user sees the resulting state, and hand back
324
349
  // the structured result on --json.
325
350
  const checks = await runChecks();
326
351
  if (globals.json) {
@@ -23,6 +23,15 @@ function readTextOrAtFile(value) {
23
23
  }
24
24
  return value;
25
25
  }
26
+ /**
27
+ * Native (ios/android) interactive platforms. For these the real target app is
28
+ * supplied at run time via `ish study run --app <bundle>`, so the iteration's
29
+ * `url` is just a placeholder — we don't force the user to invent one.
30
+ * Mirrors `isNativePlatform` in `lib/local-sim/loop.ts`.
31
+ */
32
+ function isNativePlatform(platform) {
33
+ return platform === "ios" || platform === "android";
34
+ }
26
35
  /**
27
36
  * Parse a JSON-blob flag that also supports `@filepath` (read from disk).
28
37
  * Used for participant_pair `--role-criteria-a/-b` and any future blob inputs.
@@ -282,7 +291,7 @@ function buildIterationDetails(modality, opts) {
282
291
  };
283
292
  }
284
293
  default:
285
- if (!opts.url) {
294
+ if (!opts.url && !isNativePlatform(opts.platform)) {
286
295
  throw new Error("Interactive iterations require --url. Provide the URL to test.");
287
296
  }
288
297
  if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
@@ -296,10 +305,18 @@ function buildIterationDetails(modality, opts) {
296
305
  }
297
306
  screenFormat = normalized;
298
307
  }
308
+ // Native (ios/android) names its target via app_artifact (a bundle id /
309
+ // package name, or a local .app/.apk path) and carries no url. We accept
310
+ // the target from --app (preferred) or a --url passed out of habit. When
311
+ // neither is given, app_artifact is omitted = "chosen at run time".
312
+ const isNative = isNativePlatform(opts.platform);
313
+ const nativeTarget = isNative ? (opts.app ?? opts.url)?.trim() : undefined;
299
314
  return {
300
315
  type: "interactive",
301
316
  platform: opts.platform || "browser",
302
- url: opts.url,
317
+ ...(isNative
318
+ ? (nativeTarget ? { app_artifact: nativeTarget } : {})
319
+ : { url: opts.url }),
303
320
  screen_format: screenFormat,
304
321
  ...(opts.locale && { locale: opts.locale }),
305
322
  ...(opts.fileKey && { file_key: opts.fileKey }),
@@ -365,8 +382,9 @@ Concept pages: ish docs get-page concepts/iteration
365
382
  .option("--name <name>", "Iteration name (defaults to the next position letter A/B/C… if omitted)")
366
383
  .option("--description <description>", "Iteration description")
367
384
  // Interactive
368
- .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
369
- .option("--url <url>", "URL to test — interactive only")
385
+ .option("--platform <platform>", "Platform (browser, android, ios, figma, code) — interactive only")
386
+ .option("--url <url>", "URL to test — interactive only (optional for ios/android native apps)")
387
+ .option("--app <id>", "Native app bundle id (or .app/.apk path) — ios/android; supplies the iteration target so --url isn't required")
370
388
  .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted")
371
389
  .option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
372
390
  .option("--file-key <key>", "Figma file key — required when --platform=figma")
@@ -596,7 +614,7 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
596
614
  break;
597
615
  }
598
616
  default:
599
- if (!opts.url) {
617
+ if (!opts.url && !isNativePlatform(opts.platform)) {
600
618
  throw new Error("Interactive iterations require --url. Provide the URL to test.");
601
619
  }
602
620
  }
@@ -109,7 +109,7 @@ Tips:
109
109
  .option("--study <id>", "Study ID; resolves to the latest iteration on that study (alternative to --iteration)")
110
110
  .requiredOption("--person <id>", "Participant profile ID")
111
111
  .option("--language <lang>", "Language code (e.g. en, sv)")
112
- .option("--platform <platform>", "Platform (browser, android, figma, code)")
112
+ .option("--platform <platform>", "Platform (browser, android, ios, figma, code)")
113
113
  .option("--participant-type <type>", "Participant type (ai, human)", "ai")
114
114
  .addHelpText("after", "\nExamples:\n $ ish study participant create --iteration <id> --person <id>\n $ ish study participant create --study s-XXX --person p-XXX\n $ ish study participant create --iteration <id> --person <id> --platform android --json")
115
115
  .action(async (opts, cmd) => {
@@ -24,6 +24,8 @@ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredC
24
24
  // just `study run --local`. The bun-compiled binary bundles the deep
25
25
  // path so it doesn't hit Node's resolver; only the npm path is sensitive.
26
26
  import { estimateChatPair, estimateChatSolo, estimateMediaRun } from "../lib/billing.js";
27
+ import { reportReadiness } from "../lib/report-readiness.js";
28
+ import { runChecks, scopeChecks, overall } from "./doctor.js";
27
29
  function parseMaxInteractions(value) {
28
30
  const n = parseInt(value, 10);
29
31
  if (isNaN(n) || n < 1)
@@ -262,6 +264,7 @@ function readIterationDetails(details) {
262
264
  ...(screenFormat && { screenFormat }),
263
265
  ...(typeof details.locale === "string" && { locale: details.locale }),
264
266
  ...(typeof details.title === "string" && { title: details.title }),
267
+ ...(typeof details.app_artifact === "string" && { appArtifact: details.app_artifact }),
265
268
  };
266
269
  }
267
270
  /**
@@ -760,6 +763,24 @@ Examples:
760
763
  ?? platformFromApp
761
764
  ?? detailsView.platform
762
765
  ?? "browser";
766
+ // Best-effort native-readiness report. When this is a LOCAL native run
767
+ // (iOS/Android driven on this developer's machine), fire-and-forget a
768
+ // fresh, platform-scoped `runChecks()` to the backend so the web app
769
+ // can render a live native-readiness panel — the native analog of how
770
+ // `ish connect` registers a tunnel. Fully decoupled from the run:
771
+ // never awaited (must not delay dispatch), never blocks, never throws.
772
+ // Browser local runs and all remote runs are skipped (remote runs use
773
+ // Browserbase, so this machine's device readiness is irrelevant there).
774
+ if (opts.local && (resolvedPlatform === "ios" || resolvedPlatform === "android")) {
775
+ const readinessPlatform = resolvedPlatform;
776
+ void (async () => {
777
+ const scoped = scopeChecks(await runChecks(), readinessPlatform);
778
+ await reportReadiness(readinessPlatform, scoped, overall(scoped), globals, { debug: !!opts.debug });
779
+ })().catch(() => {
780
+ // Belt-and-suspenders: reportReadiness already swallows everything,
781
+ // but runChecks() / scopeChecks() must not bubble either.
782
+ });
783
+ }
763
784
  if (isPair && pairConfig) {
764
785
  // Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
765
786
  // 1. If the iteration already carries `conversations[]` from a
@@ -884,7 +905,11 @@ Examples:
884
905
  debug: opts.debug,
885
906
  parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
886
907
  platform: resolvedPlatform,
887
- ...(opts.app && { appPath: opts.app }),
908
+ // Native target: --app overrides; otherwise default from the
909
+ // iteration's remembered app_artifact so reruns need no --app.
910
+ ...((opts.app ?? detailsView.appArtifact) && {
911
+ appPath: opts.app ?? detailsView.appArtifact,
912
+ }),
888
913
  quiet: globals.quiet,
889
914
  json: globals.json,
890
915
  });
@@ -21,6 +21,36 @@ import { dirname, extname, join } from "node:path";
21
21
  import { withClient, resolveStudy } from "../lib/command-helpers.js";
22
22
  import { resolveId } from "../lib/alias-store.js";
23
23
  import { output, printTable } from "../lib/output.js";
24
+ import { ApiError } from "../lib/api-client.js";
25
+ /**
26
+ * Server-side screenshots are produced by remote interactive runs only. A
27
+ * study whose only runs were local (`ish study run --local`) has none — and the
28
+ * grouped endpoint currently 500s instead of returning an empty index. Tag this
29
+ * hint onto the error so the bare 500 points the user at the local debug report.
30
+ */
31
+ const LOCAL_RUN_SCREENSHOT_HINT = [
32
+ "Screenshots are produced by remote runs only.",
33
+ "Ran this study locally (--local)? The per-step screenshots are in the HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
34
+ ];
35
+ /**
36
+ * GET the frame-grouped screenshot index, tagging the local-run hint onto any
37
+ * ApiError before it propagates to `outputError` (which merges `.suggestions`).
38
+ */
39
+ async function getGroupedScreenshots(client, studyId) {
40
+ try {
41
+ return await client.get(`/studies/${studyId}/screenshots/grouped`);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError) {
45
+ const tagged = err;
46
+ tagged.suggestions = [
47
+ ...(Array.isArray(tagged.suggestions) ? tagged.suggestions : []),
48
+ ...LOCAL_RUN_SCREENSHOT_HINT,
49
+ ];
50
+ }
51
+ throw err;
52
+ }
53
+ }
24
54
  function projectScreenshot(s) {
25
55
  return {
26
56
  id: s.id,
@@ -106,9 +136,12 @@ Examples:
106
136
  $ ish study screenshots download <study-id> --id <scid> --out shot.png
107
137
  $ ish study screenshots download <study-id> --all --out ./shots/
108
138
 
109
- Screenshots are produced server-side by interactive runs only — chat / video /
110
- text studies don't have them. Each row's storage URL is self-credentialed,
111
- so the CLI fetches bytes without forwarding your bearer.`);
139
+ Screenshots are produced server-side by remote interactive runs only — chat /
140
+ video / text studies don't have them, and neither do local runs
141
+ (\`ish study run --local\`), which instead write a per-step HTML debug report to
142
+ ~/.ish/debug/ (the path is printed at the end of each local run). Each row's
143
+ storage URL is self-credentialed, so the CLI fetches bytes without forwarding
144
+ your bearer.`);
112
145
  screenshots
113
146
  .command("list", { isDefault: true })
114
147
  .description("List screenshots for a study (frame-grouped).")
@@ -117,7 +150,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
117
150
  .action(async (id, _opts, cmd) => {
118
151
  await withClient(cmd, async (client, globals) => {
119
152
  const studyId = resolveStudy(id);
120
- const raw = await client.get(`/studies/${studyId}/screenshots/grouped`);
153
+ const raw = await getGroupedScreenshots(client, studyId);
121
154
  const listing = projectListing(studyId, raw);
122
155
  if (globals.json) {
123
156
  output(listing, true, { preProjected: true });
@@ -172,7 +205,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
172
205
  }
173
206
  // --all: walk the index, fetch each row, save under --out dir.
174
207
  const outDir = opts.out ?? "./screenshots";
175
- const grouped = await client.get(`/studies/${studyId}/screenshots/grouped`);
208
+ const grouped = await getGroupedScreenshots(client, studyId);
176
209
  const all = [
177
210
  ...(grouped.groups ?? []).flatMap((g) => g.screenshots ?? []),
178
211
  ...(grouped.uncategorized ?? []),
@@ -40,6 +40,7 @@ export declare class ApiClient {
40
40
  iteration_id: string;
41
41
  }): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
42
42
  localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
43
+ localSimRecordInteraction(body: import("./local-sim/types.js").LocalSimInteractionRequest): Promise<import("./local-sim/types.js").LocalSimInteractionResponse>;
43
44
  localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
44
45
  localSimMatchFrame(body: {
45
46
  product_id: string;
@@ -50,6 +51,8 @@ export declare class ApiClient {
50
51
  screen_format?: string;
51
52
  full_page_screenshot_base64?: string;
52
53
  platform?: string;
54
+ previous_frame_version_id?: string;
55
+ same_screen_continuation?: boolean;
53
56
  }): Promise<{
54
57
  frame_version_id: string;
55
58
  }>;
@@ -260,8 +260,13 @@ export class ApiClient {
260
260
  async localSimStep(body) {
261
261
  return this.post("/simulation/local/step", body, { timeout: 60_000 });
262
262
  }
263
+ async localSimRecordInteraction(body) {
264
+ return this.post("/simulation/local/interaction", body, { timeout: 30_000 });
265
+ }
263
266
  async localSimRecord(body) {
264
- return this.post("/simulation/local/record", body, { timeout: 60_000 });
267
+ // Finalize now runs the pre/post survey + participant summary inline
268
+ // server-side, so it can take materially longer than a plain DB write.
269
+ return this.post("/simulation/local/record", body, { timeout: 180_000 });
265
270
  }
266
271
  async localSimMatchFrame(body) {
267
272
  return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });
package/dist/lib/docs.js CHANGED
@@ -380,6 +380,14 @@ ish iteration create --platform figma --url https://figma.com/proto \\
380
380
  --screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
381
381
  --flow-name "Onboarding A"
382
382
 
383
+ # Native app (ios / android): --app names the target, stored as app_artifact (no URL).
384
+ ish iteration create --platform ios --app com.example.app
385
+ ish iteration create --platform ios # --app optional; "chosen at run time"
386
+ # drive it locally against a booted simulator / emulator — the iteration
387
+ # remembers the app, so no --app needed on reruns:
388
+ ish study run --local
389
+ ish study run --local --app ./Build.app # override with a fresh local build
390
+
383
391
  # Text/email content from a file:
384
392
  ish iteration create --content-text @./email.html --title "Newsletter"
385
393
 
@@ -1992,10 +2000,14 @@ Interactive study runs produce per-frame screenshots server-side. They
1992
2000
  let you (or an agent) see what participants actually saw alongside the
1993
2001
  sentiment summary.
1994
2002
 
1995
- ## Screenshots — interactive studies only
2003
+ ## Screenshots — remote interactive studies only
1996
2004
 
1997
- Screenshots are produced by interactive runs only — chat / video / text
1998
- studies don't have them.
2005
+ Screenshots are produced by remote interactive runs only — chat / video /
2006
+ text studies don't have them. **Local runs** (\`ish study run --local\`,
2007
+ including ios/android) don't push screenshots to the server either; they
2008
+ write a per-step HTML debug report to \`~/.ish/debug/sim-*.html\` (the path is
2009
+ printed at the end of the run). \`ish study screenshots list\` on a local-only
2010
+ study therefore returns nothing useful — open the debug report instead.
1999
2011
 
2000
2012
  ### CLI
2001
2013
 
@@ -25,6 +25,24 @@ export declare function resolveTextValue(action: LocalStepAction, contextValues:
25
25
  * Compare two base64 screenshots to detect visible change.
26
26
  */
27
27
  export declare function detectNoVisibleChange(before: string, after: string): boolean;
28
+ /**
29
+ * Logical classification of what a completed step's batch of actions did to the
30
+ * screen, used to tell the backend's frame matcher whether the NEXT observation
31
+ * is the same logical screen (so it reuses the previous frame instead of minting
32
+ * a new one off shifted pixels).
33
+ *
34
+ * - "scroll": a pure vertical scroll (all actions succeeded) — same screen.
35
+ * - "keyboard": raised the keyboard without submitting/navigating — same screen.
36
+ * - "ui_change": anything that (likely) changed the logical screen — NOT same.
37
+ * - "none": nothing actionable happened (empty batch / think-only).
38
+ */
39
+ export type StepKind = "scroll" | "keyboard" | "ui_change" | "none";
40
+ /**
41
+ * Classify a step's batch of actions into a StepKind. Pure — `successByIndex[i]`
42
+ * is the executor's `result.success` for `actions[i]`. `think` actions are
43
+ * ignored entirely (they neither move the screen nor count toward a verdict).
44
+ */
45
+ export declare function classifyStepKind(actions: LocalStepAction[], successByIndex: boolean[]): StepKind;
28
46
  /**
29
47
  * Build a human-readable action description matching backend's format_action_detail().
30
48
  */
@@ -403,6 +403,36 @@ function isRecoverableError(err) {
403
403
  export function detectNoVisibleChange(before, after) {
404
404
  return before === after;
405
405
  }
406
+ // Scroll directions that keep us on the same logical screen (vertical pan).
407
+ // undefined/null defaults to a downward scroll in executeScroll(), so it counts.
408
+ const VERTICAL_SCROLL_DIRECTIONS = new Set([
409
+ "up", "down", "to_top", "to_bottom", "to_element",
410
+ ]);
411
+ /**
412
+ * Classify a step's batch of actions into a StepKind. Pure — `successByIndex[i]`
413
+ * is the executor's `result.success` for `actions[i]`. `think` actions are
414
+ * ignored entirely (they neither move the screen nor count toward a verdict).
415
+ */
416
+ export function classifyStepKind(actions, successByIndex) {
417
+ // Keep the original index so success lines up after dropping `think`s.
418
+ const considered = actions
419
+ .map((action, i) => ({ action, success: successByIndex[i] }))
420
+ .filter(({ action }) => action.type !== "think");
421
+ if (considered.length === 0)
422
+ return "none";
423
+ // Pure vertical scroll: every action a successful vertical scroll.
424
+ const allScroll = considered.every(({ action, success }) => action.type === "scroll" &&
425
+ success === true &&
426
+ VERTICAL_SCROLL_DIRECTIONS.has(action.direction ?? "down"));
427
+ if (allScroll)
428
+ return "scroll";
429
+ // Keyboard-only: every action a text_input that raises the keyboard WITHOUT
430
+ // submitting/navigating. A submit makes it a ui_change (it can navigate away).
431
+ const allKeyboard = considered.every(({ action }) => action.type === "text_input" && !action.submit);
432
+ if (allKeyboard)
433
+ return "keyboard";
434
+ return "ui_change";
435
+ }
406
436
  /**
407
437
  * Build a human-readable action description matching backend's format_action_detail().
408
438
  */
@@ -10,6 +10,8 @@
10
10
  * backend's 0-1000 coordinates against the screencap pixel size and taps
11
11
  * directly. (Verified by the Layer-1 driver smoke; see scripts/mobile-e2e.)
12
12
  */
13
+ /** Resolve adb, downloading Google's platform-tools on first use if not found. */
14
+ export declare function ensureAdb(): Promise<string>;
13
15
  export declare class AdbError extends Error {
14
16
  constructor(message: string);
15
17
  }
@@ -17,6 +19,27 @@ export declare class AdbError extends Error {
17
19
  export declare function adb(args: string[], timeoutMs?: number): Promise<string>;
18
20
  /** Run `adb shell <args>` and return trimmed stdout. */
19
21
  export declare function adbShell(args: string[], timeoutMs?: number): Promise<string>;
22
+ /**
23
+ * Pull versionName / versionCode out of `dumpsys package <pkg>` text. The
24
+ * relevant lines read `versionCode=42 minSdk=24 targetSdk=34` and
25
+ * `versionName=1.2.3`; `\d+` stops the build before the trailing tokens and
26
+ * `\S+` takes the version up to the next space. Returns null when neither is
27
+ * present (wrong/empty package).
28
+ */
29
+ export declare function parseDumpsysAppBuild(out: string): {
30
+ version: string | null;
31
+ build: string | null;
32
+ } | null;
33
+ /**
34
+ * Read an installed package's versionName / versionCode from
35
+ * `dumpsys package <pkg>`. Best-effort: returns null on any failure (the run
36
+ * never depends on it). Covers both freshly-installed apks and pre-installed
37
+ * packages — by call time the package name is already resolved.
38
+ */
39
+ export declare function appBuildFromDevice(pkg: string): Promise<{
40
+ version: string | null;
41
+ build: string | null;
42
+ } | null>;
20
43
  /**
21
44
  * Capture the current screen as raw PNG bytes via `adb exec-out screencap -p`.
22
45
  * `exec-out` (not `shell`) avoids the CRLF translation that corrupts binary