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.
Files changed (108) hide show
  1. package/dist/accounts.d.ts +129 -12
  2. package/dist/accounts.js +844 -75
  3. package/dist/accounts.js.map +1 -1
  4. package/dist/actionlog.d.ts +11 -4
  5. package/dist/actionlog.js +22 -1
  6. package/dist/actionlog.js.map +1 -1
  7. package/dist/animations.d.ts +4 -2
  8. package/dist/animations.js +7 -7
  9. package/dist/animations.js.map +1 -1
  10. package/dist/api.d.ts +7 -1
  11. package/dist/api.js +12 -1
  12. package/dist/api.js.map +1 -1
  13. package/dist/audit.d.ts +1 -0
  14. package/dist/auditlog.d.ts +40 -0
  15. package/dist/auditlog.js +150 -0
  16. package/dist/auditlog.js.map +1 -0
  17. package/dist/cli.d.ts +8 -0
  18. package/dist/cli.js +143 -7
  19. package/dist/cli.js.map +1 -1
  20. package/dist/control.d.ts +51 -0
  21. package/dist/control.js +110 -0
  22. package/dist/control.js.map +1 -1
  23. package/dist/doctor.d.ts +6 -0
  24. package/dist/doctor.js +61 -4
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/drive.d.ts +5 -2
  27. package/dist/drive.js +50 -13
  28. package/dist/drive.js.map +1 -1
  29. package/dist/elicit.d.ts +47 -0
  30. package/dist/elicit.js +87 -0
  31. package/dist/elicit.js.map +1 -1
  32. package/dist/format.d.ts +6 -0
  33. package/dist/format.js +25 -4
  34. package/dist/format.js.map +1 -1
  35. package/dist/grants.d.ts +297 -0
  36. package/dist/grants.js +938 -0
  37. package/dist/grants.js.map +1 -0
  38. package/dist/grantscmd.d.ts +34 -0
  39. package/dist/grantscmd.js +100 -0
  40. package/dist/grantscmd.js.map +1 -0
  41. package/dist/hostfacts.d.ts +23 -0
  42. package/dist/hostfacts.js +65 -0
  43. package/dist/hostfacts.js.map +1 -0
  44. package/dist/inspect.d.ts +3 -2
  45. package/dist/inspect.js +20 -8
  46. package/dist/inspect.js.map +1 -1
  47. package/dist/mapcmd.d.ts +2 -2
  48. package/dist/mcp.js +1 -1
  49. package/dist/nochange.d.ts +1 -0
  50. package/dist/otpclient.d.ts +79 -0
  51. package/dist/otpclient.js +362 -0
  52. package/dist/otpclient.js.map +1 -0
  53. package/dist/pidlock.d.ts +99 -0
  54. package/dist/pidlock.js +382 -0
  55. package/dist/pidlock.js.map +1 -0
  56. package/dist/places.d.ts +11 -0
  57. package/dist/places.js +28 -2
  58. package/dist/places.js.map +1 -1
  59. package/dist/policy.d.ts +2 -1
  60. package/dist/policy.js +5 -2
  61. package/dist/policy.js.map +1 -1
  62. package/dist/protocol.d.ts +4 -0
  63. package/dist/protocol.js +2 -0
  64. package/dist/protocol.js.map +1 -1
  65. package/dist/report.d.ts +8 -0
  66. package/dist/report.js +34 -1
  67. package/dist/report.js.map +1 -1
  68. package/dist/routes.d.ts +1 -1
  69. package/dist/routes.js +27 -11
  70. package/dist/routes.js.map +1 -1
  71. package/dist/runschedule.d.ts +119 -0
  72. package/dist/runschedule.js +281 -0
  73. package/dist/runschedule.js.map +1 -0
  74. package/dist/runtime.d.ts +48 -1
  75. package/dist/runtime.js +214 -18
  76. package/dist/runtime.js.map +1 -1
  77. package/dist/schedule.d.ts +145 -0
  78. package/dist/schedule.js +401 -0
  79. package/dist/schedule.js.map +1 -0
  80. package/dist/schedulehost.d.ts +73 -0
  81. package/dist/schedulehost.js +391 -0
  82. package/dist/schedulehost.js.map +1 -0
  83. package/dist/script.d.ts +2 -0
  84. package/dist/script.js +2 -0
  85. package/dist/script.js.map +1 -1
  86. package/dist/server.d.ts +6 -0
  87. package/dist/server.js +3 -1
  88. package/dist/server.js.map +1 -1
  89. package/dist/thincall.d.ts +13 -0
  90. package/dist/thincall.js +139 -59
  91. package/dist/thincall.js.map +1 -1
  92. package/dist/tool-text-data.d.ts +1 -1
  93. package/dist/tool-text-data.js +1 -1
  94. package/dist/tool-text-data.js.map +1 -1
  95. package/dist/tool-text.d.ts +1 -1
  96. package/dist/tools.d.ts +116 -6
  97. package/dist/tools.js +758 -134
  98. package/dist/tools.js.map +1 -1
  99. package/dist/unlockcmd.d.ts +44 -0
  100. package/dist/unlockcmd.js +80 -0
  101. package/dist/unlockcmd.js.map +1 -0
  102. package/dist/uxcheck.d.ts +1 -0
  103. package/dist/vault.d.ts +75 -0
  104. package/dist/vault.js +395 -0
  105. package/dist/vault.js.map +1 -0
  106. package/package.json +1 -1
  107. package/skills/tapline/SKILL.md +34 -6
  108. 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"].includes("${account.");
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
- const given = options.asStep === true ? { ...args, quiet: true } : args;
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.host.request("tree", this.userEyes() ? { glass: true } : {}, { sessionId: session.id }));
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 = listsFor(this.policy, session.app);
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 = this.options.control ? await this.options.control.runAgent(run) : await run();
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
- return this.noteScope.run(true, () => this.reflexScope.run(reflexes, () => {
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
- if (result instanceof Promise) {
1674
- return result.then((answer) => this.withReflexLines(this.withAccountsNote(this.withPersonNote(this.withCrashNotes(answer, mark, startedWith))), reflexes));
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 this.withReflexLines(this.withAccountsNote(this.withPersonNote(this.withCrashNotes(result, mark, startedWith))), reflexes);
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.host.request("tree", { peek: true }, { sessionId: session.id }));
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.host.request("tree", { peek: true }, { sessionId: session.id }));
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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 || loaded.store !== null)
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, `Note: ${loaded.blocked}`);
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.host.request("waitFor", params, { sessionId: session.id });
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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.host.request("inspect", { target: toWireTarget(args.target) }, { sessionId: session.id });
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.host.request("audit", args.types !== undefined ? { types: args.types } : {}, { sessionId: session.id });
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.host.request("uxCheck", { ...(this.userEyes() ? { glass: true } : {}), ...(this.uxRules.wayBack ? { wayBack: true } : {}), ...(earlier !== null ? { earlier } : {}) }, { sessionId: session.id });
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.host.request("media", params, { sessionId: session.id, timeoutMs: 60_000 }),
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.host.request("check", {}, { sessionId: session.id });
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.host.request("tree", treeParams, { sessionId: sid });
3031
+ const treePromise = this.sendRequest("tree", treeParams, { sessionId: sid });
2891
3032
  const shotPromise = wantScreenshot && !this.noScreenshot.has(sid)
2892
- ? this.host
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
- const changeBlock = askChanges ? changeParts(tree, { alertGone, eyes }) : null;
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.host.request(method, params ?? {}, { sessionId: session.id }),
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 ?? "").includes("${account.")) {
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
- return this.host.request(method, sent, { sessionId: session.id, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
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 = this.options.control ? await this.options.control.runAgent(run) : await run();
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.host.request("tree", { peek: true }, { sessionId }).catch(() => null));
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.host.request("overlay", {}, { sessionId: sid }));
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.host.request("feed", feedParams(on, quality), { sessionId: session.id });
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.host.request("swipe", swipe, { sessionId: session.id });
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.host.request(method, params, { sessionId: session.id });
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.host.request("feed", feedParams(true, feed.quality), { sessionId: session.id }));
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.host.request("screenshot", { format: "jpg", quality, maxWidth: width }, { sessionId });
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.host.request("tree", { peek: true }, { sessionId }));
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 = listsFor(this.policy, session.app);
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
- frames.push(await this.takeShot(session.id, frameWidth, 0.8));
4173
- await this.host.request("press", pressParams, { sessionId: session.id });
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
- const stopped = this.options.control ? await this.options.control.runAgent(turn) : await turn();
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 = listsFor(this.policy, session.app);
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
- const needs = verdict.kind === "confirm" ? ` It needs a person's OK (policy word ${JSON.stringify(verdict.word)}).` : "";
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
- const answer = await perform(async () => {
4633
- const gate = await this.gatePreflight(session, tool, method, params, lists, ready.waited);
4634
- if ("stop" in gate)
4635
- return gate.stop;
4636
- granted = gate.via;
4637
- return null;
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 answer;
4850
+ return withNotes;
4641
4851
  this.journeyRun?.noteOk(granted);
4642
- return appendNote(answer, `Note: allowed by a person (in ${granted === "chat" ? "chat" : "the live view"}).`);
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
- const refuse = (tail) => {
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
- return { waited: { fp, via: check.via } };
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 dialogs
4712
- .askOk(okCaption({ word: verdict.word, tool, role: verdict.element.role ?? "", ...(label !== undefined ? { text: label } : {}), app: session.app.name }), abort.signal)
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
- return refuse("They declined. Ask your user what to do.");
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
- return { waited: { fp, via: used.via } };
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
- return { granted: true, via: waited.via };
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
- return { granted: true, via: check.via };
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.host.request(method, { ...params, dryRun: true }, { sessionId: session.id });
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.host.request(method, { ...params, dryRun: true }, { sessionId: session.id, internal: true });
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.host.request("tree", {}, { sessionId: session.id, internal: true });
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 = this.accountText(args.text, args.account, session);
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.includes("${account."))
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.kind });
5533
+ return this.dryRun("type_text", "type", { ...plain, text: "", ...(wire.values["kind"] !== undefined ? { kind: wire.values["kind"] } : {}) });
5053
5534
  }
5054
- return this.act("type",
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.includes("${account."))
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.includes("${account.");
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.host.request("commands", {}, { sessionId: session.id });
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.host.request("logs", args.since !== undefined ? { since: args.since } : {}, { sessionId: session.id });
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.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
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.host.request("reload", {}, { sessionId: session.id, timeoutMs: 5000 });
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.host.request("perfSettle", {}, { sessionId: fresh.id, timeoutMs: 8000 });
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.host.request("perfStart", {}, id);
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
- await this.host.request("press", press, id);
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.host.request("perfStop", stopParams, id).catch(() => undefined);
6096
+ await this.sendRequest("perfStop", stopParams, id).catch(() => undefined);
5491
6097
  throw error;
5492
6098
  }
5493
- const parsed = perfResultSchema.safeParse(await this.host.request("perfStop", stopParams, id));
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, listsFor(this.policy, session.app));
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, listsFor(this.policy, session.app), ready.waited);
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
- const outcome = tapped !== undefined && this.options.control ? await this.options.control.runAgent(window) : await window();
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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.host.request("media", { op: "disarm" }, { sessionId: live.id });
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.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
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 = listsFor(this.policy, session.app);
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.host.request("tree", { peek: true, ...(this.userEyes() ? { glass: true } : {}) }, { sessionId: session.id }).catch(() => null));
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.host.request("command", { name: call.command, args: call.args }, { sessionId: session.id });
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 } : {}) };