@ishlabs/cli 0.24.1 → 0.26.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/dist/commands/ask.js +3 -3
  2. package/dist/commands/doctor.d.ts +26 -0
  3. package/dist/commands/doctor.js +334 -0
  4. package/dist/commands/iteration.js +1 -1
  5. package/dist/commands/study-analyze.js +1 -1
  6. package/dist/commands/study-run.js +80 -12
  7. package/dist/commands/study.js +11 -7
  8. package/dist/index.js +2 -0
  9. package/dist/lib/alias-store.js +1 -1
  10. package/dist/lib/api-client.d.ts +2 -0
  11. package/dist/lib/docs.js +57 -42
  12. package/dist/lib/local-sim/actions.d.ts +10 -2
  13. package/dist/lib/local-sim/actions.js +18 -11
  14. package/dist/lib/local-sim/adb.d.ts +113 -0
  15. package/dist/lib/local-sim/adb.js +366 -0
  16. package/dist/lib/local-sim/android.d.ts +111 -0
  17. package/dist/lib/local-sim/android.js +504 -0
  18. package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
  19. package/dist/lib/local-sim/apk-manifest.js +210 -0
  20. package/dist/lib/local-sim/browser.d.ts +22 -0
  21. package/dist/lib/local-sim/browser.js +65 -0
  22. package/dist/lib/local-sim/coordinates.d.ts +69 -0
  23. package/dist/lib/local-sim/coordinates.js +59 -0
  24. package/dist/lib/local-sim/device.d.ts +143 -0
  25. package/dist/lib/local-sim/device.js +152 -0
  26. package/dist/lib/local-sim/ios.d.ts +185 -0
  27. package/dist/lib/local-sim/ios.js +599 -0
  28. package/dist/lib/local-sim/loop.d.ts +14 -2
  29. package/dist/lib/local-sim/loop.js +168 -73
  30. package/dist/lib/local-sim/native-a11y.d.ts +111 -0
  31. package/dist/lib/local-sim/native-a11y.js +419 -0
  32. package/dist/lib/local-sim/simctl.d.ts +55 -0
  33. package/dist/lib/local-sim/simctl.js +144 -0
  34. package/dist/lib/local-sim/types.d.ts +39 -2
  35. package/dist/lib/local-sim/upload.d.ts +1 -1
  36. package/dist/lib/local-sim/upload.js +9 -6
  37. package/dist/lib/local-sim/xcuitest.d.ts +60 -0
  38. package/dist/lib/local-sim/xcuitest.js +303 -0
  39. package/dist/lib/output.js +58 -12
  40. package/dist/lib/paths.d.ts +8 -0
  41. package/dist/lib/paths.js +12 -0
  42. package/dist/lib/skill-content.js +10 -9
  43. package/package.json +2 -1
@@ -44,6 +44,15 @@ export interface ContextValue {
44
44
  value: string | null;
45
45
  description?: string;
46
46
  }
47
+ /**
48
+ * Per-turn assignment status the agent can emit. Mirrors the backend's
49
+ * AssignmentStatus enum (app/db/schemas/enums/study.py) restricted to the
50
+ * LLM-emittable values (_LLM_EMITTABLE_STATUSES in
51
+ * app/interactive/instructions/output.py). The harness-only values
52
+ * (pending / max_steps_reached / failed) are NOT emittable per turn; the
53
+ * run-level AssignmentStatusUpdate sends those terminal values instead.
54
+ */
55
+ export type AssignmentStatus = "in_progress" | "completed" | "abandoned";
47
56
  export interface HistoryEntry {
48
57
  comment: string;
49
58
  action_description: string;
@@ -104,6 +113,19 @@ export interface LocalStepAction {
104
113
  modifiers: string[] | null;
105
114
  key: string | null;
106
115
  tab_id: string | null;
116
+ orientation: string | null;
117
+ panel: "quick_settings" | "notifications" | null;
118
+ scale: number | null;
119
+ coordinates: {
120
+ x: number;
121
+ y: number;
122
+ } | null;
123
+ drag: {
124
+ startX: number;
125
+ startY: number;
126
+ endX: number;
127
+ endY: number;
128
+ } | null;
107
129
  }
108
130
  /** Raw backend step response — output is nested, actions are separate. */
109
131
  export interface LocalSimStepResponseRaw {
@@ -114,7 +136,7 @@ export interface LocalSimStepResponseRaw {
114
136
  sentiment_intensity?: number;
115
137
  current_location: string;
116
138
  effort_seconds: number;
117
- assignment_completed: boolean;
139
+ assignment_status: AssignmentStatus;
118
140
  action: {
119
141
  actions: Array<{
120
142
  type: string;
@@ -135,6 +157,18 @@ export interface LocalSimStepResponseRaw {
135
157
  modifiers?: string[];
136
158
  key?: string;
137
159
  tab_id?: string;
160
+ orientation?: string;
161
+ panel?: "quick_settings" | "notifications";
162
+ scale?: number;
163
+ coordinates?: {
164
+ x: number;
165
+ y: number;
166
+ } | {
167
+ startX: number;
168
+ startY: number;
169
+ endX: number;
170
+ endY: number;
171
+ };
138
172
  }>;
139
173
  };
140
174
  };
@@ -149,6 +183,7 @@ export interface LocalSimStepResponse {
149
183
  sentiment_intensity: number;
150
184
  current_location: string;
151
185
  effort_seconds: number;
186
+ assignment_status: AssignmentStatus;
152
187
  assignment_completed: boolean;
153
188
  actions: LocalStepAction[];
154
189
  loop_detected: boolean;
@@ -174,6 +209,8 @@ export interface RecordInteraction {
174
209
  assignment_id: string;
175
210
  screenshot_base64?: string;
176
211
  screenshot_url?: string;
212
+ screen_width?: number;
213
+ screen_height?: number;
177
214
  frame_version_id?: string;
178
215
  timestamp_ms: number;
179
216
  comment: string | null;
@@ -181,7 +218,7 @@ export interface RecordInteraction {
181
218
  sentiment: SentimentData;
182
219
  actions: ActionData[];
183
220
  current_location: string | null;
184
- assignment_completed: boolean;
221
+ assignment_status: AssignmentStatus;
185
222
  tabs?: LocalTabInfo[];
186
223
  }
187
224
  export interface AssignmentStatusUpdate {
@@ -3,4 +3,4 @@ export interface ScreenshotUploadResult {
3
3
  screenshotUrl: string;
4
4
  screenshotId: string;
5
5
  }
6
- export declare function uploadScreenshot(client: ApiClient, productId: string, jpegBuffer: Buffer): Promise<ScreenshotUploadResult>;
6
+ export declare function uploadScreenshot(client: ApiClient, productId: string, imageBuffer: Buffer, contentType?: "image/jpeg" | "image/png"): Promise<ScreenshotUploadResult>;
@@ -1,20 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
- export async function uploadScreenshot(client, productId, jpegBuffer) {
2
+ export async function uploadScreenshot(client, productId, imageBuffer,
3
+ // Browser captures JPEG; native (adb screencap) emits PNG. The signed-URL
4
+ // request and the PUT header MUST agree so storage serves correct bytes.
5
+ contentType = "image/jpeg") {
3
6
  const screenshotId = randomUUID();
4
7
  // Step 1: Get signed URL from backend
5
8
  const resp = await client.localSimScreenshotUpload({
6
9
  product_id: productId,
7
10
  screenshot_id: screenshotId,
8
- content_type: "image/jpeg",
11
+ content_type: contentType,
9
12
  });
10
- // Step 2: PUT raw JPEG bytes directly to Supabase Storage
13
+ // Step 2: PUT raw image bytes directly to Supabase Storage
11
14
  const putResp = await fetch(resp.upload_info.signed_upload_url, {
12
15
  method: "PUT",
13
16
  headers: {
14
- "Content-Type": "image/jpeg",
15
- "Content-Length": String(jpegBuffer.byteLength),
17
+ "Content-Type": contentType,
18
+ "Content-Length": String(imageBuffer.byteLength),
16
19
  },
17
- body: jpegBuffer,
20
+ body: imageBuffer,
18
21
  signal: AbortSignal.timeout(30_000),
19
22
  });
20
23
  if (!putResp.ok) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Host-side WebDriverAgent (XCUITest) client — the iOS UI-interaction + a11y
3
+ * layer that replaces idb. Device LIFECYCLE (install target app, screenshot,
4
+ * launch/terminate target) stays on `xcrun simctl` in `simctl.ts`; this module
5
+ * owns ONLY the WDA surface.
6
+ *
7
+ * Mechanism (validated on the simulator, no xcodebuild): a PREBUILT WDA runner
8
+ * (Appium's `WebDriverAgentRunner-Runner.app`, bundle id
9
+ * `com.facebook.WebDriverAgentRunner.xctrunner`) is `simctl install`-ed and
10
+ * `simctl launch`-ed with `SIMCTL_CHILD_USE_PORT`; the xctrunner self-hosts the
11
+ * XCTest runtime and serves W3C WebDriver on `localhost:<port>`. We then drive
12
+ * taps/swipes/text via `/session/:id/actions` and read the a11y tree via
13
+ * `/session/:id/source?format=json` (parsed by `parseXcuiHierarchy`).
14
+ *
15
+ * API mirrors the idb functions in `simctl.ts` (udid-keyed, points space) so
16
+ * `ios.ts` swaps the import with a near-identical call surface. Per-udid session
17
+ * state (port + WDA sessionId) is held in a module map; `ensureWda` is idempotent
18
+ * and reuses an already-running runner.
19
+ */
20
+ import { type IosScreen } from "./simctl.js";
21
+ interface Session {
22
+ port: number;
23
+ baseUrl: string;
24
+ sessionId: string;
25
+ }
26
+ /**
27
+ * Resolve the prebuilt WDA `.app`, downloading it on first use:
28
+ * `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
29
+ * WebDriverAgent-for-simulator from its GitHub release. Mirrors
30
+ * `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
31
+ */
32
+ export declare function resolveWdaBundle(): Promise<string>;
33
+ /**
34
+ * Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
35
+ * runner already answers on the port (a prior `ensureWda`, or an externally
36
+ * launched one), it is reused — only a fresh session is created. Otherwise the
37
+ * prebuilt runner is installed and `simctl launch`-ed.
38
+ */
39
+ export declare function ensureWda(udid: string, opts?: {
40
+ bundleId?: string;
41
+ }): Promise<Session>;
42
+ /** Tear down the WDA session for `udid` (the runner is left for the next run). */
43
+ export declare function closeWda(udid: string): Promise<void>;
44
+ /** Screen geometry from WDA `/wda/screen` (points + retina scale). */
45
+ export declare function describeScreen(udid: string): Promise<IosScreen>;
46
+ /** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
47
+ export declare function describeAll(udid: string): Promise<string>;
48
+ export declare function uiTap(udid: string, x: number, y: number): Promise<void>;
49
+ export declare function uiLongPress(udid: string, x: number, y: number, durationMs?: number): Promise<void>;
50
+ export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
51
+ /** Type into the focused element (the caller taps a field first). */
52
+ export declare function uiText(udid: string, text: string): Promise<void>;
53
+ /**
54
+ * Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
55
+ * map it to W3C ENTER. Unknown codes are a no-op-safe error.
56
+ */
57
+ export declare function uiKey(udid: string, keycode: number): Promise<void>;
58
+ /** Re-export so a future ios.ts can drop the simctl HID constant. */
59
+ export declare const HID_KEY_RETURN = 40;
60
+ export {};
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Host-side WebDriverAgent (XCUITest) client — the iOS UI-interaction + a11y
3
+ * layer that replaces idb. Device LIFECYCLE (install target app, screenshot,
4
+ * launch/terminate target) stays on `xcrun simctl` in `simctl.ts`; this module
5
+ * owns ONLY the WDA surface.
6
+ *
7
+ * Mechanism (validated on the simulator, no xcodebuild): a PREBUILT WDA runner
8
+ * (Appium's `WebDriverAgentRunner-Runner.app`, bundle id
9
+ * `com.facebook.WebDriverAgentRunner.xctrunner`) is `simctl install`-ed and
10
+ * `simctl launch`-ed with `SIMCTL_CHILD_USE_PORT`; the xctrunner self-hosts the
11
+ * XCTest runtime and serves W3C WebDriver on `localhost:<port>`. We then drive
12
+ * taps/swipes/text via `/session/:id/actions` and read the a11y tree via
13
+ * `/session/:id/source?format=json` (parsed by `parseXcuiHierarchy`).
14
+ *
15
+ * API mirrors the idb functions in `simctl.ts` (udid-keyed, points space) so
16
+ * `ios.ts` swaps the import with a near-identical call surface. Per-udid session
17
+ * state (port + WDA sessionId) is held in a module map; `ensureWda` is idempotent
18
+ * and reuses an already-running runner.
19
+ */
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
23
+ import * as path from "node:path";
24
+ import { wdaDir, wdaVersionFile } from "../paths.js";
25
+ import { IosError } from "./simctl.js";
26
+ const execFileAsync = promisify(execFile);
27
+ const XCRUN = "/usr/bin/xcrun";
28
+ /** Bundle id of Appium's prebuilt WDA xctrunner (self-hosts the XCTest runtime). */
29
+ const WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
30
+ /** Default WDA port; override with ISH_WDA_PORT. One device → one runner. */
31
+ const DEFAULT_PORT = Number(process.env.ISH_WDA_PORT) || 8100;
32
+ /** WDA's XCTest runtime cold-starts slowly; poll /status up to this long. */
33
+ const STARTUP_TIMEOUT_MS = 75_000;
34
+ /** W3C ENTER key (maps the idb HID Return keycode 40 used by ios.ts). */
35
+ const W3C_ENTER = "\uE007"; // idb HID Return (40) -> W3C ENTER
36
+ const sessions = new Map();
37
+ // ── WDA bundle resolution (fetch is wired in the distribution phase) ──────────
38
+ /** Appium's prebuilt WebDriverAgent simulator release we fetch + pin. */
39
+ const WDA_PINNED_TAG = "v13.2.0";
40
+ /**
41
+ * Resolve the prebuilt WDA `.app`, downloading it on first use:
42
+ * `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
43
+ * WebDriverAgent-for-simulator from its GitHub release. Mirrors
44
+ * `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
45
+ */
46
+ export async function resolveWdaBundle() {
47
+ const override = process.env.ISH_WDA_PATH;
48
+ if (override) {
49
+ if (!existsSync(override)) {
50
+ throw new IosError(`ISH_WDA_PATH does not exist: ${override}`);
51
+ }
52
+ return override.endsWith(".app") ? override : findApp(override);
53
+ }
54
+ const dir = wdaDir();
55
+ if (existsSync(dir)) {
56
+ try {
57
+ return findApp(dir);
58
+ }
59
+ catch {
60
+ // cache dir exists but holds no .app yet → (re)download
61
+ }
62
+ }
63
+ return downloadWdaBundle();
64
+ }
65
+ /**
66
+ * Fetch Appium's prebuilt WDA-for-simulator zip (just the xctrunner `.app`,
67
+ * which self-hosts the XCTest runtime — no xcodebuild) and unpack it into
68
+ * `~/.ish/bin/wda/`. arch follows the host (arm64 sims on Apple Silicon, x86_64
69
+ * on Intel). Logs to stderr so a run shows the one-time fetch.
70
+ */
71
+ async function downloadWdaBundle() {
72
+ const arch = process.arch === "arm64" ? "arm64" : "x86_64";
73
+ const asset = `WebDriverAgentRunner-Build-Sim-${arch}.zip`;
74
+ const url = `https://github.com/appium/WebDriverAgent/releases/download/${WDA_PINNED_TAG}/${asset}`;
75
+ const dir = wdaDir();
76
+ console.error(`Fetching the iOS automation runner (WebDriverAgent ${WDA_PINNED_TAG}, ${arch})...`);
77
+ mkdirSync(dir, { recursive: true });
78
+ const zipPath = path.join(dir, "wda.zip");
79
+ let resp;
80
+ try {
81
+ resp = await fetch(url, { signal: AbortSignal.timeout(120_000) });
82
+ }
83
+ catch (e) {
84
+ throw new IosError(`failed to download WebDriverAgent from ${url}: ${e instanceof Error ? e.message : String(e)}`);
85
+ }
86
+ if (!resp.ok) {
87
+ throw new IosError(`failed to download WebDriverAgent: HTTP ${resp.status} from ${url}`);
88
+ }
89
+ writeFileSync(zipPath, Buffer.from(await resp.arrayBuffer()));
90
+ try {
91
+ await execFileAsync("/usr/bin/unzip", ["-o", "-q", zipPath, "-d", dir], { timeout: 60_000 });
92
+ }
93
+ catch (e) {
94
+ throw new IosError(`failed to unpack WebDriverAgent: ${e instanceof Error ? e.message : String(e)}`);
95
+ }
96
+ rmSync(zipPath, { force: true });
97
+ writeFileSync(wdaVersionFile(), `${WDA_PINNED_TAG} ${arch}\n`);
98
+ return findApp(dir);
99
+ }
100
+ /** First `*.app` directly under `dir` (or `dir` itself if it is one). */
101
+ function findApp(dir) {
102
+ if (dir.endsWith(".app") && existsSync(dir))
103
+ return dir;
104
+ try {
105
+ const hit = readdirSync(dir).find((e) => e.endsWith(".app"));
106
+ if (hit)
107
+ return path.join(dir, hit);
108
+ }
109
+ catch {
110
+ /* fallthrough */
111
+ }
112
+ throw new IosError(`No WebDriverAgentRunner-Runner.app found under ${dir}`);
113
+ }
114
+ // ── HTTP to the runner ────────────────────────────────────────────────────
115
+ async function wdaCall(port, method, route, body) {
116
+ const url = `http://localhost:${port}${route}`;
117
+ let resp;
118
+ try {
119
+ resp = await fetch(url, {
120
+ method,
121
+ headers: body ? { "Content-Type": "application/json" } : undefined,
122
+ body: body ? JSON.stringify(body) : undefined,
123
+ signal: AbortSignal.timeout(30_000),
124
+ });
125
+ }
126
+ catch (e) {
127
+ throw new IosError(`WDA ${method} ${route} failed: ${e instanceof Error ? e.message : String(e)}`);
128
+ }
129
+ const text = await resp.text();
130
+ let json;
131
+ try {
132
+ json = text ? JSON.parse(text) : {};
133
+ }
134
+ catch {
135
+ throw new IosError(`WDA ${method} ${route} returned non-JSON (HTTP ${resp.status})`);
136
+ }
137
+ if (!resp.ok) {
138
+ const detail = json?.value?.message ?? text.slice(0, 200);
139
+ throw new IosError(`WDA ${method} ${route} -> HTTP ${resp.status}: ${detail}`);
140
+ }
141
+ return json;
142
+ }
143
+ function unwrap(json) {
144
+ return json && typeof json === "object" && "value" in json
145
+ ? json.value
146
+ : json;
147
+ }
148
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
149
+ async function simctlRun(args, timeoutMs = 180_000) {
150
+ try {
151
+ const { stdout } = await execFileAsync(XCRUN, ["simctl", ...args], {
152
+ timeout: timeoutMs,
153
+ maxBuffer: 16 * 1024 * 1024,
154
+ });
155
+ return stdout;
156
+ }
157
+ catch (e) {
158
+ throw new IosError(`xcrun simctl ${args[0]} failed: ${e instanceof Error ? e.message : String(e)}`);
159
+ }
160
+ }
161
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
162
+ async function statusOk(port) {
163
+ try {
164
+ const resp = await fetch(`http://localhost:${port}/status`, { signal: AbortSignal.timeout(2500) });
165
+ return resp.ok;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ /**
172
+ * Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
173
+ * runner already answers on the port (a prior `ensureWda`, or an externally
174
+ * launched one), it is reused — only a fresh session is created. Otherwise the
175
+ * prebuilt runner is installed and `simctl launch`-ed.
176
+ */
177
+ export async function ensureWda(udid, opts = {}) {
178
+ const existing = sessions.get(udid);
179
+ if (existing && (await statusOk(existing.port)))
180
+ return existing;
181
+ const port = DEFAULT_PORT;
182
+ if (!(await statusOk(port))) {
183
+ const app = await resolveWdaBundle();
184
+ await simctlRun(["install", udid, app]);
185
+ // SIMCTL_CHILD_* env passes through to the launched process; USE_PORT tells
186
+ // WDA which port to bind. --terminate-running-process clears a stale runner.
187
+ await execFileAsync(XCRUN, ["simctl", "launch", "--terminate-running-process", udid, WDA_BUNDLE_ID], { timeout: 30_000, env: { ...process.env, SIMCTL_CHILD_USE_PORT: String(port) } }).catch((e) => {
188
+ throw new IosError(`failed to launch WDA runner: ${e instanceof Error ? e.message : String(e)}`);
189
+ });
190
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
191
+ while (!(await statusOk(port))) {
192
+ if (Date.now() > deadline) {
193
+ throw new IosError(`WDA runner did not start within ${STARTUP_TIMEOUT_MS / 1000}s`);
194
+ }
195
+ await sleep(1000);
196
+ }
197
+ }
198
+ const baseUrl = `http://localhost:${port}`;
199
+ const caps = opts.bundleId
200
+ ? { capabilities: { alwaysMatch: { bundleId: opts.bundleId } } }
201
+ : { capabilities: { alwaysMatch: {} } };
202
+ const resp = (await wdaCall(port, "POST", "/session", caps));
203
+ const sessionId = resp.sessionId ?? unwrap(resp)?.sessionId;
204
+ if (!sessionId)
205
+ throw new IosError("WDA did not return a sessionId");
206
+ const session = { port, baseUrl, sessionId };
207
+ sessions.set(udid, session);
208
+ return session;
209
+ }
210
+ async function getSession(udid) {
211
+ const s = sessions.get(udid);
212
+ if (s && (await statusOk(s.port)))
213
+ return s;
214
+ return ensureWda(udid);
215
+ }
216
+ /** Tear down the WDA session for `udid` (the runner is left for the next run). */
217
+ export async function closeWda(udid) {
218
+ const s = sessions.get(udid);
219
+ sessions.delete(udid);
220
+ if (!s)
221
+ return;
222
+ try {
223
+ await wdaCall(s.port, "DELETE", `/session/${s.sessionId}`);
224
+ }
225
+ catch {
226
+ /* best-effort teardown */
227
+ }
228
+ }
229
+ // ── Geometry + a11y ─────────────────────────────────────────────────────────
230
+ /** Screen geometry from WDA `/wda/screen` (points + retina scale). */
231
+ export async function describeScreen(udid) {
232
+ const s = await getSession(udid);
233
+ const v = unwrap(await wdaCall(s.port, "GET", "/wda/screen"));
234
+ const pointWidth = Number(v.screenSize?.width) || 0;
235
+ const pointHeight = Number(v.screenSize?.height) || 0;
236
+ const density = Number(v.scale) || 1;
237
+ if (!pointWidth || !pointHeight)
238
+ throw new IosError("WDA /wda/screen returned no screen size");
239
+ return {
240
+ pointWidth,
241
+ pointHeight,
242
+ density,
243
+ pixelWidth: Math.round(pointWidth * density),
244
+ pixelHeight: Math.round(pointHeight * density),
245
+ };
246
+ }
247
+ /** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
248
+ export async function describeAll(udid) {
249
+ const s = await getSession(udid);
250
+ const json = await wdaCall(s.port, "GET", `/session/${s.sessionId}/source?format=json`);
251
+ return JSON.stringify(json);
252
+ }
253
+ // ── Gestures (W3C pointer actions; coordinates in POINTS) ────────────────────
254
+ function pointerAction(steps) {
255
+ return {
256
+ actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: steps }],
257
+ };
258
+ }
259
+ async function performActions(udid, steps) {
260
+ const s = await getSession(udid);
261
+ await wdaCall(s.port, "POST", `/session/${s.sessionId}/actions`, pointerAction(steps));
262
+ }
263
+ export async function uiTap(udid, x, y) {
264
+ await performActions(udid, [
265
+ { type: "pointerMove", duration: 0, x, y },
266
+ { type: "pointerDown", button: 0 },
267
+ { type: "pause", duration: 60 },
268
+ { type: "pointerUp", button: 0 },
269
+ ]);
270
+ }
271
+ export async function uiLongPress(udid, x, y, durationMs = 600) {
272
+ await performActions(udid, [
273
+ { type: "pointerMove", duration: 0, x, y },
274
+ { type: "pointerDown", button: 0 },
275
+ { type: "pause", duration: durationMs },
276
+ { type: "pointerUp", button: 0 },
277
+ ]);
278
+ }
279
+ export async function uiSwipe(udid, x1, y1, x2, y2, durationMs = 300) {
280
+ await performActions(udid, [
281
+ { type: "pointerMove", duration: 0, x: x1, y: y1 },
282
+ { type: "pointerDown", button: 0 },
283
+ { type: "pointerMove", duration: durationMs, x: x2, y: y2 },
284
+ { type: "pointerUp", button: 0 },
285
+ ]);
286
+ }
287
+ /** Type into the focused element (the caller taps a field first). */
288
+ export async function uiText(udid, text) {
289
+ const s = await getSession(udid);
290
+ await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [...text] });
291
+ }
292
+ /**
293
+ * Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
294
+ * map it to W3C ENTER. Unknown codes are a no-op-safe error.
295
+ */
296
+ export async function uiKey(udid, keycode) {
297
+ if (keycode !== 40)
298
+ throw new IosError(`unsupported WDA keycode: ${keycode}`);
299
+ const s = await getSession(udid);
300
+ await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [W3C_ENTER] });
301
+ }
302
+ /** Re-export so a future ios.ts can drop the simctl HID constant. */
303
+ export const HID_KEY_RETURN = 40;
@@ -503,7 +503,7 @@ function suggestionsForError(err) {
503
503
  "If you didn't pass the resource explicitly, your saved active workspace/study/ask may be stale — run `ish status` to check, then `ish workspace use --clear` (or `ish study use --clear` / `ish ask use --clear`) to reset.",
504
504
  ];
505
505
  case "insufficient_credits":
506
- return ["Purchase more credits at https://app.ishlabs.io"];
506
+ return ["Get more credits at https://app.ishlabs.io"];
507
507
  case "usage_limit_reached": {
508
508
  const d = structuredDetail(err);
509
509
  const upgradeUrl = typeof d?.upgrade_url === "string" ? d.upgrade_url : "https://app.ishlabs.io/billing";
@@ -625,7 +625,7 @@ export function outputError(err, json) {
625
625
  }
626
626
  else {
627
627
  if (err.status === 402) {
628
- console.error("Error: Insufficient credits. Purchase more at https://app.ishlabs.io");
628
+ console.error("Error: Insufficient credits. Get more credits at https://app.ishlabs.io");
629
629
  }
630
630
  else {
631
631
  console.error(`Error: ${remapEntityName(err.message)}`);
@@ -1222,7 +1222,7 @@ export function formatStudyResults(study, participants, json) {
1222
1222
  console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
1223
1223
  }
1224
1224
  }
1225
- console.log("\nRun `ish participant get <id> --json` for full interaction details.");
1225
+ console.log("\nRun `ish study participant <id> --json` for full interaction details.");
1226
1226
  }
1227
1227
  }
1228
1228
  /**
@@ -1668,7 +1668,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1668
1668
  // Pattern A (cli half): list per-participant error_message under the table so
1669
1669
  // agents see why a simulation failed without re-fetching every participant.
1670
1670
  // Truncate to 200 chars; full text is available via --json or
1671
- // `ish study participant get <id>`.
1671
+ // `ish study participant <id>`.
1672
1672
  const failedRows = results.filter((r) => {
1673
1673
  const status = String(r.status || "").toLowerCase();
1674
1674
  return (status === "failed" || status === "errored") && r.error_message;
@@ -1689,6 +1689,43 @@ function variantLetter(index) {
1689
1689
  return String.fromCharCode(65 + index);
1690
1690
  return `V${index + 1}`;
1691
1691
  }
1692
+ /**
1693
+ * Assign one stable letter per variant id across an ask's rounds — the CLI
1694
+ * mirror of the backend's `app/asks/variant_loader.py:build_ask_label_map`.
1695
+ *
1696
+ * D2 fix: the LLM letters variants GLOBALLY across the whole ask. It scans
1697
+ * rounds in `order_index` order and gives `A, B, C…` on the *first appearance*
1698
+ * of each stable variant id, so the participant comments and round summaries
1699
+ * say `[[A]]/[[B]]` in round 1 and `[[C]]/[[D]]` in round 2. The CLI table used
1700
+ * to re-letter each round positionally (`variantLetter(localIndex)` → A/B every
1701
+ * round), so a comment's `[[C]]` pointed at a row labeled `A`. Building the same
1702
+ * id-keyed map here makes the table, the picks/winner/ratings aggregates, and
1703
+ * the cross-round columns all agree with the `[[token]]` letters.
1704
+ *
1705
+ * Identity is the variant `id` (persisted variants always carry one). A variant
1706
+ * missing an id is skipped from the map — it then falls back to the round-local
1707
+ * positional letter at the call site, matching the backend's documented fallback
1708
+ * for direct callers with no cross-round context.
1709
+ */
1710
+ function buildAskLabelMap(rounds) {
1711
+ const labelMap = new Map();
1712
+ const ordered = [...rounds].sort((a, b) => {
1713
+ const ai = typeof a.order_index === "number" ? a.order_index : 0;
1714
+ const bi = typeof b.order_index === "number" ? b.order_index : 0;
1715
+ return ai - bi;
1716
+ });
1717
+ for (const round of ordered) {
1718
+ const variants = Array.isArray(round.variants) ? round.variants : [];
1719
+ for (const v of variants) {
1720
+ const id = v.id;
1721
+ if (typeof id !== "string" || id.length === 0)
1722
+ continue;
1723
+ if (!labelMap.has(id))
1724
+ labelMap.set(id, variantLetter(labelMap.size));
1725
+ }
1726
+ }
1727
+ return labelMap;
1728
+ }
1692
1729
  export function formatAskList(asks, json) {
1693
1730
  injectAliases(asks, ALIAS_PREFIX.ask);
1694
1731
  if (json) {
@@ -1852,13 +1889,17 @@ export function formatRoundDetail(round, json) {
1852
1889
  console.log(` ${summary.comment}`);
1853
1890
  }
1854
1891
  }
1855
- function computeVariantStats(round) {
1892
+ function computeVariantStats(round, labelMap) {
1856
1893
  const variants = Array.isArray(round.variants) ? round.variants : [];
1857
1894
  const responses = Array.isArray(round.responses) ? round.responses : [];
1858
1895
  const stats = variants.map((v, i) => {
1859
1896
  const variant = v;
1897
+ const id = typeof variant.id === "string" ? variant.id : undefined;
1860
1898
  return {
1861
- letter: variantLetter(i),
1899
+ // D2: prefer the ask-global letter (id-keyed, matches the LLM's
1900
+ // `[[token]]` letters) and only fall back to the round-local positional
1901
+ // letter when no map entry exists (single-round / mapless callers).
1902
+ letter: (id && labelMap?.get(id)) || variantLetter(i),
1862
1903
  label: variant.label ? String(variant.label) : undefined,
1863
1904
  kind: String(variant.kind || "-"),
1864
1905
  pickCount: 0,
@@ -2087,13 +2128,13 @@ export function deriveWinnerConfidence(args) {
2087
2128
  return "medium";
2088
2129
  return "high";
2089
2130
  }
2090
- function buildCrossRoundSummary(rounds) {
2131
+ function buildCrossRoundSummary(rounds, labelMap) {
2091
2132
  if (rounds.length < 2)
2092
2133
  return undefined;
2093
2134
  const entries = [];
2094
2135
  for (const round of rounds) {
2095
2136
  const idx = typeof round.order_index === "number" ? round.order_index : 0;
2096
- const stats = computeVariantStats(round);
2137
+ const stats = computeVariantStats(round, labelMap);
2097
2138
  const aggregates = buildAggregates(round, stats);
2098
2139
  const entry = {
2099
2140
  round_number: idx + 1,
@@ -2128,12 +2169,17 @@ export function formatAskResults(ask, json, roundFilter) {
2128
2169
  const filtered = roundFilter !== undefined
2129
2170
  ? rounds.filter((r) => (typeof r.order_index === "number" ? r.order_index : 0) === roundFilter - 1)
2130
2171
  : rounds;
2172
+ // D2: build the ask-global variant→letter map from the FULL round list (not
2173
+ // `filtered`) so that even `--round 2` still letters its variants C/D — the
2174
+ // letter a variant earned when it debuted, matching the `[[token]]` letters
2175
+ // in the LLM's comments and round summaries.
2176
+ const labelMap = buildAskLabelMap(rounds);
2131
2177
  if (json) {
2132
2178
  let total = 0;
2133
2179
  let complete = 0;
2134
2180
  let errored = 0;
2135
2181
  const enrichedRounds = filtered.map((round) => {
2136
- const stats = computeVariantStats(round);
2182
+ const stats = computeVariantStats(round, labelMap);
2137
2183
  const aggregates = buildAggregates(round, stats);
2138
2184
  const decorated = denormalizeRoundCounts(round);
2139
2185
  total += decorated.responses_total ?? 0;
@@ -2158,7 +2204,7 @@ export function formatAskResults(ask, json, roundFilter) {
2158
2204
  }
2159
2205
  // Pattern H2: include cross-round summary when 2+ rounds exist so agents
2160
2206
  // don't have to diff two `ask results` calls themselves.
2161
- const crossRound = buildCrossRoundSummary(filtered);
2207
+ const crossRound = buildCrossRoundSummary(filtered, labelMap);
2162
2208
  if (crossRound)
2163
2209
  payload.cross_round_summary = crossRound;
2164
2210
  console.log(jsonOutput(payload));
@@ -2175,7 +2221,7 @@ export function formatAskResults(ask, json, roundFilter) {
2175
2221
  const completed = responses.filter((r) => r.status === "completed");
2176
2222
  console.log(`\nRound ${idx + 1} [${round.status || "-"}] · ${completed.length}/${responses.length} responded`);
2177
2223
  console.log(` Prompt: "${truncate(String(round.prompt || ""), 100)}"`);
2178
- const stats = computeVariantStats(round);
2224
+ const stats = computeVariantStats(round, labelMap);
2179
2225
  if (stats.length > 0 && (round.wants_pick || round.wants_ratings)) {
2180
2226
  const hasPick = !!round.wants_pick;
2181
2227
  const hasRatings = !!round.wants_ratings;
@@ -2222,7 +2268,7 @@ export function formatAskResults(ask, json, roundFilter) {
2222
2268
  }
2223
2269
  // Pattern H2: cross-round picks comparison when 2+ rounds exist. Saves
2224
2270
  // agents from re-running results twice and diffing aggregates by hand.
2225
- const crossRound = buildCrossRoundSummary(filtered);
2271
+ const crossRound = buildCrossRoundSummary(filtered, labelMap);
2226
2272
  if (crossRound) {
2227
2273
  console.log("\nCross-round summary:");
2228
2274
  const letters = new Set();
@@ -12,4 +12,12 @@ export declare function binDir(): string;
12
12
  export declare function browsersDir(): string;
13
13
  export declare function simulationsDir(): string;
14
14
  export declare function cloudflaredBin(): string;
15
+ /**
16
+ * Cache dir for the prebuilt iOS XCUITest runner (WebDriverAgent) bundle —
17
+ * the `.app` + `.xctestrun` fetched on demand for native iOS simulations,
18
+ * mirroring how `cloudflaredBin()` is fetched into `binDir()`.
19
+ */
20
+ export declare function wdaDir(): string;
21
+ /** Stamp file recording which CLI/runner version the cached WDA bundle is for. */
22
+ export declare function wdaVersionFile(): string;
15
23
  export declare function connectLockPath(): string;