tapline-host 1.6.2 → 1.7.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/accounts.d.ts +129 -12
- package/dist/accounts.js +844 -75
- package/dist/accounts.js.map +1 -1
- package/dist/actionlog.d.ts +11 -4
- package/dist/actionlog.js +22 -1
- package/dist/actionlog.js.map +1 -1
- package/dist/animations.d.ts +4 -2
- package/dist/animations.js +7 -7
- package/dist/animations.js.map +1 -1
- package/dist/api.d.ts +7 -1
- package/dist/api.js +12 -1
- package/dist/api.js.map +1 -1
- package/dist/audit.d.ts +1 -0
- package/dist/auditlog.d.ts +40 -0
- package/dist/auditlog.js +150 -0
- package/dist/auditlog.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +143 -7
- package/dist/cli.js.map +1 -1
- package/dist/control.d.ts +51 -0
- package/dist/control.js +110 -0
- package/dist/control.js.map +1 -1
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +61 -4
- package/dist/doctor.js.map +1 -1
- package/dist/drive.d.ts +5 -2
- package/dist/drive.js +50 -13
- package/dist/drive.js.map +1 -1
- package/dist/elicit.d.ts +47 -0
- package/dist/elicit.js +87 -0
- package/dist/elicit.js.map +1 -1
- package/dist/format.d.ts +6 -0
- package/dist/format.js +25 -4
- package/dist/format.js.map +1 -1
- package/dist/grants.d.ts +297 -0
- package/dist/grants.js +938 -0
- package/dist/grants.js.map +1 -0
- package/dist/grantscmd.d.ts +34 -0
- package/dist/grantscmd.js +100 -0
- package/dist/grantscmd.js.map +1 -0
- package/dist/hostfacts.d.ts +23 -0
- package/dist/hostfacts.js +65 -0
- package/dist/hostfacts.js.map +1 -0
- package/dist/inspect.d.ts +3 -2
- package/dist/inspect.js +20 -8
- package/dist/inspect.js.map +1 -1
- package/dist/mapcmd.d.ts +2 -2
- package/dist/mcp.js +1 -1
- package/dist/nochange.d.ts +1 -0
- package/dist/otpclient.d.ts +79 -0
- package/dist/otpclient.js +362 -0
- package/dist/otpclient.js.map +1 -0
- package/dist/pidlock.d.ts +99 -0
- package/dist/pidlock.js +382 -0
- package/dist/pidlock.js.map +1 -0
- package/dist/places.d.ts +11 -0
- package/dist/places.js +28 -2
- package/dist/places.js.map +1 -1
- package/dist/policy.d.ts +2 -1
- package/dist/policy.js +5 -2
- package/dist/policy.js.map +1 -1
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/report.d.ts +8 -0
- package/dist/report.js +34 -1
- package/dist/report.js.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.js +27 -11
- package/dist/routes.js.map +1 -1
- package/dist/runschedule.d.ts +119 -0
- package/dist/runschedule.js +281 -0
- package/dist/runschedule.js.map +1 -0
- package/dist/runtime.d.ts +48 -1
- package/dist/runtime.js +214 -18
- package/dist/runtime.js.map +1 -1
- package/dist/schedule.d.ts +145 -0
- package/dist/schedule.js +401 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schedulehost.d.ts +73 -0
- package/dist/schedulehost.js +391 -0
- package/dist/schedulehost.js.map +1 -0
- package/dist/script.d.ts +2 -0
- package/dist/script.js +2 -0
- package/dist/script.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/thincall.d.ts +13 -0
- package/dist/thincall.js +139 -59
- package/dist/thincall.js.map +1 -1
- package/dist/tool-text-data.d.ts +1 -1
- package/dist/tool-text-data.js +1 -1
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tool-text.d.ts +1 -1
- package/dist/tools.d.ts +116 -6
- package/dist/tools.js +758 -134
- package/dist/tools.js.map +1 -1
- package/dist/unlockcmd.d.ts +44 -0
- package/dist/unlockcmd.js +80 -0
- package/dist/unlockcmd.js.map +1 -0
- package/dist/uxcheck.d.ts +1 -0
- package/dist/vault.d.ts +75 -0
- package/dist/vault.js +395 -0
- package/dist/vault.js.map +1 -0
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +34 -6
- package/skills/tapline/references/setup.md +11 -1
package/dist/tools.js
CHANGED
|
@@ -9,7 +9,7 @@ import { clusterPercent, composeFilmstrip, diffImages, imageSize } from "./image
|
|
|
9
9
|
import { briefLines, briefScreen } from "./brief.js";
|
|
10
10
|
import { LINK_NOT_SAVED, classifyMove, placeBefore, placeNow, placeSeen } from "./places.js";
|
|
11
11
|
import { NO_CHANGE_WATCH_MS, noChangeClause, noChangeNote, readPressFacts, saysNoVisibleChange, secondTapSentence, saysRequestsRunning, threwText } from "./nochange.js";
|
|
12
|
-
import { alertNotes, changeParts, networkLine, describeElement, filterLogs, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatStatus, formatTree, offScreenLine } from "./format.js";
|
|
12
|
+
import { alertNotes, alertSignature, changeParts, networkLine, describeElement, filterLogs, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatStatus, formatTree, offScreenLine, roleOf } from "./format.js";
|
|
13
13
|
import { formatInspect, inspectResultSchema } from "./inspect.js";
|
|
14
14
|
import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
|
|
15
15
|
import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
|
|
@@ -19,19 +19,21 @@ import { ANIMATIONS_NOT_PAUSED_NOTE, ANIMATIONS_PAUSED_NOTE, afterPause, animati
|
|
|
19
19
|
import { mapArgsError, reflexSchema, reflexWords, runMap } from "./mapcmd.js";
|
|
20
20
|
import { MAX_REFLEX_FIRINGS, MAX_REFLEX_SKIPS, fireReflex, isReflexFailure, matchReflex } from "./reflexes.js";
|
|
21
21
|
import { driveArgsError, driveQuietly, startProblem, driveRoute, driveTo, guardOf, parseMoveResult } from "./drive.js";
|
|
22
|
-
import { substituteAccount } from "./accounts.js";
|
|
22
|
+
import { mentionsVault, redactAll, redactContent, substituteAccount } from "./accounts.js";
|
|
23
23
|
import { EYES, IDS_HIDDEN, Journey, DRIVE_ALLOWED, REFLEXES_ALLOWED, FAST_ANIMATIONS_ALLOWED, OPEN_URL, conditionsSchema, exportSchema, summaryLine, summaryNotes, stressSchema, survivesSchema, withoutIds, } from "./journey.js";
|
|
24
24
|
import { JOURNEYS_KEPT, JourneyRecorder, findingsLine, freshFolder, pruneJourneys, screenSignature, writeReport } from "./journeyreport.js";
|
|
25
25
|
import { exportRefusal, exportReport } from "./journeyexport.js";
|
|
26
26
|
import { randomBytes } from "node:crypto";
|
|
27
27
|
import { Confirmations } from "./confirm.js";
|
|
28
|
-
import { askDone, okCaption, plainText } from "./elicit.js";
|
|
28
|
+
import { askDone, draftCaption, okCaption, plainText } from "./elicit.js";
|
|
29
|
+
import { createOtpService, otpReference } from "./otpclient.js";
|
|
30
|
+
import { GrantBook, REACH_WORDS, elementNeverGrantable, grantCaption, grantNameBase, neverGrantable, reachesPeople } from "./grants.js";
|
|
29
31
|
import { DEFAULT_POLICY, UNREADABLE_LINK, elementKey, linkReadable, linkWords, listsFor, matchWord } from "./policy.js";
|
|
30
32
|
import { NO_UX_RULES, formatUxCheck, uxResultSchema } from "./uxcheck.js";
|
|
31
33
|
import { CONFIRM_ERROR, MediaState, mediaShape, runMedia } from "./media.js";
|
|
32
34
|
import { WIRE_TEXT_MAX, conditionsOf, holdsIn, waitParams } from "./conditions.js";
|
|
33
35
|
import { caseTwin, readScript, refStep, scriptSteps } from "./script.js";
|
|
34
|
-
import { escapeParams, routePathOf, routeRunner, routeScript, routeSlug, routeTyped, saveRoute, substituteSteps, variableProblem } from "./routes.js";
|
|
36
|
+
import { escapeParams, loadRoute, routePathOf, routeRunner, routeScript, routeSlug, routeTyped, saveRoute, substituteSteps, variableProblem } from "./routes.js";
|
|
35
37
|
import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
|
|
36
38
|
import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, ifSchema, parseError, skippedLine, stepNotes, stepSchema, } from "./run.js";
|
|
37
39
|
import { PAIR_CODE_TTL_MS, forgetLink, pairingLink } from "./pairing.js";
|
|
@@ -307,6 +309,7 @@ export const toolShapes = {
|
|
|
307
309
|
account: z.string().optional().describe("With route: a test account's name; the tool types its values, you never see the password"),
|
|
308
310
|
then: z.array(stepSchema).optional().describe("run steps that go on after arriving, in the same call"),
|
|
309
311
|
by: z.enum(["any", "taps", "link"]).optional().describe('"any" (default), "taps" (roads only, never a jump) or "link" (a jump only)'),
|
|
312
|
+
look: lookField,
|
|
310
313
|
brief: briefField,
|
|
311
314
|
dry_run: dryRunField,
|
|
312
315
|
},
|
|
@@ -517,7 +520,7 @@ function stableParams(params, result) {
|
|
|
517
520
|
}
|
|
518
521
|
/** A step that types an account's value: its text is a placeholder, kept in the script (the value never is). */
|
|
519
522
|
function isAccountStep(step) {
|
|
520
|
-
return step.method === "type" && typeof step.params["text"] === "string" && step.params["text"]
|
|
523
|
+
return step.method === "type" && typeof step.params["text"] === "string" && mentionsVault(step.params["text"]);
|
|
521
524
|
}
|
|
522
525
|
/**
|
|
523
526
|
* The app refused an account's value because of the kind of field (capability `typeKind`; its `data.secure` says
|
|
@@ -781,6 +784,24 @@ function appendNote(answer, note) {
|
|
|
781
784
|
return answer;
|
|
782
785
|
return { ...answer, content: [{ type: "text", text: [top.text.trimEnd(), note].join("\n") }, ...rest] };
|
|
783
786
|
}
|
|
787
|
+
/** Several notes at the end of an answer, in order. */
|
|
788
|
+
function appendNotes(answer, notes) {
|
|
789
|
+
return notes.reduce((done, note) => appendNote(done, note), answer);
|
|
790
|
+
}
|
|
791
|
+
/** Takes the notes collected so far (a call's grant notes) onto the end of the answer and empties the list. */
|
|
792
|
+
function flushNotes(answer, notes) {
|
|
793
|
+
return appendNotes(answer, notes.splice(0));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Codes that say a request was refused before anything was touched: the host never sent it (not connected, app in the background)
|
|
797
|
+
* or the app refused it before it dispatched (target missing, not touchable, ambiguous, stale, bad arguments). Anything else
|
|
798
|
+
* (a timeout, a disconnect, an app error, a handler that threw) leaves it uncertain whether the touch was made.
|
|
799
|
+
*/
|
|
800
|
+
const NOT_DELIVERED_CODES = new Set(["not_found", "not_actionable", "ambiguous", "stale", "bad_params", "unsupported", "not_allowed", "not_connected", "in_background", "busy", "paused"]);
|
|
801
|
+
/** True when a request's failure proves the touch was not made. */
|
|
802
|
+
function notDelivered(error) {
|
|
803
|
+
return error instanceof TaplineError && NOT_DELIVERED_CODES.has(error.code);
|
|
804
|
+
}
|
|
784
805
|
/** The app's refusal when an alert it can't answer is open (a stand-in's own buttons are an ordinary screen). */
|
|
785
806
|
const ALERT_IN_WAY = /behind_alert: (An alert is open|Tapline's own prompt is open)/;
|
|
786
807
|
/** The dialog's text (§11.5): `Needs a person: <what>. I'll go on by myself when <it clears>.` */
|
|
@@ -925,6 +946,11 @@ const ELEMENT_ACTIONS = new Set(["tap", "long_press", "type_text", "toggle"]);
|
|
|
925
946
|
const AGENT_TOUCHES = new Set(["tap", "long_press", "toggle", "back", "open_url", "swipe"]);
|
|
926
947
|
/** What a call answers when the screen is not what the person was asked about (§11.3). */
|
|
927
948
|
const CHANGED_WHILE_WAITING = "Error not_allowed: needs_confirmation: the screen changed while waiting for the OK; ask again.";
|
|
949
|
+
/** What binds a person's OK to the draft they saw: the text. A changed draft is another question. */
|
|
950
|
+
const draftBinding = (draft) => (draft === null ? "" : `|draft|${draft.text}`);
|
|
951
|
+
/** What a grant offer asks for: the counts and days of a grant a person may create after allowing a touch (§19.6). */
|
|
952
|
+
export const GRANT_OFFER_PER_DAY = 3;
|
|
953
|
+
export const GRANT_OFFER_DAYS = 7;
|
|
928
954
|
/** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
|
|
929
955
|
export class TaplineTools {
|
|
930
956
|
host;
|
|
@@ -976,7 +1002,10 @@ export class TaplineTools {
|
|
|
976
1002
|
dialogs;
|
|
977
1003
|
/** Milliseconds this server spent waiting for a person's OK; run's 120 s doesn't count them (§9.3). */
|
|
978
1004
|
holdScope = new AsyncLocalStorage();
|
|
979
|
-
policy
|
|
1005
|
+
/** Read at each use: a host that reloads its policy (a scheduled run asks for its facts) hands every client the new one. */
|
|
1006
|
+
get policy() {
|
|
1007
|
+
return this.options.policy ?? DEFAULT_POLICY;
|
|
1008
|
+
}
|
|
980
1009
|
uxRules;
|
|
981
1010
|
/**
|
|
982
1011
|
* Hook for the journey (AGENT-TOOLS 1.12 §17): gives `ux_check` the action labels of earlier screens, and is told
|
|
@@ -992,6 +1021,8 @@ export class TaplineTools {
|
|
|
992
1021
|
listed = new Set();
|
|
993
1022
|
/** Sessions whose previous answer had an alert open, so the next change block can say `Alert: gone`. */
|
|
994
1023
|
alertOpen = new Set();
|
|
1024
|
+
/** What each session's open alert said in the previous answer (title, message, buttons), to see a change of the message alone. */
|
|
1025
|
+
alertSeen = new Map();
|
|
995
1026
|
/** Per session: how many actions answered with `look: false` since the agent was last given a list or a change block. */
|
|
996
1027
|
unlistedActions = new Map();
|
|
997
1028
|
/** The route of the latest element list, for recording and checking replays. */
|
|
@@ -1025,6 +1056,17 @@ export class TaplineTools {
|
|
|
1025
1056
|
* that runs inside it (its own action) can never reset or set the outer action's state.
|
|
1026
1057
|
*/
|
|
1027
1058
|
okScope = new AsyncLocalStorage();
|
|
1059
|
+
/** Whether a guarded touch's request may have reached the app (AGENT-TOOLS 1.14 §19.4): a grant's use is given back only when it surely did not. */
|
|
1060
|
+
deliveryScope = new AsyncLocalStorage();
|
|
1061
|
+
/** The saved routes running in this call (a drive, a replay): a grant with a `route` covers touches made inside it only (§19.1). */
|
|
1062
|
+
routeScope = new AsyncLocalStorage();
|
|
1063
|
+
/** True inside a route that carries `"draftsOnly": true` (§18.5, §21.3); it nests: a route inside one is drafts-only too. */
|
|
1064
|
+
draftsScope = new AsyncLocalStorage();
|
|
1065
|
+
/** The input the agent last typed into (§21.3): the draft's field. A secure field is never kept. */
|
|
1066
|
+
lastTyped;
|
|
1067
|
+
/** The grant notes of the call in flight (§19.3): they end its answer, before the call's other closing notes. */
|
|
1068
|
+
grantNotes = new AsyncLocalStorage();
|
|
1069
|
+
grantNoticeDone = false;
|
|
1028
1070
|
setOk(used) {
|
|
1029
1071
|
const state = this.okScope.getStore();
|
|
1030
1072
|
if (state !== undefined)
|
|
@@ -1091,6 +1133,7 @@ export class TaplineTools {
|
|
|
1091
1133
|
journeyBusy;
|
|
1092
1134
|
clock;
|
|
1093
1135
|
sleep;
|
|
1136
|
+
otp;
|
|
1094
1137
|
constructor(host, options) {
|
|
1095
1138
|
this.host = host;
|
|
1096
1139
|
this.options = options;
|
|
@@ -1099,10 +1142,11 @@ export class TaplineTools {
|
|
|
1099
1142
|
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1100
1143
|
this.confirmations = options.confirmations ?? new Confirmations();
|
|
1101
1144
|
this.dialogs = options.dialogs;
|
|
1102
|
-
this.policy = options.policy ?? DEFAULT_POLICY;
|
|
1103
1145
|
this.uxRules = options.uxRules ?? NO_UX_RULES;
|
|
1104
1146
|
this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
|
|
1105
1147
|
this.actionLog?.open();
|
|
1148
|
+
this.otp = options.otp ?? createOtpService();
|
|
1149
|
+
this.actionLog?.setBackstop((value) => this.backstop(value));
|
|
1106
1150
|
this.reloadSettleMs = options.reloadSettleMs ?? 1500;
|
|
1107
1151
|
this.stillGapMs = options.stillGapMs ?? 150;
|
|
1108
1152
|
this.stillCapMs = options.stillCapMs ?? 2000;
|
|
@@ -1191,7 +1235,7 @@ export class TaplineTools {
|
|
|
1191
1235
|
}
|
|
1192
1236
|
/** Runs a measuring call with fast animations normal, then puts them back, and says so in the answer (E8). */
|
|
1193
1237
|
async pausingAnimations(run) {
|
|
1194
|
-
const { value, paused, failed } = await withNormalAnimations(this.host, this.host.current()?.id, async () => run());
|
|
1238
|
+
const { value, paused, failed } = await withNormalAnimations(this.host, this.host.current()?.id, async () => run(), this.leased);
|
|
1195
1239
|
return failed ? appendNote(value, ANIMATIONS_NOT_PAUSED_NOTE) : paused ? appendNote(value, ANIMATIONS_PAUSED_NOTE) : value;
|
|
1196
1240
|
}
|
|
1197
1241
|
/** The journey running now, for the stress moments and the report writer (AGENT-TOOLS 1.12 §17). */
|
|
@@ -1333,6 +1377,42 @@ export class TaplineTools {
|
|
|
1333
1377
|
}
|
|
1334
1378
|
return null;
|
|
1335
1379
|
}
|
|
1380
|
+
/**
|
|
1381
|
+
* An agent action on the phone: behind a scheduled run's lease when another holds it, then in the live viewer's queue
|
|
1382
|
+
* (a person goes first; paused refuses).
|
|
1383
|
+
*/
|
|
1384
|
+
agentTurn(run) {
|
|
1385
|
+
const control = this.options.control;
|
|
1386
|
+
const go = control !== undefined ? () => control.runAgent(run) : run;
|
|
1387
|
+
return this.leaseGate(go);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Behind a scheduled run's lease: another client's call waits (then answers `busy`) while the lease stands. Only the credential the
|
|
1391
|
+
* run was given gets through; the client label never does (PR 12 Codex cycle 2).
|
|
1392
|
+
*/
|
|
1393
|
+
leaseGate(run) {
|
|
1394
|
+
const lease = this.options.lease;
|
|
1395
|
+
if (lease === undefined)
|
|
1396
|
+
return run();
|
|
1397
|
+
const who = (this.clientIdFn ?? this.options.clientId)?.()?.replace(/[^\w .@/-]/g, "").slice(0, 60) ?? "";
|
|
1398
|
+
return lease.run(who, run, this.holdScope.getStore()?.lease);
|
|
1399
|
+
}
|
|
1400
|
+
/** `sendRequest` as a value, for helpers that send requests of their own (the animation switch). */
|
|
1401
|
+
leased = (...args) => this.sendRequest(...args);
|
|
1402
|
+
/** Every request to the app goes through the lease check: a run's call whose lease ran out after it was queued sends nothing (§22.3). */
|
|
1403
|
+
sendRequest(...args) {
|
|
1404
|
+
try {
|
|
1405
|
+
this.checkLease();
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
return Promise.reject(error);
|
|
1409
|
+
}
|
|
1410
|
+
return this.host.request(...args);
|
|
1411
|
+
}
|
|
1412
|
+
/** Throws `lease_lost` when this call carries a lease credential that is not the standing holder's (right before something is sent). */
|
|
1413
|
+
checkLease() {
|
|
1414
|
+
this.options.lease?.assertCurrent(this.holdScope.getStore()?.lease);
|
|
1415
|
+
}
|
|
1336
1416
|
/** The MCP server gives the dialog once it can ask its client (the client's capabilities are known after initialize). */
|
|
1337
1417
|
setDialogs(dialogs) {
|
|
1338
1418
|
this.dialogs = dialogs;
|
|
@@ -1490,13 +1570,22 @@ export class TaplineTools {
|
|
|
1490
1570
|
if (map === undefined)
|
|
1491
1571
|
return fail("Error unsupported: this app reports no id of its own, so it has no map.");
|
|
1492
1572
|
// As a `run` step a drive builds no screen of its own: the batch answers once (9.1).
|
|
1493
|
-
|
|
1573
|
+
// `look: false` (1.13.3) answers the first line only on arrival; a step's own `look` is ignored (§9.1).
|
|
1574
|
+
const { look: askedLook, ...withoutLook } = args;
|
|
1575
|
+
// With `then`, the run steps answer and take the `look` (drive.ts afterThen).
|
|
1576
|
+
const quietLook = options.asStep !== true && askedLook === false && args.dry_run !== true && args.then === undefined;
|
|
1577
|
+
const given = options.asStep === true ? { ...withoutLook, quiet: true } : quietLook ? { ...args, quiet: true } : args;
|
|
1494
1578
|
const services = this.driveServices(session, map);
|
|
1495
1579
|
const routes = this.routeServices(session, args.account, args.brief);
|
|
1496
1580
|
const drove = () => (given.route !== undefined ? driveRoute(services, routes, given) : driveTo(services, given, routes));
|
|
1497
1581
|
// A batch keeps only `Note:` lines of a step's answer, so a repair is said as one there.
|
|
1498
1582
|
const go = async () => {
|
|
1499
1583
|
const answer = await drove();
|
|
1584
|
+
if (quietLook && answer.isError !== true) {
|
|
1585
|
+
// No list and no changes were given: the next answer's changes cover this call too, and says so.
|
|
1586
|
+
this.markListUnseen();
|
|
1587
|
+
this.unlistedActions.set(session.id, (this.unlistedActions.get(session.id) ?? 0) + 1);
|
|
1588
|
+
}
|
|
1500
1589
|
if (options.asStep !== true)
|
|
1501
1590
|
return answer;
|
|
1502
1591
|
return { ...answer, content: answer.content.map((block) => (block.type === "text" ? { ...block, text: block.text.replace(/^Road repaired/gm, "Note: Road repaired") } : block)) };
|
|
@@ -1526,7 +1615,7 @@ export class TaplineTools {
|
|
|
1526
1615
|
known: async () => (session.capabilities.includes("places") ? map.ensureKnown(session.id, () => source.request("places", {})) : (map.knownPlaces() ?? { source: "none", places: [] })),
|
|
1527
1616
|
tree: async () => {
|
|
1528
1617
|
// In a user's eyes a drive reads only what is on the glass (17.1).
|
|
1529
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
1618
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", this.userEyes() ? { glass: true } : {}, { sessionId: session.id }));
|
|
1530
1619
|
if (!tree.success)
|
|
1531
1620
|
throw new TaplineError("internal", "the app returned an element list the host could not read.");
|
|
1532
1621
|
return tree.data;
|
|
@@ -1537,14 +1626,34 @@ export class TaplineTools {
|
|
|
1537
1626
|
screen: (line, brief) => this.withLook(session, line, false, this.briefOn(brief)),
|
|
1538
1627
|
};
|
|
1539
1628
|
}
|
|
1629
|
+
/** Runs what a saved route does with its name in scope: a grant that names the route covers touches made inside it (§19.1). */
|
|
1630
|
+
inRoute(route, run, draftsOnly = false) {
|
|
1631
|
+
if (route === undefined)
|
|
1632
|
+
return run();
|
|
1633
|
+
const drafts = draftsOnly || this.draftsScope.getStore() === true;
|
|
1634
|
+
return this.routeScope.run([...(this.routeScope.getStore() ?? []), route], () => this.draftsScope.run(drafts, run));
|
|
1635
|
+
}
|
|
1636
|
+
/** Whether the saved route of that name in this app's map carries `"draftsOnly": true` (§18.5). */
|
|
1637
|
+
routeDraftsOnly(session, route) {
|
|
1638
|
+
const map = route === undefined ? undefined : this.mapOf(session);
|
|
1639
|
+
return route !== undefined && map !== undefined && loadRoute(map.folder(), route)?.draftsOnly === true;
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* The policy's lists for a touch. In a drafts-only route (§21.3) the words that reach other people ask a person whatever the
|
|
1643
|
+
* policy file lists, so that every such touch stops at a draft.
|
|
1644
|
+
*/
|
|
1645
|
+
touchLists(session) {
|
|
1646
|
+
const lists = listsFor(this.policy, session.app);
|
|
1647
|
+
return this.draftsScope.getStore() === true ? { confirm: [...lists.confirm, ...REACH_WORDS], block: lists.block } : lists;
|
|
1648
|
+
}
|
|
1540
1649
|
routeServices(session, account, brief) {
|
|
1541
1650
|
return {
|
|
1542
1651
|
accounts: this.options.accounts?.store ?? undefined,
|
|
1543
1652
|
probeAccount: (field, named) => {
|
|
1544
|
-
const used = this.accountText("$" + "{account." + field + "}", named, session);
|
|
1653
|
+
const used = this.accountText(field.startsWith("${") ? field : "$" + "{account." + field + "}", named, session);
|
|
1545
1654
|
return used !== null && !used.ok ? used.message : null;
|
|
1546
1655
|
},
|
|
1547
|
-
runSteps: (steps, look) => this.run({ steps, account, brief, ...(look ? {} : { look: false }) }),
|
|
1656
|
+
runSteps: (steps, look, route) => this.inRoute(route, () => this.run({ steps, account, brief, ...(look ? {} : { look: false }) }), this.routeDraftsOnly(session, route)),
|
|
1548
1657
|
checkSteps: (steps) => {
|
|
1549
1658
|
// The call's account is the steps' default account here too, as when `run` runs them.
|
|
1550
1659
|
const before = this.runAccount;
|
|
@@ -1566,7 +1675,7 @@ export class TaplineTools {
|
|
|
1566
1675
|
async driveTouch(session, call) {
|
|
1567
1676
|
this.logExtra = undefined;
|
|
1568
1677
|
this.actionStartedAt = Date.now();
|
|
1569
|
-
const lists =
|
|
1678
|
+
const lists = this.touchLists(session);
|
|
1570
1679
|
const { tool, method, params } = call;
|
|
1571
1680
|
const short = tool === "swipe" && Math.hypot(Number(params["x1"]) - Number(params["x0"]), Number(params["y1"]) - Number(params["y0"])) < 10;
|
|
1572
1681
|
// A jump's link comes from the map, which an agent can write: its path words are checked as a link-shaped element (§11).
|
|
@@ -1609,11 +1718,14 @@ export class TaplineTools {
|
|
|
1609
1718
|
waited = ready.waited;
|
|
1610
1719
|
}
|
|
1611
1720
|
let granted;
|
|
1721
|
+
let sentDraft = false;
|
|
1722
|
+
let covered;
|
|
1612
1723
|
const wire = call.pipelined
|
|
1613
1724
|
? { method: "move", params: { method, params, ...(guard !== undefined ? { guard } : {}), ...(call.next !== undefined ? { next: call.next } : {}) } }
|
|
1614
1725
|
: { method, params };
|
|
1615
1726
|
const timeoutMs = call.pipelined ? (call.next?.capMs ?? 0) + 15_000 : undefined;
|
|
1616
1727
|
let raw;
|
|
1728
|
+
let moveSent = false;
|
|
1617
1729
|
try {
|
|
1618
1730
|
const run = async () => {
|
|
1619
1731
|
this.setOk(false);
|
|
@@ -1623,14 +1735,28 @@ export class TaplineTools {
|
|
|
1623
1735
|
if ("stop" in gate)
|
|
1624
1736
|
throw new PolicyStop(gate.stop);
|
|
1625
1737
|
granted = gate.via;
|
|
1738
|
+
sentDraft = gate.draft === true;
|
|
1739
|
+
covered = gate.grant;
|
|
1740
|
+
}
|
|
1741
|
+
moveSent = true;
|
|
1742
|
+
try {
|
|
1743
|
+
return await this.sendRequest(wire.method, wire.params, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
1744
|
+
}
|
|
1745
|
+
catch (error) {
|
|
1746
|
+
if (notDelivered(error))
|
|
1747
|
+
moveSent = false;
|
|
1748
|
+
throw error;
|
|
1626
1749
|
}
|
|
1627
|
-
return this.host.request(wire.method, wire.params, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
1628
1750
|
};
|
|
1629
|
-
raw =
|
|
1751
|
+
raw = await this.agentTurn(run);
|
|
1630
1752
|
}
|
|
1631
1753
|
catch (error) {
|
|
1754
|
+
await this.dropPreUse(waited);
|
|
1632
1755
|
if (error instanceof PolicyStop)
|
|
1633
1756
|
return { kind: "stop", answer: error.answer };
|
|
1757
|
+
// The touch surely wasn't made: the grant's use is given back; a sent or uncertain one stays counted (§19.4).
|
|
1758
|
+
if (covered !== undefined && !moveSent)
|
|
1759
|
+
await this.releaseGrant(covered);
|
|
1634
1760
|
this.logExtra = undefined;
|
|
1635
1761
|
const code = error instanceof TaplineError ? error.code : "internal";
|
|
1636
1762
|
this.showAction("agent", method, undefined, params, code, error instanceof TaplineError ? reasonOf(error.code, error.message) : undefined);
|
|
@@ -1641,11 +1767,13 @@ export class TaplineTools {
|
|
|
1641
1767
|
this.showAction("agent", method, done, params);
|
|
1642
1768
|
if (granted !== undefined)
|
|
1643
1769
|
this.journeyRun?.noteOk(granted);
|
|
1770
|
+
if (covered !== undefined)
|
|
1771
|
+
this.grantNotes.getStore()?.push(covered.line);
|
|
1644
1772
|
// A jump is a shortcut a user doesn't have; a journey whose `allow` names drive says so in its report (AGENT-TOOLS 1.13 18.12).
|
|
1645
1773
|
if (call.of === "drive" && tool === "open_url" && this.journeyRun?.userEyes === true)
|
|
1646
1774
|
this.journeyRun.noteShortcut({ tool: "open_url", what: url });
|
|
1647
1775
|
const moved = call.pipelined ? parseMoveResult(raw) : { place: null };
|
|
1648
|
-
return { kind: "done", place: moved.place, ...("next" in moved && moved.next !== undefined ? { next: moved.next } : {}), ...(granted !== undefined ? { via: granted } : {}) };
|
|
1776
|
+
return { kind: "done", place: moved.place, ...("next" in moved && moved.next !== undefined ? { next: moved.next } : {}), ...(granted !== undefined ? { via: granted } : {}), ...(sentDraft ? { sent: true } : {}) };
|
|
1649
1777
|
}
|
|
1650
1778
|
/** `session_report` (AGENT-TOOLS 1.11 §16.1): where this session's calls went, and the waste in them. */
|
|
1651
1779
|
sessionReport(args = {}) {
|
|
@@ -1668,13 +1796,18 @@ export class TaplineTools {
|
|
|
1668
1796
|
const startedWith = this.host.current();
|
|
1669
1797
|
// Reflexes fired during the call are counted for it, and said in its answer (§18.7).
|
|
1670
1798
|
const reflexes = { fired: new Map(), lines: [] };
|
|
1671
|
-
|
|
1799
|
+
const grantLines = [];
|
|
1800
|
+
return this.grantNotes.run(grantLines, () => this.noteScope.run(true, () => this.reflexScope.run(reflexes, () => {
|
|
1672
1801
|
const result = this.personScope.run(new Set(), () => this.afterMoments(run, isDrive));
|
|
1673
|
-
|
|
1674
|
-
|
|
1802
|
+
const finish = (answer) => this.withReflexLines(this.withAccountsNote(this.withPersonNote(this.withCrashNotes(flushNotes(answer, grantLines), mark, startedWith))), reflexes);
|
|
1803
|
+
// The first answer of a session with grants says which grant is refused, and whether the state file is broken (§19.1, §19.4).
|
|
1804
|
+
if (this.options.grants !== undefined && !this.grantNoticeDone) {
|
|
1805
|
+
this.grantNoticeDone = true;
|
|
1806
|
+
const grants = this.options.grants;
|
|
1807
|
+
return Promise.resolve(result).then(async (answer) => finish(appendNotes(answer, GrantBook.noticeLines(await grants.inspect()))));
|
|
1675
1808
|
}
|
|
1676
|
-
return
|
|
1677
|
-
}));
|
|
1809
|
+
return result instanceof Promise ? result.then(finish) : finish(result);
|
|
1810
|
+
})));
|
|
1678
1811
|
}
|
|
1679
1812
|
/** What reflexes did in the call in flight: how often each fired, and the lines the answer carries. */
|
|
1680
1813
|
reflexScope = new AsyncLocalStorage();
|
|
@@ -1718,7 +1851,7 @@ export class TaplineTools {
|
|
|
1718
1851
|
let tree;
|
|
1719
1852
|
let place;
|
|
1720
1853
|
try {
|
|
1721
|
-
const read = treeResultSchema.safeParse(await this.
|
|
1854
|
+
const read = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }));
|
|
1722
1855
|
if (!read.success)
|
|
1723
1856
|
break;
|
|
1724
1857
|
tree = read.data;
|
|
@@ -1790,7 +1923,7 @@ export class TaplineTools {
|
|
|
1790
1923
|
if ("index" in t || "ref" in t || "x" in t || !("testID" in t || "label" in t || "text" in t))
|
|
1791
1924
|
return null;
|
|
1792
1925
|
try {
|
|
1793
|
-
const read = treeResultSchema.safeParse(await this.
|
|
1926
|
+
const read = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }));
|
|
1794
1927
|
return read.success ? candidatesFor(target, read.data.elements) : null;
|
|
1795
1928
|
}
|
|
1796
1929
|
catch {
|
|
@@ -1978,7 +2111,7 @@ export class TaplineTools {
|
|
|
1978
2111
|
const session = this.host.current();
|
|
1979
2112
|
if (session === undefined)
|
|
1980
2113
|
return null;
|
|
1981
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
2114
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
1982
2115
|
return tree.success ? (tree.data.alerts ?? []).find((alert) => !alert.standIn) ?? null : null;
|
|
1983
2116
|
}
|
|
1984
2117
|
/**
|
|
@@ -2009,15 +2142,19 @@ export class TaplineTools {
|
|
|
2009
2142
|
/** The first answer of the session says an accounts file was refused (it sits in a git repo), once (AGENT-TOOLS 1.13 §18.6). */
|
|
2010
2143
|
withAccountsNote(answer) {
|
|
2011
2144
|
const loaded = this.options.accounts;
|
|
2012
|
-
if (this.accountsNoteDone || loaded === undefined
|
|
2145
|
+
if (this.accountsNoteDone || loaded === undefined)
|
|
2146
|
+
return answer;
|
|
2147
|
+
// A plain vault file carries its warning (§20.2); a refused one says why.
|
|
2148
|
+
const line = loaded.store !== null ? loaded.note : `Note: ${loaded.blocked}`;
|
|
2149
|
+
if (line === undefined)
|
|
2013
2150
|
return answer;
|
|
2014
2151
|
if (answer.content[0]?.type !== "text")
|
|
2015
2152
|
return answer;
|
|
2016
2153
|
this.accountsNoteDone = true;
|
|
2017
2154
|
// An answer that is the refusal itself already says it.
|
|
2018
|
-
if (answer.content[0].text.includes(loaded.blocked))
|
|
2155
|
+
if (loaded.store === null && answer.content[0].text.includes(loaded.blocked))
|
|
2019
2156
|
return answer;
|
|
2020
|
-
return appendNote(answer,
|
|
2157
|
+
return appendNote(answer, line);
|
|
2021
2158
|
}
|
|
2022
2159
|
/** The first call has been answered, so the "needs a person" note has had its turn. */
|
|
2023
2160
|
personNoteDone = false;
|
|
@@ -2390,6 +2527,10 @@ export class TaplineTools {
|
|
|
2390
2527
|
const used = typeof typed === "string" ? this.accountText(typed, typeof named === "string" ? named : undefined, session) : null;
|
|
2391
2528
|
if (used !== null && !used.ok)
|
|
2392
2529
|
return fail(`Error ${used.code}: step ${i + 1} (${describeRunStep(step)}): ${used.message}`);
|
|
2530
|
+
// A one-time code names the connected app and nothing else (§20.4); nothing is asked of the daemon before the step runs.
|
|
2531
|
+
const wrongApp = typeof typed === "string" ? this.otpPrecheck(typed, session) : null;
|
|
2532
|
+
if (wrongApp !== null)
|
|
2533
|
+
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${wrongApp}`);
|
|
2393
2534
|
// `as` is checked before any step runs too (§18.5): it goes only while a route records.
|
|
2394
2535
|
const as = params.as;
|
|
2395
2536
|
const badAs = typeof as === "string" ? this.asRefusal(as, typeof typed === "string" ? typed : "", typeof named === "string" ? named : undefined) : null;
|
|
@@ -2560,7 +2701,7 @@ export class TaplineTools {
|
|
|
2560
2701
|
let holds;
|
|
2561
2702
|
if (once) {
|
|
2562
2703
|
try {
|
|
2563
|
-
const raw = await this.
|
|
2704
|
+
const raw = await this.sendRequest("waitFor", params, { sessionId: session.id });
|
|
2564
2705
|
holds = typeof raw === "object" && raw !== null && raw.found === true;
|
|
2565
2706
|
}
|
|
2566
2707
|
catch {
|
|
@@ -2569,7 +2710,7 @@ export class TaplineTools {
|
|
|
2569
2710
|
}
|
|
2570
2711
|
}
|
|
2571
2712
|
else {
|
|
2572
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
2713
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
2573
2714
|
holds = tree.success ? holdsIn(tree.data.elements, condition) : true;
|
|
2574
2715
|
}
|
|
2575
2716
|
if (!holds)
|
|
@@ -2679,7 +2820,7 @@ export class TaplineTools {
|
|
|
2679
2820
|
const session = this.host.current();
|
|
2680
2821
|
if (session === undefined)
|
|
2681
2822
|
return null;
|
|
2682
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
2823
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
2683
2824
|
return tree.success && tree.data.route !== undefined ? tree.data.route.names : null;
|
|
2684
2825
|
}
|
|
2685
2826
|
/** Null when the expectation holds within its time; else how it failed. */
|
|
@@ -2707,7 +2848,7 @@ export class TaplineTools {
|
|
|
2707
2848
|
const until = Date.now() + seconds * 1000;
|
|
2708
2849
|
let actual;
|
|
2709
2850
|
for (;;) {
|
|
2710
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
2851
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
2711
2852
|
const element = tree.success
|
|
2712
2853
|
? tree.data.elements.find((el) => (e.id !== undefined ? el.testID === e.id : labelOfElement(el).toLowerCase() === (e.label ?? "").toLowerCase()))
|
|
2713
2854
|
: undefined;
|
|
@@ -2733,7 +2874,7 @@ export class TaplineTools {
|
|
|
2733
2874
|
return this.notConnected();
|
|
2734
2875
|
let raw;
|
|
2735
2876
|
try {
|
|
2736
|
-
raw = await this.
|
|
2877
|
+
raw = await this.sendRequest("inspect", { target: toWireTarget(args.target) }, { sessionId: session.id });
|
|
2737
2878
|
}
|
|
2738
2879
|
catch (error) {
|
|
2739
2880
|
return fail(describeError(error));
|
|
@@ -2741,7 +2882,7 @@ export class TaplineTools {
|
|
|
2741
2882
|
const parsed = inspectResultSchema.safeParse(raw);
|
|
2742
2883
|
if (!parsed.success)
|
|
2743
2884
|
return fail("Error internal: the app returned an inspect answer the host could not read.");
|
|
2744
|
-
return text(formatInspect(parsed.data));
|
|
2885
|
+
return text(formatInspect(parsed.data, undefined, (t) => this.scrubSecrets(t)));
|
|
2745
2886
|
}
|
|
2746
2887
|
// -- audit (AGENT-TOOLS 1.8 §10)
|
|
2747
2888
|
async audit(args = {}) {
|
|
@@ -2753,7 +2894,7 @@ export class TaplineTools {
|
|
|
2753
2894
|
}
|
|
2754
2895
|
let raw;
|
|
2755
2896
|
try {
|
|
2756
|
-
raw = await this.
|
|
2897
|
+
raw = await this.sendRequest("audit", args.types !== undefined ? { types: args.types } : {}, { sessionId: session.id });
|
|
2757
2898
|
}
|
|
2758
2899
|
catch (error) {
|
|
2759
2900
|
return fail(describeError(error));
|
|
@@ -2779,7 +2920,7 @@ export class TaplineTools {
|
|
|
2779
2920
|
const earlier = this.uxJourney?.earlier() ?? null;
|
|
2780
2921
|
let raw;
|
|
2781
2922
|
try {
|
|
2782
|
-
raw = await this.
|
|
2923
|
+
raw = await this.sendRequest("uxCheck", { ...(this.userEyes() ? { glass: true } : {}), ...(this.uxRules.wayBack ? { wayBack: true } : {}), ...(earlier !== null ? { earlier } : {}) }, { sessionId: session.id });
|
|
2783
2924
|
}
|
|
2784
2925
|
catch (error) {
|
|
2785
2926
|
return fail(describeError(error));
|
|
@@ -2826,7 +2967,7 @@ export class TaplineTools {
|
|
|
2826
2967
|
return runMedia(args, {
|
|
2827
2968
|
state: this.mediaState,
|
|
2828
2969
|
session,
|
|
2829
|
-
request: (params) => this.
|
|
2970
|
+
request: (params) => this.sendRequest("media", params, { sessionId: session.id, timeoutMs: 60_000 }),
|
|
2830
2971
|
describeError,
|
|
2831
2972
|
askPerson: (message) => this.personForMedia(message),
|
|
2832
2973
|
});
|
|
@@ -2838,7 +2979,7 @@ export class TaplineTools {
|
|
|
2838
2979
|
return this.notConnected();
|
|
2839
2980
|
let raw;
|
|
2840
2981
|
try {
|
|
2841
|
-
raw = await this.
|
|
2982
|
+
raw = await this.sendRequest("check", {}, { sessionId: session.id });
|
|
2842
2983
|
}
|
|
2843
2984
|
catch (error) {
|
|
2844
2985
|
return fail(describeError(error));
|
|
@@ -2887,10 +3028,9 @@ export class TaplineTools {
|
|
|
2887
3028
|
// counts on from the highest one; `look` (onlyChanges false) always restarts at e1.
|
|
2888
3029
|
const eyes = this.userEyes();
|
|
2889
3030
|
const treeParams = { ...(askChanges ? { changes: true } : {}), ...(eyes ? { glass: true } : {}) };
|
|
2890
|
-
const treePromise = this.
|
|
3031
|
+
const treePromise = this.sendRequest("tree", treeParams, { sessionId: sid });
|
|
2891
3032
|
const shotPromise = wantScreenshot && !this.noScreenshot.has(sid)
|
|
2892
|
-
? this.
|
|
2893
|
-
.request("screenshot", { format: "jpg", quality: 0.75, maxWidth: width }, { sessionId: sid })
|
|
3033
|
+
? this.sendRequest("screenshot", { format: "jpg", quality: 0.75, maxWidth: width }, { sessionId: sid })
|
|
2894
3034
|
.then((value) => ({ ok: true, value }))
|
|
2895
3035
|
.catch((error) => ({ ok: false, error }))
|
|
2896
3036
|
: Promise.resolve(undefined);
|
|
@@ -2919,7 +3059,14 @@ export class TaplineTools {
|
|
|
2919
3059
|
this.alertOpen.add(sid);
|
|
2920
3060
|
else
|
|
2921
3061
|
this.alertOpen.delete(sid);
|
|
2922
|
-
|
|
3062
|
+
// An alert that was open before and now reads differently (its message alone, say) is a change of the alert.
|
|
3063
|
+
const signature = alertsNow ? alertSignature(tree) : undefined;
|
|
3064
|
+
const alertChanged = signature !== undefined && this.alertSeen.has(sid) && this.alertSeen.get(sid) !== signature;
|
|
3065
|
+
if (signature !== undefined)
|
|
3066
|
+
this.alertSeen.set(sid, signature);
|
|
3067
|
+
else
|
|
3068
|
+
this.alertSeen.delete(sid);
|
|
3069
|
+
const changeBlock = askChanges ? changeParts(tree, { alertGone, alertChanged, eyes }) : null;
|
|
2923
3070
|
const changesText = changeBlock === null ? null : [changeBlock.label, ...changeBlock.header, ...changeBlock.changes].join("\n");
|
|
2924
3071
|
// For the call log: the list the agent now has (a change block leaves it as it was, but no longer certain).
|
|
2925
3072
|
if (changesText === null)
|
|
@@ -3026,7 +3173,7 @@ export class TaplineTools {
|
|
|
3026
3173
|
return {
|
|
3027
3174
|
capabilities: session.capabilities,
|
|
3028
3175
|
latest: () => this.host.sessions().find((s) => s.id === session.id)?.place,
|
|
3029
|
-
request: (method, params) => this.
|
|
3176
|
+
request: (method, params) => this.sendRequest(method, params ?? {}, { sessionId: session.id }),
|
|
3030
3177
|
};
|
|
3031
3178
|
}
|
|
3032
3179
|
/** Notes the place a fresh list shows. A script that replays doesn't change the map (§18.9); a map that fails never fails a call. */
|
|
@@ -3159,7 +3306,7 @@ export class TaplineTools {
|
|
|
3159
3306
|
if (wire !== undefined && wire.sessionId !== session.id) {
|
|
3160
3307
|
return fail("Error not_connected: the app changed while the account's value was being prepared; try again.");
|
|
3161
3308
|
}
|
|
3162
|
-
if (wire === undefined && method === "type" && typeof params === "object" && params !== null && String(params.text ?? "")
|
|
3309
|
+
if (wire === undefined && method === "type" && typeof params === "object" && params !== null && mentionsVault(String(params.text ?? ""))) {
|
|
3163
3310
|
return fail("Error bad_params: an account value can't be typed as plain text; it needs a loaded account and a connected app.");
|
|
3164
3311
|
}
|
|
3165
3312
|
const shortAnswer = this.briefOn(brief);
|
|
@@ -3187,10 +3334,21 @@ export class TaplineTools {
|
|
|
3187
3334
|
// `as` is a recording's name for the text, never the app's business.
|
|
3188
3335
|
const kept = typeof params === "object" && params !== null ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== "as" && (wire === undefined || key !== "account"))) : params;
|
|
3189
3336
|
const sent = wire === undefined ? kept : { ...kept, ...wire.values };
|
|
3190
|
-
|
|
3337
|
+
// From here the request may reach the app; only a failure that proves it didn't takes that back (a grant's use stays counted).
|
|
3338
|
+
const delivery = this.deliveryScope.getStore();
|
|
3339
|
+
if (delivery !== undefined)
|
|
3340
|
+
delivery.sent = true;
|
|
3341
|
+
try {
|
|
3342
|
+
return await this.sendRequest(method, sent, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
3343
|
+
}
|
|
3344
|
+
catch (error) {
|
|
3345
|
+
if (delivery !== undefined && notDelivered(error))
|
|
3346
|
+
delivery.sent = false;
|
|
3347
|
+
throw error;
|
|
3348
|
+
}
|
|
3191
3349
|
};
|
|
3192
3350
|
// With the live viewer, a person watching goes first.
|
|
3193
|
-
result =
|
|
3351
|
+
result = await this.agentTurn(run);
|
|
3194
3352
|
break;
|
|
3195
3353
|
}
|
|
3196
3354
|
catch (error) {
|
|
@@ -3357,7 +3515,7 @@ export class TaplineTools {
|
|
|
3357
3515
|
: null;
|
|
3358
3516
|
if (!element?.success)
|
|
3359
3517
|
return chosen;
|
|
3360
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
3518
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId }).catch(() => null));
|
|
3361
3519
|
if (!tree.success)
|
|
3362
3520
|
return chosen;
|
|
3363
3521
|
const t = target;
|
|
@@ -3479,7 +3637,7 @@ export class TaplineTools {
|
|
|
3479
3637
|
const sid = sessionId ?? this.host.current()?.id;
|
|
3480
3638
|
if (sid === undefined)
|
|
3481
3639
|
return null;
|
|
3482
|
-
const parsed = overlaySchema.safeParse(await this.
|
|
3640
|
+
const parsed = overlaySchema.safeParse(await this.sendRequest("overlay", {}, { sessionId: sid }));
|
|
3483
3641
|
if (!parsed.success)
|
|
3484
3642
|
return null;
|
|
3485
3643
|
const { rev, screen, elements } = parsed.data;
|
|
@@ -3502,7 +3660,7 @@ export class TaplineTools {
|
|
|
3502
3660
|
if (session === undefined)
|
|
3503
3661
|
return { ok: false, message: "No app is connected." };
|
|
3504
3662
|
try {
|
|
3505
|
-
await this.
|
|
3663
|
+
await this.sendRequest("feed", feedParams(on, quality), { sessionId: session.id });
|
|
3506
3664
|
return { ok: true };
|
|
3507
3665
|
}
|
|
3508
3666
|
catch (error) {
|
|
@@ -3529,7 +3687,7 @@ export class TaplineTools {
|
|
|
3529
3687
|
// nothing does (a list moves natively), it scrolls, as before.
|
|
3530
3688
|
if (swipe !== undefined) {
|
|
3531
3689
|
try {
|
|
3532
|
-
const swiped = await this.
|
|
3690
|
+
const swiped = await this.sendRequest("swipe", swipe, { sessionId: session.id });
|
|
3533
3691
|
return { ok: true, action: this.describeAction(personKind(input, "swipe"), swiped, point) };
|
|
3534
3692
|
}
|
|
3535
3693
|
catch (error) {
|
|
@@ -3539,7 +3697,7 @@ export class TaplineTools {
|
|
|
3539
3697
|
}
|
|
3540
3698
|
}
|
|
3541
3699
|
try {
|
|
3542
|
-
const result = await this.
|
|
3700
|
+
const result = await this.sendRequest(method, params, { sessionId: session.id });
|
|
3543
3701
|
return { ok: true, action: this.describeAction(personKind(input, method), result, point) };
|
|
3544
3702
|
}
|
|
3545
3703
|
catch (error) {
|
|
@@ -3623,7 +3781,7 @@ export class TaplineTools {
|
|
|
3623
3781
|
const session = this.host.current();
|
|
3624
3782
|
if (feed?.on === true && session !== undefined) {
|
|
3625
3783
|
try {
|
|
3626
|
-
const answer = feedAnswerSchema.safeParse(await this.
|
|
3784
|
+
const answer = feedAnswerSchema.safeParse(await this.sendRequest("feed", feedParams(true, feed.quality), { sessionId: session.id }));
|
|
3627
3785
|
const stats = answer.success ? answer.data.stats : undefined;
|
|
3628
3786
|
if (stats !== undefined) {
|
|
3629
3787
|
lines.push(`Capture: ${stats.frames} frames, ${Math.round(stats.avgCaptureMs)} ms each on average (longest ${Math.round(stats.maxCaptureMs)} ms). Stutter: ${stats.janky} of ${stats.observedFrames} animation frames were slow, ${stats.jankyWhileCapturing} of them during a capture.`);
|
|
@@ -3643,7 +3801,7 @@ export class TaplineTools {
|
|
|
3643
3801
|
}
|
|
3644
3802
|
// -- compare_screen and filmstrip
|
|
3645
3803
|
async takeShot(sessionId, width, quality) {
|
|
3646
|
-
const raw = await this.
|
|
3804
|
+
const raw = await this.sendRequest("screenshot", { format: "jpg", quality, maxWidth: width }, { sessionId });
|
|
3647
3805
|
const parsed = screenshotResultSchema.safeParse(raw);
|
|
3648
3806
|
if (!parsed.success)
|
|
3649
3807
|
throw new TaplineError("internal", "The app returned a screenshot the host could not read.");
|
|
@@ -3716,7 +3874,7 @@ export class TaplineTools {
|
|
|
3716
3874
|
/** The element list for naming regions and masking, read as a peek: the agent's refs and baseline must stay as they are. */
|
|
3717
3875
|
async peekElements(sessionId) {
|
|
3718
3876
|
try {
|
|
3719
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
3877
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId }));
|
|
3720
3878
|
if (tree.success && tree.data.screen)
|
|
3721
3879
|
return { scaleBase: tree.data.screen.width, elements: tree.data.elements };
|
|
3722
3880
|
}
|
|
@@ -3731,7 +3889,7 @@ export class TaplineTools {
|
|
|
3731
3889
|
*/
|
|
3732
3890
|
async checkAgainstReference(session, name, options) {
|
|
3733
3891
|
// A comparison, from a step's `expect` too, is made with animations normal (the tools that say so add the note).
|
|
3734
|
-
return (await withNormalAnimations(this.host, session.id, () => this.checkAgainstReferenceNow(session, name, options))).value;
|
|
3892
|
+
return (await withNormalAnimations(this.host, session.id, () => this.checkAgainstReferenceNow(session, name, options), this.leased)).value;
|
|
3735
3893
|
}
|
|
3736
3894
|
async checkAgainstReferenceNow(session, name, options) {
|
|
3737
3895
|
let shot;
|
|
@@ -4159,7 +4317,7 @@ export class TaplineTools {
|
|
|
4159
4317
|
// A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
|
|
4160
4318
|
// The tap is a touch like any other: the safety policy decides first.
|
|
4161
4319
|
const pressParams = { target: toWireTarget(args.tap), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
4162
|
-
const tapLists =
|
|
4320
|
+
const tapLists = this.touchLists(session);
|
|
4163
4321
|
const ready = await this.awaitOk(session, "tap", "press", pressParams, tapLists);
|
|
4164
4322
|
if ("stop" in ready)
|
|
4165
4323
|
return ready.stop;
|
|
@@ -4169,11 +4327,35 @@ export class TaplineTools {
|
|
|
4169
4327
|
const gate = await this.policyGate(session, "tap", "press", pressParams, tapLists, ready.waited);
|
|
4170
4328
|
if ("stop" in gate)
|
|
4171
4329
|
return gate.stop;
|
|
4172
|
-
|
|
4173
|
-
|
|
4330
|
+
let pressSent = false;
|
|
4331
|
+
try {
|
|
4332
|
+
frames.push(await this.takeShot(session.id, frameWidth, 0.8));
|
|
4333
|
+
pressSent = true;
|
|
4334
|
+
try {
|
|
4335
|
+
await this.sendRequest("press", pressParams, { sessionId: session.id });
|
|
4336
|
+
}
|
|
4337
|
+
catch (error) {
|
|
4338
|
+
if (notDelivered(error))
|
|
4339
|
+
pressSent = false;
|
|
4340
|
+
throw error;
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
catch (error) {
|
|
4344
|
+
if (gate.grant !== undefined && !pressSent)
|
|
4345
|
+
await this.releaseGrant(gate.grant);
|
|
4346
|
+
throw error;
|
|
4347
|
+
}
|
|
4348
|
+
if (gate.grant !== undefined)
|
|
4349
|
+
this.grantNotes.getStore()?.push(gate.grant.line);
|
|
4174
4350
|
return null;
|
|
4175
4351
|
};
|
|
4176
|
-
|
|
4352
|
+
let stopped;
|
|
4353
|
+
try {
|
|
4354
|
+
stopped = await this.agentTurn(turn);
|
|
4355
|
+
}
|
|
4356
|
+
finally {
|
|
4357
|
+
await this.dropPreUse(ready.waited);
|
|
4358
|
+
}
|
|
4177
4359
|
if (stopped !== null)
|
|
4178
4360
|
return stopped;
|
|
4179
4361
|
}
|
|
@@ -4275,7 +4457,7 @@ export class TaplineTools {
|
|
|
4275
4457
|
};
|
|
4276
4458
|
try {
|
|
4277
4459
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
4278
|
-
writeFileSync(path, `${JSON.stringify(script, null, 2)}\n`, { mode: 0o600 });
|
|
4460
|
+
writeFileSync(path, `${JSON.stringify(this.backstop(script), null, 2)}\n`, { mode: 0o600 });
|
|
4279
4461
|
}
|
|
4280
4462
|
catch (error) {
|
|
4281
4463
|
return fail(`Error internal: couldn't save the script: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -4340,7 +4522,7 @@ export class TaplineTools {
|
|
|
4340
4522
|
const script = routeScript({ name: recording.name, app: recording.app.id ?? recording.app.name, start: route.start, end: end?.place.name, steps, variables: route.variables });
|
|
4341
4523
|
let saved;
|
|
4342
4524
|
try {
|
|
4343
|
-
saved = await saveRoute(route.map.folder(), script, route.map);
|
|
4525
|
+
saved = await saveRoute(route.map.folder(), this.backstop(script), route.map);
|
|
4344
4526
|
}
|
|
4345
4527
|
catch (error) {
|
|
4346
4528
|
// The recording stays, so stop can be tried again.
|
|
@@ -4492,7 +4674,7 @@ export class TaplineTools {
|
|
|
4492
4674
|
update: options.updateReferences === true,
|
|
4493
4675
|
};
|
|
4494
4676
|
try {
|
|
4495
|
-
const answer = await this.run({ steps, look: args.look, screenshot: args.screenshot, brief: false });
|
|
4677
|
+
const answer = await this.inRoute(script.version === 2 ? script.name : undefined, () => this.run({ steps, look: args.look, screenshot: args.screenshot, brief: false }), script.version === 2 && script.draftsOnly === true);
|
|
4496
4678
|
if (notes.length === 0)
|
|
4497
4679
|
return answer;
|
|
4498
4680
|
const [top, ...rest] = answer.content;
|
|
@@ -4600,7 +4782,7 @@ export class TaplineTools {
|
|
|
4600
4782
|
return this.notConnected();
|
|
4601
4783
|
this.logExtra = undefined;
|
|
4602
4784
|
this.actionStartedAt = Date.now();
|
|
4603
|
-
const lists =
|
|
4785
|
+
const lists = this.touchLists(session);
|
|
4604
4786
|
if (!dryRun && lists.confirm.length === 0 && lists.block.length === 0)
|
|
4605
4787
|
return perform();
|
|
4606
4788
|
const stale = this.staleRef(params);
|
|
@@ -4620,7 +4802,16 @@ export class TaplineTools {
|
|
|
4620
4802
|
return fail(this.blockedLine(verdict.element, verdict.word));
|
|
4621
4803
|
}
|
|
4622
4804
|
const would = this.wouldLine(tool, shown, resolved.point, resolved.value, resolved.scrolls);
|
|
4623
|
-
|
|
4805
|
+
let needs = verdict.kind === "confirm" ? ` It needs a person's OK (policy word ${JSON.stringify(verdict.word)}).` : "";
|
|
4806
|
+
if (verdict.kind === "confirm") {
|
|
4807
|
+
// §19.3: a dry run says a grant would allow it, and counts nothing.
|
|
4808
|
+
const grant = await this.grantFor(session, tool, verdict.raw, verdict.word, "peek");
|
|
4809
|
+
if (grant !== null)
|
|
4810
|
+
needs = ` It is allowed by grant ${JSON.stringify(grant.name)} (${grant.counts} today).`;
|
|
4811
|
+
// §21.3: a touch that sends something stops at a draft first; nothing is shown now.
|
|
4812
|
+
else if ((await this.draftSource(session, verdict.raw, verdict.word)) !== null)
|
|
4813
|
+
needs = ` It stops at a draft first, and needs a person's OK (policy word ${JSON.stringify(verdict.word)}).`;
|
|
4814
|
+
}
|
|
4624
4815
|
this.showAction("agent", method, shown !== undefined ? { element: shown } : undefined, params);
|
|
4625
4816
|
return text([`${would}${needs}`, ...this.takePersonNote()].join("\n"));
|
|
4626
4817
|
}
|
|
@@ -4629,17 +4820,36 @@ export class TaplineTools {
|
|
|
4629
4820
|
if ("stop" in ready)
|
|
4630
4821
|
return ready.stop;
|
|
4631
4822
|
let granted;
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4823
|
+
let sentDraft = false;
|
|
4824
|
+
let covered;
|
|
4825
|
+
let answer;
|
|
4826
|
+
const delivery = { sent: false };
|
|
4827
|
+
try {
|
|
4828
|
+
answer = await this.deliveryScope.run(delivery, () => perform(async () => {
|
|
4829
|
+
const gate = await this.gatePreflight(session, tool, method, params, lists, ready.waited);
|
|
4830
|
+
if ("stop" in gate)
|
|
4831
|
+
return gate.stop;
|
|
4832
|
+
granted = gate.via;
|
|
4833
|
+
sentDraft = gate.draft === true;
|
|
4834
|
+
covered = gate.grant;
|
|
4835
|
+
return null;
|
|
4836
|
+
}));
|
|
4837
|
+
}
|
|
4838
|
+
finally {
|
|
4839
|
+
await this.dropPreUse(ready.waited);
|
|
4840
|
+
}
|
|
4841
|
+
// A use is counted when the touch is made: one that surely wasn't made gives its use back (§19.4). A touch that was sent, or
|
|
4842
|
+
// may have been (a timeout, a failed read after it, a handler that threw), stays counted: a repeat must not beat the limits.
|
|
4843
|
+
if (covered !== undefined && answer.isError === true && !delivery.sent)
|
|
4844
|
+
await this.releaseGrant(covered);
|
|
4845
|
+
const notes = this.grantNotes.getStore();
|
|
4846
|
+
const withNotes = notes !== undefined ? flushNotes(answer, notes) : answer;
|
|
4847
|
+
if (covered !== undefined && answer.isError !== true)
|
|
4848
|
+
return appendNote(withNotes, covered.line);
|
|
4639
4849
|
if (granted === undefined || answer.isError === true)
|
|
4640
|
-
return
|
|
4850
|
+
return withNotes;
|
|
4641
4851
|
this.journeyRun?.noteOk(granted);
|
|
4642
|
-
return appendNote(
|
|
4852
|
+
return appendNote(withNotes, `Note: ${sentDraft ? "sent" : "allowed"} by a person (in ${granted === "chat" ? "chat" : "the live view"}).`);
|
|
4643
4853
|
}
|
|
4644
4854
|
/**
|
|
4645
4855
|
* The check right before a touch, shared by a single call and a drive's move: the policy's verdict, the person's OK used up,
|
|
@@ -4649,11 +4859,15 @@ export class TaplineTools {
|
|
|
4649
4859
|
const gate = await this.policyGate(session, tool, method, params, lists, waited, known);
|
|
4650
4860
|
if ("stop" in gate)
|
|
4651
4861
|
return gate;
|
|
4862
|
+
if (gate.grant !== undefined) {
|
|
4863
|
+
this.logExtra = { ...(this.logExtra ?? {}), grant: gate.grant.name, grant_use: `${gate.grant.counts} today` };
|
|
4864
|
+
return { grant: gate.grant };
|
|
4865
|
+
}
|
|
4652
4866
|
if (gate.via === undefined)
|
|
4653
4867
|
return {};
|
|
4654
|
-
this.logExtra = { confirmed: true, confirmed_by: gate.via };
|
|
4868
|
+
this.logExtra = { ...(this.logExtra ?? {}), confirmed: true, confirmed_by: gate.via, ...(gate.draft === true ? { draft: true } : {}) };
|
|
4655
4869
|
this.setOk(true);
|
|
4656
|
-
return { via: gate.via };
|
|
4870
|
+
return { via: gate.via, ...(gate.draft === true ? { draft: true } : {}) };
|
|
4657
4871
|
}
|
|
4658
4872
|
/** What the person was asked for and what the screen showed then; the touch is checked against it right before it happens. */
|
|
4659
4873
|
fingerprint(element) {
|
|
@@ -4680,26 +4894,48 @@ export class TaplineTools {
|
|
|
4680
4894
|
return { waited: null };
|
|
4681
4895
|
if (this.inReflexStep())
|
|
4682
4896
|
return { stop: this.reflexNeedsPerson(verdict.element, verdict.word) };
|
|
4897
|
+
// A grant that covers the touch asks no one. Its use is taken here, before the dialog stage, so that a use that can't be
|
|
4898
|
+
// taken (the audit line fails) falls back to asking a person; the check right before the touch takes it over (§19.3, §19.4).
|
|
4899
|
+
// §21.3: a touch under a draftsOnly grant, or in a drafts-only route, stops at a draft a person sees first: no grant stands in.
|
|
4900
|
+
const draft = await this.draftOf(session, verdict.raw, verdict.word);
|
|
4901
|
+
const asked = [];
|
|
4902
|
+
const early = draft === null ? await this.grantFor(session, tool, verdict.raw, verdict.word, "use", asked) : null;
|
|
4903
|
+
if (early !== null)
|
|
4904
|
+
return { waited: { fp: this.fingerprint(verdict.raw), pre: { use: early, handed: false } } };
|
|
4905
|
+
// The reason a grant didn't cover goes into the action log with the touch, also after a long wait.
|
|
4906
|
+
const keepAsked = () => {
|
|
4907
|
+
if (asked[0] !== undefined)
|
|
4908
|
+
this.logExtra = { ...(this.logExtra ?? {}), grant_asked: asked[0] };
|
|
4909
|
+
};
|
|
4683
4910
|
const label = verdict.element.label ?? verdict.element.text ?? undefined;
|
|
4684
|
-
const caption = captionFor(tool, label ?? undefined, params) ?? tool;
|
|
4685
|
-
const key = this.confirmKey(session, tool, verdict.raw);
|
|
4911
|
+
const caption = draft?.caption ?? captionFor(tool, label ?? undefined, params) ?? tool;
|
|
4912
|
+
const key = this.confirmKey(session, tool, verdict.raw) + draftBinding(draft);
|
|
4686
4913
|
const ask = { kind: tool, caption, word: verdict.word };
|
|
4687
|
-
const fp = this.fingerprint(verdict.raw);
|
|
4914
|
+
const fp = this.fingerprint(verdict.raw) + draftBinding(draft);
|
|
4688
4915
|
const name = this.shortName(verdict.element, false);
|
|
4689
|
-
|
|
4916
|
+
// `why` is a draft's reason for not being sent (§21.3); without one a draft is waiting in the live view.
|
|
4917
|
+
const refuse = (tail, why) => {
|
|
4918
|
+
keepAsked();
|
|
4690
4919
|
this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
|
|
4920
|
+
if (draft !== null)
|
|
4921
|
+
return { stop: fail(this.draftRefusal(name, verdict.word, why, tail)) };
|
|
4691
4922
|
return { stop: fail(`Error not_allowed: needs_confirmation: ${name} needs a person's OK (policy word ${JSON.stringify(verdict.word)}). ${tail}`) };
|
|
4692
4923
|
};
|
|
4693
4924
|
const hold = this.holdScope.getStore();
|
|
4694
4925
|
const cancelled = hold?.signal;
|
|
4695
4926
|
const wasCancelled = () => cancelled?.aborted === true;
|
|
4696
4927
|
if (wasCancelled())
|
|
4697
|
-
return refuse("The call was cancelled before anyone answered.");
|
|
4928
|
+
return refuse("The call was cancelled before anyone answered.", "The call was cancelled before anyone answered.");
|
|
4698
4929
|
const check = this.confirmations.check(key, ask);
|
|
4699
|
-
if (check.granted)
|
|
4700
|
-
|
|
4930
|
+
if (check.granted) {
|
|
4931
|
+
keepAsked();
|
|
4932
|
+
return { waited: { fp, via: check.via, ...(draft !== null ? { draft: draft.source } : {}) } };
|
|
4933
|
+
}
|
|
4701
4934
|
if ("held" in check) {
|
|
4935
|
+
keepAsked();
|
|
4702
4936
|
this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
|
|
4937
|
+
if (draft !== null)
|
|
4938
|
+
return { stop: fail("Error not_allowed: needs_confirmation: the draft wasn't sent (They declined it just now.). Don't ask again by repeating the call: ask your user what they want.") };
|
|
4703
4939
|
return {
|
|
4704
4940
|
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.`),
|
|
4705
4941
|
};
|
|
@@ -4708,9 +4944,9 @@ export class TaplineTools {
|
|
|
4708
4944
|
const waitStarted = Date.now();
|
|
4709
4945
|
// The dialog's answer goes through the same request the live view answers: the first one wins, the other is closed.
|
|
4710
4946
|
const noDialog = new Promise((resolve) => {
|
|
4711
|
-
void
|
|
4712
|
-
.
|
|
4713
|
-
.then((answer) => {
|
|
4947
|
+
void (draft !== null
|
|
4948
|
+
? dialogs.askDraft(draft.caption, draft.word, abort.signal)
|
|
4949
|
+
: dialogs.askOk(okCaption({ word: verdict.word, tool, role: verdict.element.role ?? "", ...(label !== undefined ? { text: label } : {}), app: session.app.name }), abort.signal)).then((answer) => {
|
|
4714
4950
|
if (answer === "unavailable")
|
|
4715
4951
|
resolve("no_dialog");
|
|
4716
4952
|
else if (answer !== "aborted")
|
|
@@ -4733,16 +4969,23 @@ export class TaplineTools {
|
|
|
4733
4969
|
if (wasCancelled()) {
|
|
4734
4970
|
if (settled.allow)
|
|
4735
4971
|
this.confirmations.check(key, ask);
|
|
4736
|
-
return refuse("The call was cancelled before anyone answered.");
|
|
4972
|
+
return refuse("The call was cancelled before anyone answered.", "The call was cancelled before anyone answered.");
|
|
4737
4973
|
}
|
|
4738
4974
|
if (settled.expired)
|
|
4739
|
-
return refuse("Nobody answered. Ask your user, or repeat the call to ask again.");
|
|
4740
|
-
if (!settled.allow)
|
|
4741
|
-
|
|
4975
|
+
return refuse("Nobody answered. Ask your user, or repeat the call to ask again.", "Nobody answered.");
|
|
4976
|
+
if (!settled.allow) {
|
|
4977
|
+
this.options.audit?.write({ kind: "approval", app: session.app.id ?? session.app.name, name: verdict.word, detail: "declined" });
|
|
4978
|
+
return refuse("They declined. Ask your user what to do.", "They declined.");
|
|
4979
|
+
}
|
|
4742
4980
|
const used = this.confirmations.check(key, ask);
|
|
4743
4981
|
if (!used.granted)
|
|
4744
4982
|
return { stop: fail(CHANGED_WHILE_WAITING) };
|
|
4745
|
-
|
|
4983
|
+
// The person said yes in the chat's own dialog: they may also want it as a standing grant (§19.6).
|
|
4984
|
+
// A draft a person sends doesn't offer a grant: the next one is shown to them too.
|
|
4985
|
+
if (used.via === "chat" && draft === null)
|
|
4986
|
+
await this.offerGrant(session, verdict.word, verdict.raw, asked, cancelled);
|
|
4987
|
+
keepAsked();
|
|
4988
|
+
return { waited: { fp, via: used.via, ...(draft !== null ? { draft: draft.source } : {}) } };
|
|
4746
4989
|
}
|
|
4747
4990
|
/** The answer of a reflex's step that the policy flags at the moment it runs: skipped, never asked (§18.7). */
|
|
4748
4991
|
reflexNeedsPerson(element, word) {
|
|
@@ -4763,8 +5006,8 @@ export class TaplineTools {
|
|
|
4763
5006
|
* Runs a call of the agent with its own wait counter and, when there is one, the request's abort signal, so a
|
|
4764
5007
|
* wait for a person's OK is counted for this call only and closes when the call is cancelled.
|
|
4765
5008
|
*/
|
|
4766
|
-
withCall(signal, run) {
|
|
4767
|
-
return this.holdScope.run({ ms: 0, ...(signal !== undefined ? { signal } : {}) }, run);
|
|
5009
|
+
withCall(signal, run, leaseCredential) {
|
|
5010
|
+
return this.holdScope.run({ ms: 0, ...(signal !== undefined ? { signal } : {}), ...(leaseCredential !== undefined ? { lease: leaseCredential } : {}) }, run);
|
|
4768
5011
|
}
|
|
4769
5012
|
/**
|
|
4770
5013
|
* The policy's verdict on a touch about to happen: stop with an answer (blocked, needs a person's OK, or
|
|
@@ -4791,39 +5034,237 @@ export class TaplineTools {
|
|
|
4791
5034
|
// A reflex never asks (§18.7): what the screen shows now is flagged, so the reflex is skipped, with no dialog and no request.
|
|
4792
5035
|
if (verdict.kind === "confirm" && this.inReflexStep())
|
|
4793
5036
|
return { stop: this.reflexNeedsPerson(verdict.element, verdict.word) };
|
|
5037
|
+
// §21.3: read again right before the touch: the OK a person gave is for the text they saw.
|
|
5038
|
+
// Whether a draft applies was decided where the person was asked; a grant made by their answer in this very call is for the next touch.
|
|
5039
|
+
const waiting = waited !== undefined && waited !== null;
|
|
5040
|
+
const draftFrom = waiting ? ("draft" in waited ? waited.draft : undefined) : undefined;
|
|
5041
|
+
let draft = verdict.kind !== "confirm" ? null : waiting ? (draftFrom !== undefined ? await this.draftWith(session, draftFrom, verdict.word) : null) : await this.draftOf(session, verdict.raw, verdict.word);
|
|
5042
|
+
// The reserved grant stopped covering the touch while the call waited (R1): its use is back, and the touch goes the way it would have with no grant.
|
|
5043
|
+
let lostGrant = false;
|
|
4794
5044
|
if (waited !== undefined && waited !== null) {
|
|
4795
5045
|
// §11.3: the same element, with the same label, still touchable (the dry run passed), in the same app in front.
|
|
4796
5046
|
// A call that was cancelled while it waited never touches.
|
|
4797
5047
|
const same = verdict.kind === "confirm" &&
|
|
4798
|
-
this.fingerprint(verdict.raw) === waited.fp &&
|
|
5048
|
+
this.fingerprint(verdict.raw) + draftBinding(draft) === waited.fp &&
|
|
4799
5049
|
this.host.current()?.id === session.id &&
|
|
4800
5050
|
this.holdScope.getStore()?.signal?.aborted !== true;
|
|
4801
5051
|
if (!same) {
|
|
4802
5052
|
this.showAction("agent", method, undefined, params, "not_allowed", "needs_confirmation");
|
|
4803
5053
|
return { stop: fail(CHANGED_WHILE_WAITING) };
|
|
4804
5054
|
}
|
|
4805
|
-
|
|
5055
|
+
if ("pre" in waited) {
|
|
5056
|
+
// The grant's use was taken before the dialog stage. The wait for control may have outlasted what it was taken under (a
|
|
5057
|
+
// revocation, draftsOnly, quiet hours, `until`): read the policy again right now, taking no further use.
|
|
5058
|
+
const use = waited.pre.use;
|
|
5059
|
+
let covers = false;
|
|
5060
|
+
try {
|
|
5061
|
+
covers = this.options.grants !== undefined && (await this.options.grants.stillCovers({ app: session.app, element: verdict.raw, routes: this.routeScope.getStore() ?? [] }, use));
|
|
5062
|
+
}
|
|
5063
|
+
catch {
|
|
5064
|
+
covers = false;
|
|
5065
|
+
}
|
|
5066
|
+
if (covers) {
|
|
5067
|
+
// It is this touch's now, and given back if the touch isn't made.
|
|
5068
|
+
waited.pre.handed = true;
|
|
5069
|
+
return { granted: false, grant: use };
|
|
5070
|
+
}
|
|
5071
|
+
waited.pre.handed = true;
|
|
5072
|
+
await this.releaseGrant(use);
|
|
5073
|
+
lostGrant = true;
|
|
5074
|
+
draft = verdict.kind === "confirm" ? await this.draftOf(session, verdict.raw, verdict.word) : null;
|
|
5075
|
+
}
|
|
5076
|
+
else {
|
|
5077
|
+
this.approved(session, verdict.word, waited.via, draft !== null);
|
|
5078
|
+
return { granted: true, via: waited.via, ...(draft !== null ? { draft: true } : {}) };
|
|
5079
|
+
}
|
|
4806
5080
|
}
|
|
4807
5081
|
if (verdict.kind === "none")
|
|
4808
5082
|
return { granted: false };
|
|
5083
|
+
// §19.3: where the policy says a person must OK, a grant may stand in for them: it is used up here, right before the touch.
|
|
5084
|
+
// Not for a draft (§21.3): a person sees it first. Not again after the one reserved for this call stopped covering.
|
|
5085
|
+
const grant = draft === null && !lostGrant ? await this.grantFor(session, tool, verdict.raw, verdict.word, "use") : null;
|
|
5086
|
+
if (grant !== null)
|
|
5087
|
+
return { granted: false, grant };
|
|
4809
5088
|
const label = verdict.element.label ?? verdict.element.text ?? undefined;
|
|
4810
|
-
const caption = captionFor(tool, label ?? undefined, params) ?? tool;
|
|
5089
|
+
const caption = draft?.caption ?? captionFor(tool, label ?? undefined, params) ?? tool;
|
|
4811
5090
|
// The client is part of the key, so one agent can't use up an OK a person gave for another's call.
|
|
4812
|
-
const check = this.confirmations.check(this.confirmKey(session, tool, verdict.raw), { kind: tool, caption, word: verdict.word });
|
|
4813
|
-
if (check.granted)
|
|
4814
|
-
|
|
5091
|
+
const check = this.confirmations.check(this.confirmKey(session, tool, verdict.raw) + draftBinding(draft), { kind: tool, caption, word: verdict.word });
|
|
5092
|
+
if (check.granted) {
|
|
5093
|
+
this.approved(session, verdict.word, check.via, draft !== null);
|
|
5094
|
+
return { granted: true, via: check.via, ...(draft !== null ? { draft: true } : {}) };
|
|
5095
|
+
}
|
|
4815
5096
|
const name = this.shortName(verdict.element, false);
|
|
4816
5097
|
if ("held" in check) {
|
|
4817
5098
|
this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
|
|
5099
|
+
if (draft !== null)
|
|
5100
|
+
return { stop: fail("Error not_allowed: needs_confirmation: the draft wasn't sent (They declined it just now.). Don't ask again by repeating the call: ask your user what they want.") };
|
|
4818
5101
|
return {
|
|
4819
5102
|
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.`),
|
|
4820
5103
|
};
|
|
4821
5104
|
}
|
|
4822
5105
|
this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "needs_confirmation");
|
|
5106
|
+
if (draft !== null)
|
|
5107
|
+
return { stop: fail(this.draftRefusal(name, verdict.word, undefined, `${this.liveViewLine()}${check.suffix}`)) };
|
|
4823
5108
|
return {
|
|
4824
5109
|
stop: fail(`Error not_allowed: needs_confirmation: ${this.shortName(verdict.element, false)} needs a person's OK (policy word ${JSON.stringify(verdict.word)}). ${this.liveViewLine()}${check.suffix}`),
|
|
4825
5110
|
};
|
|
4826
5111
|
}
|
|
5112
|
+
/**
|
|
5113
|
+
* AGENT-TOOLS 1.14 §19.3: whether a grant covers this touch, which the policy says needs a person. `use` records the use (and
|
|
5114
|
+
* the audit line) and `peek` counts nothing. A grant that matched but can't cover the touch adds its note to the call's answer.
|
|
5115
|
+
* A touch that is never grantable, or has no grant, answers null: it asks as before.
|
|
5116
|
+
*/
|
|
5117
|
+
async grantFor(session, tool, element, word, mode, askedOut) {
|
|
5118
|
+
const book = this.options.grants;
|
|
5119
|
+
if (book === undefined || word === UNREADABLE_LINK)
|
|
5120
|
+
return null;
|
|
5121
|
+
const shown = this.host.redactOut((element.label ?? element.text ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim()).slice(0, 80);
|
|
5122
|
+
let decision;
|
|
5123
|
+
try {
|
|
5124
|
+
decision = await book.decide({ app: session.app, element, routes: this.routeScope.getStore() ?? [] }, mode, `${tool} ${JSON.stringify(shown)}`);
|
|
5125
|
+
}
|
|
5126
|
+
catch {
|
|
5127
|
+
return null;
|
|
5128
|
+
}
|
|
5129
|
+
// A draftsOnly grant covers nothing by itself: the touch stops at a draft (§21.3).
|
|
5130
|
+
if (decision.kind === "draft")
|
|
5131
|
+
return null;
|
|
5132
|
+
if (decision.kind === "ask") {
|
|
5133
|
+
const notes = this.grantNotes.getStore();
|
|
5134
|
+
if (notes !== undefined)
|
|
5135
|
+
for (const note of decision.notes)
|
|
5136
|
+
if (!notes.includes(note))
|
|
5137
|
+
notes.push(note);
|
|
5138
|
+
if (mode === "use" && decision.asked[0] !== undefined) {
|
|
5139
|
+
askedOut?.push(...decision.asked);
|
|
5140
|
+
this.logExtra = { ...(this.logExtra ?? {}), grant_asked: decision.asked[0] };
|
|
5141
|
+
}
|
|
5142
|
+
return null;
|
|
5143
|
+
}
|
|
5144
|
+
return { name: decision.name, line: decision.line, owner: decision.owner, slots: decision.slots, counts: decision.counts };
|
|
5145
|
+
}
|
|
5146
|
+
/** A person's OK is used up: one `approval` line of the audit log (§23). */
|
|
5147
|
+
approved(session, word, via, draft = false) {
|
|
5148
|
+
this.options.audit?.write({
|
|
5149
|
+
kind: "approval",
|
|
5150
|
+
app: session.app.id ?? session.app.name,
|
|
5151
|
+
name: word,
|
|
5152
|
+
detail: draft ? "draft sent" : `allowed by a person (in ${via === "chat" ? "chat" : "the live view"})`,
|
|
5153
|
+
});
|
|
5154
|
+
}
|
|
5155
|
+
/**
|
|
5156
|
+
* AGENT-TOOLS 1.14 §21.3: why a touch stops at a draft, or null. Under a `draftsOnly` grant that would cover the touch, or in a
|
|
5157
|
+
* route that carries `"draftsOnly": true` for a word that reaches other people. Reads nothing from the phone.
|
|
5158
|
+
*/
|
|
5159
|
+
async draftSource(session, element, word) {
|
|
5160
|
+
if (word === UNREADABLE_LINK)
|
|
5161
|
+
return null;
|
|
5162
|
+
if (this.draftsScope.getStore() === true && matchWord(REACH_WORDS, element) !== null)
|
|
5163
|
+
return "route";
|
|
5164
|
+
const book = this.options.grants;
|
|
5165
|
+
if (book === undefined)
|
|
5166
|
+
return null;
|
|
5167
|
+
try {
|
|
5168
|
+
const decision = await book.decide({ app: session.app, element, routes: this.routeScope.getStore() ?? [] }, "peek", "");
|
|
5169
|
+
return decision.kind === "draft" ? "grant" : null;
|
|
5170
|
+
}
|
|
5171
|
+
catch {
|
|
5172
|
+
return null;
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
5175
|
+
/** The draft a touch stops at (§21.3), with the question and the field's text as the person will see them; null when none applies. */
|
|
5176
|
+
async draftOf(session, element, word) {
|
|
5177
|
+
const source = await this.draftSource(session, element, word);
|
|
5178
|
+
return source === null ? null : this.draftWith(session, source, word);
|
|
5179
|
+
}
|
|
5180
|
+
/** The draft as the field holds it now. */
|
|
5181
|
+
async draftWith(session, source, word) {
|
|
5182
|
+
const text = await this.draftText(session);
|
|
5183
|
+
return { source, word, text, caption: draftCaption({ word, app: session.app.name, text }) };
|
|
5184
|
+
}
|
|
5185
|
+
/**
|
|
5186
|
+
* The text of the field the agent last typed into on this screen, else the only input that holds text, read from the app now
|
|
5187
|
+
* (Tapline's host reads it from the app, §21.3). Redacted of vault and account values. A secure field is never kept, and what
|
|
5188
|
+
* can't be read is an empty draft.
|
|
5189
|
+
*/
|
|
5190
|
+
async draftText(session) {
|
|
5191
|
+
let read;
|
|
5192
|
+
try {
|
|
5193
|
+
read = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id, internal: true }));
|
|
5194
|
+
}
|
|
5195
|
+
catch {
|
|
5196
|
+
return "";
|
|
5197
|
+
}
|
|
5198
|
+
if (!read.success)
|
|
5199
|
+
return "";
|
|
5200
|
+
const inputs = read.data.elements.filter((e) => roleOf(e) === "input" || (e.actions ?? []).includes("type"));
|
|
5201
|
+
const last = this.lastTyped;
|
|
5202
|
+
// By id when it has one: a full look renumbers the refs, so a ref may now name another input.
|
|
5203
|
+
const own = last !== undefined && last.sessionId === session.id ? (last.testID !== undefined ? inputs.find((e) => e.testID === last.testID) : inputs.find((e) => e.ref === last.ref)) : undefined;
|
|
5204
|
+
const filled = inputs.filter((e) => typeof e.value === "string" && e.value !== "");
|
|
5205
|
+
const field = own ?? (filled.length === 1 ? filled[0] : undefined);
|
|
5206
|
+
return typeof field?.value === "string" ? this.host.redactOut(field.value) : "";
|
|
5207
|
+
}
|
|
5208
|
+
/** A draft that wasn't sent, or one that waits in the live view (§21.3). `why` is the reason in parentheses. */
|
|
5209
|
+
draftRefusal(name, word, why, tail) {
|
|
5210
|
+
return why !== undefined
|
|
5211
|
+
? `Error not_allowed: needs_confirmation: the draft wasn't sent (${why}). Ask your user what to do.`
|
|
5212
|
+
: `Error not_allowed: needs_confirmation: a draft is waiting for a person (${name}, policy word ${JSON.stringify(word)}). ${tail}`;
|
|
5213
|
+
}
|
|
5214
|
+
/** A use taken before the dialog stage that no check took over (the call stopped first): given back. */
|
|
5215
|
+
async dropPreUse(waited) {
|
|
5216
|
+
if (waited === null || waited === undefined || !("pre" in waited) || waited.pre.handed)
|
|
5217
|
+
return;
|
|
5218
|
+
waited.pre.handed = true;
|
|
5219
|
+
await this.releaseGrant(waited.pre.use);
|
|
5220
|
+
}
|
|
5221
|
+
/** Asked once per app and word in a session, whatever the answer: a person who said no isn't asked at every touch. */
|
|
5222
|
+
grantOffers = new Set();
|
|
5223
|
+
/**
|
|
5224
|
+
* AGENT-TOOLS 1.14 §19.6: a person has just allowed a touch in the chat's own dialog; the host offers to make that a standing
|
|
5225
|
+
* grant. The host starts this, the person answers, and the grant is written only from their answers: nothing a model sends
|
|
5226
|
+
* can start, answer or change it. Never offered for what no grant can cover (§19.2), nor where a live grant matched and
|
|
5227
|
+
* ran out (a new grant would only undo its limit); an ended grant is renewed this way.
|
|
5228
|
+
*/
|
|
5229
|
+
async offerGrant(session, word, element, asked, signal) {
|
|
5230
|
+
const book = this.options.grants;
|
|
5231
|
+
const dialogs = this.dialogs;
|
|
5232
|
+
if (book === undefined || dialogs === undefined || !dialogs.available() || word === UNREADABLE_LINK || this.inReflexStep())
|
|
5233
|
+
return;
|
|
5234
|
+
if (asked.some((reason) => reason !== "ended"))
|
|
5235
|
+
return;
|
|
5236
|
+
if (neverGrantable(word, listsFor(this.policy, session.app).block) || elementNeverGrantable(element))
|
|
5237
|
+
return;
|
|
5238
|
+
const app = session.app.id ?? session.app.name;
|
|
5239
|
+
const key = `${app}|${word.toLowerCase()}`;
|
|
5240
|
+
if (this.grantOffers.has(key))
|
|
5241
|
+
return;
|
|
5242
|
+
this.grantOffers.add(key);
|
|
5243
|
+
const routes = this.routeScope.getStore() ?? [];
|
|
5244
|
+
const route = routes[routes.length - 1];
|
|
5245
|
+
const spec = {
|
|
5246
|
+
nameBase: grantNameBase(session.app.name, word),
|
|
5247
|
+
app,
|
|
5248
|
+
words: [word],
|
|
5249
|
+
...(route !== undefined ? { route } : {}),
|
|
5250
|
+
perDay: GRANT_OFFER_PER_DAY,
|
|
5251
|
+
days: GRANT_OFFER_DAYS,
|
|
5252
|
+
};
|
|
5253
|
+
const caption = grantCaption({ words: spec.words, perDay: spec.perDay, until: book.untilFor(spec.days), ...(route !== undefined ? { route } : {}), draftsOnly: reachesPeople(word) }, plainText(session.app.name) || "the app");
|
|
5254
|
+
if ((await dialogs.askGrant(caption, signal)) !== "allow")
|
|
5255
|
+
return;
|
|
5256
|
+
const lift = reachesPeople(word) ? (await dialogs.askLiftDrafts(signal)) === "allow" : false;
|
|
5257
|
+
await book.create({ ...spec, ...(lift ? { draftsOnly: false } : {}) });
|
|
5258
|
+
}
|
|
5259
|
+
/** The touch wasn't made: its use is given back (§19.4). */
|
|
5260
|
+
async releaseGrant(use) {
|
|
5261
|
+
try {
|
|
5262
|
+
await this.options.grants?.release(use);
|
|
5263
|
+
}
|
|
5264
|
+
catch {
|
|
5265
|
+
// a use that can't be given back stays counted: the safe side
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
4827
5268
|
/** Which policy word, if any, the elements the target could mean carry: a block word first, then a confirm word. */
|
|
4828
5269
|
policyWords(lists, resolved) {
|
|
4829
5270
|
// The words are matched on the raw elements, so a label an account value hides is still the word it is. What the
|
|
@@ -4865,7 +5306,7 @@ export class TaplineTools {
|
|
|
4865
5306
|
this.actionStartedAt = Date.now();
|
|
4866
5307
|
let result;
|
|
4867
5308
|
try {
|
|
4868
|
-
result = await this.
|
|
5309
|
+
result = await this.sendRequest(method, { ...params, dryRun: true }, { sessionId: session.id });
|
|
4869
5310
|
}
|
|
4870
5311
|
catch (error) {
|
|
4871
5312
|
this.logExtra = { dry_run: true };
|
|
@@ -4905,7 +5346,7 @@ export class TaplineTools {
|
|
|
4905
5346
|
if (session.capabilities.includes("dryRun")) {
|
|
4906
5347
|
let raw;
|
|
4907
5348
|
try {
|
|
4908
|
-
raw = await this.
|
|
5349
|
+
raw = await this.sendRequest(method, { ...params, dryRun: true }, { sessionId: session.id, internal: true });
|
|
4909
5350
|
}
|
|
4910
5351
|
catch (error) {
|
|
4911
5352
|
const reason = error instanceof TaplineError ? reasonOf(error.code, error.message) : undefined;
|
|
@@ -4936,7 +5377,7 @@ export class TaplineTools {
|
|
|
4936
5377
|
}
|
|
4937
5378
|
let raw;
|
|
4938
5379
|
try {
|
|
4939
|
-
raw = await this.
|
|
5380
|
+
raw = await this.sendRequest("tree", {}, { sessionId: session.id, internal: true });
|
|
4940
5381
|
}
|
|
4941
5382
|
catch (error) {
|
|
4942
5383
|
return { kind: "error", text: describeError(error), code: error instanceof TaplineError ? error.code : "internal" };
|
|
@@ -5014,6 +5455,44 @@ export class TaplineTools {
|
|
|
5014
5455
|
return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => afterScroll(r, `Long-pressed ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight, args.brief, "long_press"));
|
|
5015
5456
|
}
|
|
5016
5457
|
typeText(args) {
|
|
5458
|
+
const otpApp = otpReference(args.text);
|
|
5459
|
+
if (otpApp === null)
|
|
5460
|
+
return this.typeTextWith(args, undefined);
|
|
5461
|
+
// A one-time code (§20.4): the same checks as any typed text first, then the code is asked of Tapwright's daemon, and only
|
|
5462
|
+
// for the connected app's own id, only now that a step types it.
|
|
5463
|
+
const hiddenField = this.hiddenTarget(args.target);
|
|
5464
|
+
if (hiddenField !== null)
|
|
5465
|
+
return Promise.resolve(hiddenField);
|
|
5466
|
+
const badAs = this.asRefusal(args.as, args.text, args.account);
|
|
5467
|
+
if (badAs !== null)
|
|
5468
|
+
return Promise.resolve(fail(badAs));
|
|
5469
|
+
if (args.account !== undefined)
|
|
5470
|
+
return Promise.resolve(fail("Error bad_params: a one-time code takes no account; write ${otp.<app>} alone."));
|
|
5471
|
+
const session = this.host.current();
|
|
5472
|
+
if (session === undefined)
|
|
5473
|
+
return Promise.resolve(this.notConnected());
|
|
5474
|
+
const refused = this.otpRefusal(otpApp, session);
|
|
5475
|
+
if (refused !== null)
|
|
5476
|
+
return Promise.resolve(fail(`Error bad_params: ${refused}`));
|
|
5477
|
+
const app = session.app.id ?? session.app.name;
|
|
5478
|
+
// A dry run checks the field and asks for nothing: no code is fetched, and none is spent.
|
|
5479
|
+
if (args.dry_run === true)
|
|
5480
|
+
return this.typeTextWith(args, { value: "", source: undefined, sessionId: session.id });
|
|
5481
|
+
return this.otp.fetch(app).then((got) => got.ok
|
|
5482
|
+
? this.typeTextWith(args, { value: got.code, source: got.source, sessionId: session.id })
|
|
5483
|
+
: fail(`Error ${got.reason === "none" ? "not_found" : "unsupported"}: ${got.message}`));
|
|
5484
|
+
}
|
|
5485
|
+
/** Why `${otp.<app>}` can't be typed for the connected app, or null: the id must be the connected app's own. */
|
|
5486
|
+
otpRefusal(named, session) {
|
|
5487
|
+
const own = session.app.id ?? session.app.name;
|
|
5488
|
+
return named === own ? null : `\${otp.${named}} names another app than the connected one; a one-time code is typed only for the connected app (${own}), as \${otp.${own}}.`;
|
|
5489
|
+
}
|
|
5490
|
+
/** What `${otp.<app>}` checks before any step runs: the app it names, as for an account. Null when it holds or isn't a code. */
|
|
5491
|
+
otpPrecheck(text, session) {
|
|
5492
|
+
const named = otpReference(text);
|
|
5493
|
+
return named === null ? null : this.otpRefusal(named, session);
|
|
5494
|
+
}
|
|
5495
|
+
typeTextWith(args, otp) {
|
|
5017
5496
|
const hiddenField = this.hiddenTarget(args.target);
|
|
5018
5497
|
if (hiddenField !== null)
|
|
5019
5498
|
return Promise.resolve(hiddenField);
|
|
@@ -5021,16 +5500,18 @@ export class TaplineTools {
|
|
|
5021
5500
|
if (badAs !== null)
|
|
5022
5501
|
return Promise.resolve(fail(badAs));
|
|
5023
5502
|
// A test account's value (§18.6): `params` keeps the placeholder for the log, the viewer and a recording; the
|
|
5024
|
-
// value goes to the app only, through `wire`.
|
|
5503
|
+
// value goes to the app only, through `wire`. A one-time code goes the same way.
|
|
5025
5504
|
const session = this.host.current();
|
|
5026
|
-
const account =
|
|
5505
|
+
const account = otp !== undefined
|
|
5506
|
+
? { ok: true, value: otp.value, kind: undefined, use: undefined }
|
|
5507
|
+
: this.accountText(args.text, args.account, session);
|
|
5027
5508
|
if (account !== null && !account.ok)
|
|
5028
5509
|
return Promise.resolve(fail(`Error ${account.code}: ${account.message}`));
|
|
5029
5510
|
const params = {
|
|
5030
5511
|
text: args.text,
|
|
5031
5512
|
replace: args.replace ?? true,
|
|
5032
5513
|
submit: args.submit ?? false,
|
|
5033
|
-
...(account !== null ? { account: args.account ?? this.runAccount } : {}),
|
|
5514
|
+
...(account !== null && args.text.includes("${account.") ? { account: args.account ?? this.runAccount } : {}),
|
|
5034
5515
|
...(this.userEyes() ? { noScroll: true } : {}),
|
|
5035
5516
|
};
|
|
5036
5517
|
if (args.target !== undefined)
|
|
@@ -5038,20 +5519,23 @@ export class TaplineTools {
|
|
|
5038
5519
|
if (args.force === true)
|
|
5039
5520
|
params["force"] = true;
|
|
5040
5521
|
const wire = account !== null && account.ok && session !== undefined
|
|
5041
|
-
? { values: { text: account.value, kind: account.kind }, sessionId: session.id }
|
|
5522
|
+
? { values: { text: account.value, kind: account.kind }, sessionId: otp?.sessionId ?? session.id }
|
|
5042
5523
|
: undefined;
|
|
5043
5524
|
// Typing isn't guarded by the policy (§11.1: the form's button is); a dry run still checks the field.
|
|
5044
5525
|
if (args.dry_run === true) {
|
|
5045
5526
|
if (wire === undefined) {
|
|
5046
5527
|
// A placeholder with no checked account is never sent, not even to be resolved.
|
|
5047
|
-
if (args.text
|
|
5528
|
+
if (mentionsVault(args.text))
|
|
5048
5529
|
return Promise.resolve(this.notConnected());
|
|
5049
5530
|
return this.dryRun("type_text", "type", params);
|
|
5050
5531
|
}
|
|
5051
5532
|
const { account: _account, ...plain } = params;
|
|
5052
|
-
return this.dryRun("type_text", "type", { ...plain, text: "", kind: wire.values
|
|
5533
|
+
return this.dryRun("type_text", "type", { ...plain, text: "", ...(wire.values["kind"] !== undefined ? { kind: wire.values["kind"] } : {}) });
|
|
5053
5534
|
}
|
|
5054
|
-
|
|
5535
|
+
const use = account !== null && account.ok ? account.use : undefined;
|
|
5536
|
+
// What the audit line says about the field the value went into (§23): the app's own answer says whether it was secure.
|
|
5537
|
+
let secureField;
|
|
5538
|
+
const typed = this.act("type",
|
|
5055
5539
|
// `as` is for the recording only: `act` keeps it out of what goes to the app.
|
|
5056
5540
|
args.as !== undefined ? { ...params, as: args.as } : params, (r) => {
|
|
5057
5541
|
const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
|
|
@@ -5060,9 +5544,40 @@ export class TaplineTools {
|
|
|
5060
5544
|
// Journey or not, iOS shows the last character of a password for about a second: remember when (finding 4).
|
|
5061
5545
|
if (secure)
|
|
5062
5546
|
this.secureTypedAt = this.clock();
|
|
5547
|
+
secureField = secure;
|
|
5548
|
+
// The draft's field (§21.3): the last input typed into. A secure field's text is never read back.
|
|
5549
|
+
const typedInto = !secure && typeof r === "object" && r !== null && "element" in r ? elementSchema.safeParse(r.element) : undefined;
|
|
5550
|
+
const typedSession = this.host.current();
|
|
5551
|
+
if (typedInto?.success === true && typedSession !== undefined) {
|
|
5552
|
+
this.lastTyped = { sessionId: typedSession.id, ref: typedInto.data.ref, ...(typedInto.data.testID ? { testID: typedInto.data.testID } : {}) };
|
|
5553
|
+
}
|
|
5063
5554
|
const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
|
|
5064
5555
|
return afterScroll(r, `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`);
|
|
5065
|
-
}, args.look, args.screenshot, undefined, undefined, undefined, args.brief, undefined, wire);
|
|
5556
|
+
}, args.look, args.screenshot, undefined, undefined, use?.kind === "card" && session !== undefined ? () => this.cardOk(session, use, params) : undefined, args.brief, undefined, wire);
|
|
5557
|
+
if (otp !== undefined && session !== undefined) {
|
|
5558
|
+
// One `otp` line per code typed: the app and the source, never the code (§20.4, §23). The answer is scrubbed as well,
|
|
5559
|
+
// whatever the app echoed back.
|
|
5560
|
+
return typed.then((answer) => {
|
|
5561
|
+
if (answer.isError !== true) {
|
|
5562
|
+
this.options.audit?.write({ kind: "otp", app: session.app.id ?? session.app.name, detail: `from ${otp.source ?? "Tapwright"}` });
|
|
5563
|
+
}
|
|
5564
|
+
return { ...answer, content: answer.content.map((part) => (part.type === "text" ? { ...part, text: this.scrubSecrets(part.text) } : part)) };
|
|
5565
|
+
});
|
|
5566
|
+
}
|
|
5567
|
+
if (use === undefined || session === undefined)
|
|
5568
|
+
return typed;
|
|
5569
|
+
// Every use of a vault value is one `vault` line: the entry and the field, never the value (§20.1, §23).
|
|
5570
|
+
return typed.then((answer) => {
|
|
5571
|
+
if (answer.isError !== true) {
|
|
5572
|
+
this.options.audit?.write({
|
|
5573
|
+
kind: "vault",
|
|
5574
|
+
app: session.app.id ?? session.app.name,
|
|
5575
|
+
name: use.name,
|
|
5576
|
+
detail: `${use.field} into ${secureField === true ? "secure field" : "text field"}`,
|
|
5577
|
+
});
|
|
5578
|
+
}
|
|
5579
|
+
return answer;
|
|
5580
|
+
});
|
|
5066
5581
|
}
|
|
5067
5582
|
/** Why `as` can't be used on this typed text (AGENT-TOOLS 1.13 §18.5), or null: it goes only while a route records. */
|
|
5068
5583
|
asRefusal(as, text, account) {
|
|
@@ -5073,10 +5588,84 @@ export class TaplineTools {
|
|
|
5073
5588
|
const problem = variableProblem(as);
|
|
5074
5589
|
if (problem !== null)
|
|
5075
5590
|
return `Error bad_params: ${problem}`;
|
|
5076
|
-
if (account !== undefined || text
|
|
5591
|
+
if (account !== undefined || mentionsVault(text))
|
|
5077
5592
|
return "Error bad_params: as can't go with an account's value; the route keeps its placeholder already.";
|
|
5078
5593
|
return null;
|
|
5079
5594
|
}
|
|
5595
|
+
/**
|
|
5596
|
+
* A card's field is typed only with a person's OK, every time, and no grant covers it (AGENT-TOOLS 1.14 §19.2, §20.1). The
|
|
5597
|
+
* dialog's words name the entry and the field, never the value. Returns an answer that stops the call, or null to go on.
|
|
5598
|
+
*/
|
|
5599
|
+
async cardOk(session, use, params) {
|
|
5600
|
+
const { account: _account, ...plain } = params;
|
|
5601
|
+
const resolved = await this.resolveForPolicy(session, "type", { ...plain, text: "" });
|
|
5602
|
+
if (resolved.kind === "error")
|
|
5603
|
+
return fail(resolved.text);
|
|
5604
|
+
const label = resolved.elements[0]?.label ?? resolved.elements[0]?.text ?? undefined;
|
|
5605
|
+
const role = resolved.elements[0]?.role ?? "field";
|
|
5606
|
+
const app = plainText(session.app.name) || "the app";
|
|
5607
|
+
const what = label !== undefined && plainText(label) !== "" ? `"${plainText(label)}"` : `the ${plainText(role, 30) || "field"}`;
|
|
5608
|
+
const caption = `Allow: type card "${use.name}" ${use.field} into ${what} in ${app}?`;
|
|
5609
|
+
const words = `typing card "${use.name}" ${use.field}`;
|
|
5610
|
+
const refuse = (tail) => fail(`Error not_allowed: needs_confirmation: ${words} needs a person's OK, every time (no grant covers a card). ${tail}`);
|
|
5611
|
+
if (this.inReflexStep())
|
|
5612
|
+
return refuse("A reflex never asks.");
|
|
5613
|
+
const key = this.confirmKey(session, `card:${use.name}.${use.field}`, resolved.raw[0] ?? { ref: "", role: "input" });
|
|
5614
|
+
const ask = { kind: "type_text", caption, word: "card" };
|
|
5615
|
+
const check = this.confirmations.check(key, ask);
|
|
5616
|
+
if (check.granted) {
|
|
5617
|
+
this.approved(session, "card", check.via);
|
|
5618
|
+
return null;
|
|
5619
|
+
}
|
|
5620
|
+
if ("held" in check)
|
|
5621
|
+
return refuse("They declined it just now. Don't ask again by repeating the call: ask your user what they want.");
|
|
5622
|
+
const dialogs = this.dialogs;
|
|
5623
|
+
if (dialogs === undefined || !dialogs.available())
|
|
5624
|
+
return refuse(`${this.liveViewLine()}${check.suffix}`);
|
|
5625
|
+
const hold = this.holdScope.getStore();
|
|
5626
|
+
const cancelled = hold?.signal;
|
|
5627
|
+
const abort = new AbortController();
|
|
5628
|
+
const waitStarted = Date.now();
|
|
5629
|
+
const noDialog = new Promise((done) => {
|
|
5630
|
+
void dialogs.askOk(caption, abort.signal).then((answer) => {
|
|
5631
|
+
if (answer === "unavailable")
|
|
5632
|
+
done("no_dialog");
|
|
5633
|
+
else if (answer !== "aborted")
|
|
5634
|
+
this.confirmations.answer(check.request.id, answer === "allow", "chat");
|
|
5635
|
+
});
|
|
5636
|
+
});
|
|
5637
|
+
const onCancel = () => this.confirmations.withdraw(check.request.id);
|
|
5638
|
+
cancelled?.addEventListener("abort", onCancel, { once: true });
|
|
5639
|
+
const settled = await Promise.race([this.confirmations.wait(check.request.id), noDialog]);
|
|
5640
|
+
cancelled?.removeEventListener("abort", onCancel);
|
|
5641
|
+
abort.abort();
|
|
5642
|
+
if (hold !== undefined)
|
|
5643
|
+
hold.ms += Date.now() - waitStarted;
|
|
5644
|
+
if (settled === "no_dialog")
|
|
5645
|
+
return refuse(this.liveViewLine());
|
|
5646
|
+
if (cancelled?.aborted === true)
|
|
5647
|
+
return refuse("The call was cancelled before anyone answered.");
|
|
5648
|
+
if (settled.expired)
|
|
5649
|
+
return refuse("Nobody answered. Ask your user, or repeat the call to ask again.");
|
|
5650
|
+
if (!settled.allow) {
|
|
5651
|
+
this.options.audit?.write({ kind: "approval", app: session.app.id ?? session.app.name, name: "card", detail: "declined" });
|
|
5652
|
+
return refuse("They declined. Ask your user what to do.");
|
|
5653
|
+
}
|
|
5654
|
+
const used = this.confirmations.check(key, ask);
|
|
5655
|
+
if (!used.granted)
|
|
5656
|
+
return fail(CHANGED_WHILE_WAITING);
|
|
5657
|
+
this.approved(session, "card", used.via);
|
|
5658
|
+
return null;
|
|
5659
|
+
}
|
|
5660
|
+
/** The backstop at the writers of files (§20.3): content values lose any vault value; keys and `${…}` stay. */
|
|
5661
|
+
backstop(value) {
|
|
5662
|
+
return redactContent(value, (text) => this.scrubSecrets(text));
|
|
5663
|
+
}
|
|
5664
|
+
/** A vault value or a one-time code typed in this session becomes its placeholder. */
|
|
5665
|
+
scrubSecrets(text) {
|
|
5666
|
+
const store = this.options.accounts?.store;
|
|
5667
|
+
return redactAll(store, this.otp.codes, text);
|
|
5668
|
+
}
|
|
5080
5669
|
/** The batch's account: the default for its steps' `${account.…}` while `run` runs. */
|
|
5081
5670
|
runAccount;
|
|
5082
5671
|
/**
|
|
@@ -5087,9 +5676,15 @@ export class TaplineTools {
|
|
|
5087
5676
|
* Hook for `drive` (task 15): its `account` argument goes through here too, as `named`, for the steps it types.
|
|
5088
5677
|
*/
|
|
5089
5678
|
accountText(text, named, session) {
|
|
5090
|
-
const mentions = text
|
|
5679
|
+
const mentions = mentionsVault(text);
|
|
5091
5680
|
if (!mentions && named === undefined)
|
|
5092
5681
|
return null;
|
|
5682
|
+
// `${otp.<app>}` is typed by `typeText` itself and needs no vault file (§20.4); `run` checks its app separately.
|
|
5683
|
+
if (text.includes("${otp.")) {
|
|
5684
|
+
if (otpReference(text) !== null && named === undefined)
|
|
5685
|
+
return null;
|
|
5686
|
+
return { ok: false, code: "bad_params", message: otpReference(text) !== null ? "a one-time code takes no account; write ${otp.<app>} alone." : "a one-time code is the whole text of a step, not part of one." };
|
|
5687
|
+
}
|
|
5093
5688
|
const loaded = this.options.accounts;
|
|
5094
5689
|
if (loaded === undefined)
|
|
5095
5690
|
return { ok: false, code: "bad_params", message: "no accounts are loaded; start Tapline with --accounts FILE." };
|
|
@@ -5107,11 +5702,12 @@ export class TaplineTools {
|
|
|
5107
5702
|
if (substituted.field === null)
|
|
5108
5703
|
return null;
|
|
5109
5704
|
// An older app ignores the kind and would type a password into any field.
|
|
5110
|
-
if (!session.capabilities.includes("typeKind")) {
|
|
5705
|
+
if ((substituted.field === "password" || substituted.field === "username") && !session.capabilities.includes("typeKind")) {
|
|
5111
5706
|
return { ok: false, code: "unsupported", message: "this app's Tapline is too old to type an account's value safely; update react-native-tapline." };
|
|
5112
5707
|
}
|
|
5708
|
+
// Only a password and a username are held to a kind of field (§18.6); the rest of the vault goes where the app lets it.
|
|
5113
5709
|
const field = substituted.field;
|
|
5114
|
-
return { ok: true, value: substituted.text, kind: field === "password" ? "secure" : "plain" };
|
|
5710
|
+
return { ok: true, value: substituted.text, kind: field === "password" ? "secure" : field === "username" ? "plain" : undefined, use: substituted.use ?? { name: named ?? this.runAccount ?? "", kind: "account", field } };
|
|
5115
5711
|
}
|
|
5116
5712
|
scroll(args) {
|
|
5117
5713
|
if (args.to !== undefined && (args.direction !== undefined || args.amount !== undefined)) {
|
|
@@ -5198,7 +5794,7 @@ export class TaplineTools {
|
|
|
5198
5794
|
return this.notConnected();
|
|
5199
5795
|
let raw;
|
|
5200
5796
|
try {
|
|
5201
|
-
raw = await this.
|
|
5797
|
+
raw = await this.sendRequest("commands", {}, { sessionId: session.id });
|
|
5202
5798
|
}
|
|
5203
5799
|
catch (error) {
|
|
5204
5800
|
return fail(describeError(error));
|
|
@@ -5339,7 +5935,7 @@ export class TaplineTools {
|
|
|
5339
5935
|
if (entries.length === 0) {
|
|
5340
5936
|
// Nothing streamed to the host yet; ask the app for the lines it buffered before we connected.
|
|
5341
5937
|
try {
|
|
5342
|
-
const raw = await this.
|
|
5938
|
+
const raw = await this.sendRequest("logs", args.since !== undefined ? { since: args.since } : {}, { sessionId: session.id });
|
|
5343
5939
|
const list = typeof raw === "object" && raw !== null && "entries" in raw ? raw.entries : [];
|
|
5344
5940
|
entries = z.array(logEntrySchema).catch([]).parse(list);
|
|
5345
5941
|
}
|
|
@@ -5372,7 +5968,7 @@ export class TaplineTools {
|
|
|
5372
5968
|
try {
|
|
5373
5969
|
// The app goes away on purpose: that is no crash.
|
|
5374
5970
|
this.host.expectEnd(session.id);
|
|
5375
|
-
await this.
|
|
5971
|
+
await this.sendRequest("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
|
|
5376
5972
|
}
|
|
5377
5973
|
catch (error) {
|
|
5378
5974
|
// The app dies mid-reload, so a dropped connection or a missing answer means it worked.
|
|
@@ -5414,7 +6010,7 @@ export class TaplineTools {
|
|
|
5414
6010
|
try {
|
|
5415
6011
|
// The app goes away on purpose: that is no crash.
|
|
5416
6012
|
this.host.expectEnd(session.id);
|
|
5417
|
-
await this.
|
|
6013
|
+
await this.sendRequest("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
|
|
5418
6014
|
}
|
|
5419
6015
|
catch (error) {
|
|
5420
6016
|
// The app dies mid-reload, so a dropped connection or a missing answer means it worked.
|
|
@@ -5453,7 +6049,7 @@ export class TaplineTools {
|
|
|
5453
6049
|
// The first settled screen: the app waits until React hasn't committed for a moment (§6).
|
|
5454
6050
|
// The first settled screen: a launch time is only reported when the app said it settled.
|
|
5455
6051
|
try {
|
|
5456
|
-
await this.
|
|
6052
|
+
await this.sendRequest("perfSettle", {}, { sessionId: fresh.id, timeoutMs: 8000 });
|
|
5457
6053
|
}
|
|
5458
6054
|
catch (error) {
|
|
5459
6055
|
return { stop: fail(describeError(error)) };
|
|
@@ -5461,12 +6057,12 @@ export class TaplineTools {
|
|
|
5461
6057
|
return { session: fresh, seconds: (Date.now() - started) / 1000 };
|
|
5462
6058
|
}
|
|
5463
6059
|
/** One measuring window in the app: start, (the tap,) wait out `seconds`, stop. */
|
|
5464
|
-
async measureWindow(session, seconds, press) {
|
|
6060
|
+
async measureWindow(session, seconds, press, delivery) {
|
|
5465
6061
|
const id = { sessionId: session.id };
|
|
5466
6062
|
// The window's number: the stop names it, so a call never takes another call's numbers (an older SDK has none).
|
|
5467
6063
|
let stopParams = {};
|
|
5468
6064
|
try {
|
|
5469
|
-
const started = await this.
|
|
6065
|
+
const started = await this.sendRequest("perfStart", {}, id);
|
|
5470
6066
|
const windowId = typeof started === "object" && started !== null ? started.id : undefined;
|
|
5471
6067
|
if (typeof windowId === "number")
|
|
5472
6068
|
stopParams = { id: windowId };
|
|
@@ -5480,17 +6076,27 @@ export class TaplineTools {
|
|
|
5480
6076
|
const started = Date.now();
|
|
5481
6077
|
try {
|
|
5482
6078
|
// The caller holds the live view's queue for the whole window when there is a tap (see performance).
|
|
5483
|
-
if (press !== undefined)
|
|
5484
|
-
|
|
6079
|
+
if (press !== undefined) {
|
|
6080
|
+
if (delivery !== undefined)
|
|
6081
|
+
delivery.sent = true;
|
|
6082
|
+
try {
|
|
6083
|
+
await this.sendRequest("press", press, id);
|
|
6084
|
+
}
|
|
6085
|
+
catch (error) {
|
|
6086
|
+
if (delivery !== undefined && notDelivered(error))
|
|
6087
|
+
delivery.sent = false;
|
|
6088
|
+
throw error;
|
|
6089
|
+
}
|
|
6090
|
+
}
|
|
5485
6091
|
const rest = seconds * 1000 - (Date.now() - started);
|
|
5486
6092
|
if (rest > 0)
|
|
5487
6093
|
await sleep(rest);
|
|
5488
6094
|
}
|
|
5489
6095
|
catch (error) {
|
|
5490
|
-
await this.
|
|
6096
|
+
await this.sendRequest("perfStop", stopParams, id).catch(() => undefined);
|
|
5491
6097
|
throw error;
|
|
5492
6098
|
}
|
|
5493
|
-
const parsed = perfResultSchema.safeParse(await this.
|
|
6099
|
+
const parsed = perfResultSchema.safeParse(await this.sendRequest("perfStop", stopParams, id));
|
|
5494
6100
|
if (!parsed.success)
|
|
5495
6101
|
throw new TaplineError("internal", "the app returned performance numbers the host could not read.");
|
|
5496
6102
|
return parsed.data;
|
|
@@ -5526,7 +6132,7 @@ export class TaplineTools {
|
|
|
5526
6132
|
}
|
|
5527
6133
|
let ready = { waited: null };
|
|
5528
6134
|
if (press !== undefined) {
|
|
5529
|
-
const waitedFor = await this.awaitOk(session, "tap", "press", press,
|
|
6135
|
+
const waitedFor = await this.awaitOk(session, "tap", "press", press, this.touchLists(session));
|
|
5530
6136
|
if ("stop" in waitedFor)
|
|
5531
6137
|
return waitedFor.stop;
|
|
5532
6138
|
ready = waitedFor;
|
|
@@ -5537,13 +6143,31 @@ export class TaplineTools {
|
|
|
5537
6143
|
const tapped = press;
|
|
5538
6144
|
const window = async () => {
|
|
5539
6145
|
if (tapped !== undefined) {
|
|
5540
|
-
const gate = await this.policyGate(session, "tap", "press", tapped,
|
|
6146
|
+
const gate = await this.policyGate(session, "tap", "press", tapped, this.touchLists(session), ready.waited);
|
|
5541
6147
|
if ("stop" in gate)
|
|
5542
6148
|
return { stop: gate.stop };
|
|
6149
|
+
const delivery = { sent: false };
|
|
6150
|
+
try {
|
|
6151
|
+
const result = await this.measureWindow(session, seconds, tapped, delivery);
|
|
6152
|
+
if (gate.grant !== undefined)
|
|
6153
|
+
this.grantNotes.getStore()?.push(gate.grant.line);
|
|
6154
|
+
return { result };
|
|
6155
|
+
}
|
|
6156
|
+
catch (error) {
|
|
6157
|
+
if (gate.grant !== undefined && !delivery.sent)
|
|
6158
|
+
await this.releaseGrant(gate.grant);
|
|
6159
|
+
throw error;
|
|
6160
|
+
}
|
|
5543
6161
|
}
|
|
5544
6162
|
return { result: await this.measureWindow(session, seconds, tapped) };
|
|
5545
6163
|
};
|
|
5546
|
-
|
|
6164
|
+
let outcome;
|
|
6165
|
+
try {
|
|
6166
|
+
outcome = tapped !== undefined ? await this.agentTurn(window) : await window();
|
|
6167
|
+
}
|
|
6168
|
+
finally {
|
|
6169
|
+
await this.dropPreUse(ready.waited);
|
|
6170
|
+
}
|
|
5547
6171
|
if ("stop" in outcome)
|
|
5548
6172
|
return outcome.stop;
|
|
5549
6173
|
measure = { result: outcome.result };
|
|
@@ -5662,7 +6286,7 @@ export class TaplineTools {
|
|
|
5662
6286
|
notes.push(`Note: condition ${name} ${value} was not applied: ${record.failure ?? "the app refused"}.`);
|
|
5663
6287
|
}
|
|
5664
6288
|
// A user waits for animations: fast ones end here unless the journey's allow keeps them (PLAN-1.6 F4, E8).
|
|
5665
|
-
if (journey.userEyes && !journey.allows(FAST_ANIMATIONS_ALLOWED) && (await endAnimations(this.host, session.id))) {
|
|
6289
|
+
if (journey.userEyes && !journey.allows(FAST_ANIMATIONS_ALLOWED) && (await endAnimations(this.host, session.id, this.leased))) {
|
|
5666
6290
|
notes.push("Note: animations were turned off for this journey: a user waits for them. allow fast_animations keeps them on.");
|
|
5667
6291
|
}
|
|
5668
6292
|
this.journeyRun = journey;
|
|
@@ -5747,7 +6371,7 @@ export class TaplineTools {
|
|
|
5747
6371
|
const session = bound();
|
|
5748
6372
|
if (session === undefined)
|
|
5749
6373
|
return undefined;
|
|
5750
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
6374
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
5751
6375
|
if (!tree.success)
|
|
5752
6376
|
return undefined;
|
|
5753
6377
|
const t = tree.data;
|
|
@@ -5766,7 +6390,7 @@ export class TaplineTools {
|
|
|
5766
6390
|
return undefined;
|
|
5767
6391
|
try {
|
|
5768
6392
|
// The picture is taken with fast animations paused, as a user would see the screen (PLAN-1.6 F4, E8).
|
|
5769
|
-
return (await withNormalAnimations(this.host, session.id, () => this.takeShot(session.id, width, quality))).value;
|
|
6393
|
+
return (await withNormalAnimations(this.host, session.id, () => this.takeShot(session.id, width, quality), this.leased)).value;
|
|
5770
6394
|
}
|
|
5771
6395
|
catch {
|
|
5772
6396
|
return undefined;
|
|
@@ -5836,7 +6460,7 @@ export class TaplineTools {
|
|
|
5836
6460
|
await attempt("the photo picker could not be put back", async () => {
|
|
5837
6461
|
const live = this.journeySession();
|
|
5838
6462
|
if (live !== undefined && live.capabilities.includes("media"))
|
|
5839
|
-
await this.
|
|
6463
|
+
await this.sendRequest("media", { op: "disarm" }, { sessionId: live.id });
|
|
5840
6464
|
});
|
|
5841
6465
|
const undoLines = [];
|
|
5842
6466
|
// A network condition a stress moment turned on mid-flow goes back too (one the journey began with is undone above).
|
|
@@ -6011,7 +6635,7 @@ export class TaplineTools {
|
|
|
6011
6635
|
const session = this.host.current();
|
|
6012
6636
|
if (session === undefined)
|
|
6013
6637
|
return null;
|
|
6014
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
6638
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
6015
6639
|
const screen = tree.success ? tree.data.screen : null;
|
|
6016
6640
|
return screen ? { width: screen.width, height: screen.height } : null;
|
|
6017
6641
|
}
|
|
@@ -6069,7 +6693,7 @@ export class TaplineTools {
|
|
|
6069
6693
|
const session = this.host.current();
|
|
6070
6694
|
if (session === undefined)
|
|
6071
6695
|
return this.notConnected();
|
|
6072
|
-
const lists =
|
|
6696
|
+
const lists = this.touchLists(session);
|
|
6073
6697
|
// Resolved inside the agent's turn, right before the press, as a guarded tap does: a queued person's action can't change
|
|
6074
6698
|
// what is under the target between the check and the touch.
|
|
6075
6699
|
const refusal = { answer: null };
|
|
@@ -6131,7 +6755,7 @@ export class TaplineTools {
|
|
|
6131
6755
|
if (session === undefined)
|
|
6132
6756
|
return { survived: false, why: "the app isn't connected any more." };
|
|
6133
6757
|
// What a person sees (AGENT-TOOLS 1.12 §17.4): in a user's eyes the glass only; a label a screen reader hears is no proof.
|
|
6134
|
-
const tree = treeResultSchema.safeParse(await this.
|
|
6758
|
+
const tree = treeResultSchema.safeParse(await this.sendRequest("tree", { peek: true, ...(this.userEyes() ? { glass: true } : {}) }, { sessionId: session.id }).catch(() => null));
|
|
6135
6759
|
if (!tree.success)
|
|
6136
6760
|
return { survived: false, why: "the app didn't answer when asked what is on screen." };
|
|
6137
6761
|
const parts = [];
|
|
@@ -6195,7 +6819,7 @@ export class TaplineTools {
|
|
|
6195
6819
|
}
|
|
6196
6820
|
}
|
|
6197
6821
|
try {
|
|
6198
|
-
const raw = await this.
|
|
6822
|
+
const raw = await this.sendRequest("command", { name: call.command, args: call.args }, { sessionId: session.id });
|
|
6199
6823
|
const inner = typeof raw === "object" && raw !== null && "result" in raw ? raw.result : undefined;
|
|
6200
6824
|
const previous = typeof inner === "object" && inner !== null && "previous" in inner ? inner.previous : undefined;
|
|
6201
6825
|
return { ok: true, ...(typeof previous === "string" && previous !== "" ? { previous } : {}) };
|