@ishlabs/cli 0.24.1 → 0.25.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 (36) hide show
  1. package/dist/commands/ask.js +3 -3
  2. package/dist/commands/iteration.js +1 -1
  3. package/dist/commands/study-analyze.js +1 -1
  4. package/dist/commands/study-run.js +80 -12
  5. package/dist/commands/study.js +11 -7
  6. package/dist/lib/alias-store.js +1 -1
  7. package/dist/lib/api-client.d.ts +2 -0
  8. package/dist/lib/docs.js +57 -42
  9. package/dist/lib/local-sim/actions.d.ts +10 -2
  10. package/dist/lib/local-sim/actions.js +16 -11
  11. package/dist/lib/local-sim/adb.d.ts +103 -0
  12. package/dist/lib/local-sim/adb.js +352 -0
  13. package/dist/lib/local-sim/android.d.ts +111 -0
  14. package/dist/lib/local-sim/android.js +499 -0
  15. package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
  16. package/dist/lib/local-sim/apk-manifest.js +210 -0
  17. package/dist/lib/local-sim/browser.d.ts +22 -0
  18. package/dist/lib/local-sim/browser.js +65 -0
  19. package/dist/lib/local-sim/coordinates.d.ts +69 -0
  20. package/dist/lib/local-sim/coordinates.js +59 -0
  21. package/dist/lib/local-sim/device.d.ts +143 -0
  22. package/dist/lib/local-sim/device.js +152 -0
  23. package/dist/lib/local-sim/ios.d.ts +168 -0
  24. package/dist/lib/local-sim/ios.js +546 -0
  25. package/dist/lib/local-sim/loop.d.ts +14 -2
  26. package/dist/lib/local-sim/loop.js +166 -73
  27. package/dist/lib/local-sim/native-a11y.d.ts +97 -0
  28. package/dist/lib/local-sim/native-a11y.js +384 -0
  29. package/dist/lib/local-sim/simctl.d.ts +85 -0
  30. package/dist/lib/local-sim/simctl.js +273 -0
  31. package/dist/lib/local-sim/types.d.ts +37 -2
  32. package/dist/lib/local-sim/upload.d.ts +1 -1
  33. package/dist/lib/local-sim/upload.js +9 -6
  34. package/dist/lib/output.js +58 -12
  35. package/dist/lib/skill-content.js +10 -9
  36. package/package.json +2 -1
@@ -436,15 +436,15 @@ Picks come back with a \`pick_confidence\` (0..1) score per participant when
436
436
  // error rather than a transient failure.
437
437
  ask
438
438
  .command("dispatch")
439
- .description("Dispatch a draft ask — bills credits and starts the round")
439
+ .description("Dispatch a draft ask — draws credits and starts the round")
440
440
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
441
441
  .option("--ask <id>", "Ask ID; alternative to positional argument")
442
442
  .option("--wait", "Wait until the first round completes (or errors)")
443
443
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
444
444
  .addHelpText("after", `
445
445
  Use after \`ish ask create --no-dispatch\` to start a draft once the user has
446
- reviewed it. The dispatch is BILLABLE credits are charged when responses
447
- land, the same as a normal create.
446
+ reviewed it. Dispatch draws credits as responses land, the same as a normal
447
+ create. This is the expected way to run an ask — go ahead and dispatch.
448
448
 
449
449
  Examples:
450
450
  # Dispatch the active draft and wait for results:
@@ -383,7 +383,7 @@ Concept pages: ish docs get-page concepts/iteration
383
383
  // Media image
384
384
  .option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
385
385
  // Shared media
386
- .option("--title <title>", "Content title — media modalities")
386
+ .option("--title <title>", "Content title shown to participants (the leading headline they read) — media modalities")
387
387
  .option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
388
388
  // Copy/caption
389
389
  .option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
@@ -81,7 +81,7 @@ export function attachStudyAnalyzeCommands(study) {
81
81
  study
82
82
  .command("analyze")
83
83
  .description("Trigger an AI summary + key-insights analysis for a study. " +
84
- "First analysis per study is free; subsequent runs cost 10 credits.")
84
+ "First analysis per study is included; subsequent runs draw 10 credits.")
85
85
  .argument("[id]", "Study ID (defaults to active study)")
86
86
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
87
87
  .option("--wait", "Poll until the run reaches completed or failed")
@@ -33,8 +33,8 @@ function parseMaxInteractions(value) {
33
33
  /**
34
34
  * Default cap the CLI sends when neither `--max-interactions` nor the
35
35
  * iteration carries its own value. Picked to match the frontend's
36
- * conservative interactive launchers and to prevent runaway spend when an
37
- * iteration runs against a broken or non-responsive surface — without a
36
+ * conservative interactive launchers and to prevent runaway credit draw when
37
+ * an iteration runs against a broken or non-responsive surface — without a
38
38
  * cap, a stuck participant can rack up hundreds of steps before the SDK gives
39
39
  * up.
40
40
  */
@@ -264,6 +264,34 @@ function readIterationDetails(details) {
264
264
  ...(typeof details.title === "string" && { title: details.title }),
265
265
  };
266
266
  }
267
+ /**
268
+ * Normalize a platform string for matching. "web", "browser", and unset all
269
+ * mean the default browser path; "android"/"ios" are native. Lets `--platform
270
+ * web` match a "browser" iteration (and vice-versa) without false mismatches.
271
+ */
272
+ function normalizePlatform(platform) {
273
+ const p = (platform ?? "").toLowerCase();
274
+ if (p === "" || p === "web" || p === "browser")
275
+ return "browser";
276
+ return p;
277
+ }
278
+ /**
279
+ * The local platform the user explicitly requested via flags, before any
280
+ * iteration is picked: --platform, or inferred from --app's extension
281
+ * (.apk → android, .app → ios). Undefined when neither is set (no preference →
282
+ * don't filter iterations by platform). The iteration's stored platform is
283
+ * deliberately NOT consulted here — it's what we're selecting against.
284
+ */
285
+ function requestedLocalPlatform(opts) {
286
+ if (opts.platform)
287
+ return opts.platform;
288
+ const app = opts.app?.toLowerCase();
289
+ if (app?.endsWith(".apk"))
290
+ return "android";
291
+ if (app?.endsWith(".app"))
292
+ return "ios";
293
+ return undefined;
294
+ }
267
295
  export function attachStudyRunCommands(study) {
268
296
  // --- Primary: `study run` ---
269
297
  const studyRun = study
@@ -294,6 +322,8 @@ export function attachStudyRunCommands(study) {
294
322
  .option("--devtools", "Open Chrome DevTools (local mode only)")
295
323
  .option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
296
324
  .option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
325
+ .option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
326
+ .option("--app <path>", "Native local mode: path to an .apk (android) / .app (ios) to install, or an installed package/bundle id to launch. The extension implies --platform.")
297
327
  .addHelpText("after", `
298
328
  Note: --workspace and --study are optional if you have set active context
299
329
  via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
@@ -326,7 +356,7 @@ Examples:
326
356
  $ ish study run --config c-c3c
327
357
 
328
358
  # Cap interactions per participant (default 20 — pass higher to allow deeper
329
- # exploration, lower to cap spend on a known-broken surface):
359
+ # exploration, lower to cap credit draw on a known-broken surface):
330
360
  $ ish study run --max-interactions 30
331
361
 
332
362
  # Block until all simulations finish (or timeout):
@@ -412,8 +442,13 @@ Examples:
412
442
  if (!study.assignments || study.assignments.length === 0) {
413
443
  throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
414
444
  }
415
- // Step 1: Pick iteration (explicit --iteration, or latest on study)
445
+ // Step 1: Pick iteration (explicit --iteration, or latest on study).
446
+ // When --platform / --app requests a local platform, select the
447
+ // iteration whose details.platform matches — otherwise a multi-platform
448
+ // study (e.g. an android AND an ios iteration) would silently run the
449
+ // latest, which may be the wrong platform.
416
450
  const iterations = study.iterations || [];
451
+ const wantPlatform = opts.local ? requestedLocalPlatform(opts) : undefined;
417
452
  let iteration;
418
453
  if (opts.iteration) {
419
454
  const wantedId = resolveId(opts.iteration);
@@ -421,6 +456,25 @@ Examples:
421
456
  if (!iteration) {
422
457
  throw new Error(`Iteration ${opts.iteration} not found on this study.`);
423
458
  }
459
+ // An explicit --iteration whose platform contradicts --platform is a
460
+ // footgun (you'd drive the wrong device); refuse rather than guess.
461
+ if (wantPlatform) {
462
+ const itPlatform = readIterationDetails(iteration.details).platform;
463
+ if (normalizePlatform(itPlatform) !== normalizePlatform(wantPlatform)) {
464
+ throw new Error(`--platform ${wantPlatform} but iteration ${opts.iteration} is platform ` +
465
+ `'${itPlatform ?? "browser"}'. Pass the matching iteration or drop --platform.`);
466
+ }
467
+ }
468
+ }
469
+ else if (wantPlatform) {
470
+ // Latest iteration whose platform matches the requested one.
471
+ const matching = iterations.filter((it) => normalizePlatform(readIterationDetails(it.details).platform) === normalizePlatform(wantPlatform));
472
+ if (matching.length === 0) {
473
+ const available = [...new Set(iterations.map((it) => normalizePlatform(readIterationDetails(it.details).platform)))].join(", ") || "none";
474
+ throw new Error(`No ${wantPlatform} iteration on this study (platforms present: ${available}). ` +
475
+ `Create one with \`ish iteration create --study ${resolvedStudy} ...\`, or pass --iteration.`);
476
+ }
477
+ iteration = matching[matching.length - 1];
424
478
  }
425
479
  else if (iterations.length > 0) {
426
480
  iteration = iterations[iterations.length - 1];
@@ -691,6 +745,21 @@ Examples:
691
745
  // by reusing the iteration's existing Conversation rows or by
692
746
  // calling pair-batch.
693
747
  let pairConversationIds = [];
748
+ // Resolve the local target platform ONCE so participant-create and the
749
+ // local-sim dispatch agree. Precedence: --platform flag > --app
750
+ // extension (.apk → android, .app → ios) > iteration's stored platform
751
+ // > browser. Used to set participant.platform (so the backend's native
752
+ // trigger is driven primarily by the participant, not just the
753
+ // empty-tree fallback) and to pick the device in the local loop.
754
+ const platformFromApp = opts.app?.toLowerCase().endsWith(".apk")
755
+ ? "android"
756
+ : opts.app?.toLowerCase().endsWith(".app")
757
+ ? "ios"
758
+ : undefined;
759
+ const resolvedPlatform = opts.platform
760
+ ?? platformFromApp
761
+ ?? detailsView.platform
762
+ ?? "browser";
694
763
  if (isPair && pairConfig) {
695
764
  // Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
696
765
  // 1. If the iteration already carries `conversations[]` from a
@@ -782,7 +851,7 @@ Examples:
782
851
  participant_type: "ai",
783
852
  status: "draft",
784
853
  ...(opts.language && { language: opts.language }),
785
- ...(!isMedia && !isChat && { platform: detailsView.platform || "browser" }),
854
+ ...(!isMedia && !isChat && { platform: resolvedPlatform }),
786
855
  }));
787
856
  log(`Creating ${participantInputs.length} participant${participantInputs.length > 1 ? "s" : ""}...`);
788
857
  const batchResult = await client.post(`/iterations/${iterationId}/participants/batch`, { participants: participantInputs }, { timeout: dispatchTimeoutMs });
@@ -814,6 +883,8 @@ Examples:
814
883
  devtools: opts.devtools,
815
884
  debug: opts.debug,
816
885
  parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
886
+ platform: resolvedPlatform,
887
+ ...(opts.app && { appPath: opts.app }),
817
888
  quiet: globals.quiet,
818
889
  json: globals.json,
819
890
  });
@@ -1042,14 +1113,11 @@ Examples:
1042
1113
  }, true);
1043
1114
  }
1044
1115
  else {
1045
- for (let i = 0; i < simResults.length; i++) {
1046
- const participant = createdParticipants[i];
1047
- const personName = participant?.person?.name || "Unknown";
1048
- log(` ${personName.padEnd(24)} QUEUED`);
1049
- }
1116
+ const studyAlias = tagAlias(ALIAS_PREFIX.study, resolvedStudy);
1117
+ const n = createdParticipants.length;
1118
+ log(`Dispatched ${n} participant${n > 1 ? "s" : ""} — run \`ish study results ${studyAlias}\` for results (or \`ish study poll --study ${studyAlias}\` / --wait to track progress).`);
1050
1119
  const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
1051
- log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
1052
- log(`Run \`ish study poll --study ${resolvedStudy}\` (or --wait next time) to check progress.`);
1120
+ log(` ${terminalLink(url, "Open in browser ↗")}`);
1053
1121
  }
1054
1122
  return;
1055
1123
  }
@@ -113,7 +113,7 @@ Concept pages: ish docs get-page concepts/study
113
113
  .requiredOption("--name <name>", "Study name")
114
114
  .option("--description <description>", "Study description")
115
115
  .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
116
- .option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Not used for interactive / chat.")
116
+ .option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Changes how --title is presented to participants (e.g. content-type email renders --title as the Subject: line). Not used for interactive / chat.")
117
117
  .option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
118
118
  .option("--assignments-file <path>", "JSON file with assignments array")
119
119
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
@@ -124,7 +124,7 @@ Concept pages: ish docs get-page concepts/study
124
124
  .option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
125
125
  .option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
126
126
  .option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
127
- .option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
127
+ .option("--title <title>", "Participant-facing content title — the headline participants read before the body (text + media modalities — image, video, audio, document; optional). With --content-type email it becomes the email Subject: line. Not an internal label. Not used for interactive / chat.")
128
128
  .option("--segmentation-json <json>", "Segmentation JSON for the inline iteration A — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} (text + media). section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph). Lets one `study create` build a complete segmented iteration — no separate `iteration create` needed.")
129
129
  .option("--content-config-json <json>", "Content-config JSON for the inline iteration A (early_termination, selected_segment_indices) — text + media.")
130
130
  .option("--content-html <html>", "HTML version of the text, or @filepath — text modality (email rendering)")
@@ -239,6 +239,9 @@ Examples:
239
239
 
240
240
  Content types by modality (source: VALID_CONTENT_TYPES in src/lib/types.ts; interactive + chat omitted — they don't take --content-type):
241
241
  ${describeContentTypes()}
242
+ The content type also shapes how --title is rendered to participants: with
243
+ content-type email, --title becomes the email Subject: line; otherwise it
244
+ reads as the leading headline of the content.
242
245
 
243
246
  Tips:
244
247
  Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),
@@ -310,11 +313,12 @@ Next: configure a run with \`ish iteration create --study <id>\`,
310
313
  }
311
314
  normalizedScreenFormat = normalized;
312
315
  }
313
- // Pattern G.2: --title is metadata, not content. The backend
314
- // accepts it on text + media modalities (see
315
- // `buildIterationDetails` in iteration.ts). Reject it only on
316
- // shapes that have no title field — interactive (URL only) and
317
- // chat (endpoint config carries its own metadata).
316
+ // --title is participant-facing content: the backend renders it as a
317
+ // leading headline participants read before the body (and as the email
318
+ // Subject: line when content-type is email). It only exists on text +
319
+ // media modalities (see `buildIterationDetails` in iteration.ts), so
320
+ // reject it on shapes that have no title field — interactive (URL only)
321
+ // and chat (endpoint config carries its own configuration).
318
322
  if (opts.title !== undefined
319
323
  && opts.contentText === undefined
320
324
  && opts.contentUrl === undefined
@@ -129,7 +129,7 @@ const HYDRATE_HINT = {
129
129
  // alias map at ~/.ish/aliases.json carries any sources the CLI has
130
130
  // touched in this session.
131
131
  ps: "ish source upload <file> # or `cat ~/.ish/aliases.json | grep ^ps-` to recover prior aliases",
132
- pt: "ish participant get <participant-id>",
132
+ pt: "ish study participant <participant-id>",
133
133
  c: "ish config list",
134
134
  a: "ish ask list",
135
135
  r: "ish ask get <ask-id>",
@@ -48,6 +48,8 @@ export declare class ApiClient {
48
48
  screenshot_url?: string;
49
49
  location_name: string;
50
50
  screen_format?: string;
51
+ full_page_screenshot_base64?: string;
52
+ platform?: string;
51
53
  }): Promise<{
52
54
  frame_version_id: string;
53
55
  }>;
package/dist/lib/docs.js CHANGED
@@ -339,7 +339,7 @@ pick was wrong.
339
339
  - \`guides/slicing-results\` — filter / project \`study results\` by frame,
340
340
  segment, turn, sentiment, assignment, step.
341
341
  - \`reference/billing-limits\` — \`maxStudiesPerProduct\` cap on study creation.
342
- - \`reference/credits\` — per-run credit cost & how to preview before dispatch.
342
+ - \`reference/credits\` — per-run credit draw & how to preview before dispatch.
343
343
  `;
344
344
  const CONCEPT_ITERATION = `# concept: iteration
345
345
 
@@ -806,7 +806,7 @@ Treat this as actionable, not transient — re-running won't change anything.
806
806
  - \`concepts/run-verbs\` — how \`ish study run\` selects the iteration.
807
807
  - \`concepts/people\` — how participants are picked for a run.
808
808
  - \`reference/billing-limits\` — \`maxIterationsPerStudy\` cap on iteration creation.
809
- - \`reference/credits\` — per-iteration-run credit cost & preview shape (\`pair_preview.credit_estimate\` for participant-pair, top-level \`credit_estimate\` otherwise).
809
+ - \`reference/credits\` — per-iteration-run credit draw & preview shape (\`pair_preview.credit_estimate\` for participant-pair, top-level \`credit_estimate\` otherwise).
810
810
  `;
811
811
  const CONCEPT_ASSIGNMENT = `# concept: assignment
812
812
 
@@ -987,7 +987,7 @@ ish ask results a-6ec --json | jq '.rounds[0].aggregates'
987
987
 
988
988
  Asks carry a top-level \`status\`:
989
989
 
990
- - \`draft\` — created but not dispatched yet. No credits charged. Created
990
+ - \`draft\` — created but not dispatched yet. No credits drawn. Created
991
991
  by \`ish ask create --no-dispatch\`.
992
992
  - \`running\` — dispatched; the round is executing or queued.
993
993
  - \`completed\` — round 1 (or the most recent round) finished.
@@ -1000,10 +1000,10 @@ intact — no \`--verbose\` needed to see it.
1000
1000
  ## Stage-then-dispatch (draft asks)
1001
1001
 
1002
1002
  When you want a human to review the people and prompt **before** any
1003
- credits are spent, separate creation from dispatch:
1003
+ credits are drawn, separate creation from dispatch:
1004
1004
 
1005
1005
  \`\`\`
1006
- # 1. Stage — materializes participants, no worker enqueue, no bill yet
1006
+ # 1. Stage — materializes participants, no worker enqueue, no credits drawn yet
1007
1007
  ish ask create --workspace w-6ec --name "tagline AB" \\
1008
1008
  --prompt "Which sounds better?" \\
1009
1009
  --variant text:"Short and punchy." \\
@@ -1013,18 +1013,20 @@ ish ask create --workspace w-6ec --name "tagline AB" \\
1013
1013
 
1014
1014
  # Returns an ask with status="draft". Hand the alias back to the user.
1015
1015
 
1016
- # 2. Dispatch — flips DRAFT → RUNNING and enqueues the round (BILLABLE)
1016
+ # 2. Dispatch — flips DRAFT → RUNNING and enqueues the round (draws credits)
1017
1017
  ish ask dispatch a-6ec --wait
1018
1018
  \`\`\`
1019
1019
 
1020
1020
  \`--no-dispatch\` requires people flags (participants are still materialized
1021
- at create time — only the worker enqueue and billing are deferred). It
1022
- is incompatible with \`--wait\` since there is nothing to wait for.
1021
+ at create time — only the worker enqueue and the credit draw are
1022
+ deferred). It is incompatible with \`--wait\` since there is nothing to
1023
+ wait for.
1023
1024
 
1024
1025
  \`ish ask dispatch\` is idempotent on the server: a non-DRAFT ask returns
1025
1026
  HTTP 409 (\`already dispatched\`) which the CLI maps to a usage error, so
1026
1027
  re-running the command is safe. The user who calls \`dispatch\` is the
1027
- billing principal — keep that in mind for shared workspaces.
1028
+ principal whose credits are drawn — keep that in mind for shared
1029
+ workspaces.
1028
1030
 
1029
1031
  ## Reading the verdict
1030
1032
 
@@ -1170,7 +1172,7 @@ deleted ask was the active one.
1170
1172
  - \`concepts/round\` — what a round is and how it executes.
1171
1173
  - \`concepts/people\` — how participants are chosen at ask creation.
1172
1174
  - \`concepts/run-verbs\` — \`ish ask run\` vs \`ish study run\`.
1173
- - \`reference/credits\` — ask rounds bill **one credit per successful participant per round**, regardless of how many \`questions\` were included. The backend's asks worker bills \`amount=succeeded\` once per round dispatch; questions and round-summary synthesis don't trigger separate debits. A 3-person panel with 2 follow-up questions costs \`3\` credits when all complete, the same as a no-questions run. Failed participant responses (pre-flight errors, refusals) don't bill.
1175
+ - \`reference/credits\` — ask rounds draw **one credit per successful participant per round**, regardless of how many \`questions\` were included. The backend's asks worker draws \`amount=succeeded\` once per round dispatch; questions and round-summary synthesis don't draw separately. A 3-person panel with 2 follow-up questions draws \`3\` credits when all complete, the same as a no-questions run. Failed participant responses (pre-flight errors, refusals) draw nothing.
1174
1176
  `;
1175
1177
  const CONCEPT_ROUND = `# concept: round
1176
1178
 
@@ -1197,7 +1199,7 @@ ish ask results a-6ec --round 1
1197
1199
 
1198
1200
  Appending questions to a completed round preserves prior data — variant
1199
1201
  comments, picks, ratings, and earlier-question answers all stay. Only
1200
- the new question(s) get dispatched to the existing participants. Cost is
1202
+ the new question(s) get dispatched to the existing participants. Usage is
1201
1203
  roughly N phase-2 LLM calls instead of 2N (no phase-1 re-run). Errored
1202
1204
  responses are skipped entirely; completed responses flip to PENDING and
1203
1205
  re-finalize after the new question is answered.
@@ -1902,16 +1904,16 @@ load-bearing return value — same exception \`study run\` makes.
1902
1904
  | Source not terminal (RUNNING / QUEUED) | \`Participant is still running — cancel it first or wait for completion.\` | 2 |
1903
1905
  | Source participant not found | \`Participant not found: <id>\` | 4 |
1904
1906
  | \`additional_steps\` out of range | Client-side parser rejects before the network call | 2 |
1905
- | Insufficient credits | Bubbles the server message; retry only after topping up | 5 |
1907
+ | Insufficient credits | Bubbles the server message; retry only after the balance is replenished | 5 |
1906
1908
  | Wait timed out (\`--wait\` only) | \`WaitTimeoutError\` envelope with current status under \`progress.rows[0]\` — the run keeps going server-side; resume with \`study wait <new-participant>\` | 5 |
1907
1909
 
1908
- ## Cost model
1910
+ ## Credit model
1909
1911
 
1910
- \`extend\` charges credits for **only \`additional_steps\`**, not for
1912
+ \`extend\` draws credits for **only \`additional_steps\`**, not for
1911
1913
  the source's original \`max_interactions\` cap. The formula is the same
1912
1914
  as \`study run\` for interactive runs: \`max(1, round(N / 10))\` per
1913
- participant. So \`--add-steps 10\` costs **1 credit**; \`--add-steps 50\`
1914
- costs **5 credits**. See \`reference/credits\` for the full table.
1915
+ participant. So \`--add-steps 10\` draws **1 credit**; \`--add-steps 50\`
1916
+ draws **5 credits**. See \`reference/credits\` for the full table.
1915
1917
 
1916
1918
  ## Worked example — push past the step cap
1917
1919
 
@@ -1945,7 +1947,7 @@ ish study extend pt-072 \\
1945
1947
 
1946
1948
  - \`concepts/run-verbs\` — the top-level decision rule (\`study run\` vs
1947
1949
  \`ask run\`); extend is a lifecycle verb downstream of either.
1948
- - \`reference/credits\` — per-modality cost formulas. \`extend\` follows
1950
+ - \`reference/credits\` — per-modality credit-draw formulas. \`extend\` follows
1949
1951
  the interactive formula scaled to \`additional_steps\`.
1950
1952
  - \`reference/aliases\` — the \`t-…\` prefix and how aliases resolve.
1951
1953
  - \`reference/json-mode\` — capture-mode (\`--get participant_alias\`) for
@@ -2366,7 +2368,7 @@ The CLI guarantees these contracts so agents can chain safely:
2366
2368
  \`study update <id> --modality text\` before adding iterations.
2367
2369
  - **\`ask add-questions\` is additive by default.** Appending questions
2368
2370
  preserves variant comments / picks / ratings / prior-question
2369
- answers; only the new question(s) get dispatched. Cost: roughly N
2371
+ answers; only the new question(s) get dispatched. Usage: roughly N
2370
2372
  phase-2 LLM calls instead of 2N. Pass \`--redispatch-all\` for the
2371
2373
  legacy reset behavior when you want fresh first impressions.
2372
2374
  - **\`ask results --json\` includes \`cross_round_summary\` for 2+
@@ -2990,49 +2992,62 @@ of scope: \`workspace\`, \`config\`, \`docs\`, \`init\`, \`login\`,
2990
2992
  including \`--get workspace.alias\` to capture the active workspace
2991
2993
  without piping \`ish status --json\` through \`jq\`.
2992
2994
  `;
2993
- const REFERENCE_CREDITS = `# reference: credits & cost preview
2995
+ const REFERENCE_CREDITS = `# reference: credits & usage preview
2994
2996
 
2995
- Every billable run (study, ask, insight) costs **credits**. The CLI
2996
- surfaces a cost upper bound *before* you dispatch so you can budget. The
2997
- backend is the authoritative source its rejection envelope on
2998
- \`insufficient_credits\` carries the live required/available pair.
2997
+ Credits are ish's **usage allowance** a resource that runs draw, not
2998
+ money you pay per call. Each run (study, ask, insight) **draws credits**
2999
+ from the account's balance. Drawing credits to run studies and asks is
3000
+ the normal, expected way to use ish — an agent should run them without
3001
+ hesitation, the same as making any other tool call. The job here is just
3002
+ to be *aware* of the balance, not to ration it.
2999
3003
 
3000
- ## How costs are shaped
3004
+ **Where credits come from:**
3005
+ - **Paid plans (Starter / Pro / Enterprise)** get a **monthly credit
3006
+ allowance** that refills each billing cycle.
3007
+ - **The free tier** gets a **one-time signup grant** — it is *not*
3008
+ refilled monthly. Once it's drawn down, the user adds more or upgrades.
3009
+
3010
+ The CLI surfaces a usage upper bound *before* you dispatch so you can see
3011
+ how much a run will draw. The backend is the authoritative source — its
3012
+ rejection envelope on \`insufficient_credits\` carries the live
3013
+ required/available pair.
3014
+
3015
+ ## How usage is shaped
3001
3016
 
3002
3017
  The formula has the same shape across modalities — \`max(1, round(N / 10))\`
3003
3018
  per principal — but the inputs differ. **Treat the rates below as the
3004
3019
  current calibration**; they will evolve as we differentiate per-modality
3005
- compute cost. Agents should:
3020
+ compute. Agents should:
3006
3021
 
3007
- - For prospective cost preview: read \`credit_estimate\` from \`study run\`'s
3022
+ - For prospective usage preview: read \`credit_estimate\` from \`study run\`'s
3008
3023
  JSON envelope (top-level for solo/media runs; under \`pair_preview\` for
3009
3024
  participant-pair chat).
3010
- - For hard budget checks: catch the backend's \`insufficient_credits\`
3025
+ - For hard balance checks: catch the backend's \`insufficient_credits\`
3011
3026
  rejection (HTTP 402; envelope shape below) and react to
3012
3027
  \`required\` / \`available\`.
3013
3028
 
3014
- | Surface | Per-principal cost | Total formula | Example |
3029
+ | Surface | Per-principal draw | Total formula | Example |
3015
3030
  |---------------------|---------------------------------|--------------------------------------------------|--------------------------------------|
3016
3031
  | Interactive (URL) | \`max(1, round(steps/10))\` | \`participants × per-participant\` | 10 participants × 30 steps → 30 credits |
3017
3032
  | Text/image/video/audio/document | same | same | 5 participants × 20 steps → 10 credits |
3018
3033
  | Chat (external chatbot, solo) | \`max(1, round(turns/10))\` | \`participants × per-participant\` | 5 participants × 12 turns → 10 credits |
3019
3034
  | Chat (participant pair) | \`max(1, round(turns/10))\` × 2 | \`conv × per-side × 2\` | 3 conv × 14 turns → 6 credits |
3020
3035
  | Ask round | 1 / successful response | \`successful_participants\` | 50 responses → 50 credits |
3021
- | Study insights | first free, then **10 flat** | n/a | 2nd analysis → 10 credits |
3036
+ | Study insights | first included, then **10 flat** | n/a | 2nd analysis → 10 credits |
3022
3037
 
3023
3038
  All numbers are **upper bounds**. Early termination, refusals, or
3024
- backend trimming can reduce actual charge.
3039
+ backend trimming can reduce the actual draw.
3025
3040
 
3026
- ## Capping interactive/media spend (\`--max-interactions\`)
3041
+ ## Capping interactive/media usage (\`--max-interactions\`)
3027
3042
 
3028
3043
  \`ish study run\` always sends \`max_interactions\` to the backend for
3029
3044
  interactive and media runs. Precedence: \`--max-interactions <n>\` flag
3030
3045
  > the iteration's stored \`details.max_interactions\` > **CLI default
3031
- of 20**. The default exists to prevent runaway spend when a participant
3032
- gets stuck on a broken or non-responsive surface — without a cap, one
3033
- stuck participant can rack up 100+ steps before the SDK gives up. Pass
3034
- \`--max-interactions\` to override (e.g. \`--max-interactions 50\` for
3035
- deeper exploration, \`--max-interactions 5\` for a cheap smoke test).
3046
+ of 20**. The default exists to prevent runaway credit draw when a
3047
+ participant gets stuck on a broken or non-responsive surface — without a
3048
+ cap, one stuck participant can rack up 100+ steps before the SDK gives
3049
+ up. Pass \`--max-interactions\` to override (e.g. \`--max-interactions 50\`
3050
+ for deeper exploration, \`--max-interactions 5\` for a quick smoke test).
3036
3051
  The confirmation block shows the resolved value and where it came
3037
3052
  from (flag / iteration / CLI default). The JSON envelope's
3038
3053
  \`credit_estimate.breakdown\` reflects the dispatched value.
@@ -3121,7 +3136,7 @@ HTTP 402. The CLI surfaces it as a structured error envelope:
3121
3136
  \`\`\`
3122
3137
 
3123
3138
  Exit code \`1\` (non-retryable). Don't poll — the user has to upgrade or
3124
- free credits before re-dispatch.
3139
+ free up credits before re-dispatch.
3125
3140
 
3126
3141
  ## Agent recipe
3127
3142
 
@@ -3135,7 +3150,7 @@ free credits before re-dispatch.
3135
3150
 
3136
3151
  ## Caveats
3137
3152
 
3138
- - The CLI's preview uses the **same formula** the backend bills with,
3153
+ - The CLI's preview uses the **same formula** the backend draws against,
3139
3154
  but does **not** make a network preflight call — it's pure math
3140
3155
  client-side. If the backend formula changes mid-version, the preview
3141
3156
  will drift until the CLI is updated. The \`insufficient_credits\`
@@ -3238,9 +3253,9 @@ upgrade or delete an existing resource to free up headroom.
3238
3253
 
3239
3254
  ## Related
3240
3255
 
3241
- - \`reference/credits\` — per-run credit cost & preview (separate from
3256
+ - \`reference/credits\` — per-run credit draw & preview (separate from
3242
3257
  these entity caps; this page is about *how many things you can have*,
3243
- that page is about *how much each run costs*).
3258
+ that page is about *how many credits each run draws*).
3244
3259
  - \`concepts/workspace\` — \`maxProducts\` is per-account.
3245
3260
  - \`concepts/study\` — \`maxStudiesPerProduct\` gates study creation.
3246
3261
  - \`concepts/iteration\` — \`maxIterationsPerStudy\` gates iteration creation.
@@ -4442,8 +4457,8 @@ const PAGES = [
4442
4457
  },
4443
4458
  {
4444
4459
  slug: "reference/credits",
4445
- title: "reference: credits & cost preview",
4446
- description: "Per-modality credit cost formulas, where the CLI surfaces cost estimates (Scale line, pair_preview.credit_estimate, top-level credit_estimate), tier allotments, insufficient_credits error shape.",
4460
+ title: "reference: credits & usage preview",
4461
+ description: "Credits as a usage allowance (paid plans refill monthly; free tier is a one-time signup grant), per-modality credit draw formulas, where the CLI surfaces usage estimates (Scale line, pair_preview.credit_estimate, top-level credit_estimate), tier allotments, insufficient_credits error shape.",
4447
4462
  body: REFERENCE_CREDITS,
4448
4463
  },
4449
4464
  {
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Action executor — resolves elements and executes Playwright actions.
3
3
  *
4
- * Resolution strategy:
4
+ * Element resolution strategy (browser only):
5
5
  * 1. CDP node resolution (using node_id from tree data)
6
6
  * 2. Playwright locator fallback (using element_name + element_type)
7
- * 3. Coordinate fallback (if returned by backend)
7
+ *
8
+ * Native (Android) targets are vision-located by the backend and tapped via
9
+ * normalized coordinates in AndroidDevice.executeAction — that coordinate path
10
+ * lives there, not here.
8
11
  */
9
12
  import type { Page } from "playwright-core";
10
13
  import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
@@ -13,6 +16,11 @@ import type { TabManager } from "./tabs.js";
13
16
  * Execute a single action on the page.
14
17
  */
15
18
  export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[], tabs?: TabManager): Promise<ActionResult>;
19
+ /**
20
+ * 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.
22
+ */
23
+ export declare function resolveTextValue(action: LocalStepAction, contextValues: ContextValue[]): string;
16
24
  /**
17
25
  * Compare two base64 screenshots to detect visible change.
18
26
  */
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Action executor — resolves elements and executes Playwright actions.
3
3
  *
4
- * Resolution strategy:
4
+ * Element resolution strategy (browser only):
5
5
  * 1. CDP node resolution (using node_id from tree data)
6
6
  * 2. Playwright locator fallback (using element_name + element_type)
7
- * 3. Coordinate fallback (if returned by backend)
7
+ *
8
+ * Native (Android) targets are vision-located by the backend and tapped via
9
+ * normalized coordinates in AndroidDevice.executeAction — that coordinate path
10
+ * lives there, not here.
8
11
  */
9
12
  import { resolveNodeToBoundingBox } from "./browser.js";
10
13
  import { isDebugEnabled } from "./debug.js";
@@ -78,7 +81,7 @@ export async function executeAction(page, action, treeData, contextValues, tabs)
78
81
  coordinates = await executeTextInput(page, action, treeData, contextValues);
79
82
  break;
80
83
  case "scroll":
81
- await executeScroll(page, action, treeData);
84
+ await executeScroll(page, action);
82
85
  break;
83
86
  case "swipe":
84
87
  case "pull_to_refresh":
@@ -87,9 +90,6 @@ export async function executeAction(page, action, treeData, contextValues, tabs)
87
90
  case "wait":
88
91
  await page.waitForTimeout(action.duration_ms ?? 1000);
89
92
  break;
90
- case "navigate_back":
91
- await page.goBack({ timeout: 10_000 }).catch(() => { });
92
- break;
93
93
  case "long_press":
94
94
  coordinates = await executeLongPress(page, action, treeData);
95
95
  break;
@@ -162,7 +162,7 @@ async function resolveElement(page, action, treeData) {
162
162
  /**
163
163
  * Resolve to a Playwright Locator (for fill/type operations that need a Locator).
164
164
  */
165
- async function resolveLocator(page, action, treeData) {
165
+ async function resolveLocator(page, action) {
166
166
  return findElement(page, action);
167
167
  }
168
168
  /**
@@ -237,7 +237,7 @@ async function executeTextInput(page, action, treeData, contextValues) {
237
237
  // Resolve the actual text to type
238
238
  const text = resolveTextValue(action, contextValues);
239
239
  // Try to get a Playwright locator for fill operations
240
- const locator = await resolveLocator(page, action, treeData);
240
+ const locator = await resolveLocator(page, action);
241
241
  if (locator) {
242
242
  if (action.mode === "click_type") {
243
243
  await locator.click({ timeout: 5000 });
@@ -273,7 +273,7 @@ async function executeTextInput(page, action, treeData, contextValues) {
273
273
  }
274
274
  }
275
275
  }
276
- async function executeScroll(page, action, treeData) {
276
+ async function executeScroll(page, action) {
277
277
  const viewport = page.viewportSize() ?? { width: 1440, height: 900 };
278
278
  const amountMap = {
279
279
  small: 0.5, medium: 0.8, large: 1.5, extra_large: 3.0,
@@ -378,8 +378,9 @@ async function executeKeyboardShortcut(page, action) {
378
378
  // --- Helpers ---
379
379
  /**
380
380
  * 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.
381
382
  */
382
- function resolveTextValue(action, contextValues) {
383
+ export function resolveTextValue(action, contextValues) {
383
384
  if (action.value_type === "var" || action.value_type === "secret") {
384
385
  const cv = contextValues.find(v => v.name === action.value);
385
386
  if (cv?.value)
@@ -433,7 +434,11 @@ export function describeAction(action) {
433
434
  case "double_tap":
434
435
  return `double_tap on '${element}'${modSuffix}`;
435
436
  case "drag":
436
- return `drag '${element}'`;
437
+ return action.drag
438
+ ? `drag '${element}' (${action.drag.startX},${action.drag.startY}→${action.drag.endX},${action.drag.endY})`
439
+ : `drag '${element}'`;
440
+ case "rotate_device":
441
+ return `rotate_device ${action.orientation ?? "?"}`;
437
442
  case "think":
438
443
  return `think: "${(action.thoughts ?? "").slice(0, 50)}"`;
439
444
  case "pull_to_refresh":