simframe 0.19.0 → 0.19.1
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 +12 -1
- package/native/simframed/Sources/simframed/main.swift +18 -2
- package/package.json +1 -1
- package/scripts/smithery-bundle.mjs +4 -1
- package/scripts/smithery-metadata.mjs +47 -0
- package/scripts/sync-server-version.mjs +19 -6
- package/skills/simframe/SKILL.md +7 -0
- package/src/actions.js +49 -3
- package/src/cli.js +1 -1
- package/src/index.js +5 -1
- package/src/mcp.js +2 -2
- package/src/refs.js +18 -4
- package/src/view.js +6 -2
package/README.md
CHANGED
|
@@ -17,12 +17,23 @@ a screenshot, a model round trip and a re-read for every single step.
|
|
|
17
17
|
You need a Mac with Xcode (you have one if you have a simulator) and Node 18+.
|
|
18
18
|
Nothing else — no idb, no Appium, no Python.
|
|
19
19
|
|
|
20
|
-
**Claude Code**
|
|
20
|
+
**Claude Code** — as a plugin, which brings the MCP server and the
|
|
21
|
+
[skill](skills/simframe/SKILL.md) that teaches the protocol in one install:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
claude plugin marketplace add lvlrSajjad/simframe
|
|
25
|
+
claude plugin install simframe@simframe
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
or the MCP server alone:
|
|
21
29
|
|
|
22
30
|
```bash
|
|
23
31
|
claude mcp add --scope user simframe -- npx -y simframe mcp
|
|
24
32
|
```
|
|
25
33
|
|
|
34
|
+
Pick one, not both: two installs mean two servers driving the same device. If
|
|
35
|
+
you added it with `claude mcp add` before, `claude mcp remove simframe` first.
|
|
36
|
+
|
|
26
37
|
**Claude Desktop** — Settings → Developer → Edit Config, then add:
|
|
27
38
|
|
|
28
39
|
```json
|
|
@@ -186,6 +186,18 @@ case "run":
|
|
|
186
186
|
|
|
187
187
|
let minInterval = Double(flag("min-interval-ms") ?? "") ?? 80 // coalesce bursts
|
|
188
188
|
let idleInterval = Double(flag("idle-interval-ms") ?? "") ?? 2000 // keep state fresh
|
|
189
|
+
// How soon to look again while the last frame said "not settled".
|
|
190
|
+
//
|
|
191
|
+
// Settling needs two still frames after the last change, and a still
|
|
192
|
+
// screen produces no damage, so those frames used to come from the idle
|
|
193
|
+
// cadence: a change that landed in one frame with nothing after it — a
|
|
194
|
+
// React Native tab switch, which does not animate — read as moving for
|
|
195
|
+
// up to two idle intervals, four seconds, on a screen that had stopped.
|
|
196
|
+
// Reported from the field as `STILL MOVING · frame 2.4s old` over a map
|
|
197
|
+
// of a screen that had finished. Two probes at this spacing clear
|
|
198
|
+
// `Motion.stillDurationRequired`, so a finished screen settles ~300ms
|
|
199
|
+
// after its last change instead.
|
|
200
|
+
let settleProbeInterval = Double(flag("settle-probe-ms") ?? "") ?? 150
|
|
189
201
|
let idleExitMs = Double(flag("idle-exit-ms") ?? "") ?? 15 * 60_000
|
|
190
202
|
|
|
191
203
|
let lock = NSLock()
|
|
@@ -195,6 +207,7 @@ case "run":
|
|
|
195
207
|
/// Said once per stall episode, not once per failed read.
|
|
196
208
|
var announcedExhausted = false
|
|
197
209
|
var lastCapture = 0.0
|
|
210
|
+
var lastSettled = true
|
|
198
211
|
var frames = 0
|
|
199
212
|
var lastReport = Date().timeIntervalSince1970
|
|
200
213
|
var latencies: [Double] = []
|
|
@@ -465,7 +478,9 @@ case "run":
|
|
|
465
478
|
let isDirty = dirty
|
|
466
479
|
lock.unlock()
|
|
467
480
|
|
|
468
|
-
let due = (isDirty && now - lastCapture >= minInterval)
|
|
481
|
+
let due = (isDirty && now - lastCapture >= minInterval)
|
|
482
|
+
|| (!lastSettled && now - lastCapture >= settleProbeInterval)
|
|
483
|
+
|| (now - lastCapture >= idleInterval)
|
|
469
484
|
if due {
|
|
470
485
|
lock.lock(); dirty = false; lock.unlock()
|
|
471
486
|
let t0 = DispatchTime.now().uptimeNanoseconds
|
|
@@ -495,7 +510,8 @@ case "run":
|
|
|
495
510
|
return (scaled, native)
|
|
496
511
|
}
|
|
497
512
|
let ms = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6
|
|
498
|
-
try store.record(bmp, fullBitmap: full, captureMs: ms)
|
|
513
|
+
let recorded = try store.record(bmp, fullBitmap: full, captureMs: ms)
|
|
514
|
+
lastSettled = (recorded["settled"] as? Bool) ?? true
|
|
499
515
|
latencies.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6)
|
|
500
516
|
frames += 1
|
|
501
517
|
lastCapture = now
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
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": [
|
|
@@ -70,7 +70,10 @@ const manifest = {
|
|
|
70
70
|
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
71
71
|
|
|
72
72
|
const out = path.join(root, `simframe-${pkg.version}.mcpb`);
|
|
73
|
-
|
|
73
|
+
// One bundle at a time. A stale one from the previous version sitting next to
|
|
74
|
+
// the new one got published in its place once, by a command line that named
|
|
75
|
+
// the old file.
|
|
76
|
+
for (const f of fs.readdirSync(root)) if (/^simframe-.*\.mcpb$/.test(f)) fs.rmSync(path.join(root, f), { force: true });
|
|
74
77
|
execFileSync('zip', ['-qr', out, '.', '-x', '*.DS_Store'], { cwd: dir });
|
|
75
78
|
fs.rmSync(work, { recursive: true, force: true });
|
|
76
79
|
console.log(`${path.relative(root, out)}: ${tools.length} tools, ${(fs.statSync(out).size / 1e6).toFixed(1)} MB`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sets the simframe listing's metadata on Smithery — description, links,
|
|
3
|
+
// license and icon — which the bundle publish does not carry.
|
|
4
|
+
// node scripts/smithery-metadata.mjs
|
|
5
|
+
//
|
|
6
|
+
// The API key is the one `npx @smithery/cli` stored when it asked for it;
|
|
7
|
+
// SMITHERY_API_KEY in the environment wins if set. Nothing is printed but
|
|
8
|
+
// the server's answers.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const server = 'lvlr-xaus/simframe';
|
|
16
|
+
|
|
17
|
+
function apiKey() {
|
|
18
|
+
if (process.env.SMITHERY_API_KEY) return process.env.SMITHERY_API_KEY;
|
|
19
|
+
const dir = process.env.SMITHERY_CONFIG_PATH
|
|
20
|
+
?? (process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'smithery') : path.join(os.homedir(), '.config', 'smithery'));
|
|
21
|
+
const file = path.join(dir, 'settings.json');
|
|
22
|
+
const key = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')).apiKey : null;
|
|
23
|
+
if (!key) throw new Error(`no Smithery API key: set SMITHERY_API_KEY or run any \`npx @smithery/cli mcp publish\` once so it stores one in ${file}`);
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const headers = { Authorization: `Bearer ${apiKey()}` };
|
|
28
|
+
const base = `https://api.smithery.ai/servers/${encodeURIComponent(server)}`;
|
|
29
|
+
|
|
30
|
+
const meta = await fetch(base, {
|
|
31
|
+
method: 'PATCH',
|
|
32
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
displayName: 'simframe',
|
|
35
|
+
description: 'Eyes, hands and memory for a coding agent driving the iOS Simulator or an Android emulator. Reads the screen as text with tap points, runs whole flows in one call with every step verified, and remembers screens so repeated flows need no model calls.',
|
|
36
|
+
homepage: 'https://lvlrsajjad.github.io/simframe/',
|
|
37
|
+
repositoryUrl: 'https://github.com/lvlrSajjad/simframe',
|
|
38
|
+
backlinkUrl: 'https://lvlrsajjad.github.io/simframe/agents-shouldnt-blink.html',
|
|
39
|
+
license: 'MIT',
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
console.log(`metadata: ${meta.status} ${await meta.text()}`);
|
|
43
|
+
|
|
44
|
+
const icon = new FormData();
|
|
45
|
+
icon.append('icon', new Blob([fs.readFileSync(path.join(root, 'scripts/smithery/icon.png'))], { type: 'image/png' }), 'icon.png');
|
|
46
|
+
const up = await fetch(`${base}/icon`, { method: 'PUT', headers, body: icon });
|
|
47
|
+
console.log(`icon: ${up.status} ${await up.text()}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Keep server.json's
|
|
2
|
+
// Keep server.json's and the Claude Code plugin's versions in step with
|
|
3
|
+
// package.json's.
|
|
3
4
|
//
|
|
4
5
|
// `npm version` only knows about package.json, and the MCP registry manifest
|
|
5
6
|
// carries the version twice — once at the top level and once inside the package
|
|
@@ -8,7 +9,12 @@
|
|
|
8
9
|
// the workflow's own agreement check.
|
|
9
10
|
//
|
|
10
11
|
// npm runs this as the `version` lifecycle script: after the bump, before the
|
|
11
|
-
// commit. It stages
|
|
12
|
+
// commit. It stages both files so the version commit contains all three.
|
|
13
|
+
//
|
|
14
|
+
// The plugin manifest is the second file for the same reason server.json is:
|
|
15
|
+
// a `version` in .claude-plugin/plugin.json pins every installed copy to it, so
|
|
16
|
+
// a manifest left behind by one release would hold plugin users on the
|
|
17
|
+
// previous release's skill forever, with nothing failing.
|
|
12
18
|
import { execFileSync } from 'node:child_process';
|
|
13
19
|
import fs from 'node:fs';
|
|
14
20
|
import path from 'node:path';
|
|
@@ -30,10 +36,17 @@ server.packages[0].version = pkg.version;
|
|
|
30
36
|
fs.writeFileSync(file, `${JSON.stringify(server, null, 2)}\n`);
|
|
31
37
|
console.log(`server.json ${before.top} / ${before.pkg} -> ${pkg.version} / ${pkg.version}`);
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const pluginFile = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
|
40
|
+
const plugin = JSON.parse(fs.readFileSync(pluginFile, 'utf8'));
|
|
41
|
+
const pluginBefore = plugin.version;
|
|
42
|
+
plugin.version = pkg.version;
|
|
43
|
+
fs.writeFileSync(pluginFile, `${JSON.stringify(plugin, null, 2)}\n`);
|
|
44
|
+
console.log(`.claude-plugin/plugin.json ${pluginBefore} -> ${pkg.version}`);
|
|
45
|
+
|
|
46
|
+
// Stage them, so `npm version` commits every file together. Harmless when run
|
|
47
|
+
// with --no-git-tag-version; the files are still correct either way.
|
|
35
48
|
try {
|
|
36
|
-
execFileSync('git', ['add', '--', file], { cwd: ROOT, stdio: 'pipe' });
|
|
49
|
+
execFileSync('git', ['add', '--', file, pluginFile], { cwd: ROOT, stdio: 'pipe' });
|
|
37
50
|
} catch {
|
|
38
|
-
console.log('(could not stage server.json — commit
|
|
51
|
+
console.log('(could not stage server.json and plugin.json — commit them yourself)');
|
|
39
52
|
}
|
package/skills/simframe/SKILL.md
CHANGED
|
@@ -18,6 +18,13 @@ and CV alone. Tapping by label works; screen recognition is thinner, so prefer
|
|
|
18
18
|
naming a device explicitly and re-reading the screen after a step you are unsure
|
|
19
19
|
about.
|
|
20
20
|
|
|
21
|
+
Commands are written here as the CLI (`simframe ui`, `simframe do`), which is
|
|
22
|
+
the cheapest path. If `simframe` is not on your PATH — installed as a Claude Code
|
|
23
|
+
plugin, say — the MCP server is already connected and the screen and flow
|
|
24
|
+
commands are its tools under the same names (`sim_ui`, `sim_do`, `sim_state`,
|
|
25
|
+
`sim_goto`…). Use those; for `doctor` and the other diagnostics, ask the user
|
|
26
|
+
to run `npm install -g simframe`.
|
|
27
|
+
|
|
21
28
|
## The protocol: plan once, execute once, think only when told to
|
|
22
29
|
|
|
23
30
|
The expensive thing in a simulator session is not the tapping. It is you —
|
package/src/actions.js
CHANGED
|
@@ -939,6 +939,10 @@ export async function runScript(
|
|
|
939
939
|
action: step.action,
|
|
940
940
|
verification,
|
|
941
941
|
ok: true,
|
|
942
|
+
// Either sensor saying nothing moved, and no state change explaining
|
|
943
|
+
// it. The verdict alone missed the case where only the settle
|
|
944
|
+
// detector saw it: `ok … [no visible change] (never settled)`.
|
|
945
|
+
unconfirmed: wentNowhere,
|
|
942
946
|
ms: Date.now() - stepStart,
|
|
943
947
|
detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
|
|
944
948
|
settled,
|
|
@@ -3034,6 +3038,9 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
3034
3038
|
const points = { width: geo.pointWidth, height: geo.pointHeight };
|
|
3035
3039
|
let dir = asked ?? 'down';
|
|
3036
3040
|
let reversed = false;
|
|
3041
|
+
// Whether any scroll in this step changed what was showing. A step whose
|
|
3042
|
+
// every scroll moved nothing is not evidence about the target at all.
|
|
3043
|
+
let movedOnce = false;
|
|
3037
3044
|
// Where the target is, when the tree knows. `null` means no evidence, and
|
|
3038
3045
|
// that distinction is load bearing: guessing "up" without it scrolls to
|
|
3039
3046
|
// the top of a web page, which **triggers pull-to-refresh**, reloads the
|
|
@@ -3155,7 +3162,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
3155
3162
|
}
|
|
3156
3163
|
}
|
|
3157
3164
|
const { says: evidence, signature: wasShowing } = await lookAround();
|
|
3158
|
-
|
|
3165
|
+
// Not after a reversal. The stall that caused it is a measurement that
|
|
3166
|
+
// this direction is exhausted, and letting the tree's hint overrule it
|
|
3167
|
+
// sent the step back the way it had just failed — reported on 0.19.0 as
|
|
3168
|
+
// `it stopped moving both ways (down, down)`, a message naming two
|
|
3169
|
+
// attempts in one direction and claiming both.
|
|
3170
|
+
if (evidence && !reversed) dir = evidence;
|
|
3159
3171
|
scrolled.push(dir);
|
|
3160
3172
|
await runStep(deviceQuery, udid, { action: 'scroll', value: dir }, ctx);
|
|
3161
3173
|
// A scroll either moves immediately or not at all, so it does not need a
|
|
@@ -3181,6 +3193,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
3181
3193
|
// a scroll as before it, the scroll achieved nothing, whatever the
|
|
3182
3194
|
// framebuffer did.
|
|
3183
3195
|
const { signature: nowShowing } = await lookAround();
|
|
3196
|
+
if (wasShowing && nowShowing && wasShowing !== nowShowing) movedOnce = true;
|
|
3184
3197
|
if (wasShowing && nowShowing && wasShowing === nowShowing) {
|
|
3185
3198
|
// Before believing the hash, look. See `nowInView` — an unchanged
|
|
3186
3199
|
// whole-frame hash is weak evidence about a strip, and this step has
|
|
@@ -3215,8 +3228,14 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
3215
3228
|
// reversal, so "there is no direction to try" (which it used to
|
|
3216
3229
|
// say when the tree was silent) is no longer true and would be
|
|
3217
3230
|
// the same kind of unchecked assertion as the sentence below it.
|
|
3218
|
-
`${query} is not reachable by scrolling: it stopped moving
|
|
3231
|
+
`${query} is not reachable by scrolling: it stopped moving`
|
|
3232
|
+
+ (new Set(scrolled).size > 1 ? ' both ways' : ` scrolling ${scrolled[0]}`)
|
|
3219
3233
|
+ ` (${scrolled.join(', ')}) after ${i + 1} attempt(s).`
|
|
3234
|
+
// Reported on 0.19.0: a React Native ScrollView that a plain swipe
|
|
3235
|
+
// from another tool scrolled at once, and that none of these moved.
|
|
3236
|
+
+ (movedOnce ? '' : ' No scroll moved the screen at all, so this says nothing about'
|
|
3237
|
+
+ ' where the target is — if this view does scroll, the gesture is not reaching it'
|
|
3238
|
+
+ ' (try sim_do with a swipe that starts inside the list, or check input with sim_state).')
|
|
3220
3239
|
// Say what was established, not what would be convenient. This
|
|
3221
3240
|
// used to assert "It may not be in the accessibility tree at all"
|
|
3222
3241
|
// in every case — a claim the function never checks, and one the
|
|
@@ -3597,6 +3616,25 @@ export function settleEvidence(w) {
|
|
|
3597
3616
|
+ (m ? `\n${m.map}` : '');
|
|
3598
3617
|
}
|
|
3599
3618
|
|
|
3619
|
+
/**
|
|
3620
|
+
* The mark a step result is printed under: `ok `, `WARN` or `FAIL`.
|
|
3621
|
+
*
|
|
3622
|
+
* `ok` used to mean only "nothing threw". A tap whose own verdict was
|
|
3623
|
+
* `no-visible-change` printed `ok … [no visible change]`, and a field report
|
|
3624
|
+
* (0.19.0) counted three such taps in a row that had not landed, each caught
|
|
3625
|
+
* only by an outside screenshot, while the escalation log recorded every one
|
|
3626
|
+
* of them as a mis-tap. The verdict was right and the mark contradicted it.
|
|
3627
|
+
*
|
|
3628
|
+
* WARN rather than FAIL, and no automatic retry. `no-visible-change` also
|
|
3629
|
+
* fires on changes too small to register — a field gaining focus was measured
|
|
3630
|
+
* reading as no change on 2026-10-01 — and retrying on a false one is how a
|
|
3631
|
+
* tap fires twice, which the verify barrier forbids.
|
|
3632
|
+
*/
|
|
3633
|
+
export function stepMark(r) {
|
|
3634
|
+
if (!r?.ok) return 'FAIL';
|
|
3635
|
+
return r.unconfirmed || metrics.ESCALATING_VERDICTS.has(r.verification?.verdict) ? 'WARN' : 'ok ';
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3600
3638
|
/**
|
|
3601
3639
|
* The one-line flow summary, so `ok` and `FAIL` each mean exactly one thing.
|
|
3602
3640
|
*
|
|
@@ -3621,7 +3659,15 @@ export function flowSummary(res, { withTime = true } = {}) {
|
|
|
3621
3659
|
const skips = skipped
|
|
3622
3660
|
? ` (${skipped} optional step${skipped === 1 ? '' : 's'} skipped as absent)`
|
|
3623
3661
|
: '';
|
|
3624
|
-
if (res.ok)
|
|
3662
|
+
if (res.ok) {
|
|
3663
|
+
// "Completed" over a step that did nothing visible is the same one word
|
|
3664
|
+
// arguing with its numbers that the failure branch below was fixed for.
|
|
3665
|
+
const unconfirmed = (res.results ?? []).filter((r) => stepMark(r) === 'WARN').length;
|
|
3666
|
+
if (unconfirmed) {
|
|
3667
|
+
return `flow ran — ${res.ranSteps}/${res.totalSteps} steps, ${unconfirmed} not confirmed to have landed${time}${skips}`;
|
|
3668
|
+
}
|
|
3669
|
+
return `flow completed — ${res.ranSteps}/${res.totalSteps} steps${time}${skips}`;
|
|
3670
|
+
}
|
|
3625
3671
|
const failed = (res.results ?? []).filter((r) => r.ok === false).length || 1;
|
|
3626
3672
|
const worked = Math.max(0, res.ranSteps - failed);
|
|
3627
3673
|
const unattempted = Math.max(0, res.totalSteps - res.ranSteps);
|
package/src/cli.js
CHANGED
|
@@ -268,7 +268,7 @@ async function lineReader() {
|
|
|
268
268
|
const stepLine = (r) => {
|
|
269
269
|
const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
|
|
270
270
|
const verdict = r.verification && r.verification.verdict !== 'ok' ? ` [${r.verification.verdict}]` : '';
|
|
271
|
-
return `${r
|
|
271
|
+
return `${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}${verdict}`;
|
|
272
272
|
};
|
|
273
273
|
|
|
274
274
|
async function main() {
|
package/src/index.js
CHANGED
|
@@ -1989,7 +1989,7 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
1989
1989
|
// calling a screen unsettled while the flow was still happily waiting for
|
|
1990
1990
|
// it — and an unsettled screen records no edge, so the graph learned
|
|
1991
1991
|
// nothing and every later step read `unverified`.
|
|
1992
|
-
const { state: settledFrame, settled } = await settledState(udid, {
|
|
1992
|
+
const { state: settledFrame, settled, stillnessPredatesAction } = await settledState(udid, {
|
|
1993
1993
|
settleMs,
|
|
1994
1994
|
timeoutMs: Math.min(timeoutMs ?? IDENTITY_SETTLE_TIMEOUT_MS, IDENTITY_SETTLE_TIMEOUT_MS),
|
|
1995
1995
|
});
|
|
@@ -2012,6 +2012,10 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
2012
2012
|
keyboard: Boolean(entry.keyboard),
|
|
2013
2013
|
layoutHash: current.layoutHash,
|
|
2014
2014
|
settled,
|
|
2015
|
+
// Unsettled for the opposite reason to moving: nothing has changed since
|
|
2016
|
+
// the action at all. Both used to render as `STILL MOVING`, which told an
|
|
2017
|
+
// agent to wait for a screen that a missed tap had left perfectly still.
|
|
2018
|
+
unmoved: !settled && Boolean(stillnessPredatesAction),
|
|
2015
2019
|
// Settled and incomplete are different states and used to render
|
|
2016
2020
|
// identically. A screen awaiting a network call is perfectly still; a
|
|
2017
2021
|
// person sees a spinner and knows to wait. The classifier already says
|
package/src/mcp.js
CHANGED
|
@@ -1010,7 +1010,7 @@ function stepLines(res) {
|
|
|
1010
1010
|
: ` · WARNING: ${r.settled.stalled ? 'capture stalled' : 'never settled'} after ${r.settled.waitedMs}ms`
|
|
1011
1011
|
: '';
|
|
1012
1012
|
lines.push(
|
|
1013
|
-
` ${r
|
|
1013
|
+
` ${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`,
|
|
1014
1014
|
);
|
|
1015
1015
|
}
|
|
1016
1016
|
if (!res.ok) lines.push('later steps were not run; the screen is left wherever the failing step stopped');
|
|
@@ -1153,7 +1153,7 @@ async function goto(target, args, options) {
|
|
|
1153
1153
|
? [`already on "${res.screen}"`]
|
|
1154
1154
|
: [
|
|
1155
1155
|
`${res.ok ? 'arrived at' : 'DID NOT REACH'} "${res.screen}" in ${res.ranSteps}/${res.steps.length} remembered steps`,
|
|
1156
|
-
...(res.results ?? []).map((r) => ` ${r
|
|
1156
|
+
...(res.results ?? []).map((r) => ` ${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}`),
|
|
1157
1157
|
];
|
|
1158
1158
|
lines.push('', await mapFrom(target, options, null));
|
|
1159
1159
|
return { content: [text(lines.join('\n'))], isError: !res.ok };
|
package/src/refs.js
CHANGED
|
@@ -145,10 +145,27 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
145
145
|
throw staleError(`this is a different screen (${table.structuralHash.slice(0, 8)}`
|
|
146
146
|
+ ` → ${structuralHash.slice(0, 8)})`, 'identity');
|
|
147
147
|
}
|
|
148
|
+
// Computed before the recognition check, because it is also the evidence that
|
|
149
|
+
// check was missing. See the drift refusal below for what it measures.
|
|
150
|
+
const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
|
|
151
|
+
? hashDistance(table.layoutHash, layoutHash)
|
|
152
|
+
: null;
|
|
148
153
|
// Nothing recognises the screen we are on, so nothing can vouch for the
|
|
149
154
|
// numbers. Refusing costs a re-read; guessing taps whatever is at those
|
|
150
155
|
// coordinates now.
|
|
151
|
-
|
|
156
|
+
//
|
|
157
|
+
// Except the refs table itself. A read that never settled numbers the
|
|
158
|
+
// elements and persists no map (`persist: settled`), so the very next call
|
|
159
|
+
// found screen memory empty and refused refs issued one second earlier on
|
|
160
|
+
// the same screen — `integration (memory)` went red on that twice running on
|
|
161
|
+
// an unchanged tree, a runner slow enough to miss the settle timeout being
|
|
162
|
+
// all it took. Screen memory recalls by the same layout distance and the same
|
|
163
|
+
// tolerance, so an informative hash this close to the table's is the test
|
|
164
|
+
// memory would have passed had it been allowed to remember; when the table's
|
|
165
|
+
// screen *was* remembered, recall would already have found it, and this
|
|
166
|
+
// changes nothing.
|
|
167
|
+
const vouchedByTable = drift != null && drift <= tolerance;
|
|
168
|
+
if (screenKnown === false && !vouchedByTable) {
|
|
152
169
|
// Flagged, like every other refusal in this function, and it was the one
|
|
153
170
|
// that was not.
|
|
154
171
|
//
|
|
@@ -178,9 +195,6 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
178
195
|
// routinely while the hashes differ. So this says the distance, and says that
|
|
179
196
|
// it is pixels rather than identity — a different thing from the branch above,
|
|
180
197
|
// which had been wearing the same sentence.
|
|
181
|
-
const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
|
|
182
|
-
? hashDistance(table.layoutHash, layoutHash)
|
|
183
|
-
: null;
|
|
184
198
|
if (drift != null && drift > tolerance) {
|
|
185
199
|
throw staleError(`the screen has moved too far from where these refs were numbered`
|
|
186
200
|
+ ` (layout distance ${drift}, tolerance ${tolerance}) — the identity may be unchanged;`
|
package/src/view.js
CHANGED
|
@@ -628,7 +628,7 @@ export async function screenMap(deviceQuery, {
|
|
|
628
628
|
* cheerfully says "carry on" into an unknown screen would be worse than no hint
|
|
629
629
|
* at all.
|
|
630
630
|
*/
|
|
631
|
-
export function nextHint({ ok, escalated, settled, loading, known, hash, exits, elements, ambiguous, filtered, exitList, staleExits } = {}) {
|
|
631
|
+
export function nextHint({ ok, escalated, settled, unmoved, loading, known, hash, exits, elements, ambiguous, filtered, exitList, staleExits } = {}) {
|
|
632
632
|
if (ok === false) {
|
|
633
633
|
return 'next: the flow stopped here — this is the moment to think. sim_recall shows how you got here; sim_ui re-reads the screen.';
|
|
634
634
|
}
|
|
@@ -654,6 +654,9 @@ export function nextHint({ ok, escalated, settled, loading, known, hash, exits,
|
|
|
654
654
|
if (loading) {
|
|
655
655
|
return 'next: settled, but the transition classifier still sees loading — an empty-looking region may be a list that has not arrived. waitFor a string you expect rather than acting on this.';
|
|
656
656
|
}
|
|
657
|
+
if (settled === false && unmoved) {
|
|
658
|
+
return 'next: nothing on the screen has changed since the last action. If that action should have changed it, it did not land — re-read with sim_ui before acting again, and do not assume the step worked.';
|
|
659
|
+
}
|
|
657
660
|
if (settled === false) {
|
|
658
661
|
return 'next: the screen is still moving. sim_state polls it for a fraction of a map; do not act on this reading yet.';
|
|
659
662
|
}
|
|
@@ -706,6 +709,7 @@ export function hintFor(map, { flowOk = true, escalated = false } = {}) {
|
|
|
706
709
|
escalated: Boolean(escalated),
|
|
707
710
|
filtered: map?.filtered === true,
|
|
708
711
|
settled: map?.identity?.settled !== false,
|
|
712
|
+
unmoved: map?.identity?.unmoved === true,
|
|
709
713
|
loading: map?.identity?.loading === true,
|
|
710
714
|
known: map?.exits != null,
|
|
711
715
|
hash: map?.identity?.hash ?? null,
|
|
@@ -795,7 +799,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
|
|
|
795
799
|
(exits == null ? ' (new to simframe)' : ` (known, ${exits} known exit${exits === 1 ? '' : 's'})`)
|
|
796
800
|
: 'screen unidentified',
|
|
797
801
|
identity?.keyboard ? 'keyboard up' : null,
|
|
798
|
-
identity?.settled === false ? 'STILL MOVING' : null,
|
|
802
|
+
identity?.settled === false ? (identity?.unmoved ? 'NOT MOVED SINCE THE ACTION' : 'STILL MOVING') : null,
|
|
799
803
|
// Still and finished are not the same thing.
|
|
800
804
|
identity?.loading === true ? 'STILL LOADING' : null,
|
|
801
805
|
// How old the *frame* this map was read from is.
|