simulator-use 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,20 @@
1
1
  # simulator-use
2
2
 
3
- Give your agent a phone. simulator-use lets Claude Code, Codex, Cursor and any other MCP client read and drive the iOS Simulator on your Mac, with a small set of tools and a compact view of the screen.
3
+ **The fastest, most token-efficient way for an AI agent to use the iOS Simulator.** simulator-use gives Claude Code, Codex, Cursor and any other MCP client a phone: it builds and runs your Xcode project, reads the screen as a few lines of text, taps and types by label, and runs your tests case by case. Its own native engine talks to the Simulator directly, so reading the whole screen takes about 250 ms and a tap goes out in under a millisecond, where tools that start a helper for every call take 1.5 to 3 seconds. Free and open source (MIT), on any Mac with Xcode, and from Linux, Windows or a Mac without Xcode through a cloud iPhone.
4
4
 
5
- Formerly simtap (until 0.7): the `simtap` command and the `SIMTAP_*` settings still work.
5
+ - **Fast:** a whole-screen read in about 250 ms, a tap sent in under 1 ms, a screenshot in about 60 ms (measured on an M6 Mac mini, October 2026).
6
+ - **Token-efficient:** one line per element the agent can use, nothing else; a dozen tools with short descriptions.
7
+ - **Complete:** build, install, launch, XCTest and XCUITest per case, logs and crashes, video, permission prompts, iPhone and iPad.
8
+ - **Continuously improved:** weekly releases driven by thousands of daily sessions.
9
+ - **Anywhere:** `npx simulator-use cloud start` gives a cloud iPhone with Xcode to any machine.
10
+
11
+ It builds, runs and tests Xcode projects, reads and uses the screen of any app (SwiftUI, UIKit, React Native), answers iOS's permission prompts, reads the app's logs and crashes, and records video.
6
12
 
7
- It builds, runs and tests Xcode projects, reads and uses the screen of any app (SwiftUI, UIKit, React Native), answers iOS's permission prompts, reads the app's logs and crashes, and records video. Tested on Xcode 26.5 and 27 with iOS 26.5 and 27, on iPhone and iPad.
8
13
 
9
14
  ## What the agent sees
10
15
 
11
16
  ```
12
- Demo · Sign In · iPhone 17 Pro (iOS 26.5)
17
+ Demo · Sign In · iPhone 17 Pro (iOS 27.0)
13
18
  textfield "Email" #email @201,194
14
19
  securefield "Password" #password @201,246
15
20
  switch "Remember me" #remember off @341,298
@@ -22,7 +27,7 @@ tabs: Login (selected) @158,822, Items @244,822
22
27
  - Groups, icons inside buttons, scroll bars, the status bar and the keyboard's keys are folded away. The tab bar, scrolling and the keyboard are one line each.
23
28
  - Only what can be tapped is listed. An alert, a sheet, a menu or a full-screen view in front hides the screen behind it, and iOS's own permission prompts read as alerts over the app. Rows under the navigation bar, the tab bar or the keyboard are counted as hidden (above, below, to the left or right), not listed.
24
29
  - A web view in UIKit (Capacitor, React Native's WebView, a sign-in page inside most apps) is read point by point, and its headings, texts, fields, checkboxes, buttons and links are listed like the rest. Web views hosted by SwiftUI, Safari's own pages, in-app browsers, maps, games and iOS's out-of-process pickers have nothing for accessibility to read. They come back as one `unreadable` line with their place, so the agent knows to take a screenshot and tap by point.
25
- - A screenshot, when asked for, is sized to points, so a point in the picture is a point to tap.
30
+ - A screenshot, when asked for, is sized to points, so a point in the picture is a point to tap. `screenshot_file` saves the screen at full resolution as a PNG for a person, a report or the App Store; in cloud mode it is saved on your machine too.
26
31
  - The sign-in screen above is about 70 tokens. The full accessibility tree it comes from is about 40,000.
27
32
 
28
33
  ## Using the screen
@@ -67,7 +72,7 @@ Every step is checked, so nothing succeeds silently:
67
72
  | `devices` | `simulator-use devices` | Lists the Simulators on this Mac with their runtime, UDID and state, and its Xcodes |
68
73
  | `device_use` | `simulator-use use "iPhone 17 Pro"` | Chooses the Simulator the other tools use, booting it (one boot at a time); dark mode, a clean status bar |
69
74
  | `app_control` | `simulator-use app launch dev.example.app` | `install`, `launch` (answers with the first screen), `terminate`, `uninstall`, `open_url`; `grant`, `revoke` and `reset_permissions`; `location`; `push`; the phone's `appearance`, `locale`, `timezone`, `text_size` and a clean `status_bar` |
70
- | `ui_look` | `simulator-use look` | Reads the screen; `find` keeps matching lines, `screenshot` adds a picture, `full` saves the whole tree |
75
+ | `ui_look` | `simulator-use look` | Reads the screen; `find` keeps matching lines, `screenshot` adds a picture, `screenshot_file` saves a full-resolution PNG, `full` saves the whole tree |
71
76
  | `ui_act` | `simulator-use act "tap #signIn" ...` | Does steps in order and answers with what changed |
72
77
  | `app_logs` | `simulator-use logs` | The app's print output and its own system log lines; `grep`, `wait_for`, `crashes` |
73
78
  | `tests_run` | `simulator-use test` | Runs the unit and UI tests; each case as it finishes, counts, each failure as `file:line` and the slowest; "0 tests ran" is an error |
@@ -87,7 +92,7 @@ The app's permissions are granted when it is installed and its first launch's pr
87
92
  ```
88
93
  Built Demo in 4.2 s, installed dev.simtap.demo.
89
94
  Launched Demo in 3.9 s.
90
- Demo · Sign In · iPhone 17 Pro (iOS 26.5)
95
+ Demo · Sign In · iPhone 17 Pro (iOS 27.0)
91
96
  textfield "Email" #email @201,194
92
97
  ...
93
98
  ```
@@ -126,7 +131,7 @@ DemoTests/rejectsAnEmailWithoutAt(): Tests/SignInTests.swift:18: Expectation fai
126
131
 
127
132
  ## Several agents, one Mac
128
133
 
129
- - Each agent runs its own MCP server and holds its own Simulator through `device_use`.
134
+ - Each agent runs its own MCP server and holds its own Simulator through `device_use`, and its own cloud phone: one agent's `cloud start` never moves another's tools, its `cloud stop` never ends another's phone, and the phone stops when the agent's server ends. A `cloud start` typed in a terminal is the command line's own.
130
135
  - Boots never overlap, across every simulator-use process on the Mac: two Simulators booting at once on an 8 GB Mac pushed it into swap for minutes.
131
136
  - `SIMULATOR_USE_MAX_BOOTED` caps how many run at once; asking for another shuts down the others first.
132
137
  - Steps on one Simulator run one at a time, across every simulator-use process on the Mac, so two agents' or two commands' taps never interleave.
@@ -140,7 +145,7 @@ Agents that run simulator-use through a shell, a command per call, get the same
140
145
 
141
146
  ```
142
147
  $ simulator-use call ui_look '{"find":"Sign In"}'
143
- {"content":[{"type":"text","text":"Demo · Sign In · iPhone 17 Pro (iOS 26.5)\nbutton \"Sign In\" #signIn @201,350"}]}
148
+ {"content":[{"type":"text","text":"Demo · Sign In · iPhone 17 Pro (iOS 27.0)\nbutton \"Sign In\" #signIn @201,350"}]}
144
149
  ```
145
150
 
146
151
  ## What it can't do yet
@@ -184,13 +189,14 @@ From a shell: `npx simulator-use look`.
184
189
  Where this machine can't run the Simulator (Linux, Windows, a cloud agent), or for CI, the same tools run on a cloud iPhone Simulator on [NoMac](https://nomac.app/?utm_source=simulator-use), a Mac with Xcode, $0.80 an hour by the minute:
185
190
 
186
191
  ```
187
- simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone
192
+ simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone (--device ipad for an iPad)
188
193
  simulator-use run # every tool now runs there; run and test send the project first
194
+ simulator-use cloud extend 30 # 30 minutes more (an hour by default), up to 8 hours in all
189
195
  simulator-use cloud stop # ends it; the tools run on this machine again
190
196
  simulator-use cloud run . --tests # CI in one line: start, build, run, test, a screenshot and a recording, stop
191
197
  ```
192
198
 
193
- Only the files the phone lacks are sent (a one-file change sends one file), into the account's own encrypted workspace, kept between sessions so builds stay incremental; build output and dependencies stay behind. The `cloud` tool does the same over MCP.
199
+ Only the files the phone lacks are sent (a one-file change sends one file), into the account's own encrypted workspace, kept between sessions so builds stay incremental; build output and dependencies stay behind. Only files changed since the last send are read again (an index in simulator-use's state folder; `SIMULATOR_USE_SYNC_INDEX=0` reads every file). A CocoaPods project builds there too: `pod install` runs on the phone's host before the build when the pods are not the ones `Podfile` and `Podfile.lock` name, keeping the lock's versions, and the next build skips it. The `cloud` tool does the same over MCP.
194
200
 
195
201
  `cloud start` answers a link for a person to watch the phone in a browser and one to use it. `cloud stop` answers the session's replay: the screen as a video beside every call (typed text left out), kept for 7 days.
196
202
 
@@ -200,7 +206,7 @@ An app that talks to a backend on your machine reaches it through a tunnel (Node
200
206
  simulator-use cloud tunnel 8080 # until Ctrl-C: the phone reaches localhost:8080 here, each request printed
201
207
  ```
202
208
 
203
- The answer says the address the phone's apps use, such as `http://127.0.0.1:20199`; point the app there, for example with `run --env "API_URL=http://127.0.0.1:20199"`. Each request that passes is listed with its status and time (`cloud tunnel` without a port shows them over MCP). It lasts while the phone runs and comes back by itself after a dropped connection; `cloud tunnel` with port 0 closes it.
209
+ The answer says the address the phone's apps use, such as `http://127.0.0.1:20199`; point the app there, for example with `run --env "API_URL=http://127.0.0.1:20199"`. Each request that passes is listed with its status and time (`cloud tunnel` without a port shows them over MCP). It lasts while the phone runs and comes back by itself after a dropped connection; `cloud tunnel` with port 0 closes it. While it is open, anything on the phone reaches that port: its apps, pages in its Safari and your project's build; nothing outside the phone does.
204
210
 
205
211
  On GitHub, from any runner, with the result posted on each pull request and the screenshot and recording kept as artifacts:
206
212
 
@@ -209,14 +215,16 @@ permissions:
209
215
  pull-requests: write
210
216
  steps:
211
217
  - uses: actions/checkout@v5
212
- - uses: NoMac-app/simulator-use@v0.10
218
+ - uses: NoMac-app/simulator-use@v0.12
213
219
  with:
214
220
  api-key: ${{ secrets.NOMAC_API_KEY }}
215
221
  ```
216
222
 
223
+ It runs the simulator-use of its own tag (`@v0.12` runs what that tag holds; `version` picks another). `path` is the project's folder, `scheme` the scheme when the project has several, and `tests: false` skips the tests (a project without a test target fails otherwise: zero tests is not a pass). A cancelled run stops its phone, and a pull request from a fork, which gets no secrets, fails with that reason. A job's log and its pull request comment can be public, so in CI (`CI` or `GITHUB_ACTIONS` set) `cloud run` prints no link that opens without signing in: files it links are kept in the run's artifacts, and the replay's link is read with the account's key (`GET https://nomac.app/api/v1/simulator/sessions/{id}`, `links.replay`).
224
+
217
225
  ## Privacy
218
226
 
219
- simulator-use sends nothing unless cloud mode is on. There is no telemetry and no network access at runtime: it runs `xcrun simctl`, the bundled AXe binary and `sips`, all on your Mac. With cloud on, it sends the project being run or tested, and the tools' calls, to NoMac, which keeps the session's replay for 7 days.
227
+ simulator-use sends nothing unless cloud mode is on. There is no telemetry and no network access at runtime: it runs `xcrun simctl`, the bundled AXe binary and `sips`, all on your Mac. With cloud on, it sends the project being run or tested, and the tools' calls, to NoMac, which keeps the session's replay for 7 days. The project is the git repository it is in, as the files git tracks or would track (what `.gitignore` leaves out stays on your machine), or its folder when it is in none; either way never build output, dependencies, editor state or key and credential files (`.env` files but their examples, `.p8`, `.p12`, `.pfx`, `.pem`, `.key`, provisioning profiles, keystores, SSH keys, `.netrc`), even when git tracks them.
220
228
 
221
229
  ## How it works
222
230
 
package/dist/act/steps.js CHANGED
@@ -186,3 +186,12 @@ export function describeTarget(t) {
186
186
  return `@${t.x},${t.y}`;
187
187
  return `${t.kind ? `${t.kind} ` : ""}${JSON.stringify(t.label)}`;
188
188
  }
189
+ /** A step as an answer repeats it: a fill's or type's text left out, since the answer can reach logs and a replay. */
190
+ export function maskedStep(line) {
191
+ const trimmed = line.trim();
192
+ const verb = (/^\S+/.exec(trimmed)?.[0] ?? "").toLowerCase();
193
+ if (verb !== "fill" && verb !== "type")
194
+ return trimmed;
195
+ const step = parseStep(trimmed);
196
+ return typeof step !== "string" && step.op === "fill" ? `fill ${describeTarget(step.target)} …` : `${verb} …`;
197
+ }
package/dist/cli.js CHANGED
@@ -5,9 +5,13 @@
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { persistCloud } from "./cloud/account.js";
10
+ import { onSignalExit } from "./engine/jobs.js";
8
11
  import { persistHeld } from "./engine/state.js";
9
12
  import { serve } from "./mcp.js";
10
13
  import { callTool, toContent, TOOLS, VERSION } from "./registry.js";
14
+ import { stopServerPhone } from "./tools/cloud.js";
11
15
  import { relay, relayPorts } from "./tunnel-relay.js";
12
16
  const flag = (name) => `--${name.replace(/_/g, "-")}`;
13
17
  /** Parses `--name value` and `--switch` into arguments the tool's schema accepts. */
@@ -151,12 +155,17 @@ export async function main(argv) {
151
155
  process.exit(0);
152
156
  }
153
157
  if (command === "mcp") {
158
+ // The server's cloud phone is its agent's own: stopped when the server ends, by a signal or below.
159
+ onSignalExit(stopServerPhone);
154
160
  await serve();
155
- // The client closed stdin: answer what was in flight, then go, even if a child process still holds a pipe.
161
+ // The client closed stdin: answer what was in flight, stop the server's phone, then go, even if a child process
162
+ // still holds a pipe.
163
+ await Promise.race([stopServerPhone().catch(() => undefined), delay(15_000, undefined, { ref: false })]);
156
164
  process.exit(0);
157
165
  }
158
- // From here on each command is its own process: the Simulator chosen with `use` is kept in a file.
166
+ // From here on each command is its own process: the Simulator chosen with `use` and cloud mode are kept in files.
159
167
  persistHeld();
168
+ persistCloud();
160
169
  if (command === "call")
161
170
  return call(rest);
162
171
  const tool = TOOLS.find((t) => t.command === command || t.name === command);
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Cloud mode's account and phone: the NoMac account it signs in to (NOMAC_API_KEY, else the NoMac CLI's
3
3
  * ~/.nomac/credentials.json, else NoMac's device sign-in, whose key is saved there for both tools), and the phone
4
- * it uses, kept in simulator-use's state folder so every CLI call reaches the same one.
4
+ * it uses: an MCP server's own, in its memory; the CLI's in simulator-use's state folder, so every CLI call reaches
5
+ * the same one.
5
6
  */
6
7
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
8
  import { homedir, hostname } from "node:os";
@@ -34,7 +35,21 @@ async function saveKey(key, base) {
34
35
  await chmod(credentialsFile(), 0o600);
35
36
  }
36
37
  const stateFile = () => join(stateDir(), "cloud.json");
38
+ /**
39
+ * An MCP server keeps cloud mode in its memory, as it keeps its held Simulator: each agent runs its own server, so
40
+ * one agent's cloud start never sends another agent's tools to its phone, and one's cloud stop never ends another's.
41
+ * A CLI call is its own process, so the CLI keeps it in a file (persistCloud), and cloud start carries over to the
42
+ * next command.
43
+ */
44
+ let memory = { on: false };
45
+ let persistent = false;
46
+ /** The CLI keeps cloud mode between its calls. */
47
+ export function persistCloud() {
48
+ persistent = true;
49
+ }
37
50
  export async function cloudState() {
51
+ if (!persistent)
52
+ return { ...memory };
38
53
  try {
39
54
  return JSON.parse(await readFile(stateFile(), "utf8"));
40
55
  }
@@ -43,6 +58,10 @@ export async function cloudState() {
43
58
  }
44
59
  }
45
60
  export async function setCloudState(state) {
61
+ if (!persistent) {
62
+ memory = { ...state };
63
+ return;
64
+ }
46
65
  await mkdir(stateDir(), { recursive: true });
47
66
  await writeFile(stateFile(), JSON.stringify(state), { mode: 0o600 });
48
67
  }
@@ -57,6 +76,9 @@ export async function signIn() {
57
76
  const ready = await account();
58
77
  if (ready)
59
78
  return { api: ready };
79
+ // In CI nobody can open a sign-in link: no key is a missing secret (a fork's pull request gets none).
80
+ if (process.env.CI)
81
+ throw new Error("No NOMAC_API_KEY: in CI, set it from a secret (a pull request from a fork gets no secrets).");
60
82
  const state = await cloudState();
61
83
  const base = (process.env.NOMAC_API_URL || DEFAULT_API).replace(/\/+$/, "");
62
84
  let pending = state.signIn && state.signIn.expires_at > Date.now() && state.signIn.base === base ? state.signIn : undefined;
package/dist/cloud/api.js CHANGED
@@ -6,10 +6,13 @@ import { VERSION } from "../registry.js";
6
6
  export class CloudError extends Error {
7
7
  code;
8
8
  status;
9
- constructor(code, message, status) {
9
+ /** The rest of NoMac's error: starting (a phone not ready yet), retry_after_seconds, next_action ... */
10
+ details;
11
+ constructor(code, message, status, details = {}) {
10
12
  super(message);
11
13
  this.code = code;
12
14
  this.status = status;
15
+ this.details = details;
13
16
  }
14
17
  }
15
18
  const PASSING = new Set([429, 502, 503, 504]);
@@ -55,7 +58,8 @@ export class CloudApi {
55
58
  await pause(attempt, Number(response.headers.get("retry-after")));
56
59
  continue;
57
60
  }
58
- throw new CloudError(error.code ?? `http_${response.status}`, error.message ?? `NoMac answered ${response.status}.`, response.status);
61
+ const { code: _code, message: _message, ...details } = error;
62
+ throw new CloudError(error.code ?? `http_${response.status}`, error.message ?? `NoMac answered ${response.status}.`, response.status, details);
59
63
  }
60
64
  }
61
65
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The files a phone's answers link to, saved on this machine: a link ends with its phone, and a local tool's answer
3
+ * names a path.
4
+ */
5
+ import { createWriteStream, existsSync } from "node:fs";
6
+ import { unlink } from "node:fs/promises";
7
+ import { basename, join } from "node:path";
8
+ import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ /** A file link in a phone's answer: <base>/api/v1/simulator/files/<payload>.<signature>, never the sentence's period. */
11
+ const FILE_LINK = /https?:\/\/[^\s"'<>]+?\/api\/v1\/simulator\/files\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
12
+ const KEEP_FILES = 10;
13
+ const KEEP_BYTES = 200 * 1024 * 1024;
14
+ /** The file's name as the link carries it (its payload's n), made safe for a folder. */
15
+ function linkName(link) {
16
+ try {
17
+ const payload = /\/files\/([A-Za-z0-9_-]+)\./.exec(link)?.[1];
18
+ const name = payload ? JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).n : undefined;
19
+ const safe = typeof name === "string" ? basename(name).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") : "";
20
+ return safe || undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /**
27
+ * The files a run's answers link to (a failed build's full log), kept in out before the phone stops: a link that
28
+ * was never opened ends with its phone. At most 10 files and 200 MB; each link in lines becomes its file's path.
29
+ */
30
+ export async function keepFiles(lines, out) {
31
+ const links = [...new Set(lines.join("\n").match(FILE_LINK) ?? [])].slice(0, KEEP_FILES);
32
+ let total = 0;
33
+ for (const [i, link] of links.entries()) {
34
+ const name = linkName(link) ?? `simulator-use-file-${i + 1}`;
35
+ const file = join(out, existsSync(join(out, name)) ? `${i + 1}-${name}` : name);
36
+ try {
37
+ const response = await fetch(link, { signal: AbortSignal.timeout(120_000) });
38
+ if (!response.ok || !response.body || total + Number(response.headers.get("content-length") ?? 0) > KEEP_BYTES)
39
+ continue;
40
+ let size = 0;
41
+ const capped = Readable.fromWeb(response.body).map((chunk) => {
42
+ size += chunk.length;
43
+ if (total + size > KEEP_BYTES)
44
+ throw new Error("over the run's file budget");
45
+ return chunk;
46
+ });
47
+ await pipeline(capped, createWriteStream(file));
48
+ total += size;
49
+ for (let j = 0; j < lines.length; j++)
50
+ lines[j] = lines[j].split(link).join(file);
51
+ }
52
+ catch {
53
+ // The link stays in the text; a file cut short is not kept.
54
+ await unlink(file).catch(() => undefined);
55
+ }
56
+ }
57
+ }
@@ -1,17 +1,25 @@
1
1
  /**
2
2
  * With cloud on, every tool runs on the NoMac phone instead of this machine: called by name through NoMac's API
3
3
  * and polled until it ends. app_run and tests_run send their project first, and app_control install its .app, so
4
- * the paths the phone takes are its own. Answers read as a local tool's do; files they name are links.
4
+ * the paths the phone takes are its own. Answers read as a local tool's do; files they name are links, but for the
5
+ * files ui_look saves, which are saved here.
5
6
  */
6
7
  import { existsSync, realpathSync, statSync } from "node:fs";
8
+ import { mkdir } from "node:fs/promises";
7
9
  import { basename, dirname, extname, relative, resolve, sep } from "node:path";
8
10
  import { createHash } from "node:crypto";
9
11
  import { run } from "../engine/exec.js";
12
+ import { stateDir } from "../engine/state.js";
10
13
  import { ToolError } from "../errors.js";
11
14
  import { account, cloudState, setCloudState } from "./account.js";
12
15
  import { CloudError } from "./api.js";
16
+ import { keepFiles } from "./files.js";
13
17
  import { randomKey, syncFolder } from "./sync.js";
14
18
  const JOB = /Next: job_status \{job_id: "([^"]+)"\}/;
19
+ // job_status's answer while the job still runs (tools/jobs.ts): the same job is read again.
20
+ const STILL_RUNNING = /\nNext: job_status again, or job_cancel\./;
21
+ /** How long a call waits for a phone that is still starting (queued for a free one, or being prepared). */
22
+ const STARTING_WAIT_MS = { cli: 5 * 60_000, mcp: 30_000 };
15
23
  /** The phone cloud mode uses, or why there is none. */
16
24
  export async function cloudPhone() {
17
25
  const state = await cloudState();
@@ -48,8 +56,20 @@ async function projectRoot(path) {
48
56
  }
49
57
  return { folder, inside: relative(folder, realpathSync(local)).split(sep).join("/") };
50
58
  }
59
+ /** Waits for a phone that is still starting, up to waitMs: its state as last read (ready, or not yet). */
60
+ async function whenReady(api, session, waitMs) {
61
+ const deadline = Date.now() + waitMs;
62
+ for (;;) {
63
+ const phone = (await api.json("GET", `/api/v1/simulator/sessions/${session}`)).session;
64
+ if (phone.state !== "queued" && phone.state !== "provisioning")
65
+ return phone;
66
+ if (Date.now() >= deadline)
67
+ return phone;
68
+ await new Promise((r) => setTimeout(r, 2_000));
69
+ }
70
+ }
51
71
  /** Runs one tool on the phone and answers as the local tool would. */
52
- export async function forward(name, args, context) {
72
+ export async function forward(name, args, context, waited = false) {
53
73
  const { api, session } = await cloudPhone();
54
74
  let sent = args;
55
75
  let note = "";
@@ -70,16 +90,33 @@ export async function forward(name, args, context) {
70
90
  sent = { ...args, app: `${synced.root}/${basename(app)}` };
71
91
  }
72
92
  let result = await call(api, session, name, sent);
73
- // The command line waits for a long build, as it does on this Mac: job_status until the job ends.
74
- for (let rounds = 0; context.waitForJobs && rounds < 60; rounds++) {
75
- const job = JOB.exec(result.text)?.[1];
76
- if (!job)
77
- break;
93
+ // The command line waits for a long build, as it does on this Mac: job_status until the job ends. A job named
94
+ // in an error (another build of the project running) is not this call's.
95
+ const job = context.waitForJobs && !result.isError ? JOB.exec(result.text)?.[1] : undefined;
96
+ for (let rounds = 0; job && rounds < 120; rounds++) {
78
97
  result = await call(api, session, "job_status", { job_id: job });
98
+ if (result.isError || !STILL_RUNNING.test(result.text))
99
+ break;
100
+ }
101
+ // A file ui_look saved (the screenshot, the whole tree) is saved here too, as the tool on this machine does.
102
+ if (name === "ui_look" && (args.screenshot_file || args.full) && !result.isError) {
103
+ const lines = [result.text];
104
+ await mkdir(stateDir(), { recursive: true });
105
+ await keepFiles(lines, stateDir());
106
+ result = { ...result, text: lines[0] };
79
107
  }
80
108
  return note ? { ...result, text: `${note}${result.text}` } : result;
81
109
  }
82
110
  catch (error) {
111
+ // A phone still starting (waiting for a free one, or being prepared) is waited for, then the call goes again.
112
+ if (error instanceof CloudError && error.code === "session_not_ready" && error.details.starting === true) {
113
+ const phone = waited ? undefined : await whenReady(api, session, context.waitForJobs ? STARTING_WAIT_MS.cli : STARTING_WAIT_MS.mcp).catch(() => undefined);
114
+ if (phone?.state === "ready")
115
+ return forward(name, args, context, true);
116
+ if (phone && !["queued", "provisioning"].includes(phone.state))
117
+ return ended(session);
118
+ return { text: `The cloud phone ${session} is still starting${phone?.message ? `: ${phone.message}` : "."}\nNext: ${name} again in a moment (nothing ran); cloud status shows its place.`, isError: true };
119
+ }
83
120
  if (error instanceof CloudError && (error.code === "session_not_found" || error.code === "session_not_ready" || error.code === "session_expired"))
84
121
  return ended(session);
85
122
  if (error instanceof CloudError)
@@ -87,6 +124,19 @@ export async function forward(name, args, context) {
87
124
  throw error;
88
125
  }
89
126
  }
127
+ /**
128
+ * NoMac's stop notice, in the phone's last 15 minutes before a limit or the account's credit's end, as cloud mode can
129
+ * act on it: it keeps no saved states, so a new phone after this one is the way on (its build stays in the workspace).
130
+ */
131
+ export function stopNotice(answer) {
132
+ const notice = answer.stop_notice;
133
+ if (!notice?.message)
134
+ return "";
135
+ if (notice.reason === "credit")
136
+ return `\n${notice.message}`;
137
+ const when = (notice.minutes_left ?? 0) <= 1 ? "within a minute" : `in about ${notice.minutes_left} minutes`;
138
+ return `\nThe cloud phone stops ${when}, at its ${notice.reason === "spend_cap" ? "spend cap" : "time limit"}: cloud extend keeps it running an hour longer. Once it stops, its apps and data go with it; the project's build stays, so the next cloud start builds incrementally.`;
139
+ }
90
140
  async function call(api, session, name, args) {
91
141
  let answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/tools/${name}`, args, `cli-${name}-${randomKey()}`);
92
142
  while (answer.state === "running" && typeof answer.id === "string") {
@@ -102,7 +152,7 @@ async function call(api, session, name, args) {
102
152
  const picture = (result.content ?? []).find((c) => c.type === "image" && c.data);
103
153
  const notices = answer.notices?.includes("phone_restarted") ? "\nThe phone restarted; its apps and data are kept." : "";
104
154
  return {
105
- text: `${text}${notices}`,
155
+ text: `${text}${notices}${stopNotice(answer)}`,
106
156
  ...(picture ? { image: { data: Buffer.from(picture.data, "base64"), mimeType: picture.mimeType === "image/png" ? "image/png" : "image/jpeg" } } : {}),
107
157
  ...(result.isError ? { isError: true } : {}),
108
158
  };