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