simframe 0.10.0 → 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.
Files changed (47) hide show
  1. package/README.md +181 -2
  2. package/data/vocabulary/en.json +148 -0
  3. package/native/ocr.swift +13 -1
  4. package/native/rank.swift +87 -0
  5. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +72 -4
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
  8. package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +19 -0
  9. package/native/simframed/Sources/simframed/main.swift +33 -3
  10. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +41 -0
  11. package/native/supervise.swift +216 -0
  12. package/package.json +4 -1
  13. package/scripts/check-package.mjs +22 -2
  14. package/scripts/check-private.mjs +9 -0
  15. package/scripts/ci-integration-local.sh +79 -0
  16. package/scripts/ci-memory.mjs +104 -20
  17. package/scripts/collect-rulings.mjs +312 -0
  18. package/scripts/eval-fingerprint.mjs +100 -23
  19. package/scripts/eval-perception.mjs +33 -0
  20. package/scripts/phase17-corpus.mjs +176 -0
  21. package/scripts/probe-network.mjs +118 -0
  22. package/scripts/soak-capture.mjs +72 -0
  23. package/skills/simframe/SKILL.md +257 -7
  24. package/src/actions.js +1791 -44
  25. package/src/cli.js +216 -14
  26. package/src/control.js +1 -0
  27. package/src/fingerprint.js +43 -1
  28. package/src/graph.js +136 -7
  29. package/src/index.js +252 -14
  30. package/src/input.js +66 -3
  31. package/src/localhelper.js +161 -0
  32. package/src/matching.js +64 -1
  33. package/src/mcp.js +333 -32
  34. package/src/metrics.js +148 -3
  35. package/src/ocr.js +18 -1
  36. package/src/planner.js +195 -0
  37. package/src/platform/android.js +25 -1
  38. package/src/platform/index.js +11 -1
  39. package/src/platform/ios.js +25 -1
  40. package/src/png.js +26 -0
  41. package/src/refs.js +51 -8
  42. package/src/regions.js +215 -1
  43. package/src/screenmap.js +89 -9
  44. package/src/supervisor.js +161 -0
  45. package/src/view.js +375 -11
  46. package/src/vocabulary.js +134 -0
  47. package/src/wrote.js +136 -0
package/README.md CHANGED
@@ -96,8 +96,14 @@ ok accessibility tree simframed: AXPTranslator, host-side
96
96
  ok on-device OCR available
97
97
  ok booted simulator iPhone 17 Pro (iOS 26.5)
98
98
  ok capture frame #888 322x700 in 2ms (age 538ms)
99
+ ok sensor mode full — accessibility and OCR fused on every read (~164ms)
100
+ ok local supervisor none — not requested (SIMFRAME_SUPERVISOR is unset)
101
+ ok local planner none — not requested (SIMFRAME_PLANNER is unset)
99
102
  ```
100
103
 
104
+ The last three are experiments and `none` is their normal answer. See
105
+ [Local tiers, off by default](#local-tiers-off-by-default).
106
+
101
107
  ### Claude Code
102
108
 
103
109
  ```bash
@@ -117,6 +123,75 @@ registered only for the directory you ran the command in.
117
123
  }
118
124
  ```
119
125
 
126
+ ## Recovering without a round trip
127
+
128
+ The measured cost of driving an app is not perception — warm, an
129
+ accessibility-only read is 85 ms and a fused read 142 ms. It is **round trips**:
130
+ in one instrumented run, 75% of the wall time was the agent thinking and the
131
+ call boundary, not simframe working. So the tools that matter most are the ones
132
+ that let a batch survive a problem instead of handing it back.
133
+
134
+ **Fallback selectors.** `{"tap": "Save", "or": ["Done", "Confirm"]}` — tried
135
+ locally in order, only an exhausted list reaching the model. Eligible after a
136
+ selector that did not *resolve* and nothing else, because retrying from a screen
137
+ you did not expect to be on is a second guess. A destructive-looking label is
138
+ refused as a substitute even if you list it.
139
+
140
+ **`{"seek": "change username", "budget": 6}`** opens containers, checks, and
141
+ comes back, depth first, inside a hard budget. It **acts** — opening a door
142
+ changes state — and it refuses to open anything that commits, abandons or
143
+ answers. It does not tap the target; it leaves you on the screen where the target
144
+ resolves.
145
+
146
+ **`{"sweep": "all", "fill": {…}}`** reads a long screen a viewport at a time and
147
+ fills each field while it is on screen. A form taller than the screen is only
148
+ knowable in pieces — the tree publishes what is rendered — and one scroll gesture
149
+ travels a non-deterministic distance, so finding a field and scrolling back to it
150
+ does not work. Sweeping does: on a real web form it filled every field in one
151
+ call. It detects both ends by measuring how far the *content* moved, ignoring
152
+ fixed chrome, which is the only reliable signal available since nothing reports a
153
+ scroll offset.
154
+
155
+ **`worked here before:`** puts the graph's own vocabulary in the map, most-used
156
+ first, rather than reporting a count. When the remembered controls are *not* on
157
+ the screen it says so instead, because that means two screens share one
158
+ fingerprint — and confident advice on a misidentified screen is how a remembered
159
+ label ends up pointing at a submit button.
160
+
161
+ ## Local tiers, off by default
162
+
163
+ Two on-device model experiments, both `none` unless asked for, both degrading to
164
+ the existing matcher-then-model ladder, and CI runs with both off. They ship no
165
+ weights: Apple's Foundation Models framework has nothing to download, which is
166
+ the whole reason it clears this project's non-goal on shipping model weights.
167
+
168
+ | flag | what it does |
169
+ |---|---|
170
+ | `--sensor=ax-first` | read the accessibility tree alone (~50 ms) and pay for OCR only when a resolve fails |
171
+ | `--planner=apple` | order the containers `seek` opens; it cannot choose an action |
172
+ | `--supervisor=apple` | when a step fails, answer `wait`, `retry` or `stop` — nothing else — before the failure reaches the model |
173
+
174
+ All three are also per-call arguments on every MCP tool, because an MCP server's
175
+ environment is fixed when it spawns and comparing two modes inside one session
176
+ was otherwise impossible.
177
+
178
+ **What is measured and what is not.** The ranker: 5 of 6 top-1 on hand-written
179
+ cases, median 564 ms warm, and on a real exploration it went to the right region
180
+ in two steps where reading order wandered into version strings. The supervisor:
181
+ correct on four real batch-killers once the plan briefed it, 689–751 ms warm —
182
+ **on a bench, not in the field.** `ax-first` made no measurable difference to how
183
+ an agent drove a real app, with one small regression and one small win. Numbers
184
+ and conditions are in [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md); the judgements,
185
+ including a phase cancelled by its own measurement, are in
186
+ [`docs/DECISIONS.md`](docs/DECISIONS.md).
187
+
188
+ The supervisor's whole vocabulary is three words on purpose. It cannot invent a
189
+ step, skip one, substitute a target or continue past an unexpected screen — not
190
+ because a threshold forbids it but because those are not answers it can give.
191
+ That constraint replaced an earlier version of the same idea that was given
192
+ latitude over *what* to open and pressed a button labelled "YES, THIS FIXED MY
193
+ PROBLEM" in a live app.
194
+
120
195
  ## Capabilities are independent
121
196
 
122
197
  Each layer works without the ones above it, and `doctor` tells you which you
@@ -284,10 +359,15 @@ Three ways to name a control, anywhere one is named:
284
359
 
285
360
  | | |
286
361
  | --- | --- |
287
- | `#3` | the number the map gave it. Cheapest, unambiguous. |
288
- | `"Save"` · `the Assets tab` · `back` | resolved by intent — verbs, typos, synonyms, icon-only controls by their common name |
362
+ | `"Save"` · `the Assets tab` · `back` | **start here** — resolved by intent: verbs, typos, synonyms, icon-only controls by their common name |
363
+ | `#3` | the number the map gave it. Cheap and exact, but only within the round trip that numbered it |
289
364
  | `@120,400` | raw point coordinates. Last resort: it cannot tell you it missed. |
290
365
 
366
+ The order is deliberate and it used to be the other way round. Four peer rounds
367
+ reported that intent resolution worked every time while refs renumbered
368
+ underneath them, so a table that led with `#3` and called it "unambiguous" was
369
+ recommending the more brittle of the two.
370
+
291
371
  ## Baselines: the thing to understand
292
372
 
293
373
  Every change question is really "changed **since when**?" — and the answer is
@@ -556,9 +636,11 @@ simframe strip --count=6 # contact sheet, for an animation
556
636
  simframe doctor --strict # any degraded layer is a non-zero exit
557
637
  simframe escalations # why simframe still needs a model, by reason
558
638
  simframe escalations --session # ...this agent only, not every agent on the device
639
+ simframe supervisions # local supervisor rulings, and what came of each
559
640
  simframe hpi # speed and accuracy against a human baseline
560
641
  simframe baseline record settings-larger-text --runs=5 # record the human
561
642
  simframe input reset # rebuild the HID session, without restarting anything
643
+ simframe revive # power-cycle a wedged device: stop, shutdown, boot, start, reset input
562
644
  simframe start / status / stop [--force] / devices
563
645
  simframe ui --device=emulator-5554 # or export SIMFRAME_DEVICE once
564
646
  ```
@@ -568,6 +650,88 @@ first: the space form set the flag to `true` and then resolved a device named
568
650
  "true", which is a poor answer to a flag `doctor`'s own advice tells you to
569
651
  type.
570
652
 
653
+ ### When the simulator stops rendering
654
+
655
+ A simulator driven hard for several minutes can stop rendering: capture fails,
656
+ `simctl screenshot` fails too, and the daemon's own recoveries — re-resolving the
657
+ display port, then rebinding the device — do not help. It reports the state
658
+ rather than acting on it, because a capture loop that rebooted the device it was
659
+ watching would be a tool reaching for the mains:
660
+
661
+ ```
662
+ capture is wedged and both recoveries are spent (2 port re-resolves, 2 device
663
+ rebinds, no frame since). This needs the device restarted —
664
+ `simframe revive --device=<udid>`. Backing off until a frame arrives.
665
+ ```
666
+
667
+ `simframe revive` is that restart, in the order that matters — stop the daemon,
668
+ shut the device down, boot it and *wait for the boot to finish*, start capture,
669
+ rebuild the HID session — and it ends by checking frames are flowing again
670
+ rather than by reporting that the steps ran. It is a command and not a
671
+ behaviour: the decision stays yours.
672
+
673
+ ### Reading what the local supervisor decided
674
+
675
+ With `SIMFRAME_SUPERVISOR=apple`, every consultation is written to
676
+ `~/.simframe/<udid>/supervisions.jsonl` with what the executor observed
677
+ afterwards, which is what makes a ruling scoreable rather than merely recorded.
678
+ `simframe supervisions` reads it:
679
+
680
+ ```
681
+ 14 supervisor rulings on iPhone 17 Pro
682
+
683
+ stop -> stopped 7
684
+ wait -> recovered 5
685
+ wait -> still_failed 2
686
+
687
+ sourced: model 14
688
+ median latency: 1440ms
689
+ edges the graph had timed: 0/14 — 14 ruling(s) are on edges with no p95
690
+ ```
691
+
692
+ That last line is the honest one: a step that failed is usually a step that has
693
+ never succeeded on that edge, so the graph has no timing to compare against.
694
+
695
+ ### Keeping a session cheap
696
+
697
+ The expensive part of driving a simulator with an agent is not the tapping, it
698
+ is the thinking between taps — observe, think, tap, observe, think. Measured
699
+ over one real session against a third-party app: **62 tool calls for 179 steps**,
700
+ and 48 of those calls were three steps or fewer. A twelve-step flow arrived as
701
+ five calls, and every boundary between them was a think.
702
+
703
+ Three things move that number, and simframe does the first two for you:
704
+
705
+ - **Batch.** `sim_do` runs a whole flow in one call, with an assert after each
706
+ step that matters. The asserts are what make it safe not to look in between:
707
+ a step that lands somewhere unplanned halts the flow instead of letting the
708
+ next four run against the wrong screen.
709
+ - **A `next:` line on every action result** — from the CLI and the MCP server
710
+ alike — computed locally from what the daemon already knows — whether the screen settled, whether the graph
711
+ recognises it, how many elements it has, whether any labels repeat. When it
712
+ says *nothing ambiguous — chain the next steps in one sim_do without looking
713
+ again*, that is the tool telling the agent it does not need to think.
714
+ - **A trailing map that was re-read, not recalled.** An action pays one
715
+ perception pass — a few hundred milliseconds, locally — so the map it returns
716
+ is the screen as it is now. The alternative was an agent spending a whole turn
717
+ on `ui --refresh` because it could not trust the one it was given.
718
+ - **One goal per session.** Sessions get slower with every turn. A flow that
719
+ runs as one call adds one exchange to the context instead of twelve.
720
+
721
+ And one thing to know about waiting: `settle` asks whether the screen stopped
722
+ moving, and a screen waiting on a network call has stopped moving. For anything
723
+ that arrives over the network, assert on the content you expect —
724
+ `{"waitFor": {"value": "Kate Bell"}}` — rather than on stillness. The map says
725
+ `STILL LOADING` when the classifier can see a load in flight, but only you know
726
+ what "arrived" means.
727
+
728
+ And the expensive habit worth naming: in that session, **28 of 62 calls returned
729
+ a screenshot** — about a third of its entire token cost — because the text map
730
+ could not report what a text field contained. It can now, so check the map
731
+ before reaching for pixels: a row carries the element's contents (`= Fryer 3`)
732
+ and its state (`disabled`), and the flow's own verdict already said whether the
733
+ action worked.
734
+
571
735
  ### The Claude Code skill
572
736
 
573
737
  [`skills/simframe/SKILL.md`](skills/simframe/SKILL.md) teaches the CLI path
@@ -672,6 +836,21 @@ said a word — the exact failure shape, found by the thing built to catch it.
672
836
  black screen as a calm one, but it cannot fix it: restarting the device is
673
837
  the cure that always works, and it usually recovers on its own.
674
838
 
839
+ ## What was decided, and what was not built
840
+
841
+ [`docs/DECISIONS.md`](docs/DECISIONS.md) is the register of judgements that
842
+ changed the plan: a phase cancelled by its own measurement, two features built
843
+ and reverted for cause, and the premises that turned out to be false. It is
844
+ short on purpose — the numbers live in
845
+ [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) and the open work in
846
+ [`docs/DEFERRED.md`](docs/DEFERRED.md).
847
+
848
+ The most useful entry is a **no-go**: Phase 17 proposed a small on-device model
849
+ to choose the next element, and measuring the prize before the model showed the
850
+ matcher already resolves 37 of 40 real decisions. By the time a step reaches
851
+ simframe the decision has already been made — the goal names the option,
852
+ because the agent chose it and then asked for it by name.
853
+
675
854
  ## Roadmap
676
855
 
677
856
  - **Extend the confirm vocabulary beyond English.**
@@ -0,0 +1,148 @@
1
+ {
2
+ "locale": "en",
3
+ "note": "Data, not code. CLAUDE.md: reflexes and the destructive vocabulary live in a locale-keyed file so adding a language is not a code change. Referenced by the verify barrier, per-step alternatives, exploration and (when built) reflexes.",
4
+ "destructive": {
5
+ "note": "Matched against a control's label, case-insensitively, on word boundaries. Nothing local may act on one of these: no retry, no alternative, no exploration, no reflex, no speculation. A model call is cheaper than a wrong tap.",
6
+ "words": [
7
+ "delete",
8
+ "remove",
9
+ "erase",
10
+ "clear",
11
+ "wipe",
12
+ "destroy",
13
+ "discard",
14
+ "pay",
15
+ "purchase",
16
+ "buy",
17
+ "subscribe",
18
+ "checkout",
19
+ "confirm payment",
20
+ "send",
21
+ "submit",
22
+ "post",
23
+ "publish",
24
+ "share",
25
+ "invite",
26
+ "sign out",
27
+ "log out",
28
+ "logout",
29
+ "deactivate",
30
+ "deregister",
31
+ "reset",
32
+ "restore",
33
+ "factory",
34
+ "unpair",
35
+ "forget",
36
+ "block",
37
+ "report",
38
+ "ban",
39
+ "unfriend",
40
+ "unfollow",
41
+ "cancel subscription",
42
+ "close account",
43
+ "delete account",
44
+ "accept",
45
+ "agree",
46
+ "allow",
47
+ "grant",
48
+ "trust",
49
+ "call",
50
+ "dial",
51
+ "transfer",
52
+ "withdraw",
53
+ "deposit",
54
+ "place order",
55
+ "confirm order",
56
+ "submit order",
57
+ "cancel order"
58
+ ],
59
+ "notWords": {
60
+ "note": "Labels that are NOT destructive despite containing a destructive word. Matched against the WHOLE label, not as a phrase inside it: a bare 'Cancel' declines a dialog and must stay tappable, while 'Cancel order' is a different act and falls through to the list above. The bare noun 'order' is deliberately absent from that list for the same reason - 'Work Orders' and 'Order Details' are navigation, and only the verb phrase places one.",
61
+ "words": [
62
+ "cancel",
63
+ "clear all filters",
64
+ "clear search",
65
+ "clear filter"
66
+ ]
67
+ }
68
+ },
69
+ "leavesTheApp": {
70
+ "note": "Nothing local may follow one of these either: it takes the run somewhere simframe was not asked to drive.",
71
+ "words": [
72
+ "open in safari",
73
+ "view in browser",
74
+ "app store",
75
+ "settings app",
76
+ "privacy policy",
77
+ "terms of service",
78
+ "contact support"
79
+ ]
80
+ },
81
+ "exploration": {
82
+ "note": "Exploration is a different question from substitution, and conflating them cost a real run. `seek` opened CANCEL first — because 'cancel' is listed as safe above, so that a local tier may DECLINE a dialog — then AI TROUBLESHOOTING, then 'YES, THIS FIXED MY PROBLEM', ending five screens deep in a support chat with a half-built service request destroyed. May-I-tap-this-to-decline and may-I-open-this-as-a-door are not the same permission. This list answers the second, and it errs toward refusing: a door missed costs one step, a door taken wrongly costs the run.",
83
+ "neverOpen": [
84
+ "cancel",
85
+ "discard",
86
+ "abandon",
87
+ "close",
88
+ "dismiss",
89
+ "back",
90
+ "done",
91
+ "finish",
92
+ "yes",
93
+ "no",
94
+ "ok",
95
+ "okay",
96
+ "confirm",
97
+ "continue",
98
+ "proceed",
99
+ "next",
100
+ "skip",
101
+ "save",
102
+ "apply",
103
+ "submit",
104
+ "send",
105
+ "post",
106
+ "publish",
107
+ "accept",
108
+ "agree",
109
+ "allow",
110
+ "deny",
111
+ "block",
112
+ "grant",
113
+ "retry",
114
+ "try again",
115
+ "start over",
116
+ "help",
117
+ "support",
118
+ "contact",
119
+ "chat",
120
+ "feedback",
121
+ "rate",
122
+ "review us",
123
+ "sign in",
124
+ "sign up",
125
+ "log in",
126
+ "register",
127
+ "upgrade",
128
+ "buy",
129
+ "pay",
130
+ "activate",
131
+ "enable",
132
+ "disable",
133
+ "turn on",
134
+ "turn off",
135
+ "reset"
136
+ ],
137
+ "neverOpenPatterns": [
138
+ "^yes[,. ]",
139
+ "^no[,. ]",
140
+ "^ok[,. ]",
141
+ "^don't ",
142
+ "^do not ",
143
+ "activate to ",
144
+ "^tap to ",
145
+ "^click to "
146
+ ]
147
+ }
148
+ }
package/native/ocr.swift CHANGED
@@ -7,8 +7,20 @@ guard args.count > 1, let img = NSImage(contentsOfFile: args[1]),
7
7
  let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
8
8
  FileHandle.standardError.write("cannot read image\n".data(using: .utf8)!); exit(1)
9
9
  }
10
+ // Recognition level is a switch now, not a constant.
11
+ //
12
+ // CLAUDE.md fixes `.accurate` with language correction off, and that was chosen
13
+ // without a comparison — which is a threshold nobody had scored. `SIMFRAME_OCR`
14
+ // selects it so the two can be measured against each other on the perception
15
+ // harness: accuracy lost against milliseconds gained.
16
+ //
17
+ // The theory being tested is a human one: we read imprecisely and gain speed by
18
+ // it, tolerated by a forgiving match. simframe's matcher already forgives a
19
+ // great deal — prefixes, synonyms, typo distance, a Cyrillic-for-Latin fold —
20
+ // so a worse reading may cost nothing that matters.
10
21
  let req = VNRecognizeTextRequest()
11
- req.recognitionLevel = .accurate
22
+ let level = ProcessInfo.processInfo.environment["SIMFRAME_OCR"]?.lowercased() ?? "accurate"
23
+ req.recognitionLevel = (level == "fast") ? .fast : .accurate
12
24
  req.usesLanguageCorrection = false
13
25
  try! VNImageRequestHandler(cgImage: cg, options: [:]).perform([req])
14
26
  let w = Double(cg.width), h = Double(cg.height)
@@ -0,0 +1,87 @@
1
+ // A local ranker, and nothing more than a ranker.
2
+ //
3
+ // Reads one JSON object per line on stdin and writes one per line on stdout, so
4
+ // the caller pays model load once instead of once per question: measured, the
5
+ // first answer in a process costs ~880ms and every later one ~564ms.
6
+ //
7
+ // in {"goal":"change my username","options":["General","Account"]}
8
+ // out {"order":["Account","General"],"ms":564}
9
+ //
10
+ // It may only reorder the labels it was given. It never invents one, never says
11
+ // what to do, and never sees pixels — the caller has already decided that every
12
+ // option is permitted (see src/vocabulary.js) and will try them in some order
13
+ // regardless. This only decides which order.
14
+ //
15
+ // Unavailable is a normal answer, not an error: no Apple Intelligence, no Apple
16
+ // Silicon, an older macOS, or a user who has turned it off. It says so on the
17
+ // first line and exits, and `doctor` reports `planner: none` while the existing
18
+ // matcher-then-Claude ladder carries on unchanged.
19
+ import Foundation
20
+ #if canImport(FoundationModels)
21
+ import FoundationModels
22
+
23
+ @available(macOS 26.0, *)
24
+ @Generable
25
+ struct Ranking {
26
+ @Guide(description: "The given labels, most likely to lead to the goal first")
27
+ var order: [String]
28
+ }
29
+
30
+ struct Question: Decodable { let goal: String; let options: [String] }
31
+
32
+ func emit(_ object: [String: Any]) {
33
+ guard let data = try? JSONSerialization.data(withJSONObject: object),
34
+ let line = String(data: data, encoding: .utf8) else { return }
35
+ print(line)
36
+ fflush(stdout)
37
+ }
38
+
39
+ @available(macOS 26.0, *)
40
+ func serve() async {
41
+ switch SystemLanguageModel.default.availability {
42
+ case .available: break
43
+ case .unavailable(let reason):
44
+ emit(["unavailable": "\(reason)"])
45
+ return
46
+ @unknown default:
47
+ emit(["unavailable": "unknown availability"])
48
+ return
49
+ }
50
+ let session = LanguageModelSession(instructions: """
51
+ You rank user-interface controls. You are given something the user is looking \
52
+ for and a list of labels visible on one screen. Order the labels from most to \
53
+ least likely to lead to what they want. Use only the labels you were given, \
54
+ copied verbatim. Do not invent labels and do not explain.
55
+ """)
56
+ emit(["ready": true])
57
+ while let line = readLine(strippingNewline: true) {
58
+ if line.isEmpty { continue }
59
+ guard let data = line.data(using: .utf8),
60
+ let q = try? JSONDecoder().decode(Question.self, from: data) else {
61
+ emit(["error": "could not parse that line"])
62
+ continue
63
+ }
64
+ let started = Date()
65
+ do {
66
+ let answer = try await session.respond(
67
+ to: "Looking for: \(q.goal)\nLabels: \(q.options.joined(separator: ", "))",
68
+ generating: Ranking.self)
69
+ // Only labels we handed it, and never the same one twice: a model
70
+ // that paraphrases must not be able to smuggle in a new target.
71
+ var seen = Set<String>()
72
+ let kept = answer.content.order.filter { q.options.contains($0) && seen.insert($0).inserted }
73
+ emit(["order": kept, "ms": Int(Date().timeIntervalSince(started) * 1000)])
74
+ } catch {
75
+ emit(["error": "\(error)"])
76
+ }
77
+ }
78
+ }
79
+
80
+ if #available(macOS 26.0, *) {
81
+ await serve()
82
+ } else {
83
+ emit(["unavailable": "the on-device model needs macOS 26 or newer"])
84
+ }
85
+ #else
86
+ print("{\"unavailable\":\"this toolchain cannot import FoundationModels\"}")
87
+ #endif
@@ -167,6 +167,9 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
167
167
  /// session survives. Input is warmed too, because a session that outlived
168
168
  /// its device is dead anyway.
169
169
  public func reattachDevice(udid: String?) throws -> DeviceInfo {
170
+ // Before the port reference goes, not after: unregistering needs the
171
+ // port it was registered on.
172
+ unregisterChangeCallback()
170
173
  device = nil
171
174
  display = nil
172
175
  return try attach(udid: udid)
@@ -183,6 +186,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
183
186
  }
184
187
 
185
188
  private func resolveDisplay(on device: NSObject, warmInput: Bool) throws -> DeviceInfo {
189
+ // This re-walks the same `ioPorts` array and frequently finds the *same*
190
+ // port object, so replacing `display` without releasing the callback on
191
+ // the outgoing one is how registrations accumulated on a single port.
192
+ unregisterChangeCallback()
186
193
  guard let io = device.value(forKey: "io") as? NSObject,
187
194
  let ports = io.value(forKey: "ioPorts") as? [NSObject] else {
188
195
  throw PrivateAPIError.noDisplayPort
@@ -307,11 +314,37 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
307
314
  return raw is IOSurface
308
315
  }
309
316
 
317
+ /// How long an on-demand surface read waits for a surface to exist.
318
+ ///
319
+ /// The capture *loop* has re-resolve and rebind around this same call. This
320
+ /// path had nothing: it asked once and threw. On a loaded hosted runner
321
+ /// that cost a CI job — the display renders intermittently, one read landed
322
+ /// in a gap, text recognition reported "the display surface could not be
323
+ /// read", and the screen map came back empty. Four checks failed on a
324
+ /// device that was healthy before and after, and whose capture loop never
325
+ /// logged a single failure in the whole run.
326
+ static let surfaceRetryBudgetMs = 600
327
+ static let surfaceRetryStepMs = 50
328
+
310
329
  public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
311
330
  guard let display else { throw PrivateAPIError.noDisplayPort }
312
- guard let raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue(),
313
- let surface = raw as? IOSurface else {
314
- throw PrivateAPIError.surfaceUnavailable
331
+ var raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue()
332
+ // Wait briefly rather than failing on the first miss. A display with
333
+ // nothing to draw can be between surfaces for a few tens of
334
+ // milliseconds, which is not the same condition as a display that has
335
+ // stopped rendering — and until now both said the same sentence.
336
+ var waitedMs = 0
337
+ while !(raw is IOSurface) && waitedMs < Self.surfaceRetryBudgetMs {
338
+ usleep(UInt32(Self.surfaceRetryStepMs) * 1000)
339
+ waitedMs += Self.surfaceRetryStepMs
340
+ raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue()
341
+ }
342
+ guard let surface = raw as? IOSurface else {
343
+ // Two different conditions, and giving them one sentence bought two
344
+ // wrong diagnoses in a row: a transient miss reads exactly like the
345
+ // permanent wedge, so "it is the documented wedge, re-run it" was
346
+ // the advice both times. It said so here for the transient case.
347
+ throw PrivateAPIError.surfaceMissing(afterMs: waitedMs)
315
348
  }
316
349
  surface.lock(options: .readOnly, seed: nil)
317
350
  defer { surface.unlock(options: .readOnly, seed: nil) }
@@ -333,6 +366,17 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
333
366
  /// to dismiss as broken.
334
367
  public func observeChanges(_ handler: @escaping () -> Void) throws {
335
368
  guard let display else { throw PrivateAPIError.noDisplayPort }
369
+ // Registration is not idempotent and this used to be called as though it
370
+ // were. Every call minted a fresh UUID and left the previous
371
+ // registration live, and the recovery loop calls it on every attempt:
372
+ // one log from 2026-09-12 shows **670 port re-resolves**, which is up to
373
+ // 670 damage callbacks registered on one port, each invoked per redraw
374
+ // (~52/s while an app switches).
375
+ //
376
+ // That is the best available explanation for a wedge that gets worse the
377
+ // longer it runs and that only a device restart cures — and it means the
378
+ // recovery was a cause as well as a response. Unregister first, always.
379
+ unregisterChangeCallback()
336
380
  let sel = NSSelectorFromString("registerCallbackWithUUID:damageRectanglesCallback:")
337
381
  guard display.responds(to: sel), let imp = display.method(for: sel) else {
338
382
  throw PrivateAPIError.frameworksUnavailable("damageRectanglesCallback missing")
@@ -345,7 +389,13 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
345
389
  unsafeBitCast(imp, to: RegFn.self)(display, sel, uuid, block as AnyObject)
346
390
  }
347
391
 
348
- public func detach() {
392
+ /// Drop the damage callback we registered, if we registered one.
393
+ ///
394
+ /// Safe to call when there is nothing to drop, which is what lets both
395
+ /// `observeChanges` and the port-replacement paths call it unconditionally.
396
+ /// Unregisters against the port it was registered on — the one currently in
397
+ /// `display` — so it must run *before* that reference is replaced.
398
+ func unregisterChangeCallback() {
349
399
  if let display, let uuid = changeUUID {
350
400
  let sel = NSSelectorFromString("unregisterDamageRectanglesCallbackWithUUID:")
351
401
  if display.responds(to: sel), let imp = display.method(for: sel) {
@@ -355,6 +405,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
355
405
  }
356
406
  changeCallback = nil
357
407
  changeUUID = nil
408
+ }
409
+
410
+ public func detach() {
411
+ unregisterChangeCallback()
358
412
  display = nil
359
413
  attached = nil
360
414
  }
@@ -573,6 +627,20 @@ extension CoreSimulatorPlatform {
573
627
  guard hid != nil else { throw PrivateAPIError.hidUnavailable("could not rebuild the HID client") }
574
628
  }
575
629
 
630
+ public func pressKey(usage: UInt32, modifiers: [UInt32]) throws {
631
+ let (hid, _) = try requireHID()
632
+ for m in modifiers { hid.key(usage: m, op: .down) }
633
+ defer { for m in modifiers.reversed() { hid.key(usage: m, op: .up) } }
634
+ // The usage-code path, not the character path. `type` sends characters
635
+ // and is therefore at the mercy of whichever keyboard layout iOS has
636
+ // active — which is why this device's own doctor warns about the fa and
637
+ // hy layouts. A usage code names a key *position* and is not translated,
638
+ // so Return is Return whatever is installed.
639
+ hid.key(usage: usage, op: .down)
640
+ Thread.sleep(forTimeInterval: 0.06)
641
+ hid.key(usage: usage, op: .up)
642
+ }
643
+
576
644
  public func press(_ button: HardwareButton) throws {
577
645
  let (hid, _) = try requireHID()
578
646
  guard let code = HIDKeyboard.buttonCode(button) else {
@@ -52,6 +52,12 @@ public enum PrivateAPIError: Error, CustomStringConvertible {
52
52
  case deviceNotFound(String)
53
53
  case noDisplayPort
54
54
  case surfaceUnavailable
55
+ /// The display had no surface *right now*, after waiting. Distinct from
56
+ /// `surfaceUnavailable`, which is the wedge: that one never heals without a
57
+ /// device restart, and this one is usually gone by the next frame. One
58
+ /// sentence for both is what made two sessions in a row diagnose a
59
+ /// transient miss as the wedge and advise a re-run.
60
+ case surfaceMissing(afterMs: Int)
55
61
  case hidUnavailable(String)
56
62
  case simctlFailed(String)
57
63
 
@@ -62,6 +68,10 @@ public enum PrivateAPIError: Error, CustomStringConvertible {
62
68
  case .deviceNotFound(let u): return "no simulator matching \(u)"
63
69
  case .noDisplayPort: return "the device exposes no active display port"
64
70
  case .surfaceUnavailable: return "the display surface could not be read"
71
+ case .surfaceMissing(let ms):
72
+ return "no frame was available from the display for \(ms)ms"
73
+ + " — this is usually transient; a display that has stopped rendering says"
74
+ + " \"the display surface could not be read\" instead"
65
75
  case .hidUnavailable(let d): return "input is unavailable: \(d)"
66
76
  case .simctlFailed(let d): return "simctl \(d)"
67
77
  }
@@ -122,6 +132,23 @@ public protocol SimulatorPlatform: AnyObject {
122
132
  /// it is the only reliable route for content that must be exact.
123
133
  func paste(_ text: String) throws
124
134
  func press(_ button: HardwareButton) throws
135
+ /// Press one keyboard key by HID usage code.
136
+ ///
137
+ /// Separate from `press`, which is the hardware buttons (HOME, LOCK, SIRI).
138
+ /// A peer was blocked outright for want of this: half of mobile search
139
+ /// fields submit on the keyboard return key, and there was no way to send
140
+ /// one. Typing "\n" as text goes through the active keyboard layout and
141
+ /// mangles the field instead — measured, it turned "Coke Display" into
142
+ /// "Coke In Display".
143
+ /// Press one keyboard key, optionally while holding modifiers.
144
+ ///
145
+ /// Modifiers are usage codes too (Left GUI `0xE3`, Shift `0xE1`, Control
146
+ /// `0xE0`, Alt `0xE2`), held in order and released in reverse. This is what
147
+ /// makes clearing a field possible at all: nothing in XCUITest, Appium or
148
+ /// idb has a clear primitive, and the standard answer is Command-A followed
149
+ /// by Delete — which is layout-independent, because a modifier and Delete
150
+ /// are key *positions* and so is the `a` in Command-A.
151
+ func pressKey(usage: UInt32, modifiers: [UInt32]) throws
125
152
  /// Rebuild the HID session.
126
153
  ///
127
154
  /// Input has no feedback channel: a dispatched Indigo message reports