tapline-host 1.1.0 → 1.3.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/dist/tools.js CHANGED
@@ -1,21 +1,24 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { basename, dirname, join } from "node:path";
3
4
  import { toBuffer as qrPng } from "qrcode";
4
5
  import { z } from "zod";
5
6
  import { annotateScreenshot } from "./annotate.js";
6
7
  import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
7
- import { composeFilmstrip, diffImages } from "./images.js";
8
- import { alertNotes, describeElement, formatChanges, formatElement, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
8
+ import { composeFilmstrip, diffImages, imageSize } from "./images.js";
9
+ import { alertNotes, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
10
  import { formatInspect, inspectResultSchema } from "./inspect.js";
10
11
  import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
12
+ import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
11
13
  import { ActionLog, loggedTarget } from "./actionlog.js";
12
14
  import { Confirmations } from "./confirm.js";
13
15
  import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
14
16
  import { readScript, refStep, scriptSteps } from "./script.js";
15
- import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
17
+ import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
18
+ import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
16
19
  import { PAIR_CODE_TTL_MS, forgetLink, pairingLink } from "./pairing.js";
17
20
  import { elementSchema, logEntrySchema, screenshotResultSchema, treeResultSchema, } from "./protocol.js";
18
- import { TaplineError } from "./server.js";
21
+ import { TaplineError, identityOf } from "./server.js";
19
22
  // ---- input schemas ----------------------------------------------------------
20
23
  export const targetSchema = z
21
24
  .object({
@@ -47,6 +50,15 @@ export const targetSchema = z
47
50
  ctx.addIssue({ code: "custom", message: "index works with id, label or text." });
48
51
  }
49
52
  });
53
+ const rectSchema = z
54
+ .object({
55
+ x: z.number().describe("Points"),
56
+ y: z.number().describe("Points"),
57
+ width: z.number().positive().describe("Points"),
58
+ height: z.number().positive().describe("Points"),
59
+ })
60
+ .strict();
61
+ const ignoreListSchema = z.array(z.union([rectSchema, targetSchema]));
50
62
  const targetField = targetSchema.describe("Exactly one of ref, id, label, text or x and y");
51
63
  const lookField = z
52
64
  .boolean()
@@ -175,6 +187,16 @@ export const toolShapes = {
175
187
  .max(1320)
176
188
  .optional()
177
189
  .describe("Screenshot width in pixels, 160 to 1320 (default 600); keep it the same for a name"),
190
+ threshold: z
191
+ .number()
192
+ .min(0)
193
+ .max(100)
194
+ .optional()
195
+ .describe("The share of the screen, in percent, 0 to 100 (default 0.5), that may change and still match"),
196
+ ignore: z
197
+ .array(z.union([rectSchema, targetSchema]))
198
+ .optional()
199
+ .describe("Painted out on both pictures before comparing (a clock, an avatar): targets {id}, {label}, {text}, {ref}, or a rect {x, y, width, height} in points"),
178
200
  },
179
201
  filmstrip: {
180
202
  frames: z.number().int().min(2).max(9).optional().describe("How many frames (default 6)"),
@@ -211,15 +233,29 @@ export const toolShapes = {
211
233
  },
212
234
  logs: {
213
235
  since: z.number().optional().describe("Only lines after this timestamp (epoch milliseconds)"),
236
+ level: z.enum(["error", "warning", "info"]).optional().describe("That level and above (default info: everything)"),
237
+ grep: z.string().optional().describe("Only lines containing this text (case-insensitive)"),
238
+ limit: z.number().int().min(1).max(500).optional().describe("At most this many lines, the newest (1 to 500, default 100)"),
239
+ },
240
+ crash: {
241
+ all: z.boolean().optional().describe("The last 5 crashes instead of only the newest"),
214
242
  },
215
243
  reload: { look: lookField },
244
+ performance: {
245
+ launch: z
246
+ .boolean()
247
+ .optional()
248
+ .describe("true: reload the app's JavaScript and time it to the first settled screen (development builds only; not a cold start)"),
249
+ seconds: z.number().min(0.5).max(30).optional().describe("Measure for this long, 0.5 to 30 seconds (default 3)"),
250
+ tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
251
+ },
216
252
  wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
217
253
  run: {
218
254
  steps: z
219
255
  .array(stepSchema)
220
256
  .min(1)
221
257
  .max(MAX_STEPS)
222
- .describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command. expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2).'),
258
+ .describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command, compare_screen (checks, never saves), performance. expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2); or matches (a reference name, alone or with one of them) with threshold; on a performance step, under: limits such as {"launch_s": 2, "drops": 0, "fps_min": 50, "busy_ms": 200, "js_heap_mb": 80}.'),
223
259
  trace: z.boolean().optional().describe("Also list each step that ran, one line each"),
224
260
  dry_run: z
225
261
  .boolean()
@@ -229,6 +265,78 @@ export const toolShapes = {
229
265
  screenshot: screenshotField,
230
266
  },
231
267
  };
268
+ // ---- compare_screen ---------------------------------------------------------
269
+ const DEFAULT_MATCH_THRESHOLD = 0.5;
270
+ const MAX_REGION_LINES = 8;
271
+ function describeIgnore(item) {
272
+ if (item.id !== undefined)
273
+ return `id=${item.id}`;
274
+ if (item.label !== undefined)
275
+ return `label ${JSON.stringify(item.label)}`;
276
+ if (item.text !== undefined)
277
+ return JSON.stringify(item.text);
278
+ if (item.ref !== undefined)
279
+ return item.ref;
280
+ return `${String(item.x)},${String(item.y)}`;
281
+ }
282
+ /** The pixel rectangles to paint out for `ignore`, from a fresh element list; targets not on the screen give a note. */
283
+ /**
284
+ * An element as a {label} target matches it: its accessibility label, else its text, as the app's SDK matches
285
+ * (labelOf in the SDK). The ignore masks, recorded indexes and expectations use the same rule as the targets.
286
+ */
287
+ function labelOfElement(e) {
288
+ return e.label ?? e.text ?? "";
289
+ }
290
+ function ignoreRects(ignore, elements, scale, size) {
291
+ const rects = [];
292
+ const notes = [];
293
+ const add = (x, y, width, height) => {
294
+ const x0 = Math.max(0, Math.floor(x * scale));
295
+ const y0 = Math.max(0, Math.floor(y * scale));
296
+ const x1 = Math.min(size.width, Math.ceil((x + width) * scale));
297
+ const y1 = Math.min(size.height, Math.ceil((y + height) * scale));
298
+ if (x1 > x0 && y1 > y0)
299
+ rects.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
300
+ };
301
+ for (const item of ignore) {
302
+ if ("width" in item) {
303
+ add(item.x, item.y, item.width, item.height);
304
+ continue;
305
+ }
306
+ let found = [];
307
+ if (item.ref !== undefined)
308
+ found = elements.filter((e) => e.ref === item.ref);
309
+ else if (item.id !== undefined)
310
+ found = elements.filter((e) => e.testID === item.id);
311
+ else if (item.label !== undefined) {
312
+ const wanted = item.label.toLowerCase();
313
+ found = elements.filter((e) => labelOfElement(e).toLowerCase() === wanted);
314
+ }
315
+ else if (item.text !== undefined) {
316
+ const wanted = item.text.toLowerCase();
317
+ found = elements.filter((e) => (e.text ?? "").toLowerCase() === wanted);
318
+ if (found.length === 0)
319
+ found = elements.filter((e) => wanted !== "" && (e.text ?? "").toLowerCase().includes(wanted));
320
+ }
321
+ else if (item.x !== undefined && item.y !== undefined) {
322
+ const px = item.x;
323
+ const py = item.y;
324
+ found = elements
325
+ .filter((e) => e.frame && px >= e.frame.x && px <= e.frame.x + e.frame.width && py >= e.frame.y && py <= e.frame.y + e.frame.height)
326
+ .sort((a, b) => (a.frame?.width ?? 0) * (a.frame?.height ?? 0) - (b.frame?.width ?? 0) * (b.frame?.height ?? 0))
327
+ .slice(0, 1);
328
+ }
329
+ found = found.filter((e) => e.frame !== undefined);
330
+ if (item.index !== undefined)
331
+ found = found.slice(item.index, item.index + 1);
332
+ if (found.length === 0)
333
+ notes.push(`ignore ${describeIgnore(item)} isn't on the screen; skipped.`);
334
+ for (const e of found)
335
+ if (e.frame)
336
+ add(e.frame.x, e.frame.y, e.frame.width, e.frame.height);
337
+ }
338
+ return { rects, notes };
339
+ }
232
340
  /**
233
341
  * Under this many points from its first point, a pointer path is a tap or a hold, not a drag.
234
342
  * Tapwright uses the same number, and the viewer page's pending ring (TAP_SLOP in viewer.html)
@@ -238,6 +346,8 @@ const TAP_SLOP = 10;
238
346
  /** Held at least this long, a tap is a long press. */
239
347
  const LONG_PRESS_MS = 500;
240
348
  const SCRIPT_NAME = /^[A-Za-z0-9][\w.-]{0,60}$/;
349
+ /** The answer to a reference name that isn't one: no slashes, so no way out of the references folder. */
350
+ const BAD_REFERENCE_NAME = "Error bad_params: name the reference with letters, digits, '.', '_' and '-'.";
241
351
  const scriptSchema = z.object({
242
352
  version: z.literal(1),
243
353
  name: z.string(),
@@ -407,6 +517,18 @@ function toWireTarget(target) {
407
517
  return withIndex({ text: target.text });
408
518
  return { x: target.x ?? 0, y: target.y ?? 0 };
409
519
  }
520
+ /** A target as the agent wrote it, in words: `id=save`, `"Save"`, `e3`, `120,300`. */
521
+ function targetWords(target) {
522
+ if (target.id !== undefined)
523
+ return `id=${target.id}`;
524
+ if (target.text !== undefined)
525
+ return JSON.stringify(target.text);
526
+ if (target.label !== undefined)
527
+ return JSON.stringify(target.label);
528
+ if (target.ref !== undefined)
529
+ return target.ref;
530
+ return `${target.x ?? 0},${target.y ?? 0}`;
531
+ }
410
532
  function candidateLines(details) {
411
533
  const lines = [];
412
534
  const bareRefs = [];
@@ -529,6 +651,12 @@ function candidatesFor(target, elements) {
529
651
  return picked !== undefined ? [picked] : found;
530
652
  }
531
653
  // ---- tool handlers ----------------------------------------------------------
654
+ /** Every tool whose answer can end with a crash note (all but `crash`, which tells it in full). */
655
+ const CRASH_NOTED_TOOLS = [
656
+ "apps", "openApp", "pair", "look", "run", "inspect", "audit", "checkLayout", "viewer", "compareScreen", "filmstrip",
657
+ "record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
658
+ "openUrl", "logs", "reload", "performance", "wait",
659
+ ];
532
660
  /** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
533
661
  export class TaplineTools {
534
662
  host;
@@ -561,10 +689,27 @@ export class TaplineTools {
561
689
  personNotePending = false;
562
690
  recording = null;
563
691
  replaying = false;
692
+ /**
693
+ * While a script replays (AGENT-TOOLS 1.9 §14.3): the references folder next to it (looked in first), where a
694
+ * failed check puts its red picture, and whether the checks save instead of compare (`--update-references`).
695
+ */
696
+ replayContext = null;
697
+ /** Where the latest failed check of the running batch saved its picture. */
698
+ diffSaved = null;
699
+ /** What the last `performance` call measured, for the `under` limits of a `run` step. */
700
+ lastPerf;
701
+ /** The elements the latest comparison painted its ignore list from: a recording saves its mask from the same list. */
702
+ lastMaskElements;
564
703
  /** The screen size in points from the latest list or overlay, for the live viewer's 0..1 positions. */
565
704
  lastScreen;
566
705
  shotCount = 0;
567
706
  pairWrites = 0;
707
+ /** Per app (by identity): crash events up to this one have been told to the agent. */
708
+ notedCrashSeqs = new Map();
709
+ /** Crash events up to this one happened before this agent's session: an app with no entry above starts here. */
710
+ crashBaseline;
711
+ /** Set inside a tool call (and everything it awaits), so a call within a call (`run`'s steps) adds no notes. */
712
+ noteScope = new AsyncLocalStorage();
568
713
  constructor(host, options) {
569
714
  this.host = host;
570
715
  this.options = options;
@@ -580,6 +725,70 @@ export class TaplineTools {
580
725
  if (this.host.current()?.id === session.id)
581
726
  this.markPersonUsedApp();
582
727
  });
728
+ // Crashes before this agent's session began are not its news.
729
+ this.crashBaseline = host.crashMark();
730
+ for (const name of CRASH_NOTED_TOOLS) {
731
+ const original = Reflect.get(this, name);
732
+ if (typeof original !== "function")
733
+ continue;
734
+ const method = original;
735
+ Reflect.set(this, name, (...args) => this.noting(() => method.apply(this, args)));
736
+ }
737
+ }
738
+ /**
739
+ * Runs a tool call and ends its answer with a note when the app stopped (AGENT-TOOLS 15.2): during this
740
+ * call, or earlier, with its time, once. Calls inside a call (`run`'s steps) add nothing of their own.
741
+ */
742
+ noting(run) {
743
+ // Nesting belongs to the call and what it awaits (not to this object), so calls running side by side each
744
+ // count as outermost, and only a step inside a call is skipped.
745
+ if (this.noteScope.getStore() === true)
746
+ return run();
747
+ const mark = this.host.crashMark();
748
+ const startedWith = this.host.current();
749
+ return this.noteScope.run(true, () => {
750
+ const result = run();
751
+ if (result instanceof Promise)
752
+ return result.then((answer) => this.withCrashNotes(answer, mark, startedWith));
753
+ return this.withCrashNotes(result, mark, startedWith);
754
+ });
755
+ }
756
+ /** What was told about this app's crashes so far: the crash events up to this number. */
757
+ notedSeqOf(app) {
758
+ return this.notedCrashSeqs.get(identityOf(app)) ?? this.crashBaseline;
759
+ }
760
+ /**
761
+ * The notes for the app this call used (the one at its start, the one at its end, or, with none connected, the
762
+ * one that dropped last). Other apps' events stay pending until that app is the one being driven.
763
+ */
764
+ withCrashNotes(answer, mark, startedWith) {
765
+ const apps = new Map();
766
+ const current = this.host.current();
767
+ // A call that started on an app answers for that app only: another app that became current meanwhile (the first
768
+ // one dropped) keeps its crashes pending until it is the one being driven (Codex review of 1.3.0).
769
+ const endedWith = startedWith === undefined ? (current ?? this.host.lastDisconnect()?.session) : current !== undefined && identityOf(current) === identityOf(startedWith) ? current : undefined;
770
+ for (const app of [startedWith, endedWith])
771
+ if (app !== undefined)
772
+ apps.set(identityOf(app), app);
773
+ const events = [...apps.values()]
774
+ .flatMap((app) => this.host.crashEventsAfter(this.notedSeqOf(app), app))
775
+ .sort((a, b) => a.seq - b.seq);
776
+ if (events.length === 0)
777
+ return answer;
778
+ const told = this.host.crashMark();
779
+ for (const key of apps.keys())
780
+ this.notedCrashSeqs.set(key, told);
781
+ let noted = answer;
782
+ for (const event of events) {
783
+ // During this call: no time. A crash the phone had kept and sent later says when it happened.
784
+ const during = event.seq > mark;
785
+ // A crash kept by an earlier run of the app (this host had not started yet) says so, with its date.
786
+ const earlier = event.kind === "crash" && event.crash.source === "saved" && event.crash.ts < this.host.startedAt;
787
+ noted = appendNote(noted, event.kind === "crash"
788
+ ? crashNote(event.crash, !(during && event.crash.source === "live"), earlier)
789
+ : disconnectNote(during ? undefined : event.at));
790
+ }
791
+ return noted;
583
792
  }
584
793
  /** A person used the app: the next answer says so once, and `{ref}` targets are stale until the next full list. */
585
794
  markPersonUsedApp() {
@@ -796,11 +1005,55 @@ export class TaplineTools {
796
1005
  }
797
1006
  const shape = toolShapes[step.tool];
798
1007
  const { tool: _tool, expect: _expect, route: _route, ...params } = step;
1008
+ if (step.tool === "compare_screen" && params.update !== undefined) {
1009
+ return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
1010
+ }
1011
+ const under = step.expect?.under;
1012
+ if (under !== undefined) {
1013
+ if (step.tool !== "performance") {
1014
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under goes only on a performance step.`);
1015
+ }
1016
+ const unknown = badUnderKeys(under);
1017
+ if (unknown.length > 0) {
1018
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under has ${unknown.map((key) => JSON.stringify(key)).join(", ")}; the limits are ${UNDER_KEYS.join(", ")}.`);
1019
+ }
1020
+ if (Object.keys(under).length === 0) {
1021
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under is empty; give at least one limit (${UNDER_KEYS.join(", ")}).`);
1022
+ }
1023
+ const negative = Object.entries(under).filter(([, limit]) => limit < 0 || !Number.isFinite(limit));
1024
+ if (negative.length > 0) {
1025
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under limits can't be negative (${negative.map(([key]) => key).join(", ")}).`);
1026
+ }
1027
+ if (under["launch_s"] !== undefined && step.launch !== true) {
1028
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch_s goes only on a launch step (performance with launch: true).`);
1029
+ }
1030
+ }
1031
+ // A step's own look and screenshot are ignored (§9.1).
1032
+ if (step.tool === "performance") {
1033
+ delete params["look"];
1034
+ delete params["screenshot"];
1035
+ // Checked before any step runs (and in a dry run), not when this step's turn comes.
1036
+ if (params["launch"] === true && params["tap"] !== undefined) {
1037
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch reloads the app and tap taps in it; give one of them.`);
1038
+ }
1039
+ }
799
1040
  const parsed = z.object(shape).strict().safeParse(params);
800
1041
  if (!parsed.success) {
801
1042
  return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
802
1043
  }
1044
+ // Every reference name is checked before any step runs: a name with a path in it never reaches the disk.
1045
+ const names = [step.tool === "compare_screen" ? params.name : undefined, step.expect?.matches];
1046
+ if (names.some((name) => typeof name === "string" && !SCRIPT_NAME.test(name.trim()))) {
1047
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): name the reference with letters, digits, '.', '_' and '-'.`);
1048
+ }
1049
+ if (step.expect?.ignore !== undefined) {
1050
+ const ignored = ignoreListSchema.safeParse(step.expect.ignore);
1051
+ if (!ignored.success) {
1052
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): expect.ignore: ${ignored.error.issues.map((issue) => issue.message).join("; ")}`);
1053
+ }
1054
+ }
803
1055
  }
1056
+ this.diffSaved = null;
804
1057
  if (args.dry_run === true)
805
1058
  return this.dryRunBatch(steps);
806
1059
  const before = this.unlistedActions.get(session.id) ?? 0;
@@ -810,12 +1063,15 @@ export class TaplineTools {
810
1063
  const trace = [];
811
1064
  let ran = 0;
812
1065
  let stop = null;
1066
+ // The newest log time when the current step began: a failed step shows the app's warnings since then (§15.4).
1067
+ let stepMark;
813
1068
  for (const [i, step] of steps.entries()) {
814
1069
  if (Date.now() - started > RUN_TIMEOUT_MS) {
815
1070
  stop = { index: i, code: "timeout", message: `the batch ran longer than ${RUN_TIMEOUT_MS / 1000} s`, rest: [] };
816
1071
  break;
817
1072
  }
818
1073
  const at = Date.now();
1074
+ stepMark = this.newestLogTs(session.id);
819
1075
  this.batch = { id: batchId, step: i + 1 };
820
1076
  const answer = await this.runStep(step).finally(() => {
821
1077
  this.batch = undefined;
@@ -826,12 +1082,40 @@ export class TaplineTools {
826
1082
  break;
827
1083
  }
828
1084
  notes.push(...stepNotes(said, i + 1));
829
- if (step.expect !== undefined) {
1085
+ if (step.expect?.under !== undefined) {
1086
+ // AGENT-TOOLS §15.3: the limits a performance step must keep; every broken one is listed.
1087
+ const measured = this.lastPerf;
1088
+ if (measured === undefined) {
1089
+ // A limit nobody measured must not pass.
1090
+ stop = { index: i, code: "expect_failed", message: "no measurement to check.", rest: [] };
1091
+ break;
1092
+ }
1093
+ const checked = checkUnder(step.expect.under, measured);
1094
+ notes.push(...checked.notes.map((note) => `Note (step ${i + 1}): ${note}`));
1095
+ if (checked.missed.length > 0) {
1096
+ stop = { index: i, code: "expect_failed", message: `${checked.missed.join("; ")}.`, rest: [] };
1097
+ break;
1098
+ }
1099
+ }
1100
+ else if (step.expect !== undefined) {
830
1101
  const missed = await this.expectMissed(step.expect);
831
1102
  if (missed !== null) {
832
1103
  stop = { index: i, code: "expect_failed", message: `its action ran, but ${missed}`, rest: [] };
833
1104
  break;
834
1105
  }
1106
+ // A picture check that held is kept in a recording, as compare_screen's is: right after the action, the
1107
+ // script makes it that step's `matches` again (AGENT-TOOLS 1.9 §14.2).
1108
+ if (step.expect.matches !== undefined) {
1109
+ const recorded = await this.recordCompare({
1110
+ name: step.expect.matches,
1111
+ ...(step.expect.threshold !== undefined ? { threshold: step.expect.threshold } : {}),
1112
+ ...(step.expect.ignore !== undefined ? { ignore: ignoreListSchema.parse(step.expect.ignore) } : {}),
1113
+ });
1114
+ if (recorded !== null) {
1115
+ stop = { index: i, ...parseError(recorded) };
1116
+ break;
1117
+ }
1118
+ }
835
1119
  }
836
1120
  const route = step.route;
837
1121
  if (Array.isArray(route) && route.every((part) => typeof part === "string")) {
@@ -855,29 +1139,61 @@ export class TaplineTools {
855
1139
  const head = stop === null
856
1140
  ? `Ran ${total} of ${total} steps in ${((Date.now() - started) / 1000).toFixed(1)} s.`
857
1141
  : `Error ${stop.code}: Ran ${ran} of ${total} steps; stopped at step ${stop.index + 1} (${describeRunStep(steps[stop.index])}): ${stop.message}`;
858
- const first = [head, ...trace, ...(stop?.rest ?? [])].join("\n");
1142
+ const diffNote = stop !== null && this.diffSaved !== null ? [`Note: the difference is saved at ${this.diffSaved}.`] : [];
1143
+ const first = [head, ...trace, ...(stop?.rest ?? []), ...diffNote].join("\n");
1144
+ const tail = [...(stop !== null ? this.failureLogs(session.id, stop.index + 1, stepMark) : []), ...notes];
859
1145
  let response;
860
1146
  const live = this.host.current();
861
1147
  if (args.look === false || live === undefined) {
862
- response = text([first, ...notes].join("\n"));
1148
+ response = text([first, ...tail].join("\n"));
863
1149
  }
864
1150
  else {
865
1151
  response = await this.withLook(live, first, args.screenshot === true);
866
- if (notes.length > 0) {
1152
+ if (tail.length > 0) {
867
1153
  const [top, ...rest] = response.content;
868
1154
  if (top !== undefined && top.type === "text")
869
- response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...notes].join("\n") }, ...rest] };
1155
+ response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...tail].join("\n") }, ...rest] };
870
1156
  }
871
1157
  }
872
1158
  return failed ? { ...response, isError: true } : response;
873
1159
  }
1160
+ /** The newest log time of a session (or of the app's current session), undefined without lines. */
1161
+ newestLogTs(sessionId) {
1162
+ return this.sessionLogs(sessionId).reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
1163
+ }
1164
+ /**
1165
+ * A session's buffered lines, also when it has ended. When the same app on the same phone came back as a new
1166
+ * session, that session's lines follow. Another app's lines never stand in for it (Codex review of 1.3.0).
1167
+ */
1168
+ sessionLogs(sessionId) {
1169
+ const record = this.host.sessionRecord(sessionId);
1170
+ if (record === undefined)
1171
+ return [];
1172
+ const now = this.host.current();
1173
+ if (now === undefined || now.id === sessionId || identityOf(now) !== record.identity)
1174
+ return record.events;
1175
+ try {
1176
+ return [...record.events, ...this.host.logs(now.id)];
1177
+ }
1178
+ catch {
1179
+ return record.events;
1180
+ }
1181
+ }
1182
+ /** AGENT-TOOLS §15.4: `Logs (since step <n>):` and up to 20 lines at warning or above since the step began. */
1183
+ failureLogs(sessionId, step, since) {
1184
+ const lines = filterLogs(this.sessionLogs(sessionId), { since, level: "warning", limit: 20 });
1185
+ return lines.length === 0 ? [] : [`Logs (since step ${step}):`, ...formatLogs(lines).split("\n")];
1186
+ }
874
1187
  /** `run` with dry_run (§11.4): the steps are checked already; step 1 is resolved, nothing is touched. */
875
1188
  async dryRunBatch(steps) {
876
1189
  const total = steps.length;
877
1190
  const first = steps[0];
878
1191
  const head = `Would run ${total} step${total === 1 ? "" : "s"}`;
879
- if (["tap", "long_press", "toggle", "type_text", "scroll", "back", "swipe"].includes(first.tool)) {
880
- const answer = await this.runStep({ ...first, dry_run: true });
1192
+ // A performance step with a tap: the tap resolves like any touch; nothing is measured (AGENT-TOOLS §15.3).
1193
+ const perfTap = first.tool === "performance" ? first["tap"] : undefined;
1194
+ const probe = perfTap !== undefined ? { tool: "tap", target: perfTap } : first;
1195
+ if (["tap", "long_press", "toggle", "type_text", "scroll", "back", "swipe"].includes(probe.tool)) {
1196
+ const answer = await this.runStep({ ...probe, dry_run: true });
881
1197
  const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
882
1198
  const [line, ...rest] = said.split("\n");
883
1199
  if (answer.isError === true) {
@@ -887,6 +1203,10 @@ export class TaplineTools {
887
1203
  const would = (line ?? "").replace(/^Would /, "would ");
888
1204
  return text([`${head}; step 1 ${would}`, ...rest].join("\n"));
889
1205
  }
1206
+ if (first.tool === "performance") {
1207
+ const p = first;
1208
+ return text(`${head}; step 1 would ${p["launch"] === true ? "reload the app and time it to the first settled screen" : `measure ${Number(p["seconds"] ?? 3)} s`}.`);
1209
+ }
890
1210
  return text(`${head}; step 1 would ${describeRunStep(first)}.`);
891
1211
  }
892
1212
  /** One step of `run`, as its own tool call without a list. */
@@ -916,6 +1236,10 @@ export class TaplineTools {
916
1236
  return this.waitFor(quiet);
917
1237
  case "app_command":
918
1238
  return this.appCommand(quiet);
1239
+ case "compare_screen":
1240
+ return this.compareStep(rest);
1241
+ case "performance":
1242
+ return this.performance(rest);
919
1243
  }
920
1244
  }
921
1245
  /** The screen's route names now (one cheap read), or null when the app has no router or didn't answer. */
@@ -928,6 +1252,15 @@ export class TaplineTools {
928
1252
  }
929
1253
  /** Null when the expectation holds within its time; else how it failed. */
930
1254
  async expectMissed(e) {
1255
+ if (e.text !== undefined || e.id !== undefined || e.label !== undefined) {
1256
+ const missed = await this.textExpectMissed(e);
1257
+ if (missed !== null)
1258
+ return missed;
1259
+ }
1260
+ // A picture check comes last, once the rest held.
1261
+ return e.matches === undefined ? null : this.matchMissed(e.matches, e.threshold ?? DEFAULT_MATCH_THRESHOLD, ignoreListSchema.parse(e.ignore ?? []));
1262
+ }
1263
+ async textExpectMissed(e) {
931
1264
  const seconds = e.timeout ?? EXPECT_TIMEOUT_S;
932
1265
  const which = e.text !== undefined ? { text: e.text } : e.id !== undefined ? { id: e.id } : { label: e.label };
933
1266
  const seen = await this.waitFor({ ...which, ...(e.gone === true ? { gone: true } : {}), timeout: Math.max(0.5, seconds), look: false });
@@ -944,7 +1277,7 @@ export class TaplineTools {
944
1277
  for (;;) {
945
1278
  const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
946
1279
  const element = tree.success
947
- ? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : (el.label ?? "").toLowerCase() === (e.label ?? "").toLowerCase()))
1280
+ ? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : labelOfElement(el).toLowerCase() === (e.label ?? "").toLowerCase()))
948
1281
  : undefined;
949
1282
  if (element !== undefined) {
950
1283
  actual = typeof element.checked === "boolean" ? (element.checked ? "on" : "off") : typeof element.value === "string" ? element.value : undefined;
@@ -1270,7 +1603,7 @@ export class TaplineTools {
1270
1603
  const same = tree.data.elements.filter((el) => typeof t.testID === "string"
1271
1604
  ? el.testID === t.testID
1272
1605
  : typeof t.label === "string"
1273
- ? (el.label ?? "").toLowerCase() === t.label.toLowerCase()
1606
+ ? labelOfElement(el).toLowerCase() === t.label.toLowerCase()
1274
1607
  : typeof t.text === "string"
1275
1608
  ? el.text === t.text
1276
1609
  : false);
@@ -1555,66 +1888,99 @@ export class TaplineTools {
1555
1888
  else
1556
1889
  content.push({ type: "image", data: image.data, mimeType: image.mime });
1557
1890
  }
1558
- async compareScreen(args) {
1559
- const session = this.host.current();
1560
- if (session === undefined)
1561
- return this.notConnected();
1562
- const name = args.name.trim();
1563
- if (!SCRIPT_NAME.test(name))
1564
- return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
1891
+ /** Where this app's reference pictures are kept, or an error answer. */
1892
+ referenceFolder(session) {
1565
1893
  const dir = this.options.baselinesDir;
1566
1894
  if (dir === undefined)
1567
1895
  return fail("Error internal: no folder for reference pictures is configured.");
1568
- const appFolder = (session.app.id ?? session.app.name).replace(/[^\w.-]/g, "_");
1569
- const path = join(dir, appFolder, `${name}.jpg`);
1896
+ return join(dir, (session.app.id ?? session.app.name).replace(/[^\w.-]/g, "_"));
1897
+ }
1898
+ /** The element list for naming regions and masking, read as a peek: the agent's refs and baseline must stay as they are. */
1899
+ async peekElements(sessionId) {
1900
+ try {
1901
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId }));
1902
+ if (tree.success && tree.data.screen)
1903
+ return { scaleBase: tree.data.screen.width, elements: tree.data.elements };
1904
+ }
1905
+ catch {
1906
+ // positions stay in pixels
1907
+ }
1908
+ return { scaleBase: undefined, elements: [] };
1909
+ }
1910
+ /**
1911
+ * Takes the screen and compares it with the saved reference called `name`, never saving. `referencesDirs` are the
1912
+ * folders to look in, in order. A missing reference hands back the screenshot, so the caller may save it.
1913
+ */
1914
+ async checkAgainstReference(session, name, options) {
1570
1915
  let shot;
1571
1916
  try {
1572
- shot = await this.takeShot(session.id, args.width ?? DEFAULT_SCREENSHOT_WIDTH, 0.9);
1917
+ shot = await this.takeShot(session.id, options.width, 0.9);
1573
1918
  }
1574
1919
  catch (error) {
1575
- return fail(describeError(error));
1576
- }
1577
- if (args.update === true || !existsSync(path)) {
1578
- try {
1579
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1580
- writeFileSync(path, Buffer.from(shot.data, "base64"), { mode: 0o600 });
1581
- }
1582
- catch (error) {
1583
- return fail(`Error internal: couldn't save the reference: ${error instanceof Error ? error.message : String(error)}`);
1584
- }
1585
- return text(`Saved the reference picture "${name}" (${shot.width ?? "?"}x${shot.height ?? "?"}) in ${path}. Next time, compare_screen { name: "${name}" } shows what changed.`);
1920
+ return { kind: "error", line: describeError(error) };
1586
1921
  }
1587
- let diff;
1922
+ this.lastMaskElements = undefined;
1923
+ if (!SCRIPT_NAME.test(name))
1924
+ return { kind: "error", line: BAD_REFERENCE_NAME };
1925
+ const path = options.referencesDirs.map((dir) => join(dir, `${name}.jpg`)).find((candidate) => existsSync(candidate));
1926
+ if (path === undefined)
1927
+ return { kind: "missing", shot };
1928
+ let reference;
1588
1929
  try {
1589
- diff = diffImages({ mime: "image/jpeg", data: readFileSync(path).toString("base64") }, shot);
1930
+ reference = { mime: "image/jpeg", data: readFileSync(path).toString("base64") };
1590
1931
  }
1591
1932
  catch (error) {
1592
- return fail(`Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.`);
1933
+ // A reference that can't be read (no permission, a folder in its place) is a failed check, never a crash.
1934
+ return { kind: "error", line: `Error bad_params: the reference "${name}" can't be read (${error instanceof Error ? error.message : String(error)}). To start over, use update: true.` };
1593
1935
  }
1594
- if (diff.changedPixels === 0)
1595
- return text(`No visible change from the reference "${name}".`);
1596
- // Name the regions in points, with the elements they touch, from a fresh element list.
1597
- let scale = 1;
1598
- let elements = [];
1936
+ let refSize;
1937
+ let shotSize;
1599
1938
  try {
1600
- // `peek`: the agent isn't given this list, so its refs and its baseline for changes must stay as they are.
1601
- const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }));
1602
- if (tree.success && tree.data.screen) {
1603
- scale = diff.width / tree.data.screen.width;
1604
- elements = tree.data.elements;
1939
+ refSize = imageSize(reference);
1940
+ shotSize = imageSize(shot);
1941
+ }
1942
+ catch (error) {
1943
+ return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
1944
+ }
1945
+ if (refSize.width !== shotSize.width || refSize.height !== shotSize.height) {
1946
+ return {
1947
+ kind: "size",
1948
+ message: `the reference "${name}" is ${refSize.width}x${refSize.height} px and this screen is ${shotSize.width}x${shotSize.height}; take it at the same width, or update: true.`,
1949
+ };
1950
+ }
1951
+ let seen;
1952
+ const notes = [];
1953
+ const mask = [];
1954
+ if (options.ignore.length > 0) {
1955
+ seen = await this.peekElements(session.id);
1956
+ this.lastMaskElements = seen.elements;
1957
+ if (seen.scaleBase === undefined) {
1958
+ return { kind: "error", line: "Error internal: couldn't read the screen's elements to paint out what ignore names." };
1605
1959
  }
1960
+ const masked = ignoreRects(options.ignore, seen.elements, shotSize.width / seen.scaleBase, shotSize);
1961
+ mask.push(...masked.rects);
1962
+ notes.push(...masked.notes);
1606
1963
  }
1607
- catch {
1608
- // positions stay in pixels
1964
+ let diff;
1965
+ try {
1966
+ diff = diffImages(reference, shot, { mask });
1609
1967
  }
1968
+ catch (error) {
1969
+ return { kind: "error", line: `Error bad_params: ${error instanceof Error ? error.message : String(error)} To start over, use update: true.` };
1970
+ }
1971
+ // The pass bar compares the exact share; the rounded percent is only for showing (a few pixels round to 0%).
1972
+ const exactPercent = (diff.changedPixels / Math.max(1, diff.width * diff.height)) * 100;
1973
+ if (diff.changedPixels === 0 || exactPercent <= options.threshold) {
1974
+ return { kind: "match", percent: diff.changedPercent, notes };
1975
+ }
1976
+ // Name the regions in points, with the elements they touch.
1977
+ const found = seen ?? (await this.peekElements(session.id));
1978
+ const scale = found.scaleBase === undefined ? 1 : diff.width / found.scaleBase;
1610
1979
  const unit = scale === 1 ? "pixels" : "points";
1611
- const lines = [
1612
- `${diff.changedPercent}% of the screen changed from the reference "${name}", in ${diff.boxes.length} region${diff.boxes.length === 1 ? "" : "s"} (${unit}):`,
1613
- ];
1614
- for (const box of diff.boxes) {
1980
+ const regions = diff.boxes.map((box) => {
1615
1981
  const b = { x: box.x / scale, y: box.y / scale, width: box.width / scale, height: box.height / scale };
1616
1982
  // The smallest named elements there: a page-sized container overlaps every change.
1617
- const near = elements
1983
+ const near = found.elements
1618
1984
  .filter((e) => e.frame && e.frame.x < b.x + b.width && e.frame.x + e.frame.width > b.x && e.frame.y < b.y + b.height && e.frame.y + e.frame.height > b.y)
1619
1985
  .filter((e) => (e.text ?? "") !== "" || (e.label ?? "") !== "" || (e.testID ?? "") !== "")
1620
1986
  .map((e) => ({ e, area: (e.frame?.width ?? 0) * (e.frame?.height ?? 0) }))
@@ -1622,12 +1988,289 @@ export class TaplineTools {
1622
1988
  .sort((x, y) => x.area - y.area)
1623
1989
  .slice(0, 2)
1624
1990
  .map(({ e }) => describeElement(e));
1625
- lines.push(`- @${Math.round(b.x)},${Math.round(b.y)} ${Math.round(b.width)}x${Math.round(b.height)}${near.length > 0 ? `: ${near.join(", ")}` : ""}`);
1991
+ return { at: `@${Math.round(b.x)},${Math.round(b.y)} ${Math.round(b.width)}x${Math.round(b.height)}`, near };
1992
+ });
1993
+ const count = regions.length;
1994
+ const where = `in ${count} region${count === 1 ? "" : "s"} (${unit})`;
1995
+ const lines = [`Differs from the reference "${name}": ${diff.changedPercent}% changed, ${where}:`];
1996
+ for (const region of regions.slice(0, MAX_REGION_LINES)) {
1997
+ lines.push(`- ${region.at}${region.near.length > 0 ? `: ${region.near.join(", ")}` : ""}`);
1998
+ }
1999
+ if (count > MAX_REGION_LINES)
2000
+ lines.push(`(+${count - MAX_REGION_LINES} more regions)`);
2001
+ const listed = regions.slice(0, MAX_REGION_LINES).map((region) => region.at).join(", ");
2002
+ const reason = `${diff.changedPercent}% changed from the reference "${name}" (threshold ${options.threshold}%), ${where}: ${listed}${count > MAX_REGION_LINES ? `, +${count - MAX_REGION_LINES} more` : ""}.`;
2003
+ return { kind: "differs", percent: diff.changedPercent, lines, reason, image: diff.image, notes };
2004
+ }
2005
+ /** Why a check does not hold (for `run`, as a step or as `matches`), or null when it matches. */
2006
+ referenceReason(check, name) {
2007
+ switch (check.kind) {
2008
+ case "match":
2009
+ return null;
2010
+ case "missing":
2011
+ return { code: "expect_failed", message: `no reference "${name}" yet; save one with compare_screen { name: "${name}" }.` };
2012
+ case "size":
2013
+ return { code: "bad_params", message: check.message };
2014
+ case "error": {
2015
+ const { code, message } = parseError(check.line);
2016
+ return { code, message };
2017
+ }
2018
+ case "differs":
2019
+ return { code: "expect_failed", message: check.reason };
2020
+ }
2021
+ }
2022
+ /** Whether the screen matches the reference `name` now (`expect.matches`): null when it does, else the reason. */
2023
+ async matchMissed(name, threshold, ignore) {
2024
+ this.lastMaskElements = undefined;
2025
+ if (!SCRIPT_NAME.test(name))
2026
+ return parseError(BAD_REFERENCE_NAME).message;
2027
+ const session = this.host.current();
2028
+ if (session === undefined)
2029
+ return "the app isn't connected";
2030
+ const folder = this.referenceFolder(session);
2031
+ if (typeof folder !== "string")
2032
+ return parseError(folder.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message;
2033
+ if (this.replayContext?.update === true) {
2034
+ // --update-references: the check saves the screen into the script's folder, and holds.
2035
+ const saved = await this.saveReferenceHere(session, name, DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
2036
+ return saved.isError === true ? parseError(saved.content.map((block) => (block.type === "text" ? block.text : "")).join("\n")).message : null;
2037
+ }
2038
+ const check = await this.checkAgainstReference(session, name, {
2039
+ threshold,
2040
+ ignore,
2041
+ width: DEFAULT_SCREENSHOT_WIDTH,
2042
+ referencesDirs: this.referencesDirs(folder),
2043
+ });
2044
+ this.keepDiff(check, name);
2045
+ return this.referenceReason(check, name)?.message ?? null;
2046
+ }
2047
+ /** Where a check looks for `<name>.jpg`: the folder next to a replayed script first, then the app's own store. */
2048
+ referencesDirs(folder) {
2049
+ return this.replayContext !== null ? [this.replayContext.referencesDir, folder] : [folder];
2050
+ }
2051
+ /** In a replay, a failed check leaves its red picture at `<script>.diff/<name>.jpg`. */
2052
+ keepDiff(check, name) {
2053
+ if (this.replayContext === null || check.kind !== "differs" || !SCRIPT_NAME.test(name))
2054
+ return;
2055
+ const path = join(this.replayContext.diffDir, `${name}.jpg`);
2056
+ try {
2057
+ mkdirSync(dirname(path), { recursive: true });
2058
+ writeFileSync(path, Buffer.from(check.image.data, "base64"));
2059
+ this.diffSaved = path;
2060
+ }
2061
+ catch {
2062
+ // the failure still reads without the picture
2063
+ }
2064
+ }
2065
+ /** Takes the screen and saves it as the reference `name` in `dir`. */
2066
+ async saveReferenceHere(session, name, width, dir) {
2067
+ try {
2068
+ return this.writeReference(dir, name, await this.takeShot(session.id, width, 0.9));
2069
+ }
2070
+ catch (error) {
2071
+ return fail(describeError(error));
2072
+ }
2073
+ }
2074
+ writeReference(folder, name, shot) {
2075
+ // Every write checks the name itself, whatever the caller checked: no way out of the references folder.
2076
+ if (!SCRIPT_NAME.test(name))
2077
+ return fail(BAD_REFERENCE_NAME);
2078
+ const path = join(folder, `${name}.jpg`);
2079
+ try {
2080
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
2081
+ writeFileSync(path, Buffer.from(shot.data, "base64"), { mode: 0o600 });
2082
+ }
2083
+ catch (error) {
2084
+ return fail(`Error internal: couldn't save the reference: ${error instanceof Error ? error.message : String(error)}`);
2085
+ }
2086
+ let size = `${shot.width ?? "?"}x${shot.height ?? "?"}`;
2087
+ try {
2088
+ const real = imageSize(shot);
2089
+ size = `${real.width}x${real.height}`;
2090
+ }
2091
+ catch {
2092
+ // the app's own size stays
2093
+ }
2094
+ return text(`Saved the reference "${name}" (${size} px).`);
2095
+ }
2096
+ /** A `compare_screen` step of `run`: it never saves; it holds on a match and stops the batch otherwise. */
2097
+ async compareStep(args) {
2098
+ this.lastMaskElements = undefined;
2099
+ const session = this.host.current();
2100
+ if (session === undefined)
2101
+ return this.notConnected();
2102
+ const name = args.name.trim();
2103
+ if (!SCRIPT_NAME.test(name))
2104
+ return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
2105
+ const folder = this.referenceFolder(session);
2106
+ if (typeof folder !== "string")
2107
+ return folder;
2108
+ if (this.replayContext?.update === true) {
2109
+ return this.saveReferenceHere(session, name, args.width ?? DEFAULT_SCREENSHOT_WIDTH, this.replayContext.referencesDir);
2110
+ }
2111
+ const check = await this.checkAgainstReference(session, name, {
2112
+ threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
2113
+ ignore: args.ignore ?? [],
2114
+ width: args.width ?? DEFAULT_SCREENSHOT_WIDTH,
2115
+ referencesDirs: this.referencesDirs(folder),
2116
+ });
2117
+ this.keepDiff(check, name);
2118
+ const why = this.referenceReason(check, name);
2119
+ if (why !== null)
2120
+ return fail(`Error ${why.code}: ${why.message}`);
2121
+ if (check.kind !== "match")
2122
+ return fail("Error internal: the comparison gave no answer.");
2123
+ // A check that held inside `run` is kept in a recording too, with its options.
2124
+ const recorded = await this.recordCompare({
2125
+ name,
2126
+ ...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
2127
+ ...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
2128
+ ...(args.width !== undefined ? { width: args.width } : {}),
2129
+ });
2130
+ if (recorded !== null)
2131
+ return fail(recorded);
2132
+ return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
2133
+ }
2134
+ /**
2135
+ * Saves a visual check that held as a `compareScreen` step of the recording, when one is running: the name, and the
2136
+ * threshold, the ignore list (refs made stable) and the width when given. `update` is never written. Answers an
2137
+ * error line when an ignore ref can't be kept in a script, else null (also when nothing is recording).
2138
+ */
2139
+ async recordCompare(check) {
2140
+ if (this.recording === null || this.replaying)
2141
+ return null;
2142
+ const session = this.host.current();
2143
+ let kept = check.ignore;
2144
+ if (session !== undefined && kept !== undefined && kept.length > 0) {
2145
+ const stable = await this.stableIgnore(session.id, kept, this.lastMaskElements);
2146
+ if (typeof stable === "string")
2147
+ return stable;
2148
+ kept = stable;
2149
+ }
2150
+ if (this.recording === null)
2151
+ return null;
2152
+ this.recording.steps.push({
2153
+ method: "compareScreen",
2154
+ params: {
2155
+ name: check.name.trim(),
2156
+ ...(check.threshold !== undefined ? { threshold: check.threshold } : {}),
2157
+ ...(kept !== undefined && kept.length > 0 ? { ignore: kept } : {}),
2158
+ ...(check.width !== undefined ? { width: check.width } : {}),
2159
+ },
2160
+ });
2161
+ return null;
2162
+ }
2163
+ /**
2164
+ * `ignore` as a script keeps it (AGENT-TOOLS 1.9 §14.2): a ref becomes the element's id, else label, else text,
2165
+ * read from the list now. A string is the error answer when a ref can't be kept.
2166
+ */
2167
+ async stableIgnore(sessionId, ignore, known) {
2168
+ if (!ignore.some((item) => !("width" in item) && item.ref !== undefined))
2169
+ return ignore;
2170
+ // The elements the comparison painted out when it had them, so the script keeps exactly that mask.
2171
+ const seen = known !== undefined ? { elements: known } : await this.peekElements(sessionId);
2172
+ const kept = [];
2173
+ for (const item of ignore) {
2174
+ if ("width" in item || item.ref === undefined) {
2175
+ kept.push(item);
2176
+ continue;
2177
+ }
2178
+ const element = seen.elements.find((e) => e.ref === item.ref);
2179
+ if (element === undefined)
2180
+ return `Error bad_params: ignore ${item.ref} isn't on the screen, so it can't be saved in a script.`;
2181
+ // When several elements share the name, the saved item keeps which one it was (`index`, counted as ignoreRects
2182
+ // counts: elements with a frame, in list order), so a replay paints out that one and not all of them.
2183
+ const framed = seen.elements.filter((e) => e.frame !== undefined);
2184
+ const withIndex = (saved, same) => {
2185
+ const matching = framed.filter(same);
2186
+ const at = matching.indexOf(element);
2187
+ return matching.length > 1 && at >= 0 ? { ...saved, index: at } : saved;
2188
+ };
2189
+ if (typeof element.testID === "string" && element.testID !== "") {
2190
+ const id = element.testID;
2191
+ kept.push(withIndex({ id }, (e) => e.testID === id));
2192
+ }
2193
+ else if (typeof element.label === "string" && element.label !== "") {
2194
+ const label = element.label.toLowerCase();
2195
+ kept.push(withIndex({ label: element.label }, (e) => labelOfElement(e).toLowerCase() === label));
2196
+ }
2197
+ else if (typeof element.text === "string" && element.text !== "") {
2198
+ const text = element.text.toLowerCase();
2199
+ kept.push(withIndex({ text: element.text }, (e) => (e.text ?? "").toLowerCase() === text));
2200
+ }
2201
+ else {
2202
+ return `Error bad_params: ignore ${describeElement(element)} has no id, label or text, so a script couldn't find it again; give it a testID, or ignore a rect {x, y, width, height}.`;
2203
+ }
2204
+ }
2205
+ return kept;
2206
+ }
2207
+ async compareScreen(args) {
2208
+ // Each check starts with no mask list: an update, or a first save, never records from an earlier comparison.
2209
+ this.lastMaskElements = undefined;
2210
+ const recording = this.recording !== null && !this.replaying;
2211
+ const session = this.host.current();
2212
+ // An ignore ref that a script couldn't find again is refused before anything is compared.
2213
+ if (recording && session !== undefined && args.ignore !== undefined && args.ignore.length > 0) {
2214
+ const stable = await this.stableIgnore(session.id, args.ignore);
2215
+ if (typeof stable === "string")
2216
+ return fail(stable);
2217
+ }
2218
+ const answer = await this.compareScreenNow(args);
2219
+ if (recording && answer.isError !== true) {
2220
+ // `update` is never written: the script checks against the reference it carries.
2221
+ const recorded = await this.recordCompare({
2222
+ name: args.name,
2223
+ ...(args.threshold !== undefined ? { threshold: args.threshold } : {}),
2224
+ ...(args.ignore !== undefined ? { ignore: args.ignore } : {}),
2225
+ ...(args.width !== undefined ? { width: args.width } : {}),
2226
+ });
2227
+ if (recorded !== null)
2228
+ return fail(recorded);
2229
+ }
2230
+ return answer;
2231
+ }
2232
+ async compareScreenNow(args) {
2233
+ const session = this.host.current();
2234
+ if (session === undefined)
2235
+ return this.notConnected();
2236
+ const name = args.name.trim();
2237
+ if (!SCRIPT_NAME.test(name))
2238
+ return fail("Error bad_params: name the reference with letters, digits, '.', '_' and '-'.");
2239
+ const folder = this.referenceFolder(session);
2240
+ if (typeof folder !== "string")
2241
+ return folder;
2242
+ const width = args.width ?? DEFAULT_SCREENSHOT_WIDTH;
2243
+ const save = (shot) => this.writeReference(folder, name, shot);
2244
+ if (args.update === true) {
2245
+ try {
2246
+ return save(await this.takeShot(session.id, width, 0.9));
2247
+ }
2248
+ catch (error) {
2249
+ return fail(describeError(error));
2250
+ }
2251
+ }
2252
+ const check = await this.checkAgainstReference(session, name, {
2253
+ threshold: args.threshold ?? DEFAULT_MATCH_THRESHOLD,
2254
+ ignore: args.ignore ?? [],
2255
+ width,
2256
+ referencesDirs: [folder],
2257
+ });
2258
+ switch (check.kind) {
2259
+ case "missing":
2260
+ return save(check.shot);
2261
+ case "error":
2262
+ return fail(check.line);
2263
+ case "size":
2264
+ return fail(`Error bad_params: ${check.message}`);
2265
+ case "match":
2266
+ return text([`Matches the reference "${name}": ${check.percent}% changed.`, ...check.notes.map((n) => `Note: ${n}`)].join("\n"));
2267
+ case "differs": {
2268
+ const lines = [...check.lines, "The image shows the screen now, faded, with the changed pixels in red.", ...check.notes.map((n) => `Note: ${n}`)];
2269
+ const content = [];
2270
+ this.deliver(session.id, check.image, lines, content);
2271
+ return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
2272
+ }
1626
2273
  }
1627
- lines.push("The image shows the screen now, faded, with the changed pixels in red.");
1628
- const content = [];
1629
- this.deliver(session.id, diff.image, lines, content);
1630
- return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
1631
2274
  }
1632
2275
  async filmstrip(args) {
1633
2276
  const session = this.host.current();
@@ -1644,11 +2287,19 @@ export class TaplineTools {
1644
2287
  // A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
1645
2288
  // The tap is a touch like any other: the safety policy decides first.
1646
2289
  const pressParams = { target: toWireTarget(args.tap) };
1647
- const gate = await this.policyGate(session, "tap", "press", pressParams, listsFor(this.policy, session.app));
1648
- if ("stop" in gate)
1649
- return gate.stop;
1650
- frames.push(await this.takeShot(session.id, frameWidth, 0.8));
1651
- await this.host.request("press", pressParams, { sessionId: session.id });
2290
+ // The policy check, the "before" frame and the tap are one turn of the live view's queue: a pause refuses
2291
+ // it, a person's input goes first, and the screen can't change between the check and the tap (PR #4 review).
2292
+ const turn = async () => {
2293
+ const gate = await this.policyGate(session, "tap", "press", pressParams, listsFor(this.policy, session.app));
2294
+ if ("stop" in gate)
2295
+ return gate.stop;
2296
+ frames.push(await this.takeShot(session.id, frameWidth, 0.8));
2297
+ await this.host.request("press", pressParams, { sessionId: session.id });
2298
+ return null;
2299
+ };
2300
+ const stopped = this.options.control ? await this.options.control.runAgent(turn) : await turn();
2301
+ if (stopped !== null)
2302
+ return stopped;
1652
2303
  }
1653
2304
  for (let i = frames.length; i < count; i++) {
1654
2305
  const at = Date.now();
@@ -1722,7 +2373,10 @@ export class TaplineTools {
1722
2373
  }
1723
2374
  this.recording = null;
1724
2375
  const count = script.steps.length;
1725
- const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}.`];
2376
+ // AGENT-TOOLS 1.9 §14.3: the references the steps use travel with the script.
2377
+ const copied = this.copyReferences(script.steps, recording.app, path);
2378
+ const withPictures = copied === null ? "" : `, and ${copied.count} reference picture${copied.count === 1 ? "" : "s"} to ${copied.folder}`;
2379
+ const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}${withPictures}.`];
1726
2380
  const ref = refStep(script.steps);
1727
2381
  if (ref !== null) {
1728
2382
  lines.push(`Note: step ${ref + 1} targets a ref, which won't exist on a new run, so replay refuses the script. Give that element a testID, or record it again by its text or label.`);
@@ -1738,6 +2392,41 @@ export class TaplineTools {
1738
2392
  }
1739
2393
  }
1740
2394
  }
2395
+ /** `<script dir>/<script name>.references` and `.diff` for a script file. */
2396
+ scriptSideFolder(path, kind) {
2397
+ return join(dirname(path), `${basename(path).replace(/\.json$/i, "")}.${kind}`);
2398
+ }
2399
+ /** Copies each reference the steps use from the app's store next to the script; null when there are none. */
2400
+ copyReferences(steps, app, scriptPath) {
2401
+ const dir = this.options.baselinesDir;
2402
+ if (dir === undefined)
2403
+ return null;
2404
+ const names = new Set();
2405
+ for (const step of steps) {
2406
+ const p = step;
2407
+ if (step.tool === "compare_screen" && typeof p["name"] === "string" && SCRIPT_NAME.test(p["name"]))
2408
+ names.add(p["name"]);
2409
+ if (step.expect?.matches !== undefined && SCRIPT_NAME.test(step.expect.matches))
2410
+ names.add(step.expect.matches);
2411
+ }
2412
+ const store = join(dir, (app.id ?? app.name).replace(/[^\w.-]/g, "_"));
2413
+ const folder = this.scriptSideFolder(scriptPath, "references");
2414
+ let count = 0;
2415
+ for (const name of names) {
2416
+ const from = join(store, `${name}.jpg`);
2417
+ if (!existsSync(from))
2418
+ continue;
2419
+ try {
2420
+ mkdirSync(folder, { recursive: true });
2421
+ copyFileSync(from, join(folder, `${name}.jpg`));
2422
+ count++;
2423
+ }
2424
+ catch {
2425
+ // the script still saved; the picture stays in the store
2426
+ }
2427
+ }
2428
+ return count === 0 ? null : { count, folder };
2429
+ }
1741
2430
  scriptList() {
1742
2431
  const dir = this.options.scriptsDir;
1743
2432
  if (dir === undefined || !existsSync(dir))
@@ -1756,7 +2445,7 @@ export class TaplineTools {
1756
2445
  }
1757
2446
  return lines.length === 0 ? "No saved scripts." : ["Saved scripts:", ...lines].join("\n");
1758
2447
  }
1759
- async replay(args) {
2448
+ async replay(args, options = {}) {
1760
2449
  const name = (args.name ?? "").trim();
1761
2450
  const given = (args.path ?? "").trim();
1762
2451
  if (name === "" && given === "")
@@ -1800,6 +2489,11 @@ export class TaplineTools {
1800
2489
  notes.push("Note: the app hadn't reconnected after starting fresh when the steps began.");
1801
2490
  }
1802
2491
  this.replaying = true;
2492
+ this.replayContext = {
2493
+ referencesDir: this.scriptSideFolder(path, "references"),
2494
+ diffDir: this.scriptSideFolder(path, "diff"),
2495
+ update: options.updateReferences === true,
2496
+ };
1803
2497
  try {
1804
2498
  const answer = await this.run({ steps: script.steps, look: args.look, screenshot: args.screenshot });
1805
2499
  if (notes.length === 0)
@@ -1811,6 +2505,7 @@ export class TaplineTools {
1811
2505
  }
1812
2506
  finally {
1813
2507
  this.replaying = false;
2508
+ this.replayContext = null;
1814
2509
  }
1815
2510
  }
1816
2511
  async withLook(session, line, screenshot = false) {
@@ -2294,37 +2989,213 @@ export class TaplineTools {
2294
2989
  // fall through with an empty list
2295
2990
  }
2296
2991
  }
2297
- const last = entries.reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
2298
- const footer = last !== undefined ? `\n(latest ts ${last}; pass since=${last} for newer lines)` : "";
2299
- return text(`${formatLogs(entries)}${footer}`);
2992
+ const newest = entries.reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
2993
+ const query = { since: args.since, level: args.level, grep: args.grep, limit: args.limit };
2994
+ return text(formatLogAnswer(filterLogs(entries, query), query, newest ?? args.since ?? 0));
2995
+ }
2996
+ /** The newest fatal JS error of the app (`all`: the last 5). Reading them tells the agent what the notes did. */
2997
+ crash(args = {}) {
2998
+ // The app being driven, else the one that stopped last. Never every app's crashes at once.
2999
+ const known = this.host.current() ?? this.host.lastCrashEvent()?.of;
3000
+ if (known === undefined)
3001
+ return text(NO_CRASH_TEXT);
3002
+ // Reading them tells the agent what the notes of this app said; other apps' stay pending.
3003
+ this.notedCrashSeqs.set(identityOf(known), this.host.crashMark());
3004
+ const crashes = this.host.crashes(known);
3005
+ if (crashes.length === 0)
3006
+ return text(NO_CRASH_TEXT);
3007
+ return text(crashes.slice(0, args.all === true ? 5 : 1).map(formatCrash).join("\n\n"));
2300
3008
  }
2301
3009
  async reload(args) {
2302
3010
  const session = this.host.current();
2303
3011
  if (session === undefined)
2304
3012
  return this.notConnected();
2305
3013
  try {
2306
- await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
2307
- }
2308
- catch (error) {
2309
- // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
2310
- const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
2311
- if (!worked)
2312
- return fail(describeError(error));
3014
+ try {
3015
+ // The app goes away on purpose: that is no crash.
3016
+ this.host.expectEnd(session.id);
3017
+ await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
3018
+ }
3019
+ catch (error) {
3020
+ // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
3021
+ const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
3022
+ if (!worked)
3023
+ return fail(describeError(error));
3024
+ }
3025
+ if (args.look === false)
3026
+ return text("Reload requested.");
3027
+ const deadline = Date.now() + this.reloadWaitMs;
3028
+ for (;;) {
3029
+ const now = this.host.current();
3030
+ if (now !== undefined && now.id !== session.id)
3031
+ break;
3032
+ if (Date.now() > deadline)
3033
+ return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
3034
+ await sleep(100);
3035
+ }
2313
3036
  }
2314
- if (args.look === false)
2315
- return text("Reload requested.");
2316
- const deadline = Date.now() + this.reloadWaitMs;
2317
- for (;;) {
2318
- const now = this.host.current();
2319
- if (now !== undefined && now.id !== session.id)
2320
- break;
2321
- if (Date.now() > deadline)
2322
- return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
2323
- await sleep(100);
3037
+ finally {
3038
+ // Whatever happened, the wait is over: a reload that "worked" without the app going away must not
3039
+ // leave a later, real crash of this session explained.
3040
+ this.host.expectEnd(session.id, false);
2324
3041
  }
2325
3042
  await sleep(this.reloadSettleMs);
2326
3043
  return this.withLook(this.host.current() ?? session, "Reloaded; the app reconnected.");
2327
3044
  }
3045
+ // -- performance (AGENT-TOOLS §15.3)
3046
+ /**
3047
+ * `launch`: a JavaScript reload (development builds), timed from the request to the new session's first
3048
+ * settled screen. Stops with the answer when the reload can't be made or the app doesn't come back.
3049
+ */
3050
+ async timedReload(session) {
3051
+ const started = Date.now();
3052
+ let fresh;
3053
+ try {
3054
+ try {
3055
+ // The app goes away on purpose: that is no crash.
3056
+ this.host.expectEnd(session.id);
3057
+ await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
3058
+ }
3059
+ catch (error) {
3060
+ // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
3061
+ const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
3062
+ if (!worked) {
3063
+ if (error instanceof TaplineError && error.code === "unsupported") {
3064
+ return { stop: fail(`Error unsupported: launch is a JavaScript reload, and only development builds can do one (${error.message}).`) };
3065
+ }
3066
+ return { stop: fail(describeError(error)) };
3067
+ }
3068
+ }
3069
+ const deadline = Date.now() + this.reloadWaitMs;
3070
+ // Only the same app on the same phone, back as a new session, counts: another connected app that became
3071
+ // current meanwhile is never measured as the reload (Codex review of 1.3.0).
3072
+ const same = identityOf(session);
3073
+ while (fresh === undefined) {
3074
+ const back = this.host.sessions().find((s) => s.id !== session.id && identityOf(s) === same);
3075
+ if (back !== undefined) {
3076
+ fresh = back;
3077
+ // and the agent goes on driving it, not whichever app the host picked when the old session dropped
3078
+ if (this.host.current()?.id !== back.id)
3079
+ this.host.select(back.id);
3080
+ }
3081
+ else if (Date.now() > deadline) {
3082
+ return { stop: fail(`Error timeout: the app hadn't reconnected ${Number((this.reloadWaitMs / 1000).toFixed(1))} s after the reload; try look in a few seconds.`) };
3083
+ }
3084
+ else
3085
+ await sleep(100);
3086
+ }
3087
+ }
3088
+ finally {
3089
+ // The wait is over, whatever happened: a reload that "worked" without the app going away must not leave
3090
+ // a later crash of this session explained away.
3091
+ this.host.expectEnd(session.id, false);
3092
+ }
3093
+ // The first settled screen: the app waits until React hasn't committed for a moment (§6).
3094
+ // The first settled screen: a launch time is only reported when the app said it settled.
3095
+ try {
3096
+ await this.host.request("perfSettle", {}, { sessionId: fresh.id, timeoutMs: 8000 });
3097
+ }
3098
+ catch (error) {
3099
+ return { stop: fail(describeError(error)) };
3100
+ }
3101
+ return { session: fresh, seconds: (Date.now() - started) / 1000 };
3102
+ }
3103
+ /** One measuring window in the app: start, (the tap,) wait out `seconds`, stop. */
3104
+ async measureWindow(session, seconds, press) {
3105
+ const id = { sessionId: session.id };
3106
+ // The window's number: the stop names it, so a call never takes another call's numbers (an older SDK has none).
3107
+ let stopParams = {};
3108
+ try {
3109
+ const started = await this.host.request("perfStart", {}, id);
3110
+ const windowId = typeof started === "object" && started !== null ? started.id : undefined;
3111
+ if (typeof windowId === "number")
3112
+ stopParams = { id: windowId };
3113
+ }
3114
+ catch (error) {
3115
+ if (error instanceof TaplineError && error.code === "unsupported") {
3116
+ throw new TaplineError("unsupported", "this app's Tapline is too old for performance; update react-native-tapline.");
3117
+ }
3118
+ throw error;
3119
+ }
3120
+ const started = Date.now();
3121
+ try {
3122
+ // The caller holds the live view's queue for the whole window when there is a tap (see performance).
3123
+ if (press !== undefined)
3124
+ await this.host.request("press", press, id);
3125
+ const rest = seconds * 1000 - (Date.now() - started);
3126
+ if (rest > 0)
3127
+ await sleep(rest);
3128
+ }
3129
+ catch (error) {
3130
+ await this.host.request("perfStop", stopParams, id).catch(() => undefined);
3131
+ throw error;
3132
+ }
3133
+ const parsed = perfResultSchema.safeParse(await this.host.request("perfStop", stopParams, id));
3134
+ if (!parsed.success)
3135
+ throw new TaplineError("internal", "the app returned performance numbers the host could not read.");
3136
+ return parsed.data;
3137
+ }
3138
+ async performance(args = {}) {
3139
+ this.lastPerf = undefined;
3140
+ const session = this.host.current();
3141
+ if (session === undefined)
3142
+ return this.notConnected();
3143
+ if (args.launch === true && args.tap !== undefined) {
3144
+ return fail("Error bad_params: launch reloads the app and tap taps in it; give one of them.");
3145
+ }
3146
+ const seconds = args.seconds ?? 3;
3147
+ try {
3148
+ let measure;
3149
+ if (args.launch === true) {
3150
+ const launched = await this.timedReload(session);
3151
+ if ("stop" in launched)
3152
+ return launched.stop;
3153
+ measure = { result: await this.measureWindow(launched.session, seconds), launchS: launched.seconds };
3154
+ }
3155
+ else {
3156
+ let press;
3157
+ if (args.tap !== undefined) {
3158
+ // The tap is a touch like any other: the safety policy decides first (as for filmstrip).
3159
+ press = { target: toWireTarget(args.tap) };
3160
+ const stale = this.staleRef(press);
3161
+ if (stale !== null)
3162
+ return stale;
3163
+ }
3164
+ // With a tap, the policy check and the whole window are one turn of the live view's queue: a pause refuses
3165
+ // it, a person's input goes first and waits for the window, and the screen can't change between the check
3166
+ // and the tap (Codex and CodeRabbit reviews of 1.3.0).
3167
+ const tapped = press;
3168
+ const window = async () => {
3169
+ if (tapped !== undefined) {
3170
+ const gate = await this.policyGate(session, "tap", "press", tapped, listsFor(this.policy, session.app));
3171
+ if ("stop" in gate)
3172
+ return { stop: gate.stop };
3173
+ }
3174
+ return { result: await this.measureWindow(session, seconds, tapped) };
3175
+ };
3176
+ const outcome = tapped !== undefined && this.options.control ? await this.options.control.runAgent(window) : await window();
3177
+ if ("stop" in outcome)
3178
+ return outcome.stop;
3179
+ measure = { result: outcome.result };
3180
+ }
3181
+ this.lastPerf = measure;
3182
+ if (this.recording !== null && !this.replaying) {
3183
+ this.recording.steps.push({
3184
+ method: "performance",
3185
+ params: {
3186
+ ...(args.launch === true ? { launch: true } : {}),
3187
+ ...(args.seconds !== undefined ? { seconds: args.seconds } : {}),
3188
+ ...(args.tap !== undefined ? { tap: args.tap } : {}),
3189
+ },
3190
+ });
3191
+ }
3192
+ const tapWords = args.tap === undefined ? undefined : targetWords(args.tap);
3193
+ return text([perfFirstLine(measure, tapWords), ...formatPerfLines(measure.result), ...this.takePersonNote()].join("\n"));
3194
+ }
3195
+ catch (error) {
3196
+ return fail(describeError(error));
3197
+ }
3198
+ }
2328
3199
  async wait(args) {
2329
3200
  const session = this.host.current();
2330
3201
  if (session === undefined)