tapline-host 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,21 +1,24 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { basename, dirname, join } from "node:path";
3
4
  import { toBuffer as qrPng } from "qrcode";
4
5
  import { z } from "zod";
5
6
  import { annotateScreenshot } from "./annotate.js";
6
7
  import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
7
8
  import { composeFilmstrip, diffImages, imageSize } from "./images.js";
8
- import { alertNotes, describeElement, formatChanges, formatElement, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
+ import { alertNotes, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
9
10
  import { formatInspect, inspectResultSchema } from "./inspect.js";
10
11
  import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
12
+ import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
11
13
  import { ActionLog, loggedTarget } from "./actionlog.js";
12
14
  import { Confirmations } from "./confirm.js";
13
15
  import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
14
16
  import { readScript, refStep, scriptSteps } from "./script.js";
15
- import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
17
+ import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
18
+ import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
16
19
  import { PAIR_CODE_TTL_MS, forgetLink, pairingLink } from "./pairing.js";
17
20
  import { elementSchema, logEntrySchema, screenshotResultSchema, treeResultSchema, } from "./protocol.js";
18
- import { TaplineError } from "./server.js";
21
+ import { TaplineError, identityOf } from "./server.js";
19
22
  // ---- input schemas ----------------------------------------------------------
20
23
  export const targetSchema = z
21
24
  .object({
@@ -230,15 +233,29 @@ export const toolShapes = {
230
233
  },
231
234
  logs: {
232
235
  since: z.number().optional().describe("Only lines after this timestamp (epoch milliseconds)"),
236
+ level: z.enum(["error", "warning", "info"]).optional().describe("That level and above (default info: everything)"),
237
+ grep: z.string().optional().describe("Only lines containing this text (case-insensitive)"),
238
+ limit: z.number().int().min(1).max(500).optional().describe("At most this many lines, the newest (1 to 500, default 100)"),
239
+ },
240
+ crash: {
241
+ all: z.boolean().optional().describe("The last 5 crashes instead of only the newest"),
233
242
  },
234
243
  reload: { look: lookField },
244
+ performance: {
245
+ launch: z
246
+ .boolean()
247
+ .optional()
248
+ .describe("true: reload the app's JavaScript and time it to the first settled screen (development builds only; not a cold start)"),
249
+ seconds: z.number().min(0.5).max(30).optional().describe("Measure for this long, 0.5 to 30 seconds (default 3)"),
250
+ tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
251
+ },
235
252
  wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
236
253
  run: {
237
254
  steps: z
238
255
  .array(stepSchema)
239
256
  .min(1)
240
257
  .max(MAX_STEPS)
241
- .describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command, compare_screen (checks, never saves). expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2); or matches (a reference name, alone or with one of them) with threshold.'),
258
+ .describe('The steps, in order: each is a tool call as an object, {"tool": "tap", "target": {...}, "expect": {...}}. Tools: tap, long_press, type_text, scroll, toggle, back, swipe, open_url, wait, wait_for, app_command, compare_screen (checks, never saves), performance. expect (optional): one of text, id or label, with gone, value and timeout (seconds, default 2); or matches (a reference name, alone or with one of them) with threshold; on a performance step, under: limits such as {"launch_s": 2, "drops": 0, "fps_min": 50, "busy_ms": 200, "js_heap_mb": 80}.'),
242
259
  trace: z.boolean().optional().describe("Also list each step that ran, one line each"),
243
260
  dry_run: z
244
261
  .boolean()
@@ -500,6 +517,18 @@ function toWireTarget(target) {
500
517
  return withIndex({ text: target.text });
501
518
  return { x: target.x ?? 0, y: target.y ?? 0 };
502
519
  }
520
+ /** A target as the agent wrote it, in words: `id=save`, `"Save"`, `e3`, `120,300`. */
521
+ function targetWords(target) {
522
+ if (target.id !== undefined)
523
+ return `id=${target.id}`;
524
+ if (target.text !== undefined)
525
+ return JSON.stringify(target.text);
526
+ if (target.label !== undefined)
527
+ return JSON.stringify(target.label);
528
+ if (target.ref !== undefined)
529
+ return target.ref;
530
+ return `${target.x ?? 0},${target.y ?? 0}`;
531
+ }
503
532
  function candidateLines(details) {
504
533
  const lines = [];
505
534
  const bareRefs = [];
@@ -622,6 +651,12 @@ function candidatesFor(target, elements) {
622
651
  return picked !== undefined ? [picked] : found;
623
652
  }
624
653
  // ---- tool handlers ----------------------------------------------------------
654
+ /** Every tool whose answer can end with a crash note (all but `crash`, which tells it in full). */
655
+ const CRASH_NOTED_TOOLS = [
656
+ "apps", "openApp", "pair", "look", "run", "inspect", "audit", "checkLayout", "viewer", "compareScreen", "filmstrip",
657
+ "record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
658
+ "openUrl", "logs", "reload", "performance", "wait",
659
+ ];
625
660
  /** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
626
661
  export class TaplineTools {
627
662
  host;
@@ -661,12 +696,20 @@ export class TaplineTools {
661
696
  replayContext = null;
662
697
  /** Where the latest failed check of the running batch saved its picture. */
663
698
  diffSaved = null;
699
+ /** What the last `performance` call measured, for the `under` limits of a `run` step. */
700
+ lastPerf;
664
701
  /** The elements the latest comparison painted its ignore list from: a recording saves its mask from the same list. */
665
702
  lastMaskElements;
666
703
  /** The screen size in points from the latest list or overlay, for the live viewer's 0..1 positions. */
667
704
  lastScreen;
668
705
  shotCount = 0;
669
706
  pairWrites = 0;
707
+ /** Per app (by identity): crash events up to this one have been told to the agent. */
708
+ notedCrashSeqs = new Map();
709
+ /** Crash events up to this one happened before this agent's session: an app with no entry above starts here. */
710
+ crashBaseline;
711
+ /** Set inside a tool call (and everything it awaits), so a call within a call (`run`'s steps) adds no notes. */
712
+ noteScope = new AsyncLocalStorage();
670
713
  constructor(host, options) {
671
714
  this.host = host;
672
715
  this.options = options;
@@ -682,6 +725,70 @@ export class TaplineTools {
682
725
  if (this.host.current()?.id === session.id)
683
726
  this.markPersonUsedApp();
684
727
  });
728
+ // Crashes before this agent's session began are not its news.
729
+ this.crashBaseline = host.crashMark();
730
+ for (const name of CRASH_NOTED_TOOLS) {
731
+ const original = Reflect.get(this, name);
732
+ if (typeof original !== "function")
733
+ continue;
734
+ const method = original;
735
+ Reflect.set(this, name, (...args) => this.noting(() => method.apply(this, args)));
736
+ }
737
+ }
738
+ /**
739
+ * Runs a tool call and ends its answer with a note when the app stopped (AGENT-TOOLS 15.2): during this
740
+ * call, or earlier, with its time, once. Calls inside a call (`run`'s steps) add nothing of their own.
741
+ */
742
+ noting(run) {
743
+ // Nesting belongs to the call and what it awaits (not to this object), so calls running side by side each
744
+ // count as outermost, and only a step inside a call is skipped.
745
+ if (this.noteScope.getStore() === true)
746
+ return run();
747
+ const mark = this.host.crashMark();
748
+ const startedWith = this.host.current();
749
+ return this.noteScope.run(true, () => {
750
+ const result = run();
751
+ if (result instanceof Promise)
752
+ return result.then((answer) => this.withCrashNotes(answer, mark, startedWith));
753
+ return this.withCrashNotes(result, mark, startedWith);
754
+ });
755
+ }
756
+ /** What was told about this app's crashes so far: the crash events up to this number. */
757
+ notedSeqOf(app) {
758
+ return this.notedCrashSeqs.get(identityOf(app)) ?? this.crashBaseline;
759
+ }
760
+ /**
761
+ * The notes for the app this call used (the one at its start, the one at its end, or, with none connected, the
762
+ * one that dropped last). Other apps' events stay pending until that app is the one being driven.
763
+ */
764
+ withCrashNotes(answer, mark, startedWith) {
765
+ const apps = new Map();
766
+ const current = this.host.current();
767
+ // A call that started on an app answers for that app only: another app that became current meanwhile (the first
768
+ // one dropped) keeps its crashes pending until it is the one being driven (Codex review of 1.3.0).
769
+ const endedWith = startedWith === undefined ? (current ?? this.host.lastDisconnect()?.session) : current !== undefined && identityOf(current) === identityOf(startedWith) ? current : undefined;
770
+ for (const app of [startedWith, endedWith])
771
+ if (app !== undefined)
772
+ apps.set(identityOf(app), app);
773
+ const events = [...apps.values()]
774
+ .flatMap((app) => this.host.crashEventsAfter(this.notedSeqOf(app), app))
775
+ .sort((a, b) => a.seq - b.seq);
776
+ if (events.length === 0)
777
+ return answer;
778
+ const told = this.host.crashMark();
779
+ for (const key of apps.keys())
780
+ this.notedCrashSeqs.set(key, told);
781
+ let noted = answer;
782
+ for (const event of events) {
783
+ // During this call: no time. A crash the phone had kept and sent later says when it happened.
784
+ const during = event.seq > mark;
785
+ // A crash kept by an earlier run of the app (this host had not started yet) says so, with its date.
786
+ const earlier = event.kind === "crash" && event.crash.source === "saved" && event.crash.ts < this.host.startedAt;
787
+ noted = appendNote(noted, event.kind === "crash"
788
+ ? crashNote(event.crash, !(during && event.crash.source === "live"), earlier)
789
+ : disconnectNote(during ? undefined : event.at));
790
+ }
791
+ return noted;
685
792
  }
686
793
  /** A person used the app: the next answer says so once, and `{ref}` targets are stale until the next full list. */
687
794
  markPersonUsedApp() {
@@ -901,6 +1008,35 @@ export class TaplineTools {
901
1008
  if (step.tool === "compare_screen" && params.update !== undefined) {
902
1009
  return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
903
1010
  }
1011
+ const under = step.expect?.under;
1012
+ if (under !== undefined) {
1013
+ if (step.tool !== "performance") {
1014
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under goes only on a performance step.`);
1015
+ }
1016
+ const unknown = badUnderKeys(under);
1017
+ if (unknown.length > 0) {
1018
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under has ${unknown.map((key) => JSON.stringify(key)).join(", ")}; the limits are ${UNDER_KEYS.join(", ")}.`);
1019
+ }
1020
+ if (Object.keys(under).length === 0) {
1021
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under is empty; give at least one limit (${UNDER_KEYS.join(", ")}).`);
1022
+ }
1023
+ const negative = Object.entries(under).filter(([, limit]) => limit < 0 || !Number.isFinite(limit));
1024
+ if (negative.length > 0) {
1025
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): under limits can't be negative (${negative.map(([key]) => key).join(", ")}).`);
1026
+ }
1027
+ if (under["launch_s"] !== undefined && step.launch !== true) {
1028
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch_s goes only on a launch step (performance with launch: true).`);
1029
+ }
1030
+ }
1031
+ // A step's own look and screenshot are ignored (§9.1).
1032
+ if (step.tool === "performance") {
1033
+ delete params["look"];
1034
+ delete params["screenshot"];
1035
+ // Checked before any step runs (and in a dry run), not when this step's turn comes.
1036
+ if (params["launch"] === true && params["tap"] !== undefined) {
1037
+ return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch reloads the app and tap taps in it; give one of them.`);
1038
+ }
1039
+ }
904
1040
  const parsed = z.object(shape).strict().safeParse(params);
905
1041
  if (!parsed.success) {
906
1042
  return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
@@ -927,12 +1063,15 @@ export class TaplineTools {
927
1063
  const trace = [];
928
1064
  let ran = 0;
929
1065
  let stop = null;
1066
+ // The newest log time when the current step began: a failed step shows the app's warnings since then (§15.4).
1067
+ let stepMark;
930
1068
  for (const [i, step] of steps.entries()) {
931
1069
  if (Date.now() - started > RUN_TIMEOUT_MS) {
932
1070
  stop = { index: i, code: "timeout", message: `the batch ran longer than ${RUN_TIMEOUT_MS / 1000} s`, rest: [] };
933
1071
  break;
934
1072
  }
935
1073
  const at = Date.now();
1074
+ stepMark = this.newestLogTs(session.id);
936
1075
  this.batch = { id: batchId, step: i + 1 };
937
1076
  const answer = await this.runStep(step).finally(() => {
938
1077
  this.batch = undefined;
@@ -943,7 +1082,22 @@ export class TaplineTools {
943
1082
  break;
944
1083
  }
945
1084
  notes.push(...stepNotes(said, i + 1));
946
- if (step.expect !== undefined) {
1085
+ if (step.expect?.under !== undefined) {
1086
+ // AGENT-TOOLS §15.3: the limits a performance step must keep; every broken one is listed.
1087
+ const measured = this.lastPerf;
1088
+ if (measured === undefined) {
1089
+ // A limit nobody measured must not pass.
1090
+ stop = { index: i, code: "expect_failed", message: "no measurement to check.", rest: [] };
1091
+ break;
1092
+ }
1093
+ const checked = checkUnder(step.expect.under, measured);
1094
+ notes.push(...checked.notes.map((note) => `Note (step ${i + 1}): ${note}`));
1095
+ if (checked.missed.length > 0) {
1096
+ stop = { index: i, code: "expect_failed", message: `${checked.missed.join("; ")}.`, rest: [] };
1097
+ break;
1098
+ }
1099
+ }
1100
+ else if (step.expect !== undefined) {
947
1101
  const missed = await this.expectMissed(step.expect);
948
1102
  if (missed !== null) {
949
1103
  stop = { index: i, code: "expect_failed", message: `its action ran, but ${missed}`, rest: [] };
@@ -987,28 +1141,59 @@ export class TaplineTools {
987
1141
  : `Error ${stop.code}: Ran ${ran} of ${total} steps; stopped at step ${stop.index + 1} (${describeRunStep(steps[stop.index])}): ${stop.message}`;
988
1142
  const diffNote = stop !== null && this.diffSaved !== null ? [`Note: the difference is saved at ${this.diffSaved}.`] : [];
989
1143
  const first = [head, ...trace, ...(stop?.rest ?? []), ...diffNote].join("\n");
1144
+ const tail = [...(stop !== null ? this.failureLogs(session.id, stop.index + 1, stepMark) : []), ...notes];
990
1145
  let response;
991
1146
  const live = this.host.current();
992
1147
  if (args.look === false || live === undefined) {
993
- response = text([first, ...notes].join("\n"));
1148
+ response = text([first, ...tail].join("\n"));
994
1149
  }
995
1150
  else {
996
1151
  response = await this.withLook(live, first, args.screenshot === true);
997
- if (notes.length > 0) {
1152
+ if (tail.length > 0) {
998
1153
  const [top, ...rest] = response.content;
999
1154
  if (top !== undefined && top.type === "text")
1000
- response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...notes].join("\n") }, ...rest] };
1155
+ response = { ...response, content: [{ type: "text", text: [top.text.trimEnd(), ...tail].join("\n") }, ...rest] };
1001
1156
  }
1002
1157
  }
1003
1158
  return failed ? { ...response, isError: true } : response;
1004
1159
  }
1160
+ /** The newest log time of a session (or of the app's current session), undefined without lines. */
1161
+ newestLogTs(sessionId) {
1162
+ return this.sessionLogs(sessionId).reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
1163
+ }
1164
+ /**
1165
+ * A session's buffered lines, also when it has ended. When the same app on the same phone came back as a new
1166
+ * session, that session's lines follow. Another app's lines never stand in for it (Codex review of 1.3.0).
1167
+ */
1168
+ sessionLogs(sessionId) {
1169
+ const record = this.host.sessionRecord(sessionId);
1170
+ if (record === undefined)
1171
+ return [];
1172
+ const now = this.host.current();
1173
+ if (now === undefined || now.id === sessionId || identityOf(now) !== record.identity)
1174
+ return record.events;
1175
+ try {
1176
+ return [...record.events, ...this.host.logs(now.id)];
1177
+ }
1178
+ catch {
1179
+ return record.events;
1180
+ }
1181
+ }
1182
+ /** AGENT-TOOLS §15.4: `Logs (since step <n>):` and up to 20 lines at warning or above since the step began. */
1183
+ failureLogs(sessionId, step, since) {
1184
+ const lines = filterLogs(this.sessionLogs(sessionId), { since, level: "warning", limit: 20 });
1185
+ return lines.length === 0 ? [] : [`Logs (since step ${step}):`, ...formatLogs(lines).split("\n")];
1186
+ }
1005
1187
  /** `run` with dry_run (§11.4): the steps are checked already; step 1 is resolved, nothing is touched. */
1006
1188
  async dryRunBatch(steps) {
1007
1189
  const total = steps.length;
1008
1190
  const first = steps[0];
1009
1191
  const head = `Would run ${total} step${total === 1 ? "" : "s"}`;
1010
- if (["tap", "long_press", "toggle", "type_text", "scroll", "back", "swipe"].includes(first.tool)) {
1011
- const answer = await this.runStep({ ...first, dry_run: true });
1192
+ // A performance step with a tap: the tap resolves like any touch; nothing is measured (AGENT-TOOLS §15.3).
1193
+ const perfTap = first.tool === "performance" ? first["tap"] : undefined;
1194
+ const probe = perfTap !== undefined ? { tool: "tap", target: perfTap } : first;
1195
+ if (["tap", "long_press", "toggle", "type_text", "scroll", "back", "swipe"].includes(probe.tool)) {
1196
+ const answer = await this.runStep({ ...probe, dry_run: true });
1012
1197
  const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
1013
1198
  const [line, ...rest] = said.split("\n");
1014
1199
  if (answer.isError === true) {
@@ -1018,6 +1203,10 @@ export class TaplineTools {
1018
1203
  const would = (line ?? "").replace(/^Would /, "would ");
1019
1204
  return text([`${head}; step 1 ${would}`, ...rest].join("\n"));
1020
1205
  }
1206
+ if (first.tool === "performance") {
1207
+ const p = first;
1208
+ return text(`${head}; step 1 would ${p["launch"] === true ? "reload the app and time it to the first settled screen" : `measure ${Number(p["seconds"] ?? 3)} s`}.`);
1209
+ }
1021
1210
  return text(`${head}; step 1 would ${describeRunStep(first)}.`);
1022
1211
  }
1023
1212
  /** One step of `run`, as its own tool call without a list. */
@@ -1049,6 +1238,8 @@ export class TaplineTools {
1049
1238
  return this.appCommand(quiet);
1050
1239
  case "compare_screen":
1051
1240
  return this.compareStep(rest);
1241
+ case "performance":
1242
+ return this.performance(rest);
1052
1243
  }
1053
1244
  }
1054
1245
  /** The screen's route names now (one cheap read), or null when the app has no router or didn't answer. */
@@ -2096,11 +2287,19 @@ export class TaplineTools {
2096
2287
  // A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
2097
2288
  // The tap is a touch like any other: the safety policy decides first.
2098
2289
  const pressParams = { target: toWireTarget(args.tap) };
2099
- const gate = await this.policyGate(session, "tap", "press", pressParams, listsFor(this.policy, session.app));
2100
- if ("stop" in gate)
2101
- return gate.stop;
2102
- frames.push(await this.takeShot(session.id, frameWidth, 0.8));
2103
- await this.host.request("press", pressParams, { sessionId: session.id });
2290
+ // The policy check, the "before" frame and the tap are one turn of the live view's queue: a pause refuses
2291
+ // it, a person's input goes first, and the screen can't change between the check and the tap (PR #4 review).
2292
+ const turn = async () => {
2293
+ const gate = await this.policyGate(session, "tap", "press", pressParams, listsFor(this.policy, session.app));
2294
+ if ("stop" in gate)
2295
+ return gate.stop;
2296
+ frames.push(await this.takeShot(session.id, frameWidth, 0.8));
2297
+ await this.host.request("press", pressParams, { sessionId: session.id });
2298
+ return null;
2299
+ };
2300
+ const stopped = this.options.control ? await this.options.control.runAgent(turn) : await turn();
2301
+ if (stopped !== null)
2302
+ return stopped;
2104
2303
  }
2105
2304
  for (let i = frames.length; i < count; i++) {
2106
2305
  const at = Date.now();
@@ -2790,37 +2989,213 @@ export class TaplineTools {
2790
2989
  // fall through with an empty list
2791
2990
  }
2792
2991
  }
2793
- const last = entries.reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
2794
- const footer = last !== undefined ? `\n(latest ts ${last}; pass since=${last} for newer lines)` : "";
2795
- return text(`${formatLogs(entries)}${footer}`);
2992
+ const newest = entries.reduce((max, e) => (e.ts !== undefined && (max === undefined || e.ts > max) ? e.ts : max), undefined);
2993
+ const query = { since: args.since, level: args.level, grep: args.grep, limit: args.limit };
2994
+ return text(formatLogAnswer(filterLogs(entries, query), query, newest ?? args.since ?? 0));
2995
+ }
2996
+ /** The newest fatal JS error of the app (`all`: the last 5). Reading them tells the agent what the notes did. */
2997
+ crash(args = {}) {
2998
+ // The app being driven, else the one that stopped last. Never every app's crashes at once.
2999
+ const known = this.host.current() ?? this.host.lastCrashEvent()?.of;
3000
+ if (known === undefined)
3001
+ return text(NO_CRASH_TEXT);
3002
+ // Reading them tells the agent what the notes of this app said; other apps' stay pending.
3003
+ this.notedCrashSeqs.set(identityOf(known), this.host.crashMark());
3004
+ const crashes = this.host.crashes(known);
3005
+ if (crashes.length === 0)
3006
+ return text(NO_CRASH_TEXT);
3007
+ return text(crashes.slice(0, args.all === true ? 5 : 1).map(formatCrash).join("\n\n"));
2796
3008
  }
2797
3009
  async reload(args) {
2798
3010
  const session = this.host.current();
2799
3011
  if (session === undefined)
2800
3012
  return this.notConnected();
2801
3013
  try {
2802
- await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
2803
- }
2804
- catch (error) {
2805
- // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
2806
- const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
2807
- if (!worked)
2808
- return fail(describeError(error));
3014
+ try {
3015
+ // The app goes away on purpose: that is no crash.
3016
+ this.host.expectEnd(session.id);
3017
+ await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
3018
+ }
3019
+ catch (error) {
3020
+ // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
3021
+ const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
3022
+ if (!worked)
3023
+ return fail(describeError(error));
3024
+ }
3025
+ if (args.look === false)
3026
+ return text("Reload requested.");
3027
+ const deadline = Date.now() + this.reloadWaitMs;
3028
+ for (;;) {
3029
+ const now = this.host.current();
3030
+ if (now !== undefined && now.id !== session.id)
3031
+ break;
3032
+ if (Date.now() > deadline)
3033
+ return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
3034
+ await sleep(100);
3035
+ }
2809
3036
  }
2810
- if (args.look === false)
2811
- return text("Reload requested.");
2812
- const deadline = Date.now() + this.reloadWaitMs;
2813
- for (;;) {
2814
- const now = this.host.current();
2815
- if (now !== undefined && now.id !== session.id)
2816
- break;
2817
- if (Date.now() > deadline)
2818
- return text("Reload requested, but the app has not reconnected yet. Try look in a few seconds.");
2819
- await sleep(100);
3037
+ finally {
3038
+ // Whatever happened, the wait is over: a reload that "worked" without the app going away must not
3039
+ // leave a later, real crash of this session explained.
3040
+ this.host.expectEnd(session.id, false);
2820
3041
  }
2821
3042
  await sleep(this.reloadSettleMs);
2822
3043
  return this.withLook(this.host.current() ?? session, "Reloaded; the app reconnected.");
2823
3044
  }
3045
+ // -- performance (AGENT-TOOLS §15.3)
3046
+ /**
3047
+ * `launch`: a JavaScript reload (development builds), timed from the request to the new session's first
3048
+ * settled screen. Stops with the answer when the reload can't be made or the app doesn't come back.
3049
+ */
3050
+ async timedReload(session) {
3051
+ const started = Date.now();
3052
+ let fresh;
3053
+ try {
3054
+ try {
3055
+ // The app goes away on purpose: that is no crash.
3056
+ this.host.expectEnd(session.id);
3057
+ await this.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
3058
+ }
3059
+ catch (error) {
3060
+ // The app dies mid-reload, so a dropped connection or a missing answer means it worked.
3061
+ const worked = error instanceof TaplineError && (error.code === "reconnecting" || error.code === "timeout" || error.code === "not_answering");
3062
+ if (!worked) {
3063
+ if (error instanceof TaplineError && error.code === "unsupported") {
3064
+ return { stop: fail(`Error unsupported: launch is a JavaScript reload, and only development builds can do one (${error.message}).`) };
3065
+ }
3066
+ return { stop: fail(describeError(error)) };
3067
+ }
3068
+ }
3069
+ const deadline = Date.now() + this.reloadWaitMs;
3070
+ // Only the same app on the same phone, back as a new session, counts: another connected app that became
3071
+ // current meanwhile is never measured as the reload (Codex review of 1.3.0).
3072
+ const same = identityOf(session);
3073
+ while (fresh === undefined) {
3074
+ const back = this.host.sessions().find((s) => s.id !== session.id && identityOf(s) === same);
3075
+ if (back !== undefined) {
3076
+ fresh = back;
3077
+ // and the agent goes on driving it, not whichever app the host picked when the old session dropped
3078
+ if (this.host.current()?.id !== back.id)
3079
+ this.host.select(back.id);
3080
+ }
3081
+ else if (Date.now() > deadline) {
3082
+ return { stop: fail(`Error timeout: the app hadn't reconnected ${Number((this.reloadWaitMs / 1000).toFixed(1))} s after the reload; try look in a few seconds.`) };
3083
+ }
3084
+ else
3085
+ await sleep(100);
3086
+ }
3087
+ }
3088
+ finally {
3089
+ // The wait is over, whatever happened: a reload that "worked" without the app going away must not leave
3090
+ // a later crash of this session explained away.
3091
+ this.host.expectEnd(session.id, false);
3092
+ }
3093
+ // The first settled screen: the app waits until React hasn't committed for a moment (§6).
3094
+ // The first settled screen: a launch time is only reported when the app said it settled.
3095
+ try {
3096
+ await this.host.request("perfSettle", {}, { sessionId: fresh.id, timeoutMs: 8000 });
3097
+ }
3098
+ catch (error) {
3099
+ return { stop: fail(describeError(error)) };
3100
+ }
3101
+ return { session: fresh, seconds: (Date.now() - started) / 1000 };
3102
+ }
3103
+ /** One measuring window in the app: start, (the tap,) wait out `seconds`, stop. */
3104
+ async measureWindow(session, seconds, press) {
3105
+ const id = { sessionId: session.id };
3106
+ // The window's number: the stop names it, so a call never takes another call's numbers (an older SDK has none).
3107
+ let stopParams = {};
3108
+ try {
3109
+ const started = await this.host.request("perfStart", {}, id);
3110
+ const windowId = typeof started === "object" && started !== null ? started.id : undefined;
3111
+ if (typeof windowId === "number")
3112
+ stopParams = { id: windowId };
3113
+ }
3114
+ catch (error) {
3115
+ if (error instanceof TaplineError && error.code === "unsupported") {
3116
+ throw new TaplineError("unsupported", "this app's Tapline is too old for performance; update react-native-tapline.");
3117
+ }
3118
+ throw error;
3119
+ }
3120
+ const started = Date.now();
3121
+ try {
3122
+ // The caller holds the live view's queue for the whole window when there is a tap (see performance).
3123
+ if (press !== undefined)
3124
+ await this.host.request("press", press, id);
3125
+ const rest = seconds * 1000 - (Date.now() - started);
3126
+ if (rest > 0)
3127
+ await sleep(rest);
3128
+ }
3129
+ catch (error) {
3130
+ await this.host.request("perfStop", stopParams, id).catch(() => undefined);
3131
+ throw error;
3132
+ }
3133
+ const parsed = perfResultSchema.safeParse(await this.host.request("perfStop", stopParams, id));
3134
+ if (!parsed.success)
3135
+ throw new TaplineError("internal", "the app returned performance numbers the host could not read.");
3136
+ return parsed.data;
3137
+ }
3138
+ async performance(args = {}) {
3139
+ this.lastPerf = undefined;
3140
+ const session = this.host.current();
3141
+ if (session === undefined)
3142
+ return this.notConnected();
3143
+ if (args.launch === true && args.tap !== undefined) {
3144
+ return fail("Error bad_params: launch reloads the app and tap taps in it; give one of them.");
3145
+ }
3146
+ const seconds = args.seconds ?? 3;
3147
+ try {
3148
+ let measure;
3149
+ if (args.launch === true) {
3150
+ const launched = await this.timedReload(session);
3151
+ if ("stop" in launched)
3152
+ return launched.stop;
3153
+ measure = { result: await this.measureWindow(launched.session, seconds), launchS: launched.seconds };
3154
+ }
3155
+ else {
3156
+ let press;
3157
+ if (args.tap !== undefined) {
3158
+ // The tap is a touch like any other: the safety policy decides first (as for filmstrip).
3159
+ press = { target: toWireTarget(args.tap) };
3160
+ const stale = this.staleRef(press);
3161
+ if (stale !== null)
3162
+ return stale;
3163
+ }
3164
+ // With a tap, the policy check and the whole window are one turn of the live view's queue: a pause refuses
3165
+ // it, a person's input goes first and waits for the window, and the screen can't change between the check
3166
+ // and the tap (Codex and CodeRabbit reviews of 1.3.0).
3167
+ const tapped = press;
3168
+ const window = async () => {
3169
+ if (tapped !== undefined) {
3170
+ const gate = await this.policyGate(session, "tap", "press", tapped, listsFor(this.policy, session.app));
3171
+ if ("stop" in gate)
3172
+ return { stop: gate.stop };
3173
+ }
3174
+ return { result: await this.measureWindow(session, seconds, tapped) };
3175
+ };
3176
+ const outcome = tapped !== undefined && this.options.control ? await this.options.control.runAgent(window) : await window();
3177
+ if ("stop" in outcome)
3178
+ return outcome.stop;
3179
+ measure = { result: outcome.result };
3180
+ }
3181
+ this.lastPerf = measure;
3182
+ if (this.recording !== null && !this.replaying) {
3183
+ this.recording.steps.push({
3184
+ method: "performance",
3185
+ params: {
3186
+ ...(args.launch === true ? { launch: true } : {}),
3187
+ ...(args.seconds !== undefined ? { seconds: args.seconds } : {}),
3188
+ ...(args.tap !== undefined ? { tap: args.tap } : {}),
3189
+ },
3190
+ });
3191
+ }
3192
+ const tapWords = args.tap === undefined ? undefined : targetWords(args.tap);
3193
+ return text([perfFirstLine(measure, tapWords), ...formatPerfLines(measure.result), ...this.takePersonNote()].join("\n"));
3194
+ }
3195
+ catch (error) {
3196
+ return fail(describeError(error));
3197
+ }
3198
+ }
2824
3199
  async wait(args) {
2825
3200
  const session = this.host.current();
2826
3201
  if (session === undefined)