@flowriki/agent 0.1.0-beta.53 → 0.1.0-beta.56

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.
@@ -68,7 +68,7 @@ import {
68
68
  textIndependent,
69
69
  validateActors,
70
70
  webCloudOf
71
- } from "./chunk-FIR6NOLA.js";
71
+ } from "./chunk-O34WXNH6.js";
72
72
 
73
73
  // src/engine/machine.ts
74
74
  import fs from "fs";
@@ -76,7 +76,7 @@ import os from "os";
76
76
  import path from "path";
77
77
  import { createRequire } from "module";
78
78
  import { spawnSync } from "child_process";
79
- var AGENT_VERSION = "0.1.0-beta.53";
79
+ var AGENT_VERSION = "0.1.0-beta.56";
80
80
  async function prepareBrowsers() {
81
81
  if (browsersReady()) {
82
82
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -136,7 +136,7 @@ async function checkMachine() {
136
136
  }
137
137
  } catch {
138
138
  }
139
- const { mobileDoctor: mobileDoctor2 } = await import("./dist-6GKVE5CL.js");
139
+ const { mobileDoctor: mobileDoctor2 } = await import("./dist-RDJIIBQV.js");
140
140
  return {
141
141
  agentVersion: AGENT_VERSION,
142
142
  node: process.versions.node,
@@ -8857,7 +8857,7 @@ var AgentLink = class {
8857
8857
  this.opts.onStatus?.(status2, detail);
8858
8858
  }
8859
8859
  async start() {
8860
- const backoff = this.opts.backoff ?? [250, 500, 1e3, 2e3, 5e3];
8860
+ const backoff = this.opts.backoff ?? [250, 500, 1e3, 2e3, 5e3, 1e4, 2e4, 3e4];
8861
8861
  const sleep2 = this.opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
8862
8862
  let attempt = 0;
8863
8863
  while (!this.stopped) {
@@ -8878,8 +8878,9 @@ var AgentLink = class {
8878
8878
  socket.send(JSON.stringify(this.opts.agent.hello()));
8879
8879
  this.unsubscribe = this.opts.agent.journal.subscribe(() => this.flush());
8880
8880
  this.setStatus("connected");
8881
- attempt = 0;
8881
+ const since = Date.now();
8882
8882
  await closed;
8883
+ if (Date.now() - since > 1e4) attempt = 0;
8883
8884
  this.unsubscribe?.();
8884
8885
  this.unsubscribe = null;
8885
8886
  this.socket = null;
@@ -9846,8 +9847,11 @@ var ownerOf = (l) => l.draft ? "recording" : "debug";
9846
9847
  var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|\bpin\b|cvv|cvc/i;
9847
9848
  async function screenOf(l, withImage = false) {
9848
9849
  l.session.changed();
9849
- const screen = await l.session.screen();
9850
- const png = withImage ? await l.session.screenshot() : null;
9850
+ const [screen, png, where] = await Promise.all([
9851
+ l.session.screen(),
9852
+ withImage ? l.session.preview() : Promise.resolve(null),
9853
+ l.session.currentScreenName()
9854
+ ]);
9851
9855
  return {
9852
9856
  ...png ? { image: png.toString("base64") } : {},
9853
9857
  width: screen.size.width,
@@ -9862,7 +9866,7 @@ async function screenOf(l, withImage = false) {
9862
9866
  ...n2.value ? { value: n2.password ? "\u2022\u2022\u2022" : n2.value } : {},
9863
9867
  ...n2.id ? { id: n2.id } : {}
9864
9868
  })),
9865
- where: await l.session.currentScreenName(),
9869
+ where,
9866
9870
  device: l.session.deviceName
9867
9871
  };
9868
9872
  }
@@ -9890,7 +9894,7 @@ function mobileHandlers(ctx) {
9890
9894
  const session = live.get(platform)?.session ?? openSessionOn(platform);
9891
9895
  if (!session) return { ok: true, result: { image: null } };
9892
9896
  try {
9893
- const png = await session.screenshot();
9897
+ const png = await session.preview(720);
9894
9898
  return { ok: true, result: { image: png.toString("base64"), device: session.deviceName } };
9895
9899
  } catch {
9896
9900
  return { ok: true, result: { image: null } };
@@ -10003,18 +10007,18 @@ function mobileHandlers(ctx) {
10003
10007
  if (op === "run" || step.type === "signIn" || step.type === "assert") {
10004
10008
  const done = await runMobileSteps(l.session, [step], { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
10005
10009
  const result = done.results[0];
10006
- if (op === "run") return { ok: true, result: { result: result ?? null, screen: await screenOf(l) } };
10010
+ if (op === "run") return { ok: true, result: { result: result ?? null } };
10007
10011
  if (done.status !== "passed") throw new Error(result?.detail ?? done.error ?? "it did not work");
10008
10012
  } else {
10009
10013
  await performMobileStep(l.session, step, { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
10010
10014
  }
10011
10015
  } catch (error) {
10012
10016
  if (secret2) l.typed.delete(ref);
10013
- return { ok: true, result: { kept: false, error: error.message.split("\n")[0], screen: await screenOf(l) } };
10017
+ return { ok: true, result: { kept: false, error: error.message.split("\n")[0] } };
10014
10018
  }
10015
- await new Promise((r) => setTimeout(r, 500));
10019
+ await new Promise((r) => setTimeout(r, 300));
10016
10020
  l.draft?.steps.push(step);
10017
- return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? [], screen: await screenOf(l) } };
10021
+ return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? [] } };
10018
10022
  }
10019
10023
  if (op === "check") {
10020
10024
  const assertion = RecordedStepSchema.shape.proposals.element.parse({ enabled: true, ...command.args["assertion"] });
@@ -10260,9 +10264,10 @@ function eventsFromLine(askId, line) {
10260
10264
  function createClaudeCli(options) {
10261
10265
  const spawn2 = options.spawn ?? nodeSpawn;
10262
10266
  const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
10263
- const maxAskMs = options.maxAskMs ?? 30 * 6e4;
10267
+ const maxAskMs = options.maxAskMs ?? 60 * 6e4;
10264
10268
  const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
10265
- let current = null;
10269
+ const running = /* @__PURE__ */ new Map();
10270
+ const MAX_AT_ONCE = 3;
10266
10271
  let cancelled = null;
10267
10272
  function capture(args, timeoutMs = 15e3) {
10268
10273
  return new Promise((resolve2) => {
@@ -10304,7 +10309,7 @@ function createClaudeCli(options) {
10304
10309
  return { installed: true, version: version.out.trim().split(/\s+/)[0] ?? null, ...parsed };
10305
10310
  },
10306
10311
  connect(emit) {
10307
- if (current) return { started: false, detail: "The assistant is busy answering a question." };
10312
+ if (running.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
10308
10313
  let child;
10309
10314
  try {
10310
10315
  child = spawn2(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
@@ -10334,88 +10339,107 @@ function createClaudeCli(options) {
10334
10339
  return { started: true };
10335
10340
  },
10336
10341
  ask(ask) {
10337
- if (current) return { started: false, detail: "The assistant is already answering a question on this machine." };
10342
+ if (running.size >= MAX_AT_ONCE) return { started: false, detail: `The assistant is already answering ${MAX_AT_ONCE} questions on this machine \u2014 wait for one to finish.` };
10338
10343
  fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
10339
- const dir = fs43.mkdtempSync(path42.join(scratchRoot2, "ask-"));
10344
+ const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
10345
+ fs43.mkdirSync(dir, { recursive: true, mode: 448 });
10340
10346
  const mcpConfigPath = path42.join(dir, "mcp.json");
10341
10347
  fs43.writeFileSync(
10342
10348
  mcpConfigPath,
10343
10349
  JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args } } }),
10344
10350
  { mode: 384 }
10345
10351
  );
10346
- let child;
10347
- try {
10348
- child = spawn2(
10349
- binary,
10350
- buildAskArgs({
10351
- prompt: ask.prompt,
10352
- mcpConfigPath,
10353
- projectId: ask.projectId,
10354
- projectName: ask.projectName,
10355
- ...ask.sessionId ? { sessionId: ask.sessionId } : {},
10356
- ...ask.folders?.length ? { folders: ask.folders } : {},
10357
- ...ask.mode ? { mode: ask.mode } : {}
10358
- }),
10359
- // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10360
- { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
10361
- );
10362
- } catch {
10363
- fs43.rmSync(dir, { recursive: true, force: true });
10364
- return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10365
- }
10366
- current = { askId: ask.askId, child };
10367
- let buffer = "", stderr = "", sawDone = false, ended = false;
10368
- const handleLine = (line) => {
10369
- for (const event of eventsFromLine(ask.askId, line)) {
10370
- if (event.name === "assistant.done") sawDone = true;
10371
- ask.emit(event.name, event.data);
10372
- }
10373
- };
10374
- const end = (ok, detail) => {
10375
- if (ended) return;
10376
- ended = true;
10377
- clearTimeout(timer);
10378
- if (buffer.trim()) handleLine(buffer);
10379
- if (!sawDone) ask.emit("assistant.done", { askId: ask.askId, ok, result: detail });
10380
- current = null;
10381
- fs43.rmSync(dir, { recursive: true, force: true });
10382
- };
10383
- const timer = setTimeout(() => {
10384
- child.kill("SIGTERM");
10385
- end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
10386
- }, maxAskMs);
10387
- timer.unref?.();
10388
- child.stdout?.on("data", (chunk) => {
10389
- buffer += chunk.toString();
10390
- let at;
10391
- while ((at = buffer.indexOf("\n")) >= 0) {
10392
- handleLine(buffer.slice(0, at));
10393
- buffer = buffer.slice(at + 1);
10394
- }
10395
- });
10396
- child.stderr?.on("data", (chunk) => {
10397
- stderr = (stderr + chunk.toString()).slice(-2e3);
10398
- });
10399
- child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
10400
- child.on(
10401
- "close",
10402
- (code) => end(
10403
- code === 0,
10404
- cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10405
- )
10406
- );
10407
- return { started: true };
10352
+ return launch(ask, dir, mcpConfigPath, ask.sessionId);
10408
10353
  },
10409
10354
  cancel(askId) {
10410
- if (!current || current.askId !== askId) return false;
10355
+ const child = running.get(askId);
10356
+ if (!child) return false;
10411
10357
  cancelled = askId;
10412
- current.child.kill("SIGTERM");
10413
- const child = current.child;
10358
+ child.kill("SIGTERM");
10414
10359
  setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
10415
10360
  return true;
10416
10361
  },
10417
- busy: () => current !== null
10362
+ /** Full — no room for another question — rather than "something runs":
10363
+ * a second conversation is welcome while the first works. */
10364
+ busy() {
10365
+ return running.size >= MAX_AT_ONCE;
10366
+ }
10418
10367
  };
10368
+ function launch(ask, dir, mcpConfigPath, sessionId) {
10369
+ let child;
10370
+ try {
10371
+ child = spawn2(
10372
+ binary,
10373
+ buildAskArgs({
10374
+ prompt: ask.prompt,
10375
+ mcpConfigPath,
10376
+ projectId: ask.projectId,
10377
+ projectName: ask.projectName,
10378
+ ...sessionId ? { sessionId } : {},
10379
+ ...ask.folders?.length ? { folders: ask.folders } : {},
10380
+ ...ask.mode ? { mode: ask.mode } : {},
10381
+ ...ask.brief ? { brief: ask.brief } : {}
10382
+ }),
10383
+ // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10384
+ { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
10385
+ );
10386
+ } catch {
10387
+ return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10388
+ }
10389
+ running.set(ask.askId, child);
10390
+ let buffer = "", stderr = "", sawDone = false, ended = false;
10391
+ let silentFailure = false;
10392
+ const handleLine = (line) => {
10393
+ for (const event of eventsFromLine(ask.askId, line)) {
10394
+ if (event.name === "assistant.done" && event.data["ok"] === false && !String(event.data["result"] ?? "").trim()) {
10395
+ silentFailure = true;
10396
+ continue;
10397
+ }
10398
+ if (event.name === "assistant.done") sawDone = true;
10399
+ ask.emit(event.name, event.data);
10400
+ }
10401
+ };
10402
+ const end = (ok, detail) => {
10403
+ if (ended) return;
10404
+ ended = true;
10405
+ clearTimeout(timer);
10406
+ if (buffer.trim()) handleLine(buffer);
10407
+ running.delete(ask.askId);
10408
+ if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
10409
+ const again = launch(ask, dir, mcpConfigPath, void 0);
10410
+ if (again.started) return;
10411
+ }
10412
+ if (!sawDone) {
10413
+ const why = detail || stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || "The assistant stopped without saying why.";
10414
+ ask.emit("assistant.done", { askId: ask.askId, ok: ok && !silentFailure, result: ok && !silentFailure ? "" : why });
10415
+ }
10416
+ };
10417
+ const timer = setTimeout(() => {
10418
+ child.kill("SIGTERM");
10419
+ end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
10420
+ }, maxAskMs);
10421
+ timer.unref?.();
10422
+ child.stdout?.on("data", (chunk) => {
10423
+ buffer += chunk.toString();
10424
+ let at;
10425
+ while ((at = buffer.indexOf("\n")) >= 0) {
10426
+ handleLine(buffer.slice(0, at));
10427
+ buffer = buffer.slice(at + 1);
10428
+ }
10429
+ });
10430
+ child.stderr?.on("data", (chunk) => {
10431
+ stderr = (stderr + chunk.toString()).slice(-2e3);
10432
+ });
10433
+ child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
10434
+ child.on(
10435
+ "close",
10436
+ (code) => end(
10437
+ code === 0,
10438
+ cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10439
+ )
10440
+ );
10441
+ return { started: true };
10442
+ }
10419
10443
  }
10420
10444
  function createAgentRuntime(options) {
10421
10445
  const lease = new BrowserLease();
@@ -10847,6 +10871,7 @@ export {
10847
10871
  executeStep,
10848
10872
  replayCase,
10849
10873
  fieldNames,
10874
+ flowrikiHome,
10850
10875
  helperRunner,
10851
10876
  resolveCheckouts,
10852
10877
  start,
@@ -14,7 +14,9 @@ import { spawn as spawn2 } from "child_process";
14
14
  import fs3 from "fs";
15
15
  import os3 from "os";
16
16
  import path3 from "path";
17
+ import { execFile as execFile2 } from "child_process";
17
18
  import fs4 from "fs";
19
+ import os4 from "os";
18
20
  import path4 from "path";
19
21
 
20
22
  // ../test-model/dist/chunk-I5COYCQ5.js
@@ -2566,8 +2568,15 @@ async function localCapabilities(target, udid, opts = {}) {
2566
2568
  "appium:automationName": "UiAutomator2",
2567
2569
  ...common,
2568
2570
  "appium:appPackage": app.id,
2569
- ...app.activity ? { "appium:appActivity": app.activity } : {},
2571
+ ...app.activity ?? opts.activity ? { "appium:appActivity": app.activity ?? opts.activity } : {},
2570
2572
  "appium:appWaitActivity": "*",
2573
+ // THE APP IS STARTED BY US, NOT BY THE DRIVER. A session opens, then the
2574
+ // app is closed and opened the way its icon does (activateApp), from
2575
+ // its first screen. The driver's own launch needs ONE launcher activity
2576
+ // and fails on a build that has several ("Intent matches multiple
2577
+ // activities") — a dev build with a debug launcher, typically — while
2578
+ // the icon's way never has to choose.
2579
+ "appium:autoLaunch": false,
2571
2580
  // Its own ports: two phones on one Appium each need their own driver
2572
2581
  // server and picture stream, or the second session steals the first's.
2573
2582
  ...opts.ports?.system ? { "appium:systemPort": opts.ports.system } : {},
@@ -2647,7 +2656,7 @@ async function cloudDevices(account, key) {
2647
2656
  if (!res.ok) throw new Error(`${account.provider} answered ${res.status}.`);
2648
2657
  return res.json();
2649
2658
  };
2650
- const platformOf = (os4) => /android/i.test(os4) ? "android" : /ios/i.test(os4) ? "ios" : null;
2659
+ const platformOf = (os5) => /android/i.test(os5) ? "android" : /ios/i.test(os5) ? "ios" : null;
2651
2660
  const out = [];
2652
2661
  if (account.provider === "browserstack") {
2653
2662
  for (const d of await get("https://api-cloud.browserstack.com/app-automate/devices.json")) {
@@ -2661,11 +2670,11 @@ async function cloudDevices(account, key) {
2661
2670
  if (p) out.push({ name: d.name, platform: p, osVersion: d.osVersion });
2662
2671
  }
2663
2672
  } else {
2664
- for (const os4 of ["android", "ios"]) {
2665
- const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os4}`);
2673
+ for (const os5 of ["android", "ios"]) {
2674
+ const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os5}`);
2666
2675
  for (const [, devices] of Object.entries(listed)) {
2667
2676
  if (!Array.isArray(devices)) continue;
2668
- for (const d of devices) out.push({ name: d.deviceName, platform: os4, osVersion: d.platformVersion });
2677
+ for (const d of devices) out.push({ name: d.deviceName, platform: os5, osVersion: d.platformVersion });
2669
2678
  }
2670
2679
  }
2671
2680
  }
@@ -3232,7 +3241,7 @@ var MobileSession = class _MobileSession {
3232
3241
  if (target.device.kind === "cloud") {
3233
3242
  const choice2 = target.device;
3234
3243
  const account = target.mobile.cloud.find((c) => c.provider === choice2.provider);
3235
- if (!account) throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} is not connected \u2014 add it in Settings \u2192 Mobile.`);
3244
+ if (!account) throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} is not connected \u2014 add it in Settings \u2192 Platforms.`);
3236
3245
  const key = opts.resolveSecret?.(account.keyRef);
3237
3246
  if (!key) throw new Error(`The ${CLOUD_PROVIDER_NAME[choice2.provider]} access key is not available to this run.`);
3238
3247
  let appRef = null;
@@ -3266,7 +3275,8 @@ var MobileSession = class _MobileSession {
3266
3275
  const buildFile = app.build ? await localBuild(app.build) : null;
3267
3276
  say(`Opening the app on ${known.name}\u2026`);
3268
3277
  const ports = target.platform === "android" ? { system: await freePort(), mjpeg: await freePort() } : { wda: await freePort(), mjpeg: await freePort() };
3269
- const capabilities = await localCapabilities(target, udid, { buildFile, ports });
3278
+ const activity = target.platform === "android" && !app.activity && !buildFile ? await launchActivityOf(udid, app.id) : null;
3279
+ const capabilities = await localCapabilities(target, udid, { buildFile, ports, ...activity ? { activity } : {} });
3270
3280
  const driver = await WebDriverSession.create(endpoint, capabilities).catch((e) => {
3271
3281
  const tail = appiumLog().filter((l) => /error/i.test(l)).slice(-2).join(" ");
3272
3282
  throw new Error(explainStartFailure(e.message, target.platform) + (tail ? ` (${tail.slice(0, 300)})` : ""));
@@ -3313,6 +3323,16 @@ var MobileSession = class _MobileSession {
3313
3323
  }
3314
3324
  return png;
3315
3325
  }
3326
+ /** The screen as a picture to LOOK at — the recorder's mirror, the run
3327
+ * page's live phone — at most `maxPx` on its longer side. A phone's own
3328
+ * resolution is three to four times what a mirror draws, and every byte
3329
+ * of it crosses the control channel each time; on macOS `sips` shrinks it
3330
+ * in a few milliseconds, elsewhere the full picture is sent. Evidence
3331
+ * files keep the full size. */
3332
+ async preview(maxPx = 900) {
3333
+ const png = await this.screenshot();
3334
+ return shrink(png, maxPx);
3335
+ }
3316
3336
  /** Android: the activity on screen. iOS has none worth reading. */
3317
3337
  async currentScreenName() {
3318
3338
  if (this.platform !== "android") return "";
@@ -3443,7 +3463,7 @@ var MobileSession = class _MobileSession {
3443
3463
  await this.terminateApp().catch(() => {
3444
3464
  });
3445
3465
  await this.driver.execute("mobile: clearApp", this.appArg()).catch((e) => {
3446
- throw new Error(`This device cannot clear the app's data (${e.message}). Reinstall it, or choose "Start clean" in Settings \u2192 Mobile.`);
3466
+ throw new Error(`This device cannot clear the app's data (${e.message}). Reinstall it, or choose "Start clean" in Settings \u2192 Platforms.`);
3447
3467
  });
3448
3468
  await this.launchApp();
3449
3469
  }
@@ -3510,6 +3530,33 @@ var MobileSession = class _MobileSession {
3510
3530
  await this.driver.close();
3511
3531
  }
3512
3532
  };
3533
+ async function launchActivityOf(udid, appPackage) {
3534
+ const adb = adbPath();
3535
+ if (!adb) return null;
3536
+ try {
3537
+ const out = await run(adb, ["-s", udid, "shell", "cmd", "package", "query-activities", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER", appPackage], 1e4, await toolEnv());
3538
+ const found = out.split("\n").map((l) => l.trim()).filter((l) => l.startsWith(`${appPackage}/`)).map((l) => l.slice(appPackage.length + 1));
3539
+ return found.length === 1 ? found[0] : null;
3540
+ } catch {
3541
+ return null;
3542
+ }
3543
+ }
3544
+ async function shrink(png, maxPx) {
3545
+ if (process.platform !== "darwin") return png;
3546
+ const dir = fs4.mkdtempSync(path4.join(os4.tmpdir(), "flowriki-shot-"));
3547
+ const file = path4.join(dir, "s.png");
3548
+ try {
3549
+ fs4.writeFileSync(file, png);
3550
+ await new Promise((resolve, reject) => {
3551
+ execFile2("/usr/bin/sips", ["-Z", String(maxPx), file], { timeout: 5e3 }, (error) => error ? reject(error) : resolve());
3552
+ });
3553
+ return fs4.readFileSync(file);
3554
+ } catch {
3555
+ return png;
3556
+ } finally {
3557
+ fs4.rmSync(dir, { recursive: true, force: true });
3558
+ }
3559
+ }
3513
3560
  var ANDROID_KEYS = {
3514
3561
  enter: 66,
3515
3562
  return: 66,
@@ -3532,11 +3579,14 @@ function explainStartFailure(message, platform) {
3532
3579
  return `Appium's ${platform === "android" ? "Android (uiautomator2)" : "iOS (xcuitest)"} driver is not installed \u2014 run \`flowriki-agent mobile setup\`.`;
3533
3580
  }
3534
3581
  if (/is not installed|does not exist or is not accessible|Activity name .* cannot be found|App with bundle identifier .* unknown/i.test(message)) {
3535
- return "The app is not installed on that device. Install it, or add its build (.apk / .ipa / .app) in Settings \u2192 Mobile.";
3582
+ return "The app is not installed on that device. Install it, or add its build (.apk / .ipa / .app) to the environment in Settings \u2192 Environments.";
3583
+ }
3584
+ if (/Intent matches multiple activities/i.test(message)) {
3585
+ return "The app has more than one launch screen, and the driver could not choose. Set the Android app's First screen (e.g. .MainActivity) on the environment in Settings \u2192 Environments.";
3536
3586
  }
3537
3587
  if (/JAVA_HOME/i.test(message)) return "Java is needed for Android tests \u2014 install a JDK (Android Studio includes one).";
3538
3588
  if (/ANDROID_HOME|ANDROID_SDK_ROOT/i.test(message)) return "The Android SDK was not found \u2014 install Android Studio.";
3539
- if (/xcodebuild|WebDriverAgent/i.test(message)) return `Appium could not start its helper on the iPhone: ${message}. For a real iPhone, set your Apple team id in Settings \u2192 Mobile.`;
3589
+ if (/xcodebuild|WebDriverAgent/i.test(message)) return `Appium could not start its helper on the iPhone: ${message}. For a real iPhone, set your Apple team id in Settings \u2192 Platforms.`;
3540
3590
  return `The app could not be opened: ${message}`;
3541
3591
  }
3542
3592
  var MOBILE_TIMEOUTS = { step: 15e3, assertion: 5e3 };
@@ -3799,7 +3849,7 @@ async function chooseDevice(platform, mobile, asked, where) {
3799
3849
  const picked = pickDevice(devices, platform);
3800
3850
  if (picked) return picked;
3801
3851
  const farm = mobile.cloud[0];
3802
- if (farm) throw new Error(`No ${platform === "ios" ? "iPhone or simulator" : "Android phone or emulator"} is connected to this computer. Choose a ${farm.provider} device in Settings \u2192 Mobile, or connect one.`);
3852
+ if (farm) throw new Error(`No ${platform === "ios" ? "iPhone or simulator" : "Android phone or emulator"} is connected to this computer. Choose a ${farm.provider} device in Settings \u2192 Platforms, or connect one.`);
3803
3853
  throw new Error(platform === "ios" ? "No iPhone or simulator is available \u2014 install Xcode for simulators, or plug in an iPhone." : "No Android phone or emulator is available \u2014 start an emulator in Android Studio, or plug in a phone with USB debugging on.");
3804
3854
  }
3805
3855
  async function openMobileSession(opts) {
@@ -4166,6 +4216,7 @@ export {
4166
4216
  checkMobileAssertion,
4167
4217
  openSessionOn,
4168
4218
  MobileSession,
4219
+ shrink,
4169
4220
  MOBILE_TIMEOUTS,
4170
4221
  performMobileStep,
4171
4222
  runMobileSteps,
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  doctor,
14
14
  executeStep,
15
15
  fieldNames,
16
+ flowrikiHome,
16
17
  helperRunner,
17
18
  openRunSession,
18
19
  prepareBrowsers,
@@ -23,7 +24,7 @@ import {
23
24
  runtimeValue,
24
25
  start,
25
26
  status
26
- } from "./chunk-6AR63WTN.js";
27
+ } from "./chunk-H3YHYPVE.js";
27
28
  import {
28
29
  CLOUD_PROVIDERS,
29
30
  CLOUD_PROVIDER_NAME,
@@ -47,9 +48,10 @@ import {
47
48
  replayMobileCase,
48
49
  runMobileSteps,
49
50
  screenLines,
51
+ slugify,
50
52
  stepRefusedOn,
51
53
  targetFor
52
- } from "./chunk-FIR6NOLA.js";
54
+ } from "./chunk-O34WXNH6.js";
53
55
 
54
56
  // src/cli.ts
55
57
  import { parseArgs } from "util";
@@ -145,8 +147,14 @@ var ServiceClient = class {
145
147
  get origin() {
146
148
  return this.connection.service;
147
149
  }
148
- async call(path2, init = {}) {
149
- const res = await this.fetcher(`${this.connection.service}${path2}`, {
150
+ /** One request to the service, retried when the service — or the proxy in
151
+ * front of it — says it is too busy. A 429 or a 503 from the edge means
152
+ * the request was never handled, so trying again is safe for any method;
153
+ * a 502/504 is retried only when nothing could have happened (a GET).
154
+ * Backing off here, inside the tool, is what keeps the model from asking
155
+ * again every two seconds and making the limit worse. */
156
+ async call(path3, init = {}, attempt = 0) {
157
+ const res = await this.fetcher(`${this.connection.service}${path3}`, {
150
158
  ...init,
151
159
  headers: {
152
160
  "x-flowriki-device": this.connection.deviceId,
@@ -161,9 +169,18 @@ var ServiceClient = class {
161
169
  } catch {
162
170
  }
163
171
  if (!res.ok) {
172
+ const method = String(init.method ?? "GET").toUpperCase();
173
+ const busy = res.status === 429 || res.status === 503 || method === "GET" && (res.status === 502 || res.status === 504);
174
+ if (busy && attempt < 4) {
175
+ const asked = Number(res.headers.get("retry-after"));
176
+ const wait = Math.min(15e3, res.headers.get("retry-after") !== null && Number.isFinite(asked) && asked >= 0 ? asked * 1e3 : 1e3 * 2 ** attempt);
177
+ await new Promise((r) => setTimeout(r, wait));
178
+ return this.call(path3, init, attempt + 1);
179
+ }
164
180
  if (res.status === 404 && !("error" in body)) {
165
181
  throw new Error("the Flowriki service does not offer this yet \u2014 it may need updating");
166
182
  }
183
+ if (busy) throw new Error(`the Flowriki service is too busy right now (${res.status}) and stayed so after several tries \u2014 wait a minute before asking again, and do not retry in a loop`);
167
184
  throw new Error(String(body["error"] ?? `the service answered ${res.status}`));
168
185
  }
169
186
  return body;
@@ -288,9 +305,9 @@ var ServiceClient = class {
288
305
  }
289
306
  // ── Manual cycles and bugs: the dashboard's own routes, behind this
290
307
  // machine's credential, so the assistant can do what a person can. ──────
291
- scoped(path2, projectId, extra = {}) {
308
+ scoped(path3, projectId, extra = {}) {
292
309
  const query = new URLSearchParams({ projectId, ...extra });
293
- return `/api/agent${path2}?${query}`;
310
+ return `/api/agent${path3}?${query}`;
294
311
  }
295
312
  cycles(projectId) {
296
313
  return this.call(this.scoped("/cycles", projectId));
@@ -333,8 +350,8 @@ var ServiceClient = class {
333
350
  knowledgePages(projectId) {
334
351
  return this.call(this.scoped("/knowledge", projectId));
335
352
  }
336
- knowledgePage(projectId, path2) {
337
- return this.call(this.scoped("/knowledge/page", projectId, { path: path2 }));
353
+ knowledgePage(projectId, path3) {
354
+ return this.call(this.scoped("/knowledge/page", projectId, { path: path3 }));
338
355
  }
339
356
  writeKnowledge(projectId, body) {
340
357
  return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
@@ -375,6 +392,8 @@ var ServiceClient = class {
375
392
  };
376
393
 
377
394
  // src/mcp/session.ts
395
+ import fs from "fs";
396
+ import path from "path";
378
397
  var MAX_TREE = 14e3;
379
398
  var MAX_STEP_VIEW = 6e3;
380
399
  function topLevelBlocks(tree) {
@@ -529,7 +548,7 @@ function describe(step) {
529
548
  return `${step.type} ${target}${where}`;
530
549
  }
531
550
  }
532
- var AuthoringSession = class {
551
+ var AuthoringSession = class _AuthoringSession {
533
552
  constructor(service, opts = {}) {
534
553
  this.service = service;
535
554
  this.opts = opts;
@@ -546,6 +565,9 @@ var AuthoringSession = class {
546
565
  /** Values typed into secret fields, by their `typed:` reference. */
547
566
  typed = /* @__PURE__ */ new Map();
548
567
  draft = null;
568
+ /** The steps as last replayed from the start and passed — a save of the
569
+ * same steps needs no second replay. Cleared by any change to them. */
570
+ verified = null;
549
571
  catalog = null;
550
572
  /** Text going back to Claude, with every secret typed here masked — a
551
573
  * password box's value is in the page's tree, and a careless form puts it
@@ -595,15 +617,107 @@ var AuthoringSession = class {
595
617
  if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
596
618
  return base;
597
619
  }
598
- /** Open a browser for a new flow, signed in first if asked. */
599
- async start(input, catalog) {
620
+ /** Open a browser for a new flow, signed in first if asked.
621
+ *
622
+ * A DRAFT LEFT BEHIND IS PICKED UP. Every kept step is written to disk as
623
+ * it is kept, so a flow interrupted — the program stopped, the limit hit,
624
+ * the agent restarted — is not lost: starting the same flow again replays
625
+ * what was kept and carries on from there, unless `fresh` says start over. */
626
+ async start(input, catalog, opts = {}) {
600
627
  await this.close();
601
628
  this.catalog = catalog;
602
629
  this.draft = { ...input, steps: [] };
603
630
  this.values = /* @__PURE__ */ new Map();
604
631
  this.typed = /* @__PURE__ */ new Map();
605
632
  this.lastTree = null;
606
- if (isMobile(input.platform)) return this.startMobile(input.platform, catalog);
633
+ this.verified = null;
634
+ const left = opts.fresh ? null : this.readDraft(input.projectId, input.name);
635
+ if (opts.fresh) this.dropDraft(input.projectId, input.name);
636
+ const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
637
+ if (!left?.steps.length) return opened;
638
+ return this.resume(left.steps, opened);
639
+ }
640
+ /** The steps of an earlier attempt, performed again in the open session
641
+ * and kept as they pass; the first that fails is where the person — or
642
+ * the model — takes over. */
643
+ async resume(steps, opened) {
644
+ const draft = this.draft;
645
+ let failed = null;
646
+ for (const step of steps) {
647
+ try {
648
+ if (this.mobile) {
649
+ const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
650
+ if (done.status !== "passed") throw new Error(done.results[0]?.detail ?? done.error ?? "it did not work");
651
+ } else {
652
+ const { page, run } = this.requireOpen();
653
+ this.page = await executeStep(page, step, {
654
+ pages: run.pages,
655
+ values: this.values,
656
+ baseUrl: this.baseUrl(),
657
+ ...this.resolvers ?? {},
658
+ resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
659
+ timeoutMs: STEP_TIMEOUT_MS,
660
+ navigationTimeoutMs: 45e3
661
+ });
662
+ await this.settle();
663
+ }
664
+ draft.steps.push(step);
665
+ this.checkpoint();
666
+ } catch (error) {
667
+ failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
668
+ break;
669
+ }
670
+ }
671
+ this.checkpoint();
672
+ const kept = draft.steps.length;
673
+ const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}). Carry on from here.` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
674
+ const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
675
+ return `${head}
676
+
677
+ ${view}`;
678
+ }
679
+ // ── Drafts on disk ──────────────────────────────────────────────────────
680
+ static draftFile(projectId, name) {
681
+ return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
682
+ }
683
+ /** Written after every kept step: the draft as it stands, never a typed
684
+ * secret (those stay in memory until the vault has them). */
685
+ checkpoint() {
686
+ const draft = this.draft;
687
+ if (!draft) return;
688
+ this.verified = null;
689
+ try {
690
+ const file = _AuthoringSession.draftFile(draft.projectId, draft.name);
691
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
692
+ const { preparation: _p, ...rest } = draft;
693
+ fs.writeFileSync(file, JSON.stringify({ ...rest, steps: withoutTyped(draft.steps), savedAt: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
694
+ } catch {
695
+ }
696
+ }
697
+ readDraft(projectId, name) {
698
+ try {
699
+ const raw = JSON.parse(fs.readFileSync(_AuthoringSession.draftFile(projectId, name), "utf8"));
700
+ const steps = (raw.steps ?? []).map((s) => RecordedStepSchema.parse(s));
701
+ return { steps };
702
+ } catch {
703
+ return null;
704
+ }
705
+ }
706
+ dropDraft(projectId, name) {
707
+ try {
708
+ fs.rmSync(_AuthoringSession.draftFile(projectId, name), { force: true });
709
+ } catch {
710
+ }
711
+ }
712
+ /** Close and forget the flow being written: close_browser means it. */
713
+ async closeAndDrop() {
714
+ const draft = this.draft;
715
+ await this.close();
716
+ if (draft) this.dropDraft(draft.projectId, draft.name);
717
+ this.draft = null;
718
+ }
719
+ async startWeb(input, catalog) {
720
+ void catalog;
607
721
  const base = this.baseUrl();
608
722
  this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
609
723
  this.page = this.run.page;
@@ -756,6 +870,7 @@ ${await this.view(false)}`);
756
870
  }
757
871
  await this.settle();
758
872
  draft.steps.push(step);
873
+ this.checkpoint();
759
874
  return `Step ${draft.steps.length} kept: ${describe(step)}.
760
875
 
761
876
  ${await this.view(false)}`;
@@ -793,6 +908,7 @@ ${await this.view(false)}`;
793
908
  proposals: [assertion]
794
909
  }])[0]);
795
910
  draft.steps.push(step);
911
+ this.checkpoint();
796
912
  return `Step ${draft.steps.length} kept: ${describe(step)}.`;
797
913
  }
798
914
  /** Get a value from outside the page — an email code, a 2FA code, a text,
@@ -835,6 +951,7 @@ ${await this.view(false)}`;
835
951
  }
836
952
  await this.settle();
837
953
  draft.steps.push(step);
954
+ this.checkpoint();
838
955
  return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
839
956
 
840
957
  ${await this.view(false)}` : "");
@@ -854,6 +971,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
854
971
  if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
855
972
  if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
856
973
  const [gone] = draft.steps.splice(number - 1, 1);
974
+ this.checkpoint();
857
975
  return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
858
976
 
859
977
  ${this.list()}`;
@@ -872,17 +990,34 @@ ${this.list()}`;
872
990
  ];
873
991
  const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
874
992
  const lines = outcome.steps.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
993
+ if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
875
994
  return `${outcome.status === "passed" ? "The whole flow passed from the start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
876
995
  ${lines.join("\n")}`;
877
996
  }
878
- /** Save the draft as a flow on a new branch, for a person to review. */
879
- async save(title, where = {}) {
997
+ /** Save the draft as a flow on a new branch, for a person to review.
998
+ *
999
+ * REPLAYED FIRST. A flow that was only ever performed step by step, with
1000
+ * pauses to look, has not been shown to run as a test runs it; so unless
1001
+ * the same steps already passed a replay, they are replayed from the
1002
+ * start now, and a flow that fails is not saved — the failing step is
1003
+ * named instead. `force` saves anyway, for a person who knows. */
1004
+ async save(title, where = {}, opts = {}) {
880
1005
  const draft = this.draft;
881
1006
  if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
882
1007
  if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
883
1008
  if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
884
1009
  throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
885
1010
  }
1011
+ let replayed = "";
1012
+ if (!opts.force && this.verified !== signatureOf(draft.steps)) {
1013
+ const report = await this.replay();
1014
+ if (!/^The whole flow passed/.test(report)) {
1015
+ throw new Error(`Not saved: replayed from the start, the flow did not pass.
1016
+ ${report}
1017
+ Fix the step that failed (remove_step, then act again) and save again \u2014 or save_flow with force: true to keep it as it is.`);
1018
+ }
1019
+ replayed = " It was replayed from the start first and passed.";
1020
+ }
886
1021
  const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
887
1022
  const checkCount = checksOff.filter((step) => step.type === "assert").length;
888
1023
  const secretValues = {};
@@ -908,7 +1043,8 @@ ${lines.join("\n")}`;
908
1043
  const note = others.length ? `
909
1044
  Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
910
1045
  const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
911
- return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
1046
+ this.dropDraft(draft.projectId, draft.name);
1047
+ return `${what} ${where_}.${replayed}${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
912
1048
  Review and publish it here: ${review}${note}`;
913
1049
  }
914
1050
  /** A test case built from flows, without saving: its rows and every check,
@@ -1080,6 +1216,7 @@ ${await this.view(false)}`);
1080
1216
  }
1081
1217
  await new Promise((r) => setTimeout(r, 600));
1082
1218
  draft.steps.push(step);
1219
+ this.checkpoint();
1083
1220
  return `Step ${draft.steps.length} kept: ${describe(step)}.
1084
1221
 
1085
1222
  ${await this.view(false)}`;
@@ -1113,6 +1250,7 @@ ${await this.view(false)}`;
1113
1250
  proposals: [assertion]
1114
1251
  }])[0]);
1115
1252
  draft.steps.push(step);
1253
+ this.checkpoint();
1116
1254
  return `Step ${draft.steps.length} kept: ${describe(step)}.`;
1117
1255
  }
1118
1256
  async mobileHelper(input) {
@@ -1141,6 +1279,7 @@ ${await this.view(false)}`;
1141
1279
  throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
1142
1280
  }
1143
1281
  draft.steps.push(step);
1282
+ this.checkpoint();
1144
1283
  return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got?.value ?? ""}" (${got?.detail ?? ""}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
1145
1284
 
1146
1285
  ${await this.view(false)}` : "");
@@ -1163,6 +1302,7 @@ ${await this.view(false)}` : "");
1163
1302
  if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await mobile.openLink(draft.startPath);
1164
1303
  const outcome = await runMobileSteps(mobile, steps, { values: /* @__PURE__ */ new Map(), ...resolvers, resolveSecret: (ref) => this.typed.get(ref) ?? resolvers.resolveSecret(ref) });
1165
1304
  this.lastTree = null;
1305
+ if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
1166
1306
  const lines = outcome.results.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
1167
1307
  return `${outcome.status === "passed" ? "The whole flow passed from the app's start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
1168
1308
  ${lines.join("\n")}`;
@@ -1175,6 +1315,9 @@ ${lines.join("\n")}`;
1175
1315
  await page.waitForTimeout(300);
1176
1316
  }
1177
1317
  };
1318
+ function signatureOf(steps) {
1319
+ return JSON.stringify(steps.map((s) => [s.id, s.type, s.role, s.name, s.value, s.nth ?? null, s.proposals.map((p) => [p.kind, p.value, p.enabled])]));
1320
+ }
1178
1321
 
1179
1322
  // src/mcp/tools.ts
1180
1323
  var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
@@ -1195,6 +1338,9 @@ THE MODEL \u2014 get this right before recording anything:
1195
1338
  something different.
1196
1339
  - A flow records checks (add_check) that prove each thing happened, but they
1197
1340
  are SAVED SWITCHED OFF: which ones matter depends on the test.
1341
+ - Every kept step is written down as it is kept: if you are stopped, start_flow
1342
+ with the same name picks the flow up where it was (fresh: true starts over).
1343
+ save_flow replays the flow from the start first and refuses one that fails.
1198
1344
  - A TEST CASE is the test. It strings flows together as rows (in the same
1199
1345
  browser, or a new one for a second person) and switches on the checks it is
1200
1346
  about. Build it with preview_case, then save_case \u2014 which runs it first.
@@ -1479,6 +1625,7 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
1479
1625
  required: ["profileId"],
1480
1626
  additionalProperties: false
1481
1627
  },
1628
+ fresh: { type: "boolean", description: "Start over, ignoring a draft of this flow left by an earlier attempt. By default the earlier steps are replayed and kept, and recording carries on from there." },
1482
1629
  projectId: { type: "string" }
1483
1630
  },
1484
1631
  required: ["name", "feature"],
@@ -1510,7 +1657,7 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
1510
1657
  ...feature ? { featureId: feature.id } : { featureName: featureAsked },
1511
1658
  startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
1512
1659
  ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
1513
- }, catalog);
1660
+ }, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
1514
1661
  const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
1515
1662
  return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} ${isMobile(platform) ? "The app is open on the phone." : "The browser is open."}
1516
1663
 
@@ -1632,20 +1779,21 @@ ${page}`);
1632
1779
  {
1633
1780
  name: "save_flow",
1634
1781
  title: "Save the flow to a branch",
1635
- description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. Returns the review link. Refused if the flow checks nothing.",
1782
+ description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. The flow is replayed from the start first, as a test run will run it, unless the same steps already passed replay_flow; one that fails is not saved and the failing step is named. Returns the review link. Refused if the flow checks nothing.",
1636
1783
  inputSchema: {
1637
1784
  type: "object",
1638
1785
  properties: {
1639
1786
  title: { type: "string", description: "The branch's title, used only when a new branch is started." },
1640
1787
  branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
1641
- newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
1788
+ newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." },
1789
+ force: { type: "boolean", description: "Save even though the replay from the start failed \u2014 only when the person asked for it as it is." }
1642
1790
  },
1643
1791
  additionalProperties: false
1644
1792
  },
1645
1793
  run: async (args) => text(await session.save(str(args["title"]), {
1646
1794
  ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
1647
1795
  ...args["newBranch"] === true ? { newBranch: true } : {}
1648
- }))
1796
+ }, { ...args["force"] === true ? { force: true } : {} }))
1649
1797
  },
1650
1798
  {
1651
1799
  name: "list_my_branches",
@@ -2031,7 +2179,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
2031
2179
  }
2032
2180
  }
2033
2181
  const { pages } = await service.knowledgePages(projectId);
2034
- const read = async (path2) => pages.some((p) => p.path === path2) ? (await service.knowledgePage(projectId, path2)).content : null;
2182
+ const read = async (path3) => pages.some((p) => p.path === path3) ? (await service.knowledgePage(projectId, path3)).content : null;
2035
2183
  const [product, glossary, decisions, web, android, ios, tagged] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md", "product/web.md", "product/android.md", "product/ios.md", "codebase.json"].map(read));
2036
2184
  const codebases = pages.filter((p) => p.path.startsWith("codebase/"));
2037
2185
  const tags = codebaseIndexOf(tagged).folders;
@@ -2122,8 +2270,7 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
2122
2270
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
2123
2271
  annotations: { destructiveHint: true },
2124
2272
  run: async () => {
2125
- await session.close();
2126
- session.draft = null;
2273
+ await session.closeAndDrop();
2127
2274
  return text("Closed.");
2128
2275
  }
2129
2276
  }
@@ -2315,14 +2462,14 @@ function serveWithUpgrades(tools, info, opts) {
2315
2462
  }
2316
2463
 
2317
2464
  // src/updates.ts
2318
- import fs from "fs";
2319
- import path from "path";
2465
+ import fs2 from "fs";
2466
+ import path2 from "path";
2320
2467
  var CACHE_MS = 30 * 60 * 1e3;
2321
2468
  async function latestPublished(opts = {}) {
2322
- const file = path.join(dataDir(), "latest-agent.json");
2469
+ const file = path2.join(dataDir(), "latest-agent.json");
2323
2470
  if (!opts.fresh) {
2324
2471
  try {
2325
- const kept = JSON.parse(fs.readFileSync(file, "utf8"));
2472
+ const kept = JSON.parse(fs2.readFileSync(file, "utf8"));
2326
2473
  if (kept.version && kept.at && Date.now() - kept.at < CACHE_MS) return kept.version;
2327
2474
  } catch {
2328
2475
  }
@@ -2333,8 +2480,8 @@ async function latestPublished(opts = {}) {
2333
2480
  const version = (await res.json()).version ?? null;
2334
2481
  if (version) {
2335
2482
  try {
2336
- fs.mkdirSync(path.dirname(file), { recursive: true });
2337
- fs.writeFileSync(file, JSON.stringify({ version, at: Date.now() }));
2483
+ fs2.mkdirSync(path2.dirname(file), { recursive: true });
2484
+ fs2.writeFileSync(file, JSON.stringify({ version, at: Date.now() }));
2338
2485
  } catch {
2339
2486
  }
2340
2487
  }
@@ -40,12 +40,13 @@ import {
40
40
  run,
41
41
  runMobileSteps,
42
42
  screenLines,
43
+ shrink,
43
44
  stopAppium,
44
45
  targetFor,
45
46
  textOf,
46
47
  toolEnv,
47
48
  typesSecrets
48
- } from "./chunk-FIR6NOLA.js";
49
+ } from "./chunk-O34WXNH6.js";
49
50
  export {
50
51
  AGENT_HOME,
51
52
  APPIUM_DIR,
@@ -87,6 +88,7 @@ export {
87
88
  runMobileSteps,
88
89
  run as runTool,
89
90
  screenLines,
91
+ shrink,
90
92
  stopAppium,
91
93
  targetFor,
92
94
  textOf,
package/dist/engine.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-6AR63WTN.js";
13
- import "./chunk-FIR6NOLA.js";
12
+ } from "./chunk-H3YHYPVE.js";
13
+ import "./chunk-O34WXNH6.js";
14
14
  export {
15
15
  AGENT_VERSION,
16
16
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.53",
3
+ "version": "0.1.0-beta.56",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",