simframe 0.7.2 → 0.8.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
@@ -37,6 +37,7 @@ Same four-tab navigation flow, on a real production app:
37
37
  | | Before | With simframe |
38
38
  | --- | --- | --- |
39
39
  | Look at the screen | ~130–400 ms, blocking | **~20 ms**, already captured |
40
+
40
41
  | "Did anything change?" | a full image | **~2 ms**, text only |
41
42
  | Finding a control | read tree (~570 ms) + reason | **~1 ms** from memory |
42
43
  | A 4-step flow, verified | 4+ model round trips | **1 call**, 3.6 s |
@@ -44,6 +45,13 @@ Same four-tab navigation flow, on a real production app:
44
45
  | A 10-step flow | 10 turns, 10 images (~16,000 tokens at best) | **1 turn, 0 images, ~1,650 characters** |
45
46
  | Reading a screen | an image: ~1,600 tokens, no tap points | **~330 tokens** of text, with tap points |
46
47
 
48
+ Every figure above is the cost inside a live process — the MCP server, or the
49
+ daemon answering a socket — which is how an agent actually uses simframe. A
50
+ one-shot `simframe` command from a shell pays about 200 ms of Node startup on
51
+ top, and a frame sitting on an idle screen can be older than 20 ms because the
52
+ capture loop throttles when nothing moves. `~20 ms` is the read, not the
53
+ process.
54
+
47
55
  The four-tab tour, three times back to back from a cleared memory:
48
56
 
49
57
  | Pass | Wall clock | Steps verified | Controls from memory |
@@ -67,10 +75,16 @@ npm install -g simframe
67
75
  simframe doctor
68
76
  ```
69
77
 
70
- The first `simframe start` builds a small Swift daemon from source — a few
71
- seconds, once. It needs the Xcode command line tools, which you already have if
72
- you have a simulator. Without them simframe falls back to the original
73
- `simctl` loop and says so.
78
+ **Whichever of those two commands you run first** builds a small Swift daemon
79
+ from source — including `doctor`, which is why a cold `doctor` takes around 15
80
+ seconds and every later one takes two. It needs the Xcode command line tools,
81
+ which you already have if you have a simulator. Without them simframe falls
82
+ back to the original `simctl` loop and says so.
83
+
84
+ If more than one simulator is booted, name the one you mean — `--device=<udid>`,
85
+ or `export SIMFRAME_DEVICE=<udid>` once per shell. simframe refuses to choose
86
+ for you, because the first booted device is nobody's idea of "yours" and the
87
+ command that would act on it is a tap.
74
88
 
75
89
  `doctor` checks each capability separately and tells you what you have:
76
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
5
  "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
@@ -257,7 +257,14 @@ if (first) {
257
257
  // to defend — and it is how this check has failed twice.
258
258
  if (moved) {
259
259
  const stale = await cli(['find', `#${first.ref}`], { expectFail: true });
260
- check(/different screen|read the screen/i.test(stale),
260
+ // Matched on prose, which is this check's weakness: simframe refused
261
+ // correctly with "#1 cannot be trusted here — simframe does not recognise
262
+ // this screen. Read it again", and the check failed because that wording was
263
+ // not one of the two it knew. The refusal is what matters, so the
264
+ // alternation covers how a refusal is actually phrased; the durable fix is a
265
+ // machine-readable reason on the failure, which `find --json` does not yet
266
+ // carry.
267
+ check(/different screen|read the screen|read it again|does not recognise this screen|cannot be trusted/i.test(stale),
261
268
  'a ref numbered on another screen refuses instead of tapping those coordinates',
262
269
  stale.trim().split('\n')[0]?.slice(0, 90));
263
270
  }
@@ -398,7 +405,13 @@ check(Array.isArray(nonsense.known) && nonsense.known.length > 0,
398
405
 
399
406
  const target = screens[0];
400
407
  const walked = await jsonRetry(['goto', target.hash], { allowFail: true });
401
- const outcomes = ['no-route', 'unreplayable-edge', 'ambiguous', 'unknown-screen'];
408
+ // `no-identity` is new in 0.7.2 and belongs here: `goto` used to *throw*
409
+ // `Cannot read properties of null (reading 'slice')` on a screen whose token set
410
+ // was empty, because `hashTokens` returns null for one on purpose. A crash is
411
+ // neither walking there nor naming why it cannot, so this check failed with an
412
+ // empty detail — the reason was `undefined` — and the check was right to fail.
413
+ // Now there is a name for it, and the list has to know the name.
414
+ const outcomes = ['no-route', 'unreplayable-edge', 'ambiguous', 'unknown-screen', 'no-identity'];
402
415
  check(walked.ok === true || outcomes.includes(walked.reason),
403
416
  'and asked for a screen it knows, it either walks there or names why it cannot',
404
417
  walked.ok ? (walked.already ? 'already there' : `walked ${walked.ranSteps} step(s)`) : walked.reason);
package/src/cli.js CHANGED
@@ -243,6 +243,11 @@ async function main() {
243
243
  `stopped ${stopped} daemon${stopped === 1 ? '' : 's'}` +
244
244
  (inUse ? `; left ${inUse} in use by another client (pass --force to stop anyway)` : ''),
245
245
  );
246
+ // Asked to stop one device, refused, and exited 0 — which is a success
247
+ // code for work not done, and a script checking `$?` could not tell the
248
+ // difference. `--all` is informational by nature, so it keeps exiting 0
249
+ // when it skips a device somebody else holds.
250
+ if (!flags.all && inUse && !stopped) process.exitCode = 1;
246
251
  return;
247
252
  }
248
253
 
@@ -176,6 +176,18 @@ async function resolveDevice(query, opts) {
176
176
  : 'no booted emulator (start one with `emulator -avd <name>`)',
177
177
  );
178
178
  }
179
+ // See the same guard in ios.js: an arbitrary pick is a tap on the wrong
180
+ // device, and this backend can act too.
181
+ if (booted.length > 1) {
182
+ throw Object.assign(
183
+ new Error(
184
+ `${booted.length} emulators are running and none was named: ` +
185
+ `${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
186
+ 'or set SIMFRAME_DEVICE to pick a default for this shell',
187
+ ),
188
+ { ambiguous: true },
189
+ );
190
+ }
179
191
  return booted[0];
180
192
  }
181
193
  const q = loose(query);
@@ -66,6 +66,29 @@ async function resolveDevice(query, opts) {
66
66
  const booted = all.filter((d) => d.state === 'Booted');
67
67
  if (!query) {
68
68
  if (booted.length === 0) throw new Error('no booted simulator (open Simulator.app or run `xcrun simctl boot <udid>`)');
69
+ // `booted[0]` was the wrong-device bug, and it was worse than it looked.
70
+ // simctl's order is not "yours" by any definition, so on a machine with
71
+ // more than one booted simulator a bare `simframe ui` read whichever came
72
+ // first — and a bare `simframe tap` would have *injected input* into it. A
73
+ // reviewer reproduced it deterministically against a colleague's simulator.
74
+ //
75
+ // `doctor` got a guard for its own fan-out and this default did not, which
76
+ // is how the same command set could pick two different devices in one
77
+ // moment. Refusing is the only safe answer here: the seam cannot see which
78
+ // device simframe is already driving (that is store state, above the
79
+ // boundary), and a backend must never guess when the cost of guessing wrong
80
+ // is a tap on somebody else's screen. `ambiguous` so that a second platform
81
+ // matching cleanly cannot override this — see resolveAcross.
82
+ if (booted.length > 1) {
83
+ throw Object.assign(
84
+ new Error(
85
+ `${booted.length} simulators are booted and none was named: ` +
86
+ `${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
87
+ 'or set SIMFRAME_DEVICE to pick a default for this shell',
88
+ ),
89
+ { ambiguous: true },
90
+ );
91
+ }
69
92
  return booted[0];
70
93
  }
71
94
  const q = query.toLowerCase();