simframe 0.8.0 → 0.10.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/src/input.js CHANGED
@@ -2,8 +2,11 @@
2
2
  // capability layered on top, so every entry point here has to answer "is this
3
3
  // even available?" before it answers anything else.
4
4
  import { execFile } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
  import * as control from './control.js';
6
- import { capabilitiesFor, geometryFor, inputDriverFor, setPasteboard } from './platform/index.js';
8
+ import * as store from './store.js';
9
+ import { bootedAtFor, capabilitiesFor, geometryFor, inputDriverFor, setPasteboard } from './platform/index.js';
7
10
  import { promisify } from 'node:util';
8
11
 
9
12
  const run = promisify(execFile);
@@ -245,6 +248,11 @@ export function elementToNode(e) {
245
248
  type: e.role ?? null,
246
249
  identifier: e.identifier ?? null,
247
250
  enabled: e.state?.enabled ?? null,
251
+ // The daemon batches AXSelected and AXFocused alongside AXEnabled and has
252
+ // since 0.6.0. This converter took one of the three, and it is the one on
253
+ // the path that actually runs — `normalizeNode` below is the idb fallback.
254
+ selected: e.state?.selected ?? null,
255
+ focused: e.state?.focused ?? null,
248
256
  frame: e.frame ?? null,
249
257
  raw: e,
250
258
  };
@@ -270,6 +278,12 @@ function normalizeNode(node) {
270
278
  type: node.type ?? node.AXType ?? null,
271
279
  identifier: node.AXUniqueId ?? node.identifier ?? null,
272
280
  enabled: node.AXEnabled ?? node.enabled ?? null,
281
+ // The daemon has asked the tree for AXSelected and AXFocused since 0.6.0 —
282
+ // they are two of the eight attributes in its batched round trip — and this
283
+ // function dropped both. `view.renderRow` has printed `selected` for as
284
+ // long as it has existed, against a field nobody set.
285
+ selected: node.AXSelected ?? node.selected ?? null,
286
+ focused: node.AXFocused ?? node.focused ?? null,
273
287
  frame: frame
274
288
  ? {
275
289
  x: frame.x ?? frame.X ?? 0,
@@ -323,6 +337,7 @@ export function centerOf(node) {
323
337
  }
324
338
 
325
339
  export async function tapPoint(udid, x, y, { durationMs } = {}) {
340
+ await ensureFreshSession(udid);
326
341
  const point = { x: Math.round(x), y: Math.round(y) };
327
342
  const own = inputDriverFor(udid);
328
343
  if (own) {
@@ -347,6 +362,7 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
347
362
  }
348
363
 
349
364
  export async function typeText(udid, value) {
365
+ await ensureFreshSession(udid);
350
366
  const own = inputDriverFor(udid);
351
367
  if (own) {
352
368
  // No pasteboard on Android (docs/DEFERRED.md), so exact text goes through
@@ -379,6 +395,7 @@ export async function typeText(udid, value) {
379
395
  * to say so; `type` still works on every device.
380
396
  */
381
397
  export async function pasteText(udid, value) {
398
+ await ensureFreshSession(udid);
382
399
  const own = inputDriverFor(udid);
383
400
  if (own?.key) {
384
401
  // The clipboard goes over gRPC; KEYCODE_PASTE is what puts it in the field.
@@ -413,6 +430,7 @@ export async function typeKeys(udid, value) {
413
430
  }
414
431
 
415
432
  export async function pressKey(udid, keycode) {
433
+ await ensureFreshSession(udid);
416
434
  const own = inputDriverFor(udid);
417
435
  if (own) {
418
436
  await own.key(udid, keycode);
@@ -433,10 +451,144 @@ export async function pressKey(udid, keycode) {
433
451
  *
434
452
  * @returns {Promise<boolean>} whether a session was actually reset.
435
453
  */
454
+ /**
455
+ * Is the daemon's HID session older than the device it talks to?
456
+ *
457
+ * Pure, so the comparison is testable without a device. `graceMs` covers the
458
+ * ordinary case where a daemon is started immediately after a boot and the two
459
+ * timestamps land within milliseconds of each other in either order.
460
+ */
461
+ export function sessionStaleness({ bootedAt, sessionSince, graceMs = 2000 }) {
462
+ if (!Number.isFinite(bootedAt)) {
463
+ return { stale: false, reason: 'cannot tell when the device booted' };
464
+ }
465
+ if (!Number.isFinite(sessionSince)) {
466
+ return { stale: false, reason: 'no capture daemon has recorded a start time, so there is no session to compare against' };
467
+ }
468
+ if (bootedAt <= sessionSince + graceMs) return { stale: false, reason: null };
469
+ return {
470
+ stale: true,
471
+ reason: `the device booted ${Math.round((bootedAt - sessionSince) / 1000)}s after the capture daemon started, `
472
+ + 'so the daemon holds an HID session for a device session that no longer exists',
473
+ bootedAt,
474
+ sessionSince,
475
+ };
476
+ }
477
+
478
+ /**
479
+ * What state the input path is in, for doctor and sim_state.
480
+ *
481
+ * Reads two timestamps off disk — the device's boot marker and the daemon's
482
+ * own `startedAt` — and costs a stat each. No input is dispatched to find out,
483
+ * because the whole failure being detected is input that reports success and
484
+ * does nothing.
485
+ */
486
+ const bootCache = new Map();
487
+ /**
488
+ * Boot time, cached for a moment.
489
+ *
490
+ * iOS answers with a stat; Android runs `adb shell cat /proc/uptime`, a
491
+ * subprocess of 20-40 ms, and `getState` runs twice per flow step. A few
492
+ * seconds of staleness in the staleness detector costs nothing — a device that
493
+ * rebooted three seconds ago is still rebooted at the next check.
494
+ */
495
+ async function bootedAtCached(udid, ttlMs = 3000) {
496
+ const hit = bootCache.get(udid);
497
+ if (hit && Date.now() - hit.at < ttlMs) return hit.value;
498
+ const value = await bootedAtFor(udid);
499
+ bootCache.set(udid, { at: Date.now(), value });
500
+ return value;
501
+ }
502
+
503
+ export async function sessionHealth(udid) {
504
+ if (!udid || !control.available(udid)) return { stale: false, reason: null };
505
+ let bootedAt = null;
506
+ try {
507
+ bootedAt = await bootedAtCached(udid);
508
+ } catch (err) {
509
+ return { stale: false, reason: `cannot tell when the device booted: ${err.message}` };
510
+ }
511
+ const meta = store.readJson(store.paths(udid).meta);
512
+ const rebuilt = store.readJson(sessionFile(udid))?.rebuiltAt;
513
+ // The newer of the two: the daemon starting creates a session, and rebuilding
514
+ // it replaces one. Either makes the session current as of that moment.
515
+ const sessionSince = Math.max(meta?.startedAt ?? 0, rebuilt ?? 0) || undefined;
516
+ return sessionStaleness({ bootedAt, sessionSince });
517
+ }
518
+
519
+ /**
520
+ * Should this staleness be acted on, given what has already been rebuilt?
521
+ *
522
+ * Pure, and separate from the check because the *key* is the whole bug. This
523
+ * gate used to be a set of udids — "once per process, per device" — and the
524
+ * reasoning was to avoid statting on every action. What it actually bought was
525
+ * that the feature could not fire in the one process that matters. A CLI
526
+ * command is a new process every time, so per-process is per-call there and
527
+ * the gate never showed; the MCP server is a single process that lives for a
528
+ * whole session, so it checked once, at the first action, and then never
529
+ * again — and a device that reboots *mid-session* is precisely the case this
530
+ * exists to catch. Reported from a real session: capture kept working, input
531
+ * died, every tap returned `ok`, and about ten calls went into two wrong
532
+ * conclusions about the app.
533
+ *
534
+ * The right key is the boot the rebuild was for. One attempt per device boot:
535
+ * enough that a failed rebuild does not retry on every tap forever, and not so
536
+ * much that the next boot is invisible.
537
+ */
538
+ export function shouldRebuildSession({ stale, bootedAt }, rebuiltFor) {
539
+ if (!stale) return false;
540
+ // A boot we cannot date cannot be memoised against, and re-attempting on
541
+ // every action would be worse than not detecting it. sessionStaleness only
542
+ // reports stale with a finite bootedAt, so this is a belt, not a case.
543
+ if (!Number.isFinite(bootedAt)) return false;
544
+ return rebuiltFor !== bootedAt;
545
+ }
546
+
547
+ /**
548
+ * Rebuild the session if the device outlived it. Once per device boot.
549
+ *
550
+ * Rebuild, and retry nothing: this runs *before* the action, so the action is
551
+ * delivered on a session known to be current. Retrying afterwards is how an
552
+ * action fires twice, which is the hazard the verify barrier exists to
553
+ * prevent — and it is why the existing recovery covers hardware buttons only.
554
+ *
555
+ * The check now runs on every dispatch rather than once. It costs two small
556
+ * `readJson`s and, at most every three seconds, one stat — `bootedAtCached`
557
+ * already caps the part that was expensive, which is what made the
558
+ * once-per-process gate unnecessary as well as wrong.
559
+ */
560
+ const rebuiltForBoot = new Map();
561
+ export async function ensureFreshSession(udid) {
562
+ if (!udid) return null;
563
+ const health = await sessionHealth(udid);
564
+ if (!shouldRebuildSession(health, rebuiltForBoot.get(udid))) return null;
565
+ rebuiltForBoot.set(udid, health.bootedAt);
566
+ const rebuilt = await resetSession(udid);
567
+ return { ...health, rebuilt };
568
+ }
569
+
570
+ /**
571
+ * When the HID session was last rebuilt, if it has been.
572
+ *
573
+ * The daemon's `startedAt` is the wrong clock on its own: rebuilding the
574
+ * session makes it current again without restarting the daemon, so comparing
575
+ * against the daemon's start left `doctor` reporting `stale` about a session
576
+ * that had just been rebuilt and was demonstrably working. It is a file rather
577
+ * than a variable because every CLI command is a new process and the daemon
578
+ * holding the session outlives all of them.
579
+ */
580
+ const sessionFile = (udid) => path.join(store.deviceDir(udid), 'input-session.json');
581
+
436
582
  export async function resetSession(udid) {
437
583
  if (!control.available(udid)) return false;
438
584
  try {
439
585
  await control.resetInput(udid);
586
+ try {
587
+ fs.mkdirSync(store.deviceDir(udid), { recursive: true });
588
+ store.writeAtomic(sessionFile(udid), JSON.stringify({ rebuiltAt: Date.now() }));
589
+ } catch {
590
+ /* the rebuild happened; failing to write it down only costs a stale report */
591
+ }
440
592
  return true;
441
593
  } catch {
442
594
  return false;
@@ -444,6 +596,7 @@ export async function resetSession(udid) {
444
596
  }
445
597
 
446
598
  export async function pressButton(udid, name) {
599
+ await ensureFreshSession(udid);
447
600
  const own = inputDriverFor(udid);
448
601
  if (own) {
449
602
  // Android's whole key vocabulary is safe to offer: `input keyevent` takes
@@ -464,6 +617,7 @@ export async function pressButton(udid, name) {
464
617
  }
465
618
 
466
619
  export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
620
+ await ensureFreshSession(udid);
467
621
  const own = inputDriverFor(udid);
468
622
  if (own) {
469
623
  await own.swipe(udid, from, to, { durationMs });
package/src/intent.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // instinct removes the expensive part of driving a UI with an agent: the model
6
6
  // round trip spent reasoning about controls it has never seen.
7
7
  import * as input from './input.js';
8
+ import * as metrics from './metrics.js';
8
9
 
9
10
  /** Confirming words, most specific first. The first tier with a hit wins. */
10
11
  const CONFIRM_TIERS = [
@@ -77,7 +78,11 @@ export function findOptions(nodes, geo) {
77
78
  export async function chooseAny(udid, { prefer, geo } = {}) {
78
79
  const nodes = await input.describeAll(udid);
79
80
  const options = findOptions(nodes, geo);
80
- if (!options.length) throw new Error('no selectable options found on screen');
81
+ if (!options.length) {
82
+ throw metrics.tag(new Error('no selectable options found on screen'), 'novel_dialog', {
83
+ candidates: nodes.filter((n) => n.label).slice(0, 8).map((n) => ({ label: n.label })),
84
+ });
85
+ }
81
86
  const chosen =
82
87
  (prefer && options.find((o) => o.label.toLowerCase().includes(String(prefer).toLowerCase()))) ||
83
88
  options[0];
@@ -89,7 +94,11 @@ export async function chooseAny(udid, { prefer, geo } = {}) {
89
94
  export async function confirm(udid, { geo } = {}) {
90
95
  const nodes = await input.describeAll(udid);
91
96
  const target = findConfirm(nodes, geo);
92
- if (!target) throw new Error('no confirming control on screen');
97
+ if (!target) {
98
+ throw metrics.tag(new Error('no confirming control on screen'), 'novel_dialog', {
99
+ candidates: nodes.filter((n) => n.label).slice(0, 8).map((n) => ({ label: n.label })),
100
+ });
101
+ }
93
102
  const point = input.centerOf(target);
94
103
  await input.tapPoint(udid, point.x, point.y);
95
104
  return { label: target.label, point, enabled: target.enabled };
package/src/matching.js CHANGED
@@ -59,7 +59,25 @@ export function nameScore(name, query) {
59
59
  const q = norm(query);
60
60
  if (!n || !q) return 0;
61
61
  if (n === q) return 1;
62
- if (n.startsWith(q) || q.startsWith(n)) return 0.86;
62
+ // A prefix match is only as good as the share it covers, and this branch had
63
+ // to be taught that twice.
64
+ //
65
+ // `n.startsWith(q)` — the name begins with the query, "Acce" for
66
+ // "Accessibility" — is the ordinary case and keeps most of its score: a
67
+ // prefix of a name is how people abbreviate. `q.startsWith(n)` is the
68
+ // opposite direction, where the *name* is a fragment of the query, and it
69
+ // returned the same flat 0.86 no matter how little of the query it was. So
70
+ // the section-index letter "S" scored 0.86 against the query "Search" and
71
+ // beat the search field's own label "Q Search" at 0.585 — and simframe
72
+ // tapped a scrubber and typed into it.
73
+ //
74
+ // The sibling branch below already carries this lesson in a comment about
75
+ // "back" matching a list row. Only one of the two had learned it. Both scale
76
+ // now, and the floor differs by direction on purpose: a query that is a
77
+ // prefix of a name is usually deliberate, while a name that is a fragment of
78
+ // the query is usually a coincidence, and one character is always one.
79
+ if (n.startsWith(q)) return 0.86 * Math.max(PREFIX_FLOOR, Math.min(1, q.length / n.length + 0.35));
80
+ if (q.startsWith(n)) return 0.8 * Math.max(0.15, n.length / q.length);
63
81
  // A substring match is only as good as the share of the name it covers.
64
82
  // Without this, "back" scores 0.78 against a two-hundred-character list row
65
83
  // that happens to contain "Back of House", and beats the actual back button.
@@ -76,7 +94,31 @@ export function nameScore(name, query) {
76
94
  if (distance > cap) return 0;
77
95
  const longest = Math.max(n.length, q.length);
78
96
  const similarity = 1 - distance / longest;
79
- return similarity >= 0.7 ? similarity * 0.72 : 0;
97
+ if (similarity >= 0.7) return similarity * 0.72;
98
+ // Last tier: OCR read a confusable character.
99
+ //
100
+ // Language correction is deliberately off, which is right for labels and
101
+ // wrong for exactly this. Measured on a real app, `(All)` reads back as
102
+ // `(AII)` and would fail an assert against the string it is; capital-I,
103
+ // lowercase-l, the digit one and a pipe are one shape in most UI fonts, as
104
+ // are capital-O and zero.
105
+ //
106
+ // Deliberately the *last* tier and discounted, not part of `norm`. Folding
107
+ // in `norm` would make it change what an exact match means — "Log in" and
108
+ // "1og in" would become the same string everywhere — and identity is not
109
+ // something to be fuzzy about. Here it only ever rescues a comparison that
110
+ // had already scored zero.
111
+ const foldedScore = confusableFold(n) === confusableFold(q) ? 0.62 : 0;
112
+ return foldedScore;
113
+ }
114
+
115
+ /** One shape per glyph family, for comparison only. Never for identity. */
116
+ export function confusableFold(s) {
117
+ return String(s ?? '')
118
+ .replace(/[il1|!]/gi, '1')
119
+ .replace(/[o0]/gi, '0')
120
+ .replace(/[s5]/gi, '5')
121
+ .replace(/[b8]/gi, '8');
80
122
  }
81
123
 
82
124
  function synonymGroup(query) {
@@ -159,6 +201,17 @@ export function rank(targets, intent, { screen } = {}) {
159
201
  export const AMBIGUITY_MARGIN = 0.08;
160
202
  /** Below this, no candidate is worth acting on. */
161
203
  export const MINIMUM_SCORE = 0.45;
204
+ /**
205
+ * How much of a name's score survives scaling a prefix by its coverage.
206
+ *
207
+ * Tied to `MINIMUM_SCORE` rather than chosen: at 0.5 the shortest useful
208
+ * abbreviation — "Ac" for "Accessibility" — scored 0.433 and fell *below* the
209
+ * threshold to resolve at all, which would have turned a ranking fix into a
210
+ * feature removal. 0.6 puts the worst case at 0.516, comfortably resolvable and
211
+ * still well under a fuller match. The unit test asserts the relationship so
212
+ * the cliff cannot come back by someone tuning one of the two numbers.
213
+ */
214
+ export const PREFIX_FLOOR = 0.6;
162
215
 
163
216
  /**
164
217
  * How close two tap points have to be to mean the same control.
@@ -182,6 +235,77 @@ export const SAME_CONTROL_POINTS = 12;
182
235
  */
183
236
  const INTERACTIVE_ROLE = /button|field|cell|row|link|switch|slider|tab|menu|segment|checkbox/i;
184
237
 
238
+ /**
239
+ * Is this target the accessibility tree's reading of a control?
240
+ *
241
+ * A merged target carries `ax|ocr`, because both sensors saw it. Every
242
+ * comparison against the string `'ax'` had to become this: an element does not
243
+ * stop being the tree's element because OCR agreed with it, and treating
244
+ * `ax|ocr` as "not ax" would have quietly demoted exactly the elements the
245
+ * merge is most confident about.
246
+ */
247
+ export const isAxTarget = (t) => /(^|\|)ax(\||$)/.test(t?.source ?? '');
248
+
249
+ /** How much of `inner` lies inside `outer`, as a fraction of inner's own area. */
250
+ export function containedFraction(inner, outer) {
251
+ if (!inner || !outer) return 0;
252
+ const iw = Math.max(0, inner.width ?? 0);
253
+ const ih = Math.max(0, inner.height ?? 0);
254
+ const innerArea = iw * ih;
255
+ if (innerArea <= 0) return 0;
256
+ const x = Math.max(inner.x, outer.x);
257
+ const y = Math.max(inner.y, outer.y);
258
+ const right = Math.min(inner.x + iw, outer.x + (outer.width ?? 0));
259
+ const bottom = Math.min(inner.y + ih, outer.y + (outer.height ?? 0));
260
+ const overlap = Math.max(0, right - x) * Math.max(0, bottom - y);
261
+ // Clamped: float arithmetic on sub-pixel OCR frames put a fully contained
262
+ // box at 1.0000000000000007, and a fraction of an area cannot exceed 1.
263
+ return Math.min(1, overlap / innerArea);
264
+ }
265
+
266
+ /**
267
+ * How much of the smaller box must sit inside the larger one to be the same
268
+ * element. OCR boxes sit a pixel or two outside the row they are printed on
269
+ * often enough that 1.0 would miss them.
270
+ */
271
+ export const CONTAINMENT = 0.9;
272
+
273
+ /**
274
+ * Do these two strings name the same thing?
275
+ *
276
+ * The discriminator that makes containment safe. A tab bar contains all five
277
+ * of its tab labels, and merging a container with its contents is the failure
278
+ * the old size cap was defending against — but a tab bar's own label is not
279
+ * "Assets", so the text test refuses that merge while allowing a row labelled
280
+ * "Kate Bell" to absorb OCR's reading of "Kate Bell".
281
+ *
282
+ * Substring counts because iOS labels carry state the printed text does not:
283
+ * a row reads "Larger Text" on screen and publishes "Larger Text, Off".
284
+ */
285
+ export function sameText(a, b) {
286
+ const x = norm(a);
287
+ const y = norm(b);
288
+ if (!x || !y) return false;
289
+ if (x === y) return true;
290
+ if (x.includes(y) || y.includes(x)) return Math.min(x.length, y.length) >= 3;
291
+ // Fuzzy, because OCR misreads a letter or two — measured: "Location (AII)"
292
+ // for "Location (All)", and a Cyrillic К for a K in a monogram.
293
+ return nameScore(x, y) >= 0.5;
294
+ }
295
+
296
+ /**
297
+ * The same element, seen by both sensors.
298
+ *
299
+ * `ax` is the tree's element, `ocr` a text box. True when the text sits
300
+ * (almost) wholly inside the element AND says the same thing as its label or
301
+ * value.
302
+ */
303
+ export function sameElementSeenTwice(ax, ocr) {
304
+ if (!ax?.frame || !ocr?.frame) return false;
305
+ if (containedFraction(ocr.frame, ax.frame) < CONTAINMENT) return false;
306
+ return sameText(ax.label, ocr.label ?? ocr.text) || sameText(ax.value, ocr.label ?? ocr.text);
307
+ }
308
+
185
309
  const contains = (frame, target) =>
186
310
  Boolean(frame)
187
311
  && target.x >= frame.x && target.x <= frame.x + (frame.width ?? 0)
@@ -205,6 +329,14 @@ function sameControl(a, b) {
205
329
  // tab bar from absorbing its own tabs.
206
330
  if (INTERACTIVE_ROLE.test(a.type ?? '') && contains(a.frame, b)) return true;
207
331
  if (INTERACTIVE_ROLE.test(b.type ?? '') && contains(b.frame, a)) return true;
332
+ // And a labelled accessibility element that is not an interactive role —
333
+ // a list row published as StaticText — with OCR's reading of its own label
334
+ // inside it. This is the pair that made `tap "Kate Bell"` refuse on every
335
+ // Contacts list: 16 escalations in the first instrumented run, all one
336
+ // screen. Defence in depth: the screen map now merges this pair at fusion,
337
+ // and a map built before that still resolves.
338
+ if (isAxTarget(a) && !isAxTarget(b) && sameElementSeenTwice(a, b)) return true;
339
+ if (isAxTarget(b) && !isAxTarget(a) && sameElementSeenTwice(b, a)) return true;
208
340
  return false;
209
341
  }
210
342
 
@@ -219,8 +351,8 @@ function collapseSamePlace(ranked) {
219
351
  // Prefer the real hit target: an accessibility element over OCR's reading of
220
352
  // it, and an interactive role over a caption sitting inside it.
221
353
  const better = (candidate, incumbent) => {
222
- if (candidate.target.source === 'ax' && incumbent.target.source !== 'ax') return true;
223
- if (candidate.target.source !== 'ax' && incumbent.target.source === 'ax') return false;
354
+ if (isAxTarget(candidate.target) && !isAxTarget(incumbent.target)) return true;
355
+ if (!isAxTarget(candidate.target) && isAxTarget(incumbent.target)) return false;
224
356
  return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
225
357
  && !INTERACTIVE_ROLE.test(incumbent.target.type ?? '');
226
358
  };
package/src/mcp.js CHANGED
@@ -480,7 +480,7 @@ async function look(target, args, options) {
480
480
  async function state(target, args, options) {
481
481
  const { device } = await api.ensureDaemon(target, options);
482
482
  const requested = baselineFor(device.udid, args.since);
483
- const res = await api.getState(target, { since: requested, options });
483
+ const res = await api.getState(target, { since: requested, options, inputHealth: true });
484
484
  const s = res.state;
485
485
  const lines = [
486
486
  header(res.device, s, res.ageMs),
@@ -500,6 +500,19 @@ async function state(target, args, options) {
500
500
  }
501
501
  const warn = livenessLine(res.live);
502
502
  if (warn) lines.unshift(warn);
503
+ // An agent that cannot see this spends five turns wondering why a correct
504
+ // tap on a correct element did nothing. The repair happens before the next
505
+ // action either way; this is so the cause is visible when it does.
506
+ if (res.input?.stale) lines.unshift(`input: stale — ${res.input.reason}`);
507
+ // §7's timing line. Worth a line because "is it still coming or is it done"
508
+ // is a question an agent otherwise answers by waiting and guessing.
509
+ if (res.timing?.samples) {
510
+ lines.push(
511
+ `timing: usually ${res.timing.edge_p50}ms to arrive here (p95 ${res.timing.edge_p95}ms over `
512
+ + `${res.timing.samples} samples), ${res.timing.elapsed_ms}ms since the last change`
513
+ + (res.timing.slower_than_usual ? ` — ${res.timing.note}` : ''),
514
+ );
515
+ }
503
516
  remember(device.udid, s);
504
517
  return { content: [text(lines.filter(Boolean).join('\n'))] };
505
518
  }