@swmansion/argent 0.22.0 → 0.22.1-next.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.
@@ -95692,7 +95692,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
95692
95692
  var SESSION_ID = (0, import_node_crypto3.randomUUID)();
95693
95693
  function readCliVersion() {
95694
95694
  if (true) {
95695
- return "0.22.0";
95695
+ return "0.22.1-next.0";
95696
95696
  }
95697
95697
  return "0.0.0";
95698
95698
  }
@@ -123431,13 +123431,15 @@ async function pollDescribeTree(args) {
123431
123431
  let polls = 0;
123432
123432
  let lastData = null;
123433
123433
  let lastError;
123434
+ let lastAttemptSettled = false;
123434
123435
  const outcome = (result, aborted2) => ({
123435
123436
  result,
123436
123437
  aborted: aborted2,
123437
123438
  polls,
123438
123439
  elapsedMs: Date.now() - start2,
123439
123440
  lastData,
123440
- lastError
123441
+ lastError,
123442
+ lastAttemptSettled
123441
123443
  });
123442
123444
  for (; ; ) {
123443
123445
  if (signal?.aborted) return outcome(void 0, true);
@@ -123445,6 +123447,7 @@ async function pollDescribeTree(args) {
123445
123447
  const settled = await settleWithin(fetchTree2(), remaining, signal);
123446
123448
  polls += 1;
123447
123449
  if (settled.type === "aborted") return outcome(void 0, true);
123450
+ lastAttemptSettled = settled.type !== "timeout";
123448
123451
  if (settled.type === "timeout") {
123449
123452
  if (lastData === null) {
123450
123453
  lastError ??= `tree fetch did not complete within the ${timeoutMs}ms wait budget`;
@@ -129524,6 +129527,20 @@ var AWAIT_UI_ELEMENT_TOOL_ID = "await-ui-element";
129524
129527
  function isUnmetUiWaitResult(tool, result) {
129525
129528
  return tool === AWAIT_UI_ELEMENT_TOOL_ID && typeof result === "object" && result !== null && result.success === false;
129526
129529
  }
129530
+ var WAIT_CANCELLED_NOTE = "wait was cancelled before the condition was met";
129531
+ var TREE_FETCH_FAILED_NOTE_PREFIX = "last tree fetch failed: ";
129532
+ var HIDDEN_UNREADABLE_NOTE = "could not confirm the element is hidden \u2014 the UI tree was empty or unreadable at timeout";
129533
+ function unmetUiWaitCause(result) {
129534
+ const carried = result?.cause;
129535
+ if (carried === "unmet" || carried === "unreadable" || carried === "cancelled") return carried;
129536
+ const note = result?.note;
129537
+ if (typeof note !== "string") return "unmet";
129538
+ if (note === WAIT_CANCELLED_NOTE) return "cancelled";
129539
+ if (note.startsWith(TREE_FETCH_FAILED_NOTE_PREFIX) || note.startsWith(HIDDEN_UNREADABLE_NOTE)) {
129540
+ return "unreadable";
129541
+ }
129542
+ return "unmet";
129543
+ }
129527
129544
  var DEFAULT_TIMEOUT_MS3 = 5e3;
129528
129545
  var DEFAULT_POLL_INTERVAL_MS = 400;
129529
129546
  var zodSchema27 = external_exports.object({
@@ -129561,6 +129578,19 @@ var conditionCompleted = {
129561
129578
  hidden: "UI element became hidden",
129562
129579
  text: "UI element matched expected text"
129563
129580
  };
129581
+ var DARK_TAIL_TOLERANCE_INTERVALS = 2;
129582
+ var DARK_TAIL_TOLERANCE_MAX_MS = 2e3;
129583
+ function timeoutCause(condition, lastTrustedReadAt, finalRead, pollIntervalMs) {
129584
+ if (lastTrustedReadAt === void 0) return "unreadable";
129585
+ if (finalRead === "trusted") return "unmet";
129586
+ if (finalRead === "untrusted" && condition === "hidden") return "unreadable";
129587
+ const darkTailMs = Date.now() - lastTrustedReadAt;
129588
+ const tolerance = Math.min(
129589
+ DARK_TAIL_TOLERANCE_INTERVALS * pollIntervalMs,
129590
+ DARK_TAIL_TOLERANCE_MAX_MS
129591
+ );
129592
+ return darkTailMs > tolerance ? "unreadable" : "unmet";
129593
+ }
129564
129594
  var capability21 = {
129565
129595
  apple: { simulator: true, device: true },
129566
129596
  android: { emulator: true, device: true, unknown: true },
@@ -129586,7 +129616,7 @@ function appendDiagnostics(base, lastData) {
129586
129616
  return extras.length === 0 ? base : `${base} (${extras.join("; ")})`;
129587
129617
  }
129588
129618
  function timeoutNote(params, lastTree, fetchError, lastData) {
129589
- if (fetchError) return `last tree fetch failed: ${fetchError}`;
129619
+ if (fetchError) return `${TREE_FETCH_FAILED_NOTE_PREFIX}${fetchError}`;
129590
129620
  const matches2 = lastTree ? findAll(lastTree, params.selector) : [];
129591
129621
  let base;
129592
129622
  switch (params.condition) {
@@ -129597,7 +129627,7 @@ function timeoutNote(params, lastTree, fetchError, lastData) {
129597
129627
  break;
129598
129628
  }
129599
129629
  case "hidden":
129600
- base = matches2.some(isVisible) ? "an element matching the selector was still visible at timeout" : "could not confirm the element is hidden \u2014 the UI tree was empty or unreadable at timeout";
129630
+ base = matches2.some(isVisible) ? "an element matching the selector was still visible at timeout" : HIDDEN_UNREADABLE_NOTE;
129601
129631
  break;
129602
129632
  case "visible":
129603
129633
  base = matches2.length > 0 ? "element(s) matched but none was visible (zero-area frame) before timeout" : "no element matched the selector before timeout";
@@ -129646,9 +129676,11 @@ It polls the same accessibility / DOM tree as \`describe\`
129646
129676
  (iOS AXRuntime, Android uiautomator, Chromium CDP, Vega automation toolkit) every pollIntervalMs
129647
129677
  (default ${DEFAULT_POLL_INTERVAL_MS}ms) until timeoutMs (default ${DEFAULT_TIMEOUT_MS3}ms).
129648
129678
 
129649
- Returns { success: boolean, elapsed: number } \u2014 success=false means the condition never held before the
129650
- timeout (a \`note\` then explains what was seen). Use this after a tap/navigation to wait for the next screen,
129651
- or before tapping an element that appears asynchronously.`,
129679
+ Returns { success: boolean, elapsed: number, note?, cause? } \u2014 success=false means the wait ended without the
129680
+ condition holding, which is not always a verdict on the condition: \`cause\` says which it was \u2014 \`unmet\` (the tree
129681
+ was read and the condition was false there), \`unreadable\` (no trustworthy read, so nothing was judged) or
129682
+ \`cancelled\` \u2014 and \`note\` describes what was seen. Only \`unmet\` licenses rewriting the check. Use this after a
129683
+ tap/navigation to wait for the next screen, or before tapping an element that appears asynchronously.`,
129652
129684
  alwaysLoad: true,
129653
129685
  searchHint: "wait await poll until visible hidden exists text appears disappears timeout element condition settle",
129654
129686
  longRunning: true,
@@ -129674,21 +129706,24 @@ or before tapping an element that appears asynchronously.`,
129674
129706
  const cancelled = () => ({
129675
129707
  success: false,
129676
129708
  elapsed: Date.now() - start2,
129677
- note: "wait was cancelled before the condition was met"
129709
+ note: WAIT_CANCELLED_NOTE,
129710
+ cause: "cancelled"
129678
129711
  });
129679
129712
  const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
129680
129713
  const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
129681
129714
  const selector = params.selector;
129682
129715
  let everMatched = false;
129716
+ let lastTrustedReadAt;
129683
129717
  const poll = await pollDescribeTree({
129684
129718
  fetchTree: () => fetchTree2(device, params, services, isTvOs, androidIsTv),
129685
129719
  timeoutMs,
129686
129720
  pollIntervalMs,
129687
129721
  signal,
129688
- onSample: (data) => {
129722
+ onSample: (data, nowMs) => {
129689
129723
  const matches2 = findAll(data.tree, selector);
129690
129724
  if (matches2.length > 0) everMatched = true;
129691
129725
  const blind = isBlindRead(data, everMatched);
129726
+ if (!blind) lastTrustedReadAt = nowMs;
129692
129727
  if (!blind && evaluateMatches(params, matches2)) {
129693
129728
  const result = { success: true, elapsed: Date.now() - start2 };
129694
129729
  if (params.condition === "hidden" && !everMatched) {
@@ -129701,10 +129736,12 @@ or before tapping an element that appears asynchronously.`,
129701
129736
  });
129702
129737
  if (poll.aborted) return cancelled();
129703
129738
  if (poll.result) return poll.result;
129739
+ const finalRead = !poll.lastAttemptSettled ? "unsettled" : poll.lastError === void 0 && poll.lastData !== null && !isBlindRead(poll.lastData, everMatched) ? "trusted" : "untrusted";
129704
129740
  return {
129705
129741
  success: false,
129706
129742
  elapsed: Date.now() - start2,
129707
- note: timeoutNote(params, poll.lastData?.tree ?? null, poll.lastError, poll.lastData)
129743
+ note: timeoutNote(params, poll.lastData?.tree ?? null, poll.lastError, poll.lastData),
129744
+ cause: timeoutCause(params.condition, lastTrustedReadAt, finalRead, pollIntervalMs)
129708
129745
  };
129709
129746
  }
129710
129747
  };
@@ -144185,13 +144222,58 @@ function assertSessionStillLive(session, step) {
144185
144222
  }
144186
144223
  );
144187
144224
  }
144225
+ function renderStepForCompare(step) {
144226
+ try {
144227
+ return JSON.stringify(step);
144228
+ } catch {
144229
+ return null;
144230
+ }
144231
+ }
144232
+ function sameStepRun(now, before, n, nowFrom, beforeFrom) {
144233
+ if (now.length < nowFrom + n || before.length < beforeFrom + n) return false;
144234
+ for (let i = 0; i < n; i += 1) {
144235
+ const rendered = now[nowFrom + i];
144236
+ if (rendered === null || rendered !== before[beforeFrom + i]) {
144237
+ return false;
144238
+ }
144239
+ }
144240
+ return true;
144241
+ }
144242
+ function anchorHolds(now, before, n) {
144243
+ if (!sameStepRun(now, before, n, 0, 0)) return false;
144244
+ const deleted = before.length - now.length;
144245
+ const inserted = now.length - before.length;
144246
+ for (let at = 0; at < n; at += 1) {
144247
+ for (let size = 1; size <= deleted; size += 1) {
144248
+ if (sameStepRun(now, before, n - at, at, at + size)) return false;
144249
+ }
144250
+ for (let size = 1; size <= inserted; size += 1) {
144251
+ if (sameStepRun(now, before, n - at, at + size, at)) return false;
144252
+ }
144253
+ }
144254
+ return true;
144255
+ }
144256
+ function dropMovedWarnings(warnings, now, before) {
144257
+ if (!warnings) return 0;
144258
+ const nowRendered = now.map(renderStepForCompare);
144259
+ const beforeRendered = before.map(renderStepForCompare);
144260
+ let dropped = 0;
144261
+ for (const n of [...warnings.keys()]) {
144262
+ if (anchorHolds(nowRendered, beforeRendered, n)) continue;
144263
+ warnings.delete(n);
144264
+ dropped += 1;
144265
+ }
144266
+ return dropped;
144267
+ }
144188
144268
  async function appendStepToFlow(session, step) {
144189
144269
  return withFlowLock(session.key, async () => {
144190
144270
  assertSessionStillLive(session, step);
144191
144271
  session.lastTouchedSeq = touch();
144192
144272
  if (session.persist === "host") {
144273
+ const before = session.flow.steps;
144193
144274
  const flowFile = await appendStep(session.filePath, step);
144194
144275
  session.flow = parseFlow(flowFile);
144276
+ session.discardedWarnings = (session.discardedWarnings ?? 0) + dropMovedWarnings(session.stepWarnings, session.flow.steps.slice(0, -1), before);
144195
144277
  return { savedTo: session.filePath, stepCount: session.flow.steps.length };
144196
144278
  }
144197
144279
  session.flow.steps.push(step);
@@ -144248,16 +144330,24 @@ land in another's file. Steps still run LIVE, so give each concurrent recording
144248
144330
  its own device and pick a name unique to your task.
144249
144331
 
144250
144332
  After starting, use flow-add-step to append tool calls \u2014 each step is executed
144251
- LIVE so you can verify it works before it gets recorded. For a self-contained
144333
+ LIVE so you can verify it works before it gets recorded. Read each step's
144334
+ \`message\`: an await-ui-element whose condition never held is still recorded (it
144335
+ returns success:false rather than failing), and a check that passes live can
144336
+ still fail once polished into an \`await:\`/\`assert:\` directive, which resolves
144337
+ against a different tree. flow-add-step warns about both when you record the
144338
+ wait DIRECTLY. A wait nested inside a recorded run-sequence gets neither warning
144339
+ \u2014 that tool reports its own shape \u2014 so for those, read \`toolResult\`. For a self-contained
144252
144340
  e2e flow, record a restart-app of the app under test as the FIRST step (captured
144253
144341
  as the flow's \`launch\` step); for a reusable fragment, skip that and pass
144254
144342
  executionPrerequisite instead. Use flow-add-echo to add labels. Call
144255
144343
  flow-finish-recording when done.
144256
144344
 
144257
- If a recorded step turns out to be wrong, you can edit the .yaml file directly
144258
- to remove or reorder steps. Against a remote client, only after
144259
- flow-finish-recording: the in-memory copy is authoritative there, and every
144260
- write serializes it over your edit.`,
144345
+ If a recorded step turns out to be wrong, edit the .yaml file directly to
144346
+ remove or reorder steps - after flow-finish-recording, not during the
144347
+ recording. Against a remote client the in-memory copy is authoritative and
144348
+ every write serializes it over your edit; in host mode the recorder re-reads
144349
+ the file before each append, so a mid-recording edit renumbers the steps and
144350
+ costs the finish the cross-tree verdicts anchored to them.`,
144261
144351
  zodSchema: zodSchema61,
144262
144352
  fileInputs: fileInputs2,
144263
144353
  services: () => ({}),
@@ -144315,141 +144405,10 @@ write serializes it over your edit.`,
144315
144405
 
144316
144406
  // ../tool-server/src/tools/flows/flow-add-step.ts
144317
144407
  init_zod();
144318
- var fs47 = __toESM(require("node:fs/promises"));
144408
+ var fs48 = __toESM(require("node:fs/promises"));
144319
144409
  var path33 = __toESM(require("node:path"));
144320
144410
  init_src();
144321
144411
 
144322
- // ../tool-server/src/tools/flows/flow-finish-recording.ts
144323
- init_zod();
144324
- var fs46 = __toESM(require("node:fs/promises"));
144325
- function selectorLabel(sel) {
144326
- return JSON.stringify(selectorToYaml(sel));
144327
- }
144328
- function textConditionLabel(sel, expectedText, textMatch) {
144329
- const selector = selectorLabel(sel);
144330
- const expected = expectedText ?? "";
144331
- return textMatch === "matches" ? `text ${selector} matches /${expected}/` : textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
144332
- }
144333
- var zodSchema62 = external_exports.object({
144334
- name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
144335
- project_root: external_exports.string().describe(
144336
- "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording to finish."
144337
- )
144338
- });
144339
- var flowFinishRecordingTool = {
144340
- id: "flow-finish-recording",
144341
- interaction: {
144342
- // Name the flow: other recordings stay live across this call, so an
144343
- // unqualified "Finishing flow recording" would not identify which one.
144344
- startedMsg: ({ params }) => `Finishing recording of flow ${params.name}`,
144345
- // `params.name` rather than the basename of `result.path`: the two are the
144346
- // same string on every branch — `assertSafeFlowName` admits no dots or
144347
- // separators, so `getFlowPath` produces `<name>.yaml` and nothing else —
144348
- // and this spelling matches the two formatters either side of it.
144349
- completedMsg: ({ params }) => `Saved recorded flow ${params.name}`,
144350
- failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to finish recording of flow ${params.name}: ${failureSignal2.error_code}`
144351
- },
144352
- description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any other key untouched. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress.
144353
- You can still edit the .yaml file directly afterwards to remove or reorder steps.`,
144354
- zodSchema: zodSchema62,
144355
- services: () => ({}),
144356
- async execute(_services, params) {
144357
- const { filePath, flowFile, savedTo, flow, summary } = await withFlowFileLock(
144358
- params.project_root,
144359
- params.name,
144360
- async () => {
144361
- const session = await requireRecordingSession(params.project_root, params.name);
144362
- const filePath2 = session.filePath;
144363
- let flowFile2;
144364
- let savedTo2;
144365
- if (session.persist === "client") {
144366
- flowFile2 = serializeFlow(session.flow);
144367
- savedTo2 = clientFileDirective(filePath2, flowFile2);
144368
- } else {
144369
- flowFile2 = await fs46.readFile(filePath2, "utf8");
144370
- savedTo2 = filePath2;
144371
- }
144372
- const flow2 = parseFlow(flowFile2);
144373
- const summary2 = summarizeSteps(flow2);
144374
- clearRecordingSession(session);
144375
- return { filePath: filePath2, flowFile: flowFile2, savedTo: savedTo2, flow: flow2, summary: summary2 };
144376
- }
144377
- );
144378
- return {
144379
- message: `Finished recording "${params.name}" flow (${flow.steps.length} steps)`,
144380
- path: filePath,
144381
- executionPrerequisite: flow.executionPrerequisite,
144382
- steps: flow.steps.length,
144383
- summary,
144384
- flowFile,
144385
- savedTo
144386
- };
144387
- }
144388
- };
144389
- function renderToolArgs(args) {
144390
- try {
144391
- return `${JSON.stringify(args)}`;
144392
- } catch {
144393
- return "[cyclic args]";
144394
- }
144395
- }
144396
- function delayLabel(step) {
144397
- if (!step.delayMs) return "";
144398
- const ms = Number(step.delayMs);
144399
- return Number.isFinite(ms) && ms >= 1 ? ` (after ${ms}ms)` : "";
144400
- }
144401
- function summarizeSteps(flow) {
144402
- return flow.steps.map((step, i) => summarizeStep(step, i + 1));
144403
- }
144404
- function summarizeStep(step, n) {
144405
- switch (step.kind) {
144406
- case "echo":
144407
- return `${n}. echo: ${step.message}`;
144408
- case "launch":
144409
- return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`;
144410
- case "run":
144411
- return `${n}. run: ${step.flow}`;
144412
- case "tap":
144413
- case "long-press": {
144414
- const target = step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`;
144415
- const times = step.kind === "tap" && step.times !== void 0 && step.times > 1 ? ` \xD7${step.times}` : "";
144416
- const held = step.kind === "long-press" && step.duration !== void 0 ? ` for ${step.duration}ms` : "";
144417
- return `${n}. ${step.kind}: ${target}${times}${held}`;
144418
- }
144419
- case "type":
144420
- return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
144421
- case "await":
144422
- case "assert": {
144423
- const tail = step.condition === "text" ? textConditionLabel(step.selector, step.expectedText, step.textMatch) : `${step.condition} ${selectorLabel(step.selector)}`;
144424
- return `${n}. ${step.kind}: ${tail}`;
144425
- }
144426
- case "wait":
144427
- return `${n}. wait: ${step.ms}ms`;
144428
- case "when": {
144429
- const cond = step.condition.kind === "platform" ? `platform ${step.condition.platform}` : step.condition.condition === "text" ? textConditionLabel(
144430
- step.condition.selector,
144431
- step.condition.expectedText,
144432
- step.condition.textMatch
144433
- ) : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`;
144434
- const count2 = step.steps.length;
144435
- return `${n}. when: ${cond} (${count2} step${count2 === 1 ? "" : "s"})`;
144436
- }
144437
- case "scroll-to":
144438
- return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
144439
- case "pinch":
144440
- return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
144441
- case "rotate":
144442
- return `${n}. rotate: by ${step.by}\xB0${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
144443
- case "snapshot":
144444
- return `${n}. snapshot: ${step.name}`;
144445
- case "idle":
144446
- return `${n}. await: screen idle`;
144447
- case "tool":
144448
- default:
144449
- return `${n}. tool: ${step.name} ${renderToolArgs(step.args)}${delayLabel(step)}`;
144450
- }
144451
- }
144452
-
144453
144412
  // ../tool-server/src/tools/flows/flow-device.ts
144454
144413
  init_src();
144455
144414
  var DEVICE_BIND_KEYS = ["udid", "device_id", "device"];
@@ -145030,359 +144989,8 @@ function supportsFlowTree(platform) {
145030
144989
  return FLOW_TREE_SOURCES[platform] !== void 0;
145031
144990
  }
145032
144991
 
145033
- // ../tool-server/src/tools/flows/flow-add-step.ts
145034
- var zodSchema63 = external_exports.object({
145035
- name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
145036
- project_root: external_exports.string().describe(
145037
- "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this step belongs to."
145038
- ),
145039
- command: external_exports.string().describe('MCP tool name (e.g. "gesture-tap", "screenshot", "launch-app")'),
145040
- args: external_exports.string().optional().describe(
145041
- `Tool arguments as a JSON string, e.g. '{"udid": "ABC", "x": 0.5, "y": 0.3}'. Omit for tools with no arguments.`
145042
- ),
145043
- delayMs: external_exports.number().int().min(0).optional().describe("Milliseconds to sleep before executing this step during replay.")
145044
- });
145045
- var REPLAY_TREE_SOURCES = {
145046
- ios: "native-devtools",
145047
- android: "android-devtools"
145048
- };
145049
- function recordedLaunchedApp(session, platform) {
145050
- for (let i = session.flow.steps.length - 1; i >= 0; i--) {
145051
- const step = session.flow.steps[i];
145052
- if (step.kind === "launch") return appIdForPlatform(step.app, platform) ?? void 0;
145053
- }
145054
- return void 0;
145055
- }
145056
- function fallbackSourceWarning(source, platform) {
145057
- const expected = REPLAY_TREE_SOURCES[platform];
145058
- if (!expected || source === expected) return void 0;
145059
- return `selector captured from the fallback ${source} tree (${expected} unavailable) \u2014 replay resolves against the full hierarchy, which may not match it`;
145060
- }
145061
- async function captureTapSelector(registry2, session, udid, point) {
145062
- try {
145063
- const device = resolveDevice(udid);
145064
- const launched = recordedLaunchedApp(session, device.platform);
145065
- const { tree, source } = await fetchFlowTree(registry2, device, launched);
145066
- const node = nodeAtPoint(tree, point);
145067
- if (!node) return { warning: "no element found under the tap; kept coordinates (brittle)" };
145068
- const selector = deriveSelector(node);
145069
- if (!selector)
145070
- return { warning: "tapped element has no stable text/id; kept coordinates (brittle)" };
145071
- const resolved = selectorToFrame(tree, selector);
145072
- if (!resolved) {
145073
- return {
145074
- warning: `selector ${describeSelector(selector)} matches no element on this screen; kept coordinates (brittle)`
145075
- };
145076
- }
145077
- if (!frameContains(resolved, point.x, point.y)) {
145078
- return {
145079
- warning: `selector ${describeSelector(selector)} resolves to a different element on this screen; kept coordinates (brittle)`
145080
- };
145081
- }
145082
- return { selector, warning: fallbackSourceWarning(source, device.platform) };
145083
- } catch (err) {
145084
- return {
145085
- warning: `selector capture failed (${err instanceof Error ? err.message : String(err)}); kept coordinates`
145086
- };
145087
- }
145088
- }
145089
- var RUN_TARGET_COMMAND = "flow-execute";
145090
- async function rewriteSiblingFlowPath(session, args) {
145091
- const flowPath = args.flow_path;
145092
- if (typeof flowPath !== "string" || args.name !== void 0) return;
145093
- const invalid = (detail) => new FailureError(
145094
- `Cannot record a flow-execute of flow_path "${flowPath}": ${detail}. flow_path carries no file-input resolution through flow-add-step's opaque args \u2014 pass name + project_root for a flow saved beside the recording, or add a \`run: <relative path>.yaml\` step to the flow YAML by hand for a cross-directory target.`,
145095
- {
145096
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
145097
- failure_stage: "flow_add_step_flow_path",
145098
- failure_area: "tool_server",
145099
- error_kind: "validation"
145100
- }
145101
- );
145102
- if (!session || session.persist !== "host") {
145103
- throw invalid(
145104
- "the recording is not persisted on this host, so its siblings cannot be resolved here"
145105
- );
145106
- }
145107
- if (flowPath.split(/[\\/]+/).includes("..")) {
145108
- throw invalid(
145109
- 'flow paths must not contain ".." segments \u2014 sibling identity is decided lexically from this path, and a symlinked directory component would make the rewrite run a different file than the path opens'
145110
- );
145111
- }
145112
- const ext = path33.extname(flowPath);
145113
- const bareExtension = path33.basename(flowPath).toLowerCase() === ".yaml";
145114
- if (!bareExtension && ext !== ".yaml") {
145115
- throw invalid(
145116
- ext.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${ext}"` : "flow files must use the .yaml extension"
145117
- );
145118
- }
145119
- const flowsDir = path33.dirname(session.filePath);
145120
- if (path33.resolve(path33.dirname(flowPath)) !== path33.resolve(flowsDir)) {
145121
- throw invalid(
145122
- `it is not in the recording's flow directory ("${flowsDir}"), and a raw tool: step has no boundary to resolve a path through at replay`
145123
- );
145124
- }
145125
- const stem = bareExtension ? "" : path33.basename(flowPath, ".yaml");
145126
- assertSafeFlowName(stem);
145127
- const projectRoot = args.project_root;
145128
- if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
145129
- throw invalid(
145130
- `project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) \u2014 a relative root would be resolved against the tool server's cwd, not the calling agent's`
145131
- );
145132
- }
145133
- if (path33.resolve(flowsDirFor(projectRoot), `${stem}.yaml`) !== path33.resolve(flowPath)) {
145134
- throw invalid(`project_root "${projectRoot}" does not resolve "${stem}" to it`);
145135
- }
145136
- const suppliedBase = path33.basename(flowPath);
145137
- const spelling = await classifyOnDiskSpelling(flowsDir, suppliedBase);
145138
- if (spelling.state !== "listed") {
145139
- const recovery = spelling.state === "absent" ? `pass the basename exactly as it appears on disk` : spelling.addressable ? `pass flow_path with the on-disk basename "${spelling.actual}"` : `rename "${spelling.actual}" to "${suppliedBase}" to record it \u2014 flow files must be lowercase .yaml`;
145140
- throw invalid(
145141
- `the file must be named as it appears on disk \u2014 no directory entry is named "${suppliedBase}"` + (spelling.state === "case_folded" ? ` (this filesystem matched it case-insensitively to "${spelling.actual}")` : "") + `, so the recorded run: step would name a flow no case-sensitive checkout can find \u2014 ` + recovery
145142
- );
145143
- }
145144
- delete args.flow_path;
145145
- args.name = stem;
145146
- }
145147
- async function captureRunTarget(session, args) {
145148
- const name = typeof args.name === "string" ? args.name : void 0;
145149
- if (name === void 0) {
145150
- return { warning: "flow-execute call had no flow name; kept the raw step" };
145151
- }
145152
- if (session.persist !== "host") {
145153
- return {
145154
- warning: `kept the raw flow-execute step \u2014 run: composition is host-resolved, so a remote recording can't reference "${name}" portably`
145155
- };
145156
- }
145157
- try {
145158
- assertSafeFlowName(name);
145159
- const realFlowPath = await fs47.realpath(session.filePath);
145160
- const flowsDir = path33.dirname(realFlowPath);
145161
- const fragPath = path33.join(flowsDir, `${name}.yaml`);
145162
- const projectRoot = args.project_root;
145163
- if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
145164
- return {
145165
- warning: `kept the raw flow-execute step \u2014 project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) to confirm "${name}" names the recording's own sibling`
145166
- };
145167
- }
145168
- const spelling = await classifyOnDiskSpelling(flowsDir, `${name}.yaml`);
145169
- if (spelling.state === "case_folded") {
145170
- const recovery = spelling.addressable ? `re-run it as name "${path33.basename(spelling.actual, ".yaml")}" to record it` : `rename "${spelling.actual}" to "${name}.yaml" to record it \u2014 flow files must be lowercase .yaml`;
145171
- return {
145172
- warning: `kept the raw flow-execute step \u2014 no sibling is named "${name}.yaml" (this filesystem matched it case-insensitively to "${spelling.actual}"), so a run: ${name} step would name a flow no case-sensitive checkout can find \u2014 ${recovery}`
145173
- };
145174
- }
145175
- parseFlow(await fs47.readFile(fragPath, "utf8"));
145176
- let executedPath;
145177
- try {
145178
- executedPath = await fs47.realpath(path33.join(flowsDirFor(projectRoot), `${name}.yaml`));
145179
- } catch {
145180
- executedPath = void 0;
145181
- }
145182
- if (executedPath === void 0) {
145183
- return {
145184
- warning: `kept the raw flow-execute step \u2014 could not verify which file the live flow-execute ran ("${name}" has no canonical file in project_root's flows dir to compare the sibling against)`
145185
- };
145186
- }
145187
- if (executedPath !== await fs47.realpath(fragPath)) {
145188
- return {
145189
- warning: `kept the raw flow-execute step \u2014 project_root "${projectRoot}" resolves "${name}" to "${executedPath}", not the recording's sibling "${fragPath}", so "${name}.yaml" beside the recording's real file is not the file the live flow-execute ran and a run: ${name} step would replay a different flow than the one that just ran`
145190
- };
145191
- }
145192
- return { flow: `${name}.yaml` };
145193
- } catch (err) {
145194
- return {
145195
- warning: `could not resolve "${name}" as a sibling fragment (${err instanceof Error ? err.message : String(err)}); kept the raw flow-execute step`
145196
- };
145197
- }
145198
- }
145199
- function createFlowAddStepTool(registry2) {
145200
- return {
145201
- id: "flow-add-step",
145202
- interaction: {
145203
- // Name the flow: recordings are concurrent, so several of these lines can
145204
- // interleave in one log and "the recorded flow" would not identify which.
145205
- startedMsg: ({ params }) => `Adding ${params.command} step to flow ${params.name}`,
145206
- completedMsg: ({ params }) => `Added ${params.command} step to flow ${params.name}`,
145207
- failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add ${params.command} step to flow ${params.name}: ${failureSignal2.error_code}`
145208
- },
145209
- description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open \u2014 see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment \u2014 add the \`launch: { chromium: <app path> }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it).
145210
- Returns { message, toolResult, stepCount, recorded, savedTo } on success. If it fails an error is returned and nothing is recorded.
145211
- If a step was recorded by mistake, edit the .yaml to remove it \u2014 against a remote client, only after \`flow-finish-recording\`: the in-memory copy is authoritative there, and every write serializes it over your edit.`,
145212
- zodSchema: zodSchema63,
145213
- services: () => ({}),
145214
- async execute(_services, params, ctx) {
145215
- const session = await requireRecordingSession(params.project_root, params.name);
145216
- const args = params.args ? JSON.parse(params.args) : {};
145217
- if (params.command === RUN_TARGET_COMMAND) await rewriteSiblingFlowPath(session, args);
145218
- const isTap = params.command === "gesture-tap" && params.delayMs === void 0 && typeof args.udid === "string" && typeof args.x === "number" && typeof args.y === "number";
145219
- let captured;
145220
- if (isTap) {
145221
- captured = await captureTapSelector(registry2, session, args.udid, {
145222
- x: args.x,
145223
- y: args.y
145224
- });
145225
- }
145226
- const toolResult = await invokeSubTool(registry2, ctx, params.command, args);
145227
- const runTarget = params.command === RUN_TARGET_COMMAND && params.delayMs === void 0 ? await captureRunTarget(session, args) : void 0;
145228
- const strippedArgs = stripDeviceKeys(args);
145229
- const isLaunch = params.command === "restart-app" && params.delayMs === void 0 && typeof strippedArgs.bundleId === "string" && Object.keys(strippedArgs).length === 1;
145230
- const cc = args.clickCount;
145231
- const tapTimes = isTap && typeof cc === "number" && Number.isInteger(cc) && cc >= 2 && cc <= 10 ? { times: cc } : {};
145232
- let step;
145233
- let warning;
145234
- if (captured?.selector) {
145235
- step = { kind: "tap", selector: captured.selector, ...tapTimes };
145236
- warning = captured.warning;
145237
- } else if (isTap) {
145238
- step = { kind: "tap", x: args.x, y: args.y, ...tapTimes };
145239
- warning = captured?.warning;
145240
- } else if (isLaunch) {
145241
- step = { kind: "launch", app: strippedArgs.bundleId };
145242
- } else if (runTarget?.flow) {
145243
- step = { kind: "run", flow: runTarget.flow };
145244
- } else {
145245
- warning = runTarget?.warning;
145246
- step = {
145247
- kind: "tool",
145248
- name: params.command,
145249
- args: strippedArgs,
145250
- delayMs: params.delayMs
145251
- };
145252
- }
145253
- const { savedTo, stepCount } = await appendStepToFlow(session, step);
145254
- return {
145255
- message: `Step added to "${params.name}" flow${warning ? ` \u2014 ${warning}` : ""}`,
145256
- toolResult,
145257
- stepCount,
145258
- recorded: summarizeStep(step, stepCount),
145259
- // Host mode: a path. Client mode: the directive that carries the YAML
145260
- // to the client, which IS the persistence mechanism there — the one
145261
- // place the full file still has to travel per step.
145262
- savedTo
145263
- };
145264
- }
145265
- };
145266
- }
145267
-
145268
- // ../tool-server/src/tools/flows/flow-insert-echo.ts
145269
- init_zod();
145270
- var zodSchema64 = external_exports.object({
145271
- name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
145272
- project_root: external_exports.string().describe(
145273
- "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this echo belongs to."
145274
- ),
145275
- message: external_exports.string().describe("Message to echo when the flow is replayed")
145276
- });
145277
- var flowInsertEchoTool = {
145278
- id: "flow-add-echo",
145279
- interaction: {
145280
- // Name the flow: recordings are concurrent, so several of these lines can
145281
- // interleave in one log and "the recorded flow" would not identify which.
145282
- startedMsg: ({ params }) => `Adding note to flow ${params.name}`,
145283
- completedMsg: ({ params }) => `Added note to flow ${params.name}`,
145284
- failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add note to flow ${params.name}: ${failureSignal2.error_code}`
145285
- },
145286
- description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed \u2014 useful as labels between tool calls.
145287
- Use when you want to annotate a recorded flow with a human-readable label or checkpoint message.
145288
- Returns { message, stepCount, savedTo }. Fails if that flow has no recording in progress.`,
145289
- zodSchema: zodSchema64,
145290
- services: () => ({}),
145291
- async execute(_services, params) {
145292
- const session = await requireRecordingSession(params.project_root, params.name);
145293
- const { savedTo, stepCount } = await appendStepToFlow(session, {
145294
- kind: "echo",
145295
- message: params.message
145296
- });
145297
- return {
145298
- message: `Echo added to "${params.name}" flow`,
145299
- stepCount,
145300
- savedTo
145301
- };
145302
- }
145303
- };
145304
-
145305
- // ../tool-server/src/tools/flows/flow-run.ts
145306
- init_zod();
145307
- var fs51 = __toESM(require("node:fs/promises"));
145308
- var path36 = __toESM(require("node:path"));
145309
- init_src();
145310
-
145311
- // ../tool-server/src/tools/flows/flow-nested-outcome.ts
145312
- var FLOW_EXECUTE_TOOL_ID = "flow-execute";
145313
- var RUN_SEQUENCE_TOOL_ID = "run-sequence";
145314
- function isRecord(value) {
145315
- return typeof value === "object" && value !== null;
145316
- }
145317
- function firstFailingStep(steps) {
145318
- if (!Array.isArray(steps)) return void 0;
145319
- for (const entry of steps) {
145320
- if (!isRecord(entry)) continue;
145321
- if (entry.status !== "fail" && entry.status !== "error") continue;
145322
- const what = typeof entry.tool === "string" ? entry.tool : typeof entry.kind === "string" ? entry.kind : "step";
145323
- const why = typeof entry.reason === "string" ? entry.reason : "no reason given";
145324
- return `${what}: ${why}`;
145325
- }
145326
- return void 0;
145327
- }
145328
- function count(value) {
145329
- return typeof value === "number" ? value : 0;
145330
- }
145331
- function flowExecuteOutcome(result) {
145332
- const flow = typeof result.flow === "string" ? result.flow : "the composed flow";
145333
- if (!("steps" in result) && typeof result.notice === "string") {
145334
- const prerequisite = typeof result.executionPrerequisite === "string" && result.executionPrerequisite ? `: ${result.executionPrerequisite}` : "";
145335
- return {
145336
- status: "error",
145337
- reason: `flow "${flow}" did not run \u2014 its execution prerequisite was not acknowledged${prerequisite}. Add prerequisiteAcknowledged: true to the step's args, or compose with run: instead.`
145338
- };
145339
- }
145340
- if (result.aborted === true) {
145341
- return { status: "skip", reason: `flow "${flow}" was aborted` };
145342
- }
145343
- if (result.ok === false) {
145344
- const detail = firstFailingStep(result.steps);
145345
- return {
145346
- status: "fail",
145347
- reason: `flow "${flow}" failed: ${count(result.passed)} passed, ${count(result.failed)} failed, ${count(result.errored)} errored${detail ? ` (${detail})` : ""}`
145348
- };
145349
- }
145350
- return void 0;
145351
- }
145352
- function runSequenceOutcome(result) {
145353
- const steps = result.steps;
145354
- if (!Array.isArray(steps)) return void 0;
145355
- const failed = steps.find((s) => isRecord(s) && typeof s.error === "string");
145356
- if (failed && isRecord(failed)) {
145357
- const tool = typeof failed.tool === "string" ? failed.tool : "step";
145358
- return {
145359
- status: "fail",
145360
- reason: `run-sequence stopped at ${tool} after ${count(result.completed)} of ${count(result.total)} steps: ${String(failed.error)}`
145361
- };
145362
- }
145363
- const total = count(result.total);
145364
- if (total > 0 && steps.length < total) {
145365
- return {
145366
- status: "skip",
145367
- reason: `run-sequence was aborted after ${count(result.completed)} of ${total} steps`
145368
- };
145369
- }
145370
- return void 0;
145371
- }
145372
- var NESTED_ORCHESTRATORS = /* @__PURE__ */ new Map([
145373
- [FLOW_EXECUTE_TOOL_ID, flowExecuteOutcome],
145374
- [RUN_SEQUENCE_TOOL_ID, runSequenceOutcome]
145375
- ]);
145376
- function isNestedOrchestratorTool(tool) {
145377
- return NESTED_ORCHESTRATORS.has(tool);
145378
- }
145379
- function nestedOrchestratorOutcome(tool, result) {
145380
- if (!isRecord(result)) return void 0;
145381
- return NESTED_ORCHESTRATORS.get(tool)?.(result);
145382
- }
145383
-
145384
144992
  // ../tool-server/src/tools/flows/flow-pixels.ts
145385
- var fs48 = __toESM(require("node:fs/promises"));
144993
+ var fs46 = __toESM(require("node:fs/promises"));
145386
144994
  var import_pngjs2 = __toESM(require_png());
145387
144995
  init_adb();
145388
144996
  var CAPTURE_SCALE = 0.25;
@@ -145452,9 +145060,9 @@ async function capturePng(env, budgetMs) {
145452
145060
  if (env.device.platform === "chromium") return captureChromiumPng(env);
145453
145061
  const file2 = await captureFile(env, budgetMs);
145454
145062
  try {
145455
- return await fs48.readFile(file2);
145063
+ return await fs46.readFile(file2);
145456
145064
  } finally {
145457
- await fs48.rm(file2, { force: true }).catch(() => {
145065
+ await fs46.rm(file2, { force: true }).catch(() => {
145458
145066
  });
145459
145067
  }
145460
145068
  }
@@ -146268,6 +145876,715 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
146268
145876
  }
146269
145877
  }
146270
145878
 
145879
+ // ../tool-server/src/tools/flows/flow-finish-recording.ts
145880
+ init_zod();
145881
+ var fs47 = __toESM(require("node:fs/promises"));
145882
+ function selectorLabel(sel) {
145883
+ const yaml = selectorToYaml(sel);
145884
+ if (typeof yaml !== "object" || yaml === null) return JSON.stringify(yaml);
145885
+ const sorted = Object.fromEntries(
145886
+ Object.entries(yaml).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
145887
+ );
145888
+ return JSON.stringify(sorted);
145889
+ }
145890
+ function textConditionLabel(sel, expectedText, textMatch) {
145891
+ const selector = selectorLabel(sel);
145892
+ const expected = expectedText ?? "";
145893
+ return textMatch === "matches" ? `text ${selector} matches /${expected}/` : textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
145894
+ }
145895
+ var zodSchema62 = external_exports.object({
145896
+ name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
145897
+ project_root: external_exports.string().describe(
145898
+ "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording to finish."
145899
+ )
145900
+ });
145901
+ function attachStepWarnings(summary, warnings) {
145902
+ if (warnings.size === 0) return summary;
145903
+ return summary.flatMap((line, i) => {
145904
+ const recorded = warnings.get(i + 1);
145905
+ return recorded ? [line, ` warning: ${recorded.warning}`] : [line];
145906
+ });
145907
+ }
145908
+ function warningHeadline(warnings, discarded) {
145909
+ const counts = { conversion: 0, wait: 0 };
145910
+ for (const { kind } of warnings.values()) counts[kind] += 1;
145911
+ const clauses = [];
145912
+ if (counts.conversion > 0) {
145913
+ clauses.push(
145914
+ `${counts.conversion} ${counts.conversion === 1 ? "step carries" : "steps carry"} a cross-tree warning about converting a recorded wait`
145915
+ );
145916
+ }
145917
+ if (counts.wait > 0) {
145918
+ clauses.push(
145919
+ `${counts.wait} ${counts.wait === 1 ? "step" : "steps"} recorded a wait that did not pass`
145920
+ );
145921
+ }
145922
+ const carried = clauses.length === 0 ? "" : ` \u2014 ${clauses.join(", and ")}; read \`summary\` before converting or replaying`;
145923
+ if (discarded === 0) return carried;
145924
+ const one = discarded === 1;
145925
+ const drop = `${discarded} ${one ? "warning" : "warnings"} raised during this recording ${one ? "is" : "are"} NOT in \`summary\`: a hand edit to the .yaml moved the ${one ? "step it judged" : "steps they judged"}, so which step ${one ? "it belongs" : "they belong"} to is no longer knowable \u2014 re-record ${one ? "that wait" : "those waits"} to see ${one ? "it" : "them"} again`;
145926
+ return carried === "" ? ` \u2014 ${drop}` : `${carried}. ${drop}`;
145927
+ }
145928
+ function anchoredWarnings(session, steps) {
145929
+ const kept = /* @__PURE__ */ new Map();
145930
+ const recorded = session.flow.steps;
145931
+ if (recorded.length !== steps.length) return kept;
145932
+ if (!steps.every((step, i) => stepAnchor(step) === stepAnchor(recorded[i]))) return kept;
145933
+ for (const [n, verdict] of session.stepWarnings ?? []) {
145934
+ const step = steps[n - 1];
145935
+ if (step !== void 0 && stepAnchor(step) === verdict.step) kept.set(n, verdict);
145936
+ }
145937
+ return kept;
145938
+ }
145939
+ var flowFinishRecordingTool = {
145940
+ id: "flow-finish-recording",
145941
+ interaction: {
145942
+ // Name the flow: other recordings stay live across this call, so an
145943
+ // unqualified "Finishing flow recording" would not identify which one.
145944
+ startedMsg: ({ params }) => `Finishing recording of flow ${params.name}`,
145945
+ // `params.name` rather than the basename of `result.path`: the two are the
145946
+ // same string on every branch — `assertSafeFlowName` admits no dots or
145947
+ // separators, so `getFlowPath` produces `<name>.yaml` and nothing else —
145948
+ // and this spelling matches the two formatters either side of it.
145949
+ completedMsg: ({ params }) => `Saved recorded flow ${params.name}`,
145950
+ failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to finish recording of flow ${params.name}: ${failureSignal2.error_code}`
145951
+ },
145952
+ description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any other key untouched. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress.
145953
+ A warning flow-add-step raised on a recorded \`await-ui-element\` is repeated in \`summary\` as a \`warning:\` line of its own, right below the step it judges, and \`message\` counts them by kind. A warning is repeated only while the step it judges is still identifiable by its number: hand-editing the .yaml during the recording moves the steps, so those warnings are DROPPED rather than pinned on whichever step inherited the number, and \`message\` says how many were dropped. A step that carries a cross-tree warning was re-probed against the runner's tree: read it before converting that wait to \`await:\`/\`assert:\`, which is what the verdict is about and what this moment is for. A step that recorded a wait which did not pass was never probed at all, and its own warning names the CAUSE, because only one of them judges the condition: an unmet wait was read and found false, and it stops the run at replay; a wait whose tree source could not be read, or one that was cancelled, observed nothing and leaves the condition UNKNOWN rather than known-bad. Read those before replaying.
145954
+ You can still edit the .yaml file directly afterwards to remove or reorder steps.`,
145955
+ zodSchema: zodSchema62,
145956
+ services: () => ({}),
145957
+ async execute(_services, params) {
145958
+ const { filePath, flowFile, savedTo, flow, summary, headline } = await withFlowFileLock(
145959
+ params.project_root,
145960
+ params.name,
145961
+ async () => {
145962
+ const session = await requireRecordingSession(params.project_root, params.name);
145963
+ const filePath2 = session.filePath;
145964
+ let flowFile2;
145965
+ let savedTo2;
145966
+ if (session.persist === "client") {
145967
+ flowFile2 = serializeFlow(session.flow);
145968
+ savedTo2 = clientFileDirective(filePath2, flowFile2);
145969
+ } else {
145970
+ flowFile2 = await fs47.readFile(filePath2, "utf8");
145971
+ savedTo2 = filePath2;
145972
+ }
145973
+ const flow2 = parseFlow(flowFile2);
145974
+ const anchored = anchoredWarnings(session, flow2.steps);
145975
+ const summary2 = attachStepWarnings(summarizeSteps(flow2), anchored);
145976
+ const discarded = (session.discardedWarnings ?? 0) + (session.stepWarnings?.size ?? 0) - anchored.size;
145977
+ const headline2 = warningHeadline(anchored, discarded);
145978
+ clearRecordingSession(session);
145979
+ return { filePath: filePath2, flowFile: flowFile2, savedTo: savedTo2, flow: flow2, summary: summary2, headline: headline2 };
145980
+ }
145981
+ );
145982
+ return {
145983
+ // Name the counts in `message` as well. A caller that reads only
145984
+ // `message` would otherwise polish blind.
145985
+ message: `Finished recording "${params.name}" flow (${flow.steps.length} steps)` + headline,
145986
+ path: filePath,
145987
+ executionPrerequisite: flow.executionPrerequisite,
145988
+ steps: flow.steps.length,
145989
+ summary,
145990
+ flowFile,
145991
+ savedTo
145992
+ };
145993
+ }
145994
+ };
145995
+ function renderToolArgs(args) {
145996
+ try {
145997
+ return `${JSON.stringify(args)}`;
145998
+ } catch {
145999
+ return "[cyclic args]";
146000
+ }
146001
+ }
146002
+ function delayLabel(step) {
146003
+ if (!step.delayMs) return "";
146004
+ const ms = Number(step.delayMs);
146005
+ return Number.isFinite(ms) && ms >= 1 ? ` (after ${ms}ms)` : "";
146006
+ }
146007
+ function summarizeSteps(flow) {
146008
+ return flow.steps.map((step, i) => summarizeStep(step, i + 1));
146009
+ }
146010
+ function stepAnchor(step) {
146011
+ return summarizeStep(step, 0);
146012
+ }
146013
+ function summarizeStep(step, n) {
146014
+ switch (step.kind) {
146015
+ case "echo":
146016
+ return `${n}. echo: ${step.message}`;
146017
+ case "launch":
146018
+ return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`;
146019
+ case "run":
146020
+ return `${n}. run: ${step.flow}`;
146021
+ case "tap":
146022
+ case "long-press": {
146023
+ const target = step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`;
146024
+ const times = step.kind === "tap" && step.times !== void 0 && step.times > 1 ? ` \xD7${step.times}` : "";
146025
+ const held = step.kind === "long-press" && step.duration !== void 0 ? ` for ${step.duration}ms` : "";
146026
+ return `${n}. ${step.kind}: ${target}${times}${held}`;
146027
+ }
146028
+ case "type":
146029
+ return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
146030
+ case "await":
146031
+ case "assert": {
146032
+ const tail = step.condition === "text" ? textConditionLabel(step.selector, step.expectedText, step.textMatch) : `${step.condition} ${selectorLabel(step.selector)}`;
146033
+ return `${n}. ${step.kind}: ${tail}`;
146034
+ }
146035
+ case "wait":
146036
+ return `${n}. wait: ${step.ms}ms`;
146037
+ case "when": {
146038
+ const cond = step.condition.kind === "platform" ? `platform ${step.condition.platform}` : step.condition.condition === "text" ? textConditionLabel(
146039
+ step.condition.selector,
146040
+ step.condition.expectedText,
146041
+ step.condition.textMatch
146042
+ ) : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`;
146043
+ const count2 = step.steps.length;
146044
+ return `${n}. when: ${cond} (${count2} step${count2 === 1 ? "" : "s"})`;
146045
+ }
146046
+ case "scroll-to":
146047
+ return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
146048
+ case "pinch":
146049
+ return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
146050
+ case "rotate":
146051
+ return `${n}. rotate: by ${step.by}\xB0${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
146052
+ case "snapshot":
146053
+ return `${n}. snapshot: ${step.name}`;
146054
+ case "idle":
146055
+ return `${n}. await: screen idle`;
146056
+ case "tool":
146057
+ default:
146058
+ return `${n}. tool: ${step.name} ${renderToolArgs(step.args)}${delayLabel(step)}`;
146059
+ }
146060
+ }
146061
+
146062
+ // ../tool-server/src/tools/flows/flow-add-step.ts
146063
+ var zodSchema63 = external_exports.object({
146064
+ name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
146065
+ project_root: external_exports.string().describe(
146066
+ "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this step belongs to."
146067
+ ),
146068
+ command: external_exports.string().describe('MCP tool name (e.g. "gesture-tap", "screenshot", "launch-app")'),
146069
+ args: external_exports.string().optional().describe(
146070
+ `Tool arguments as a JSON string, e.g. '{"udid": "ABC", "x": 0.5, "y": 0.3}'. Omit for tools with no arguments.`
146071
+ ),
146072
+ delayMs: external_exports.number().int().min(0).optional().describe("Milliseconds to sleep before executing this step during replay.")
146073
+ });
146074
+ var REPLAY_TREE_SOURCES = {
146075
+ ios: "native-devtools",
146076
+ android: "android-devtools"
146077
+ };
146078
+ function recordedLaunchedApp(session, platform) {
146079
+ for (let i = session.flow.steps.length - 1; i >= 0; i--) {
146080
+ const step = session.flow.steps[i];
146081
+ if (step.kind === "launch") return appIdForPlatform(step.app, platform) ?? void 0;
146082
+ }
146083
+ return void 0;
146084
+ }
146085
+ function fallbackSourceWarning(source, platform) {
146086
+ const expected = REPLAY_TREE_SOURCES[platform];
146087
+ if (!expected || source === expected) return void 0;
146088
+ return `selector captured from the fallback ${source} tree (${expected} unavailable) \u2014 replay resolves against the full hierarchy, which may not match it`;
146089
+ }
146090
+ function platformOf(udid) {
146091
+ return typeof udid === "string" ? resolveDevice(udid).platform : void 0;
146092
+ }
146093
+ var UNSUPPORTED_PLATFORM = {
146094
+ divergence: "The recorder and the runner read different projections of the screen.",
146095
+ read: "No read-only tool is known to report the runner's projection on this platform \u2014 keep the step raw"
146096
+ };
146097
+ function retargetRemedy(idKind, condition) {
146098
+ if (condition === "hidden") {
146099
+ return `but this verdict says that tree still HAS the element, so retargeting at ${idKind} it definitely carries is the wrong direction \u2014 either narrow the selector until it matches only what you expect to leave, or gate on something that does leave, and prove it with \`flow-execute\`; or keep the step raw`;
146100
+ }
146101
+ return `so retarget the DIRECTIVE at ${idKind} the full hierarchy carries and prove it with \`flow-execute\`, or keep the step raw`;
146102
+ }
146103
+ function runnerSideReadClause(udid, condition) {
146104
+ const platform = platformOf(udid);
146105
+ if (platform === "ios") {
146106
+ return "No read-only tool reports the runner's projection on iOS \u2014 `native-find-views` and `native-full-hierarchy` return the RAW view tree, keeping the hidden, transparent, scroll-clipped and unlabelled container views the runner drops, and neither answers the question a selector asks: `native-find-views` matches `identifier`/`label`/`className` EXACTLY and takes no substring `text` or `role`, and `native-full-hierarchy` takes no matcher at all \u2014 it dumps the tree for you to read \u2014 " + retargetRemedy("an `id`", condition);
146107
+ }
146108
+ if (platform === "android") {
146109
+ return "No read-only tool exposes the runner's full hierarchy on Android \u2014 `describe` returns the trimmed tree the recorder read, not the runner's \u2014 " + retargetRemedy("a `resource-id`", condition);
146110
+ }
146111
+ if (platform === "chromium") {
146112
+ const settle = "No read-only tool exposes the runner's trimmed tree on Chromium \u2014 `describe` re-reads the same DOM on a shorter walk, so it both lists nodes the runner drops and omits nodes the runner keeps \u2014 so settle it by running the conversion: put the directive in a flow and `flow-execute` it. ";
146113
+ if (condition === "hidden") {
146114
+ return settle + "This verdict says that tree still HAS the element, so the usual chromium tips are the wrong direction here: a `scroll-to` before the check, or a switch to an `id`/`role` selector, only makes the directive match more surely. Absence from `describe` is not the element having left, either \u2014 on a dense page the recorder's walk stops at 5000 nodes where the runner's goes to 12000. Narrow the selector until it matches only what you expect to leave, or gate on something that does leave; or keep the step raw";
146115
+ }
146116
+ return settle + // Name both axes: `normRect` clamps each edge on its own, so a
146117
+ // horizontally scrolled node comes back zero-WIDTH at a normal height.
146118
+ "A zero-area frame in `describe` means off-viewport \u2014 zero height for a node above or below the viewport, zero width for one left or right of it, since the walker clamps each edge on its own \u2014 and the fix there is a `scroll-to` before the check rather than a different selector; a password field reaches the runner under the name `[password]`, so only an `id`/`role` selector can match it";
146119
+ }
146120
+ if (platform === "vega") {
146121
+ return "`describe` reads the same source the runner does, so re-run the wait rather than re-recording the selector";
146122
+ }
146123
+ return UNSUPPORTED_PLATFORM.read;
146124
+ }
146125
+ var SCREEN_MAY_HAVE_MOVED = " A screen that changed between the live wait and this re-probe reads the same way, so rule that out first.";
146126
+ function treeDivergenceFor(udid, condition) {
146127
+ const platform = platformOf(udid);
146128
+ if (platform === "ios") {
146129
+ return "The recorder reads the accessibility tree and the runner reads the full native view hierarchy; they overlap but neither contains the other." + SCREEN_MAY_HAVE_MOVED;
146130
+ }
146131
+ if (platform === "chromium") {
146132
+ if (condition === "hidden") {
146133
+ return "Both read the same DOM but project it differently, and here it is the RECORDER that never saw the element: the live wait passed on absence, and this verdict says the runner's tree holds it \u2014 so nothing the flow tree DROPS can be the cause. What is left is the recorder's own limit: its walk stops at 5000 nodes where the flow tree's goes to 12000, so on a dense page the element is past the end of what it read." + SCREEN_MAY_HAVE_MOVED;
146134
+ }
146135
+ return "Both read the same DOM but project it differently, and here it is the RUNNER's side to check: the live wait passed, so the recorder's tree did hold a matching element and its 5000-node walk limit is not what went wrong. The flow tree keeps only addressable nodes (id, label, value, clickable or focused) whose frame the walker did not clamp to zero area for being off-viewport, and it redacts a password field's name to `[password]`." + SCREEN_MAY_HAVE_MOVED;
146136
+ }
146137
+ if (platform === "android") {
146138
+ return "Both read the same `getHierarchy` dump from the android-devtools helper; this host then parses it two ways. `describe`'s interactables trim collapses a `testID`-only container into a passthrough and drops the node carrying the id, while borrowing a descendant's text into an unlabelled clickable's own label \u2014 where the flow adapter keeps every view with a `resource-id` or a label, and asks for 12000 nodes against the helper's 5000 default. So each holds elements the other drops." + SCREEN_MAY_HAVE_MOVED;
146139
+ }
146140
+ if (platform === "vega") {
146141
+ const cause = condition === "text" ? "disagreement means either the SCREEN changed between the live wait and this re-probe or the two sides elected different elements, as above \u2014 not that the two trees differ." : "disagreement means the SCREEN changed between the live wait and this re-probe, not that the two trees differ.";
146142
+ return "Both read the same automation-toolkit page source, and the flow tree only re-shapes it \u2014 it drops no element and its text hoist can only add matches \u2014 so on this platform a " + cause;
146143
+ }
146144
+ return UNSUPPORTED_PLATFORM.divergence;
146145
+ }
146146
+ function awaitStillNeeds(condition) {
146147
+ if (condition === "hidden") return "the element LEAVES that tree";
146148
+ if (condition === "text") return "the element THAT tree elects comes to match on it";
146149
+ return "the element reaches that tree";
146150
+ }
146151
+ function textTieClause(udid) {
146152
+ const order = platformOf(udid) === "ios" ? "and the two are flat lists built from different sources \u2014 the accessibility element order and the view-hierarchy walk \u2014 so neither order follows from the other" : "and the recorder's lists a container before its children where the runner's lists children before their container";
146153
+ return ` Check FIRST whether the selector matches more than one element, because a \`text\` check reads only one of them \u2014 the first visible match in reading order \u2014 and the two sides can elect DIFFERENT ones from the very same nodes: an exact frame tie is settled by which node its tree listed first, ${order}. The reason above quotes whichever element the RUNNER elected, so compare it against the one you meant. If that is what happened, both trees hold both elements and neither the tree differences nor a changed screen below explains anything \u2014 narrow the selector until it resolves a single node, and note that a longer \`await:\` timeout cannot help, since the text it read is already final.`;
146154
+ }
146155
+ var SPELLING_CLAUSE = "Both of those are about the selector exactly as recorded, so convert it in the strict map spelling (`{ text: \u2026 }` / `{ id: \u2026 }`, a straight copy of the step's `selector:`): a bare-string conversion (`{ visible: Continue }`) re-parses as a LOOSE selector \u2014 identifier first, text only as a fallback \u2014 which is a different check this probe never made.";
146156
+ var UNMET_WAIT_WARNING = "recorded, but the wait itself never held \u2014 `await-ui-element` reports an unmet condition by returning success:false instead of failing, so the step was written to the flow anyway. At replay an unmet wait FAILS the step and stops the run there, so re-record it once the condition can actually hold, and delete the failed step after `flow-finish-recording` rather than mid-recording: against a remote client the in-memory copy is authoritative and the next append writes the step straight back, and in host mode the recorder re-reads the file before each append, so an edit that renumbers the steps costs the finish the verdicts it would otherwise carry. The cross-tree re-probe was skipped: it asks whether a check that PASSED would survive conversion to `await:`/`assert:`, and this one did not pass";
146157
+ var UNREADABLE_WAIT_WARNING = "recorded, but this wait reached its deadline without a trustworthy read of the UI tree, so the condition was never judged \u2014 `await-ui-element` returns success:false for that too, and the step was written to the flow anyway. Either no read in the window could be trusted, or the reads went dark before the end and what they saw no longer describes it. Whether the condition holds is UNKNOWN, not known-bad: `toolResult.note` names the tree-source error where a fetch threw, and describes what was seen where the tree was merely empty or degraded. Get that source back and re-record the step to find out. Do not delete the step on this warning alone. The cross-tree re-probe was skipped: it asks whether a check that PASSED would survive conversion to `await:`/`assert:`, and this one never got an answer";
146158
+ var CANCELLED_WAIT_WARNING = "recorded, but this wait was cancelled before its deadline, so the condition was never settled \u2014 `await-ui-element` reports a cancelled wait as success:false, and the step was written to the flow anyway. Whether it holds is UNKNOWN, not known-bad: re-record the step to find out. The cross-tree re-probe was skipped for the same reason";
146159
+ function unmetWaitWarningFor(cause) {
146160
+ if (cause === "unreadable") return UNREADABLE_WAIT_WARNING;
146161
+ if (cause === "cancelled") return CANCELLED_WAIT_WARNING;
146162
+ return UNMET_WAIT_WARNING;
146163
+ }
146164
+ function indeterminateReasonCaveat(udid) {
146165
+ if (platformOf(udid) !== "ios") return "";
146166
+ return ". That reason may tell you to pass `bundleId` \u2014 it is quoted from the shared native-target error, and it does not apply here: the probe predicts an `await:`/`assert:` directive, and no directive takes a bundleId, so neither this probe nor the runner accepts one (the `bundleId` on this step reached the live wait only). What the runner's iOS tree needs is an app with argent's instrumentation loaded \u2014 relaunch it with `launch-app` or a flow `launch:` step. An app that cannot load it at all, such as a `com.apple.*` system app, can never be probed or converted: keep the check as a raw `tool:` step";
146167
+ }
146168
+ var CANCELLED_PROBE_WARNING = "recorded, but the re-probe against the tree the RUNNER reads was cancelled before it answered. The step itself ran and is written to the flow; only the verdict is missing, so whether it would convert to `await:`/`assert:` is UNKNOWN, not known-bad \u2014 record the wait again, uncancelled, before trusting the conversion";
146169
+ var PROBE_MAX_TREE_READ_MS = 2500;
146170
+ var PROBE_ASSERT_GRACE_MS = 1e3;
146171
+ var PROBE_BUDGET_MS = PROBE_ASSERT_GRACE_MS + 2 * PROBE_MAX_TREE_READ_MS;
146172
+ var MAX_PROBE_REASON_CHARS = 200;
146173
+ var PROBE_REASON_TAIL_CHARS = 60;
146174
+ function elisionMarker(dropped) {
146175
+ return `\u2026 (${dropped} more chars) \u2026`;
146176
+ }
146177
+ function cappedReason(reason) {
146178
+ if (reason.length <= MAX_PROBE_REASON_CHARS) return reason;
146179
+ const widestMarker = elisionMarker(reason.length).length;
146180
+ const tailChars = Math.max(
146181
+ 0,
146182
+ Math.min(PROBE_REASON_TAIL_CHARS, MAX_PROBE_REASON_CHARS - widestMarker)
146183
+ );
146184
+ const headChars = Math.max(0, MAX_PROBE_REASON_CHARS - widestMarker - tailChars);
146185
+ const dropped = reason.length - headChars - tailChars;
146186
+ return `${reason.slice(0, headChars)}${elisionMarker(dropped)}${reason.slice(reason.length - tailChars)}`;
146187
+ }
146188
+ async function probeAgainstRunnerTree(registry2, ctx, args) {
146189
+ const selector = args.selector;
146190
+ const condition = args.condition;
146191
+ if (typeof condition !== "string" || selector === null || typeof selector !== "object") {
146192
+ return {};
146193
+ }
146194
+ if (typeof args.udid !== "string") return {};
146195
+ const device = resolveDevice(args.udid);
146196
+ const giveUp = new AbortController();
146197
+ const probeSignal = ctx?.signal ? AbortSignal.any([ctx.signal, giveUp.signal]) : giveUp.signal;
146198
+ const settled = await settleWithin(
146199
+ probeWhenCondition(
146200
+ // The loop reads the signal off ActionEnv, so pass it there as well.
146201
+ { registry: registry2, ctx, device, signal: probeSignal },
146202
+ {
146203
+ condition,
146204
+ selector,
146205
+ expectedText: typeof args.expectedText === "string" ? args.expectedText : void 0,
146206
+ textMatch: args.textMatch
146207
+ }
146208
+ ),
146209
+ PROBE_BUDGET_MS,
146210
+ ctx?.signal
146211
+ );
146212
+ giveUp.abort();
146213
+ if (settled.type === "aborted") return { warning: CANCELLED_PROBE_WARNING };
146214
+ const timedOut = settled.type === "timeout";
146215
+ const outcome = settled.type === "value" ? settled.value : {
146216
+ ok: false,
146217
+ indeterminate: true,
146218
+ reason: timedOut ? `the runner's tree was still being read ${PROBE_BUDGET_MS}ms in, which is longer than the recorder waits \u2014 the source is slow, not down` : `reading the runner's tree failed: ${settled.error}`
146219
+ };
146220
+ if (outcome.ok) return {};
146221
+ if (outcome.indeterminate) {
146222
+ return {
146223
+ // Deliberately NOT joined with treeDivergenceFor/runnerSideReadClause.
146224
+ // Nothing was compared, so claiming the two trees differ would send the
146225
+ // author to rewrite a selector that may be perfectly good.
146226
+ //
146227
+ // The reason is quoted whole rather than through `cappedReason`; see
146228
+ // {@link MAX_PROBE_REASON_CHARS}.
146229
+ warning: `this check could not be re-verified against the tree the RUNNER reads (${outcome.reason ?? "no reason given"}), so it passed against the tree \`${AWAIT_UI_ELEMENT_TOOL_ID}\` reads and nothing else. Whether it would convert to \`await:\`/\`assert:\` is UNKNOWN, not known-bad \u2014 ` + // A timeout and an outage need different next moves: "once that tree
146230
+ // source is back" is nonsense for a source that never left.
146231
+ (timedOut ? `re-record this step when the device is quieter, or settle the conversion directly by putting the directive in a flow and running \`flow-execute\`, which has no such ceiling` : `re-probe once that tree source is back before trusting the conversion` + indeterminateReasonCaveat(args.udid))
146232
+ };
146233
+ }
146234
+ return {
146235
+ warning: `recorded, but this condition does NOT hold against the tree the runner resolves directives against (${cappedReason(outcome.reason ?? "no match")}). As the raw \`tool: ${AWAIT_UI_ELEMENT_TOOL_ID}\` step it replays fine \u2014 it reads the same tree it just passed against. What conversion costs you depends on WHY the two disagree: if the trees really do differ over this element, an \`assert:\` conversion fails the same way (it reads that tree on the same short grace this probe just used), and an \`await:\` does too unless ${awaitStillNeeds(condition)} within its longer timeout; if the SCREEN simply moved on since the live wait, this verdict is no evidence against either \u2014 at replay the directive runs where that wait ran, not a moment after it.` + // Ahead of the tree stories: when it applies it makes all of them
146236
+ // inapplicable.
146237
+ (condition === "text" ? textTieClause(args.udid) : "") + " " + SPELLING_CLAUSE + ` ${treeDivergenceFor(args.udid, condition)} ${runnerSideReadClause(args.udid, condition)}`
146238
+ };
146239
+ }
146240
+ async function captureTapSelector(registry2, session, udid, point) {
146241
+ try {
146242
+ const device = resolveDevice(udid);
146243
+ const launched = recordedLaunchedApp(session, device.platform);
146244
+ const { tree, source } = await fetchFlowTree(registry2, device, launched);
146245
+ const node = nodeAtPoint(tree, point);
146246
+ if (!node) return { warning: "no element found under the tap; kept coordinates (brittle)" };
146247
+ const selector = deriveSelector(node);
146248
+ if (!selector)
146249
+ return { warning: "tapped element has no stable text/id; kept coordinates (brittle)" };
146250
+ const resolved = selectorToFrame(tree, selector);
146251
+ if (!resolved) {
146252
+ return {
146253
+ warning: `selector ${describeSelector(selector)} matches no element on this screen; kept coordinates (brittle)`
146254
+ };
146255
+ }
146256
+ if (!frameContains(resolved, point.x, point.y)) {
146257
+ return {
146258
+ warning: `selector ${describeSelector(selector)} resolves to a different element on this screen; kept coordinates (brittle)`
146259
+ };
146260
+ }
146261
+ return { selector, warning: fallbackSourceWarning(source, device.platform) };
146262
+ } catch (err) {
146263
+ return {
146264
+ warning: `selector capture failed (${err instanceof Error ? err.message : String(err)}); kept coordinates`
146265
+ };
146266
+ }
146267
+ }
146268
+ var RUN_TARGET_COMMAND = "flow-execute";
146269
+ async function rewriteSiblingFlowPath(session, args) {
146270
+ const flowPath = args.flow_path;
146271
+ if (typeof flowPath !== "string" || args.name !== void 0) return;
146272
+ const invalid = (detail) => new FailureError(
146273
+ `Cannot record a flow-execute of flow_path "${flowPath}": ${detail}. flow_path carries no file-input resolution through flow-add-step's opaque args \u2014 pass name + project_root for a flow saved beside the recording, or add a \`run: <relative path>.yaml\` step to the flow YAML by hand for a cross-directory target.`,
146274
+ {
146275
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
146276
+ failure_stage: "flow_add_step_flow_path",
146277
+ failure_area: "tool_server",
146278
+ error_kind: "validation"
146279
+ }
146280
+ );
146281
+ if (!session || session.persist !== "host") {
146282
+ throw invalid(
146283
+ "the recording is not persisted on this host, so its siblings cannot be resolved here"
146284
+ );
146285
+ }
146286
+ if (flowPath.split(/[\\/]+/).includes("..")) {
146287
+ throw invalid(
146288
+ 'flow paths must not contain ".." segments \u2014 sibling identity is decided lexically from this path, and a symlinked directory component would make the rewrite run a different file than the path opens'
146289
+ );
146290
+ }
146291
+ const ext = path33.extname(flowPath);
146292
+ const bareExtension = path33.basename(flowPath).toLowerCase() === ".yaml";
146293
+ if (!bareExtension && ext !== ".yaml") {
146294
+ throw invalid(
146295
+ ext.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${ext}"` : "flow files must use the .yaml extension"
146296
+ );
146297
+ }
146298
+ const flowsDir = path33.dirname(session.filePath);
146299
+ if (path33.resolve(path33.dirname(flowPath)) !== path33.resolve(flowsDir)) {
146300
+ throw invalid(
146301
+ `it is not in the recording's flow directory ("${flowsDir}"), and a raw tool: step has no boundary to resolve a path through at replay`
146302
+ );
146303
+ }
146304
+ const stem = bareExtension ? "" : path33.basename(flowPath, ".yaml");
146305
+ assertSafeFlowName(stem);
146306
+ const projectRoot = args.project_root;
146307
+ if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
146308
+ throw invalid(
146309
+ `project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) \u2014 a relative root would be resolved against the tool server's cwd, not the calling agent's`
146310
+ );
146311
+ }
146312
+ if (path33.resolve(flowsDirFor(projectRoot), `${stem}.yaml`) !== path33.resolve(flowPath)) {
146313
+ throw invalid(`project_root "${projectRoot}" does not resolve "${stem}" to it`);
146314
+ }
146315
+ const suppliedBase = path33.basename(flowPath);
146316
+ const spelling = await classifyOnDiskSpelling(flowsDir, suppliedBase);
146317
+ if (spelling.state !== "listed") {
146318
+ const recovery = spelling.state === "absent" ? `pass the basename exactly as it appears on disk` : spelling.addressable ? `pass flow_path with the on-disk basename "${spelling.actual}"` : `rename "${spelling.actual}" to "${suppliedBase}" to record it \u2014 flow files must be lowercase .yaml`;
146319
+ throw invalid(
146320
+ `the file must be named as it appears on disk \u2014 no directory entry is named "${suppliedBase}"` + (spelling.state === "case_folded" ? ` (this filesystem matched it case-insensitively to "${spelling.actual}")` : "") + `, so the recorded run: step would name a flow no case-sensitive checkout can find \u2014 ` + recovery
146321
+ );
146322
+ }
146323
+ delete args.flow_path;
146324
+ args.name = stem;
146325
+ }
146326
+ async function captureRunTarget(session, args) {
146327
+ const name = typeof args.name === "string" ? args.name : void 0;
146328
+ if (name === void 0) {
146329
+ return { warning: "flow-execute call had no flow name; kept the raw step" };
146330
+ }
146331
+ if (session.persist !== "host") {
146332
+ return {
146333
+ warning: `kept the raw flow-execute step \u2014 run: composition is host-resolved, so a remote recording can't reference "${name}" portably`
146334
+ };
146335
+ }
146336
+ try {
146337
+ assertSafeFlowName(name);
146338
+ const realFlowPath = await fs48.realpath(session.filePath);
146339
+ const flowsDir = path33.dirname(realFlowPath);
146340
+ const fragPath = path33.join(flowsDir, `${name}.yaml`);
146341
+ const projectRoot = args.project_root;
146342
+ if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
146343
+ return {
146344
+ warning: `kept the raw flow-execute step \u2014 project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) to confirm "${name}" names the recording's own sibling`
146345
+ };
146346
+ }
146347
+ const spelling = await classifyOnDiskSpelling(flowsDir, `${name}.yaml`);
146348
+ if (spelling.state === "case_folded") {
146349
+ const recovery = spelling.addressable ? `re-run it as name "${path33.basename(spelling.actual, ".yaml")}" to record it` : `rename "${spelling.actual}" to "${name}.yaml" to record it \u2014 flow files must be lowercase .yaml`;
146350
+ return {
146351
+ warning: `kept the raw flow-execute step \u2014 no sibling is named "${name}.yaml" (this filesystem matched it case-insensitively to "${spelling.actual}"), so a run: ${name} step would name a flow no case-sensitive checkout can find \u2014 ${recovery}`
146352
+ };
146353
+ }
146354
+ parseFlow(await fs48.readFile(fragPath, "utf8"));
146355
+ let executedPath;
146356
+ try {
146357
+ executedPath = await fs48.realpath(path33.join(flowsDirFor(projectRoot), `${name}.yaml`));
146358
+ } catch {
146359
+ executedPath = void 0;
146360
+ }
146361
+ if (executedPath === void 0) {
146362
+ return {
146363
+ warning: `kept the raw flow-execute step \u2014 could not verify which file the live flow-execute ran ("${name}" has no canonical file in project_root's flows dir to compare the sibling against)`
146364
+ };
146365
+ }
146366
+ if (executedPath !== await fs48.realpath(fragPath)) {
146367
+ return {
146368
+ warning: `kept the raw flow-execute step \u2014 project_root "${projectRoot}" resolves "${name}" to "${executedPath}", not the recording's sibling "${fragPath}", so "${name}.yaml" beside the recording's real file is not the file the live flow-execute ran and a run: ${name} step would replay a different flow than the one that just ran`
146369
+ };
146370
+ }
146371
+ return { flow: `${name}.yaml` };
146372
+ } catch (err) {
146373
+ return {
146374
+ warning: `could not resolve "${name}" as a sibling fragment (${err instanceof Error ? err.message : String(err)}); kept the raw flow-execute step`
146375
+ };
146376
+ }
146377
+ }
146378
+ function createFlowAddStepTool(registry2) {
146379
+ return {
146380
+ id: "flow-add-step",
146381
+ interaction: {
146382
+ // Name the flow: recordings are concurrent, so several of these lines can
146383
+ // interleave in one log and "the recorded flow" would not identify which.
146384
+ startedMsg: ({ params }) => `Adding ${params.command} step to flow ${params.name}`,
146385
+ completedMsg: ({ params }) => `Added ${params.command} step to flow ${params.name}`,
146386
+ failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add ${params.command} step to flow ${params.name}: ${failureSignal2.error_code}`
146387
+ },
146388
+ description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open \u2014 see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment \u2014 add the \`launch: { chromium: <app path> }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it).
146389
+ A recorded \`await-ui-element\` that PASSED is re-probed against the tree the RUNNER resolves \`await:\`/\`assert:\` directives against, which is NOT the tree the live call read; a wait that came back \`{ success: false }\` is not probed at all, and its warning says so; when the condition does not hold there the step is still recorded and \`message\` carries a warning to read before converting \u2014 whether the conversion actually breaks depends on WHY the two disagree, since a screen that moved on between the live wait and the re-probe reads the same way. If that tree could not be read at all, the warning says so instead: the conversion is UNKNOWN, not known-bad. The probe judges the selector exactly as recorded, so write the conversion in the strict map spelling (\`{ visible: { text: Continue } }\`, copying the step's \`selector:\`) \u2014 the bare-string spelling (\`{ visible: Continue }\`) re-parses as a loose selector that resolves identifier-first and falls back to text, which is a different check. \`message\` also warns when the live wait itself came back \`{ success: false }\` \u2014 that tool reports a failed wait by returning rather than throwing, so the step is recorded either way. That warning names the cause, because only one of them judges the condition: a genuine miss will stop the run at replay, while a wait whose tree source was unreadable, or one that was cancelled, observed nothing and leaves the condition UNKNOWN.
146390
+ Returns { message, toolResult, stepCount, recorded, savedTo } on success \u2014 \`message\` is \`Step added to "<name>" flow\` plus any warning about what was recorded (read it; a warning never means the step was skipped). If it fails an error is returned and nothing is recorded.
146391
+ If a step was recorded by mistake, remove it from the .yaml after \`flow-finish-recording\` rather than during the recording: against a remote client the in-memory copy is authoritative and every write serializes it over your edit, and in host mode a mid-recording edit renumbers the steps, which costs the finish the cross-tree verdicts anchored to them.`,
146392
+ // The recorded tool RUNS here, so this call lasts as long as whatever it
146393
+ // wraps, and the three it most often wraps declare this too. Without it the
146394
+ // MCP adapter capped the POST at 30s and retried the identical body four
146395
+ // more times — and every retry re-runs the action and appends another step,
146396
+ // because an aborted request still appends its first.
146397
+ longRunning: true,
146398
+ zodSchema: zodSchema63,
146399
+ services: () => ({}),
146400
+ async execute(_services, params, ctx) {
146401
+ const session = await requireRecordingSession(params.project_root, params.name);
146402
+ const args = params.args ? JSON.parse(params.args) : {};
146403
+ if (params.command === RUN_TARGET_COMMAND) await rewriteSiblingFlowPath(session, args);
146404
+ const isTap = params.command === "gesture-tap" && params.delayMs === void 0 && typeof args.udid === "string" && typeof args.x === "number" && typeof args.y === "number";
146405
+ let captured;
146406
+ if (isTap) {
146407
+ captured = await captureTapSelector(registry2, session, args.udid, {
146408
+ x: args.x,
146409
+ y: args.y
146410
+ });
146411
+ }
146412
+ const toolResult = await invokeSubTool(registry2, ctx, params.command, args);
146413
+ let waitWarning;
146414
+ if (params.command === AWAIT_UI_ELEMENT_TOOL_ID) {
146415
+ if (isUnmetUiWaitResult(params.command, toolResult)) {
146416
+ waitWarning = {
146417
+ warning: unmetWaitWarningFor(unmetUiWaitCause(toolResult)),
146418
+ kind: "wait"
146419
+ };
146420
+ } else {
146421
+ const probed = (await probeAgainstRunnerTree(registry2, ctx, args)).warning;
146422
+ if (probed) waitWarning = { warning: probed, kind: "conversion" };
146423
+ }
146424
+ }
146425
+ const runTarget = params.command === RUN_TARGET_COMMAND && params.delayMs === void 0 ? await captureRunTarget(session, args) : void 0;
146426
+ const strippedArgs = stripDeviceKeys(args);
146427
+ const isLaunch = params.command === "restart-app" && params.delayMs === void 0 && typeof strippedArgs.bundleId === "string" && Object.keys(strippedArgs).length === 1;
146428
+ const cc = args.clickCount;
146429
+ const tapTimes = isTap && typeof cc === "number" && Number.isInteger(cc) && cc >= 2 && cc <= 10 ? { times: cc } : {};
146430
+ let step;
146431
+ let warning;
146432
+ if (captured?.selector) {
146433
+ step = { kind: "tap", selector: captured.selector, ...tapTimes };
146434
+ warning = captured.warning;
146435
+ } else if (isTap) {
146436
+ step = { kind: "tap", x: args.x, y: args.y, ...tapTimes };
146437
+ warning = captured?.warning;
146438
+ } else if (isLaunch) {
146439
+ step = { kind: "launch", app: strippedArgs.bundleId };
146440
+ } else if (runTarget?.flow) {
146441
+ step = { kind: "run", flow: runTarget.flow };
146442
+ } else {
146443
+ warning = waitWarning?.warning ?? runTarget?.warning;
146444
+ step = {
146445
+ kind: "tool",
146446
+ name: params.command,
146447
+ args: strippedArgs,
146448
+ delayMs: params.delayMs
146449
+ };
146450
+ }
146451
+ const { savedTo, stepCount } = await appendStepToFlow(session, step);
146452
+ if (waitWarning) {
146453
+ (session.stepWarnings ??= /* @__PURE__ */ new Map()).set(stepCount, {
146454
+ ...waitWarning,
146455
+ step: stepAnchor(step)
146456
+ });
146457
+ }
146458
+ return {
146459
+ message: `Step added to "${params.name}" flow${warning ? ` \u2014 ${warning}` : ""}`,
146460
+ toolResult,
146461
+ stepCount,
146462
+ recorded: summarizeStep(step, stepCount),
146463
+ // Host mode: a path. Client mode: the directive that carries the YAML
146464
+ // to the client, which IS the persistence mechanism there — the one
146465
+ // place the full file still has to travel per step.
146466
+ savedTo
146467
+ };
146468
+ }
146469
+ };
146470
+ }
146471
+
146472
+ // ../tool-server/src/tools/flows/flow-insert-echo.ts
146473
+ init_zod();
146474
+ var zodSchema64 = external_exports.object({
146475
+ name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
146476
+ project_root: external_exports.string().describe(
146477
+ "Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this echo belongs to."
146478
+ ),
146479
+ message: external_exports.string().describe("Message to echo when the flow is replayed")
146480
+ });
146481
+ var flowInsertEchoTool = {
146482
+ id: "flow-add-echo",
146483
+ interaction: {
146484
+ // Name the flow: recordings are concurrent, so several of these lines can
146485
+ // interleave in one log and "the recorded flow" would not identify which.
146486
+ startedMsg: ({ params }) => `Adding note to flow ${params.name}`,
146487
+ completedMsg: ({ params }) => `Added note to flow ${params.name}`,
146488
+ failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add note to flow ${params.name}: ${failureSignal2.error_code}`
146489
+ },
146490
+ description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed \u2014 useful as labels between tool calls.
146491
+ Use when you want to annotate a recorded flow with a human-readable label or checkpoint message.
146492
+ Returns { message, stepCount, savedTo }. Fails if that flow has no recording in progress.`,
146493
+ zodSchema: zodSchema64,
146494
+ services: () => ({}),
146495
+ async execute(_services, params) {
146496
+ const session = await requireRecordingSession(params.project_root, params.name);
146497
+ const { savedTo, stepCount } = await appendStepToFlow(session, {
146498
+ kind: "echo",
146499
+ message: params.message
146500
+ });
146501
+ return {
146502
+ message: `Echo added to "${params.name}" flow`,
146503
+ stepCount,
146504
+ savedTo
146505
+ };
146506
+ }
146507
+ };
146508
+
146509
+ // ../tool-server/src/tools/flows/flow-run.ts
146510
+ init_zod();
146511
+ var fs51 = __toESM(require("node:fs/promises"));
146512
+ var path36 = __toESM(require("node:path"));
146513
+ init_src();
146514
+
146515
+ // ../tool-server/src/tools/flows/flow-nested-outcome.ts
146516
+ var FLOW_EXECUTE_TOOL_ID = "flow-execute";
146517
+ var RUN_SEQUENCE_TOOL_ID = "run-sequence";
146518
+ function isRecord(value) {
146519
+ return typeof value === "object" && value !== null;
146520
+ }
146521
+ function firstFailingStep(steps) {
146522
+ if (!Array.isArray(steps)) return void 0;
146523
+ for (const entry of steps) {
146524
+ if (!isRecord(entry)) continue;
146525
+ if (entry.status !== "fail" && entry.status !== "error") continue;
146526
+ const what = typeof entry.tool === "string" ? entry.tool : typeof entry.kind === "string" ? entry.kind : "step";
146527
+ const why = typeof entry.reason === "string" ? entry.reason : "no reason given";
146528
+ return `${what}: ${why}`;
146529
+ }
146530
+ return void 0;
146531
+ }
146532
+ function count(value) {
146533
+ return typeof value === "number" ? value : 0;
146534
+ }
146535
+ function flowExecuteOutcome(result) {
146536
+ const flow = typeof result.flow === "string" ? result.flow : "the composed flow";
146537
+ if (!("steps" in result) && typeof result.notice === "string") {
146538
+ const prerequisite = typeof result.executionPrerequisite === "string" && result.executionPrerequisite ? `: ${result.executionPrerequisite}` : "";
146539
+ return {
146540
+ status: "error",
146541
+ reason: `flow "${flow}" did not run \u2014 its execution prerequisite was not acknowledged${prerequisite}. Add prerequisiteAcknowledged: true to the step's args, or compose with run: instead.`
146542
+ };
146543
+ }
146544
+ if (result.aborted === true) {
146545
+ return { status: "skip", reason: `flow "${flow}" was aborted` };
146546
+ }
146547
+ if (result.ok === false) {
146548
+ const detail = firstFailingStep(result.steps);
146549
+ return {
146550
+ status: "fail",
146551
+ reason: `flow "${flow}" failed: ${count(result.passed)} passed, ${count(result.failed)} failed, ${count(result.errored)} errored${detail ? ` (${detail})` : ""}`
146552
+ };
146553
+ }
146554
+ return void 0;
146555
+ }
146556
+ function runSequenceOutcome(result) {
146557
+ const steps = result.steps;
146558
+ if (!Array.isArray(steps)) return void 0;
146559
+ const failed = steps.find((s) => isRecord(s) && typeof s.error === "string");
146560
+ if (failed && isRecord(failed)) {
146561
+ const tool = typeof failed.tool === "string" ? failed.tool : "step";
146562
+ return {
146563
+ status: "fail",
146564
+ reason: `run-sequence stopped at ${tool} after ${count(result.completed)} of ${count(result.total)} steps: ${String(failed.error)}`
146565
+ };
146566
+ }
146567
+ const total = count(result.total);
146568
+ if (total > 0 && steps.length < total) {
146569
+ return {
146570
+ status: "skip",
146571
+ reason: `run-sequence was aborted after ${count(result.completed)} of ${total} steps`
146572
+ };
146573
+ }
146574
+ return void 0;
146575
+ }
146576
+ var NESTED_ORCHESTRATORS = /* @__PURE__ */ new Map([
146577
+ [FLOW_EXECUTE_TOOL_ID, flowExecuteOutcome],
146578
+ [RUN_SEQUENCE_TOOL_ID, runSequenceOutcome]
146579
+ ]);
146580
+ function isNestedOrchestratorTool(tool) {
146581
+ return NESTED_ORCHESTRATORS.has(tool);
146582
+ }
146583
+ function nestedOrchestratorOutcome(tool, result) {
146584
+ if (!isRecord(result)) return void 0;
146585
+ return NESTED_ORCHESTRATORS.get(tool)?.(result);
146586
+ }
146587
+
146271
146588
  // ../tool-server/src/tools/flows/flow-visual.ts
146272
146589
  var import_node_crypto8 = require("node:crypto");
146273
146590
  var fs50 = __toESM(require("node:fs/promises"));