tapline-host 1.3.0 → 1.4.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
@@ -6,11 +6,13 @@ import { z } from "zod";
6
6
  import { annotateScreenshot } from "./annotate.js";
7
7
  import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
8
8
  import { composeFilmstrip, diffImages, imageSize } from "./images.js";
9
- import { alertNotes, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
+ import { alertNotes, networkLine, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
10
10
  import { formatInspect, inspectResultSchema } from "./inspect.js";
11
11
  import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
12
12
  import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
13
13
  import { ActionLog, loggedTarget } from "./actionlog.js";
14
+ import { reportFor } from "./report.js";
15
+ import { randomBytes } from "node:crypto";
14
16
  import { Confirmations } from "./confirm.js";
15
17
  import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
16
18
  import { readScript, refStep, scriptSteps } from "./script.js";
@@ -250,6 +252,9 @@ export const toolShapes = {
250
252
  tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
251
253
  },
252
254
  wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
255
+ session_report: {
256
+ since: z.number().min(0).optional().describe("Epoch milliseconds: report only calls from then on (default: the start of this session)"),
257
+ },
253
258
  run: {
254
259
  steps: z
255
260
  .array(stepSchema)
@@ -265,6 +270,20 @@ export const toolShapes = {
265
270
  screenshot: screenshotField,
266
271
  },
267
272
  };
273
+ /**
274
+ * The answer for a call that carries arguments the tool doesn't have (null when all are known). Zod strips unknown
275
+ * keys silently, so `audit {labels: true}` would run and ignore `labels`; this says so instead. Every way in to a
276
+ * tool (the MCP server, and through it the bench) goes through here; script steps are checked by `run` itself.
277
+ */
278
+ export function unknownArgumentError(name, args) {
279
+ const known = Object.keys(toolShapes[name]);
280
+ const unknown = Object.keys(args).filter((key) => !known.includes(key));
281
+ if (unknown.length === 0)
282
+ return null;
283
+ // AGENT-TOOLS 1.11 wording, shared with Tapwright: every unknown name in one sentence.
284
+ const takes = known.length === 0 ? "it takes no arguments" : `it takes ${known.join(", ")}`;
285
+ return fail(`Error bad_params: ${name} doesn't take ${unknown.join(", ")}; ${takes}.`);
286
+ }
268
287
  // ---- compare_screen ---------------------------------------------------------
269
288
  const DEFAULT_MATCH_THRESHOLD = 0.5;
270
289
  const MAX_REGION_LINES = 8;
@@ -618,7 +637,17 @@ const dryRunResultSchema = z.object({
618
637
  dryRun: z.literal(true),
619
638
  point: z.object({ x: z.number(), y: z.number() }).optional(),
620
639
  value: z.unknown().optional(),
640
+ /** The element is off screen and the action would scroll it into view first (§16.2); `point` is where it expects to touch after. */
641
+ scrolls: z.boolean().optional(),
621
642
  });
643
+ /** Whether an app's answer says the action scrolled the element into view first (AGENT-TOOLS 1.12 §16.2). */
644
+ function scrolledFirst(result) {
645
+ return typeof result === "object" && result !== null && "scrolled" in result && result.scrolled === true;
646
+ }
647
+ /** `Scrolled to it, then tapped …` when the action scrolled first: the sentence goes on after a lead-in. */
648
+ function afterScroll(result, sentence) {
649
+ return scrolledFirst(result) ? `Scrolled to it, then ${sentence.charAt(0).toLowerCase()}${sentence.slice(1)}` : sentence;
650
+ }
622
651
  /** For an app without dry runs: every element on its list the target could mean. */
623
652
  function candidatesFor(target, elements) {
624
653
  if (typeof target !== "object" || target === null)
@@ -657,6 +686,34 @@ const CRASH_NOTED_TOOLS = [
657
686
  "record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
658
687
  "openUrl", "logs", "reload", "performance", "wait",
659
688
  ];
689
+ /** Every tool the agent calls, by handler name (the call log numbers these). */
690
+ const CALL_TOOLS = {
691
+ apps: "apps", openApp: "open_app", pair: "pair", look: "look", run: "run", inspect: "inspect", audit: "audit",
692
+ checkLayout: "check_layout", viewer: "viewer", compareScreen: "compare_screen", filmstrip: "filmstrip", record: "record",
693
+ replay: "replay", tap: "tap", longPress: "long_press", typeText: "type_text", scroll: "scroll", toggle: "toggle",
694
+ appCommand: "app_command", waitFor: "wait_for", swipe: "swipe", back: "back", openUrl: "open_url", logs: "logs",
695
+ crash: "crash", reload: "reload", performance: "performance", wait: "wait", sessionReport: "session_report",
696
+ };
697
+ /** Calls logged with `"kind": "read"` when they logged no action of their own. */
698
+ const READ_TOOLS = new Set([
699
+ "apps", "open_app", "pair", "look", "inspect", "audit", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
700
+ "replay", "wait_for", "logs", "crash", "reload", "performance", "wait", "session_report",
701
+ ]);
702
+ /** A look's picture arguments for the call log: booleans and numbers only; undefined when it gave none. */
703
+ function pictureArgs(input) {
704
+ const out = {};
705
+ if (typeof input["screenshot"] === "boolean")
706
+ out.screenshot = input["screenshot"];
707
+ if (typeof input["width"] === "number" && Number.isFinite(input["width"]))
708
+ out.width = input["width"];
709
+ if (typeof input["labels"] === "boolean")
710
+ out.labels = input["labels"];
711
+ return Object.keys(out).length > 0 ? out : undefined;
712
+ }
713
+ /** Tools whose answer carries the changes or the full list unless the call said `look: false`. */
714
+ const LISTING_TOOLS = new Set(["tap", "long_press", "type_text", "scroll", "toggle", "swipe", "back", "open_url", "app_command", "run"]);
715
+ /** Tools that act on one element: after a run of scrolls they log whether the element was already in the list. */
716
+ const ELEMENT_ACTIONS = new Set(["tap", "long_press", "type_text", "toggle"]);
660
717
  /** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
661
718
  export class TaplineTools {
662
719
  host;
@@ -670,9 +727,28 @@ export class TaplineTools {
670
727
  batch;
671
728
  /** Extra fields for the next log line (a dry run, a confirmed touch). */
672
729
  logExtra;
730
+ /** The call log (AGENT-TOOLS 1.11 §16.1): this run's id, when it began, and how many calls the agent has made. */
731
+ sessionTag = randomBytes(4).toString("hex");
732
+ startedAt = Date.now();
733
+ callCount = 0;
734
+ /** The call in flight (and everything it awaits): its log entries wait here for the size of its answer. */
735
+ callScope = new AsyncLocalStorage();
736
+ clientIdFn;
737
+ /** The tool of the previous call, for scroll runs. */
738
+ prevCallTool;
739
+ /**
740
+ * The latest full element list the agent was given. `stale`: only changes since. `unseen`: an action since then
741
+ * answered with no list and no changes (`look: false`), so the screen may be another one: neither "in the
742
+ * list" nor "not in it" can be said until a list is seen again.
743
+ */
744
+ listBase;
745
+ /** The list as it was before the current run of scrolls began. */
746
+ scrollBase;
673
747
  confirmations;
674
748
  policy;
675
749
  reloadSettleMs;
750
+ stillGapMs;
751
+ stillCapMs;
676
752
  noScreenshot = new Set();
677
753
  shotFiles = [];
678
754
  /** Sessions this agent has had an element list for (so later lists can be just the changes). */
@@ -719,6 +795,8 @@ export class TaplineTools {
719
795
  this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
720
796
  this.actionLog?.open();
721
797
  this.reloadSettleMs = options.reloadSettleMs ?? 1500;
798
+ this.stillGapMs = options.stillGapMs ?? 150;
799
+ this.stillCapMs = options.stillCapMs ?? 2000;
722
800
  // A finger on the phone itself: the app saw a real touch. It can't be queued like viewer input
723
801
  // (it has already happened), so it only leaves the note and makes `{ref}` targets stale.
724
802
  host.on("touch", (session) => {
@@ -734,6 +812,120 @@ export class TaplineTools {
734
812
  const method = original;
735
813
  Reflect.set(this, name, (...args) => this.noting(() => method.apply(this, args)));
736
814
  }
815
+ // Outermost: every call of the agent is numbered and logged (reads too) once its answer is known.
816
+ for (const [name, tool] of Object.entries(CALL_TOOLS)) {
817
+ const original = Reflect.get(this, name);
818
+ if (typeof original !== "function")
819
+ continue;
820
+ const method = original;
821
+ Reflect.set(this, name, (...args) => this.loggedCall(tool, args[0], () => method.apply(this, args)));
822
+ }
823
+ }
824
+ /** The agent's client id for the call log; the MCP server passes the client's name once it is known. */
825
+ setClientId(read) {
826
+ this.clientIdFn = read;
827
+ }
828
+ /** A call refused before it reached its tool (an unknown argument): numbered and logged like any other, so the report counts it. */
829
+ rejectedCall(tool, args, answer) {
830
+ const result = this.loggedCall(tool, args, () => answer);
831
+ return result instanceof Promise ? answer : result;
832
+ }
833
+ /** Numbers a call of the agent and logs it when its answer is known (§16.1). Calls within a call (`run`'s steps) are part of it. */
834
+ loggedCall(tool, args, run) {
835
+ if (this.actionLog === null || this.callScope.getStore() !== undefined)
836
+ return run();
837
+ const afterScrolls = this.prevCallTool === "scroll";
838
+ if (tool === "scroll" && !afterScrolls)
839
+ this.scrollBase = this.listBase;
840
+ this.prevCallTool = tool;
841
+ const state = { call: ++this.callCount, tool, afterScrolls, entries: [] };
842
+ const began = new Date();
843
+ let result;
844
+ try {
845
+ result = this.callScope.run(state, run);
846
+ }
847
+ catch (error) {
848
+ this.flushCall(state, args, began, undefined);
849
+ throw error;
850
+ }
851
+ if (result instanceof Promise) {
852
+ return result.then((answer) => {
853
+ this.flushCall(state, args, began, answer);
854
+ return answer;
855
+ }, (error) => {
856
+ this.flushCall(state, args, began, undefined);
857
+ throw error;
858
+ });
859
+ }
860
+ this.flushCall(state, args, began, result);
861
+ return result;
862
+ }
863
+ flushCall(state, args, began, answer) {
864
+ if (this.actionLog === null)
865
+ return;
866
+ const said = (answer?.content ?? []).map((block) => (block.type === "text" ? block.text : ""));
867
+ const size = said.reduce((sum, part) => sum + part.length, 0);
868
+ const failed = answer === undefined || answer.isError === true;
869
+ const input = typeof args === "object" && args !== null ? args : {};
870
+ const listed = LISTING_TOOLS.has(state.tool) && !failed && input["look"] !== false && input["dry_run"] !== true && !(state.tool === "app_command" && input["name"] === undefined);
871
+ const client = (this.clientIdFn ?? this.options.clientId)?.()?.replace(/[^\w .@/-]/g, "").slice(0, 60);
872
+ const common = {
873
+ session: this.sessionTag,
874
+ call: state.call,
875
+ ...(client !== undefined && client !== "" ? { client } : {}),
876
+ size,
877
+ };
878
+ const code = failed ? /^Error (\w+):/.exec(said.join("\n"))?.[1] : undefined;
879
+ if (state.entries.length > 0) {
880
+ // Actions that went through, in a call that failed after them (an expectation that didn't hold): the call's
881
+ // own outcome goes with them, so the report doesn't count it as a success (Codex review of 1.4.0).
882
+ const callFailed = failed && state.entries.every(({ entry }) => entry.ok) ? { call_failed: code ?? "internal" } : {};
883
+ for (const { entry, at } of state.entries)
884
+ this.actionLog.write({ ...entry, ...common, ...callFailed, ...(listed ? { listed: true } : {}) }, at);
885
+ return;
886
+ }
887
+ // A call that logged no action: a read, or an action refused before it began (then without `kind`).
888
+ const session = this.host.current();
889
+ const lookArgs = state.tool === "look" ? pictureArgs(input) : undefined;
890
+ this.actionLog.write({
891
+ by: "agent",
892
+ tool: state.tool,
893
+ ...(READ_TOOLS.has(state.tool) ? { kind: "read" } : {}),
894
+ // the target only (as given); typed text, pictures and log text never
895
+ ...(loggedTarget(args).target !== undefined ? { target: loggedTarget(args).target } : {}),
896
+ ok: !failed,
897
+ ...(code !== undefined ? { code } : {}),
898
+ ms: Date.now() - began.getTime(),
899
+ ...(session !== undefined ? { app: session.app.id ?? session.app.name } : {}),
900
+ ...common,
901
+ ...(listed ? { listed: true } : {}),
902
+ ...(lookArgs !== undefined ? { look_args: lookArgs } : {}),
903
+ }, began);
904
+ }
905
+ /**
906
+ * Whether a touch's target was in the list the agent had before its run of scrolls: true (it was), false (it
907
+ * wasn't), undefined (can't be known: no list, a point, or a list that only changed since).
908
+ */
909
+ knownBeforeScrolls(params) {
910
+ const base = this.scrollBase;
911
+ const target = typeof params === "object" && params !== null ? params.target : undefined;
912
+ if (base === undefined || typeof target !== "object" || target === null)
913
+ return undefined;
914
+ const t = target;
915
+ if (!["ref", "testID", "label", "text"].some((key) => typeof t[key] === "string"))
916
+ return undefined;
917
+ if (base.unseen)
918
+ return undefined;
919
+ if (candidatesFor(target, base.elements).length > 0)
920
+ return true;
921
+ return base.stale ? undefined : false;
922
+ }
923
+ /** `session_report` (AGENT-TOOLS 1.11 §16.1): where this session's calls went, and the waste in them. */
924
+ sessionReport(args = {}) {
925
+ const dir = this.options.logDir;
926
+ if (dir === undefined)
927
+ return fail("Error unsupported: the action log is off (--no-log), so there is nothing to report.");
928
+ return text(reportFor(dir, args.since ?? this.startedAt, this.sessionTag));
737
929
  }
738
930
  /**
739
931
  * Runs a tool call and ends its answer with a note when the app stopped (AGENT-TOOLS 15.2): during this
@@ -749,10 +941,32 @@ export class TaplineTools {
749
941
  return this.noteScope.run(true, () => {
750
942
  const result = run();
751
943
  if (result instanceof Promise)
752
- return result.then((answer) => this.withCrashNotes(answer, mark, startedWith));
753
- return this.withCrashNotes(result, mark, startedWith);
944
+ return result.then((answer) => this.withPersonNote(this.withCrashNotes(answer, mark, startedWith)));
945
+ return this.withPersonNote(this.withCrashNotes(result, mark, startedWith));
754
946
  });
755
947
  }
948
+ /** The first call has been answered, so the "needs a person" note has had its turn. */
949
+ personNoteDone = false;
950
+ /**
951
+ * The first answer of the session says what only a person can do, once, when something is pending: the app isn't
952
+ * connected (open it on the phone, same Wi-Fi), and the phone has never paired. Nothing pending: never.
953
+ */
954
+ withPersonNote(answer) {
955
+ if (this.personNoteDone || this.options.pairedBefore === undefined)
956
+ return answer;
957
+ if (this.host.current() !== undefined) {
958
+ this.personNoteDone = true;
959
+ return answer;
960
+ }
961
+ if (answer.content[0]?.type !== "text")
962
+ return answer; // nowhere to put it yet: the next answer says it
963
+ const items = [];
964
+ if (!this.options.pairedBefore())
965
+ items.push("the phone isn't paired yet (call `pair`, then the user scans the QR code or taps Connect)");
966
+ items.push("the app isn't connected (the user opens it on the phone, on the same Wi-Fi as this computer)");
967
+ this.personNoteDone = true;
968
+ return appendNote(answer, `Note: needs a person: ${items.join("; ")}.`);
969
+ }
756
970
  /** What was told about this app's crashes so far: the crash events up to this number. */
757
971
  notedSeqOf(app) {
758
972
  return this.notedCrashSeqs.get(identityOf(app)) ?? this.crashBaseline;
@@ -837,6 +1051,7 @@ export class TaplineTools {
837
1051
  }
838
1052
  // -- pair
839
1053
  async pair(args) {
1054
+ this.personNoteDone = true; // its answer says what the person does; no "call pair" note on it
840
1055
  const { url, name } = this.options.connect;
841
1056
  // Zero-QR pairing (Tapline 1.1): a development build whose JavaScript comes from this computer finds the
842
1057
  // offer itself and asks its person to connect. Open for 10 minutes, one use, local network only.
@@ -1004,7 +1219,9 @@ export class TaplineTools {
1004
1219
  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(", ")}.`);
1005
1220
  }
1006
1221
  const shape = toolShapes[step.tool];
1007
- const { tool: _tool, expect: _expect, route: _route, ...params } = step;
1222
+ const { tool: _tool, expect: _expect, route: _route, ...given } = step;
1223
+ // §9.1: a step's own look and screenshot are ignored (the batch answers once), whether its tool has them or not.
1224
+ const params = Object.fromEntries(Object.entries(given).filter(([key]) => !(key === "look" || key === "screenshot") || key in shape));
1008
1225
  if (step.tool === "compare_screen" && params.update !== undefined) {
1009
1226
  return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
1010
1227
  }
@@ -1037,6 +1254,12 @@ export class TaplineTools {
1037
1254
  return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch reloads the app and tap taps in it; give one of them.`);
1038
1255
  }
1039
1256
  }
1257
+ // AGENT-TOOLS 1.11 §4: the shared wording, with the step first.
1258
+ const known = Object.keys(shape);
1259
+ const stray = Object.keys(params).filter((key) => !known.includes(key));
1260
+ if (stray.length > 0) {
1261
+ return fail(`Error bad_params: step ${i + 1}: ${step.tool} doesn't take ${stray.join(", ")}; ${known.length === 0 ? "it takes no arguments" : `it takes ${known.join(", ")}`}.`);
1262
+ }
1040
1263
  const parsed = z.object(shape).strict().safeParse(params);
1041
1264
  if (!parsed.success) {
1042
1265
  return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
@@ -1145,6 +1368,7 @@ export class TaplineTools {
1145
1368
  let response;
1146
1369
  const live = this.host.current();
1147
1370
  if (args.look === false || live === undefined) {
1371
+ this.markListUnseen();
1148
1372
  response = text([first, ...tail].join("\n"));
1149
1373
  }
1150
1374
  else {
@@ -1409,6 +1633,11 @@ export class TaplineTools {
1409
1633
  else
1410
1634
  this.alertOpen.delete(sid);
1411
1635
  const changesText = askChanges ? formatChanges(tree, { alertGone }) : null;
1636
+ // For the call log: the list the agent now has (a change block leaves it as it was, but no longer certain).
1637
+ if (changesText === null)
1638
+ this.listBase = { elements: tree.elements, stale: false, unseen: false };
1639
+ else if (this.listBase !== undefined)
1640
+ this.listBase = { elements: this.listBase.elements, stale: true, unseen: false };
1412
1641
  // A full list gives every ref a fresh meaning, so refs are trustworthy again.
1413
1642
  if (changesText === null)
1414
1643
  this.personActed = false;
@@ -1478,6 +1707,11 @@ export class TaplineTools {
1478
1707
  this.personNotePending = false;
1479
1708
  return ["Note: a person used the app since your last look."];
1480
1709
  }
1710
+ /** An action answered with no list and no changes: the call log can't say what is in the list any more. */
1711
+ markListUnseen() {
1712
+ if (this.listBase !== undefined)
1713
+ this.listBase = { ...this.listBase, unseen: true };
1714
+ }
1481
1715
  /** Runs a request, then (unless disabled) appends the fresh element list and how long the app took to settle. */
1482
1716
  async act(method, params, describe, withLook, screenshot, extraNotes,
1483
1717
  /** True when the result means nothing was done: the first line is then the whole answer. */
@@ -1525,6 +1759,7 @@ export class TaplineTools {
1525
1759
  ? `Settled in ${(settledMs / 1000).toFixed(2)} s.`
1526
1760
  : undefined;
1527
1761
  if (withLook === false) {
1762
+ this.markListUnseen();
1528
1763
  this.unlistedActions.set(session.id, (this.unlistedActions.get(session.id) ?? 0) + 1);
1529
1764
  response = text([line, ...this.takePersonNote()].join("\n"));
1530
1765
  }
@@ -1643,7 +1878,8 @@ export class TaplineTools {
1643
1878
  ? elementSchema.safeParse(result.element)
1644
1879
  : null;
1645
1880
  const session = this.host.current();
1646
- this.actionLog.write({
1881
+ const state = by === "agent" ? this.callScope.getStore() : undefined;
1882
+ const entry = {
1647
1883
  by,
1648
1884
  tool,
1649
1885
  ...loggedTarget(params),
@@ -1656,9 +1892,22 @@ export class TaplineTools {
1656
1892
  ...(session !== undefined ? { app: session.app.id ?? session.app.name } : {}),
1657
1893
  ...(by === "agent" && this.batch !== undefined ? { batch: this.batch } : {}),
1658
1894
  ...(by === "agent" ? (this.logExtra ?? {}) : {}),
1659
- });
1895
+ };
1660
1896
  if (by === "agent")
1661
1897
  this.logExtra = undefined;
1898
+ if (state === undefined) {
1899
+ this.actionLog.write(entry);
1900
+ return;
1901
+ }
1902
+ if (state.tool !== tool)
1903
+ entry.of = state.tool;
1904
+ // After scrolls: was the target already in the list before the first one? (a scroll hunt is exact when it was)
1905
+ if (state.afterScrolls && state.tool === tool && ELEMENT_ACTIONS.has(tool)) {
1906
+ const known = this.knownBeforeScrolls(params);
1907
+ if (known !== undefined)
1908
+ entry.in_list = known;
1909
+ }
1910
+ state.entries.push({ entry, at: new Date() });
1662
1911
  }
1663
1912
  showAction(by, method, result, params, code, reason) {
1664
1913
  const onAction = this.options.onAction;
@@ -1914,7 +2163,7 @@ export class TaplineTools {
1914
2163
  async checkAgainstReference(session, name, options) {
1915
2164
  let shot;
1916
2165
  try {
1917
- shot = await this.takeShot(session.id, options.width, 0.9);
2166
+ shot = await this.stillShot(session.id, options.width, 0.9);
1918
2167
  }
1919
2168
  catch (error) {
1920
2169
  return { kind: "error", line: describeError(error) };
@@ -2062,10 +2311,35 @@ export class TaplineTools {
2062
2311
  // the failure still reads without the picture
2063
2312
  }
2064
2313
  }
2314
+ /**
2315
+ * A picture of the screen once it stopped moving: two in a row about 150 ms apart that agree (under 0.1% changed),
2316
+ * at most 2 s. The app's settle sees React commits, not a native transition, so a screen pushed by the action before
2317
+ * can still be sliding in (found on the phone, 2026-11-12). Past the cap, the last picture.
2318
+ */
2319
+ async stillShot(sessionId, width, quality) {
2320
+ let previous = await this.takeShot(sessionId, width, quality);
2321
+ const started = Date.now();
2322
+ while (Date.now() - started < this.stillCapMs) {
2323
+ await sleep(this.stillGapMs);
2324
+ const next = await this.takeShot(sessionId, width, quality);
2325
+ if (next.data === previous.data)
2326
+ return next;
2327
+ try {
2328
+ const diff = diffImages(previous, next, { mask: [] });
2329
+ if (diff.changedPercent < 0.1)
2330
+ return next;
2331
+ }
2332
+ catch {
2333
+ return next; // sizes differ (a rotation): nothing to wait for
2334
+ }
2335
+ previous = next;
2336
+ }
2337
+ return previous;
2338
+ }
2065
2339
  /** Takes the screen and saves it as the reference `name` in `dir`. */
2066
2340
  async saveReferenceHere(session, name, width, dir) {
2067
2341
  try {
2068
- return this.writeReference(dir, name, await this.takeShot(session.id, width, 0.9));
2342
+ return this.writeReference(dir, name, await this.stillShot(session.id, width, 0.9));
2069
2343
  }
2070
2344
  catch (error) {
2071
2345
  return fail(describeError(error));
@@ -2243,7 +2517,7 @@ export class TaplineTools {
2243
2517
  const save = (shot) => this.writeReference(folder, name, shot);
2244
2518
  if (args.update === true) {
2245
2519
  try {
2246
- return save(await this.takeShot(session.id, width, 0.9));
2520
+ return save(await this.stillShot(session.id, width, 0.9));
2247
2521
  }
2248
2522
  catch (error) {
2249
2523
  return fail(describeError(error));
@@ -2566,7 +2840,7 @@ export class TaplineTools {
2566
2840
  this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "blocked");
2567
2841
  return fail(this.blockedLine(verdict.element, verdict.word));
2568
2842
  }
2569
- const would = this.wouldLine(tool, shown, resolved.point, resolved.value);
2843
+ const would = this.wouldLine(tool, shown, resolved.point, resolved.value, resolved.scrolls);
2570
2844
  const needs = verdict.kind === "confirm" ? ` It needs a person's OK (policy word ${JSON.stringify(verdict.word)}).` : "";
2571
2845
  this.showAction("agent", method, shown !== undefined ? { element: shown } : undefined, params);
2572
2846
  return text([`${would}${needs}`, ...this.takePersonNote()].join("\n"));
@@ -2684,7 +2958,7 @@ export class TaplineTools {
2684
2958
  return fail(resolved.text);
2685
2959
  }
2686
2960
  this.showAction("agent", method, resolved.elements[0] !== undefined ? { element: resolved.elements[0] } : undefined, params);
2687
- return text([this.wouldLine(tool, resolved.elements[0], resolved.point, resolved.value), ...this.takePersonNote()].join("\n"));
2961
+ return text([this.wouldLine(tool, resolved.elements[0], resolved.point, resolved.value, resolved.scrolls), ...this.takePersonNote()].join("\n"));
2688
2962
  }
2689
2963
  /**
2690
2964
  * What a touch would act on. Apps that know dry runs resolve it themselves, with every check; older
@@ -2713,6 +2987,7 @@ export class TaplineTools {
2713
2987
  elements: parsed.data.element ? [parsed.data.element] : [],
2714
2988
  ...(parsed.data.point !== undefined ? { point: parsed.data.point } : {}),
2715
2989
  ...(parsed.data.value !== undefined ? { value: parsed.data.value } : {}),
2990
+ ...(parsed.data.scrolls === true ? { scrolls: true } : {}),
2716
2991
  };
2717
2992
  }
2718
2993
  // An app without dry runs: a swipe is checked by what lies under its start.
@@ -2743,24 +3018,27 @@ export class TaplineTools {
2743
3018
  };
2744
3019
  }
2745
3020
  /** `Would tap e12 button "Delete trip" at 215,640.` */
2746
- wouldLine(tool, element, point, value) {
2747
- const name = element !== undefined ? this.shortName(element, true) : "the element";
3021
+ wouldLine(tool, element, point, value, scrolls) {
3022
+ const name = element !== undefined ? this.shortName(element, true, scrolls === true) : "the element";
3023
+ // Off screen (§16.2): `Would scroll to it, then tap e41 button "Save" id=save at 215,640.` The point is the one expected after the scroll.
3024
+ const lead = scrolls === true ? "Would scroll to it, then" : "Would";
2748
3025
  const at = point !== undefined ? ` at ${point.x},${point.y}` : "";
2749
3026
  switch (tool) {
2750
3027
  case "long_press":
2751
- return `Would long-press ${name}${at}.`;
3028
+ return `${lead} long-press ${name}${at}.`;
2752
3029
  case "toggle":
2753
- return `Would toggle ${name}${typeof value === "boolean" ? ` to ${String(value)}` : ""}${at}.`;
3030
+ return `${lead} toggle ${name}${typeof value === "boolean" ? ` to ${String(value)}` : ""}${at}.`;
2754
3031
  case "type_text":
2755
- return `Would type into ${name}.`;
3032
+ return `${lead} type into ${name}${scrolls === true ? at : ""}.`;
2756
3033
  default:
2757
- return `Would tap ${name}${at}.`;
3034
+ return `${lead} tap ${name}${at}.`;
2758
3035
  }
2759
3036
  }
2760
3037
  /** `e12 button "Delete trip"`: ref (when asked), role and name, nothing else. */
2761
- shortName(element, withRef) {
3038
+ shortName(element, withRef, withId = false) {
2762
3039
  return formatElement({
2763
3040
  ref: withRef ? element.ref : "",
3041
+ ...(withId ? { testID: element.testID ?? null } : {}),
2764
3042
  role: element.role ?? null,
2765
3043
  type: element.type ?? null,
2766
3044
  label: element.label ?? null,
@@ -2782,12 +3060,12 @@ export class TaplineTools {
2782
3060
  }
2783
3061
  tap(args) {
2784
3062
  const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
2785
- 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));
3063
+ return this.guarded("tap", "press", params, args.dry_run === true, (preflight) => this.act("press", params, (r) => afterScroll(r, `Tapped ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
2786
3064
  }
2787
3065
  /** `seconds` is accepted for the shared interface; Tapline calls the handler, so it doesn't hold. */
2788
3066
  longPress(args) {
2789
3067
  const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
2790
- 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));
3068
+ return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => afterScroll(r, `Long-pressed ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
2791
3069
  }
2792
3070
  typeText(args) {
2793
3071
  const params = {
@@ -2807,7 +3085,7 @@ export class TaplineTools {
2807
3085
  // AGENT-TOOLS 1.8 §3.3: a secure field's value is never shown, not even as bullets in this line
2808
3086
  const secure = typeof r === "object" && r !== null && "secure" in r && r.secure === true;
2809
3087
  const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
2810
- return `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`;
3088
+ return afterScroll(r, `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`);
2811
3089
  }, args.look, args.screenshot);
2812
3090
  }
2813
3091
  scroll(args) {
@@ -2876,8 +3154,8 @@ export class TaplineTools {
2876
3154
  return this.guarded("toggle", "toggle", params, args.dry_run === true, (preflight) => this.act("toggle", params, (r) => {
2877
3155
  const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
2878
3156
  if (unchanged(r))
2879
- return `${this.elementName(r)} is already ${JSON.stringify(value)}.`;
2880
- return `Toggled ${this.elementName(r)}${value !== undefined ? ` to ${JSON.stringify(value)}` : ""}.`;
3157
+ return `${scrolledFirst(r) ? "Scrolled to it; " : ""}${this.elementName(r)} is already ${JSON.stringify(value)}.`;
3158
+ return afterScroll(r, `Toggled ${this.elementName(r)}${value !== undefined ? ` to ${JSON.stringify(value)}` : ""}.`);
2881
3159
  }, args.look, args.screenshot, undefined, unchanged, preflight));
2882
3160
  }
2883
3161
  async appCommand(args) {
@@ -2901,8 +3179,28 @@ export class TaplineTools {
2901
3179
  : `${session.app.name} offers no commands.`);
2902
3180
  }
2903
3181
  const name = args.name.trim();
3182
+ // AGENT-TOOLS 1.11 §16.4: a policy whose block list holds `network` refuses a condition; `normal` always goes.
3183
+ if (name === "network") {
3184
+ const mode = args.args?.mode;
3185
+ const app = this.host.current()?.app;
3186
+ const names = (words) => (words ?? []).some((word) => word.trim().toLowerCase() === "network");
3187
+ const own = app === undefined ? undefined : (this.policy.apps?.[app.id ?? ""] ?? this.policy.apps?.[app.name]);
3188
+ if (mode !== "normal" && names(listsFor(this.policy, app).block)) {
3189
+ const where = own?.block !== undefined && app !== undefined ? `for ${app.name}` : "here";
3190
+ return fail(`Error not_allowed: blocked: the safety policy blocks network ${where}.`);
3191
+ }
3192
+ }
2904
3193
  return this.act("command", { name, args: args.args ?? {} }, (r) => {
2905
3194
  const result = typeof r === "object" && r !== null && "result" in r ? r.result : null;
3195
+ // Built in `network`: the same line every header carries while a condition is on (AGENT-TOOLS §16.4),
3196
+ // then one sentence on what it reaches.
3197
+ if (name === "network" && typeof result === "object" && result !== null && "mode" in result && typeof result.mode === "string") {
3198
+ const covers = "covers" in result && typeof result.covers === "string" ? ` ${result.covers}` : "";
3199
+ return `${networkLine(result.mode)}${covers}`;
3200
+ }
3201
+ // Standard `language`, `region` and `text_size` (AGENT-TOOLS §16.3): the app's own sentence, e.g. `Text size xl (1.12×).`
3202
+ if (typeof result === "object" && result !== null && "say" in result && typeof result.say === "string")
3203
+ return result.say;
2906
3204
  return result === null || result === undefined ? `Ran ${name}.` : `Ran ${name}: ${JSON.stringify(result)}`;
2907
3205
  }, args.look, args.screenshot);
2908
3206
  }
@@ -3022,8 +3320,10 @@ export class TaplineTools {
3022
3320
  if (!worked)
3023
3321
  return fail(describeError(error));
3024
3322
  }
3025
- if (args.look === false)
3323
+ if (args.look === false) {
3324
+ this.markListUnseen();
3026
3325
  return text("Reload requested.");
3326
+ }
3027
3327
  const deadline = Date.now() + this.reloadWaitMs;
3028
3328
  for (;;) {
3029
3329
  const now = this.host.current();