simframe 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sadjad Asadi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # simframe
2
+
3
+ [![ci](https://github.com/lvlrSajjad/simframe/actions/workflows/ci.yml/badge.svg)](https://github.com/lvlrSajjad/simframe/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/simframe.svg)](https://www.npmjs.com/package/simframe)
5
+ [![license](https://img.shields.io/npm/l/simframe.svg)](./LICENSE)
6
+
7
+ **Always-warm iOS Simulator frames for coding agents.**
8
+
9
+ [Website](https://lvlrsajjad.github.io/simframe/) · [npm](https://www.npmjs.com/package/simframe)
10
+
11
+ An agent that drives the iOS Simulator spends most of its time waiting on
12
+ screenshots. Every "let me check the screen" is a fresh `simctl io screenshot`:
13
+ a process spawn, a framebuffer grab, a file write, an image encode. On this
14
+ machine that is ~130 ms of pure blocking latency, paid again on every look — and
15
+ the agent pays it *twice* whenever it screenshots too early, sees a
16
+ mid-animation frame, and has to look again.
17
+
18
+ simframe removes the wait from the request path. A tiny background loop keeps
19
+ the newest frame of your simulator permanently warm on disk, so when the agent
20
+ asks what's on screen it gets an answer in **~20 ms** instead of ~130 ms — and
21
+ can ask "did anything change?" for **~2 ms and no image at all**.
22
+
23
+ ```
24
+ without simframe with simframe
25
+ agent asks ──► spawn simctl ──► grab ──► encode ──► image ~130-400 ms
26
+ agent asks ──► read the frame that is already there ──► image ~20 ms
27
+ agent polls ──► read 300 bytes of JSON ──► text ~2 ms
28
+ ```
29
+
30
+ ## Why this makes an agent faster
31
+
32
+ Speed is not only latency. It is also *how many* round trips a question takes
33
+ and how many tokens each one costs.
34
+
35
+ | Question the agent has | Before | With simframe |
36
+ | --- | --- | --- |
37
+ | "What's on screen?" | screenshot, ~130-400 ms, full image every time | `sim_look`, ~20 ms, warm frame |
38
+ | "Has it finished loading yet?" | screenshot in a loop, an image per attempt | `sim_state`, ~2 ms, **text only** |
39
+ | "Did my tap do anything?" | screenshot, compare by eye | `sim_state` — a stable screen hash plus an ASCII map of which regions moved |
40
+ | "Wait for the animation to end" | sleep, screenshot, hope, repeat | `sim_wait` — blocks until the screen actually settles, then returns the frame |
41
+ | "What did that transition look like?" | 5 screenshots, 5 round trips, 5 images | `sim_strip` — the last N buffered frames tiled into **one** image, looking backwards in time |
42
+
43
+ The change map is the part that pays for itself. A screen hash and a
44
+ 4×8 movement grid cost a couple of hundred bytes, so an agent can poll freely
45
+ and only spend image tokens when there is genuinely something new to look at:
46
+
47
+ ```
48
+ $ simframe state
49
+ iPhone 17 Pro frame #3 age 124ms 322x700
50
+ hash 007cfefefefefefefefefefefefefe00 diff 0.15453 stable 0ms
51
+ @@@@
52
+ ###*
53
+ +*#*
54
+ :.#*
55
+ ..#*
56
+ ..#*
57
+ ..#*
58
+ @@@@ ← a screen sliding in from the right, mid-transition
59
+ ```
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install -g simframe
65
+ simframe doctor
66
+ ```
67
+
68
+ `doctor` verifies Xcode's command line tools, `sips`, a booted simulator, and
69
+ an actual round-trip capture.
70
+
71
+ ### Claude Code
72
+
73
+ ```bash
74
+ claude mcp add simframe -- npx -y simframe mcp
75
+ ```
76
+
77
+ ### Any other MCP client
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "simframe": {
83
+ "command": "npx",
84
+ "args": ["-y", "simframe", "mcp"]
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Nothing else to set up. Capture starts on the first tool call, targets the
91
+ booted simulator, and stops itself 15 minutes after the last request.
92
+
93
+ ## MCP tools
94
+
95
+ | Tool | What it does |
96
+ | --- | --- |
97
+ | `sim_look` | The newest buffered frame as an image, no capture wait. `detail`: `low` (~420 px) / `normal` (~700 px, default) / `high` (~1100 px) / `full` (native). |
98
+ | `sim_state` | Text only: screen hash, how long the screen has been still, change since the previous frame, and the region movement map. |
99
+ | `sim_wait` | Blocks until the screen settles (`mode: "stable"`) or moves away from what it shows now (`mode: "change"`), then returns the frame. |
100
+ | `sim_strip` | The last N buffered frames tiled into one image, oldest first, with millisecond offsets. |
101
+ | `sim_capture` | `status` / `start` / `stop` for the background loops. Rarely needed. |
102
+ | `sim_devices` | Booted simulators simframe can capture. |
103
+
104
+ Every tool takes an optional `device` (UDID or a substring of the name) and
105
+ defaults to the booted simulator.
106
+
107
+ ## CLI
108
+
109
+ The same capabilities without an agent, useful for debugging and scripts:
110
+
111
+ ```bash
112
+ simframe start # start the capture loop
113
+ simframe state # metadata + change map
114
+ simframe frame --out=now.png # newest frame, --detail=low|normal|high|full
115
+ simframe wait --stable-ms=700 # block until the screen settles
116
+ simframe wait --change # block until the screen changes
117
+ simframe strip --count=6 # contact sheet of recent frames
118
+ simframe status # what is running, and how fresh
119
+ simframe stop --all
120
+ simframe devices --all
121
+ simframe doctor
122
+ ```
123
+
124
+ ## How it works
125
+
126
+ ```
127
+ ┌──────────────────────────── background, one per simulator ───┐
128
+ │ xcrun simctl io screenshot ──► sips -Z ──► decode PNG │
129
+ │ ~130 ms ~30 ms ~6 ms │
130
+ │ │ │
131
+ │ rename into ~/.simframe/<udid>/ │
132
+ │ latest.png · ring/<seq>.png · state.json │
133
+ └───────────────────────────────────────────────────────────────┘
134
+ │ a rename is atomic
135
+ ┌──────────────────────────▼────────────────────────────────────┐
136
+ │ MCP server / CLI: stat + read. No simctl in the request path.│
137
+ └───────────────────────────────────────────────────────────────┘
138
+ ```
139
+
140
+ A few decisions worth knowing about:
141
+
142
+ - **Files are the IPC.** The loop renames completed frames into place and
143
+ readers just read them. A rename is atomic, so a reader can never see a
144
+ half-written frame, and there is no socket, port or protocol to get wrong.
145
+ - **Zero image dependencies.** Resizing uses `sips`, which ships with macOS.
146
+ PNG encode/decode and all frame comparison are a few hundred lines of plain
147
+ JavaScript over `node:zlib`. The only runtime dependency is the MCP SDK.
148
+ - **It backs off when nothing is happening.** 4 fps while the screen is moving,
149
+ 1.5 fps once it has been still for 2.5 s, snapping back instantly on change.
150
+ Measured on an M-series Mac: **1.1 % CPU idle, 3.1 % active.**
151
+ - **Comparison is done on a small grayscale grid,** which is why "did anything
152
+ change?" costs microseconds. The screen hash is a 128-bit mean-threshold
153
+ hash: the same screen always produces the same hash, even though the JPEG and
154
+ PNG bytes coming out of `simctl` are not stable frame to frame.
155
+ - **One writer per device.** Ownership is recorded in `meta.json`; a second loop
156
+ refuses to start, and a loop that has been superseded retires itself. Two
157
+ loops would otherwise overwrite and prune each other's frames.
158
+ - **Capture is independent of the Simulator window.** `simctl` reads the
159
+ framebuffer, so frames keep flowing while the window is hidden, behind other
160
+ windows, or on another Space.
161
+
162
+ ## Measured
163
+
164
+ iPhone 17 Pro, iOS 26.5, Apple Silicon, default settings:
165
+
166
+ | | |
167
+ | --- | --- |
168
+ | Warm frame read (`sim_look`) | ~20 ms |
169
+ | State check (`sim_state`) | ~2 ms |
170
+ | Contact sheet (`sim_strip`, 5 frames) | ~30 ms |
171
+ | Cold start (first frame after boot) | ~400 ms, once |
172
+ | Frame age when read | ≤ ~250 ms active, ≤ ~670 ms idle |
173
+ | Raw `simctl io screenshot` for comparison | ~130 ms, on every single look |
174
+ | CPU | 1.1 % idle, 3.1 % active |
175
+ | Disk | ~2 MB per device (24-frame ring) |
176
+
177
+ Image sizes are chosen for token cost as much as legibility: at `detail: normal`
178
+ a frame is ~322×700, roughly a third of the pixels — and so roughly a third of
179
+ the image tokens — of a native-resolution screenshot, while the status bar stays
180
+ readable. `sim_state` sends no image at all.
181
+
182
+ ## Requirements
183
+
184
+ - macOS with Xcode command line tools (`xcrun simctl`)
185
+ - Node.js ≥ 18.17
186
+ - A booted iOS Simulator
187
+
188
+ ## Limitations
189
+
190
+ - Simulators only. `simctl` cannot capture a physical device.
191
+ - simframe **reads** the screen; it does not tap, swipe or type. It is meant to
192
+ sit alongside whatever already drives input, replacing only the screenshot.
193
+ - Capture tops out near 6 fps, because `simctl io screenshot` costs ~130 ms.
194
+ Fast animations are sampled, not recorded.
195
+
196
+ ## Roadmap
197
+
198
+ - A higher-frame-rate backend via `simctl io recordVideo` piped through ffmpeg,
199
+ used automatically when ffmpeg is present.
200
+ - Optional accessibility-tree text alongside the frame, so an agent can read
201
+ labels without spending image tokens.
202
+ - Fusing input with settle-and-look, so tap → wait → see is one round trip
203
+ rather than three.
204
+
205
+ ## Releasing
206
+
207
+ `npm version <patch|minor|major>` does not update `server.json`, so bump both,
208
+ then push the tag:
209
+
210
+ ```bash
211
+ npm version minor --no-git-tag-version # bumps package.json
212
+ $EDITOR server.json # match "version" and packages[0].version
213
+ git commit -am "Release v0.2.0" && git tag v0.2.0
214
+ git push && git push --tags
215
+ ```
216
+
217
+ The `release` workflow then verifies that the tag, `package.json` and
218
+ `server.json` all agree, validates `server.json` against the live registry, and
219
+ publishes to npm and to the MCP Registry. It needs an npm automation token in
220
+ the `NPM_TOKEN` repository secret; the MCP Registry needs no secret, because it
221
+ trusts the workflow's GitHub OIDC identity.
222
+
223
+ To publish by hand instead:
224
+
225
+ ```bash
226
+ npm publish --access public
227
+
228
+ curl -fsSL https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_darwin_arm64.tar.gz | tar -xz mcp-publisher
229
+ ./mcp-publisher validate
230
+ ./mcp-publisher login github
231
+ ./mcp-publisher publish
232
+ ```
233
+
234
+ ## License
235
+
236
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "simframe",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.lvlrSajjad/simframe",
5
+ "description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
+ "keywords": [
7
+ "ios",
8
+ "simulator",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "claude",
12
+ "claude-code",
13
+ "screenshot",
14
+ "xcode",
15
+ "simctl",
16
+ "agent"
17
+ ],
18
+ "homepage": "https://github.com/lvlrSajjad/simframe#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/lvlrSajjad/simframe/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/lvlrSajjad/simframe.git"
25
+ },
26
+ "license": "MIT",
27
+ "author": "Sadjad Asadi (https://github.com/lvlrSajjad)",
28
+ "type": "module",
29
+ "os": [
30
+ "darwin"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18.17"
34
+ },
35
+ "bin": {
36
+ "simframe": "src/cli.js"
37
+ },
38
+ "exports": {
39
+ ".": "./src/index.js"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --test test/unit.test.mjs",
48
+ "mcp": "node src/cli.js mcp",
49
+ "smoke": "node scripts/smoke.mjs"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.0.0"
53
+ }
54
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,65 @@
1
+ // Frame comparison. Everything here works on a small grayscale grid, so a
2
+ // "did anything change?" question costs microseconds and no image tokens.
3
+ import { grayGrid } from './png.js';
4
+
5
+ export const HASH_COLS = 8;
6
+ export const HASH_ROWS = 16;
7
+ export const REGION_COLS = 4;
8
+ export const REGION_ROWS = 8;
9
+
10
+ /** A 128-bit mean-threshold hash, rendered as hex. Same screen => same hash. */
11
+ export function frameHash(bmp) {
12
+ const { gray } = grayGrid(bmp, HASH_COLS, HASH_ROWS);
13
+ let mean = 0;
14
+ for (const v of gray) mean += v;
15
+ mean /= gray.length;
16
+ let hex = '';
17
+ for (let i = 0; i < gray.length; i += 4) {
18
+ let nibble = 0;
19
+ for (let b = 0; b < 4; b++) if (gray[i + b] > mean) nibble |= 1 << b;
20
+ hex += nibble.toString(16);
21
+ }
22
+ return hex;
23
+ }
24
+
25
+ /** Coarse per-region change, 0-100, laid out row-major over REGION_COLS x REGION_ROWS. */
26
+ export function regionSignature(bmp) {
27
+ return Array.from(grayGrid(bmp, REGION_COLS, REGION_ROWS).gray);
28
+ }
29
+
30
+ /** Mean absolute difference of two region signatures, as a 0-1 fraction. */
31
+ export function signatureDiff(a, b) {
32
+ if (!a || !b || a.length !== b.length) return 1;
33
+ let sum = 0;
34
+ for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
35
+ return sum / a.length / 255;
36
+ }
37
+
38
+ /** Per-region change fractions, so callers can tell a toast from a screen push. */
39
+ export function regionDeltas(a, b) {
40
+ if (!a || !b || a.length !== b.length) return a ? a.map(() => 1) : [];
41
+ return a.map((v, i) => Math.abs(v - b[i]) / 255);
42
+ }
43
+
44
+ const RAMP = ['.', ':', '+', '*', '#', '@'];
45
+ // Screen changes span orders of magnitude — a moving caret is ~0.3%, a screen
46
+ // push is ~30% — so the ramp is bucketed logarithmically rather than linearly.
47
+ const RAMP_STOPS = [0.002, 0.01, 0.03, 0.08, 0.2];
48
+
49
+ export function rampLevel(delta) {
50
+ let level = 0;
51
+ for (const stop of RAMP_STOPS) if (delta >= stop) level++;
52
+ return level;
53
+ }
54
+
55
+ /** Render region deltas as a tiny ASCII map: readable in text, ~40 tokens. */
56
+ export function regionMap(deltas, cols = REGION_COLS) {
57
+ if (!deltas.length) return '';
58
+ const lines = [];
59
+ for (let r = 0; r < deltas.length / cols; r++) {
60
+ let line = '';
61
+ for (let c = 0; c < cols; c++) line += RAMP[rampLevel(deltas[r * cols + c] ?? 0)];
62
+ lines.push(line);
63
+ }
64
+ return lines.join('\n');
65
+ }
package/src/cli.js ADDED
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runDaemon, DEFAULTS } from './daemon.js';
5
+ import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
6
+ import * as api from './index.js';
7
+ import * as store from './store.js';
8
+
9
+ const USAGE = `simframe — always-warm iOS Simulator frames
10
+
11
+ simframe mcp run the MCP server on stdio (for agents)
12
+ simframe start [device] start the capture loop in the background
13
+ simframe stop [device|--all] stop the capture loop
14
+ simframe status [device] show daemon and newest-frame status
15
+ simframe frame [device] write the newest frame to a file
16
+ simframe state [device] print frame metadata and the change map
17
+ simframe wait [device] wait for the screen to settle
18
+ simframe strip [device] write a contact sheet of recent frames
19
+ simframe devices list simulators
20
+ simframe doctor check that this machine can capture
21
+
22
+ Options
23
+ --device=<udid|name> simulator to target (default: the booted one)
24
+ --out=<file> output path for frame/strip
25
+ --detail=low|normal|high|full or --detail=<max pixels>
26
+ --fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
27
+ --count=<n> frames in a strip (default 5)
28
+ --stable-ms=<n> settle window for wait (default 600)
29
+ --timeout-ms=<n> give up after this long (default 8000)
30
+ --json machine-readable output
31
+ `;
32
+
33
+ function parseArgs(argv) {
34
+ const flags = {};
35
+ const positional = [];
36
+ for (const arg of argv) {
37
+ if (arg.startsWith('--')) {
38
+ const [key, value] = arg.slice(2).split('=');
39
+ const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
40
+ flags[camel] = value === undefined ? true : value;
41
+ } else {
42
+ positional.push(arg);
43
+ }
44
+ }
45
+ return { flags, positional };
46
+ }
47
+
48
+ const num = (v, fallback) => (v == null ? fallback : Number(v));
49
+
50
+ async function main() {
51
+ const [command, ...rest] = process.argv.slice(2);
52
+ const { flags, positional } = parseArgs(rest);
53
+ const device = flags.device || positional[0];
54
+ const options = {};
55
+ if (flags.fps) options.fps = num(flags.fps);
56
+ if (flags.maxDim) options.maxDim = num(flags.maxDim);
57
+ if (flags.ringSize) options.ringSize = num(flags.ringSize);
58
+
59
+ switch (command) {
60
+ case undefined:
61
+ case '-h':
62
+ case '--help':
63
+ case 'help':
64
+ process.stdout.write(USAGE);
65
+ return;
66
+
67
+ case '--version':
68
+ case 'version': {
69
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
70
+ console.log(pkg.version);
71
+ return;
72
+ }
73
+
74
+ case 'mcp': {
75
+ const { serve } = await import('./mcp.js');
76
+ await serve({ device, options });
77
+ return;
78
+ }
79
+
80
+ // Internal: the detached capture process.
81
+ case 'daemon': {
82
+ const resolved = await resolveDevice(device);
83
+ await runDaemon(resolved, options);
84
+ return;
85
+ }
86
+
87
+ case 'start': {
88
+ const { device: dev, state, started } = await api.ensureDaemon(device, options);
89
+ console.log(
90
+ `${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) frame #${state.seq} ${state.width}x${state.height}`,
91
+ );
92
+ return;
93
+ }
94
+
95
+ case 'stop': {
96
+ const targets = flags.all
97
+ ? fs.existsSync(store.ROOT)
98
+ ? fs.readdirSync(store.ROOT)
99
+ : []
100
+ : [(await resolveDevice(device)).udid];
101
+ let stopped = 0;
102
+ for (const udid of targets) if (api.stopDaemon(udid)) stopped++;
103
+ console.log(`stopped ${stopped} daemon${stopped === 1 ? '' : 's'}`);
104
+ return;
105
+ }
106
+
107
+ case 'status': {
108
+ const udids = device
109
+ ? [(await resolveDevice(device)).udid]
110
+ : fs.existsSync(store.ROOT)
111
+ ? fs.readdirSync(store.ROOT)
112
+ : [];
113
+ const rows = udids.map((udid) => {
114
+ const { meta, pid, alive, stale } = api.daemonStatus(udid);
115
+ const state = store.readJson(store.paths(udid).state);
116
+ return {
117
+ udid,
118
+ device: meta?.device?.name ?? '?',
119
+ pid,
120
+ running: alive || stale,
121
+ stale,
122
+ seq: state?.seq ?? null,
123
+ ageMs: state ? Date.now() - state.capturedAt : null,
124
+ size: state ? `${state.width}x${state.height}` : null,
125
+ stableForMs: state?.stableForMs ?? null,
126
+ };
127
+ });
128
+ if (flags.json) {
129
+ console.log(JSON.stringify(rows, null, 2));
130
+ } else if (!rows.length) {
131
+ console.log('no simframe daemons have run yet');
132
+ } else {
133
+ for (const r of rows) {
134
+ console.log(
135
+ `${r.running ? '●' : '○'} ${r.device.padEnd(18)} pid=${r.pid ?? '-'} frame=#${r.seq ?? '-'} age=${r.ageMs ?? '-'}ms ${r.size ?? ''} stable=${r.stableForMs ?? '-'}ms`,
136
+ );
137
+ }
138
+ }
139
+ return;
140
+ }
141
+
142
+ case 'frame': {
143
+ const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
144
+ const out = flags.out || path.join(process.cwd(), 'simframe.png');
145
+ fs.writeFileSync(out, res.png);
146
+ console.log(`${out} — ${res.width}x${res.height}, ${res.ageMs}ms old, frame #${res.state.seq}`);
147
+ return;
148
+ }
149
+
150
+ case 'state': {
151
+ const res = await api.getState(device, { options });
152
+ if (flags.json) {
153
+ console.log(JSON.stringify({ ...res.state, ageMs: res.ageMs }, null, 2));
154
+ } else {
155
+ const s = res.state;
156
+ console.log(
157
+ `${res.device.name} frame #${s.seq} age ${res.ageMs}ms ${s.width}x${s.height}\n` +
158
+ `hash ${s.hash} diff ${s.diff} stable ${s.stableForMs}ms\n${res.map}`,
159
+ );
160
+ }
161
+ return;
162
+ }
163
+
164
+ case 'wait': {
165
+ const res = await api.waitFor(device, {
166
+ mode: flags.mode || (flags.change ? 'change' : 'stable'),
167
+ stableMs: num(flags.stableMs, 600),
168
+ timeoutMs: num(flags.timeoutMs, 8000),
169
+ options,
170
+ });
171
+ console.log(
172
+ `${res.satisfied ? 'settled' : 'timed out'} after ${res.waitedMs}ms — frame #${res.state.seq}, stable ${res.state.stableForMs}ms`,
173
+ );
174
+ process.exitCode = res.satisfied ? 0 : 1;
175
+ return;
176
+ }
177
+
178
+ case 'strip': {
179
+ const res = await api.getStrip(device, {
180
+ count: num(flags.count, 5),
181
+ spanMs: flags.spanMs ? num(flags.spanMs) : undefined,
182
+ options,
183
+ });
184
+ const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
185
+ fs.writeFileSync(out, res.png);
186
+ console.log(
187
+ `${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
188
+ );
189
+ return;
190
+ }
191
+
192
+ case 'devices': {
193
+ const all = await listDevices();
194
+ const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
195
+ if (flags.json) {
196
+ console.log(JSON.stringify(shown, null, 2));
197
+ } else if (!shown.length) {
198
+ console.log('no booted simulators (pass --all to list every device)');
199
+ } else {
200
+ for (const d of shown) console.log(`${d.state === 'Booted' ? '●' : '○'} ${d.name} ${d.runtime} ${d.udid}`);
201
+ }
202
+ return;
203
+ }
204
+
205
+ case 'doctor': {
206
+ await doctor();
207
+ return;
208
+ }
209
+
210
+ default:
211
+ process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
212
+ process.exitCode = 2;
213
+ }
214
+ }
215
+
216
+ async function doctor() {
217
+ const checks = [];
218
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
219
+
220
+ add('node', true, process.version);
221
+ try {
222
+ const { execFileSync } = await import('node:child_process');
223
+ add('xcrun', true, execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
224
+ } catch (err) {
225
+ add('xcrun', false, err.message);
226
+ }
227
+ try {
228
+ const { execFileSync } = await import('node:child_process');
229
+ execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
230
+ add('sips', true, 'available');
231
+ } catch (err) {
232
+ add('sips', false, err.message);
233
+ }
234
+ try {
235
+ const booted = await bootedDevices();
236
+ add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
237
+ if (booted.length) {
238
+ const t0 = Date.now();
239
+ const res = await api.getFrame(booted[0].udid);
240
+ add('capture', true, `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`);
241
+ }
242
+ } catch (err) {
243
+ add('capture', false, err.message);
244
+ }
245
+
246
+ for (const c of checks) console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.name.padEnd(18)} ${c.detail}`);
247
+ process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
248
+ }
249
+
250
+ main().catch((err) => {
251
+ process.stderr.write(`simframe: ${err.message}\n`);
252
+ process.exitCode = 1;
253
+ });