tapline-host 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/actionlog.d.ts +43 -0
  2. package/dist/actionlog.js +62 -0
  3. package/dist/actionlog.js.map +1 -0
  4. package/dist/audit.d.ts +44 -0
  5. package/dist/audit.js +60 -0
  6. package/dist/audit.js.map +1 -0
  7. package/dist/cli.js +79 -17
  8. package/dist/cli.js.map +1 -1
  9. package/dist/confirm.d.ts +69 -0
  10. package/dist/confirm.js +120 -0
  11. package/dist/confirm.js.map +1 -0
  12. package/dist/format.d.ts +8 -1
  13. package/dist/format.js +53 -11
  14. package/dist/format.js.map +1 -1
  15. package/dist/init.d.ts +59 -0
  16. package/dist/init.js +475 -0
  17. package/dist/init.js.map +1 -0
  18. package/dist/inspect.d.ts +75 -0
  19. package/dist/inspect.js +127 -0
  20. package/dist/inspect.js.map +1 -0
  21. package/dist/mcp.d.ts +4 -1
  22. package/dist/mcp.js +56 -2
  23. package/dist/mcp.js.map +1 -1
  24. package/dist/policy.d.ts +42 -0
  25. package/dist/policy.js +74 -0
  26. package/dist/policy.js.map +1 -0
  27. package/dist/run.d.ts +47 -0
  28. package/dist/run.js +88 -0
  29. package/dist/run.js.map +1 -0
  30. package/dist/script.d.ts +68 -0
  31. package/dist/script.js +142 -0
  32. package/dist/script.js.map +1 -0
  33. package/dist/server.d.ts +39 -0
  34. package/dist/server.js +120 -0
  35. package/dist/server.js.map +1 -1
  36. package/dist/tool-text-data.d.ts +24 -7
  37. package/dist/tool-text-data.js +24 -7
  38. package/dist/tool-text-data.js.map +1 -1
  39. package/dist/tool-text.d.ts +1 -1
  40. package/dist/tools.d.ts +152 -6
  41. package/dist/tools.js +905 -118
  42. package/dist/tools.js.map +1 -1
  43. package/dist/viewer-page.js +1 -1
  44. package/dist/viewer-page.js.map +1 -1
  45. package/dist/viewer.d.ts +3 -0
  46. package/dist/viewer.js +21 -4
  47. package/dist/viewer.js.map +1 -1
  48. package/package.json +2 -2
  49. package/skills/tapline/SKILL.md +41 -11
  50. package/skills/tapline/references/setup.md +24 -2
package/dist/tools.js CHANGED
@@ -6,19 +6,26 @@ import { annotateScreenshot } from "./annotate.js";
6
6
  import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
7
7
  import { composeFilmstrip, diffImages } from "./images.js";
8
8
  import { alertNotes, describeElement, formatChanges, formatElement, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
+ import { formatInspect, inspectResultSchema } from "./inspect.js";
10
+ import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
11
+ import { ActionLog, loggedTarget } from "./actionlog.js";
12
+ import { Confirmations } from "./confirm.js";
13
+ import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
14
+ 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";
9
16
  import { PAIR_CODE_TTL_MS, forgetLink, pairingLink } from "./pairing.js";
10
17
  import { elementSchema, logEntrySchema, screenshotResultSchema, treeResultSchema, } from "./protocol.js";
11
18
  import { TaplineError } from "./server.js";
12
19
  // ---- input schemas ----------------------------------------------------------
13
20
  export const targetSchema = z
14
21
  .object({
15
- ref: z.string().optional().describe('Element ref from your latest list, e.g. "e12"'),
16
- id: z.string().optional().describe("Exact identifier (React Native testID, the iOS accessibility identifier)"),
17
- label: z.string().optional().describe("Accessibility label, exact, ignoring case"),
18
- text: z.string().optional().describe("Visible text: an exact match first (ignoring case), then a substring"),
19
- index: z.number().int().min(0).optional().describe("With id, label or text: pick the Nth match (0-based, reading order)"),
20
- x: z.number().optional().describe("X in points; use together with y"),
21
- y: z.number().optional().describe("Y in points; use together with x"),
22
+ ref: z.string().optional().describe('From your latest list, e.g. "e12"'),
23
+ id: z.string().optional().describe("The testID, exact"),
24
+ label: z.string().optional().describe("Accessibility label, exact"),
25
+ text: z.string().optional().describe("Visible text: exact first, then part"),
26
+ index: z.number().int().min(0).optional().describe("The Nth match of id, label or text (0-based)"),
27
+ x: z.number().optional().describe("Points, with y"),
28
+ y: z.number().optional().describe("Points, with x"),
22
29
  })
23
30
  .strict()
24
31
  .superRefine((value, ctx) => {
@@ -40,22 +47,29 @@ export const targetSchema = z
40
47
  ctx.addIssue({ code: "custom", message: "index works with id, label or text." });
41
48
  }
42
49
  });
43
- const targetField = targetSchema.describe("Which element: exactly one of {ref}, {id}, {label}, {text} or {x, y}; add index to pick among several id, label or text matches. Prefer ref or id from your latest list.");
50
+ const targetField = targetSchema.describe("Exactly one of ref, id, label, text or x and y");
44
51
  const lookField = z
45
52
  .boolean()
46
53
  .optional()
47
- .describe("Set false to answer only the first line, with no change list. Call look when you need the screen.");
48
- const screenshotField = z.boolean().optional().describe("Add a screenshot to the answer (default false)");
54
+ .describe("false: only the first line; the next answer's changes then cover this action too");
55
+ const screenshotField = z.boolean().optional().describe("Add a screenshot (default false)");
56
+ const dryRunField = z
57
+ .boolean()
58
+ .optional()
59
+ .describe("true: run every check and say what would happen; touch nothing");
60
+ /** `index` written beside `target` rather than in it: agents do that, and it means the same. */
61
+ const indexField = z.number().int().min(0).optional().describe("Same as target.index");
49
62
  const forceField = z
50
63
  .boolean()
51
64
  .optional()
52
- .describe("Skip the off-screen, covered, under-the-tab-bar and behind-an-alert checks (never disabled). Default false; prefer scrolling or dismissing what covers it, since a person would have to.");
65
+ .describe("Skip the reach checks (never disabled), only when you've decided a refusal is wrong");
53
66
  export const toolShapes = {
54
67
  apps: {},
55
68
  open_app: { app: z.string().describe("The app's session id, app id or name, as apps lists them") },
56
69
  pair: {
57
70
  scheme: z
58
71
  .string()
72
+ .optional()
59
73
  .describe('The app\'s URL scheme ("scheme" in app.json or app.config), e.g. "myapp"; for an app running in Expo Go, the address Expo prints, e.g. "exp://192.168.1.20:8081"'),
60
74
  },
61
75
  look: {
@@ -73,9 +87,17 @@ export const toolShapes = {
73
87
  .describe("Draw each actionable element's box and ref (e12) on the screenshot, so whoever views it can name elements by ref"),
74
88
  },
75
89
  check_layout: {},
76
- tap: { target: targetField, force: forceField, look: lookField, screenshot: screenshotField },
90
+ audit: {
91
+ types: z
92
+ .array(z.enum(AUDIT_TYPES))
93
+ .optional()
94
+ .describe("Which checks to run (default all): contrast, hit_region, description, dynamic_type, clipped_text, trait, element_detection, overlap, duplicate_id"),
95
+ },
96
+ inspect: { target: targetField },
97
+ tap: { target: targetField, index: indexField, force: forceField, dry_run: dryRunField, look: lookField, screenshot: screenshotField },
77
98
  long_press: {
78
99
  target: targetField,
100
+ index: indexField,
79
101
  seconds: z
80
102
  .number()
81
103
  .min(0.1)
@@ -83,15 +105,18 @@ export const toolShapes = {
83
105
  .optional()
84
106
  .describe("How long to hold (default 1.0). Tapline calls the handler, so it doesn't wait"),
85
107
  force: forceField,
108
+ dry_run: dryRunField,
86
109
  look: lookField,
87
110
  screenshot: screenshotField,
88
111
  },
89
112
  type_text: {
90
113
  text: z.string().describe("Text to enter"),
91
114
  target: targetField.optional().describe("Input to type into; default: the focused input, else the only input on screen (else ambiguous; none at all is not_found)"),
115
+ index: indexField,
92
116
  replace: z.boolean().optional().describe("Replace the current text (default true); false appends"),
93
117
  submit: z.boolean().optional().describe("Also submit (like pressing return) after typing (default false)"),
94
118
  force: forceField,
119
+ dry_run: dryRunField,
95
120
  look: lookField,
96
121
  screenshot: screenshotField,
97
122
  },
@@ -105,13 +130,16 @@ export const toolShapes = {
105
130
  target: targetField
106
131
  .optional()
107
132
  .describe('Scroll view to scroll, or an element inside it; default: the largest one on screen. With to: "target", the element to bring onto the screen'),
133
+ dry_run: dryRunField,
108
134
  look: lookField,
109
135
  screenshot: screenshotField,
110
136
  },
111
137
  toggle: {
112
138
  target: targetField,
139
+ index: indexField,
113
140
  value: z.boolean().optional().describe("Desired state; omit to flip"),
114
141
  force: forceField,
142
+ dry_run: dryRunField,
115
143
  look: lookField,
116
144
  screenshot: screenshotField,
117
145
  },
@@ -120,13 +148,14 @@ export const toolShapes = {
120
148
  look: lookField,
121
149
  screenshot: screenshotField,
122
150
  },
123
- back: { look: lookField, screenshot: screenshotField },
151
+ back: { dry_run: dryRunField, look: lookField, screenshot: screenshotField },
124
152
  swipe: {
125
153
  x0: z.number().describe("Where the finger starts, in points from the left"),
126
154
  y0: z.number().describe("Where the finger starts, in points from the top"),
127
155
  x1: z.number().describe("Where it lifts, in points from the left"),
128
156
  y1: z.number().describe("Where it lifts, in points from the top"),
129
157
  seconds: z.number().min(0.05).max(5).optional().describe("How long the drag takes (default 0.35)"),
158
+ dry_run: dryRunField,
130
159
  look: lookField,
131
160
  screenshot: screenshotField,
132
161
  },
@@ -155,13 +184,20 @@ export const toolShapes = {
155
184
  },
156
185
  viewer: {},
157
186
  record: {
187
+ start: z.boolean().optional().describe("Start recording: every action that succeeds from now on becomes a step"),
188
+ stop: z.boolean().optional().describe("Stop and save the script (to path, else to the scripts folder under name)"),
189
+ name: z.string().optional().describe("The script's name: letters, digits, '.', '_' and '-'"),
190
+ path: z.string().optional().describe("Where stop saves the script file (optional)"),
158
191
  action: z
159
192
  .enum(["start", "stop", "status", "discard"])
160
- .describe("start a recording (needs name), stop and save it, see the status, or discard it"),
161
- name: z.string().optional().describe("Script name for start: letters, digits, '.', '_' and '-'"),
193
+ .optional()
194
+ .describe("Older form: start, stop, status or discard"),
162
195
  },
163
196
  replay: {
164
- name: z.string().optional().describe("The script to replay; omit to list saved scripts"),
197
+ name: z.string().optional().describe("A saved script's name; omit name and path to list saved scripts"),
198
+ path: z.string().optional().describe("A script file to replay (the shared agent-tools-script format)"),
199
+ look: lookField,
200
+ screenshot: screenshotField,
165
201
  },
166
202
  wait_for: {
167
203
  text: z.string().optional().describe("Wait until this visible text is on screen (or gone)"),
@@ -178,6 +214,20 @@ export const toolShapes = {
178
214
  },
179
215
  reload: { look: lookField },
180
216
  wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
217
+ run: {
218
+ steps: z
219
+ .array(stepSchema)
220
+ .min(1)
221
+ .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).'),
223
+ trace: z.boolean().optional().describe("Also list each step that ran, one line each"),
224
+ dry_run: z
225
+ .boolean()
226
+ .optional()
227
+ .describe("Check every step's parameters and resolve step 1 (later steps depend on what it does), then touch nothing"),
228
+ look: lookField,
229
+ screenshot: screenshotField,
230
+ },
181
231
  };
182
232
  /**
183
233
  * Under this many points from its first point, a pointer path is a tap or a hold, not a drag.
@@ -341,6 +391,10 @@ function text(message) {
341
391
  function fail(message) {
342
392
  return { content: [{ type: "text", text: message }], isError: true };
343
393
  }
394
+ /** The target with an `index` that was written beside it moved inside (its own index wins). */
395
+ function withIndex(target, index) {
396
+ return index !== undefined && target.index === undefined ? { ...target, index } : target;
397
+ }
344
398
  function toWireTarget(target) {
345
399
  const withIndex = (t) => target.index !== undefined ? { ...t, index: target.index } : t;
346
400
  if (target.ref !== undefined)
@@ -421,12 +475,75 @@ export function describeError(error) {
421
475
  }
422
476
  return `Error internal: ${error instanceof Error ? error.message : String(error)}`;
423
477
  }
478
+ /** Ends a guarded call inside the agent's turn with the policy's answer (nothing was touched). */
479
+ class PolicyStop extends Error {
480
+ answer;
481
+ constructor(answer) {
482
+ super("stopped by the policy");
483
+ this.answer = answer;
484
+ }
485
+ }
486
+ /** Adds a note line to the end of an answer's first text block. */
487
+ function appendNote(answer, note) {
488
+ const [top, ...rest] = answer.content;
489
+ if (top === undefined || top.type !== "text")
490
+ return answer;
491
+ return { ...answer, content: [{ type: "text", text: [top.text.trimEnd(), note].join("\n") }, ...rest] };
492
+ }
493
+ /** An app's dry-run answer (AGENT-TOOLS 1.8 §11.4). */
494
+ const dryRunResultSchema = z.object({
495
+ element: elementSchema.nullish(),
496
+ dryRun: z.literal(true),
497
+ point: z.object({ x: z.number(), y: z.number() }).optional(),
498
+ value: z.unknown().optional(),
499
+ });
500
+ /** For an app without dry runs: every element on its list the target could mean. */
501
+ function candidatesFor(target, elements) {
502
+ if (typeof target !== "object" || target === null)
503
+ return [];
504
+ const t = target;
505
+ const lower = (value) => (typeof value === "string" ? value.toLowerCase() : "");
506
+ const x = t["x"];
507
+ const y = t["y"];
508
+ let found;
509
+ if (typeof t["ref"] === "string")
510
+ found = elements.filter((e) => e.ref === t["ref"]);
511
+ else if (typeof t["testID"] === "string")
512
+ found = elements.filter((e) => e.testID === t["testID"]);
513
+ else if (typeof t["label"] === "string")
514
+ found = elements.filter((e) => lower(e.label) === lower(t["label"]));
515
+ else if (typeof t["text"] === "string") {
516
+ const want = lower(t["text"]);
517
+ found = elements.filter((e) => lower(e.text).includes(want) || lower(e.label).includes(want));
518
+ }
519
+ else if (typeof x === "number" && typeof y === "number") {
520
+ found = elements.filter((e) => {
521
+ const f = e.frame;
522
+ return f !== null && f !== undefined && x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + f.height;
523
+ });
524
+ }
525
+ else
526
+ found = [];
527
+ const index = t["index"];
528
+ const picked = typeof index === "number" ? found[index] : undefined;
529
+ return picked !== undefined ? [picked] : found;
530
+ }
424
531
  // ---- tool handlers ----------------------------------------------------------
425
532
  /** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
426
533
  export class TaplineTools {
427
534
  host;
428
535
  options;
429
536
  reloadWaitMs;
537
+ /** The action log (§12), when one is configured. */
538
+ actionLog;
539
+ /** When the action in flight started, for the log's `ms`. */
540
+ actionStartedAt;
541
+ /** Set while `run` runs a step: the log ties the action to its batch. */
542
+ batch;
543
+ /** Extra fields for the next log line (a dry run, a confirmed touch). */
544
+ logExtra;
545
+ confirmations;
546
+ policy;
430
547
  reloadSettleMs;
431
548
  noScreenshot = new Set();
432
549
  shotFiles = [];
@@ -452,6 +569,10 @@ export class TaplineTools {
452
569
  this.host = host;
453
570
  this.options = options;
454
571
  this.reloadWaitMs = options.reloadWaitMs ?? 20_000;
572
+ this.confirmations = options.confirmations ?? new Confirmations();
573
+ this.policy = options.policy ?? DEFAULT_POLICY;
574
+ this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
575
+ this.actionLog?.open();
455
576
  this.reloadSettleMs = options.reloadSettleMs ?? 1500;
456
577
  // A finger on the phone itself: the app saw a real touch. It can't be queued like viewer input
457
578
  // (it has already happened), so it only leaves the note and makes `{ref}` targets stale.
@@ -490,15 +611,17 @@ export class TaplineTools {
490
611
  const silent = this.silentDropMessage();
491
612
  lines.push(silent !== undefined
492
613
  ? silent
493
- : state === "background" || state === "inactive"
494
- ? `${last.session.app.name} disconnected at ${when} after it went to the background (the user switched apps or locked the phone, and iOS paused it). Ask the user to open the app again: it reconnects by itself.`
495
- : `${last.session.app.name} disconnected at ${when} (${last.reason}). If the app is still open, it reconnects by itself.`);
614
+ : state === "inactive"
615
+ ? `${last.session.app.name} disconnected at ${when} after iOS put something in front of it (a system prompt such as a password request, a call, or Control Center). Tapline can't see or answer it: ask the user to deal with it on the phone. The app reconnects by itself.`
616
+ : state === "background"
617
+ ? `${last.session.app.name} disconnected at ${when} after it went to the background (the user switched apps or locked the phone, and iOS paused it). Ask the user to open the app again: it reconnects by itself.`
618
+ : `${last.session.app.name} disconnected at ${when} (${last.reason}). If the app is still open, it reconnects by itself.`);
496
619
  }
497
620
  return [
498
621
  "No app is connected to Tapline.",
499
622
  ...lines,
500
623
  `This host listens at ${url} on ${name}.`,
501
- 'To connect a phone, call `pair` with the app\'s URL scheme ("scheme" in app.json, e.g. "myapp") and show the user the QR code. They scan it with the phone\'s camera and tap Connect in the app.',
624
+ 'To connect a phone, call `pair`. A development build loading its code from this computer then asks by itself: the user taps Connect. Other builds: pass the app\'s URL scheme ("scheme" in app.json, e.g. "myapp") and show the user the QR code to scan.',
502
625
  "The phone needs to be on the same Wi-Fi as this computer, and the app must be a build with Tapline turned on, open in the foreground.",
503
626
  "A phone that paired with this host before reconnects by itself while the app is open; one paired with a host on another port (e.g. before a restart) needs `pair` again. Once it connects, retry.",
504
627
  ].join("\n");
@@ -506,6 +629,19 @@ export class TaplineTools {
506
629
  // -- pair
507
630
  async pair(args) {
508
631
  const { url, name } = this.options.connect;
632
+ // Zero-QR pairing (Tapline 1.1): a development build whose JavaScript comes from this computer finds the
633
+ // offer itself and asks its person to connect. Open for 10 minutes, one use, local network only.
634
+ const offer = this.host.openDevOffer(name);
635
+ this.writeOffer(offer);
636
+ const devLine = "A development build that loads its JavaScript from this computer (Metro) on the same Wi-Fi asks by itself within a few seconds: the user only taps Connect, no QR code (open for 10 minutes, once).";
637
+ if (args.scheme === undefined || args.scheme.trim() === "") {
638
+ return text([
639
+ `Pairing is open for development builds for 10 minutes (${name}).`,
640
+ devLine,
641
+ "Ask the user to open the app on the phone and tap Connect, then call `apps`.",
642
+ "For a TestFlight or release build, call pair again with the app's URL scheme to get a QR code.",
643
+ ].join("\n"));
644
+ }
509
645
  let link;
510
646
  let forget;
511
647
  try {
@@ -540,6 +676,7 @@ export class TaplineTools {
540
676
  "",
541
677
  `Show the user the QR code (attached).${saved} They scan it with the phone's camera, open the app and tap Connect.`,
542
678
  "The phone needs to be on the same Wi-Fi as this computer, and the app must be a build with Tapline turned on.",
679
+ devLine,
543
680
  "Once paired, the app reconnects to this host (this address and port) by itself whenever it is open; call `apps` to check.",
544
681
  `To unpair the phone, open ${forget} on it.`,
545
682
  ];
@@ -550,6 +687,51 @@ export class TaplineTools {
550
687
  ],
551
688
  };
552
689
  }
690
+ /** `<offerDir>/<port>.json` (0600): the open offer for the Metro helper. Never fails the pair call. */
691
+ writeOffer(offer) {
692
+ const dir = this.options.offerDir;
693
+ const port = this.host.boundPort();
694
+ if (dir === undefined || port === undefined)
695
+ return;
696
+ try {
697
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
698
+ const file = join(dir, `${port}.json`);
699
+ writeFileSync(file, JSON.stringify({ code: offer.code, name: offer.name, port, until: offer.until }), { mode: 0o600 });
700
+ this.host.setDevOfferFile(file);
701
+ const remove = () => {
702
+ try {
703
+ // only our own offer: a newer pair call has written its own
704
+ if (existsSync(file) && readFileSync(file, "utf8").includes(offer.code))
705
+ rmSync(file, { force: true });
706
+ }
707
+ catch {
708
+ // gone already
709
+ }
710
+ };
711
+ // Used: the code works once, so Metro stops handing it out at once.
712
+ const onConnect = () => {
713
+ if (this.host.currentDevOffer()?.code === offer.code)
714
+ return;
715
+ this.host.off("connect", onConnect);
716
+ remove();
717
+ };
718
+ this.host.on("connect", onConnect);
719
+ setTimeout(() => {
720
+ this.host.off("connect", onConnect);
721
+ try {
722
+ // only our own offer: a newer pair call has written its own
723
+ if (existsSync(file) && readFileSync(file, "utf8").includes(offer.code))
724
+ rmSync(file, { force: true });
725
+ }
726
+ catch {
727
+ // gone already
728
+ }
729
+ }, Math.max(0, offer.until - Date.now())).unref();
730
+ }
731
+ catch {
732
+ // the host's own endpoint still has it
733
+ }
734
+ }
553
735
  // -- apps
554
736
  apps() {
555
737
  const sessions = this.host.sessions();
@@ -602,6 +784,218 @@ export class TaplineTools {
602
784
  const parts = await this.lookParts(session, args.screenshot ?? true, args.width ?? DEFAULT_SCREENSHOT_WIDTH, false, args.labels === true);
603
785
  return { content: parts.content, ...(parts.isError ? { isError: true } : {}) };
604
786
  }
787
+ // -- run (AGENT-TOOLS 1.8 §9): a batch of steps, answered once
788
+ async run(args) {
789
+ const session = this.host.current();
790
+ if (session === undefined)
791
+ return this.notConnected();
792
+ const steps = args.steps;
793
+ for (const [i, step] of steps.entries()) {
794
+ if (!RUN_TOOLS.includes(step.tool)) {
795
+ return fail(`Error bad_params: step ${i + 1}: "${step.tool}" can't be a step (steps act; use it on its own call). Steps can be: ${RUN_TOOLS.join(", ")}.`);
796
+ }
797
+ const shape = toolShapes[step.tool];
798
+ const { tool: _tool, expect: _expect, route: _route, ...params } = step;
799
+ const parsed = z.object(shape).strict().safeParse(params);
800
+ if (!parsed.success) {
801
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
802
+ }
803
+ }
804
+ if (args.dry_run === true)
805
+ return this.dryRunBatch(steps);
806
+ const before = this.unlistedActions.get(session.id) ?? 0;
807
+ const started = Date.now();
808
+ const batchId = `b${started.toString(36)}`;
809
+ const notes = [];
810
+ const trace = [];
811
+ let ran = 0;
812
+ let stop = null;
813
+ for (const [i, step] of steps.entries()) {
814
+ if (Date.now() - started > RUN_TIMEOUT_MS) {
815
+ stop = { index: i, code: "timeout", message: `the batch ran longer than ${RUN_TIMEOUT_MS / 1000} s`, rest: [] };
816
+ break;
817
+ }
818
+ const at = Date.now();
819
+ this.batch = { id: batchId, step: i + 1 };
820
+ const answer = await this.runStep(step).finally(() => {
821
+ this.batch = undefined;
822
+ });
823
+ const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
824
+ if (answer.isError === true) {
825
+ stop = { index: i, ...parseError(said) };
826
+ break;
827
+ }
828
+ notes.push(...stepNotes(said, i + 1));
829
+ if (step.expect !== undefined) {
830
+ const missed = await this.expectMissed(step.expect);
831
+ if (missed !== null) {
832
+ stop = { index: i, code: "expect_failed", message: `its action ran, but ${missed}`, rest: [] };
833
+ break;
834
+ }
835
+ }
836
+ const route = step.route;
837
+ if (Array.isArray(route) && route.every((part) => typeof part === "string")) {
838
+ const now = await this.currentRoute();
839
+ if (now !== null && now.join(" \u203a ") !== route.join(" \u203a ")) {
840
+ stop = { index: i, code: "expect_failed", message: `its action ran, but it ended on ${now.join(" \u203a ")}; the recording ended on ${route.join(" \u203a ")}`, rest: [] };
841
+ break;
842
+ }
843
+ }
844
+ ran++;
845
+ if (args.trace === true)
846
+ trace.push(` ${i + 1}. ${said.split("\n")[0] ?? ""} (${((Date.now() - at) / 1000).toFixed(1)} s)`);
847
+ }
848
+ // The batch is one call: the changes below are measured from the list the agent had before it.
849
+ if (before > 0)
850
+ this.unlistedActions.set(session.id, before);
851
+ else
852
+ this.unlistedActions.delete(session.id);
853
+ const total = steps.length;
854
+ const failed = stop !== null;
855
+ const head = stop === null
856
+ ? `Ran ${total} of ${total} steps in ${((Date.now() - started) / 1000).toFixed(1)} s.`
857
+ : `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");
859
+ let response;
860
+ const live = this.host.current();
861
+ if (args.look === false || live === undefined) {
862
+ response = text([first, ...notes].join("\n"));
863
+ }
864
+ else {
865
+ response = await this.withLook(live, first, args.screenshot === true);
866
+ if (notes.length > 0) {
867
+ const [top, ...rest] = response.content;
868
+ if (top !== undefined && top.type === "text")
869
+ response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...notes].join("\n") }, ...rest] };
870
+ }
871
+ }
872
+ return failed ? { ...response, isError: true } : response;
873
+ }
874
+ /** `run` with dry_run (§11.4): the steps are checked already; step 1 is resolved, nothing is touched. */
875
+ async dryRunBatch(steps) {
876
+ const total = steps.length;
877
+ const first = steps[0];
878
+ 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 });
881
+ const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
882
+ const [line, ...rest] = said.split("\n");
883
+ if (answer.isError === true) {
884
+ const { code, message } = parseError(said);
885
+ return fail(`Error ${code}: ${head}; step 1 (${describeRunStep(first)}) would fail: ${message}`);
886
+ }
887
+ const would = (line ?? "").replace(/^Would /, "would ");
888
+ return text([`${head}; step 1 ${would}`, ...rest].join("\n"));
889
+ }
890
+ return text(`${head}; step 1 would ${describeRunStep(first)}.`);
891
+ }
892
+ /** One step of `run`, as its own tool call without a list. */
893
+ runStep(step) {
894
+ const { tool, expect: _expect, route: _route, ...rest } = step;
895
+ const quiet = { ...rest, look: false, screenshot: false };
896
+ switch (tool) {
897
+ case "tap":
898
+ return this.tap(quiet);
899
+ case "long_press":
900
+ return this.longPress(quiet);
901
+ case "type_text":
902
+ return this.typeText(quiet);
903
+ case "scroll":
904
+ return this.scroll(quiet);
905
+ case "toggle":
906
+ return this.toggle(quiet);
907
+ case "back":
908
+ return this.back(quiet);
909
+ case "swipe":
910
+ return this.swipe(quiet);
911
+ case "open_url":
912
+ return this.openUrl(quiet);
913
+ case "wait":
914
+ return this.wait({ seconds: Number(rest.seconds ?? 0) });
915
+ case "wait_for":
916
+ return this.waitFor(quiet);
917
+ case "app_command":
918
+ return this.appCommand(quiet);
919
+ }
920
+ }
921
+ /** The screen's route names now (one cheap read), or null when the app has no router or didn't answer. */
922
+ async currentRoute() {
923
+ const session = this.host.current();
924
+ if (session === undefined)
925
+ return null;
926
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
927
+ return tree.success && tree.data.route !== undefined ? tree.data.route.names : null;
928
+ }
929
+ /** Null when the expectation holds within its time; else how it failed. */
930
+ async expectMissed(e) {
931
+ const seconds = e.timeout ?? EXPECT_TIMEOUT_S;
932
+ const which = e.text !== undefined ? { text: e.text } : e.id !== undefined ? { id: e.id } : { label: e.label };
933
+ const seen = await this.waitFor({ ...which, ...(e.gone === true ? { gone: true } : {}), timeout: Math.max(0.5, seconds), look: false });
934
+ if (seen.isError === true)
935
+ return expectFailure(e, seconds);
936
+ if (e.value === undefined || e.gone === true)
937
+ return null;
938
+ // The element is there: wait for its value (a switch reads "on" or "off").
939
+ const session = this.host.current();
940
+ if (session === undefined)
941
+ return expectFailure(e, seconds);
942
+ const until = Date.now() + seconds * 1000;
943
+ let actual;
944
+ for (;;) {
945
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
946
+ const element = tree.success
947
+ ? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : (el.label ?? "").toLowerCase() === (e.label ?? "").toLowerCase()))
948
+ : undefined;
949
+ if (element !== undefined) {
950
+ actual = typeof element.checked === "boolean" ? (element.checked ? "on" : "off") : typeof element.value === "string" ? element.value : undefined;
951
+ // A switch takes "true" and "false" too (it reads checked=true in the list).
952
+ const wanted = typeof element.checked === "boolean" && (e.value === "true" || e.value === "false") ? (e.value === "true" ? "on" : "off") : e.value;
953
+ if (actual === wanted)
954
+ return null;
955
+ }
956
+ if (Date.now() >= until)
957
+ return expectFailure(e, seconds, actual);
958
+ await sleep(100);
959
+ }
960
+ }
961
+ // -- inspect (Tapline 1.1): from the screen to the code
962
+ async inspect(args) {
963
+ const session = this.host.current();
964
+ if (session === undefined)
965
+ return this.notConnected();
966
+ let raw;
967
+ try {
968
+ raw = await this.host.request("inspect", { target: toWireTarget(args.target) }, { sessionId: session.id });
969
+ }
970
+ catch (error) {
971
+ return fail(describeError(error));
972
+ }
973
+ const parsed = inspectResultSchema.safeParse(raw);
974
+ if (!parsed.success)
975
+ return fail("Error internal: the app returned an inspect answer the host could not read.");
976
+ return text(formatInspect(parsed.data));
977
+ }
978
+ // -- audit (AGENT-TOOLS 1.8 §10)
979
+ async audit(args = {}) {
980
+ const session = this.host.current();
981
+ if (session === undefined)
982
+ return this.notConnected();
983
+ if (!session.capabilities.includes("audit")) {
984
+ return fail("Error unsupported: this app's Tapline is too old for audit; update react-native-tapline (check_layout works meanwhile).");
985
+ }
986
+ let raw;
987
+ try {
988
+ raw = await this.host.request("audit", args.types !== undefined ? { types: args.types } : {}, { sessionId: session.id });
989
+ }
990
+ catch (error) {
991
+ return fail(describeError(error));
992
+ }
993
+ const parsed = auditResultSchema.safeParse(raw);
994
+ if (!parsed.success)
995
+ return fail("Error internal: the app returned an audit the host could not read.");
996
+ const title = auditTitle(await this.currentRoute(), session.app.name);
997
+ return text([formatAudit(title, parsed.data, args.types), ...this.takePersonNote()].join("\n"));
998
+ }
605
999
  // -- check_layout
606
1000
  async checkLayout(_args = {}) {
607
1001
  const session = this.host.current();
@@ -754,32 +1148,42 @@ export class TaplineTools {
754
1148
  /** Runs a request, then (unless disabled) appends the fresh element list and how long the app took to settle. */
755
1149
  async act(method, params, describe, withLook, screenshot, extraNotes,
756
1150
  /** True when the result means nothing was done: the first line is then the whole answer. */
757
- nothingDone) {
1151
+ nothingDone,
1152
+ /** Runs inside the agent's turn, right before the request: an answer stops the call there (the policy). */
1153
+ preflight) {
758
1154
  const session = this.host.current();
759
1155
  if (session === undefined)
760
1156
  return this.notConnected();
761
- if (this.personActed) {
762
- const target = typeof params === "object" && params !== null ? params.target : undefined;
763
- if (typeof target === "object" && target !== null && "ref" in target) {
764
- // The error says it already, so the note isn't repeated.
765
- this.personNotePending = false;
766
- return fail("Error stale: a person used the app since your last look, so refs from that list may point elsewhere. Look again, or target by id, label or text.");
767
- }
768
- }
1157
+ const stale = this.staleRef(params);
1158
+ if (stale !== null)
1159
+ return stale;
769
1160
  let result;
1161
+ this.actionStartedAt = Date.now();
770
1162
  try {
771
1163
  const timeoutMs = answerTimeoutMs(method, params);
772
- const run = () => this.host.request(method, params, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
1164
+ const run = async () => {
1165
+ if (preflight !== undefined) {
1166
+ const stop = await preflight();
1167
+ if (stop !== null)
1168
+ throw new PolicyStop(stop);
1169
+ }
1170
+ return this.host.request(method, params, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
1171
+ };
773
1172
  // With the live viewer, a person watching goes first.
774
1173
  result = this.options.control ? await this.options.control.runAgent(run) : await run();
775
1174
  }
776
1175
  catch (error) {
1176
+ if (error instanceof PolicyStop)
1177
+ return error.answer;
1178
+ this.logExtra = undefined;
777
1179
  this.showAction("agent", method, undefined, params, error instanceof TaplineError ? error.code : "internal", error instanceof TaplineError ? reasonOf(error.code, error.message) : undefined);
778
1180
  return fail(describeError(error));
779
1181
  }
780
1182
  const line = describe(result);
781
- if (nothingDone?.(result) === true)
1183
+ if (nothingDone?.(result) === true) {
1184
+ this.logExtra = undefined;
782
1185
  return text([line, ...this.takePersonNote()].join("\n"));
1186
+ }
783
1187
  this.showAction("agent", method, result, params);
784
1188
  let response;
785
1189
  this.lastLookRoute = undefined;
@@ -826,8 +1230,14 @@ export class TaplineTools {
826
1230
  }
827
1231
  }
828
1232
  if (this.recording !== null && !this.replaying) {
829
- const { params: stable, fragile } = stableParams(params, result);
830
- const step = { method, params: stable };
1233
+ const { params: chosen, fragile } = stableParams(params, result);
1234
+ // A ref saved as a label or text that several elements share needs its index (AGENT-TOOLS 1.8 §9.4).
1235
+ const stable = await this.withIndexIfShared(params, chosen, result, session.id);
1236
+ // A password field's text is never saved in a script (Tapwright's review, M4).
1237
+ const secret = method === "type" && typeof result === "object" && result !== null && "secure" in result && result.secure === true;
1238
+ const step = { method, params: secret ? { ...stable, text: "" } : stable };
1239
+ if (secret)
1240
+ step.secret = true;
831
1241
  if (this.lastLookRoute !== undefined)
832
1242
  step.route = this.lastLookRoute;
833
1243
  if (fragile)
@@ -836,6 +1246,39 @@ export class TaplineTools {
836
1246
  }
837
1247
  return response;
838
1248
  }
1249
+ /**
1250
+ * When the agent's ref was saved as a testID, label or text that more than one element on screen has, add
1251
+ * the element's `index` among them (reading order), so the replay picks the same one instead of
1252
+ * answering `ambiguous`. One extra read, only while recording and only for ref targets.
1253
+ */
1254
+ async withIndexIfShared(original, chosen, result, sessionId) {
1255
+ const before = typeof original === "object" && original !== null ? original.target : undefined;
1256
+ const target = chosen["target"];
1257
+ if (typeof before !== "object" || before === null || !("ref" in before))
1258
+ return chosen;
1259
+ if (typeof target !== "object" || target === null || "ref" in target || "index" in target)
1260
+ return chosen;
1261
+ const element = typeof result === "object" && result !== null && "element" in result
1262
+ ? elementSchema.safeParse(result.element)
1263
+ : null;
1264
+ if (!element?.success)
1265
+ return chosen;
1266
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId }).catch(() => null));
1267
+ if (!tree.success)
1268
+ return chosen;
1269
+ const t = target;
1270
+ const same = tree.data.elements.filter((el) => typeof t.testID === "string"
1271
+ ? el.testID === t.testID
1272
+ : typeof t.label === "string"
1273
+ ? (el.label ?? "").toLowerCase() === t.label.toLowerCase()
1274
+ : typeof t.text === "string"
1275
+ ? el.text === t.text
1276
+ : false);
1277
+ if (same.length < 2)
1278
+ return chosen;
1279
+ const at = same.findIndex((el) => el.ref === element.data.ref);
1280
+ return at < 0 ? chosen : { ...chosen, target: { ...target, index: at } };
1281
+ }
839
1282
  // -- the live viewer
840
1283
  /** An element's frame (points) as 0..1 of the screen. */
841
1284
  normalized(frame) {
@@ -859,15 +1302,43 @@ export class TaplineTools {
859
1302
  ...(point ? { point } : {}),
860
1303
  };
861
1304
  }
1305
+ /** One line in the action log (§12). Never fails the action. */
1306
+ logAction(by, tool, params, result, code, reason) {
1307
+ if (this.actionLog === null)
1308
+ return;
1309
+ const element = typeof result === "object" && result !== null && "element" in result
1310
+ ? elementSchema.safeParse(result.element)
1311
+ : null;
1312
+ const session = this.host.current();
1313
+ this.actionLog.write({
1314
+ by,
1315
+ tool,
1316
+ ...loggedTarget(params),
1317
+ // typed text never reaches the log, not even as the field's value (§12)
1318
+ ...(element?.success ? { element: formatElement({ ...element.data, ref: "", value: null, placeholder: null }, { actions: false }) } : {}),
1319
+ ok: code === undefined,
1320
+ ...(code !== undefined ? { code } : {}),
1321
+ ...(reason !== undefined ? { reason } : {}),
1322
+ ...(by === "agent" && this.actionStartedAt !== undefined ? { ms: Date.now() - this.actionStartedAt } : {}),
1323
+ ...(session !== undefined ? { app: session.app.id ?? session.app.name } : {}),
1324
+ ...(by === "agent" && this.batch !== undefined ? { batch: this.batch } : {}),
1325
+ ...(by === "agent" ? (this.logExtra ?? {}) : {}),
1326
+ });
1327
+ if (by === "agent")
1328
+ this.logExtra = undefined;
1329
+ }
862
1330
  showAction(by, method, result, params, code, reason) {
863
1331
  const onAction = this.options.onAction;
864
- if (onAction === undefined)
1332
+ if (onAction === undefined) {
1333
+ this.logAction(by, by === "agent" ? agentKind(method) : method, params, result, code, reason);
865
1334
  return;
1335
+ }
866
1336
  const target = typeof params === "object" && params !== null ? params.target : undefined;
867
1337
  const t = typeof target === "object" && target !== null ? target : undefined;
868
1338
  const screen = this.lastScreen;
869
1339
  const point = t && typeof t.x === "number" && typeof t.y === "number" && screen ? { x: t.x / screen.w, y: t.y / screen.h } : undefined;
870
1340
  const kind = by === "agent" ? agentKind(method) : method;
1341
+ this.logAction(by, kind, params, result, code, reason);
871
1342
  const described = this.describeAction(kind, result, point);
872
1343
  // A failed step has no element in its answer: name it from the target the agent gave, if it's words.
873
1344
  const named = described.label ??
@@ -968,7 +1439,10 @@ export class TaplineTools {
968
1439
  }
969
1440
  };
970
1441
  try {
971
- return this.options.control ? await this.options.control.runPerson(run) : await run();
1442
+ const outcome = this.options.control ? await this.options.control.runPerson(run) : await run();
1443
+ // The log (§12): a person's input too, its typed text only as a length.
1444
+ this.logAction("person", outcome.action?.kind ?? input.type, input.type === "text" ? { text: input.text } : {}, undefined, outcome.ok ? undefined : outcome.code, outcome.reason);
1445
+ return outcome;
972
1446
  }
973
1447
  catch (error) {
974
1448
  return { ok: false, code: "internal", message: error instanceof Error ? error.message : String(error) };
@@ -1015,11 +1489,28 @@ export class TaplineTools {
1015
1489
  const links = this.options.viewerLinks?.() ?? [];
1016
1490
  if (links.length === 0)
1017
1491
  return fail("Error unsupported: this host has no live viewer.");
1018
- const lines = [
1019
- "Live viewer (watch the app, see what you do, and let the user tap and type too):",
1020
- ...links.map((link) => `- ${link}`),
1021
- "The link carries a key: give it to the user only. While they use the viewer, their touches go first and your actions wait.",
1022
- ];
1492
+ const open = this.options.openViewer;
1493
+ const local = links[0];
1494
+ let lines;
1495
+ if (open !== undefined && this.options.viewerLinkToAgent !== true && local !== undefined) {
1496
+ // The key in the link can approve a held-back touch: it goes to the browser, never to the agent.
1497
+ const opened = open(local);
1498
+ lines = [
1499
+ opened
1500
+ ? "Opened the live view in a browser on this computer: the user watches the app and what you do there, and can tap and type too."
1501
+ : "Couldn't open a browser on this computer. Ask the user to start the MCP server with --viewer-link if they want the link.",
1502
+ "The link isn't shown to you: its key can approve touches that need a person's OK.",
1503
+ ...(links.length > 1 ? ["For their own devices on Tailscale, the user finds that link in this server's log."] : []),
1504
+ "While the user acts in the live view, their touches go first and your actions wait.",
1505
+ ];
1506
+ }
1507
+ else {
1508
+ lines = [
1509
+ "Live viewer (watch the app, see what you do, and let the user tap and type too):",
1510
+ ...links.map((link) => `- ${link}`),
1511
+ "The link carries a key: give it to the user only. While they use the viewer, their touches go first and your actions wait.",
1512
+ ];
1513
+ }
1023
1514
  const feed = this.options.viewerFeed?.();
1024
1515
  const session = this.host.current();
1025
1516
  if (feed?.on === true && session !== undefined) {
@@ -1151,8 +1642,13 @@ export class TaplineTools {
1151
1642
  const started = Date.now();
1152
1643
  if (args.tap !== undefined) {
1153
1644
  // A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
1645
+ // The tap is a touch like any other: the safety policy decides first.
1646
+ 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;
1154
1650
  frames.push(await this.takeShot(session.id, frameWidth, 0.8));
1155
- await this.host.request("press", { target: toWireTarget(args.tap) }, { sessionId: session.id });
1651
+ await this.host.request("press", pressParams, { sessionId: session.id });
1156
1652
  }
1157
1653
  for (let i = frames.length; i < count; i++) {
1158
1654
  const at = Date.now();
@@ -1178,9 +1674,10 @@ export class TaplineTools {
1178
1674
  return dir === undefined || !SCRIPT_NAME.test(name) ? null : join(dir, `${name}.json`);
1179
1675
  }
1180
1676
  async record(args) {
1181
- switch (args.action) {
1677
+ const op = args.action ?? (args.start === true ? "start" : args.stop === true ? "stop" : "status");
1678
+ switch (op) {
1182
1679
  case "start": {
1183
- const name = (args.name ?? "").trim();
1680
+ const name = (args.name ?? "").trim() || `session-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")}`;
1184
1681
  if (this.scriptPath(name) === null) {
1185
1682
  return fail("Error bad_params: give the script a name of letters, digits, '.', '_' and '-' (e.g. \"checkout\").");
1186
1683
  }
@@ -1190,7 +1687,7 @@ export class TaplineTools {
1190
1687
  if (session === undefined)
1191
1688
  return this.notConnected();
1192
1689
  this.recording = { name, app: { name: session.app.name, ...(session.app.id ? { id: session.app.id } : {}) }, steps: [] };
1193
- return text(`Recording "${name}" on ${session.app.name}. Every action from now on is a step; wait_for steps check the app is where it should be. Prefer testIDs: they replay best. Stop with record { action: "stop" }.`);
1690
+ return text(`Recording "${name}" on ${session.app.name}. Every action from now on is a step; wait_for steps check the app is where it should be. Prefer testIDs: they replay best. Stop with record { stop: true }.`);
1194
1691
  }
1195
1692
  case "status":
1196
1693
  return text(this.recording === null
@@ -1204,16 +1701,17 @@ export class TaplineTools {
1204
1701
  case "stop": {
1205
1702
  const recording = this.recording;
1206
1703
  if (recording === null)
1207
- return fail("Error bad_params: not recording; start with record { action: \"start\", name }.");
1208
- const path = this.scriptPath(recording.name);
1704
+ return fail("Error bad_params: not recording; start with record { start: true, name }.");
1705
+ const path = args.path !== undefined && args.path.trim() !== "" ? args.path.trim() : this.scriptPath(recording.name);
1209
1706
  if (path === null)
1210
- return fail("Error internal: no scripts folder is configured.");
1707
+ return fail("Error internal: no scripts folder is configured; give a path.");
1211
1708
  const script = {
1709
+ format: "agent-tools-script",
1212
1710
  version: 1,
1213
1711
  name: recording.name,
1214
- app: recording.app,
1215
- recordedAt: new Date().toISOString(),
1216
- steps: recording.steps,
1712
+ app: recording.app.id ?? recording.app.name,
1713
+ fresh: true,
1714
+ steps: scriptSteps(recording.steps),
1217
1715
  };
1218
1716
  try {
1219
1717
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -1224,15 +1722,17 @@ export class TaplineTools {
1224
1722
  }
1225
1723
  this.recording = null;
1226
1724
  const count = script.steps.length;
1227
- const lines = [`Saved "${script.name}": ${count} step${count === 1 ? "" : "s"} in ${path}. Replay it with replay { name: "${script.name}" }.`];
1228
- script.steps.forEach((step, i) => {
1229
- if (step.fragile) {
1230
- const target = step.params["target"];
1231
- lines.push(`Note: step ${i + 1} targets ref ${String(target.ref)}, which won't exist on a new run. Give that element a testID, or record it again by its text or label.`);
1232
- }
1233
- });
1234
- if (script.steps.some((step) => step.method === "type")) {
1235
- lines.push("The script keeps the text typed in it (test passwords too): keep it private.");
1725
+ const lines = [`Saved ${count} step${count === 1 ? "" : "s"} to ${path}.`];
1726
+ const ref = refStep(script.steps);
1727
+ if (ref !== null) {
1728
+ 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.`);
1729
+ }
1730
+ const secrets = recording.steps.filter((step) => step.secret === true).length;
1731
+ if (secrets > 0) {
1732
+ lines.push(`${secrets} step${secrets === 1 ? " types" : "s type"} into a password field: that text isn't saved. Put a test password into the script before you replay it.`);
1733
+ }
1734
+ if (recording.steps.some((step) => step.method === "type" && step.secret !== true)) {
1735
+ lines.push("The script keeps the other text typed in it: keep it private.");
1236
1736
  }
1237
1737
  return text(lines.join("\n"));
1238
1738
  }
@@ -1245,11 +1745,10 @@ export class TaplineTools {
1245
1745
  const lines = [];
1246
1746
  for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
1247
1747
  try {
1248
- const parsed = scriptSchema.safeParse(JSON.parse(readFileSync(join(dir, file), "utf8")));
1249
- if (!parsed.success)
1748
+ const s = readScript(readFileSync(join(dir, file), "utf8"));
1749
+ if (s === null)
1250
1750
  continue;
1251
- const s = parsed.data;
1252
- lines.push(`- ${s.name} (${s.steps.length} step${s.steps.length === 1 ? "" : "s"}, recorded ${s.recordedAt.slice(0, 10)} on ${s.app.name})`);
1751
+ lines.push(`- ${s.name} (${s.steps.length} step${s.steps.length === 1 ? "" : "s"}${s.app ? `, ${s.app}` : ""})`);
1253
1752
  }
1254
1753
  catch {
1255
1754
  // skip unreadable files
@@ -1259,64 +1758,60 @@ export class TaplineTools {
1259
1758
  }
1260
1759
  async replay(args) {
1261
1760
  const name = (args.name ?? "").trim();
1262
- if (name === "")
1761
+ const given = (args.path ?? "").trim();
1762
+ if (name === "" && given === "")
1263
1763
  return text(this.scriptList());
1264
1764
  if (this.recording !== null)
1265
1765
  return fail(`Error bad_params: stop recording "${this.recording.name}" before replaying.`);
1266
- const path = this.scriptPath(name);
1766
+ const path = given !== "" ? given : this.scriptPath(name);
1267
1767
  if (path === null || !existsSync(path))
1268
- return fail(`Error not_found: no script "${name}".\n${this.scriptList()}`);
1768
+ return fail(`Error not_found: no script "${given || name}".\n${this.scriptList()}`);
1269
1769
  let script;
1270
1770
  try {
1271
- const parsed = scriptSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
1272
- if (!parsed.success)
1273
- return fail(`Error bad_params: ${path} isn't a Tapline script.`);
1274
- script = parsed.data;
1771
+ script = readScript(readFileSync(path, "utf8"));
1275
1772
  }
1276
1773
  catch (error) {
1277
1774
  return fail(`Error internal: couldn't read ${path}: ${error instanceof Error ? error.message : String(error)}`);
1278
1775
  }
1776
+ if (script === null)
1777
+ return fail(`Error bad_params: ${path} isn't a script (format agent-tools-script, version 1).`);
1778
+ const ref = refStep(script.steps);
1779
+ if (ref !== null)
1780
+ return fail(`Error bad_params: step ${ref + 1} targets a ref; a script needs an id, label, text or point.`);
1781
+ if (script.steps.length === 0)
1782
+ return text(`Ran 0 of 0 steps in 0.0 s.`);
1279
1783
  const session = this.host.current();
1280
1784
  if (session === undefined)
1281
1785
  return this.notConnected();
1282
- const started = Date.now();
1283
- const total = script.steps.length;
1284
- const lines = [];
1285
- const seconds = (ms) => (Math.round(ms / 100) / 10).toFixed(1);
1786
+ const notes = [];
1787
+ script.steps.forEach((step, i) => {
1788
+ const target = step["target"];
1789
+ if (typeof target === "object" && target !== null && "x" in target) {
1790
+ notes.push(`Note (step ${i + 1}): a point target; it breaks when the layout changes.`);
1791
+ }
1792
+ });
1793
+ if (script.fresh !== false) {
1794
+ // Tapline can't close and open the app: it starts its JavaScript again (a development build).
1795
+ const fresh = await this.reload({});
1796
+ const said = fresh.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
1797
+ if (fresh.isError === true)
1798
+ notes.push("Note: couldn't start the app fresh (a release build has no reload); the script ran on the screen as it was.");
1799
+ else if (said.startsWith("Reload requested, but"))
1800
+ notes.push("Note: the app hadn't reconnected after starting fresh when the steps began.");
1801
+ }
1286
1802
  this.replaying = true;
1287
1803
  try {
1288
- for (const [i, step] of script.steps.entries()) {
1289
- const at = Date.now();
1290
- const label = `${i + 1} ${describeStep(step)}`;
1291
- if (step.method === "sleep") {
1292
- const s = typeof step.params["seconds"] === "number" ? step.params["seconds"] : 0;
1293
- await sleep(Math.min(30, Math.max(0, s)) * 1000);
1294
- lines.push(`${i + 1} ✓ ${describeStep(step)} (${seconds(Date.now() - at)} s)`);
1295
- continue;
1296
- }
1297
- const result = await this.act(step.method, step.params, () => "", true);
1298
- const problem = result.isError
1299
- ? result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim()
1300
- : step.route !== undefined &&
1301
- this.lastLookRoute !== undefined &&
1302
- step.route.join(" › ") !== this.lastLookRoute.join(" › ")
1303
- ? `It ended on ${this.lastLookRoute.join(" › ")}; the recording ended on ${step.route.join(" › ")}.`
1304
- : null;
1305
- if (problem !== null) {
1306
- return fail([
1307
- `Replay "${script.name}" failed at step ${i + 1} of ${total}: ${describeStep(step)}`,
1308
- problem,
1309
- ...(lines.length > 0 ? ["Steps before it passed:", ...lines] : []),
1310
- ...(step.fragile ? ["Note: this step targets a ref from the recording run; give the element a testID."] : []),
1311
- ].join("\n"));
1312
- }
1313
- lines.push(`${label.replace(/^(\d+) /, "$1 ✓ ")} (${seconds(Date.now() - at)} s)`);
1314
- }
1804
+ const answer = await this.run({ steps: script.steps, look: args.look, screenshot: args.screenshot });
1805
+ if (notes.length === 0)
1806
+ return answer;
1807
+ const [top, ...rest] = answer.content;
1808
+ return top !== undefined && top.type === "text"
1809
+ ? { ...answer, content: [{ type: "text", text: [top.text.trimEnd(), ...notes].join("\n") }, ...rest] }
1810
+ : answer;
1315
1811
  }
1316
1812
  finally {
1317
1813
  this.replaying = false;
1318
1814
  }
1319
- return text([`Replayed "${script.name}" on ${session.app.name}: ${total} of ${total} steps passed in ${seconds(Date.now() - started)} s.`, ...lines].join("\n"));
1320
1815
  }
1321
1816
  async withLook(session, line, screenshot = false) {
1322
1817
  // The session may have been replaced (app restarted) in the meantime.
@@ -1329,21 +1824,275 @@ export class TaplineTools {
1329
1824
  const head = first !== undefined && first.type === "text" ? first.text : "";
1330
1825
  return { content: [{ type: "text", text: `${line}\n${head}` }, ...rest] };
1331
1826
  }
1332
- /** The line (without its actions) of the element a result carries under `key`. */
1333
- elementName(result, key = "element") {
1827
+ /**
1828
+ * The element a result carries under `key`, for an answer's first line: without its actions, position or
1829
+ * size (AGENT-TOOLS 1.8 §13 rule 1; the change block or list has them). `withoutValue` for type_text,
1830
+ * whose sentence says the value (rule 5).
1831
+ */
1832
+ elementName(result, key = "element", withoutValue = false) {
1334
1833
  if (typeof result === "object" && result !== null && key in result) {
1335
1834
  const element = elementSchema.safeParse(result[key]);
1336
- if (element.success)
1337
- return formatElement(element.data, { actions: false });
1835
+ if (element.success) {
1836
+ const shown = withoutValue ? { ...element.data, value: null, placeholder: null } : element.data;
1837
+ return formatElement(shown, { actions: false, frame: false });
1838
+ }
1338
1839
  }
1339
1840
  return "element";
1340
1841
  }
1842
+ // -- the safety policy (AGENT-TOOLS 1.8 §11)
1843
+ /**
1844
+ * A guarded touch: the app resolves the target in a dry run, the policy's words are matched against the
1845
+ * element's own label and text, and a confirm word waits for a person's OK in the live view. The check runs
1846
+ * inside the agent's turn, right before the touch, so nothing a person does in between can change what is
1847
+ * touched; when the check can't be made, nothing is touched (fail closed).
1848
+ */
1849
+ async guarded(tool, method, params, dryRun, perform) {
1850
+ const session = this.host.current();
1851
+ if (session === undefined)
1852
+ return this.notConnected();
1853
+ this.logExtra = undefined;
1854
+ this.actionStartedAt = Date.now();
1855
+ const lists = listsFor(this.policy, session.app);
1856
+ if (!dryRun && lists.confirm.length === 0 && lists.block.length === 0)
1857
+ return perform();
1858
+ const stale = this.staleRef(params);
1859
+ if (stale !== null)
1860
+ return stale;
1861
+ if (dryRun) {
1862
+ const resolved = await this.resolveForPolicy(session, method, params);
1863
+ this.logExtra = { dry_run: true };
1864
+ if (resolved.kind === "error") {
1865
+ this.showAction("agent", method, undefined, params, resolved.code, resolved.reason);
1866
+ return fail(resolved.text);
1867
+ }
1868
+ const verdict = this.policyWords(lists, resolved.elements);
1869
+ const shown = resolved.elements[0];
1870
+ if (verdict.kind === "blocked") {
1871
+ this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "blocked");
1872
+ return fail(this.blockedLine(verdict.element, verdict.word));
1873
+ }
1874
+ const would = this.wouldLine(tool, shown, resolved.point, resolved.value);
1875
+ const needs = verdict.kind === "confirm" ? ` It needs a person's OK (policy word ${JSON.stringify(verdict.word)}).` : "";
1876
+ this.showAction("agent", method, shown !== undefined ? { element: shown } : undefined, params);
1877
+ return text([`${would}${needs}`, ...this.takePersonNote()].join("\n"));
1878
+ }
1879
+ let granted = false;
1880
+ const answer = await perform(async () => {
1881
+ const gate = await this.policyGate(session, tool, method, params, lists);
1882
+ if ("stop" in gate)
1883
+ return gate.stop;
1884
+ granted = gate.granted;
1885
+ if (granted)
1886
+ this.logExtra = { confirmed: true };
1887
+ return null;
1888
+ });
1889
+ return granted && answer.isError !== true ? appendNote(answer, "Note: a person allowed this.") : answer;
1890
+ }
1891
+ /**
1892
+ * The policy's verdict on a touch about to happen: stop with an answer (blocked, needs a person's OK, or
1893
+ * the check couldn't be made: fail closed), or go on (`granted` when a person's OK is being used up).
1894
+ */
1895
+ async policyGate(session, tool, method, params, lists) {
1896
+ if (lists.confirm.length === 0 && lists.block.length === 0)
1897
+ return { granted: false };
1898
+ const resolved = await this.resolveForPolicy(session, method, params);
1899
+ if (resolved.kind === "error") {
1900
+ // Fail closed: what can't be checked isn't touched. The answer is the one the touch would give.
1901
+ this.showAction("agent", method, undefined, params, resolved.code, resolved.reason);
1902
+ return { stop: fail(resolved.text) };
1903
+ }
1904
+ const verdict = this.policyWords(lists, resolved.elements);
1905
+ if (verdict.kind === "blocked") {
1906
+ this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "blocked");
1907
+ return { stop: fail(this.blockedLine(verdict.element, verdict.word)) };
1908
+ }
1909
+ if (verdict.kind === "none")
1910
+ return { granted: false };
1911
+ const label = verdict.element.label ?? verdict.element.text ?? undefined;
1912
+ const caption = captionFor(tool, label ?? undefined, params) ?? tool;
1913
+ // An OK is for this app, this tool and this element only (Tapwright's review, M1).
1914
+ const check = this.confirmations.check(`${session.app.id}|${tool}|${elementKey(verdict.element)}`, { kind: tool, caption, word: verdict.word });
1915
+ if (check.granted)
1916
+ return { granted: true };
1917
+ const name = this.shortName(verdict.element, false);
1918
+ if ("held" in check) {
1919
+ this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
1920
+ return {
1921
+ stop: fail(`Error not_allowed: needs_confirmation: ${name} needs a person's OK (policy word ${JSON.stringify(verdict.word)}), and they declined it just now. Don't ask again by repeating the call: ask your user what they want.`),
1922
+ };
1923
+ }
1924
+ const where = this.options.canConfirm === false
1925
+ ? "The live view is off, so nobody can allow it here; ask your user."
1926
+ : "The live view asks them now; when they allow it, make the same call again. If nobody is watching, ask your user.";
1927
+ this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
1928
+ return {
1929
+ stop: fail(`Error not_allowed: needs_confirmation: ${this.shortName(verdict.element, false)} needs a person's OK (policy word ${JSON.stringify(verdict.word)}). ${where}${check.suffix}`),
1930
+ };
1931
+ }
1932
+ /** Which policy word, if any, the elements the target could mean carry: a block word first, then a confirm word. */
1933
+ policyWords(lists, elements) {
1934
+ for (const element of elements) {
1935
+ const word = matchWord(lists.block, element);
1936
+ if (word !== null)
1937
+ return { kind: "blocked", element, word };
1938
+ }
1939
+ for (const element of elements) {
1940
+ const word = matchWord(lists.confirm, element);
1941
+ if (word !== null)
1942
+ return { kind: "confirm", element, word };
1943
+ }
1944
+ return { kind: "none" };
1945
+ }
1946
+ blockedLine(element, word) {
1947
+ return `Error not_allowed: blocked: ${this.shortName(element, false)} is blocked by the policy (${JSON.stringify(word)}); don't try another way.`;
1948
+ }
1949
+ /** A dry run the app answers itself (scroll, back, swipe): its checks, nothing touched, one line. */
1950
+ async dryCall(method, params, line) {
1951
+ const session = this.host.current();
1952
+ if (session === undefined)
1953
+ return this.notConnected();
1954
+ if (!session.capabilities.includes("dryRun")) {
1955
+ return fail("Error unsupported: this app's Tapline is too old for dry runs; update react-native-tapline.");
1956
+ }
1957
+ const stale = this.staleRef(params);
1958
+ if (stale !== null)
1959
+ return stale;
1960
+ this.actionStartedAt = Date.now();
1961
+ let result;
1962
+ try {
1963
+ result = await this.host.request(method, { ...params, dryRun: true }, { sessionId: session.id });
1964
+ }
1965
+ catch (error) {
1966
+ this.logExtra = { dry_run: true };
1967
+ this.showAction("agent", method, undefined, params, error instanceof TaplineError ? error.code : "internal", error instanceof TaplineError ? reasonOf(error.code, error.message) : undefined);
1968
+ return fail(describeError(error));
1969
+ }
1970
+ if (typeof result !== "object" || result === null || result.dryRun !== true) {
1971
+ return fail("Error internal: the app didn't answer the dry run.");
1972
+ }
1973
+ this.logExtra = { dry_run: true };
1974
+ this.showAction("agent", method, result, params);
1975
+ return text([line(result), ...this.takePersonNote()].join("\n"));
1976
+ }
1977
+ /** A dry run of a tool the policy doesn't guard (type_text): the app's checks, nothing touched. */
1978
+ async dryRun(tool, method, params) {
1979
+ const session = this.host.current();
1980
+ if (session === undefined)
1981
+ return this.notConnected();
1982
+ const stale = this.staleRef(params);
1983
+ if (stale !== null)
1984
+ return stale;
1985
+ const resolved = await this.resolveForPolicy(session, method, params);
1986
+ this.logExtra = { dry_run: true };
1987
+ if (resolved.kind === "error") {
1988
+ this.showAction("agent", method, undefined, params, resolved.code, resolved.reason);
1989
+ return fail(resolved.text);
1990
+ }
1991
+ this.showAction("agent", method, resolved.elements[0] !== undefined ? { element: resolved.elements[0] } : undefined, params);
1992
+ return text([this.wouldLine(tool, resolved.elements[0], resolved.point, resolved.value), ...this.takePersonNote()].join("\n"));
1993
+ }
1994
+ /**
1995
+ * What a touch would act on. Apps that know dry runs resolve it themselves, with every check; older
1996
+ * apps only give their element list, and every element the target could mean is checked.
1997
+ */
1998
+ async resolveForPolicy(session, method, params) {
1999
+ if (session.capabilities.includes("dryRun")) {
2000
+ let raw;
2001
+ try {
2002
+ raw = await this.host.request(method, { ...params, dryRun: true }, { sessionId: session.id });
2003
+ }
2004
+ catch (error) {
2005
+ const reason = error instanceof TaplineError ? reasonOf(error.code, error.message) : undefined;
2006
+ return {
2007
+ kind: "error",
2008
+ text: describeError(error),
2009
+ code: error instanceof TaplineError ? error.code : "internal",
2010
+ ...(reason !== undefined ? { reason } : {}),
2011
+ };
2012
+ }
2013
+ const parsed = dryRunResultSchema.safeParse(raw);
2014
+ if (!parsed.success)
2015
+ return { kind: "error", text: "Error internal: the app didn't answer the dry run.", code: "internal" };
2016
+ return {
2017
+ kind: "ok",
2018
+ elements: parsed.data.element ? [parsed.data.element] : [],
2019
+ ...(parsed.data.point !== undefined ? { point: parsed.data.point } : {}),
2020
+ ...(parsed.data.value !== undefined ? { value: parsed.data.value } : {}),
2021
+ };
2022
+ }
2023
+ // An app without dry runs: a swipe is checked by what lies under its start.
2024
+ const target = method === "swipe" ? { x: params["x0"], y: params["y0"] } : params["target"];
2025
+ if (method === "type" || target === undefined) {
2026
+ return { kind: "error", text: "Error unsupported: this app's Tapline is too old for dry runs; update react-native-tapline.", code: "unsupported" };
2027
+ }
2028
+ let raw;
2029
+ try {
2030
+ raw = await this.host.request("tree", {}, { sessionId: session.id });
2031
+ }
2032
+ catch (error) {
2033
+ return { kind: "error", text: describeError(error), code: error instanceof TaplineError ? error.code : "internal" };
2034
+ }
2035
+ const tree = treeResultSchema.safeParse(raw);
2036
+ if (!tree.success)
2037
+ return { kind: "error", text: "Error internal: the app returned an element list the host could not read.", code: "internal" };
2038
+ const elements = candidatesFor(target, tree.data.elements);
2039
+ if (elements.length === 0 && method !== "swipe") {
2040
+ return { kind: "error", text: "Error not_found: nothing on screen matches that target.", code: "not_found" };
2041
+ }
2042
+ const first = elements[0];
2043
+ const frame = elements.length === 1 && first !== undefined ? first.frame : null;
2044
+ return {
2045
+ kind: "ok",
2046
+ elements,
2047
+ ...(frame ? { point: { x: Math.round(frame.x + frame.width / 2), y: Math.round(frame.y + frame.height / 2) } } : {}),
2048
+ };
2049
+ }
2050
+ /** `Would tap e12 button "Delete trip" at 215,640.` */
2051
+ wouldLine(tool, element, point, value) {
2052
+ const name = element !== undefined ? this.shortName(element, true) : "the element";
2053
+ const at = point !== undefined ? ` at ${point.x},${point.y}` : "";
2054
+ switch (tool) {
2055
+ case "long_press":
2056
+ return `Would long-press ${name}${at}.`;
2057
+ case "toggle":
2058
+ return `Would toggle ${name}${typeof value === "boolean" ? ` to ${String(value)}` : ""}${at}.`;
2059
+ case "type_text":
2060
+ return `Would type into ${name}.`;
2061
+ default:
2062
+ return `Would tap ${name}${at}.`;
2063
+ }
2064
+ }
2065
+ /** `e12 button "Delete trip"`: ref (when asked), role and name, nothing else. */
2066
+ shortName(element, withRef) {
2067
+ return formatElement({
2068
+ ref: withRef ? element.ref : "",
2069
+ role: element.role ?? null,
2070
+ type: element.type ?? null,
2071
+ label: element.label ?? null,
2072
+ text: element.text ?? null,
2073
+ textMore: element.textMore ?? null,
2074
+ }, { actions: false });
2075
+ }
2076
+ /** A `{ref}` target after a person used the app: refs may point elsewhere. */
2077
+ staleRef(params) {
2078
+ if (!this.personActed)
2079
+ return null;
2080
+ const target = typeof params === "object" && params !== null ? params.target : undefined;
2081
+ if (typeof target === "object" && target !== null && "ref" in target) {
2082
+ // The error says it already, so the note isn't repeated.
2083
+ this.personNotePending = false;
2084
+ return fail("Error stale: a person used the app since your last look, so refs from that list may point elsewhere. Look again, or target by id, label or text.");
2085
+ }
2086
+ return null;
2087
+ }
1341
2088
  tap(args) {
1342
- return this.act("press", { target: toWireTarget(args.target), ...(args.force === true ? { force: true } : {}) }, (r) => `Tapped ${this.elementName(r)}.`, args.look, args.screenshot);
2089
+ const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
2090
+ return this.guarded("tap", "press", params, args.dry_run === true, (preflight) => this.act("press", params, (r) => `Tapped ${this.elementName(r)}.`, args.look, args.screenshot, undefined, undefined, preflight));
1343
2091
  }
1344
2092
  /** `seconds` is accepted for the shared interface; Tapline calls the handler, so it doesn't hold. */
1345
2093
  longPress(args) {
1346
- return this.act("longPress", { target: toWireTarget(args.target), ...(args.force === true ? { force: true } : {}) }, (r) => `Long-pressed ${this.elementName(r)}.`, args.look, args.screenshot);
2094
+ const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
2095
+ return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => `Long-pressed ${this.elementName(r)}.`, args.look, args.screenshot, undefined, undefined, preflight));
1347
2096
  }
1348
2097
  typeText(args) {
1349
2098
  const params = {
@@ -1352,13 +2101,18 @@ export class TaplineTools {
1352
2101
  submit: args.submit ?? false,
1353
2102
  };
1354
2103
  if (args.target !== undefined)
1355
- params["target"] = toWireTarget(args.target);
2104
+ params["target"] = toWireTarget(withIndex(args.target, args.index));
1356
2105
  if (args.force === true)
1357
2106
  params["force"] = true;
2107
+ // Typing isn't guarded by the policy (§11.1: the form's button is); a dry run still checks the field.
2108
+ if (args.dry_run === true)
2109
+ return this.dryRun("type_text", "type", params);
1358
2110
  return this.act("type", params, (r) => {
1359
2111
  const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
1360
- const shown = typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
1361
- return `Typed into ${this.elementName(r)}${args.submit ? " and submitted" : ""}.${shown}`;
2112
+ // AGENT-TOOLS 1.8 §3.3: a secure field's value is never shown, not even as bullets in this line
2113
+ const secure = typeof r === "object" && r !== null && "secure" in r && r.secure === true;
2114
+ const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
2115
+ return `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`;
1362
2116
  }, args.look, args.screenshot);
1363
2117
  }
1364
2118
  scroll(args) {
@@ -1375,6 +2129,13 @@ export class TaplineTools {
1375
2129
  const flag = (r, key) => typeof r === "object" && r !== null && key in r ? r[key] : undefined;
1376
2130
  // The element was on screen and clear already: nothing was scrolled, and the first line says so.
1377
2131
  const alreadyThere = (r) => toTarget && flag(r, "unchanged") === true;
2132
+ if (args.dry_run === true) {
2133
+ return this.dryCall("scroll", params, (r) => {
2134
+ if (toTarget)
2135
+ return `Would scroll ${this.elementName(r)} to ${this.elementName(r, "target")}.`;
2136
+ return `Would scroll ${this.elementName(r)} ${args.to !== undefined ? `to ${args.to}` : (args.direction ?? "down")}.`;
2137
+ });
2138
+ }
1378
2139
  const atEndAlready = (r) => (args.to === "start" || args.to === "end") && flag(r, "moved") === false;
1379
2140
  return this.act("scroll", params, (r) => {
1380
2141
  if (alreadyThere(r))
@@ -1410,19 +2171,19 @@ export class TaplineTools {
1410
2171
  });
1411
2172
  }
1412
2173
  toggle(args) {
1413
- const params = { target: toWireTarget(args.target) };
2174
+ const params = { target: toWireTarget(withIndex(args.target, args.index)) };
1414
2175
  if (args.value !== undefined)
1415
2176
  params["value"] = args.value;
1416
2177
  if (args.force === true)
1417
2178
  params["force"] = true;
1418
2179
  // The switch already had the asked value: the app touched nothing, and that line is the whole answer.
1419
2180
  const unchanged = (r) => typeof r === "object" && r !== null && "unchanged" in r && r.unchanged === true;
1420
- return this.act("toggle", params, (r) => {
2181
+ return this.guarded("toggle", "toggle", params, args.dry_run === true, (preflight) => this.act("toggle", params, (r) => {
1421
2182
  const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
1422
2183
  if (unchanged(r))
1423
2184
  return `${this.elementName(r)} is already ${JSON.stringify(value)}.`;
1424
2185
  return `Toggled ${this.elementName(r)}${value !== undefined ? ` to ${JSON.stringify(value)}` : ""}.`;
1425
- }, args.look, args.screenshot, undefined, unchanged);
2186
+ }, args.look, args.screenshot, undefined, unchanged, preflight));
1426
2187
  }
1427
2188
  async appCommand(args) {
1428
2189
  if (args.name === undefined || args.name.trim() === "") {
@@ -1474,12 +2235,30 @@ export class TaplineTools {
1474
2235
  const params = { x0: args.x0, y0: args.y0, x1: args.x1, y1: args.y1 };
1475
2236
  if (args.seconds !== undefined)
1476
2237
  params["seconds"] = args.seconds;
1477
- return this.act("swipe", params, (r) => {
2238
+ if (args.dry_run === true) {
2239
+ return this.dryCall("swipe", params, (r) => {
2240
+ const on = this.elementName(r);
2241
+ return `Would swipe from (${args.x0}, ${args.y0}) to (${args.x1}, ${args.y1})${on === "element" ? "" : ` starting on ${on}`}.`;
2242
+ });
2243
+ }
2244
+ // A swipe that moves less than 10 pt is a touch where it starts: the policy checks it (AGENT-TOOLS 1.8 §11.1).
2245
+ const short = Math.hypot(args.x1 - args.x0, args.y1 - args.y0) < 10;
2246
+ const perform = (preflight) => this.act("swipe", params, (r) => {
1478
2247
  const on = this.elementName(r);
1479
2248
  return `Swiped from (${args.x0}, ${args.y0}) to (${args.x1}, ${args.y1})${on === "element" ? "" : ` on ${on}`}.`;
1480
- }, args.look, args.screenshot);
2249
+ }, args.look, args.screenshot, undefined, undefined, preflight);
2250
+ return short ? this.guarded("swipe", "swipe", params, false, perform) : perform();
1481
2251
  }
1482
2252
  back(args) {
2253
+ if (args.dry_run === true) {
2254
+ return this.dryCall("back", {}, (r) => {
2255
+ if (typeof r === "object" && r !== null && "closed" in r && r.closed === "sheet")
2256
+ return "Would close the sheet.";
2257
+ const route = typeof r === "object" && r !== null && "route" in r ? r.route : undefined;
2258
+ const from = Array.isArray(route) && route.every((name) => typeof name === "string") ? ` from ${route.join(" › ")}` : "";
2259
+ return `Would go back${from}.`;
2260
+ });
2261
+ }
1483
2262
  return this.act("back", {}, (r) => {
1484
2263
  if (typeof r === "object" && r !== null && "closed" in r && r.closed === "sheet")
1485
2264
  return "Closed the sheet.";
@@ -1488,8 +2267,16 @@ export class TaplineTools {
1488
2267
  return `Went back${from}.`;
1489
2268
  }, args.look, args.screenshot);
1490
2269
  }
1491
- openUrl(args) {
1492
- return this.act("openURL", { url: args.url }, () => `Opened ${args.url}.`, args.look, args.screenshot);
2270
+ async openUrl(args) {
2271
+ const answer = await this.act("openURL", { url: args.url }, () => `Opened ${args.url}.`, args.look, args.screenshot);
2272
+ // The app's own forget link unpairs it, so it drops the connection before answering: that is the link working.
2273
+ if (answer.isError === true && /(^|\/)tapline\/forget\/?$/.test(args.url.replace(/[?#].*$/, ""))) {
2274
+ const said = answer.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
2275
+ if (/^Error (reconnecting|not_connected)\b/.test(said)) {
2276
+ return text("Unpaired: the app forgot this computer and disconnected. To connect it again, call `pair`.");
2277
+ }
2278
+ }
2279
+ return answer;
1493
2280
  }
1494
2281
  async logs(args) {
1495
2282
  const session = this.host.current();