@ishlabs/cli 0.27.0 → 0.28.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.
Files changed (43) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/doctor.js +21 -11
  3. package/dist/commands/feedback.d.ts +22 -0
  4. package/dist/commands/feedback.js +259 -0
  5. package/dist/commands/iteration.js +13 -4
  6. package/dist/commands/study-run.js +12 -12
  7. package/dist/commands/study-screenshots.js +15 -12
  8. package/dist/commands/study.js +22 -3
  9. package/dist/commands/workspace.js +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/lib/command-helpers.js +7 -3
  12. package/dist/lib/docs.js +238 -19
  13. package/dist/lib/local-sim/actions.d.ts +18 -2
  14. package/dist/lib/local-sim/actions.js +24 -1
  15. package/dist/lib/local-sim/adb.d.ts +19 -2
  16. package/dist/lib/local-sim/adb.js +71 -23
  17. package/dist/lib/local-sim/android.js +7 -2
  18. package/dist/lib/local-sim/device-pool.d.ts +85 -0
  19. package/dist/lib/local-sim/device-pool.js +316 -0
  20. package/dist/lib/local-sim/device.d.ts +4 -0
  21. package/dist/lib/local-sim/device.js +19 -1
  22. package/dist/lib/local-sim/emulator.d.ts +50 -0
  23. package/dist/lib/local-sim/emulator.js +189 -0
  24. package/dist/lib/local-sim/install.js +23 -3
  25. package/dist/lib/local-sim/ios.d.ts +26 -1
  26. package/dist/lib/local-sim/ios.js +61 -17
  27. package/dist/lib/local-sim/loop.js +117 -11
  28. package/dist/lib/local-sim/screen-signature.js +4 -0
  29. package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
  30. package/dist/lib/local-sim/simctl-provision.js +89 -0
  31. package/dist/lib/local-sim/simctl.d.ts +6 -4
  32. package/dist/lib/local-sim/simctl.js +18 -5
  33. package/dist/lib/local-sim/types.d.ts +27 -1
  34. package/dist/lib/local-sim/xcuitest.d.ts +39 -1
  35. package/dist/lib/local-sim/xcuitest.js +70 -6
  36. package/dist/lib/output.d.ts +11 -1
  37. package/dist/lib/output.js +56 -2
  38. package/dist/lib/paths.d.ts +1 -0
  39. package/dist/lib/paths.js +3 -0
  40. package/dist/lib/skill-content.js +14 -2
  41. package/dist/lib/upload.d.ts +27 -0
  42. package/dist/lib/upload.js +108 -11
  43. package/package.json +2 -2
package/dist/lib/docs.js CHANGED
@@ -381,12 +381,19 @@ ish iteration create --platform figma --url https://figma.com/proto \\
381
381
  --flow-name "Onboarding A"
382
382
 
383
383
  # Native app (ios / android): --app names the target, stored as app_artifact (no URL).
384
+ # screen_format defaults to mobile_portrait for native (vs desktop for browser).
384
385
  ish iteration create --platform ios --app com.example.app
385
386
  ish iteration create --platform ios # --app optional; "chosen at run time"
386
387
  # drive it locally against a booted simulator / emulator — the iteration
387
388
  # remembers the app, so no --app needed on reruns:
388
389
  ish study run --local
389
390
  ish study run --local --app ./Build.app # override with a fresh local build
391
+ # State reset between participants: with a local .app build the runner does a
392
+ # clean uninstall+reinstall before each participant, so state one participant
393
+ # creates (a reminder, a saved record) does NOT leak into the next. A bare
394
+ # bundle-id / system-app target can't be reinstalled — it relaunches and warns
395
+ # that state may persist; pass --app <.app> or run one participant per study for
396
+ # a guaranteed clean start. See guides/native-app.
390
397
 
391
398
  # Text/email content from a file:
392
399
  ish iteration create --content-text @./email.html --title "Newsletter"
@@ -2000,14 +2007,22 @@ Interactive study runs produce per-frame screenshots server-side. They
2000
2007
  let you (or an agent) see what participants actually saw alongside the
2001
2008
  sentiment summary.
2002
2009
 
2003
- ## Screenshots — remote interactive studies only
2010
+ ## Screenshots — the grouped index vs. per-interaction frames
2004
2011
 
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.
2012
+ \`ish study screenshots list\` reads the **frame-grouped index**
2013
+ (\`/screenshots/grouped\`), a remote-interactive-run artifact keyed by frame
2014
+ chat / video / text studies don't populate it, and neither do local runs. On a
2015
+ local-only study this endpoint currently 500s rather than returning an empty
2016
+ index, so \`screenshots list\` isn't the way to view a local run.
2017
+
2018
+ **Local runs DO still capture per-interaction screenshots** (\`ish study run
2019
+ --local\`, including ios/android). They live on the participant interaction rows,
2020
+ not in the grouped index — read them two ways:
2021
+
2022
+ - \`ish study get <id>\` — each interaction carries a \`screenshot_url\` (a public
2023
+ storage URL you can fetch directly).
2024
+ - the per-step HTML debug report at \`~/.ish/debug/sim-*.html\` (path printed at
2025
+ the end of each local run).
2011
2026
 
2012
2027
  ### CLI
2013
2028
 
@@ -2180,6 +2195,12 @@ The CLI guarantees these contracts so agents can chain safely:
2180
2195
  \`--fields\` set, you can identify the affected resource. Default
2181
2196
  write-path JSON is compact (\`{id, alias, name, updated_at,
2182
2197
  ...changed_fields}\`); pass \`--verbose\` for the full server payload.
2198
+ - **Active-context setters emit a capturable object on stdout.** The
2199
+ \`use\` commands (\`study use\`, \`workspace use\`, \`ask use\`) write their
2200
+ human "Active … set to …" confirmation to **stderr** and, under
2201
+ \`--json\`, an \`{id, alias, name, active}\` object to **stdout** — so
2202
+ \`ish study use s-b2c --json --get alias\` is capturable. (\`--clear\`
2203
+ is a stderr-only confirmation.)
2183
2204
  - **Write-path echoes keep collection arrays even when empty.** On a
2184
2205
  create/update echo (e.g. \`study create\`/\`study update\`), entity
2185
2206
  collections like \`assignments\`, \`interview_questions\`, and
@@ -2528,6 +2549,15 @@ from envelopes that don't originate from an HTTP response (Commander
2528
2549
  parse errors, local validation failures, alias-resolution errors). Do
2529
2550
  not branch on \`status: 0\` — that value is never emitted as of 0.20.
2530
2551
 
2552
+ A **\`report\`** field appears on *genuine faults* — uncategorized
2553
+ client errors, \`server\`/5xx, network failures, and unknown throws —
2554
+ carrying the suggested \`ish feedback "…"\` invocation to report the
2555
+ bug. It is deliberately **absent** on user-actionable failures (usage /
2556
+ validation exit 2, auth exit 3, not-found exit 4, \`usage_limit_reached\`)
2557
+ so agents don't report their own input mistakes. In human mode the same
2558
+ nudge prints as a \`→ Looks like a bug? Report it: …\` line on stderr.
2559
+ See \`guides/feedback\`.
2560
+
2531
2561
  ## Conventions
2532
2562
 
2533
2563
  - Successful commands exit 0 and print one JSON object/array on stdout.
@@ -3203,22 +3233,26 @@ request time, for any client, is the backend's \`TIER_LIMITS\` dict in
3203
3233
 
3204
3234
  ## Limits enforced
3205
3235
 
3206
- | Limit | Free | Media | Starter | Pro | Enterprise |
3207
- |-----------------------------|------|-------|---------|-----|------------|
3208
- | \`maxProducts\` | 1 | 1 | ∞ | ∞ | ∞ |
3209
- | \`maxStudiesPerProduct\` | 3 | | ∞ | ∞ | ∞ |
3210
- | \`maxIterationsPerStudy\` | 2 | | ∞ | ∞ | ∞ |
3211
- | \`maxCustomPersons\` | 3 | 10 | 10 | ∞ | ∞ |
3212
- | \`maxConcurrentParticipants\` | 3 | 3 | 10 | 50 | ∞ |
3213
- | \`maxWorkspaceMembers\` | 1 | 1 | 1 | 10 | ∞ |
3236
+ | Limit | Free | Starter | Pro | Enterprise |
3237
+ |-----------------------------|------|---------|-----|------------|
3238
+ | \`maxProducts\` | 1 | 3 | ∞ | ∞ |
3239
+ | \`maxStudiesPerProduct\` | 3 | 15 | ∞ | ∞ |
3240
+ | \`maxAsksPerProduct\` | 3 | 15 | ∞ | ∞ |
3241
+ | \`maxIterationsPerStudy\` | 2 | 5 | ∞ | ∞ |
3242
+ | \`maxCustomPersons\` | 3 | 10 | | ∞ |
3243
+ | \`maxConcurrentParticipants\` | 3 | 10 | 50 | ∞ |
3244
+ | \`maxSeats\` | 1 | 1 | 10 | ∞ |
3214
3245
 
3215
3246
  Commands that may hit a limit: \`ish workspace create\`,
3216
- \`ish study create\`, \`ish study generate\`, \`ish iteration create\`,
3247
+ \`ish study create\`, \`ish study generate\`, \`ish ask create\`
3248
+ (and \`ish ask run --new\`), \`ish iteration create\`,
3217
3249
  \`ish person create\`, \`ish person generate\`.
3218
3250
 
3219
- \`maxConcurrentParticipants\` gates how many participants can be in-flight
3220
- at once per dispatch. \`maxWorkspaceMembers\` gates workspace membership
3221
- (seats). Both are enforced server-side.
3251
+ \`maxConcurrentParticipants\` caps how many participants a single run can
3252
+ include cumulative per iteration for studies, per ask for asks — and is
3253
+ enforced when participants are created/added, not at dispatch. \`maxSeats\`
3254
+ gates workspace membership (active members + pending invitations). Both are
3255
+ enforced server-side.
3222
3256
 
3223
3257
  ## What you see when a limit is hit
3224
3258
 
@@ -3270,6 +3304,7 @@ upgrade or delete an existing resource to free up headroom.
3270
3304
  that page is about *how many credits each run draws*).
3271
3305
  - \`concepts/workspace\` — \`maxProducts\` is per-account.
3272
3306
  - \`concepts/study\` — \`maxStudiesPerProduct\` gates study creation.
3307
+ - \`concepts/ask\` — \`maxAsksPerProduct\` gates ask creation.
3273
3308
  - \`concepts/iteration\` — \`maxIterationsPerStudy\` gates iteration creation.
3274
3309
  - \`concepts/person\` — \`maxCustomPersons\` gates person creation.
3275
3310
  - \`reference/json-mode\` — full error envelope shape and exit codes.
@@ -4340,6 +4375,178 @@ The viewer is only as good as the run behind it. Before sharing, make sure:
4340
4375
  - \`concepts/active-context\` — \`ish study share\` defaults to the active study.
4341
4376
  - \`reference/json-mode\` — the \`{ token, share_url, … }\` envelope.
4342
4377
  `;
4378
+ const GUIDE_NATIVE_APP = `# guide: native app studies (ios / android)
4379
+
4380
+ Run an interactive study against a **native iOS or Android app** on a local
4381
+ simulator/emulator, driven step-by-step by AI participants — the native
4382
+ counterpart of a browser (URL) interactive study.
4383
+
4384
+ ## 1. Check the local toolchain
4385
+
4386
+ \`\`\`
4387
+ ish check ios # Xcode/xcrun, a booted simulator, the WDA runner, auth
4388
+ ish check android # adb, a running emulator
4389
+ ish setup # fetch/install whatever's missing (WDA runner, etc.)
4390
+ \`\`\`
4391
+
4392
+ \`check\` must be green before a run — it verifies the whole chain (simulator
4393
+ booted → automation runner installed → logged in), so you don't discover a
4394
+ missing piece mid-run.
4395
+
4396
+ ## 2. Create the study, then a native iteration
4397
+
4398
+ The study (assignments + questionnaire) is platform-agnostic. The **iteration**
4399
+ names the platform and the app:
4400
+
4401
+ \`\`\`
4402
+ ish study create --name "Onboarding" --modality interactive \\
4403
+ --assignment "Explore:Open the app and look around" \\
4404
+ --question "How clear was it?"
4405
+
4406
+ # --app is a bundle id (already-installed / system app) OR a local .app/.apk path.
4407
+ ish iteration create --platform ios --app com.acme.app # installed bundle id
4408
+ ish iteration create --platform ios --app ./Build/MyApp.app # local build (installed for you)
4409
+ ish iteration create --platform android --app ./app-debug.apk
4410
+ \`\`\`
4411
+
4412
+ The target is stored as \`app_artifact\`; no \`--url\` is needed. \`screen_format\`
4413
+ defaults to **mobile_portrait** for native (vs desktop for browser).
4414
+
4415
+ ## 3. Run locally
4416
+
4417
+ \`\`\`
4418
+ ish study run --local --platform ios --person p-913 --wait
4419
+ ish study run --local --platform ios --all --wait # whole matching cohort
4420
+ \`\`\`
4421
+
4422
+ The platform defaults to the iteration's; \`--app\` on \`study run\` overrides the
4423
+ stored target with a fresh local build. The WebDriverAgent runner cold-starts
4424
+ slowly the first time (~30-60s) and is then reused across participants.
4425
+
4426
+ ## 3b. Parallel runs — \`--parallel N\` (iOS + Android)
4427
+
4428
+ \`\`\`
4429
+ ish study run --local --platform ios --all --parallel 5 --wait
4430
+ ish study run --local --platform android --all --parallel 5 --wait
4431
+ \`\`\`
4432
+
4433
+ Native runs can drive a **pool of N devices** at once, one participant per
4434
+ device:
4435
+ - **iOS** reuses any booted simulators and **auto-creates + boots** the
4436
+ shortfall, then deletes the simulators it created (reused ones are left alone).
4437
+ - **Android** reuses online emulators and **auto-launches headless emulators**
4438
+ (tuned low-RAM), then stops the ones it started. You only need **one AVD**: the
4439
+ pool **clones it** (a fast file-copy — no avdmanager/JDK needed) to as many as
4440
+ it needs, and deletes the clones afterward. Make one AVD in Android Studio ›
4441
+ Device Manager.
4442
+
4443
+ N is **auto-sized to the host's RAM** —
4444
+ default 1, capped at 5. A small machine runs fewer concurrently and queues the
4445
+ rest rather than erroring, so the same command works everywhere, scaled to the
4446
+ machine. Each participant still gets a clean device per the reset rules below.
4447
+
4448
+ ## 4. State reset between participants (important)
4449
+
4450
+ Participants share one simulator, run **sequentially** for native. A
4451
+ terminate+relaunch alone does NOT clear app data, so:
4452
+
4453
+ - **Local \`.app\`/\`.apk\` build** → the runner does a clean **uninstall+reinstall**
4454
+ before each participant. State one participant creates (a saved record, a new
4455
+ reminder) does NOT leak into the next.
4456
+ - **Bare bundle-id / system app** (e.g. \`com.apple.reminders\`) → can't be
4457
+ reinstalled, so it relaunches and **warns once** that earlier-participant state
4458
+ may persist. For a guaranteed clean start, pass \`--app <.app>\` or run one
4459
+ participant per study.
4460
+
4461
+ ## 5. Locale / keyboard
4462
+
4463
+ The simulator uses the host machine's keyboard locale. A non-English keyboard
4464
+ can derail text entry — pin a locale on the iteration (\`--locale en-US\`) for
4465
+ reproducible runs.
4466
+
4467
+ ## 6. Results, screenshots, transcripts
4468
+
4469
+ - \`ish study results <id>\` — sentiment + interview answers, same as any study.
4470
+ - **Per-interaction screenshots** are captured even for local runs — read them
4471
+ via \`ish study get <id>\` (each interaction carries a \`screenshot_url\`) or the
4472
+ per-step HTML debug report at \`~/.ish/debug/sim-*.html\` (path printed at the
4473
+ end of the run). Note \`ish study screenshots list\` reads the *remote-run*
4474
+ frame index and won't show local frames — see reference/screenshots.
4475
+
4476
+ ## Related
4477
+
4478
+ - \`concepts/iteration\` — \`app_artifact\`, screen_format, platforms.
4479
+ - \`reference/screenshots\` — grouped index vs per-interaction frames.
4480
+ - \`guides/first-study\` — the browser-URL version of this flow.
4481
+ `;
4482
+ const GUIDE_FEEDBACK = `# guide: report a bug or send feedback
4483
+
4484
+ \`ish feedback\` files a bug report, feature request, or general note
4485
+ straight from the terminal — to the same place the web app's "Send
4486
+ feedback" dialog goes. As the agent operating the CLI, you usually see
4487
+ a failure first; this is how you hand it to the team without leaving
4488
+ the shell.
4489
+
4490
+ \`\`\`bash
4491
+ ish feedback "the save button on the profile page does nothing"
4492
+ ish feedback --type feature "let me export interactions as CSV"
4493
+ ish feedback --type other "the guided setup was great"
4494
+ \`\`\`
4495
+
4496
+ ## The message
4497
+
4498
+ Pass the body as the positional argument, or read it from elsewhere —
4499
+ the same idioms as other text fields:
4500
+
4501
+ - \`ish feedback @bug-report.md\` — read the body from a file.
4502
+ - \`… 2>err.txt; ish feedback @err.txt\` — attach a failing command's output.
4503
+ - \`some-cmd 2>&1 | ish feedback -\` — read the body from stdin.
4504
+
4505
+ The body must be at least 10 characters. \`--title\` is optional; by
4506
+ default the first line of the message becomes the title.
4507
+
4508
+ ## Type
4509
+
4510
+ \`--type\` is one of \`bug\` (default), \`feature\`, or \`other\` — the same
4511
+ three the web dialog offers.
4512
+
4513
+ ## Diagnostics (help whoever debugs it)
4514
+
4515
+ To give the debugger as much grounded signal as possible, the report
4516
+ auto-attaches an environment + account block: ish CLI version, OS,
4517
+ node, the logged-in account, and your active workspace/study/ask. Opt
4518
+ out with \`--no-diagnostics\`.
4519
+
4520
+ \`--health\` adds more for simulation/setup bugs: it runs the \`ish check\`
4521
+ setup checklist (Xcode, simulators, adb, Chromium, account) and
4522
+ attaches the tail of \`~/.ish/local-sim.log\` when present. Use it
4523
+ whenever the bug involves \`study run --local\` or native device setup.
4524
+
4525
+ \`--dry-run\` prints exactly what would be sent (message + diagnostics)
4526
+ without submitting — preview it first if you're unsure.
4527
+
4528
+ ## When the CLI nudges you to report
4529
+
4530
+ On a *genuine fault* (uncategorized client error, 5xx/\`server\`, network
4531
+ failure, unknown throw) the CLI appends a hint: a \`report\` field in the
4532
+ \`--json\` error envelope, and a \`→ Looks like a bug? Report it: …\` line
4533
+ on stderr in human mode. It is intentionally **silent** on
4534
+ user-actionable failures (usage, validation, auth, not-found,
4535
+ usage-limit) so the nudge stays high-signal. When you see it, that
4536
+ error is worth an \`ish feedback\` — paste what you were doing and add
4537
+ \`--health\` if it was a local/native run.
4538
+
4539
+ ## Where it lands
4540
+
4541
+ Sends as the logged-in user (run \`ish login\` first) through the ish
4542
+ backend, so reports show up alongside web feedback in the team's review
4543
+ surface. Output is the created row \`{id, type, title, status}\`.
4544
+
4545
+ ## Related
4546
+
4547
+ - \`reference/json-mode\` — the \`report\` field + error envelope.
4548
+ - \`guides/native-app\` — when to reach for \`--health\`.
4549
+ `;
4343
4550
  const PAGES = [
4344
4551
  {
4345
4552
  slug: "overview",
@@ -4503,12 +4710,24 @@ const PAGES = [
4503
4710
  description: "Iterative probe loop for one specific persona: person suggest-scenarios returns LLM probes; answer them locally; person evidence add persists answers; person evidence list reads them back.",
4504
4711
  body: GUIDE_BUILD_SPECIFIC_PERSON,
4505
4712
  },
4713
+ {
4714
+ slug: "guides/native-app",
4715
+ title: "guide: native app studies (ios / android)",
4716
+ description: "Run an interactive study against a native iOS or Android app on a local simulator/emulator: check ios/android, create a --platform ios/android iteration with --app (bundle id or .app/.apk), run --local, per-participant state reset, locale/keyboard, and where local screenshots live.",
4717
+ body: GUIDE_NATIVE_APP,
4718
+ },
4506
4719
  {
4507
4720
  slug: "guides/mcp-add",
4508
4721
  title: "guide: wire ish into your AI clients (`ish mcp add`)",
4509
4722
  description: "One command wires the hosted ish MCP server into Cursor, VS Code, Claude Code, Claude Desktop, and Windsurf. Idempotent, atomic, preserves unrelated keys, no tokens written.",
4510
4723
  body: GUIDE_MCP_ADD,
4511
4724
  },
4725
+ {
4726
+ slug: "guides/feedback",
4727
+ title: "guide: report a bug or send feedback (`ish feedback`)",
4728
+ description: "File a bug/feature/other report from the terminal: message via arg/@file/stdin, --type, auto-attached environment+account diagnostics (--no-diagnostics to opt out), --health for setup checks + local-sim logs, --dry-run preview. Also: the `report` hint the CLI appends to genuine-fault errors.",
4729
+ body: GUIDE_FEEDBACK,
4730
+ },
4512
4731
  ];
4513
4732
  const PAGES_BY_SLUG = new Map(PAGES.map((p) => [p.slug, p]));
4514
4733
  export function listPages() {
@@ -10,15 +10,31 @@
10
10
  * lives there, not here.
11
11
  */
12
12
  import type { Page } from "playwright-core";
13
- import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
13
+ import type { LocalStepAction, ActionResult, ContextValue, WireContextValue, TreeData } from "./types.js";
14
14
  import type { TabManager } from "./tabs.js";
15
15
  /**
16
16
  * Execute a single action on the page.
17
17
  */
18
18
  export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[], tabs?: TabManager): Promise<ActionResult>;
19
+ /**
20
+ * Normalize the backend's wire context-value shape ({@link WireContextValue}:
21
+ * `{key, requires_resolution, …}`) into the internal {@link ContextValue}
22
+ * ({name, type, …}) the executors and {@link resolveTextValue} consume.
23
+ *
24
+ * This mapping is the fix for a long-standing silent break: the backend has
25
+ * always sent `key`/`requires_resolution`, the CLI has always read `name`/`type`,
26
+ * and nothing mapped between them — so var/secret lookups never matched and the
27
+ * agent typed the literal key (e.g. `LOGIN_USERNAME`) into login fields on web,
28
+ * iOS and Android alike. `requires_resolution` is true for secrets (resolved at
29
+ * runtime) and false for preloaded variables.
30
+ */
31
+ export declare function normalizeContextValues(wire: WireContextValue[]): ContextValue[];
19
32
  /**
20
33
  * Resolve the actual text to type from an action, handling var/secret value types.
21
- * Exported so the native (Android) executor can resolve values the same way.
34
+ * Exported so the native (Android/iOS) executors can resolve values the same way.
35
+ *
36
+ * `contextValues` must already be normalized via {@link normalizeContextValues}
37
+ * — the lookup matches on `name`, which is the backend's `key` post-mapping.
22
38
  */
23
39
  export declare function resolveTextValue(action: LocalStepAction, contextValues: ContextValue[]): string;
24
40
  /**
@@ -376,9 +376,32 @@ async function executeKeyboardShortcut(page, action) {
376
376
  await page.keyboard.press(combo);
377
377
  }
378
378
  // --- Helpers ---
379
+ /**
380
+ * Normalize the backend's wire context-value shape ({@link WireContextValue}:
381
+ * `{key, requires_resolution, …}`) into the internal {@link ContextValue}
382
+ * ({name, type, …}) the executors and {@link resolveTextValue} consume.
383
+ *
384
+ * This mapping is the fix for a long-standing silent break: the backend has
385
+ * always sent `key`/`requires_resolution`, the CLI has always read `name`/`type`,
386
+ * and nothing mapped between them — so var/secret lookups never matched and the
387
+ * agent typed the literal key (e.g. `LOGIN_USERNAME`) into login fields on web,
388
+ * iOS and Android alike. `requires_resolution` is true for secrets (resolved at
389
+ * runtime) and false for preloaded variables.
390
+ */
391
+ export function normalizeContextValues(wire) {
392
+ return wire.map(cv => ({
393
+ name: cv.key,
394
+ type: cv.requires_resolution ? "secret" : "var",
395
+ value: cv.value,
396
+ ...(cv.description != null && { description: cv.description }),
397
+ }));
398
+ }
379
399
  /**
380
400
  * Resolve the actual text to type from an action, handling var/secret value types.
381
- * Exported so the native (Android) executor can resolve values the same way.
401
+ * Exported so the native (Android/iOS) executors can resolve values the same way.
402
+ *
403
+ * `contextValues` must already be normalized via {@link normalizeContextValues}
404
+ * — the lookup matches on `name`, which is the backend's `key` post-mapping.
382
405
  */
383
406
  export function resolveTextValue(action, contextValues) {
384
407
  if (action.value_type === "var" || action.value_type === "secret") {
@@ -10,12 +10,16 @@
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
+ /** Run `fn` with all its adb calls pinned to `serial` (parallel pool path). */
14
+ export declare function withAdbSerial<T>(serial: string | undefined, fn: () => Promise<T>): Promise<T>;
15
+ /** `["-s", serial]` or `[]` — the device-targeting prefix. Pure (tested). */
16
+ export declare function serialArgs(serial: string | undefined): string[];
13
17
  /** Resolve adb, downloading Google's platform-tools on first use if not found. */
14
18
  export declare function ensureAdb(): Promise<string>;
15
19
  export declare class AdbError extends Error {
16
20
  constructor(message: string);
17
21
  }
18
- /** Run `adb <args>` and return trimmed stdout. Throws AdbError on failure. */
22
+ /** Run `adb [-s serial] <args>` and return trimmed stdout. Throws AdbError on failure. */
19
23
  export declare function adb(args: string[], timeoutMs?: number): Promise<string>;
20
24
  /** Run `adb shell <args>` and return trimmed stdout. */
21
25
  export declare function adbShell(args: string[], timeoutMs?: number): Promise<string>;
@@ -62,7 +66,20 @@ export declare function currentActivity(): Promise<string>;
62
66
  * output. Returns the PNG buffer at full device resolution.
63
67
  */
64
68
  export declare function screencapPng(): Promise<Buffer>;
65
- /** Assert exactly one device/emulator is in the `device` state. */
69
+ /**
70
+ * Parse `adb devices` output into {serial, state} rows. Pure (tested). Skips the
71
+ * "List of devices attached" header and blank lines.
72
+ */
73
+ export declare function parseAdbDevices(out: string): Array<{
74
+ serial: string;
75
+ state: string;
76
+ }>;
77
+ /** List online (state==="device") serials. */
78
+ export declare function listOnlineSerials(): Promise<string[]>;
79
+ /**
80
+ * Assert the target device is online. With a serial in effect (pool path or
81
+ * ANDROID_SERIAL), confirm THAT serial is online. Otherwise require exactly one.
82
+ */
66
83
  export declare function requireOneDevice(): Promise<void>;
67
84
  export declare function inputTap(x: number, y: number): Promise<void>;
68
85
  export declare function inputSwipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
@@ -14,8 +14,32 @@ import { execFile, execFileSync } from "node:child_process";
14
14
  import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { promisify } from "node:util";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
17
18
  import { binDir, adbBin } from "../paths.js";
18
19
  const execFileAsync = promisify(execFile);
20
+ /**
21
+ * The adb serial to target for the current async call chain. A parallel run
22
+ * drives N emulators in ONE process; every adb call must hit the right device,
23
+ * but the CLI targets devices via the `adb -s <serial>` prefix, not a per-call
24
+ * argument threaded through ~25 functions. AsyncLocalStorage carries the serial
25
+ * implicitly through the call stack so `adb()` / `screencapPng()` pick it up,
26
+ * and two concurrent `withAdbSerial(A, …)` / `withAdbSerial(B, …)` chains stay
27
+ * isolated. Single-device runs leave the store empty and fall back to
28
+ * ANDROID_SERIAL / the one online device (unchanged behavior).
29
+ */
30
+ const serialStore = new AsyncLocalStorage();
31
+ /** Run `fn` with all its adb calls pinned to `serial` (parallel pool path). */
32
+ export function withAdbSerial(serial, fn) {
33
+ return serialStore.run(serial?.trim() || undefined, fn);
34
+ }
35
+ /** The serial in effect for this call chain: store → ANDROID_SERIAL → none. */
36
+ function activeSerial() {
37
+ return serialStore.getStore() ?? (process.env.ANDROID_SERIAL?.trim() || undefined);
38
+ }
39
+ /** `["-s", serial]` or `[]` — the device-targeting prefix. Pure (tested). */
40
+ export function serialArgs(serial) {
41
+ return serial ? ["-s", serial] : [];
42
+ }
19
43
  // Resolve adb without depending on the caller's PATH: ISH_ADB/ADB override → the
20
44
  // Android SDK → Homebrew → our own download cache → PATH. If none is found,
21
45
  // ensureAdb() fetches Google's standalone platform-tools (a small zip) into
@@ -105,11 +129,12 @@ export class AdbError extends Error {
105
129
  this.name = "AdbError";
106
130
  }
107
131
  }
108
- /** Run `adb <args>` and return trimmed stdout. Throws AdbError on failure. */
132
+ /** Run `adb [-s serial] <args>` and return trimmed stdout. Throws AdbError on failure. */
109
133
  export async function adb(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
110
134
  const bin = await ensureAdb();
135
+ const full = [...serialArgs(activeSerial()), ...args];
111
136
  try {
112
- const { stdout } = await execFileAsync(bin, args, {
137
+ const { stdout } = await execFileAsync(bin, full, {
113
138
  timeout: timeoutMs,
114
139
  maxBuffer: 4 * 1024 * 1024,
115
140
  });
@@ -117,7 +142,7 @@ export async function adb(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
117
142
  }
118
143
  catch (err) {
119
144
  const msg = err instanceof Error ? err.message : String(err);
120
- throw new AdbError(`adb ${args.join(" ")} failed: ${msg}`);
145
+ throw new AdbError(`adb ${full.join(" ")} failed: ${msg}`);
121
146
  }
122
147
  }
123
148
  /** Run `adb shell <args>` and return trimmed stdout. */
@@ -191,8 +216,9 @@ export async function currentActivity() {
191
216
  */
192
217
  export async function screencapPng() {
193
218
  const bin = await ensureAdb();
219
+ const full = [...serialArgs(activeSerial()), "exec-out", "screencap", "-p"];
194
220
  try {
195
- const { stdout } = await execFileAsync(bin, ["exec-out", "screencap", "-p"], {
221
+ const { stdout } = await execFileAsync(bin, full, {
196
222
  timeout: SCREENCAP_TIMEOUT_MS,
197
223
  maxBuffer: SCREENCAP_MAX_BUFFER,
198
224
  encoding: "buffer",
@@ -204,40 +230,62 @@ export async function screencapPng() {
204
230
  throw new AdbError(`adb exec-out screencap failed: ${msg}`);
205
231
  }
206
232
  }
207
- /** Assert exactly one device/emulator is in the `device` state. */
233
+ /**
234
+ * Parse `adb devices` output into {serial, state} rows. Pure (tested). Skips the
235
+ * "List of devices attached" header and blank lines.
236
+ */
237
+ export function parseAdbDevices(out) {
238
+ return out
239
+ .split("\n")
240
+ .slice(1)
241
+ .map((l) => l.trim())
242
+ .filter(Boolean)
243
+ .map((l) => {
244
+ const [serial, state] = l.split("\t");
245
+ return { serial: serial ?? "", state: state ?? "" };
246
+ })
247
+ .filter((d) => d.serial);
248
+ }
249
+ /** `adb devices` WITHOUT a serial prefix (the list is global, not per-device). */
250
+ async function devicesRaw() {
251
+ const bin = await ensureAdb();
252
+ const { stdout } = await execFileAsync(bin, ["devices"], {
253
+ timeout: DEFAULT_TIMEOUT_MS,
254
+ maxBuffer: 1024 * 1024,
255
+ });
256
+ return stdout.trim();
257
+ }
258
+ /** List online (state==="device") serials. */
259
+ export async function listOnlineSerials() {
260
+ return parseAdbDevices(await devicesRaw())
261
+ .filter((d) => d.state === "device")
262
+ .map((d) => d.serial);
263
+ }
264
+ /**
265
+ * Assert the target device is online. With a serial in effect (pool path or
266
+ * ANDROID_SERIAL), confirm THAT serial is online. Otherwise require exactly one.
267
+ */
208
268
  export async function requireOneDevice() {
209
- let out;
269
+ let online;
210
270
  try {
211
- out = await adb(["devices"]);
271
+ online = await listOnlineSerials();
212
272
  }
213
273
  catch (err) {
214
274
  const msg = err instanceof Error ? err.message : String(err);
215
275
  throw new AdbError(`Could not run adb (looked for "${findAdb() ?? "adb"}"). Run \`ish check android\` to check your setup. ${msg}`);
216
276
  }
217
- // Output: "List of devices attached\n<serial>\tdevice\n..."
218
- const online = out
219
- .split("\n")
220
- .slice(1)
221
- .map((l) => l.trim())
222
- .filter((l) => l && l.endsWith("\tdevice"));
223
277
  if (online.length === 0) {
224
278
  throw new AdbError("No Android device/emulator online. Run `ish check android` to check your setup and how to boot one.");
225
279
  }
226
- // Honor ANDROID_SERIAL (the standard adb convention): when it names an online
227
- // device, pin to it instead of failing on "more than one device". The adb
228
- // wrapper inherits process.env, so every subsequent `adb` call already targets
229
- // that serial — this lets multiple emulators run in parallel, each driven by a
230
- // CLI invocation with its own ANDROID_SERIAL.
231
- const pinned = process.env.ANDROID_SERIAL?.trim();
280
+ const pinned = activeSerial();
232
281
  if (pinned) {
233
- if (online.some((l) => l.startsWith(`${pinned}\t`)))
282
+ if (online.includes(pinned))
234
283
  return;
235
- throw new AdbError(`ANDROID_SERIAL=${pinned} is set but that device is not online. ` +
236
- `Online: ${online.map((l) => l.split("\t")[0]).join(", ") || "none"}.`);
284
+ throw new AdbError(`Android device ${pinned} is not online. Online: ${online.join(", ") || "none"}.`);
237
285
  }
238
286
  if (online.length > 1) {
239
287
  throw new AdbError(`Expected exactly one Android device, found ${online.length}. ` +
240
- `Stop the extras, or set ANDROID_SERIAL=<serial> to pin one (parallel runs).`);
288
+ `Stop the extras, or run with --parallel to pool them.`);
241
289
  }
242
290
  }
243
291
  // --- Input gestures (all in screencap pixel space) ---
@@ -403,10 +403,15 @@ export class AndroidDevice {
403
403
  await settle(250);
404
404
  }
405
405
  const text = resolveTextValue(action, this.contextValues);
406
- if (action.mode === "click_type") {
406
+ // Clear before typing by default — ADBKeyboard's type broadcast APPENDS, so
407
+ // a retried or app-pre-filled field would otherwise accumulate text. This is
408
+ // the replace-on-type semantic the browser (select-all+type) and iOS paths
409
+ // also use; previously only click_type cleared. Skip the clear+type entirely
410
+ // when there's nothing to type so an empty no-op can't wipe the field.
411
+ if (text) {
407
412
  await adbKeyboardClear();
413
+ await adbKeyboardType(text);
408
414
  }
409
- await adbKeyboardType(text);
410
415
  if (action.submit) {
411
416
  await settle(150);
412
417
  await pressKeyEvent("KEYCODE_ENTER");