tapline-host 1.4.0 → 1.5.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 (49) hide show
  1. package/dist/audit.d.ts +2 -0
  2. package/dist/audit.js +1 -1
  3. package/dist/audit.js.map +1 -1
  4. package/dist/cli.js +4 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/format.d.ts +4 -0
  7. package/dist/format.js +61 -20
  8. package/dist/format.js.map +1 -1
  9. package/dist/images.d.ts +27 -0
  10. package/dist/images.js +50 -0
  11. package/dist/images.js.map +1 -1
  12. package/dist/journey.d.ts +259 -0
  13. package/dist/journey.js +336 -0
  14. package/dist/journey.js.map +1 -0
  15. package/dist/journeyexport.d.ts +58 -0
  16. package/dist/journeyexport.js +318 -0
  17. package/dist/journeyexport.js.map +1 -0
  18. package/dist/journeygif.d.ts +62 -0
  19. package/dist/journeygif.js +192 -0
  20. package/dist/journeygif.js.map +1 -0
  21. package/dist/journeyreport.d.ts +202 -0
  22. package/dist/journeyreport.js +664 -0
  23. package/dist/journeyreport.js.map +1 -0
  24. package/dist/mcp.d.ts +1 -0
  25. package/dist/mcp.js +27 -1
  26. package/dist/mcp.js.map +1 -1
  27. package/dist/media.d.ts +86 -0
  28. package/dist/media.js +244 -0
  29. package/dist/media.js.map +1 -0
  30. package/dist/protocol.d.ts +4 -0
  31. package/dist/protocol.js +2 -0
  32. package/dist/protocol.js.map +1 -1
  33. package/dist/run.js +1 -1
  34. package/dist/run.js.map +1 -1
  35. package/dist/server.d.ts +2 -0
  36. package/dist/server.js +8 -0
  37. package/dist/server.js.map +1 -1
  38. package/dist/tool-text-data.d.ts +3 -0
  39. package/dist/tool-text-data.js +3 -0
  40. package/dist/tool-text-data.js.map +1 -1
  41. package/dist/tools.d.ts +213 -0
  42. package/dist/tools.js +1075 -28
  43. package/dist/tools.js.map +1 -1
  44. package/dist/uxcheck.d.ts +102 -0
  45. package/dist/uxcheck.js +128 -0
  46. package/dist/uxcheck.js.map +1 -0
  47. package/package.json +2 -1
  48. package/skills/tapline/SKILL.md +7 -0
  49. package/skills/tapline/references/personas.md +45 -0
package/dist/tools.js CHANGED
@@ -12,9 +12,14 @@ import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit
12
12
  import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
13
13
  import { ActionLog, loggedTarget } from "./actionlog.js";
14
14
  import { reportFor } from "./report.js";
15
+ import { EYES, IDS_HIDDEN, Journey, OPEN_URL, conditionsSchema, exportSchema, summaryLine, summaryNotes, stressSchema, survivesSchema, withoutIds, } from "./journey.js";
16
+ import { JOURNEYS_KEPT, JourneyRecorder, findingsLine, freshFolder, pruneJourneys, screenSignature, writeReport } from "./journeyreport.js";
17
+ import { exportRefusal, exportReport } from "./journeyexport.js";
15
18
  import { randomBytes } from "node:crypto";
16
19
  import { Confirmations } from "./confirm.js";
17
20
  import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
21
+ import { NO_UX_RULES, formatUxCheck, uxResultSchema } from "./uxcheck.js";
22
+ import { CONFIRM_ERROR, MediaState, mediaShape, runMedia } from "./media.js";
18
23
  import { readScript, refStep, scriptSteps } from "./script.js";
19
24
  import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
20
25
  import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
@@ -101,6 +106,7 @@ export const toolShapes = {
101
106
  .describe("Draw each actionable element's box and ref (e12) on the screenshot, so whoever views it can name elements by ref"),
102
107
  },
103
108
  check_layout: {},
109
+ ux_check: {},
104
110
  audit: {
105
111
  types: z
106
112
  .array(z.enum(AUDIT_TYPES))
@@ -252,9 +258,31 @@ export const toolShapes = {
252
258
  tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
253
259
  },
254
260
  wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
261
+ media: mediaShape,
255
262
  session_report: {
256
263
  since: z.number().min(0).optional().describe("Epoch milliseconds: report only calls from then on (default: the start of this session)"),
257
264
  },
265
+ journey: {
266
+ start: z.boolean().optional().describe("Start a journey (needs goal); one journey at a time"),
267
+ goal: z.string().min(1).max(300).optional().describe('What the user wants to do, e.g. "log breakfast by scanning its barcode"'),
268
+ persona: z.string().max(500).optional().describe("Who is using the app: free text, or the name of an example persona"),
269
+ conditions: conditionsSchema
270
+ .optional()
271
+ .describe("language, region, text_size, network: applied at the start, undone at the stop"),
272
+ allow: z
273
+ .array(z.string().min(1).max(60))
274
+ .max(30)
275
+ .optional()
276
+ .describe('Shortcuts the journey may use: "open_url", and the names of app commands'),
277
+ eyes: z.enum(EYES).optional().describe('"user" (default): see the screen as a user does; "agent" turns that off for the rest of the journey'),
278
+ stop: z.boolean().optional().describe("End the journey (needs reached)"),
279
+ reached: z.boolean().optional().describe("With stop: whether the goal was reached"),
280
+ why: z.string().max(1000).optional().describe("With stop: why, in a line"),
281
+ export: exportSchema.optional().describe("With stop, only when your user asks: put the report in a GitHub PR or issue. Needs confirm: true; public: true for a public repository, fork: true for a PR from a fork"),
282
+ stress: stressSchema.optional().describe("A real-world moment during the journey: network (with mode), double_tap (with target), relaunch (a reload in development builds), background or rotate (a person does them, then call again with done)"),
283
+ survives: survivesSchema.optional().describe("With stress: what must still be there after, {text} or {goal_step}"),
284
+ done: z.boolean().optional().describe("With stress: the person has done the moment you asked them to (background or rotate)"),
285
+ },
258
286
  run: {
259
287
  steps: z
260
288
  .array(stepSchema)
@@ -382,6 +410,9 @@ const scriptSchema = z.object({
382
410
  /** A target that works again on a new run: testID, else label, else visible text; refs only work for the run they came from. */
383
411
  function stableParams(params, result) {
384
412
  const copy = typeof params === "object" && params !== null ? { ...params } : {};
413
+ // A journey's own flags are not part of a script.
414
+ delete copy["noScroll"];
415
+ delete copy["glass"];
385
416
  const target = copy["target"];
386
417
  if (typeof target !== "object" || target === null)
387
418
  return { params: copy, fragile: false };
@@ -503,6 +534,7 @@ function deviceLabel(s) {
503
534
  export const DEFAULT_SCREENSHOT_WIDTH = 600;
504
535
  /** The app methods whose result may say `closedKeyboard: true` (the app closed the keyboard before acting). */
505
536
  const KEYBOARD_METHODS = new Set(["press", "longPress", "toggle", "type"]);
537
+ const SECURE_QUIET_NOTE = "Note: no screenshot: a password field was typed into a moment ago and iOS may still show its last character; look again in a second.";
506
538
  const NO_SCREENSHOT_NOTE = "Note: no screenshot: this app doesn't support it; add react-native-view-shot.";
507
539
  function sleep(ms) {
508
540
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -536,6 +568,18 @@ function toWireTarget(target) {
536
568
  return withIndex({ text: target.text });
537
569
  return { x: target.x ?? 0, y: target.y ?? 0 };
538
570
  }
571
+ /** The target of a call as the agent wrote it, in words (`"Save"`, `e3`, `120,300`); undefined when it gave none. */
572
+ function journeyTargetWords(input) {
573
+ const raw = input["target"] ?? input["tap"];
574
+ const parsed = typeof raw === "object" && raw !== null ? targetSchema.safeParse(raw) : null;
575
+ if (parsed?.success === true)
576
+ return targetWords(parsed.data);
577
+ if (typeof input["url"] === "string")
578
+ return input["url"];
579
+ if (typeof input["name"] === "string")
580
+ return input["name"];
581
+ return undefined;
582
+ }
539
583
  /** A target as the agent wrote it, in words: `id=save`, `"Save"`, `e3`, `120,300`. */
540
584
  function targetWords(target) {
541
585
  if (target.id !== undefined)
@@ -580,6 +624,7 @@ function candidateLines(details) {
580
624
  }
581
625
  /** At most this many candidates are listed in an `ambiguous` answer (AGENT-TOOLS §4). */
582
626
  const MAX_CANDIDATES = 5;
627
+ const NO_JOURNEY = "Error bad_params: no journey is running; start one with start: true and a goal.";
583
628
  /**
584
629
  * The `ambiguous` block (AGENT-TOOLS §4): the message, `Candidates:` with up to 5 lines indented two
585
630
  * spaces, a `(+N more; …)` line when there are more, then the retry line. `total` is the number of
@@ -682,21 +727,37 @@ function candidatesFor(target, elements) {
682
727
  // ---- tool handlers ----------------------------------------------------------
683
728
  /** Every tool whose answer can end with a crash note (all but `crash`, which tells it in full). */
684
729
  const CRASH_NOTED_TOOLS = [
685
- "apps", "openApp", "pair", "look", "run", "inspect", "audit", "checkLayout", "viewer", "compareScreen", "filmstrip",
730
+ "apps", "openApp", "pair", "look", "run", "inspect", "audit", "uxCheck", "media", "checkLayout", "viewer", "compareScreen", "filmstrip",
686
731
  "record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
687
732
  "openUrl", "logs", "reload", "performance", "wait",
688
733
  ];
689
734
  /** Every tool the agent calls, by handler name (the call log numbers these). */
690
735
  const CALL_TOOLS = {
691
736
  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",
737
+ uxCheck: "ux_check", media: "media", checkLayout: "check_layout", viewer: "viewer", compareScreen: "compare_screen", filmstrip: "filmstrip", record: "record",
693
738
  replay: "replay", tap: "tap", longPress: "long_press", typeText: "type_text", scroll: "scroll", toggle: "toggle",
694
739
  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",
740
+ crash: "crash", reload: "reload", performance: "performance", wait: "wait", sessionReport: "session_report", journey: "journey",
696
741
  };
742
+ /** The calls a journey counts and measures (AGENT-TOOLS 1.12 §17.1): the agent's looks and actions. */
743
+ const JOURNEY_TOOLS = new Set([
744
+ "look", "run", "inspect", "audit", "ux_check", "check_layout", "compare_screen", "filmstrip", "tap", "long_press", "type_text", "scroll",
745
+ "toggle", "app_command", "wait_for", "swipe", "back", "open_url", "logs", "crash", "wait",
746
+ ]);
747
+ /** A stress double tap's two taps, this many ms apart (AGENT-TOOLS 1.12 §17.4): the app makes the second. */
748
+ const DOUBLE_TAP_GAP_MS = 80;
749
+ /** What the app says of a double tap's second tap. */
750
+ const pressSecondSchema = z.union([
751
+ z.object({ done: z.literal(true), afterMs: z.number() }),
752
+ z.object({ done: z.literal(false), reason: z.string().regex(/^[a-z_]{1,40}$/), afterMs: z.number() }),
753
+ ]);
754
+ /** The calls after which a journey keeps a small picture of the screen (§17.5); the rest only read text. */
755
+ const FRAME_TOOLS = new Set([
756
+ "look", "tap", "long_press", "type_text", "scroll", "toggle", "swipe", "back", "open_url", "app_command", "wait_for", "wait",
757
+ ]);
697
758
  /** Calls logged with `"kind": "read"` when they logged no action of their own. */
698
759
  const READ_TOOLS = new Set([
699
- "apps", "open_app", "pair", "look", "inspect", "audit", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
760
+ "apps", "open_app", "pair", "look", "inspect", "audit", "ux_check", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
700
761
  "replay", "wait_for", "logs", "crash", "reload", "performance", "wait", "session_report",
701
762
  ]);
702
763
  /** A look's picture arguments for the call log: booleans and numbers only; undefined when it gave none. */
@@ -746,6 +807,12 @@ export class TaplineTools {
746
807
  scrollBase;
747
808
  confirmations;
748
809
  policy;
810
+ uxRules;
811
+ /**
812
+ * Hook for the journey (AGENT-TOOLS 1.12 §17): gives `ux_check` the action labels of earlier screens, and is told
813
+ * what each `ux_check` found. Null while no journey runs (naming is then not checked, and the answer says so).
814
+ */
815
+ uxJourney = null;
749
816
  reloadSettleMs;
750
817
  stillGapMs;
751
818
  stillCapMs;
@@ -786,12 +853,53 @@ export class TaplineTools {
786
853
  crashBaseline;
787
854
  /** Set inside a tool call (and everything it awaits), so a call within a call (`run`'s steps) adds no notes. */
788
855
  noteScope = new AsyncLocalStorage();
856
+ /** The journey this agent has running (AGENT-TOOLS 1.12 §17.1); one at a time. */
857
+ journeyRun;
858
+ /** The app the running journey started on (its identity and name): its clean-up goes to that app only. */
859
+ journeyApp;
860
+ /** The journey's own app if it is connected (the current one first), else undefined: never another app (Codex review of 1.5.0). */
861
+ journeySession() {
862
+ const app = this.journeyApp;
863
+ const current = this.host.current();
864
+ if (app === undefined)
865
+ return current;
866
+ if (current !== undefined && identityOf(current) === app.identity)
867
+ return current;
868
+ return this.host.sessions().find((s) => identityOf(s) === app.identity);
869
+ }
870
+ /** Why a journey's clean-up didn't reach its app. */
871
+ journeyAppGone() {
872
+ return this.journeyApp === undefined ? "no app is connected" : `${this.journeyApp.name} isn't connected (Tapline changes no other app)`;
873
+ }
874
+ /** The state of the journey that stopped last, for the report writer and the stress moments. */
875
+ journeyDone;
876
+ /** Inside `run`: "run" for the batch's own checks (not counted), "step" for a step (counted like a call of its own). */
877
+ journeyScope = new AsyncLocalStorage();
878
+ /** The running journey's frames and report pictures (AGENT-TOOLS 1.12 §17.5); undefined without a `journeysDir`. */
879
+ recorder;
880
+ /** The hook this journey gave `ux_check`, to take back at the stop. */
881
+ journeyHook;
882
+ pickerOff;
883
+ /** Where the last touch of the call in flight was, for the frame's tap ring. */
884
+ lastTouch;
885
+ /** Stress moments that wait for a person (§17.4), by kind: what must survive. */
886
+ stressWaiting = new Map();
887
+ /** When the app last left the front or its link dropped, and when an app last connected (real time, for stress done). */
888
+ appLeftAt = new Map();
889
+ appConnectedAt = new Map();
890
+ /** Which app's picker event is being counted right now (set around the media state's own count). */
891
+ pickerFrom;
892
+ /** "starting" until a journey's set-up is done, "stopping" until its clean-up is done: no other start meanwhile. */
893
+ journeyBusy;
894
+ clock;
789
895
  constructor(host, options) {
790
896
  this.host = host;
791
897
  this.options = options;
792
898
  this.reloadWaitMs = options.reloadWaitMs ?? 20_000;
899
+ this.clock = options.clock ?? Date.now;
793
900
  this.confirmations = options.confirmations ?? new Confirmations();
794
901
  this.policy = options.policy ?? DEFAULT_POLICY;
902
+ this.uxRules = options.uxRules ?? NO_UX_RULES;
795
903
  this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
796
904
  this.actionLog?.open();
797
905
  this.reloadSettleMs = options.reloadSettleMs ?? 1500;
@@ -803,6 +911,28 @@ export class TaplineTools {
803
911
  if (this.host.current()?.id === session.id)
804
912
  this.markPersonUsedApp();
805
913
  });
914
+ // What a stress moment a person does leaves behind, for `done` to check (AGENT-TOOLS 1.12 §17.4).
915
+ // Kept per app, so another connected app's events never count for the journey's app.
916
+ host.on("appState", (session, state) => {
917
+ if (state !== "active")
918
+ this.appLeftAt.set(identityOf(session), Date.now());
919
+ });
920
+ host.on("disconnect", (session) => {
921
+ this.appLeftAt.set(identityOf(session), Date.now());
922
+ });
923
+ host.on("connect", (session) => {
924
+ this.appConnectedAt.set(identityOf(session), Date.now());
925
+ });
926
+ // The app's picker used the armed test pictures: that is when a pick was "skipped" (1.5 review, finding 5).
927
+ host.on("pickerSkipped", (session, count) => {
928
+ this.pickerFrom = identityOf(session);
929
+ try {
930
+ this.mediaState.recordPickerSkipped(session.app.id, count);
931
+ }
932
+ finally {
933
+ this.pickerFrom = undefined;
934
+ }
935
+ });
806
936
  // Crashes before this agent's session began are not its news.
807
937
  this.crashBaseline = host.crashMark();
808
938
  for (const name of CRASH_NOTED_TOOLS) {
@@ -820,6 +950,137 @@ export class TaplineTools {
820
950
  const method = original;
821
951
  Reflect.set(this, name, (...args) => this.loggedCall(tool, args[0], () => method.apply(this, args)));
822
952
  }
953
+ // A running journey measures the agent's looks and actions, and shows its answers as a user would see them.
954
+ for (const [name, tool] of Object.entries(CALL_TOOLS)) {
955
+ if (!JOURNEY_TOOLS.has(tool))
956
+ continue;
957
+ const original = Reflect.get(this, name);
958
+ if (typeof original !== "function")
959
+ continue;
960
+ const method = original;
961
+ Reflect.set(this, name, (...args) => this.journeyCall(tool, args[0], () => method.apply(this, args)));
962
+ }
963
+ }
964
+ /** The journey running now, for the stress moments and the report writer (AGENT-TOOLS 1.12 §17). */
965
+ currentJourney() {
966
+ return this.journeyRun;
967
+ }
968
+ /** The state of the journey that stopped last. */
969
+ finishedJourney() {
970
+ return this.journeyDone;
971
+ }
972
+ /** Whether the agent sees the app as a user does: a journey is running and its eyes are the user's. */
973
+ userEyes() {
974
+ return this.journeyRun?.userEyes === true;
975
+ }
976
+ /**
977
+ * Measures one call of a running journey and shows its answer as a user would see it. Calls inside `run` count
978
+ * as steps (the batch itself and its expectation checks do not); a dry run counts as nothing.
979
+ */
980
+ journeyCall(tool, args, run) {
981
+ const journey = this.journeyRun;
982
+ if (journey === undefined)
983
+ return run();
984
+ const input = typeof args === "object" && args !== null ? args : {};
985
+ const scope = this.journeyScope.getStore();
986
+ if (tool === "run") {
987
+ const answer = this.journeyScope.run("run", run);
988
+ return answer instanceof Promise ? answer.then((a) => this.shownToUser(a)) : this.shownToUser(answer);
989
+ }
990
+ if (scope === "run" || input["dry_run"] === true)
991
+ return run();
992
+ const at = this.clock();
993
+ const pre = journey.pre();
994
+ this.lastTouch = undefined;
995
+ const done = (answer) => {
996
+ // A step of `run` runs with no list, so the screen it ended on is read here (one cheap read), before the step is counted.
997
+ if (scope !== "step")
998
+ return counted(answer);
999
+ return this.currentRoute().then((route) => {
1000
+ if (route !== null)
1001
+ journey.noteFullList({ route, text: "", fromAction: false });
1002
+ return counted(answer);
1003
+ });
1004
+ };
1005
+ const counted = (answer) => {
1006
+ const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
1007
+ const code = answer.isError === true ? /^Error (\w+):/.exec(said)?.[1] : undefined;
1008
+ const target = journeyTargetWords(input);
1009
+ journey.record({ tool, ok: answer.isError !== true, ...(code !== undefined ? { code } : {}), ...(target !== undefined ? { target } : {}), text: said, at, ms: this.clock() - at }, pre);
1010
+ const shown = this.shownToUser(answer);
1011
+ const recorder = this.recorder;
1012
+ if (recorder === undefined || !FRAME_TOOLS.has(tool))
1013
+ return shown;
1014
+ // The window itself is opened where the answer is made (typeText); here it only keeps the picture away.
1015
+ if (this.secureWindow())
1016
+ return shown;
1017
+ // A small picture of the screen after the step, kept in the report's folder and never in the answer.
1018
+ const touch = this.lastTouch;
1019
+ this.lastTouch = undefined;
1020
+ const animating = /^Note: the screen was still changing/m.test(said);
1021
+ return recorder.frame(journey.state.steps.length, journey.state.currentScreen, touch, animating).then(() => shown);
1022
+ };
1023
+ const result = run();
1024
+ return result instanceof Promise ? result.then(done) : done(result);
1025
+ }
1026
+ /** When a password field was last typed into, and whether that was within the second iOS still shows its last character. */
1027
+ secureTypedAt;
1028
+ secureWindow() {
1029
+ return this.secureTypedAt !== undefined && this.clock() - this.secureTypedAt < 1500;
1030
+ }
1031
+ /** In a user's eyes no id reaches the agent, not even inside an error the app composed. */
1032
+ shownToUser(answer) {
1033
+ if (!this.userEyes())
1034
+ return answer;
1035
+ return { ...answer, content: answer.content.map((block) => (block.type === "text" ? { ...block, text: withoutIds(block.text) } : block)) };
1036
+ }
1037
+ /** `id` and `label` answer `bad_params` in a journey's user's eyes (§17.1). */
1038
+ hiddenTarget(target) {
1039
+ if (!this.userEyes() || target === undefined)
1040
+ return null;
1041
+ return target.id !== undefined || target.label !== undefined ? fail(IDS_HIDDEN) : null;
1042
+ }
1043
+ /** Whether the journey's `allow` lets this shortcut through (always, outside a user's eyes). */
1044
+ shortcutAllowed(tool, what) {
1045
+ const journey = this.journeyRun;
1046
+ return journey === undefined || !journey.userEyes || journey.allows(tool === "open_url" ? OPEN_URL : what);
1047
+ }
1048
+ shortcutWords(tool, what) {
1049
+ return tool === "open_url"
1050
+ ? "Error not_allowed: in a journey, open_url is a shortcut a user doesn't have, and this journey's allow doesn't name it; reach the screen the way a user would."
1051
+ : `Error not_allowed: in a journey, app_command ${JSON.stringify(what)} is a shortcut a user doesn't have, and this journey's allow doesn't name it.`;
1052
+ }
1053
+ /** A shortcut in a user's eyes: refused unless `allow` names it, and noted for the report when it goes through. */
1054
+ shortcutRefusal(tool, what) {
1055
+ const journey = this.journeyRun;
1056
+ if (journey === undefined || !journey.userEyes)
1057
+ return null;
1058
+ if (!this.shortcutAllowed(tool, what))
1059
+ return fail(this.shortcutWords(tool, what));
1060
+ journey.noteShortcut({ tool, what });
1061
+ return null;
1062
+ }
1063
+ static SCROLL_TO_TARGET_OFF = 'Error not_allowed: in a journey, scroll to: "target" is off: a person scrolls until they see it. Scroll down or up, then look.';
1064
+ /** What a journey's user's eyes refuse in a step of `run`, before anything runs (a step number first); null when it is fine. */
1065
+ journeyStepRefusal(step, index) {
1066
+ if (!this.userEyes())
1067
+ return null;
1068
+ const s = step;
1069
+ const hidden = (value) => typeof value === "object" && value !== null && ("id" in value || "label" in value);
1070
+ const expect = s["expect"];
1071
+ if (hidden(s["target"]) || hidden(s["tap"]) || hidden(expect) || (step.tool === "wait_for" && ("id" in s || "label" in s))) {
1072
+ return IDS_HIDDEN.replace("Error bad_params: ", `Error bad_params: step ${index + 1}: `);
1073
+ }
1074
+ if (step.tool === "scroll" && s["to"] === "target")
1075
+ return TaplineTools.SCROLL_TO_TARGET_OFF.replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
1076
+ if (step.tool === "open_url" && !this.shortcutAllowed("open_url", String(s["url"] ?? ""))) {
1077
+ return this.shortcutWords("open_url", "").replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
1078
+ }
1079
+ const name = String(s["name"] ?? "");
1080
+ if (step.tool === "app_command" && name !== "" && !this.shortcutAllowed("app_command", name)) {
1081
+ return this.shortcutWords("app_command", name).replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
1082
+ }
1083
+ return null;
823
1084
  }
824
1085
  /** The agent's client id for the call log; the MCP server passes the client's name once it is known. */
825
1086
  setClientId(read) {
@@ -1276,6 +1537,11 @@ export class TaplineTools {
1276
1537
  }
1277
1538
  }
1278
1539
  }
1540
+ for (const [i, step] of steps.entries()) {
1541
+ const refused = this.journeyStepRefusal(step, i);
1542
+ if (refused !== null)
1543
+ return fail(refused);
1544
+ }
1279
1545
  this.diffSaved = null;
1280
1546
  if (args.dry_run === true)
1281
1547
  return this.dryRunBatch(steps);
@@ -1435,6 +1701,9 @@ export class TaplineTools {
1435
1701
  }
1436
1702
  /** One step of `run`, as its own tool call without a list. */
1437
1703
  runStep(step) {
1704
+ return this.journeyScope.run("step", () => this.runStepNow(step));
1705
+ }
1706
+ runStepNow(step) {
1438
1707
  const { tool, expect: _expect, route: _route, ...rest } = step;
1439
1708
  const quiet = { ...rest, look: false, screenshot: false };
1440
1709
  switch (tool) {
@@ -1517,6 +1786,9 @@ export class TaplineTools {
1517
1786
  }
1518
1787
  // -- inspect (Tapline 1.1): from the screen to the code
1519
1788
  async inspect(args) {
1789
+ const hiddenInspect = this.hiddenTarget(args.target);
1790
+ if (hiddenInspect !== null)
1791
+ return hiddenInspect;
1520
1792
  const session = this.host.current();
1521
1793
  if (session === undefined)
1522
1794
  return this.notConnected();
@@ -1553,6 +1825,72 @@ export class TaplineTools {
1553
1825
  const title = auditTitle(await this.currentRoute(), session.app.name);
1554
1826
  return text([formatAudit(title, parsed.data, args.types), ...this.takePersonNote()].join("\n"));
1555
1827
  }
1828
+ // -- ux_check (AGENT-TOOLS 1.12 §17.3)
1829
+ /** The house rule `max_taps_to_goal`, for the journey to check at stop; undefined when the user set none. */
1830
+ get maxTapsToGoal() {
1831
+ return this.uxRules.maxTapsToGoal;
1832
+ }
1833
+ async uxCheck(_args = {}) {
1834
+ const session = this.host.current();
1835
+ if (session === undefined)
1836
+ return this.notConnected();
1837
+ if (!session.capabilities.includes("uxCheck")) {
1838
+ return fail("Error unsupported: this app's Tapline is too old for ux_check; update react-native-tapline (audit and check_layout work meanwhile).");
1839
+ }
1840
+ const earlier = this.uxJourney?.earlier() ?? null;
1841
+ let raw;
1842
+ try {
1843
+ raw = await this.host.request("uxCheck", { ...(this.userEyes() ? { glass: true } : {}), ...(this.uxRules.wayBack ? { wayBack: true } : {}), ...(earlier !== null ? { earlier } : {}) }, { sessionId: session.id });
1844
+ }
1845
+ catch (error) {
1846
+ return fail(describeError(error));
1847
+ }
1848
+ const parsed = uxResultSchema.safeParse(raw);
1849
+ if (!parsed.success)
1850
+ return fail("Error internal: the app returned a UX check the host could not read.");
1851
+ const title = auditTitle(await this.currentRoute(), session.app.name);
1852
+ this.uxJourney?.seen?.(title, parsed.data.actions, parsed.data.issues.map((i) => {
1853
+ const frame = i.element?.frame ?? i.where?.frame;
1854
+ return {
1855
+ kind: i.kind,
1856
+ problem: i.problem,
1857
+ basis: i.basis,
1858
+ ...(i.element ? { ref: i.element.ref } : {}),
1859
+ ...(frame ? { frame } : {}),
1860
+ };
1861
+ }));
1862
+ const body = formatUxCheck(title, parsed.data, { textRules: this.uxRules.text, journeyRunning: this.uxJourney !== null, eyes: this.userEyes() });
1863
+ return text([body, ...this.takePersonNote()].join("\n"));
1864
+ }
1865
+ // -- media (AGENT-TOOLS 1.12 §17.6): the logic is in media.ts
1866
+ /** What `media` remembers per app: the asset ids it added, and the picker skips a journey report reads. */
1867
+ mediaState = new MediaState();
1868
+ async media(args = {}) {
1869
+ if (args.remove === true && (args.add !== undefined || args.picker === true)) {
1870
+ return fail("Error bad_params: media with remove: true takes nothing else; add and picker are separate calls.");
1871
+ }
1872
+ if (args.remove !== true && args.add === undefined) {
1873
+ return fail("Error bad_params: media needs add (picture paths), or remove: true.");
1874
+ }
1875
+ if (args.picker === true && args.add === undefined)
1876
+ return fail("Error bad_params: media with picker: true needs add (picture paths).");
1877
+ // Adding needs the user's yes, and so does picker: true (its pictures go into the app as if the person picked them;
1878
+ // AGENT-TOOLS 1.12 §17.6: media with add needs confirm; 1.5 review, finding 10).
1879
+ if (args.remove !== true && args.confirm !== true) {
1880
+ return fail(args.picker === true
1881
+ ? "Error bad_params: media with picker hands pictures to the app as if the person picked them; ask your user first, then pass confirm: true."
1882
+ : CONFIRM_ERROR);
1883
+ }
1884
+ const session = this.host.current();
1885
+ if (session === undefined)
1886
+ return this.notConnected();
1887
+ return runMedia(args, {
1888
+ state: this.mediaState,
1889
+ session,
1890
+ request: (params) => this.host.request("media", params, { sessionId: session.id, timeoutMs: 60_000 }),
1891
+ describeError,
1892
+ });
1893
+ }
1556
1894
  // -- check_layout
1557
1895
  async checkLayout(_args = {}) {
1558
1896
  const session = this.host.current();
@@ -1597,11 +1935,16 @@ export class TaplineTools {
1597
1935
  /** Text lines plus an optional screenshot (an image, or a file path in file mode) for the current screen. */
1598
1936
  async lookParts(session, wantScreenshot, width = DEFAULT_SCREENSHOT_WIDTH, onlyChanges = false, labels = false) {
1599
1937
  const sid = session.id;
1938
+ // Right after typing into a password field iOS may still show its last character: no picture then, and the note says why.
1939
+ const secureQuiet = wantScreenshot && this.secureWindow();
1940
+ if (secureQuiet)
1941
+ wantScreenshot = false;
1600
1942
  // Changes only make sense against a list this agent has already been given in this session.
1601
1943
  const askChanges = onlyChanges && this.listed.has(sid);
1602
1944
  // `onlyChanges` is the after-an-action call. With `changes: true` the app keeps the refs and
1603
1945
  // counts on from the highest one; `look` (onlyChanges false) always restarts at e1.
1604
- const treeParams = askChanges ? { changes: true } : {};
1946
+ const eyes = this.userEyes();
1947
+ const treeParams = { ...(askChanges ? { changes: true } : {}), ...(eyes ? { glass: true } : {}) };
1605
1948
  const treePromise = this.host.request("tree", treeParams, { sessionId: sid });
1606
1949
  const shotPromise = wantScreenshot && !this.noScreenshot.has(sid)
1607
1950
  ? this.host
@@ -1632,7 +1975,7 @@ export class TaplineTools {
1632
1975
  this.alertOpen.add(sid);
1633
1976
  else
1634
1977
  this.alertOpen.delete(sid);
1635
- const changesText = askChanges ? formatChanges(tree, { alertGone }) : null;
1978
+ const changesText = askChanges ? formatChanges(tree, { alertGone, eyes }) : null;
1636
1979
  // For the call log: the list the agent now has (a change block leaves it as it was, but no longer certain).
1637
1980
  if (changesText === null)
1638
1981
  this.listBase = { elements: tree.elements, stale: false, unseen: false };
@@ -1641,9 +1984,12 @@ export class TaplineTools {
1641
1984
  // A full list gives every ref a fresh meaning, so refs are trustworthy again.
1642
1985
  if (changesText === null)
1643
1986
  this.personActed = false;
1644
- const lines = [changesText ?? formatTree(tree, { name: session.app.name, id: session.app.id })];
1987
+ const lines = [changesText ?? formatTree(tree, { name: session.app.name, id: session.app.id }, { eyes })];
1988
+ // A journey keeps the first full list of each screen (and counts the screens entered) for its report.
1989
+ if (changesText === null)
1990
+ this.journeyRun?.noteFullList({ route: tree.route?.names ?? [], text: lines[0] ?? "", fromAction: onlyChanges });
1645
1991
  // A full list in place of the changes (most of the screen changed) still says what changed out of view.
1646
- if (askChanges && changesText === null)
1992
+ if (askChanges && changesText === null && !eyes)
1647
1993
  lines.push(...formatOffScreenAfterList(tree));
1648
1994
  const content = [];
1649
1995
  // Notes come last (AGENT-TOOLS §3.6), after the list and any `Screenshot:` path line.
@@ -1661,7 +2007,9 @@ export class TaplineTools {
1661
2007
  }
1662
2008
  const shot = await shotPromise;
1663
2009
  if (shot === undefined) {
1664
- if (wantScreenshot)
2010
+ if (secureQuiet)
2011
+ notes.push(SECURE_QUIET_NOTE);
2012
+ else if (wantScreenshot)
1665
2013
  notes.push(NO_SCREENSHOT_NOTE);
1666
2014
  }
1667
2015
  else if (shot.ok) {
@@ -1712,6 +2060,19 @@ export class TaplineTools {
1712
2060
  if (this.listBase !== undefined)
1713
2061
  this.listBase = { ...this.listBase, unseen: true };
1714
2062
  }
2063
+ /** Remembers where a touch landed (points, and the touched element's frame) for the journey's frame and GIF. */
2064
+ noteTouch(method, params, result) {
2065
+ if (this.recorder === undefined || (method !== "press" && method !== "longPress" && method !== "toggle"))
2066
+ return;
2067
+ const element = typeof result === "object" && result !== null && "element" in result ? elementSchema.safeParse(result.element) : null;
2068
+ const frame = element?.success ? element.data.frame : undefined;
2069
+ const target = typeof params === "object" && params !== null ? params.target : undefined;
2070
+ const t = typeof target === "object" && target !== null ? target : undefined;
2071
+ const point = t !== undefined && typeof t.x === "number" && typeof t.y === "number"
2072
+ ? { x: t.x, y: t.y }
2073
+ : frame ? { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 } : undefined;
2074
+ this.lastTouch = { ...(point !== undefined ? { point } : {}), ...(frame ? { frame } : {}) };
2075
+ }
1715
2076
  /** Runs a request, then (unless disabled) appends the fresh element list and how long the app took to settle. */
1716
2077
  async act(method, params, describe, withLook, screenshot, extraNotes,
1717
2078
  /** True when the result means nothing was done: the first line is then the whole answer. */
@@ -1747,6 +2108,7 @@ export class TaplineTools {
1747
2108
  return fail(describeError(error));
1748
2109
  }
1749
2110
  const line = describe(result);
2111
+ this.noteTouch(method, params, result);
1750
2112
  if (nothingDone?.(result) === true) {
1751
2113
  this.logExtra = undefined;
1752
2114
  return text([line, ...this.takePersonNote()].join("\n"));
@@ -2547,6 +2909,9 @@ export class TaplineTools {
2547
2909
  }
2548
2910
  }
2549
2911
  async filmstrip(args) {
2912
+ const hiddenFilm = this.hiddenTarget(args.tap);
2913
+ if (hiddenFilm !== null)
2914
+ return hiddenFilm;
2550
2915
  const session = this.host.current();
2551
2916
  if (session === undefined)
2552
2917
  return this.notConnected();
@@ -2560,7 +2925,7 @@ export class TaplineTools {
2560
2925
  if (args.tap !== undefined) {
2561
2926
  // A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
2562
2927
  // The tap is a touch like any other: the safety policy decides first.
2563
- const pressParams = { target: toWireTarget(args.tap) };
2928
+ const pressParams = { target: toWireTarget(args.tap), ...(this.userEyes() ? { noScroll: true } : {}) };
2564
2929
  // The policy check, the "before" frame and the tap are one turn of the live view's queue: a pause refuses
2565
2930
  // it, a person's input goes first, and the screen can't change between the check and the tap (PR #4 review).
2566
2931
  const turn = async () => {
@@ -2803,7 +3168,7 @@ export class TaplineTools {
2803
3168
  const element = elementSchema.safeParse(result[key]);
2804
3169
  if (element.success) {
2805
3170
  const shown = withoutValue ? { ...element.data, value: null, placeholder: null } : element.data;
2806
- return formatElement(shown, { actions: false, frame: false });
3171
+ return formatElement(shown, { actions: false, frame: false, eyes: this.userEyes() });
2807
3172
  }
2808
3173
  }
2809
3174
  return "element";
@@ -3044,7 +3409,7 @@ export class TaplineTools {
3044
3409
  label: element.label ?? null,
3045
3410
  text: element.text ?? null,
3046
3411
  textMore: element.textMore ?? null,
3047
- }, { actions: false });
3412
+ }, { actions: false, eyes: this.userEyes() });
3048
3413
  }
3049
3414
  /** A `{ref}` target after a person used the app: refs may point elsewhere. */
3050
3415
  staleRef(params) {
@@ -3059,19 +3424,29 @@ export class TaplineTools {
3059
3424
  return null;
3060
3425
  }
3061
3426
  tap(args) {
3062
- const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
3427
+ const hiddenTap = this.hiddenTarget(args.target);
3428
+ if (hiddenTap !== null)
3429
+ return Promise.resolve(hiddenTap);
3430
+ const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}), ...(this.userEyes() ? { noScroll: true } : {}) };
3063
3431
  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));
3064
3432
  }
3065
3433
  /** `seconds` is accepted for the shared interface; Tapline calls the handler, so it doesn't hold. */
3066
3434
  longPress(args) {
3067
- const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
3435
+ const hiddenPress = this.hiddenTarget(args.target);
3436
+ if (hiddenPress !== null)
3437
+ return Promise.resolve(hiddenPress);
3438
+ const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}), ...(this.userEyes() ? { noScroll: true } : {}) };
3068
3439
  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));
3069
3440
  }
3070
3441
  typeText(args) {
3442
+ const hiddenField = this.hiddenTarget(args.target);
3443
+ if (hiddenField !== null)
3444
+ return Promise.resolve(hiddenField);
3071
3445
  const params = {
3072
3446
  text: args.text,
3073
3447
  replace: args.replace ?? true,
3074
3448
  submit: args.submit ?? false,
3449
+ ...(this.userEyes() ? { noScroll: true } : {}),
3075
3450
  };
3076
3451
  if (args.target !== undefined)
3077
3452
  params["target"] = toWireTarget(withIndex(args.target, args.index));
@@ -3084,6 +3459,9 @@ export class TaplineTools {
3084
3459
  const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
3085
3460
  // AGENT-TOOLS 1.8 §3.3: a secure field's value is never shown, not even as bullets in this line
3086
3461
  const secure = typeof r === "object" && r !== null && "secure" in r && r.secure === true;
3462
+ // Journey or not, iOS shows the last character of a password for about a second: remember when (finding 4).
3463
+ if (secure)
3464
+ this.secureTypedAt = this.clock();
3087
3465
  const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
3088
3466
  return afterScroll(r, `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`);
3089
3467
  }, args.look, args.screenshot);
@@ -3092,7 +3470,12 @@ export class TaplineTools {
3092
3470
  if (args.to !== undefined && (args.direction !== undefined || args.amount !== undefined)) {
3093
3471
  return Promise.resolve(fail("Error bad_params: use either `to` or `direction`/`amount`, not both."));
3094
3472
  }
3473
+ const hiddenScroll = this.hiddenTarget(args.target);
3474
+ if (hiddenScroll !== null)
3475
+ return Promise.resolve(hiddenScroll);
3095
3476
  const toTarget = args.to === "target";
3477
+ if (toTarget && this.userEyes())
3478
+ return Promise.resolve(fail(TaplineTools.SCROLL_TO_TARGET_OFF));
3096
3479
  if (toTarget && args.target === undefined) {
3097
3480
  return Promise.resolve(fail('Error bad_params: to: "target" needs a target: the element to bring onto the screen.'));
3098
3481
  }
@@ -3144,7 +3527,10 @@ export class TaplineTools {
3144
3527
  });
3145
3528
  }
3146
3529
  toggle(args) {
3147
- const params = { target: toWireTarget(withIndex(args.target, args.index)) };
3530
+ const hiddenSwitch = this.hiddenTarget(args.target);
3531
+ if (hiddenSwitch !== null)
3532
+ return Promise.resolve(hiddenSwitch);
3533
+ const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(this.userEyes() ? { noScroll: true } : {}) };
3148
3534
  if (args.value !== undefined)
3149
3535
  params["value"] = args.value;
3150
3536
  if (args.force === true)
@@ -3179,17 +3565,12 @@ export class TaplineTools {
3179
3565
  : `${session.app.name} offers no commands.`);
3180
3566
  }
3181
3567
  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
- }
3568
+ const shortcut = this.shortcutRefusal("app_command", name);
3569
+ if (shortcut !== null)
3570
+ return shortcut;
3571
+ const blocked = name === "network" ? this.networkRefusal(args.args?.mode) : null;
3572
+ if (blocked !== null)
3573
+ return blocked;
3193
3574
  return this.act("command", { name, args: args.args ?? {} }, (r) => {
3194
3575
  const result = typeof r === "object" && r !== null && "result" in r ? r.result : null;
3195
3576
  // Built in `network`: the same line every header carries while a condition is on (AGENT-TOOLS §16.4),
@@ -3204,8 +3585,22 @@ export class TaplineTools {
3204
3585
  return result === null || result === undefined ? `Ran ${name}.` : `Ran ${name}: ${JSON.stringify(result)}`;
3205
3586
  }, args.look, args.screenshot);
3206
3587
  }
3588
+ /** AGENT-TOOLS 1.11 §16.4: a policy whose block list holds `network` refuses a condition; `normal` always goes. */
3589
+ networkRefusal(mode, app = this.host.current()?.app) {
3590
+ const names = (words) => (words ?? []).some((word) => word.trim().toLowerCase() === "network");
3591
+ const own = app === undefined ? undefined : (this.policy.apps?.[app.id ?? ""] ?? this.policy.apps?.[app.name]);
3592
+ if (mode !== "normal" && names(listsFor(this.policy, app).block)) {
3593
+ const where = own?.block !== undefined && app !== undefined ? `for ${app.name}` : "here";
3594
+ return fail(`Error not_allowed: blocked: the safety policy blocks network ${where}.`);
3595
+ }
3596
+ return null;
3597
+ }
3207
3598
  waitFor(args) {
3208
- const params = {};
3599
+ const hiddenWait = this.hiddenTarget(args);
3600
+ if (hiddenWait !== null)
3601
+ return Promise.resolve(hiddenWait);
3602
+ // In a user's eyes it matches what is on the glass only (§17.1).
3603
+ const params = this.userEyes() ? { glass: true } : {};
3209
3604
  if (args.text !== undefined)
3210
3605
  params["text"] = args.text;
3211
3606
  if (args.id !== undefined)
@@ -3261,6 +3656,9 @@ export class TaplineTools {
3261
3656
  }, args.look, args.screenshot);
3262
3657
  }
3263
3658
  async openUrl(args) {
3659
+ const refused = this.shortcutRefusal("open_url", args.url);
3660
+ if (refused !== null)
3661
+ return refused;
3264
3662
  const answer = await this.act("openURL", { url: args.url }, () => `Opened ${args.url}.`, args.look, args.screenshot);
3265
3663
  // The app's own forget link unpairs it, so it drops the connection before answering: that is the link working.
3266
3664
  if (answer.isError === true && /(^|\/)tapline\/forget\/?$/.test(args.url.replace(/[?#].*$/, ""))) {
@@ -3443,6 +3841,9 @@ export class TaplineTools {
3443
3841
  if (args.launch === true && args.tap !== undefined) {
3444
3842
  return fail("Error bad_params: launch reloads the app and tap taps in it; give one of them.");
3445
3843
  }
3844
+ const hiddenPerf = this.hiddenTarget(args.tap);
3845
+ if (hiddenPerf !== null)
3846
+ return hiddenPerf;
3446
3847
  const seconds = args.seconds ?? 3;
3447
3848
  try {
3448
3849
  let measure;
@@ -3456,7 +3857,7 @@ export class TaplineTools {
3456
3857
  let press;
3457
3858
  if (args.tap !== undefined) {
3458
3859
  // The tap is a touch like any other: the safety policy decides first (as for filmstrip).
3459
- press = { target: toWireTarget(args.tap) };
3860
+ press = { target: toWireTarget(args.tap), ...(this.userEyes() ? { noScroll: true } : {}) };
3460
3861
  const stale = this.staleRef(press);
3461
3862
  if (stale !== null)
3462
3863
  return stale;
@@ -3496,6 +3897,652 @@ export class TaplineTools {
3496
3897
  return fail(describeError(error));
3497
3898
  }
3498
3899
  }
3900
+ // -- journey (AGENT-TOOLS 1.12 §17.1)
3901
+ async journey(args = {}) {
3902
+ if (args.start === true && args.stop === true)
3903
+ return fail("Error bad_params: give start or stop, not both.");
3904
+ if (args.start === true)
3905
+ return this.journeyStart(args);
3906
+ const startOnly = ["goal", "persona", "conditions", "allow"].filter((key) => args[key] !== undefined);
3907
+ if (startOnly.length > 0)
3908
+ return fail(`Error bad_params: ${startOnly.join(", ")} go with start: true.`);
3909
+ if (args.stop === true)
3910
+ return this.journeyStop(args);
3911
+ const stopOnly = ["reached", "why", "export"].filter((key) => args[key] !== undefined);
3912
+ if (stopOnly.length > 0)
3913
+ return fail(`Error bad_params: ${stopOnly.join(", ")} go with stop: true.`);
3914
+ const running = this.journeyRun;
3915
+ if (args.stress !== undefined || args.survives !== undefined || args.done !== undefined) {
3916
+ if (running === undefined)
3917
+ return fail(NO_JOURNEY);
3918
+ return this.journeyStress(running, args);
3919
+ }
3920
+ if (args.eyes !== undefined) {
3921
+ if (running === undefined)
3922
+ return fail(NO_JOURNEY);
3923
+ if (args.eyes === "user") {
3924
+ return running.userEyes
3925
+ ? text("Already in a user's eyes.")
3926
+ : fail("Error bad_params: eyes: \"agent\" can't be turned back to a user's during a journey; start a new one.");
3927
+ }
3928
+ if (!running.userEyes)
3929
+ return text("Already in the agent's eyes.");
3930
+ running.switchEyes();
3931
+ // The next list is a full one: what the agent can see just changed.
3932
+ const session = this.host.current();
3933
+ if (session !== undefined)
3934
+ this.listed.delete(session.id);
3935
+ return text("Eyes: the agent's from now on (ids, labels, everything in the tree; jumps and shortcuts allowed). The report says so.");
3936
+ }
3937
+ if (running !== undefined) {
3938
+ const m = running.state.measures;
3939
+ return text(`Journey running: ${JSON.stringify(running.state.goal)}, ${running.userEyes ? "in a user's eyes" : "in the agent's eyes"}; ${m.taps} taps, ${m.screensEntered} screens so far.`);
3940
+ }
3941
+ return fail("Error bad_params: give start: true with a goal, or stop: true with reached.");
3942
+ }
3943
+ async journeyStart(args) {
3944
+ const running = this.journeyRun;
3945
+ if (running !== undefined) {
3946
+ return fail(`Error bad_params: a journey is already running (${JSON.stringify(running.state.goal)}); stop it first with stop: true and reached.`);
3947
+ }
3948
+ if (this.journeyBusy !== undefined) {
3949
+ return fail(`Error busy: a journey is still ${this.journeyBusy === "starting" ? "starting" : "stopping"}; try again in a moment.`);
3950
+ }
3951
+ const goal = args.goal?.trim() ?? "";
3952
+ if (goal === "")
3953
+ return fail("Error bad_params: start needs a goal: what the user wants to do.");
3954
+ if (args.reached !== undefined || args.why !== undefined || args.export !== undefined) {
3955
+ return fail("Error bad_params: reached, why and export go with stop: true.");
3956
+ }
3957
+ const session = this.host.current();
3958
+ if (session === undefined)
3959
+ return this.notConnected();
3960
+ // Taken before the first await: a second start (or one during a stop's clean-up) can't overlap this one.
3961
+ this.journeyBusy = "starting";
3962
+ try {
3963
+ return await this.journeyStarted(args, goal, session);
3964
+ }
3965
+ finally {
3966
+ // Only the start releases its own hold (a stop never takes it over: it answers busy while this runs).
3967
+ if (this.journeyBusy === "starting")
3968
+ this.journeyBusy = undefined;
3969
+ if (this.journeyRun === undefined)
3970
+ this.journeyApp = undefined;
3971
+ }
3972
+ }
3973
+ async journeyStarted(args, goal, session) {
3974
+ this.journeyApp = { identity: identityOf(session), name: session.app.name };
3975
+ const journey = new Journey({
3976
+ goal,
3977
+ ...(args.persona !== undefined && args.persona.trim() !== "" ? { persona: args.persona.trim() } : {}),
3978
+ ...(args.conditions !== undefined ? { conditions: args.conditions } : {}),
3979
+ ...(args.allow !== undefined ? { allow: args.allow } : {}),
3980
+ ...(args.eyes !== undefined ? { eyes: args.eyes } : {}),
3981
+ }, this.clock);
3982
+ const notes = [];
3983
+ const conditions = args.conditions ?? {};
3984
+ // Network first: the others then run under it. Each goes through the app's own commands (AGENT-TOOLS §16).
3985
+ const order = ["network", "language", "region", "text_size"];
3986
+ for (const name of order) {
3987
+ const value = conditions[name];
3988
+ if (value === undefined)
3989
+ continue;
3990
+ const record = await this.applyCondition(session, name, value);
3991
+ journey.state.conditionRecords.push(record);
3992
+ if (!record.applied)
3993
+ notes.push(`Note: condition ${name} ${value} was not applied: ${record.failure ?? "the app refused"}.`);
3994
+ }
3995
+ this.journeyRun = journey;
3996
+ this.journeyDone = undefined;
3997
+ this.stressWaiting.clear();
3998
+ // The naming memory of an earlier journey (or a hook someone left) is not this journey's.
3999
+ this.uxJourney = null;
4000
+ this.journeyHook = undefined;
4001
+ this.startRecorder(journey, session, notes);
4002
+ const live = this.host.current();
4003
+ const persona = journey.state.persona ?? "no persona";
4004
+ const eyesWords = journey.userEyes ? "in a user's eyes" : "in the agent's eyes";
4005
+ const first = `Journey started: "${goal}" as ${persona}, ${eyesWords}.`;
4006
+ const applied = journey.state.conditionRecords.filter((r) => r.applied).map((r) => `${r.name.replace("_", " ")} ${r.value}`);
4007
+ const head = [first, ...(applied.length > 0 ? [`Conditions: ${applied.join(", ")}.`] : []), ...notes];
4008
+ if (live === undefined) {
4009
+ return text([...head, "Note: the app is reconnecting, so there's no screen yet; look in a few seconds."].join("\n"));
4010
+ }
4011
+ const parts = await this.lookParts(live, false, DEFAULT_SCREENSHOT_WIDTH, false);
4012
+ const [top, ...rest] = parts.content;
4013
+ const list = top !== undefined && top.type === "text" ? top.text : "";
4014
+ // The screen the journey starts on is the GIF's first frame.
4015
+ await this.recorder?.frame(0, journey.state.currentScreen, undefined, false);
4016
+ return this.shownToUser({ content: [{ type: "text", text: [...head, list].join("\n") }, ...rest], ...(parts.isError ? { isError: true } : {}) });
4017
+ }
4018
+ /** Opens the report folder for a journey that has somewhere to keep one, and hands `ux_check` the hook that feeds it. */
4019
+ startRecorder(journey, first, notes) {
4020
+ const root = this.options.journeysDir;
4021
+ if (root === undefined)
4022
+ return;
4023
+ try {
4024
+ mkdirSync(root, { recursive: true });
4025
+ // The newest reports stay; older folders go, so a long-lived host doesn't fill the disk (1.5 review, finding 6).
4026
+ pruneJourneys(root, JOURNEYS_KEPT - 1);
4027
+ // Frames and naming memory belong to the journey's own app, and only while it is connected and in front.
4028
+ const app = identityOf(first);
4029
+ const recorder = new JourneyRecorder(freshFolder(root, journey.state.goal, this.clock()), this.frameSource(app), this.clock);
4030
+ this.recorder = recorder;
4031
+ const mine = () => {
4032
+ const now = this.host.current();
4033
+ return now !== undefined && identityOf(now) === app;
4034
+ };
4035
+ const hook = {
4036
+ earlier: () => (mine() ? recorder.earlier(journey.state.currentScreen) : null),
4037
+ seen: (title, actions, findings) => {
4038
+ if (mine())
4039
+ void recorder.uxSeen(journey.state.currentScreen, title, actions, findings);
4040
+ },
4041
+ };
4042
+ this.journeyHook = hook;
4043
+ this.uxJourney = hook;
4044
+ // Only this journey's app: another connected app using its armed pictures is not this journey's skip.
4045
+ this.pickerOff = this.mediaState.onPickerSkipped((appId, count) => {
4046
+ if (this.pickerFrom !== undefined ? this.pickerFrom !== app : appId !== first.app.id)
4047
+ return;
4048
+ recorder.pickerSkips++;
4049
+ recorder.pickerPictures += count;
4050
+ });
4051
+ }
4052
+ catch (error) {
4053
+ notes.push(`Note: no report will be written: ${error instanceof Error ? error.message : String(error)}`);
4054
+ }
4055
+ }
4056
+ /** The recorder's eyes: a read that touches nothing, and a small picture. */
4057
+ frameSource(app) {
4058
+ // Rechecked before each request: the journey's own app or nothing, whatever `open_app` chose meanwhile.
4059
+ const bound = () => {
4060
+ const session = this.journeyApp === undefined ? undefined : this.journeySession();
4061
+ return session !== undefined && identityOf(session) === app ? session : undefined;
4062
+ };
4063
+ return {
4064
+ where: () => {
4065
+ const now = this.host.current();
4066
+ if (now === undefined)
4067
+ return "disconnected";
4068
+ if (identityOf(now) !== app)
4069
+ return "other";
4070
+ const state = now.appState?.state;
4071
+ return state === "background" || state === "inactive" ? "background" : "front";
4072
+ },
4073
+ peek: async () => {
4074
+ const session = bound();
4075
+ if (session === undefined)
4076
+ return undefined;
4077
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
4078
+ if (!tree.success)
4079
+ return undefined;
4080
+ const t = tree.data;
4081
+ return {
4082
+ signature: screenSignature(t.elements, t.route?.names ?? [], (t.alerts ?? []).length),
4083
+ ...(t.screen ? { screen: { w: t.screen.width, h: t.screen.height } } : {}),
4084
+ route: t.route?.names ?? [],
4085
+ };
4086
+ },
4087
+ shot: async (width, quality) => {
4088
+ const session = bound();
4089
+ if (session === undefined || this.noScreenshot.has(session.id))
4090
+ return undefined;
4091
+ // Right after a password was typed iOS may still show its last character: no picture then, from any path (a ux_check's too).
4092
+ if (this.secureWindow())
4093
+ return undefined;
4094
+ try {
4095
+ return await this.takeShot(session.id, width, quality);
4096
+ }
4097
+ catch {
4098
+ return undefined;
4099
+ }
4100
+ },
4101
+ };
4102
+ }
4103
+ async journeyStop(args) {
4104
+ // A stop while the start still reads its first screen would let the start's end clear the stop's hold.
4105
+ if (this.journeyBusy === "starting")
4106
+ return fail("Error busy: the journey is still starting; stop it in a moment.");
4107
+ const running = this.journeyRun;
4108
+ if (running === undefined)
4109
+ return fail(NO_JOURNEY);
4110
+ if (args.reached === undefined)
4111
+ return fail("Error bad_params: stop needs reached: true or false.");
4112
+ // An export writes to the user's GitHub: the agent must say its user asked. Refused before the journey ends, so the call can be made again.
4113
+ if (args.export !== undefined && args.export.confirm !== true) {
4114
+ return fail("Error bad_params: export writes to your user's GitHub, so it needs confirm: true inside export (only when your user asked for it). The journey is still running.");
4115
+ }
4116
+ if (this.journeyBusy === "stopping")
4117
+ return fail("Error busy: this journey is already stopping.");
4118
+ // Held until the clean-up is done, so a new journey can't start while the old one still changes the app.
4119
+ this.journeyBusy = "stopping";
4120
+ try {
4121
+ // The public-repository check also runs before the journey ends, so a refusal can be answered with public: true on a second stop.
4122
+ const run = this.options.exportRunner;
4123
+ if (args.export !== undefined && run !== undefined) {
4124
+ const refused = await exportRefusal(args.export, run);
4125
+ if (refused !== null)
4126
+ return fail(`Error not_allowed: ${refused}. The journey is still running; stop again with the option, or without export.`);
4127
+ }
4128
+ return await this.journeyStopped(args, running, args.reached);
4129
+ }
4130
+ finally {
4131
+ if (this.journeyBusy === "stopping")
4132
+ this.journeyBusy = undefined;
4133
+ }
4134
+ }
4135
+ async journeyStopped(args, running, reached) {
4136
+ const state = running.finish(reached, args.why);
4137
+ this.journeyRun = undefined;
4138
+ this.journeyDone = state;
4139
+ const recorder = this.recorder;
4140
+ this.recorder = undefined;
4141
+ this.pickerOff?.();
4142
+ this.pickerOff = undefined;
4143
+ this.uxJourney = null;
4144
+ this.journeyHook = undefined;
4145
+ this.stressWaiting.clear();
4146
+ // From here nothing fails the stop: what goes wrong is a Note line, and the local report stays.
4147
+ const failures = [];
4148
+ const attempt = async (what, work) => {
4149
+ try {
4150
+ return await work();
4151
+ }
4152
+ catch (error) {
4153
+ failures.push(`Note: ${what}: ${error instanceof Error ? error.message : String(error)}`);
4154
+ return undefined;
4155
+ }
4156
+ };
4157
+ const lines = [summaryLine(state)];
4158
+ const content = [];
4159
+ try {
4160
+ await attempt("conditions could not be undone", () => this.undoConditions(state));
4161
+ // A picker stub the journey armed and nobody used goes back, so a person's next pick is their own (finding 5).
4162
+ await attempt("the photo picker could not be put back", async () => {
4163
+ const live = this.journeySession();
4164
+ if (live !== undefined && live.capabilities.includes("media"))
4165
+ await this.host.request("media", { op: "disarm" }, { sessionId: live.id });
4166
+ });
4167
+ const undoLines = [];
4168
+ // A network condition a stress moment turned on mid-flow goes back too (one the journey began with is undone above).
4169
+ if (state.networkStress && !state.conditionRecords.some((r) => r.name === "network" && r.applied)) {
4170
+ const done = await attempt("the network condition a stress moment set was not undone", async () => {
4171
+ const live = this.journeySession();
4172
+ return live === undefined ? undefined : this.runCondition(live, "network", "normal");
4173
+ });
4174
+ undoLines.push(done?.ok === true ? "Undone: network is normal again." : "Note: the network condition a stress moment set was not undone; set it back with app_command network normal.");
4175
+ }
4176
+ this.journeyApp = undefined;
4177
+ const session = this.host.current();
4178
+ // The agent's next list is a full one: the journey's lists were of the glass only.
4179
+ if (session !== undefined)
4180
+ this.listed.delete(session.id);
4181
+ const report = recorder === undefined
4182
+ ? undefined
4183
+ : await attempt("the report could not be written", () => writeReport({
4184
+ state,
4185
+ rec: recorder,
4186
+ maxTaps: this.maxTapsToGoal,
4187
+ textRules: this.uxRules.text,
4188
+ ...(this.options.gifMaxBytes !== undefined ? { gifMaxBytes: this.options.gifMaxBytes } : {}),
4189
+ }));
4190
+ if (report !== undefined)
4191
+ lines.push(`${findingsLine(report.counts)} Report: ${report.folder}`);
4192
+ lines.push(...summaryNotes(state), ...undoLines, ...(report?.notes ?? []));
4193
+ if (report !== undefined) {
4194
+ // At most two small key pictures: where the journey ended, and the worst finding's screen.
4195
+ if (this.options.screenshots === "file") {
4196
+ if (report.keys.length > 0)
4197
+ lines.push(`Pictures: ${report.keys.map((k) => join(report.folder, k)).join(", ")}`);
4198
+ }
4199
+ else {
4200
+ for (const key of report.keys.slice(0, 2)) {
4201
+ const data = await attempt("a key picture could not be read", () => readFileSync(join(report.folder, key)).toString("base64"));
4202
+ if (data !== undefined)
4203
+ content.push({ type: "image", data, mimeType: "image/jpeg" });
4204
+ }
4205
+ }
4206
+ }
4207
+ const target = args.export;
4208
+ if (target !== undefined) {
4209
+ if (recorder === undefined)
4210
+ lines.push("Export needs the report folder, which this host isn't set up with; nothing was exported.");
4211
+ else if (report === undefined)
4212
+ lines.push("Export skipped: there is no report to export.");
4213
+ else {
4214
+ const exported = await attempt("the export failed", () => this.exportReport(target, state, report, recorder));
4215
+ lines.push(...(exported ?? [`Export failed. The report stays local: ${report.folder}`]));
4216
+ }
4217
+ }
4218
+ }
4219
+ catch (error) {
4220
+ failures.push(`Note: the stop did not finish everything: ${error instanceof Error ? error.message : String(error)}`);
4221
+ }
4222
+ lines.push(...failures);
4223
+ if (this.options.onJourneyStop !== undefined) {
4224
+ try {
4225
+ lines.push(...(await this.options.onJourneyStop(state)));
4226
+ }
4227
+ catch (error) {
4228
+ lines.push(`Note: the report could not be written: ${error instanceof Error ? error.message : String(error)}`);
4229
+ }
4230
+ }
4231
+ return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
4232
+ }
4233
+ /** `export` of `stop`: the report to GitHub through the user's own gh and git; the report stays local when it fails. */
4234
+ async exportReport(target, state, report, recorder) {
4235
+ const run = this.options.exportRunner;
4236
+ if (run === undefined)
4237
+ return [`Export isn't available here (this host has no gh runner); the report stays local: ${report.folder}`];
4238
+ const outcome = await exportReport({
4239
+ target,
4240
+ folder: recorder.folder,
4241
+ name: report.name,
4242
+ goal: state.goal,
4243
+ summary: summaryLine(state),
4244
+ findingsLine: findingsLine(report.counts),
4245
+ findings: report.findings,
4246
+ files: report.exportFiles,
4247
+ headline: report.gif !== null ? "journey.gif" : report.firstPicture,
4248
+ }, run);
4249
+ return outcome.ok ? outcome.lines : [`Export failed: ${outcome.message}. The report stays local: ${report.folder}`];
4250
+ }
4251
+ // -- stress moments (AGENT-TOOLS 1.12 §17.4)
4252
+ async journeyStress(running, args) {
4253
+ const stress = args.stress;
4254
+ if (stress === undefined)
4255
+ return fail("Error bad_params: survives and done go with stress.");
4256
+ const kind = stress.kind;
4257
+ if (stress.mode !== undefined && kind !== "network")
4258
+ return fail("Error bad_params: mode goes with stress network.");
4259
+ if (stress.target !== undefined && kind !== "double_tap")
4260
+ return fail("Error bad_params: target goes with stress double_tap.");
4261
+ const survives = args.survives;
4262
+ if (args.done === true) {
4263
+ const asked = this.stressWaiting.get(kind);
4264
+ if (asked === undefined) {
4265
+ return fail(`Error bad_params: no ${kind} moment is waiting for a person; ask for it first with journey { stress: { kind: "${kind}" } }.`);
4266
+ }
4267
+ await this.reconnected();
4268
+ // Done is checked, not taken on the agent's word: the app must show that the moment happened.
4269
+ // An app that is gone by then is judged by survives (it did not survive), not by what it left behind.
4270
+ const unseen = this.host.current() === undefined ? null : await this.stressUnseen(kind, asked);
4271
+ if (unseen !== null)
4272
+ return fail(`Error bad_params: ${unseen} Ask the person again, then say done.`);
4273
+ this.stressWaiting.delete(kind);
4274
+ return this.stressDone(running, kind, "", await this.survivalOf(running, survives ?? asked.survives));
4275
+ }
4276
+ if (this.host.current() === undefined)
4277
+ return this.notConnected();
4278
+ switch (kind) {
4279
+ case "network":
4280
+ return this.stressNetwork(running, stress.mode, survives);
4281
+ case "double_tap":
4282
+ return this.stressDoubleTap(running, stress.target, survives);
4283
+ case "relaunch": {
4284
+ // A development build reloads; anything else needs a person. Said as what it is.
4285
+ const before = this.host.current()?.id;
4286
+ const answer = await this.reload({ look: true });
4287
+ const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
4288
+ if (answer.isError === true && /^Error unsupported/.test(said))
4289
+ return this.waitForPerson(kind, "close the app and open it again", survives);
4290
+ if (answer.isError === true)
4291
+ return answer;
4292
+ await this.reconnected();
4293
+ // A reload that timed out leaves the old session: that is no relaunch to record.
4294
+ const after = this.host.current();
4295
+ if (after === undefined || after.id === before) {
4296
+ return fail("Error not_allowed: the app has not come back as a new session after the reload, so the moment can't be counted.");
4297
+ }
4298
+ return this.stressDone(running, kind, " (reload, not a relaunch)", await this.survivalOf(running, survives));
4299
+ }
4300
+ case "background":
4301
+ return this.waitForPerson(kind, "switch to another app and back", survives);
4302
+ case "rotate":
4303
+ return this.waitForPerson(kind, "turn the phone and keep it turned", survives);
4304
+ }
4305
+ }
4306
+ async waitForPerson(kind, what, survives) {
4307
+ this.stressWaiting.set(kind, { survives, askedAt: Date.now(), size: kind === "rotate" ? await this.freshScreenSize() : null });
4308
+ return text([
4309
+ `Stress: ${kind} needs a person: ${what}, then say done.`,
4310
+ `When they have, call journey { stress: { kind: "${kind}" }, done: true }${survives !== undefined ? " (it checks what you asked to survive then)" : ", with survives to say what must still be there"}.`,
4311
+ ].join("\n"));
4312
+ }
4313
+ /** The screen's size in points from a read that touches nothing; null when the app doesn't answer. */
4314
+ async freshScreenSize() {
4315
+ const session = this.host.current();
4316
+ if (session === undefined)
4317
+ return null;
4318
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
4319
+ const screen = tree.success ? tree.data.screen : null;
4320
+ return screen ? { width: screen.width, height: screen.height } : null;
4321
+ }
4322
+ /**
4323
+ * Why a person's stress moment can't be taken as done, or null when the app shows it happened: background left the
4324
+ * front (or the link dropped) since it was asked; relaunch connected anew; rotate flipped the screen's size.
4325
+ */
4326
+ async stressUnseen(kind, asked) {
4327
+ const mine = this.journeyApp?.identity ?? "";
4328
+ switch (kind) {
4329
+ case "background":
4330
+ return (this.appLeftAt.get(mine) ?? 0) > asked.askedAt ? null : "The app never left the front since the moment was asked, so it can't be counted.";
4331
+ case "relaunch":
4332
+ return (this.appConnectedAt.get(mine) ?? 0) > asked.askedAt ? null : "The app hasn't started again since the moment was asked (no new connection), so it can't be counted.";
4333
+ case "rotate": {
4334
+ const now = await this.freshScreenSize();
4335
+ const was = asked.size;
4336
+ if (now === null || was === null)
4337
+ return "The screen's size couldn't be read, so the turn can't be confirmed.";
4338
+ const flipped = Math.round(now.width) === Math.round(was.height) && Math.round(now.height) === Math.round(was.width) && Math.round(now.width) !== Math.round(now.height);
4339
+ return flipped ? null : `The screen is still ${Math.round(now.width)}x${Math.round(now.height)} pt, as when the moment was asked: say done while the phone is turned.`;
4340
+ }
4341
+ default:
4342
+ return null;
4343
+ }
4344
+ }
4345
+ /** Waits for an app after a reload or a person leaving it (a new session comes up), a few seconds at most. */
4346
+ async reconnected() {
4347
+ const deadline = Date.now() + Math.min(this.reloadWaitMs, 8000);
4348
+ while (this.host.current() === undefined && Date.now() < deadline)
4349
+ await sleep(100);
4350
+ if (this.host.current() !== undefined)
4351
+ await sleep(Math.min(this.reloadSettleMs, 500));
4352
+ }
4353
+ async stressNetwork(running, mode, survives) {
4354
+ if (mode === undefined)
4355
+ return fail('Error bad_params: stress network needs mode: "offline", "3g", "lossy" or "normal".');
4356
+ // On the journey's own app, the one the stop puts back to normal; never another app selected since.
4357
+ const session = this.journeySession();
4358
+ if (session === undefined)
4359
+ return fail(`Error not_connected: ${this.journeyAppGone()}.`);
4360
+ const done = await this.runCondition(session, "network", mode);
4361
+ if (!done.ok)
4362
+ return fail(`Error not_allowed: network ${mode} wasn't applied: ${done.failure}.`);
4363
+ running.state.networkStress = mode !== "normal";
4364
+ return this.stressDone(running, "network", ` ${mode}`, await this.survivalOf(running, survives));
4365
+ }
4366
+ /** The same touch twice, 80 ms apart, each through the usual checks and the policy. */
4367
+ async stressDoubleTap(running, raw, survives) {
4368
+ const parsed = raw === undefined ? null : targetSchema.safeParse(raw);
4369
+ if (parsed === null || !parsed.success)
4370
+ return fail("Error bad_params: stress double_tap needs a target: exactly one of ref, text or x and y.");
4371
+ const target = parsed.data;
4372
+ // One OK never covers two touches: a target the policy flags (a word that asks a person, or blocks) is refused whole.
4373
+ const session = this.host.current();
4374
+ if (session === undefined)
4375
+ return this.notConnected();
4376
+ const lists = listsFor(this.policy, session.app);
4377
+ // Resolved inside the agent's turn, right before the press, as a guarded tap does: a queued person's action can't change
4378
+ // what is under the target between the check and the touch.
4379
+ const refusal = { answer: null };
4380
+ const preflight = async () => {
4381
+ if (lists.confirm.length === 0 && lists.block.length === 0)
4382
+ return null;
4383
+ const resolved = await this.resolveForPolicy(session, "press", { target: toWireTarget(target) });
4384
+ if (resolved.kind === "error")
4385
+ refusal.answer = fail(resolved.text);
4386
+ else {
4387
+ const verdict = this.policyWords(lists, resolved.elements);
4388
+ if (verdict.kind === "blocked")
4389
+ refusal.answer = fail(this.blockedLine(verdict.element, verdict.word));
4390
+ else if (verdict.kind === "confirm") {
4391
+ refusal.answer = fail(`Error not_allowed: needs_confirmation: double_tap on ${this.shortName(verdict.element, false)} is refused: it needs a person's OK (policy word ${JSON.stringify(verdict.word)}), and one OK never covers two touches. Tap it once instead (a person is asked), or choose another target.`);
4392
+ }
4393
+ }
4394
+ return refusal.answer;
4395
+ };
4396
+ // Both taps go to the app in one request, in one turn of the agent: the app makes the second DOUBLE_TAP_GAP_MS after
4397
+ // the first, so nothing (the control queue, the app's own request queue, a settle) can stretch the gap. The moment is
4398
+ // one stress, not two taps of the journey.
4399
+ const params = { target: toWireTarget(target), again: DOUBLE_TAP_GAP_MS, ...(this.userEyes() ? { noScroll: true } : {}) };
4400
+ let pressed;
4401
+ const answer = await this.act("press", params, (r) => {
4402
+ pressed = r;
4403
+ return `Tapped ${this.elementName(r)}.`;
4404
+ }, false, undefined, undefined, undefined, preflight);
4405
+ if (refusal.answer !== null)
4406
+ return refusal.answer;
4407
+ const line = (r) => (r.content.map((c) => (c.type === "text" ? c.text : "")).join("\n").split("\n")[0] ?? "");
4408
+ if (answer.isError === true) {
4409
+ return fail(`Stress: double_tap on ${targetWords(target)} didn't happen as asked: ${line(answer)}`);
4410
+ }
4411
+ const second = pressSecondSchema.safeParse(typeof pressed === "object" && pressed !== null && "second" in pressed ? pressed.second : undefined);
4412
+ if (!second.success) {
4413
+ return fail(`Stress: double_tap on ${targetWords(target)} didn't happen as asked: the app made only the first tap (a double tap in one request needs react-native-tapline 1.5 or later in the app).`);
4414
+ }
4415
+ const checked = await this.survivalOf(running, survives);
4416
+ const done = this.stressDone(running, "double_tap", ` on ${targetWords(target)}`, checked);
4417
+ const s = second.data;
4418
+ const how = s.done
4419
+ ? `Both taps went through, ${s.afterMs} ms apart.`
4420
+ : `The first tap went through; the second, ${s.afterMs} ms later, didn't land (${s.reason}).`;
4421
+ return text(`${line(done)}\n${how}`);
4422
+ }
4423
+ /** Records the moment and answers it: `Stress: <kind>; it survived: …` or `…; it didn't survive: …`. */
4424
+ stressDone(running, kind, via, check) {
4425
+ const verdict = check.survived === null ? `; ${check.why}` : check.survived ? `; it survived: ${check.why}` : `; it didn't survive: ${check.why}`;
4426
+ const said = `Stress: ${kind}${via}${verdict}`;
4427
+ running.state.stress.push({ kind, said, survived: check.survived, afterStep: running.state.steps.length, at: this.clock() });
4428
+ return text(said);
4429
+ }
4430
+ /** Whether what had to survive is still there, from a read that touches nothing. */
4431
+ async survivalOf(running, survives) {
4432
+ if (survives === undefined || (survives.text === undefined && survives.goal_step === undefined)) {
4433
+ return { survived: null, why: "nothing was checked (no survives given)." };
4434
+ }
4435
+ // The journey's own app, the one the moment happened to; never another app selected since.
4436
+ const session = this.journeySession();
4437
+ if (session === undefined)
4438
+ return { survived: false, why: "the app isn't connected any more." };
4439
+ // What a person sees (AGENT-TOOLS 1.12 §17.4): in a user's eyes the glass only; a label a screen reader hears is no proof.
4440
+ const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true, ...(this.userEyes() ? { glass: true } : {}) }, { sessionId: session.id }).catch(() => null));
4441
+ if (!tree.success)
4442
+ return { survived: false, why: "the app didn't answer when asked what is on screen." };
4443
+ const parts = [];
4444
+ if (survives.text !== undefined) {
4445
+ const wanted = survives.text;
4446
+ const holds = (value) => (typeof value === "string" || typeof value === "number") && String(value).includes(wanted);
4447
+ const found = tree.data.elements.find((e) => holds(e.text) || holds(e.value));
4448
+ if (found === undefined)
4449
+ parts.push({ ok: false, why: `text ${JSON.stringify(wanted)} is not on screen any more.` });
4450
+ else {
4451
+ const field = found.role === "textfield" || found.role === "input" || (found.actions ?? []).includes("type");
4452
+ parts.push({ ok: true, why: `text ${JSON.stringify(wanted)} is still ${field ? "in the field" : "on screen"}.` });
4453
+ }
4454
+ }
4455
+ if (survives.goal_step !== undefined)
4456
+ parts.push(this.goalStepHolds(running, survives.goal_step, tree.data.route?.names ?? []));
4457
+ // A check that couldn't be made (ok null) is no pass: survived only when every check held, null when one was unknown and none failed.
4458
+ const survived = parts.some((p) => p.ok === false) ? false : parts.every((p) => p.ok === true) ? true : null;
4459
+ return { survived, why: parts.map((p) => p.why).join(" ") };
4460
+ }
4461
+ /** `goal_step`: a step number (the screen that step ended on) or a screen's name (part of its route). */
4462
+ goalStepHolds(running, goalStep, route) {
4463
+ const now = route.join(" › ");
4464
+ if (/^\d+$/.test(goalStep.trim())) {
4465
+ const n = Number(goalStep);
4466
+ const step = running.state.steps[n - 1];
4467
+ if (step === undefined)
4468
+ return { ok: false, why: `the journey has no step ${n}.` };
4469
+ const screen = step.screen === undefined ? undefined : running.state.screens[step.screen - 1];
4470
+ if (screen === undefined || screen.route.length === 0 || route.length === 0) {
4471
+ return { ok: null, why: `step ${n}'s screen can't be told apart (the app has no routes); only text can be checked.` };
4472
+ }
4473
+ const same = screen.route.join(" › ") === now;
4474
+ return same ? { ok: true, why: `the app is still on step ${n}'s screen (${now}).` } : { ok: false, why: `the app is on ${now}, not step ${n}'s screen (${screen.route.join(" › ")}).` };
4475
+ }
4476
+ const named = now.toLowerCase().includes(goalStep.trim().toLowerCase());
4477
+ return named ? { ok: true, why: `the app is still on ${now}.` } : { ok: false, why: `the app is on ${now === "" ? "a screen with no route" : now}, not ${JSON.stringify(goalStep)}.` };
4478
+ }
4479
+ /** The command and arguments that set one of a journey's conditions. */
4480
+ conditionCall(name, value) {
4481
+ switch (name) {
4482
+ case "network":
4483
+ return { command: "network", args: { mode: value } };
4484
+ case "language":
4485
+ return { command: "language", args: { language: value } };
4486
+ case "region":
4487
+ return { command: "region", args: { region: value } };
4488
+ case "text_size":
4489
+ return { command: "text_size", args: { size: value } };
4490
+ }
4491
+ }
4492
+ /** Runs one condition command straight on the app (a journey's own, not the agent's shortcut); the app's answer may say what it was before. */
4493
+ async runCondition(session, name, value) {
4494
+ const call = this.conditionCall(name, value);
4495
+ if (name === "network") {
4496
+ // The app that receives the condition (a journey's own, maybe not the selected one) is the one the policy is read for.
4497
+ const refused = this.networkRefusal(value, session.app);
4498
+ if (refused !== null) {
4499
+ const said = refused.content.map((block) => (block.type === "text" ? block.text : "")).join(" ");
4500
+ return { ok: false, failure: said.replace(/^Error \w+: /, "").replace(/\.$/, "") };
4501
+ }
4502
+ }
4503
+ try {
4504
+ const raw = await this.host.request("command", { name: call.command, args: call.args }, { sessionId: session.id });
4505
+ const inner = typeof raw === "object" && raw !== null && "result" in raw ? raw.result : undefined;
4506
+ const previous = typeof inner === "object" && inner !== null && "previous" in inner ? inner.previous : undefined;
4507
+ return { ok: true, ...(typeof previous === "string" && previous !== "" ? { previous } : {}) };
4508
+ }
4509
+ catch (error) {
4510
+ return { ok: false, failure: describeError(error).replace(/^Error \w+: /, "").replace(/\.$/, "") };
4511
+ }
4512
+ }
4513
+ async applyCondition(session, name, value) {
4514
+ const done = await this.runCondition(session, name, value);
4515
+ if (!done.ok)
4516
+ return { name, value, applied: false, failure: done.failure };
4517
+ return { name, value, applied: true, ...(done.previous !== undefined ? { previous: done.previous } : {}) };
4518
+ }
4519
+ /** At the stop: network goes back to normal, text size to the phone's own, language and region to what the app said they were. */
4520
+ async undoConditions(state) {
4521
+ const session = this.journeySession();
4522
+ for (const record of state.conditionRecords) {
4523
+ if (!record.applied)
4524
+ continue;
4525
+ const words = record.name.replace("_", " ");
4526
+ if ((record.name === "language" || record.name === "region") && record.previous === undefined) {
4527
+ record.undone = false;
4528
+ record.undo = `Note: ${words} ${record.value} was not restored: the app doesn't report what it was before.`;
4529
+ continue;
4530
+ }
4531
+ if (session === undefined) {
4532
+ record.undone = false;
4533
+ record.undo = `Note: ${words} ${record.value} was not undone: ${this.journeyAppGone()}.`;
4534
+ continue;
4535
+ }
4536
+ const back = record.name === "network" ? "normal" : record.name === "text_size" ? "system" : (record.previous ?? "");
4537
+ const done = await this.runCondition(session, record.name, back);
4538
+ record.undone = done.ok;
4539
+ record.undo = done.ok
4540
+ ? record.name === "text_size"
4541
+ ? "Undone: text size is the phone's own again."
4542
+ : `Undone: ${words} is ${back} again.`
4543
+ : `Note: ${words} ${record.value} was not undone: ${done.failure}.`;
4544
+ }
4545
+ }
3499
4546
  async wait(args) {
3500
4547
  const session = this.host.current();
3501
4548
  if (session === undefined)