@flowriki/agent 0.1.0-beta.63 → 0.1.0-beta.65

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.
@@ -66,9 +66,10 @@ import {
66
66
  stepRefusedOn,
67
67
  targetFor,
68
68
  textIndependent,
69
+ usageKindOf,
69
70
  validateActors,
70
71
  webCloudOf
71
- } from "./chunk-IFUODU4W.js";
72
+ } from "./chunk-PDUMGI6V.js";
72
73
 
73
74
  // src/engine/machine.ts
74
75
  import fs from "fs";
@@ -76,7 +77,7 @@ import os from "os";
76
77
  import path from "path";
77
78
  import { createRequire } from "module";
78
79
  import { spawnSync } from "child_process";
79
- var AGENT_VERSION = "0.1.0-beta.63";
80
+ var AGENT_VERSION = "0.1.0-beta.65";
80
81
  async function prepareBrowsers() {
81
82
  if (browsersReady()) {
82
83
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -136,7 +137,7 @@ async function checkMachine() {
136
137
  }
137
138
  } catch {
138
139
  }
139
- const { mobileDoctor: mobileDoctor2 } = await import("./dist-Z67YCHL5.js");
140
+ const { mobileDoctor: mobileDoctor2 } = await import("./dist-3PMNCKSW.js");
140
141
  return {
141
142
  agentVersion: AGENT_VERSION,
142
143
  node: process.versions.node,
@@ -551,7 +552,9 @@ async function connect(options = {}) {
551
552
  const res = await fetch(`${service}/api/pairing-requests`, {
552
553
  method: "POST",
553
554
  headers: { "content-type": "application/json" },
554
- body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION }),
555
+ // Ask for a code to show here: the page then insists on it, so a link
556
+ // sent by somebody else's agent cannot be approved by mistake.
557
+ body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION, wantsCode: true }),
555
558
  signal: options.signal ?? null
556
559
  });
557
560
  if (!res.ok) {
@@ -565,7 +568,7 @@ async function connect(options = {}) {
565
568
  options.onProgress?.({
566
569
  kind: "approve",
567
570
  message: "waiting for approval in your browser",
568
- detail: { url: approve, machineName, canOpen: options.open !== false && canOpenBrowser() }
571
+ detail: { url: approve, machineName, canOpen: options.open !== false && canOpenBrowser(), ...asked.code ? { code: `${asked.code.slice(0, 3)}-${asked.code.slice(3)}` } : {} }
569
572
  });
570
573
  if (options.open !== false) openBrowser(approve);
571
574
  const pollMs = options.pollMs ?? 1500;
@@ -2130,6 +2133,12 @@ async function performHelperStep(page, step, options) {
2130
2133
  }
2131
2134
  return got;
2132
2135
  }
2136
+ var LOAD_CAP_MS = 3e3;
2137
+ var QUIET_CAP_MS = 1e3;
2138
+ async function settleAfterNavigation(page) {
2139
+ await page.waitForLoadState("load", { timeout: LOAD_CAP_MS }).catch(() => void 0);
2140
+ await page.waitForLoadState("networkidle", { timeout: QUIET_CAP_MS }).catch(() => void 0);
2141
+ }
2133
2142
  var secretsBehind = (auth) => [.../* @__PURE__ */ new Set([
2134
2143
  ...Object.values(auth.secretRefs ?? {}),
2135
2144
  ...auth.steps.flatMap((step) => step.secretRef ? [step.secretRef] : [])
@@ -2333,7 +2342,10 @@ var RunActors = class {
2333
2342
  if (switching && this.opts.headless === false) await session.page.bringToFront().catch(() => {
2334
2343
  });
2335
2344
  if (this.initialized.has(id)) return;
2336
- if (startUrl) await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2345
+ if (startUrl) {
2346
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2347
+ await settleAfterNavigation(session.page);
2348
+ }
2337
2349
  if (actor?.startSignedIn) {
2338
2350
  if (kept?.state) {
2339
2351
  const confirmed = await confirmSignedIn(session.page, kept.auth, this.opts.reuseCheckMs ?? 3e3);
@@ -2345,7 +2357,10 @@ var RunActors = class {
2345
2357
  this.opts.reuse?.cache.evict(kept.key);
2346
2358
  this.preparations.set(id, "stale");
2347
2359
  session = await this.replaceWithGuest(id, session);
2348
- await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2360
+ {
2361
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2362
+ await settleAfterNavigation(session.page);
2363
+ }
2349
2364
  }
2350
2365
  await executeStep(
2351
2366
  session.page,
@@ -2377,7 +2392,10 @@ var RunActors = class {
2377
2392
  } catch {
2378
2393
  }
2379
2394
  }
2380
- if (startUrl) await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2395
+ if (startUrl) {
2396
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2397
+ await settleAfterNavigation(session.page);
2398
+ }
2381
2399
  }
2382
2400
  this.initialized.add(id);
2383
2401
  }
@@ -2474,7 +2492,10 @@ var RunActors = class {
2474
2492
  this.retired.add(previous);
2475
2493
  }
2476
2494
  await this.opts.onCreate?.(session);
2477
- await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
2495
+ {
2496
+ await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
2497
+ await settleAfterNavigation(session.page);
2498
+ }
2478
2499
  this.initialized.add(id);
2479
2500
  }
2480
2501
  /** Back to one participant, between debug replays.
@@ -2805,7 +2826,7 @@ var ExecutionPages = class {
2805
2826
  await Promise.all(this.dialogTasks);
2806
2827
  if (this.unexpected) throw new Error(this.unexpected);
2807
2828
  if (this.expectedDialogs.length) throw new Error("The expected browser dialog did not appear");
2808
- if (step.downloads && JSON.stringify(downloads) !== JSON.stringify(step.downloads)) throw new Error(`Expected downloads ${step.downloads.join(", ")}; received ${downloads.join(", ") || "none"}`);
2829
+ if (step.downloads && !downloadsMatch(step.downloads, downloads)) throw new Error(`Expected downloads ${step.downloads.join(", ")}; received ${downloads.join(", ") || "none"}`);
2809
2830
  } finally {
2810
2831
  page.off("download", onDownload);
2811
2832
  this.expectedDialogs = [];
@@ -2820,6 +2841,17 @@ var ExecutionPages = class {
2820
2841
  this.unexpected = null;
2821
2842
  }
2822
2843
  };
2844
+ var shapeOf = (name) => name.toLowerCase().replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "#").replace(/(?<![a-z0-9])[0-9a-f]{12,}(?![a-z0-9])/g, "#").replace(/\d+/g, "#").replace(/#([^a-z]*#)+/g, "#");
2845
+ function downloadsMatch(expected, received) {
2846
+ if (expected.length !== received.length) return false;
2847
+ const left = received.map(shapeOf);
2848
+ for (const name of expected) {
2849
+ const at = left.indexOf(shapeOf(name));
2850
+ if (at === -1) return false;
2851
+ left.splice(at, 1);
2852
+ }
2853
+ return true;
2854
+ }
2823
2855
  function rootFor(page, step) {
2824
2856
  let root = page;
2825
2857
  for (const frame of step.frame ?? []) root = root.frameLocator(frameSelector(frame));
@@ -2899,17 +2931,22 @@ async function scrollUntilFound(page, loc, budgetMs = 3e3) {
2899
2931
  for (let pass = 0; pass < PASSES && Date.now() < giveUpAt; pass++) {
2900
2932
  if (await loc.count() > 0) return true;
2901
2933
  const moved = await page.evaluate(() => {
2902
- const scrollable = [
2903
- document.scrollingElement,
2904
- ...Array.from(document.querySelectorAll("*"))
2905
- ].filter((el) => !!el && el.scrollHeight > el.clientHeight + 4);
2906
- let any = false;
2907
- for (const el of scrollable) {
2934
+ const nudge = (el) => {
2908
2935
  const before = el.scrollTop;
2909
2936
  el.scrollTop = before + Math.max(80, el.clientHeight * 0.8);
2910
- if (el.scrollTop !== before) any = true;
2911
- }
2912
- return any;
2937
+ return el.scrollTop !== before;
2938
+ };
2939
+ const page2 = document.scrollingElement;
2940
+ if (page2 && page2.scrollHeight > page2.clientHeight + 4 && nudge(page2)) return true;
2941
+ const areas = Array.from(document.querySelectorAll("*")).filter((el) => el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 40).map((el) => {
2942
+ const box = el.getBoundingClientRect();
2943
+ const style = getComputedStyle(el);
2944
+ const scrolls = /(auto|scroll|overlay)/.test(style.overflowY);
2945
+ const shown = box.width > 0 && box.height > 0 && box.bottom > 0 && box.top < innerHeight && style.visibility !== "hidden";
2946
+ return { el, area: scrolls && shown ? box.width * box.height : 0 };
2947
+ }).filter((each) => each.area > 0).sort((a, b2) => b2.area - a.area);
2948
+ for (const { el } of areas) if (nudge(el)) return true;
2949
+ return false;
2913
2950
  }).catch(() => false);
2914
2951
  if (!moved) break;
2915
2952
  await page.waitForTimeout(120);
@@ -3422,6 +3459,7 @@ async function runStepsIn(session, steps, opts) {
3422
3459
  try {
3423
3460
  if (opts.startUrl && !opensItsOwnEntry(steps, opts)) {
3424
3461
  await page.goto(opts.startUrl, { waitUntil: "domcontentloaded" });
3462
+ await settleAfterNavigation(page);
3425
3463
  }
3426
3464
  for (const recorded of steps) {
3427
3465
  let step = recorded;
@@ -3625,8 +3663,9 @@ async function runStepsIn(session, steps, opts) {
3625
3663
  // SAID OUT LOUD, on a step that passed. The element was found, but
3626
3664
  // not the way this step says it should be — something has moved,
3627
3665
  // and the run is the only place anybody would learn it.
3628
- onFallback: (_step, foundBy) => {
3666
+ onFallback: (_step, foundBy, renamedTo) => {
3629
3667
  result.foundBy = foundBy;
3668
+ if (renamedTo) result.renamedTo = renamedTo;
3630
3669
  }
3631
3670
  });
3632
3671
  try {
@@ -3815,6 +3854,7 @@ async function executeStep(main, input, options = {}) {
3815
3854
  const remaining = () => Math.max(1, deadline - Date.now());
3816
3855
  const navigationBudget = () => step.navigationTimeoutMs ?? options.navigationTimeoutMs ?? 45e3;
3817
3856
  page.setDefaultTimeout(remaining());
3857
+ page.setDefaultNavigationTimeout(navigationBudget());
3818
3858
  const act = async () => {
3819
3859
  if (step.type === "unsupported") throw new Error(step.warning || step.value || "This interaction requires a supported action before replay");
3820
3860
  if (step.type === "assert") {
@@ -3823,6 +3863,7 @@ async function executeStep(main, input, options = {}) {
3823
3863
  }
3824
3864
  if (step.type === "navigate") {
3825
3865
  await page.goto(new URL(step.value, step.navigationBase === "application" ? options.baseUrl ?? page.url() : page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
3866
+ await settleAfterNavigation(page);
3826
3867
  return;
3827
3868
  }
3828
3869
  if (step.type === "closePage") {
@@ -3852,7 +3893,7 @@ async function executeStep(main, input, options = {}) {
3852
3893
  }
3853
3894
  const target = await ensureTarget(page, step, remaining());
3854
3895
  const loc = target.loc;
3855
- if (target.foundBy) options.onFallback?.(step, target.foundBy);
3896
+ if (target.foundBy) options.onFallback?.(step, target.foundBy, target.renamedTo);
3856
3897
  page.setDefaultTimeout(remaining());
3857
3898
  await applyAction(loc, step, value, { resolveFile: options.resolveFile, dropTarget: await locateDrop(page, step) });
3858
3899
  };
@@ -3904,6 +3945,26 @@ async function applyScroll(page, step, timeout) {
3904
3945
  } catch {
3905
3946
  }
3906
3947
  }
3948
+ async function renamedFrom(page, step, found) {
3949
+ if (!step.role || step.locator) return void 0;
3950
+ try {
3951
+ const first = (await found.ariaSnapshot({ timeout: 1e3 })).split("\n")[0] ?? "";
3952
+ const parsed = /^- ([a-z]+) "((?:[^"\\]|\\.)*)"/.exec(first);
3953
+ if (!parsed || parsed[1] !== step.role) return void 0;
3954
+ const name = JSON.parse(`"${parsed[2]}"`).trim();
3955
+ if (!name || name === step.name || name.length > 200) return void 0;
3956
+ const { nameFrom: _from, candidates: _backups, ...rest } = step;
3957
+ const [again] = await locateCandidates(page, { ...rest, name });
3958
+ if (!again || await again.count() !== 1) return void 0;
3959
+ const handle = await found.elementHandle({ timeout: 1e3 });
3960
+ const same = handle ? await again.evaluate((el, other) => el === other, handle) : false;
3961
+ await handle?.dispose().catch(() => {
3962
+ });
3963
+ return same ? name : void 0;
3964
+ } catch {
3965
+ return void 0;
3966
+ }
3967
+ }
3907
3968
  async function ensureTarget(page, step, timeout) {
3908
3969
  const candidates = await locateCandidates(page, step);
3909
3970
  const described = describeLocators(step);
@@ -3920,7 +3981,10 @@ async function ensureTarget(page, step, timeout) {
3920
3981
  let crowded = null;
3921
3982
  for (let at = 1; at < candidates.length; at += 1) {
3922
3983
  const found = await candidates[at].count().catch(() => 0);
3923
- if (found === 1) return { loc: candidates[at], foundBy: described[at] ?? "another locator" };
3984
+ if (found === 1) {
3985
+ const renamedTo = await renamedFrom(page, step, candidates[at]);
3986
+ return { loc: candidates[at], foundBy: described[at] ?? "another locator", ...renamedTo ? { renamedTo } : {} };
3987
+ }
3924
3988
  if (found > 1 && !crowded) crowded = { where: described[at] ?? "another locator", count: found };
3925
3989
  }
3926
3990
  if (several) throw Object.assign(several, { kind: "ambiguous" });
@@ -5261,6 +5325,45 @@ async function fieldNames(page) {
5261
5325
  return found;
5262
5326
  }).catch(() => []);
5263
5327
  }
5328
+ async function backupsFor(page, step) {
5329
+ if (!step.role || !step.name || step.locator) return [];
5330
+ try {
5331
+ const [primary] = await locateCandidates(page, step);
5332
+ if (!primary || await primary.count() !== 1) return [];
5333
+ return await primary.evaluate((el) => {
5334
+ const clean = (s2) => (s2 ?? "").replace(/\s+/g, " ").trim();
5335
+ const root = el.getRootNode();
5336
+ const unique = (selector) => {
5337
+ try {
5338
+ return root.querySelectorAll(selector).length === 1;
5339
+ } catch {
5340
+ return false;
5341
+ }
5342
+ };
5343
+ const found = [];
5344
+ const testId = el.getAttribute("data-testid");
5345
+ if (testId && unique(`[data-testid=${JSON.stringify(testId)}]`)) found.push({ strategy: "testId", value: testId });
5346
+ const placeholder = el.getAttribute("placeholder");
5347
+ if (clean(placeholder) && unique(`[placeholder=${JSON.stringify(placeholder)}]`)) found.push({ strategy: "placeholder", value: clean(placeholder) });
5348
+ const alt = el.getAttribute("alt");
5349
+ if (clean(alt) && unique(`[alt=${JSON.stringify(alt)}]`)) found.push({ strategy: "altText", value: clean(alt) });
5350
+ const title = el.getAttribute("title");
5351
+ if (clean(title) && unique(`[title=${JSON.stringify(title)}]`)) found.push({ strategy: "title", value: clean(title) });
5352
+ if (el.id && !/:r|[a-f0-9]{12}|\d{6}/i.test(el.id) && unique(`#${CSS.escape(el.id)}`)) {
5353
+ found.push({ strategy: "css", value: `#${CSS.escape(el.id)}` });
5354
+ }
5355
+ return found.slice(0, 4);
5356
+ }, void 0, { timeout: 1e3 });
5357
+ } catch {
5358
+ return [];
5359
+ }
5360
+ }
5361
+ function withBackups(target, backups) {
5362
+ if (!backups.length) return target;
5363
+ const seen = new Set((target.candidates ?? []).map((c) => `${c.strategy}\0${c.value}`));
5364
+ const merged = [...target.candidates ?? [], ...backups.filter((b2) => !seen.has(`${b2.strategy}\0${b2.value}`))].slice(0, 4);
5365
+ return { ...target, candidates: merged };
5366
+ }
5264
5367
  var playwrightVersion2 = () => {
5265
5368
  try {
5266
5369
  return createRequire22(import.meta.url)("playwright/package.json").version;
@@ -6106,8 +6209,18 @@ var SqliteStorage = class {
6106
6209
  ) AS rn FROM run
6107
6210
  ) WHERE rn > ?`
6108
6211
  ).all(keep).map((r) => String(r.id));
6212
+ const dirOf = this.handle.prepare("SELECT artifacts_dir FROM run WHERE id=?");
6109
6213
  const stmt = this.handle.prepare("DELETE FROM run WHERE id=?");
6110
- for (const id of doomed) stmt.run(id);
6214
+ for (const id of doomed) {
6215
+ const dir = dirOf.get(id)?.artifacts_dir;
6216
+ stmt.run(id);
6217
+ if (typeof dir === "string" && dir) {
6218
+ try {
6219
+ fs5.rmSync(dir, { recursive: true, force: true });
6220
+ } catch {
6221
+ }
6222
+ }
6223
+ }
6111
6224
  return doomed;
6112
6225
  });
6113
6226
  }
@@ -6978,11 +7091,12 @@ function totp(secretBase32, at, options = {}) {
6978
7091
  const remaining = period - Math.floor(at / 1e3 % period);
6979
7092
  return { code, remaining };
6980
7093
  }
6981
- async function imapMailbox(config, password) {
7094
+ async function imapMailbox(config, password, target = { host: config.host }) {
6982
7095
  const { ImapFlow } = await import("imapflow");
6983
7096
  const { simpleParser } = await import("mailparser");
6984
7097
  const client = new ImapFlow({
6985
- host: config.host,
7098
+ host: target.host,
7099
+ ...target.servername ? { servername: target.servername } : {},
6986
7100
  port: config.port,
6987
7101
  secure: config.secure,
6988
7102
  auth: { user: config.username, pass: password },
@@ -7041,7 +7155,8 @@ async function runMailbox(config, ctx) {
7041
7155
  subject: fill(config.match.subject, ctx.inputs),
7042
7156
  since: ctx.since ?? null
7043
7157
  };
7044
- const mailbox = await (ctx.openMailbox ?? imapMailbox)(config, secret(ctx, config.passwordRef, "mailbox password"));
7158
+ const password = secret(ctx, config.passwordRef, "mailbox password");
7159
+ const mailbox = ctx.openMailbox ? await ctx.openMailbox(config, password) : await imapMailbox(config, password, ctx.connectTo ? await ctx.connectTo(config.host) : void 0);
7045
7160
  const deadline = now() + config.waitSeconds * 1e3;
7046
7161
  try {
7047
7162
  for (; ; ) {
@@ -7195,6 +7310,7 @@ async function cloudBrowserFor(config, secrets, name) {
7195
7310
  return connectCloudBrowser(account, key, choice, name);
7196
7311
  }
7197
7312
  var LOCAL_CHROMIUM = { id: "chromium", provider: "local", engine: "chromium" };
7313
+ var KEEP_RUNS_LOCALLY = 50;
7198
7314
  async function executeFlow(opts) {
7199
7315
  const { store, storage = null, flowId, trigger, watch = false, onEvent } = opts;
7200
7316
  const headless = watch ? false : opts.headless ?? store.readConfig().headless;
@@ -7431,6 +7547,7 @@ async function executeFlow(opts) {
7431
7547
  artifactsDir: runDir && fs7.existsSync(runDir) ? runDir : null
7432
7548
  });
7433
7549
  }
7550
+ if (storage) void storage.pruneRuns(KEEP_RUNS_LOCALLY).catch(() => void 0);
7434
7551
  await emit({ type: "run.finished", runId: run?.id ?? "", flowId: flow.id, outcome: last });
7435
7552
  return {
7436
7553
  outcome: last,
@@ -7733,6 +7850,7 @@ async function executeCase(opts) {
7733
7850
  masked: false
7734
7851
  });
7735
7852
  }
7853
+ void storage.pruneRuns(50).catch(() => void 0);
7736
7854
  await storage.finishRun(run.id, {
7737
7855
  status: outcome.status === "passed" ? "passed" : outcome.error === "the run was cancelled" ? "cancelled" : outcome.status === "failed" ? "failed" : "errored",
7738
7856
  errorMessage: outcome.error,
@@ -7750,6 +7868,10 @@ function redactPanel(state) {
7750
7868
  return { ...panel, steps: panel.steps.map((s2) => redact(s2)) };
7751
7869
  }
7752
7870
  var START_GRACE_MS = 3e3;
7871
+ var IDLE_MS = (() => {
7872
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
7873
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
7874
+ })();
7753
7875
  var RecordingCoordinator = class {
7754
7876
  constructor(deps) {
7755
7877
  this.deps = deps;
@@ -7773,6 +7895,33 @@ var RecordingCoordinator = class {
7773
7895
  * than rebuilding it from the event, which would either lose the values or
7774
7896
  * publish them. */
7775
7897
  lastResult = null;
7898
+ /** When somebody last did anything in the recording. */
7899
+ touchedAt = 0;
7900
+ panelSeen = "";
7901
+ idleTimer = null;
7902
+ stoppingForIdle = false;
7903
+ touch() {
7904
+ this.touchedAt = Date.now();
7905
+ }
7906
+ watchIdle() {
7907
+ const limit = this.deps.idleMs ?? IDLE_MS;
7908
+ this.stopWatchingIdle();
7909
+ if (limit <= 0) return;
7910
+ this.touch();
7911
+ this.idleTimer = setInterval(() => {
7912
+ if (!this.session || this.stopInFlight || Date.now() - this.touchedAt < limit) return;
7913
+ this.stopWatchingIdle();
7914
+ this.stoppingForIdle = true;
7915
+ void (this.draft?.steps.length ? this.stop() : this.discard()).finally(() => {
7916
+ this.stoppingForIdle = false;
7917
+ });
7918
+ }, Math.min(this.deps.idleCheckMs ?? 6e4, limit));
7919
+ this.idleTimer.unref?.();
7920
+ }
7921
+ stopWatchingIdle() {
7922
+ if (this.idleTimer) clearInterval(this.idleTimer);
7923
+ this.idleTimer = null;
7924
+ }
7776
7925
  /** What the last completed stop answered, for a caller that missed it —
7777
7926
  * a dropped socket, or a Stop pressed inside the recorded page. */
7778
7927
  lastStop() {
@@ -7796,6 +7945,7 @@ var RecordingCoordinator = class {
7796
7945
  }
7797
7946
  async answerDialog(accept, promptText) {
7798
7947
  if (!this.session) throw new Error("No recording is active");
7948
+ this.touch();
7799
7949
  await this.session.answerDialog(accept, promptText);
7800
7950
  }
7801
7951
  /** Open a headed browser and start capturing.
@@ -7871,18 +8021,29 @@ var RecordingCoordinator = class {
7871
8021
  // a file nothing ever read.
7872
8022
  environment: ExecutionEnvironmentSchema.parse(opts.environment ?? config.environment),
7873
8023
  headless: opts.headless ?? false,
7874
- onStep: (step) => this.upsert(step),
8024
+ onStep: (step) => {
8025
+ this.touch();
8026
+ this.upsert(step);
8027
+ },
7875
8028
  // The panel carries its OWN copy of every step, and it used to be
7876
8029
  // emitted exactly as the session built it — so while `recording.step`
7877
8030
  // was carefully redacted, the very next frame published the same step
7878
8031
  // with the password still in it. Found by a test that typed a real
7879
8032
  // password into a real browser; nothing about the redacted step event
7880
8033
  // hinted at it.
7881
- onState: (state) => this.deps.emit({ type: "recording.panel", state: redactPanel(state) }),
8034
+ onState: (state) => {
8035
+ const seen = JSON.stringify(state);
8036
+ if (seen !== this.panelSeen) {
8037
+ this.panelSeen = seen;
8038
+ this.touch();
8039
+ }
8040
+ this.deps.emit({ type: "recording.panel", state: redactPanel(state) });
8041
+ },
7882
8042
  // Stop pressed on the floating control inside the recorded page.
7883
8043
  onStopRequested: (commit) => void (commit ? this.stop() : this.discard()),
7884
8044
  onDisconnect: () => {
7885
8045
  this.session = null;
8046
+ this.stopWatchingIdle();
7886
8047
  this.deps.lease?.release("recording");
7887
8048
  this.deps.emit({ type: "recording.interrupted" });
7888
8049
  }
@@ -7898,6 +8059,7 @@ var RecordingCoordinator = class {
7898
8059
  const projectOrigin = configuredBase ? new URL(normalizeStartUrl(configuredBase)).origin : "";
7899
8060
  this.draft.baseUrl = opened.origin === projectOrigin ? "" : opened.origin;
7900
8061
  this.draft.startPath = opened.pathname + opened.search;
8062
+ this.watchIdle();
7901
8063
  this.deps.emit({ type: "recording.started", name: this.draft.name, url: this.session.startUrl });
7902
8064
  return null;
7903
8065
  }
@@ -7921,6 +8083,7 @@ var RecordingCoordinator = class {
7921
8083
  * followed by an await, so two stops arriving together could otherwise both
7922
8084
  * pass it and save the flow twice. */
7923
8085
  stop() {
8086
+ this.stopWatchingIdle();
7924
8087
  if (!this.stopInFlight) {
7925
8088
  this.deps.lease?.beginRelease("recording");
7926
8089
  this.stopInFlight = this.doStop().finally(() => {
@@ -7969,7 +8132,7 @@ var RecordingCoordinator = class {
7969
8132
  const file2 = this.deps.store.saveFlow(doc);
7970
8133
  this.draft = null;
7971
8134
  const saved = { ...this.deps.store.getFlow(doc.id) ?? doc, path: file2 };
7972
- this.deps.emit({ type: "recording.stopped", flow: saved, file: file2 });
8135
+ this.deps.emit({ type: "recording.stopped", flow: saved, file: file2, ...this.stoppingForIdle ? { reason: "idle" } : {} });
7973
8136
  return this.lastResult = { ok: true, flow: saved, secrets: captured };
7974
8137
  }
7975
8138
  /** Throw the recording away: close the browser, drop the draft, write
@@ -7980,6 +8143,7 @@ var RecordingCoordinator = class {
7980
8143
  * time — a password briefly reaches disk for a recording nobody wanted. */
7981
8144
  async discard() {
7982
8145
  if (!this.draft && !this.session) return { ok: false, error: "no recording in progress" };
8146
+ this.stopWatchingIdle();
7983
8147
  this.deps.lease?.beginRelease("recording");
7984
8148
  const ending = this.session;
7985
8149
  this.session = null;
@@ -8122,15 +8286,41 @@ var COMMANDS = /* @__PURE__ */ new Set([
8122
8286
  ]);
8123
8287
  var UnknownDebugCommand = class extends Error {
8124
8288
  };
8289
+ var DEBUG_IDLE_MS = (() => {
8290
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
8291
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
8292
+ })();
8125
8293
  var DebugCoordinator = class {
8126
8294
  constructor(deps) {
8127
8295
  this.deps = deps;
8128
8296
  }
8129
8297
  deps;
8298
+ touchedAt = 0;
8299
+ stateSeen = "";
8300
+ /** The steps as the session opened them: unchanged means nothing to lose. */
8301
+ openedWith = "";
8302
+ idleTimer = null;
8130
8303
  retainCompleted = false;
8131
8304
  lastState = null;
8132
8305
  lastEnd = null;
8133
8306
  current = null;
8307
+ watchIdle() {
8308
+ this.stopWatchingIdle();
8309
+ const limit = this.deps.idleMs ?? DEBUG_IDLE_MS;
8310
+ if (limit <= 0) return;
8311
+ this.touchedAt = Date.now();
8312
+ this.idleTimer = setInterval(() => {
8313
+ if (!this.current || Date.now() - this.touchedAt < limit) return;
8314
+ if (JSON.stringify(this.current.session.state().steps) !== this.openedWith) return;
8315
+ this.stopWatchingIdle();
8316
+ void this.end(false).catch(() => void 0);
8317
+ }, Math.min(this.deps.idleCheckMs ?? 6e4, limit));
8318
+ this.idleTimer.unref?.();
8319
+ }
8320
+ stopWatchingIdle() {
8321
+ if (this.idleTimer) clearInterval(this.idleTimer);
8322
+ this.idleTimer = null;
8323
+ }
8134
8324
  status() {
8135
8325
  return this.current ? { flowId: this.current.flowId, state: this.current.session.state() } : this.lastEnd?.ok && this.lastEnd.flow && this.lastState ? { flowId: this.lastEnd.flow.id, state: this.lastState } : { flowId: null, state: null };
8136
8326
  }
@@ -8169,11 +8359,19 @@ var DebugCoordinator = class {
8169
8359
  // Secrets are read on THIS machine and handed to the browser here. A
8170
8360
  // hosted dashboard drives the session and never learns a value.
8171
8361
  resolveSecret: (ref) => secrets[ref],
8172
- onState: (state2) => this.deps.emit({ type: "debug.state", flowId: flow.id, state: state2 }),
8362
+ onState: (state2) => {
8363
+ const seen = JSON.stringify(state2);
8364
+ if (seen !== this.stateSeen) {
8365
+ this.stateSeen = seen;
8366
+ this.touchedAt = Date.now();
8367
+ }
8368
+ this.deps.emit({ type: "debug.state", flowId: flow.id, state: state2 });
8369
+ },
8173
8370
  // Save & close / Discard, pressed on the panel inside the page.
8174
8371
  onEndRequested: (commit) => void this.end(commit),
8175
8372
  onDisconnect: () => {
8176
8373
  this.current = null;
8374
+ this.stopWatchingIdle();
8177
8375
  this.deps.lease?.release("debug");
8178
8376
  this.deps.emit({ type: "debug.ended", flowId: flow.id, committed: false });
8179
8377
  }
@@ -8181,6 +8379,8 @@ var DebugCoordinator = class {
8181
8379
  this.deps.lease?.activate("debug");
8182
8380
  this.current = { flowId: flow.id, session };
8183
8381
  const state = session.state();
8382
+ this.openedWith = JSON.stringify(state.steps);
8383
+ this.watchIdle();
8184
8384
  this.deps.emit({ type: "debug.state", flowId: flow.id, state });
8185
8385
  return { flowId: flow.id, state };
8186
8386
  } catch (err) {
@@ -8191,6 +8391,7 @@ var DebugCoordinator = class {
8191
8391
  async command(command, args = {}) {
8192
8392
  if (!this.current) throw new Error("no debug session is running");
8193
8393
  if (!COMMANDS.has(command)) throw new UnknownDebugCommand(`unknown command: ${command}`);
8394
+ this.touchedAt = Date.now();
8194
8395
  const s2 = this.current.session;
8195
8396
  switch (command) {
8196
8397
  case "play":
@@ -8233,6 +8434,7 @@ var DebugCoordinator = class {
8233
8434
  }
8234
8435
  const { flowId, session } = this.current;
8235
8436
  this.current = null;
8437
+ this.stopWatchingIdle();
8236
8438
  this.deps.lease?.beginRelease("debug");
8237
8439
  try {
8238
8440
  if (!commit) {
@@ -8388,7 +8590,7 @@ function resolveCheckouts(dirs) {
8388
8590
  // src/engine/session.ts
8389
8591
  import fs12 from "fs";
8390
8592
 
8391
- // ../agent-core/dist/chunk-YAPHJ4AT.js
8593
+ // ../agent-core/dist/chunk-CRKFBFSC.js
8392
8594
  import { WebSocket, WebSocketServer } from "ws";
8393
8595
  var HEARTBEAT_MS = 3e4;
8394
8596
  function adapt(socket) {
@@ -8565,6 +8767,27 @@ var EventJournal = class {
8565
8767
  }
8566
8768
  };
8567
8769
  var { DatabaseSync } = createRequire4(import.meta.url)("node:sqlite");
8770
+ var LOCAL_LIMIT = 64 * 1024;
8771
+ function fitted(value, limit = LOCAL_LIMIT) {
8772
+ const text = JSON.stringify(value);
8773
+ if (Buffer.byteLength(text) <= limit) return text;
8774
+ for (const [maxText, maxItems] of [[2e3, 400], [500, 120], [200, 40], [80, 12]]) {
8775
+ const smaller = JSON.stringify(shrink(value, maxText, maxItems));
8776
+ if (Buffer.byteLength(smaller) <= limit) return smaller;
8777
+ }
8778
+ throw new Error("event exceeds local size limit even when shortened");
8779
+ }
8780
+ function shrink(value, maxText, maxItems) {
8781
+ if (typeof value === "string") return value.length > maxText ? `${value.slice(0, maxText)}\u2026 (cut)` : value;
8782
+ if (Array.isArray(value)) {
8783
+ const items = value.length > maxItems ? [...value.slice(0, Math.ceil(maxItems / 2)), `\u2026 ${value.length - maxItems} left out`, ...value.slice(-Math.floor(maxItems / 2))] : value;
8784
+ return items.map((v) => shrink(v, maxText, maxItems));
8785
+ }
8786
+ if (value && typeof value === "object") {
8787
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shrink(v, maxText, maxItems)]));
8788
+ }
8789
+ return value;
8790
+ }
8568
8791
  var AgentState = class {
8569
8792
  constructor(file2, deviceId, commandLimit = 1e5) {
8570
8793
  this.commandLimit = commandLimit;
@@ -8635,6 +8858,10 @@ var AgentState = class {
8635
8858
  begin(command, fingerprint2) {
8636
8859
  return this.transaction(() => {
8637
8860
  if (this.get(command.id)) return false;
8861
+ if (Number(this.db.prepare("SELECT count(*) AS n FROM commands").get()?.n) >= this.commandLimit) {
8862
+ const cutoff = (command.issuedAt ?? command.expiresAt) - 30 * 24 * 60 * 6e4;
8863
+ if (cutoff > 0) this.db.prepare("DELETE FROM commands WHERE state IN ('completed','retired') AND finished_at < ?").run(cutoff);
8864
+ }
8638
8865
  if (Number(this.db.prepare("SELECT count(*) AS n FROM commands").get()?.n) >= this.commandLimit) {
8639
8866
  throw new Error("command ledger capacity reached; retain state and reconcile before further work");
8640
8867
  }
@@ -8643,8 +8870,7 @@ var AgentState = class {
8643
8870
  });
8644
8871
  }
8645
8872
  finish(id, ack, now) {
8646
- const text = JSON.stringify(AckSchema.parse(ack));
8647
- if (Buffer.byteLength(text) > 64 * 1024) throw new Error("command result exceeds local size limit");
8873
+ const text = fitted(AckSchema.parse(ack));
8648
8874
  const result = this.db.prepare("UPDATE commands SET state='completed', ack=?, finished_at=? WHERE id=? AND state='started'").run(text, now, id);
8649
8875
  if (Number(result.changes) !== 1) throw new Error("command intent is unavailable");
8650
8876
  }
@@ -8657,8 +8883,7 @@ var AgentState = class {
8657
8883
  this.db.prepare("UPDATE commands SET state='retired', ack=NULL WHERE state='completed' AND finished_at < ? AND expires_at <= ?").run(cutoff, now);
8658
8884
  }
8659
8885
  append(event, limit) {
8660
- const text = JSON.stringify(event);
8661
- if (Buffer.byteLength(text) > 64 * 1024) throw new Error("event exceeds local size limit");
8886
+ const text = fitted(event);
8662
8887
  return this.transaction(() => {
8663
8888
  const row = this.db.prepare("INSERT INTO events(payload) VALUES (?)").run(text);
8664
8889
  const seq = Number(row.lastInsertRowid) - 1;
@@ -8687,6 +8912,13 @@ var AgentState = class {
8687
8912
  this.db.close();
8688
8913
  }
8689
8914
  };
8915
+ function withoutSecrets(value) {
8916
+ if (Array.isArray(value)) return value.map(withoutSecrets);
8917
+ if (value && typeof value === "object") {
8918
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, k === "secrets" ? {} : withoutSecrets(v)]));
8919
+ }
8920
+ return value;
8921
+ }
8690
8922
  var Agent = class {
8691
8923
  constructor(opts) {
8692
8924
  this.opts = opts;
@@ -8824,7 +9056,7 @@ var Agent = class {
8824
9056
  ack = this.refuse(command.id, "failed", String(err instanceof Error ? err.message : err).split("\n")[0]);
8825
9057
  }
8826
9058
  if (EFFECTFUL.has(command.name)) {
8827
- this.state.finish(command.id, ack, this.now());
9059
+ this.state.finish(command.id, withoutSecrets(ack), this.now());
8828
9060
  }
8829
9061
  return ack;
8830
9062
  }
@@ -9859,6 +10091,12 @@ var runFile = (file2, args, timeoutMs) => new Promise((resolve2, reject) => {
9859
10091
  });
9860
10092
  var live = /* @__PURE__ */ new Map();
9861
10093
  var liveById = (sessionId) => [...live.values()].find((l) => l.id === sessionId);
10094
+ var IDLE_MS2 = (() => {
10095
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
10096
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
10097
+ })();
10098
+ var parked = /* @__PURE__ */ new Map();
10099
+ var PARKED_FOR_MS = 24 * 60 * 6e4;
9862
10100
  var ownerOf = (l) => l.draft ? "recording" : "debug";
9863
10101
  var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|\bpin\b|cvv|cvc/i;
9864
10102
  async function screenOf(l, withImage = false) {
@@ -9886,12 +10124,31 @@ async function screenOf(l, withImage = false) {
9886
10124
  device: l.session.deviceName
9887
10125
  };
9888
10126
  }
10127
+ async function closeIdlePhoneSessions(release, limit = IDLE_MS2, now = Date.now()) {
10128
+ for (const [id, kept] of parked) if (now - kept.at > PARKED_FOR_MS) parked.delete(id);
10129
+ if (limit <= 0) return;
10130
+ for (const l of [...live.values()]) {
10131
+ if (now - l.touchedAt < limit) continue;
10132
+ live.delete(l.platform);
10133
+ release(ownerOf(l), l.platform);
10134
+ await l.session.close().catch(() => void 0);
10135
+ if (l.draft?.steps.length) parked.set(l.id, { l, at: now });
10136
+ }
10137
+ }
9889
10138
  function mobileHandlers(ctx) {
9890
10139
  const require2 = (command) => {
9891
10140
  const l = liveById(command.args["sessionId"]);
9892
- if (!l) throw new Error("that phone session has ended \u2014 open it again");
9893
- return l;
10141
+ if (l) {
10142
+ l.touchedAt = Date.now();
10143
+ return l;
10144
+ }
10145
+ if (parked.has(String(command.args["sessionId"]))) {
10146
+ throw new Error("this phone was let go after a long time with nothing done \u2014 press Stop to save what was recorded");
10147
+ }
10148
+ throw new Error("that phone session has ended \u2014 open it again");
9894
10149
  };
10150
+ const sweeper = setInterval(() => void closeIdlePhoneSessions((owner, platform) => ctx.lease?.release(owner, platform)), 6e4);
10151
+ sweeper.unref?.();
9895
10152
  return {
9896
10153
  // Everything this computer has for testing — web and phones — for the Guide.
9897
10154
  ...ctx.deps.machineCheck ? {
@@ -9965,6 +10222,7 @@ function mobileHandlers(ctx) {
9965
10222
  platform,
9966
10223
  values: /* @__PURE__ */ new Map(),
9967
10224
  typed,
10225
+ touchedAt: Date.now(),
9968
10226
  resolveSecret: (ref) => typed.get(ref) ?? secrets[ref],
9969
10227
  resolveAuth: authenticationResolver(command.args["authentications"]),
9970
10228
  runHelper: helperRunner(command.args["helpers"], secrets)
@@ -9994,6 +10252,12 @@ function mobileHandlers(ctx) {
9994
10252
  }
9995
10253
  return { ok: true, result: { sessionId: l2.id, screen: await screenOf(l2) } };
9996
10254
  }
10255
+ const resting = parked.get(String(command.args["sessionId"]));
10256
+ if (resting && (op === "stop" || op === "discard")) {
10257
+ parked.delete(String(command.args["sessionId"]));
10258
+ if (op === "discard") return { ok: true, result: { ended: true } };
10259
+ return { ok: true, result: finished(resting.l) };
10260
+ }
9997
10261
  const l = require2(command);
9998
10262
  if (op === "act" || op === "run") {
9999
10263
  const raw = command.args["step"] ?? {};
@@ -10069,24 +10333,7 @@ function mobileHandlers(ctx) {
10069
10333
  ctx.lease?.release(ownerOf(l), l.platform);
10070
10334
  await l.session.close().catch(() => void 0);
10071
10335
  if (op === "discard" || !l.draft) return { ok: true, result: { ended: true } };
10072
- const now = (/* @__PURE__ */ new Date()).toISOString();
10073
- const d = l.draft;
10074
- const flow = {
10075
- schemaVersion: CURRENT_SCHEMA_VERSION,
10076
- id: `flw_${randomUUID2().replace(/-/g, "").slice(0, 12)}`,
10077
- featureId: d.featureId,
10078
- name: d.name,
10079
- slug: slugify(d.name) || "flow",
10080
- startPath: d.startPath,
10081
- platform: l.platform,
10082
- recordedAt: now,
10083
- recordedBy: d.recordedBy,
10084
- updatedAt: now,
10085
- revision: 0,
10086
- steps: d.steps,
10087
- ...d.signIn ? { signIn: d.signIn } : {}
10088
- };
10089
- return { ok: true, result: { flow, secrets: Object.fromEntries(l.typed) } };
10336
+ return { ok: true, result: finished(l) };
10090
10337
  }
10091
10338
  return { ok: false, reason: "failed", detail: `unknown phone session operation "${op}"` };
10092
10339
  } catch (error) {
@@ -10096,6 +10343,26 @@ function mobileHandlers(ctx) {
10096
10343
  }
10097
10344
  };
10098
10345
  }
10346
+ function finished(l) {
10347
+ const now = (/* @__PURE__ */ new Date()).toISOString();
10348
+ const d = l.draft;
10349
+ const flow = {
10350
+ schemaVersion: CURRENT_SCHEMA_VERSION,
10351
+ id: `flw_${randomUUID2().replace(/-/g, "").slice(0, 12)}`,
10352
+ featureId: d.featureId,
10353
+ name: d.name,
10354
+ slug: slugify(d.name) || "flow",
10355
+ startPath: d.startPath,
10356
+ platform: l.platform,
10357
+ recordedAt: now,
10358
+ recordedBy: d.recordedBy,
10359
+ updatedAt: now,
10360
+ revision: 0,
10361
+ steps: d.steps,
10362
+ ...d.signIn ? { signIn: d.signIn } : {}
10363
+ };
10364
+ return { flow, secrets: Object.fromEntries(l.typed) };
10365
+ }
10099
10366
  function buildHandlers(deps) {
10100
10367
  const context = {
10101
10368
  deps,
@@ -10154,6 +10421,20 @@ function toolArgs(tool, input) {
10154
10421
  if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
10155
10422
  }
10156
10423
  if (record["newBranch"] === true) out["newBranch"] = true;
10424
+ if (typeof record["case"] === "string" && record["case"]) out["case"] = clip(record["case"], 40);
10425
+ if (Array.isArray(record["features"])) {
10426
+ const cases = [];
10427
+ for (const feature of record["features"]) {
10428
+ if (!feature || typeof feature !== "object") continue;
10429
+ const f = feature;
10430
+ const folder = typeof f["feature"] === "string" ? f["feature"] : typeof f["name"] === "string" ? f["name"] : "";
10431
+ for (const c of Array.isArray(f["cases"]) ? f["cases"] : []) {
10432
+ const name = c && typeof c === "object" ? c["name"] : void 0;
10433
+ if (typeof name === "string" && name && cases.length < 40) cases.push({ folder: clip(folder), name: clip(name) });
10434
+ }
10435
+ }
10436
+ if (cases.length) out["cases"] = cases;
10437
+ }
10157
10438
  if (tool === "add_check" && typeof record["value"] === "string") out["value"] = clip(record["value"], 120);
10158
10439
  if (Array.isArray(record["after"])) {
10159
10440
  out["after"] = record["after"].filter((v) => typeof v === "string").slice(0, 5).map((v) => clip(v));
@@ -10185,13 +10466,65 @@ var ASK_MODE_TOOLS = [
10185
10466
  "list_my_branches",
10186
10467
  "run_insights"
10187
10468
  ];
10469
+ var NOT_RECORDING = [
10470
+ "create_project",
10471
+ "list_projects",
10472
+ "read_import_file",
10473
+ "save_import_cases",
10474
+ "read_update_request",
10475
+ "propose_case_updates",
10476
+ "write_test_cases",
10477
+ "gather_branches",
10478
+ "discard_branch"
10479
+ ];
10480
+ var NOT_SAVING_TESTS = [
10481
+ "save_flow",
10482
+ "save_case",
10483
+ "preview_case",
10484
+ "start_case",
10485
+ "continue_flow",
10486
+ "remove_step",
10487
+ "replay_flow",
10488
+ "add_check",
10489
+ "use_helper",
10490
+ "create_sign_in",
10491
+ "create_account",
10492
+ "create_helper",
10493
+ "report_blocker"
10494
+ ];
10495
+ var JOB_TOOLS_EXCEPT = {
10496
+ heal: [...NOT_RECORDING, "create_sign_in", "create_account"],
10497
+ automate: NOT_RECORDING,
10498
+ reautomate: NOT_RECORDING,
10499
+ update: NOT_RECORDING.filter((t) => t !== "read_update_request" && t !== "propose_case_updates"),
10500
+ explore: [...NOT_RECORDING, ...NOT_SAVING_TESTS],
10501
+ // Writing cases may still set up a sign-in it finds missing.
10502
+ write: [
10503
+ "create_project",
10504
+ "list_projects",
10505
+ "gather_branches",
10506
+ "discard_branch",
10507
+ "read_update_request",
10508
+ "propose_case_updates",
10509
+ ...NOT_SAVING_TESTS.filter((t) => !t.startsWith("create_"))
10510
+ ]
10511
+ };
10512
+ function toolsExceptFor(prompt, briefGiven) {
10513
+ const except = [...JOB_TOOLS_EXCEPT[usageKindOf(prompt)] ?? []];
10514
+ if (briefGiven) except.push("project_brief");
10515
+ return except;
10516
+ }
10188
10517
  function buildAskArgs(opts) {
10189
10518
  const ask = opts.mode === "ask";
10190
10519
  const folders = opts.folders ?? [];
10191
10520
  const brief = opts.brief?.trim();
10192
10521
  const guidance = [
10193
- `You are the Flowriki assistant for the project "${opts.projectName}" (id ${opts.projectId}). Use the Flowriki tools, and pass projectId "${opts.projectId}" wherever a tool takes one. ` + (brief ? "The project brief below is this project's memory for this job: read it first." : "Start with project_brief: it is this project's memory."),
10194
- ask ? "ASK MODE: answer the question; change nothing. Cite what you rely on as [[testCase:<its id, tc_\u2026>]], [[knowledge:features/checkout.md]] or [[file:path/in/repo.ts]]." : "DO MODE: do what is asked, on Claude's working branch, for a person to review. Ask in the chat and wait before publishing, deleting, or sending anything to another system. Work quietly: the person sees what you build and your final answer, so keep words between tool calls to a minimum and end with a short result \u2014 what was saved, and anything that needs them. When you learned something about the app the next job should know (a screen, a rule, a quirk, what failed and why), write it with write_knowledge (memory/sessions/<date>-<topic>.md, append) \u2014 Flowriki keeps it and gives it to the next job.",
10522
+ `You are the Flowriki assistant for the project "${opts.projectName}" (id ${opts.projectId}). Use the Flowriki tools, and pass projectId "${opts.projectId}" wherever a tool takes one. ` + (brief ? "The project brief below IS project_brief for this job \u2014 do not call project_brief." : "Start with project_brief: it is this project's memory."),
10523
+ ask ? "ASK MODE: answer the question; change nothing. Cite what you rely on as [[testCase:<its id, tc_\u2026>]], [[knowledge:features/checkout.md]] or [[file:path/in/repo.ts]]." : "DO MODE: do what is asked, on Claude's working branch, for a person to review. Ask in the chat and wait before publishing, deleting, or sending anything to another system. Work quietly: the person sees what you build and your final answer, so keep words between tool calls to a minimum and end with a short result \u2014 what was saved, and anything that needs them. Only when you learned something about the app that the brief does not already say and the next job needs (a screen, a rule, a quirk, what failed and why), write it with write_knowledge (memory/sessions/<date>-<topic>.md, append) \u2014 briefly.",
10524
+ // SPENDING. Every turn re-reads the whole conversation, so turns are the
10525
+ // cost: do several steps per act call, don't re-read a page a step already
10526
+ // returned, and stop instead of retrying the same failing step.
10527
+ ask ? "" : "Spend turns carefully: act takes several steps at once; a step's answer already shows the page \u2014 read_page only when it did not; after 3 failed tries at the same step, report_blocker and move on.",
10195
10528
  "Use only the tools you have: a tool is used by calling it, not by writing the call out as text. If Flowriki's tools are missing, say so in one sentence and stop.",
10196
10529
  folders.length ? `The project's code is in ${folders.join(" and ")}. Read it with Read, Grep and Glob when it helps; you cannot change it.` : "",
10197
10530
  brief ? `
@@ -10221,9 +10554,25 @@ ${brief}` : ""
10221
10554
  "dontAsk",
10222
10555
  "--append-system-prompt",
10223
10556
  guidance,
10557
+ // THE MODEL, SAID — not whatever the person's default is. Recording and
10558
+ // automating tests is what Sonnet does well; a default of Opus costs
10559
+ // several times as much for the same tests. FLOWRIKI_ASSISTANT_MODEL
10560
+ // overrides it.
10561
+ "--model",
10562
+ opts.model ?? (process.env["FLOWRIKI_ASSISTANT_MODEL"]?.trim() || "sonnet"),
10563
+ // A ceiling, not a target: a job that has taken this many turns is going
10564
+ // round in circles. What it saved stays; "continue" carries on.
10565
+ "--max-turns",
10566
+ String(opts.maxTurns ?? (ask ? 40 : 160)),
10224
10567
  ...opts.sessionId ? ["--resume", opts.sessionId] : []
10225
10568
  ];
10226
10569
  }
10570
+ var tokensOf = (u) => ({
10571
+ input: u.input_tokens ?? 0,
10572
+ output: u.output_tokens ?? 0,
10573
+ cacheRead: u.cache_read_input_tokens ?? 0,
10574
+ cacheWrite: u.cache_creation_input_tokens ?? 0
10575
+ });
10227
10576
  function clearOld(dir) {
10228
10577
  try {
10229
10578
  for (const name of fs43.readdirSync(dir)) {
@@ -10248,11 +10597,13 @@ function eventsFromLine(askId, line) {
10248
10597
  if (parsed.session_id) out.push({ name: "assistant.session", data: { askId, sessionId: parsed.session_id } });
10249
10598
  }
10250
10599
  if (parsed.type === "assistant") {
10600
+ const tools = [];
10251
10601
  for (const block of parsed.message?.content ?? []) {
10252
10602
  if (block.type === "text" && block.text?.trim()) {
10253
10603
  out.push({ name: "assistant.text", data: { askId, text: block.text } });
10254
10604
  } else if (block.type === "tool_use" && block.name) {
10255
10605
  const tool = block.name.replace(TOOL_PREFIX, "");
10606
+ tools.push(tool);
10256
10607
  out.push({
10257
10608
  name: "assistant.tool",
10258
10609
  data: {
@@ -10265,6 +10616,12 @@ function eventsFromLine(askId, line) {
10265
10616
  });
10266
10617
  }
10267
10618
  }
10619
+ if (parsed.message?.usage && parsed.message.id) {
10620
+ out.push({
10621
+ name: "assistant.usage",
10622
+ data: { askId, messageId: parsed.message.id, tools, ...tokensOf(parsed.message.usage), ...parsed.message.model ? { model: parsed.message.model } : {} }
10623
+ });
10624
+ }
10268
10625
  }
10269
10626
  if (parsed.type === "user") {
10270
10627
  for (const block of parsed.message?.content ?? []) {
@@ -10278,6 +10635,20 @@ function eventsFromLine(askId, line) {
10278
10635
  });
10279
10636
  }
10280
10637
  }
10638
+ if (parsed.type === "rate_limit_event" && parsed.rate_limit_info) {
10639
+ const info = parsed.rate_limit_info;
10640
+ out.push({
10641
+ name: "assistant.limits",
10642
+ data: {
10643
+ askId,
10644
+ ...typeof info.status === "string" ? { status: info.status } : {},
10645
+ ...typeof info.rateLimitType === "string" ? { window: info.rateLimitType } : {},
10646
+ ...typeof info.utilization === "number" ? { utilization: info.utilization } : {},
10647
+ ...typeof info.resetsAt === "number" ? { resetsAt: info.resetsAt } : {},
10648
+ ...info.isUsingOverage === true ? { overage: true } : {}
10649
+ }
10650
+ });
10651
+ }
10281
10652
  if (parsed.type === "result") {
10282
10653
  out.push({
10283
10654
  name: "assistant.done",
@@ -10285,12 +10656,24 @@ function eventsFromLine(askId, line) {
10285
10656
  askId,
10286
10657
  ok: parsed.is_error !== true,
10287
10658
  result: typeof parsed.result === "string" ? parsed.result : "",
10288
- ...parsed.session_id ? { sessionId: parsed.session_id } : {}
10659
+ ...parsed.session_id ? { sessionId: parsed.session_id } : {},
10660
+ // The whole job, as the model counted it.
10661
+ ...parsed.usage ? { usage: tokensOf(parsed.usage) } : {},
10662
+ ...typeof parsed.total_cost_usd === "number" ? { costUsd: parsed.total_cost_usd } : {},
10663
+ ...typeof parsed.num_turns === "number" ? { turns: parsed.num_turns } : {},
10664
+ ...typeof parsed.duration_ms === "number" ? { durationMs: parsed.duration_ms } : {}
10289
10665
  }
10290
10666
  });
10291
10667
  }
10292
10668
  return out;
10293
10669
  }
10670
+ function stop(child) {
10671
+ child.kill("SIGTERM");
10672
+ const force = setTimeout(() => {
10673
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
10674
+ }, 5e3);
10675
+ force.unref?.();
10676
+ }
10294
10677
  function createClaudeCli(options) {
10295
10678
  const spawn2 = options.spawn ?? nodeSpawn;
10296
10679
  const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
@@ -10298,7 +10681,18 @@ function createClaudeCli(options) {
10298
10681
  const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
10299
10682
  const running = /* @__PURE__ */ new Map();
10300
10683
  const MAX_AT_ONCE = 3;
10301
- let cancelled = null;
10684
+ process.once("exit", () => {
10685
+ for (const child of running.values()) {
10686
+ try {
10687
+ child.kill("SIGTERM");
10688
+ } catch {
10689
+ }
10690
+ }
10691
+ });
10692
+ const cancelled = /* @__PURE__ */ new Set();
10693
+ const retrying = /* @__PURE__ */ new Set();
10694
+ const sessionsInUse = /* @__PURE__ */ new Set();
10695
+ const occupied = () => running.size + retrying.size;
10302
10696
  function capture(args, timeoutMs = 15e3) {
10303
10697
  return new Promise((resolve2) => {
10304
10698
  let out = "", err = "", settled = false;
@@ -10328,8 +10722,8 @@ function createClaudeCli(options) {
10328
10722
  child.on("close", (code) => finish(code, false));
10329
10723
  });
10330
10724
  }
10331
- const LANE_FRESH_MS = 60 * 6e4;
10332
- const LANE_MAX_TOKENS = 15e4;
10725
+ const LANE_FRESH_MS = 5 * 6e4;
10726
+ const LANE_MAX_TOKENS = 4e4;
10333
10727
  const laneFile = (dir) => path42.join(dir, "lane-sessions.json");
10334
10728
  const readLanes = (dir) => {
10335
10729
  try {
@@ -10355,6 +10749,8 @@ function createClaudeCli(options) {
10355
10749
  }
10356
10750
  const kept = readLanes(dir)[ask.platform];
10357
10751
  if (!kept || Date.now() - kept.updatedAt > LANE_FRESH_MS || kept.tokens > LANE_MAX_TOKENS) return void 0;
10752
+ if (sessionsInUse.has(kept.sessionId)) return void 0;
10753
+ if ((kept.kind ?? "chat") !== usageKindOf(ask.prompt)) return void 0;
10358
10754
  return kept.sessionId;
10359
10755
  }
10360
10756
  return {
@@ -10375,10 +10771,10 @@ function createClaudeCli(options) {
10375
10771
  } catch {
10376
10772
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10377
10773
  }
10378
- let finished = false;
10774
+ let finished2 = false;
10379
10775
  const done = async (detail) => {
10380
- if (finished) return;
10381
- finished = true;
10776
+ if (finished2) return;
10777
+ finished2 = true;
10382
10778
  const status2 = await this.status();
10383
10779
  emit("assistant.connect.finished", { signedIn: status2.signedIn, ...detail ? { detail } : {} });
10384
10780
  };
@@ -10398,31 +10794,49 @@ function createClaudeCli(options) {
10398
10794
  return { started: true };
10399
10795
  },
10400
10796
  ask(ask) {
10401
- 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.` };
10797
+ if (occupied() >= 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.` };
10402
10798
  fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
10403
10799
  const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
10404
10800
  fs43.mkdirSync(dir, { recursive: true, mode: 448 });
10405
10801
  clearOld(dir);
10406
10802
  const mcpConfigPath = path42.join(dir, `mcp-${ask.askId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
10803
+ const session = laneSession(ask, dir);
10804
+ const except = ask.mode === "ask" ? [] : toolsExceptFor(ask.prompt, Boolean(ask.brief && !session));
10407
10805
  fs43.writeFileSync(
10408
10806
  mcpConfigPath,
10409
- JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args, env: { FLOWRIKI_ASK_ID: ask.askId } } } }),
10807
+ JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args, env: {
10808
+ FLOWRIKI_ASK_ID: ask.askId,
10809
+ // The project, known from the start: no "which project?" turn.
10810
+ FLOWRIKI_PROJECT_ID: ask.projectId,
10811
+ // The agent that launched it decides its version: the tool server
10812
+ // does not update itself in the middle of somebody's job.
10813
+ FLOWRIKI_NO_UPDATE: "1",
10814
+ FLOWRIKI_MCP_CHILD: "1",
10815
+ // A question: only the reading tools are served at all.
10816
+ ...ask.mode === "ask" ? { FLOWRIKI_TOOLS: ASK_MODE_TOOLS.join(",") } : {},
10817
+ // A job of a known kind: the tools it never uses are not served.
10818
+ ...except.length ? { FLOWRIKI_TOOLS_EXCEPT: except.join(",") } : {}
10819
+ } } } }),
10410
10820
  { mode: 384 }
10411
10821
  );
10412
- return launch(ask, dir, mcpConfigPath, laneSession(ask, dir));
10822
+ return launch(ask, dir, mcpConfigPath, session);
10413
10823
  },
10414
10824
  cancel(askId) {
10825
+ if (retrying.has(askId)) {
10826
+ cancelled.add(askId);
10827
+ return true;
10828
+ }
10415
10829
  const child = running.get(askId);
10416
10830
  if (!child) return false;
10417
- cancelled = askId;
10418
- child.kill("SIGTERM");
10831
+ cancelled.add(askId);
10832
+ stop(child);
10419
10833
  setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
10420
10834
  return true;
10421
10835
  },
10422
10836
  /** Full — no room for another question — rather than "something runs":
10423
10837
  * a second conversation is welcome while the first works. */
10424
10838
  busy() {
10425
- return running.size >= MAX_AT_ONCE;
10839
+ return occupied() >= MAX_AT_ONCE;
10426
10840
  }
10427
10841
  };
10428
10842
  function launch(ask, dir, mcpConfigPath, sessionId, attempt = 1) {
@@ -10438,20 +10852,26 @@ function createClaudeCli(options) {
10438
10852
  ...sessionId ? { sessionId } : {},
10439
10853
  ...ask.folders?.length ? { folders: ask.folders } : {},
10440
10854
  ...ask.mode ? { mode: ask.mode } : {},
10441
- ...ask.brief ? { brief: ask.brief } : {}
10855
+ // Resumed, the conversation already holds its brief: a changed
10856
+ // one in the system prompt would throw away the whole cache.
10857
+ ...ask.brief && !sessionId ? { brief: ask.brief } : {}
10442
10858
  }),
10443
10859
  // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10444
- { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
10860
+ // Thinking is output, priced at five times input: capped per turn,
10861
+ // unless the person set their own.
10862
+ { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, MAX_THINKING_TOKENS: process.env["MAX_THINKING_TOKENS"] ?? "6000" } }
10445
10863
  );
10446
10864
  } catch {
10447
10865
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10448
10866
  }
10449
10867
  running.set(ask.askId, child);
10868
+ if (sessionId) sessionsInUse.add(sessionId);
10450
10869
  let buffer = "", stderr = "", sawDone = false, ended = false;
10451
10870
  let silentFailure = false;
10452
10871
  let toolsMissing = false;
10453
10872
  let session;
10454
10873
  let spent = 0;
10874
+ let lastContext = 0;
10455
10875
  const handleLine = (line) => {
10456
10876
  if (toolsMissing) return;
10457
10877
  for (const event of eventsFromLine(ask.askId, line)) {
@@ -10464,6 +10884,7 @@ function createClaudeCli(options) {
10464
10884
  continue;
10465
10885
  }
10466
10886
  if (event.name === "assistant.session") session = String(event.data["sessionId"] ?? "") || session;
10887
+ if (event.name === "assistant.usage") lastContext = Number(event.data["input"] ?? 0) + Number(event.data["cacheRead"] ?? 0) + Number(event.data["cacheWrite"] ?? 0) + Number(event.data["output"] ?? 0);
10467
10888
  if (event.name === "assistant.done" && event.data["ok"] === false && !String(event.data["result"] ?? "").trim()) {
10468
10889
  silentFailure = true;
10469
10890
  continue;
@@ -10486,9 +10907,17 @@ function createClaudeCli(options) {
10486
10907
  clearTimeout(timer);
10487
10908
  if (buffer.trim()) handleLine(buffer);
10488
10909
  running.delete(ask.askId);
10489
- if (toolsMissing && cancelled !== ask.askId) {
10910
+ if (sessionId) sessionsInUse.delete(sessionId);
10911
+ const stopped = cancelled.has(ask.askId);
10912
+ if (toolsMissing && !stopped) {
10490
10913
  if (attempt < 3) {
10914
+ retrying.add(ask.askId);
10491
10915
  setTimeout(() => {
10916
+ retrying.delete(ask.askId);
10917
+ if (cancelled.delete(ask.askId)) {
10918
+ ask.emit("assistant.done", { askId: ask.askId, ok: false, result: "Stopped." });
10919
+ return;
10920
+ }
10492
10921
  const again = launch(ask, dir, mcpConfigPath, sessionId, attempt + 1);
10493
10922
  if (!again.started) ask.emit("assistant.done", { askId: ask.askId, ok: false, result: again.detail ?? "The assistant could not be started." });
10494
10923
  }, 1500 * attempt + Math.floor(Math.random() * 1e3)).unref?.();
@@ -10497,22 +10926,22 @@ function createClaudeCli(options) {
10497
10926
  ask.emit("assistant.done", { askId: ask.askId, ok: false, result: "Flowriki's tools did not connect to the assistant on this computer after three tries, so nothing was done. Send it again in a moment; if it keeps happening, restart the agent." });
10498
10927
  return;
10499
10928
  }
10500
- if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
10929
+ if (silentFailure && sessionId && /no conversation found/i.test(stderr) && !stopped) {
10501
10930
  if (ask.platform) writeLane(dir, ask.platform, null);
10502
10931
  const again = launch(ask, dir, mcpConfigPath, void 0);
10503
10932
  if (again.started) return;
10504
10933
  }
10505
10934
  if (ask.platform && session && ok && !silentFailure) {
10506
- const before = sessionId && readLanes(dir)[ask.platform]?.sessionId === sessionId ? readLanes(dir)[ask.platform].tokens : 0;
10507
- writeLane(dir, ask.platform, { sessionId: session, updatedAt: Date.now(), tokens: Math.max(before, spent) });
10935
+ writeLane(dir, ask.platform, { sessionId: session, updatedAt: Date.now(), tokens: lastContext || spent, kind: usageKindOf(ask.prompt) });
10508
10936
  }
10509
10937
  if (!sawDone) {
10510
10938
  const why = detail || stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || "The assistant stopped without saying why.";
10511
10939
  ask.emit("assistant.done", { askId: ask.askId, ok: ok && !silentFailure, result: ok && !silentFailure ? "" : why });
10512
10940
  }
10941
+ cancelled.delete(ask.askId);
10513
10942
  };
10514
10943
  const timer = setTimeout(() => {
10515
- child.kill("SIGTERM");
10944
+ stop(child);
10516
10945
  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.`);
10517
10946
  }, maxAskMs);
10518
10947
  timer.unref?.();
@@ -10520,7 +10949,12 @@ function createClaudeCli(options) {
10520
10949
  buffer += chunk.toString();
10521
10950
  let at;
10522
10951
  while ((at = buffer.indexOf("\n")) >= 0) {
10523
- handleLine(buffer.slice(0, at));
10952
+ try {
10953
+ handleLine(buffer.slice(0, at));
10954
+ } catch (error) {
10955
+ stderr = `${stderr}
10956
+ ${error.message}`.slice(-2e3);
10957
+ }
10524
10958
  buffer = buffer.slice(at + 1);
10525
10959
  }
10526
10960
  });
@@ -10532,7 +10966,7 @@ function createClaudeCli(options) {
10532
10966
  "close",
10533
10967
  (code) => end(
10534
10968
  code === 0,
10535
- cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10969
+ cancelled.has(ask.askId) ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10536
10970
  )
10537
10971
  );
10538
10972
  return { started: true };
@@ -10868,8 +11302,12 @@ async function start(options = {}) {
10868
11302
  if (stopped) return;
10869
11303
  stopped = true;
10870
11304
  link.stop();
10871
- agent.close();
10872
- lock.lock.release();
11305
+ try {
11306
+ agent.close();
11307
+ } catch {
11308
+ } finally {
11309
+ lock.lock.release();
11310
+ }
10873
11311
  }
10874
11312
  };
10875
11313
  }
@@ -10968,6 +11406,8 @@ export {
10968
11406
  executeStep,
10969
11407
  replayCase,
10970
11408
  fieldNames,
11409
+ backupsFor,
11410
+ withBackups,
10971
11411
  flowrikiHome,
10972
11412
  helperRunner,
10973
11413
  resolveCheckouts,