@flowriki/agent 0.1.0-beta.64 → 0.1.0-beta.66

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.
@@ -28,6 +28,8 @@ import {
28
28
  adjacentLabelPattern,
29
29
  caseDebugPlan,
30
30
  checkMobileAssertion,
31
+ commandFor,
32
+ commandSystemOf,
31
33
  describeAssertion,
32
34
  describeLocators,
33
35
  expandForRun,
@@ -66,9 +68,10 @@ import {
66
68
  stepRefusedOn,
67
69
  targetFor,
68
70
  textIndependent,
71
+ usageKindOf,
69
72
  validateActors,
70
73
  webCloudOf
71
- } from "./chunk-RI63WNGU.js";
74
+ } from "./chunk-YD7F7JMA.js";
72
75
 
73
76
  // src/engine/machine.ts
74
77
  import fs from "fs";
@@ -76,7 +79,7 @@ import os from "os";
76
79
  import path from "path";
77
80
  import { createRequire } from "module";
78
81
  import { spawnSync } from "child_process";
79
- var AGENT_VERSION = "0.1.0-beta.64";
82
+ var AGENT_VERSION = "0.1.0-beta.66";
80
83
  async function prepareBrowsers() {
81
84
  if (browsersReady()) {
82
85
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -136,7 +139,7 @@ async function checkMachine() {
136
139
  }
137
140
  } catch {
138
141
  }
139
- const { mobileDoctor: mobileDoctor2 } = await import("./dist-FNAQZJYU.js");
142
+ const { mobileDoctor: mobileDoctor2 } = await import("./dist-CUDUSTFO.js");
140
143
  return {
141
144
  agentVersion: AGENT_VERSION,
142
145
  node: process.versions.node,
@@ -235,7 +238,7 @@ function forgetConnection(service, env = process.env) {
235
238
  }
236
239
  var fingerprint = (secret2) => crypto2.createHash("sha256").update(secret2).digest("hex").slice(0, 12);
237
240
 
238
- // ../contracts/dist/chunk-NTBXG5EZ.js
241
+ // ../contracts/dist/chunk-VTO4SX4J.js
239
242
  import { z } from "zod";
240
243
  var PROTOCOL_VERSION = 1;
241
244
  var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
@@ -288,7 +291,10 @@ var CommandNameSchema = z.enum([
288
291
  // a recording's — as a picture, for the live mirror. Reading.
289
292
  "mobile.peek",
290
293
  // What this computer has installed for web and phone testing. Reading.
291
- "machine.check"
294
+ "machine.check",
295
+ // A helper run HERE — "Try it" on a command helper, which only the
296
+ // tester's computer can run. NOT baseline.
297
+ "helper.run"
292
298
  ]);
293
299
  var EFFECTFUL = /* @__PURE__ */ new Set([
294
300
  "record.start",
@@ -332,7 +338,9 @@ var EFFECTFUL = /* @__PURE__ */ new Set([
332
338
  "project.claim",
333
339
  "run.cancel",
334
340
  // A phone session taps and types on somebody's device.
335
- "mobile.session"
341
+ "mobile.session",
342
+ // Runs a command on somebody's computer.
343
+ "helper.run"
336
344
  ]);
337
345
  var CommandSchema = z.object({
338
346
  /** Unique per command. The agent stores it, so a redelivery after a
@@ -483,7 +491,7 @@ function compareAgentVersions(a, b2) {
483
491
  }
484
492
  return 0;
485
493
  }
486
- var agentUpdateFor = (running, latest) => Boolean(running && latest && running !== "test" && compareAgentVersions(latest, running) > 0);
494
+ var agentUpdateFor = (running2, latest) => Boolean(running2 && latest && running2 !== "test" && compareAgentVersions(latest, running2) > 0);
487
495
 
488
496
  // src/service.ts
489
497
  var BadServiceUrl = class extends Error {
@@ -551,7 +559,9 @@ async function connect(options = {}) {
551
559
  const res = await fetch(`${service}/api/pairing-requests`, {
552
560
  method: "POST",
553
561
  headers: { "content-type": "application/json" },
554
- body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION }),
562
+ // Ask for a code to show here: the page then insists on it, so a link
563
+ // sent by somebody else's agent cannot be approved by mistake.
564
+ body: JSON.stringify({ machineName, agentVersion: AGENT_VERSION, wantsCode: true }),
555
565
  signal: options.signal ?? null
556
566
  });
557
567
  if (!res.ok) {
@@ -565,7 +575,7 @@ async function connect(options = {}) {
565
575
  options.onProgress?.({
566
576
  kind: "approve",
567
577
  message: "waiting for approval in your browser",
568
- detail: { url: approve, machineName, canOpen: options.open !== false && canOpenBrowser() }
578
+ detail: { url: approve, machineName, canOpen: options.open !== false && canOpenBrowser(), ...asked.code ? { code: `${asked.code.slice(0, 3)}-${asked.code.slice(3)}` } : {} }
569
579
  });
570
580
  if (options.open !== false) openBrowser(approve);
571
581
  const pollMs = options.pollMs ?? 1500;
@@ -1840,7 +1850,7 @@ function installDebugPanel() {
1840
1850
  pendingRender = false;
1841
1851
  const p = root.getElementById("p");
1842
1852
  p.className = "p" + (vertical ? " v" : "");
1843
- const running = state.status === "running";
1853
+ const running2 = state.status === "running";
1844
1854
  const done = state.status === "finished";
1845
1855
  const stCls = state.failure ? "failed" : state.recording ? "running" : state.status;
1846
1856
  const stLabel = state.recording ? "recording" : state.failure ? "paused at a failure" : state.status === "idle" ? "not started" : state.status;
@@ -1933,7 +1943,7 @@ function installDebugPanel() {
1933
1943
  });
1934
1944
  return;
1935
1945
  }
1936
- p.innerHTML = '<div class="bar"><span class="grip" title="Drag to move"><i></i><i></i><i></i></span><span class="st ' + esc(stCls) + '"><span class="d"></span><span>' + esc(stLabel) + '</span><span class="prog">&#160;&#183;&#160;' + ran + "/" + state.steps.length + '</span></span><span class="dv"></span><button id="play" class="primary" title="' + (state.failure ? "Give up on this step and carry on" : "Run until the next breakpoint or failure") + '" ' + (running || done ? "disabled" : "") + ">" + (state.failure ? "Skip &amp; continue" : "Play") + "</button>" + (state.failure ? '<button id="retry" title="Run this step again">Retry</button>' : "") + '<button id="step" title="Run exactly one step" ' + (running || done ? "disabled" : "") + '>Step</button><button id="pause" title="Stop after the step in flight" ' + (running ? "" : "disabled") + '>Pause</button><button id="replay" title="Start over from the first step" ' + (running ? "disabled" : "") + '>Replay</button><span class="dv"></span>' + // While armed, this is the only way to stop it — so it says so, rather
1946
+ p.innerHTML = '<div class="bar"><span class="grip" title="Drag to move"><i></i><i></i><i></i></span><span class="st ' + esc(stCls) + '"><span class="d"></span><span>' + esc(stLabel) + '</span><span class="prog">&#160;&#183;&#160;' + ran + "/" + state.steps.length + '</span></span><span class="dv"></span><button id="play" class="primary" title="' + (state.failure ? "Give up on this step and carry on" : "Run until the next breakpoint or failure") + '" ' + (running2 || done ? "disabled" : "") + ">" + (state.failure ? "Skip &amp; continue" : "Play") + "</button>" + (state.failure ? '<button id="retry" title="Run this step again">Retry</button>' : "") + '<button id="step" title="Run exactly one step" ' + (running2 || done ? "disabled" : "") + '>Step</button><button id="pause" title="Stop after the step in flight" ' + (running2 ? "" : "disabled") + '>Pause</button><button id="replay" title="Start over from the first step" ' + (running2 ? "disabled" : "") + '>Replay</button><span class="dv"></span>' + // While armed, this is the only way to stop it — so it says so, rather
1937
1947
  // than reading as a status label nobody realises is a button.
1938
1948
  (state.recording ? '<button id="rec" class="danger" title="Stop capturing"><span class="rec"><span class="dot"></span>Stop recording</span></button>' : '<button id="rec" title="Capture new steps at the cursor">Record</button>') + // AUTHORING A CHECK BELONGS HERE MOST OF ALL. Recording had it and
1939
1949
  // debugging did not, which is the wrong way round: the moment worth
@@ -2130,6 +2140,12 @@ async function performHelperStep(page, step, options) {
2130
2140
  }
2131
2141
  return got;
2132
2142
  }
2143
+ var LOAD_CAP_MS = 3e3;
2144
+ var QUIET_CAP_MS = 1e3;
2145
+ async function settleAfterNavigation(page) {
2146
+ await page.waitForLoadState("load", { timeout: LOAD_CAP_MS }).catch(() => void 0);
2147
+ await page.waitForLoadState("networkidle", { timeout: QUIET_CAP_MS }).catch(() => void 0);
2148
+ }
2133
2149
  var secretsBehind = (auth) => [.../* @__PURE__ */ new Set([
2134
2150
  ...Object.values(auth.secretRefs ?? {}),
2135
2151
  ...auth.steps.flatMap((step) => step.secretRef ? [step.secretRef] : [])
@@ -2333,7 +2349,10 @@ var RunActors = class {
2333
2349
  if (switching && this.opts.headless === false) await session.page.bringToFront().catch(() => {
2334
2350
  });
2335
2351
  if (this.initialized.has(id)) return;
2336
- if (startUrl) await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2352
+ if (startUrl) {
2353
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2354
+ await settleAfterNavigation(session.page);
2355
+ }
2337
2356
  if (actor?.startSignedIn) {
2338
2357
  if (kept?.state) {
2339
2358
  const confirmed = await confirmSignedIn(session.page, kept.auth, this.opts.reuseCheckMs ?? 3e3);
@@ -2345,7 +2364,10 @@ var RunActors = class {
2345
2364
  this.opts.reuse?.cache.evict(kept.key);
2346
2365
  this.preparations.set(id, "stale");
2347
2366
  session = await this.replaceWithGuest(id, session);
2348
- await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2367
+ {
2368
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2369
+ await settleAfterNavigation(session.page);
2370
+ }
2349
2371
  }
2350
2372
  await executeStep(
2351
2373
  session.page,
@@ -2377,7 +2399,10 @@ var RunActors = class {
2377
2399
  } catch {
2378
2400
  }
2379
2401
  }
2380
- if (startUrl) await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2402
+ if (startUrl) {
2403
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2404
+ await settleAfterNavigation(session.page);
2405
+ }
2381
2406
  }
2382
2407
  this.initialized.add(id);
2383
2408
  }
@@ -2456,9 +2481,9 @@ var RunActors = class {
2456
2481
  const previous = this.sessions.get(id);
2457
2482
  const round = (this.resets.get(id) ?? 0) + 1;
2458
2483
  this.resets.set(id, round);
2459
- const home = `actor-${id}-${round}`;
2460
- const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home) });
2461
- this.homes.set(session, home);
2484
+ const home2 = `actor-${id}-${round}`;
2485
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home2) });
2486
+ this.homes.set(session, home2);
2462
2487
  this.sessions.set(id, session);
2463
2488
  this.current = session;
2464
2489
  this.currentId = id;
@@ -2474,7 +2499,10 @@ var RunActors = class {
2474
2499
  this.retired.add(previous);
2475
2500
  }
2476
2501
  await this.opts.onCreate?.(session);
2477
- await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
2502
+ {
2503
+ await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
2504
+ await settleAfterNavigation(session.page);
2505
+ }
2478
2506
  this.initialized.add(id);
2479
2507
  }
2480
2508
  /** Back to one participant, between debug replays.
@@ -2805,7 +2833,7 @@ var ExecutionPages = class {
2805
2833
  await Promise.all(this.dialogTasks);
2806
2834
  if (this.unexpected) throw new Error(this.unexpected);
2807
2835
  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"}`);
2836
+ if (step.downloads && !downloadsMatch(step.downloads, downloads)) throw new Error(`Expected downloads ${step.downloads.join(", ")}; received ${downloads.join(", ") || "none"}`);
2809
2837
  } finally {
2810
2838
  page.off("download", onDownload);
2811
2839
  this.expectedDialogs = [];
@@ -2820,6 +2848,17 @@ var ExecutionPages = class {
2820
2848
  this.unexpected = null;
2821
2849
  }
2822
2850
  };
2851
+ 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, "#");
2852
+ function downloadsMatch(expected, received) {
2853
+ if (expected.length !== received.length) return false;
2854
+ const left = received.map(shapeOf);
2855
+ for (const name of expected) {
2856
+ const at = left.indexOf(shapeOf(name));
2857
+ if (at === -1) return false;
2858
+ left.splice(at, 1);
2859
+ }
2860
+ return true;
2861
+ }
2823
2862
  function rootFor(page, step) {
2824
2863
  let root = page;
2825
2864
  for (const frame of step.frame ?? []) root = root.frameLocator(frameSelector(frame));
@@ -2899,17 +2938,22 @@ async function scrollUntilFound(page, loc, budgetMs = 3e3) {
2899
2938
  for (let pass = 0; pass < PASSES && Date.now() < giveUpAt; pass++) {
2900
2939
  if (await loc.count() > 0) return true;
2901
2940
  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) {
2941
+ const nudge = (el) => {
2908
2942
  const before = el.scrollTop;
2909
2943
  el.scrollTop = before + Math.max(80, el.clientHeight * 0.8);
2910
- if (el.scrollTop !== before) any = true;
2911
- }
2912
- return any;
2944
+ return el.scrollTop !== before;
2945
+ };
2946
+ const page2 = document.scrollingElement;
2947
+ if (page2 && page2.scrollHeight > page2.clientHeight + 4 && nudge(page2)) return true;
2948
+ const areas = Array.from(document.querySelectorAll("*")).filter((el) => el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 40).map((el) => {
2949
+ const box = el.getBoundingClientRect();
2950
+ const style = getComputedStyle(el);
2951
+ const scrolls = /(auto|scroll|overlay)/.test(style.overflowY);
2952
+ const shown = box.width > 0 && box.height > 0 && box.bottom > 0 && box.top < innerHeight && style.visibility !== "hidden";
2953
+ return { el, area: scrolls && shown ? box.width * box.height : 0 };
2954
+ }).filter((each) => each.area > 0).sort((a, b2) => b2.area - a.area);
2955
+ for (const { el } of areas) if (nudge(el)) return true;
2956
+ return false;
2913
2957
  }).catch(() => false);
2914
2958
  if (!moved) break;
2915
2959
  await page.waitForTimeout(120);
@@ -3422,6 +3466,7 @@ async function runStepsIn(session, steps, opts) {
3422
3466
  try {
3423
3467
  if (opts.startUrl && !opensItsOwnEntry(steps, opts)) {
3424
3468
  await page.goto(opts.startUrl, { waitUntil: "domcontentloaded" });
3469
+ await settleAfterNavigation(page);
3425
3470
  }
3426
3471
  for (const recorded of steps) {
3427
3472
  let step = recorded;
@@ -3625,8 +3670,9 @@ async function runStepsIn(session, steps, opts) {
3625
3670
  // SAID OUT LOUD, on a step that passed. The element was found, but
3626
3671
  // not the way this step says it should be — something has moved,
3627
3672
  // and the run is the only place anybody would learn it.
3628
- onFallback: (_step, foundBy) => {
3673
+ onFallback: (_step, foundBy, renamedTo) => {
3629
3674
  result.foundBy = foundBy;
3675
+ if (renamedTo) result.renamedTo = renamedTo;
3630
3676
  }
3631
3677
  });
3632
3678
  try {
@@ -3815,6 +3861,7 @@ async function executeStep(main, input, options = {}) {
3815
3861
  const remaining = () => Math.max(1, deadline - Date.now());
3816
3862
  const navigationBudget = () => step.navigationTimeoutMs ?? options.navigationTimeoutMs ?? 45e3;
3817
3863
  page.setDefaultTimeout(remaining());
3864
+ page.setDefaultNavigationTimeout(navigationBudget());
3818
3865
  const act = async () => {
3819
3866
  if (step.type === "unsupported") throw new Error(step.warning || step.value || "This interaction requires a supported action before replay");
3820
3867
  if (step.type === "assert") {
@@ -3823,6 +3870,7 @@ async function executeStep(main, input, options = {}) {
3823
3870
  }
3824
3871
  if (step.type === "navigate") {
3825
3872
  await page.goto(new URL(step.value, step.navigationBase === "application" ? options.baseUrl ?? page.url() : page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
3873
+ await settleAfterNavigation(page);
3826
3874
  return;
3827
3875
  }
3828
3876
  if (step.type === "closePage") {
@@ -3852,7 +3900,7 @@ async function executeStep(main, input, options = {}) {
3852
3900
  }
3853
3901
  const target = await ensureTarget(page, step, remaining());
3854
3902
  const loc = target.loc;
3855
- if (target.foundBy) options.onFallback?.(step, target.foundBy);
3903
+ if (target.foundBy) options.onFallback?.(step, target.foundBy, target.renamedTo);
3856
3904
  page.setDefaultTimeout(remaining());
3857
3905
  await applyAction(loc, step, value, { resolveFile: options.resolveFile, dropTarget: await locateDrop(page, step) });
3858
3906
  };
@@ -3904,6 +3952,26 @@ async function applyScroll(page, step, timeout) {
3904
3952
  } catch {
3905
3953
  }
3906
3954
  }
3955
+ async function renamedFrom(page, step, found) {
3956
+ if (!step.role || step.locator) return void 0;
3957
+ try {
3958
+ const first = (await found.ariaSnapshot({ timeout: 1e3 })).split("\n")[0] ?? "";
3959
+ const parsed = /^- ([a-z]+) "((?:[^"\\]|\\.)*)"/.exec(first);
3960
+ if (!parsed || parsed[1] !== step.role) return void 0;
3961
+ const name = JSON.parse(`"${parsed[2]}"`).trim();
3962
+ if (!name || name === step.name || name.length > 200) return void 0;
3963
+ const { nameFrom: _from, candidates: _backups, ...rest } = step;
3964
+ const [again] = await locateCandidates(page, { ...rest, name });
3965
+ if (!again || await again.count() !== 1) return void 0;
3966
+ const handle = await found.elementHandle({ timeout: 1e3 });
3967
+ const same = handle ? await again.evaluate((el, other) => el === other, handle) : false;
3968
+ await handle?.dispose().catch(() => {
3969
+ });
3970
+ return same ? name : void 0;
3971
+ } catch {
3972
+ return void 0;
3973
+ }
3974
+ }
3907
3975
  async function ensureTarget(page, step, timeout) {
3908
3976
  const candidates = await locateCandidates(page, step);
3909
3977
  const described = describeLocators(step);
@@ -3920,7 +3988,10 @@ async function ensureTarget(page, step, timeout) {
3920
3988
  let crowded = null;
3921
3989
  for (let at = 1; at < candidates.length; at += 1) {
3922
3990
  const found = await candidates[at].count().catch(() => 0);
3923
- if (found === 1) return { loc: candidates[at], foundBy: described[at] ?? "another locator" };
3991
+ if (found === 1) {
3992
+ const renamedTo = await renamedFrom(page, step, candidates[at]);
3993
+ return { loc: candidates[at], foundBy: described[at] ?? "another locator", ...renamedTo ? { renamedTo } : {} };
3994
+ }
3924
3995
  if (found > 1 && !crowded) crowded = { where: described[at] ?? "another locator", count: found };
3925
3996
  }
3926
3997
  if (several) throw Object.assign(several, { kind: "ambiguous" });
@@ -5261,6 +5332,45 @@ async function fieldNames(page) {
5261
5332
  return found;
5262
5333
  }).catch(() => []);
5263
5334
  }
5335
+ async function backupsFor(page, step) {
5336
+ if (!step.role || !step.name || step.locator) return [];
5337
+ try {
5338
+ const [primary] = await locateCandidates(page, step);
5339
+ if (!primary || await primary.count() !== 1) return [];
5340
+ return await primary.evaluate((el) => {
5341
+ const clean = (s2) => (s2 ?? "").replace(/\s+/g, " ").trim();
5342
+ const root = el.getRootNode();
5343
+ const unique = (selector) => {
5344
+ try {
5345
+ return root.querySelectorAll(selector).length === 1;
5346
+ } catch {
5347
+ return false;
5348
+ }
5349
+ };
5350
+ const found = [];
5351
+ const testId = el.getAttribute("data-testid");
5352
+ if (testId && unique(`[data-testid=${JSON.stringify(testId)}]`)) found.push({ strategy: "testId", value: testId });
5353
+ const placeholder = el.getAttribute("placeholder");
5354
+ if (clean(placeholder) && unique(`[placeholder=${JSON.stringify(placeholder)}]`)) found.push({ strategy: "placeholder", value: clean(placeholder) });
5355
+ const alt = el.getAttribute("alt");
5356
+ if (clean(alt) && unique(`[alt=${JSON.stringify(alt)}]`)) found.push({ strategy: "altText", value: clean(alt) });
5357
+ const title = el.getAttribute("title");
5358
+ if (clean(title) && unique(`[title=${JSON.stringify(title)}]`)) found.push({ strategy: "title", value: clean(title) });
5359
+ if (el.id && !/:r|[a-f0-9]{12}|\d{6}/i.test(el.id) && unique(`#${CSS.escape(el.id)}`)) {
5360
+ found.push({ strategy: "css", value: `#${CSS.escape(el.id)}` });
5361
+ }
5362
+ return found.slice(0, 4);
5363
+ }, void 0, { timeout: 1e3 });
5364
+ } catch {
5365
+ return [];
5366
+ }
5367
+ }
5368
+ function withBackups(target, backups) {
5369
+ if (!backups.length) return target;
5370
+ const seen = new Set((target.candidates ?? []).map((c) => `${c.strategy}\0${c.value}`));
5371
+ const merged = [...target.candidates ?? [], ...backups.filter((b2) => !seen.has(`${b2.strategy}\0${b2.value}`))].slice(0, 4);
5372
+ return { ...target, candidates: merged };
5373
+ }
5264
5374
  var playwrightVersion2 = () => {
5265
5375
  try {
5266
5376
  return createRequire22(import.meta.url)("playwright/package.json").version;
@@ -6106,8 +6216,18 @@ var SqliteStorage = class {
6106
6216
  ) AS rn FROM run
6107
6217
  ) WHERE rn > ?`
6108
6218
  ).all(keep).map((r) => String(r.id));
6219
+ const dirOf = this.handle.prepare("SELECT artifacts_dir FROM run WHERE id=?");
6109
6220
  const stmt = this.handle.prepare("DELETE FROM run WHERE id=?");
6110
- for (const id of doomed) stmt.run(id);
6221
+ for (const id of doomed) {
6222
+ const dir = dirOf.get(id)?.artifacts_dir;
6223
+ stmt.run(id);
6224
+ if (typeof dir === "string" && dir) {
6225
+ try {
6226
+ fs5.rmSync(dir, { recursive: true, force: true });
6227
+ } catch {
6228
+ }
6229
+ }
6230
+ }
6111
6231
  return doomed;
6112
6232
  });
6113
6233
  }
@@ -6912,7 +7032,7 @@ function ensureRootCommit(root) {
6912
7032
  }
6913
7033
 
6914
7034
  // ../helpers/dist/index.js
6915
- import { createHmac as createHmac2 } from "crypto";
7035
+ import { createHash, createHmac as createHmac2 } from "crypto";
6916
7036
  var HelperFailure = class extends Error {
6917
7037
  constructor(message) {
6918
7038
  super(message);
@@ -6978,11 +7098,12 @@ function totp(secretBase32, at, options = {}) {
6978
7098
  const remaining = period - Math.floor(at / 1e3 % period);
6979
7099
  return { code, remaining };
6980
7100
  }
6981
- async function imapMailbox(config, password) {
7101
+ async function imapMailbox(config, password, target = { host: config.host }) {
6982
7102
  const { ImapFlow } = await import("imapflow");
6983
7103
  const { simpleParser } = await import("mailparser");
6984
7104
  const client = new ImapFlow({
6985
- host: config.host,
7105
+ host: target.host,
7106
+ ...target.servername ? { servername: target.servername } : {},
6986
7107
  port: config.port,
6987
7108
  secure: config.secure,
6988
7109
  auth: { user: config.username, pass: password },
@@ -7041,7 +7162,8 @@ async function runMailbox(config, ctx) {
7041
7162
  subject: fill(config.match.subject, ctx.inputs),
7042
7163
  since: ctx.since ?? null
7043
7164
  };
7044
- const mailbox = await (ctx.openMailbox ?? imapMailbox)(config, secret(ctx, config.passwordRef, "mailbox password"));
7165
+ const password = secret(ctx, config.passwordRef, "mailbox password");
7166
+ const mailbox = ctx.openMailbox ? await ctx.openMailbox(config, password) : await imapMailbox(config, password, ctx.connectTo ? await ctx.connectTo(config.host) : void 0);
7045
7167
  const deadline = now() + config.waitSeconds * 1e3;
7046
7168
  try {
7047
7169
  for (; ; ) {
@@ -7164,6 +7286,82 @@ async function runHttp(config, ctx) {
7164
7286
  }
7165
7287
  }
7166
7288
  }
7289
+ function commandFingerprint(config, system) {
7290
+ const line = commandFor(config, system);
7291
+ const written = JSON.stringify({
7292
+ program: line.program,
7293
+ args: line.args,
7294
+ folder: config.folder,
7295
+ env: config.env,
7296
+ secretEnv: Object.keys(config.secretEnv).sort(),
7297
+ mode: config.mode,
7298
+ readyPattern: config.readyPattern
7299
+ });
7300
+ return createHash("sha256").update(written).digest("hex").slice(0, 16);
7301
+ }
7302
+ var lastLine = (text) => text.trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
7303
+ async function runCommand(config, ctx, name) {
7304
+ if (!ctx.commands) {
7305
+ throw new HelperFailure("this helper runs a command on a tester's computer \u2014 try it, and run tests that use it, from a computer running the Flowriki agent");
7306
+ }
7307
+ const system = ctx.system ?? commandSystemOf(process.platform);
7308
+ const line = commandFor(config, system);
7309
+ const env = {};
7310
+ for (const [key, value] of Object.entries(config.env)) env[key] = fill(value, ctx.inputs);
7311
+ const redact2 = [];
7312
+ for (const [key, ref] of Object.entries(config.secretEnv)) {
7313
+ const value = secret(ctx, ref, `${key} secret`);
7314
+ env[key] = value;
7315
+ redact2.push(value);
7316
+ }
7317
+ const outcome = await ctx.commands.run({
7318
+ name,
7319
+ fingerprint: commandFingerprint(config, system),
7320
+ program: fill(line.program, ctx.inputs),
7321
+ // EACH ARGUMENT FILLED ON ITS OWN: a value is never split into two, and
7322
+ // never read by a shell.
7323
+ args: line.args.map((arg) => fill(arg, ctx.inputs)),
7324
+ folder: fill(config.folder, ctx.inputs),
7325
+ env,
7326
+ redact: redact2,
7327
+ mode: config.mode,
7328
+ readyPattern: config.readyPattern,
7329
+ timeoutMs: config.timeoutSeconds * 1e3
7330
+ });
7331
+ const evidence = {
7332
+ ...outcome.exitCode === null ? { state: config.mode === "background" ? "running until the test ends" : "stopped" } : { "exit code": String(outcome.exitCode) },
7333
+ ...lastLine(outcome.stdout) ? { "last line": lastLine(outcome.stdout).slice(0, 200) } : {}
7334
+ };
7335
+ if (config.mode === "run" && outcome.exitCode !== config.expectExit) {
7336
+ const why = lastLine(outcome.stderr) || lastLine(outcome.stdout);
7337
+ throw new HelperFailure(`the command ended with exit code ${outcome.exitCode ?? "?"}${config.expectExit ? ` (expected ${config.expectExit})` : ""}${why ? `: ${why.slice(0, 300)}` : ""}`);
7338
+ }
7339
+ const text = outcome.stdout;
7340
+ switch (config.extract.take) {
7341
+ case "nothing":
7342
+ return { value: "", detail: config.mode === "background" ? "started, and runs until the test ends" : "it ran", evidence };
7343
+ case "output": {
7344
+ const value = text.trim();
7345
+ return { value: value.length > 2e3 ? lastLine(value) : value, detail: "what the command printed", evidence };
7346
+ }
7347
+ case "pattern": {
7348
+ const value = takeCode(text, config.extract.pattern);
7349
+ if (value === null) throw new HelperFailure(`the command printed nothing matching ${config.extract.pattern}`);
7350
+ return { value, detail: "the match in what the command printed", evidence };
7351
+ }
7352
+ case "json": {
7353
+ let parsed;
7354
+ try {
7355
+ parsed = JSON.parse(text);
7356
+ } catch {
7357
+ throw new HelperFailure("the command did not print JSON");
7358
+ }
7359
+ const value = atPath(parsed, config.extract.path);
7360
+ if (value === void 0 || value === null) throw new HelperFailure(`what the command printed has no "${config.extract.path}"`);
7361
+ return { value: typeof value === "string" ? value : JSON.stringify(value), detail: `"${config.extract.path}" from what the command printed`, evidence };
7362
+ }
7363
+ }
7364
+ }
7167
7365
  async function runHelper(helper, ctx) {
7168
7366
  const config = "config" in helper ? helper.config : helper;
7169
7367
  switch (config.kind) {
@@ -7177,11 +7375,17 @@ async function runHelper(helper, ctx) {
7177
7375
  return runSms(config, ctx);
7178
7376
  case "http":
7179
7377
  return runHttp(config, ctx);
7378
+ case "command":
7379
+ return runCommand(config, ctx, "name" in helper ? helper.name : "this command");
7180
7380
  }
7181
7381
  }
7182
7382
 
7183
7383
  // ../execution/dist/index.js
7384
+ import { spawn as spawn2 } from "child_process";
7184
7385
  import fs24 from "fs";
7386
+ import os5 from "os";
7387
+ import path24 from "path";
7388
+ import fs34 from "fs";
7185
7389
  import crypto3 from "crypto";
7186
7390
  async function cloudBrowserFor(config, secrets, name) {
7187
7391
  const settings = config ?? {};
@@ -7195,6 +7399,7 @@ async function cloudBrowserFor(config, secrets, name) {
7195
7399
  return connectCloudBrowser(account, key, choice, name);
7196
7400
  }
7197
7401
  var LOCAL_CHROMIUM = { id: "chromium", provider: "local", engine: "chromium" };
7402
+ var KEEP_RUNS_LOCALLY = 50;
7198
7403
  async function executeFlow(opts) {
7199
7404
  const { store, storage = null, flowId, trigger, watch = false, onEvent } = opts;
7200
7405
  const headless = watch ? false : opts.headless ?? store.readConfig().headless;
@@ -7354,6 +7559,7 @@ async function executeFlow(opts) {
7354
7559
  writes.push(emit({ type: "run.step", runId: run?.id ?? "", flowId: flow.id, result }));
7355
7560
  }
7356
7561
  });
7562
+ store.runHelper?.close?.();
7357
7563
  if (storage && taskId && run) {
7358
7564
  const reached = new Set(outcome.steps.map((r) => r.stepId));
7359
7565
  for (const step of flow.steps) {
@@ -7431,6 +7637,7 @@ async function executeFlow(opts) {
7431
7637
  artifactsDir: runDir && fs7.existsSync(runDir) ? runDir : null
7432
7638
  });
7433
7639
  }
7640
+ if (storage) void storage.pruneRuns(KEEP_RUNS_LOCALLY).catch(() => void 0);
7434
7641
  await emit({ type: "run.finished", runId: run?.id ?? "", flowId: flow.id, outcome: last });
7435
7642
  return {
7436
7643
  outcome: last,
@@ -7518,27 +7725,203 @@ function toStepRow(ordinal, result, step) {
7518
7725
  checks
7519
7726
  };
7520
7727
  }
7728
+ var home = () => process.env["FLOWRIKI_HOME"] || path24.join(os5.homedir(), ".flowriki");
7729
+ var approvalsFile = () => path24.join(home(), "allowed-commands.json");
7730
+ function readApprovals() {
7731
+ try {
7732
+ const parsed = JSON.parse(fs24.readFileSync(approvalsFile(), "utf8"));
7733
+ return { allowed: parsed.allowed ?? {}, waiting: parsed.waiting ?? {} };
7734
+ } catch {
7735
+ return { allowed: {}, waiting: {} };
7736
+ }
7737
+ }
7738
+ function writeApprovals(approvals) {
7739
+ fs24.mkdirSync(home(), { recursive: true, mode: 448 });
7740
+ const temporary = `${approvalsFile()}.${process.pid}.tmp`;
7741
+ fs24.writeFileSync(temporary, JSON.stringify(approvals, null, 2), { mode: 384 });
7742
+ fs24.renameSync(temporary, approvalsFile());
7743
+ }
7744
+ function allowCommands(which) {
7745
+ const approvals = readApprovals();
7746
+ const wanted = which.trim().toLowerCase();
7747
+ const chosen = Object.values(approvals.waiting).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
7748
+ for (const command of chosen) {
7749
+ approvals.allowed[command.fingerprint] = { ...command, at: (/* @__PURE__ */ new Date()).toISOString() };
7750
+ delete approvals.waiting[command.fingerprint];
7751
+ }
7752
+ if (chosen.length) writeApprovals(approvals);
7753
+ return chosen;
7754
+ }
7755
+ function revokeCommands(which) {
7756
+ const approvals = readApprovals();
7757
+ const wanted = which.trim().toLowerCase();
7758
+ const chosen = Object.values(approvals.allowed).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
7759
+ for (const command of chosen) delete approvals.allowed[command.fingerprint];
7760
+ if (chosen.length) writeApprovals(approvals);
7761
+ return chosen;
7762
+ }
7763
+ var CommandNotAllowed = class extends HelperFailure {
7764
+ constructor(command) {
7765
+ super(`"${command.name}" has not been allowed on this computer yet. In a terminal here, run: flowriki-agent allow "${command.name}" \u2014 it shows the command before allowing it.`);
7766
+ this.command = command;
7767
+ this.name = "CommandNotAllowed";
7768
+ }
7769
+ command;
7770
+ code = "command_not_allowed";
7771
+ };
7772
+ var KEEP_BYTES = 64 * 1024;
7773
+ var BACKGROUND_LIMIT_MS = 30 * 6e4;
7774
+ var SETTLE_MS = 2e3;
7775
+ var running = /* @__PURE__ */ new Set();
7776
+ process.once("exit", () => {
7777
+ for (const child of running) {
7778
+ try {
7779
+ child.kill("SIGKILL");
7780
+ } catch {
7781
+ }
7782
+ }
7783
+ });
7784
+ function stop(child) {
7785
+ if (child.exitCode !== null || child.signalCode !== null) return;
7786
+ try {
7787
+ child.kill("SIGTERM");
7788
+ } catch {
7789
+ }
7790
+ const hard = setTimeout(() => {
7791
+ try {
7792
+ child.kill("SIGKILL");
7793
+ } catch {
7794
+ }
7795
+ }, 2e3);
7796
+ hard.unref();
7797
+ }
7798
+ function launchLine(program, args) {
7799
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(program)) {
7800
+ const unsafe = [program, ...args].find((part) => /[&|<>^%!"]/.test(part));
7801
+ if (unsafe !== void 0) throw new HelperFailure(`on Windows a .cmd or .bat cannot take "${unsafe}" \u2014 those characters mean something to cmd.exe`);
7802
+ return { file: process.env["ComSpec"] || "cmd.exe", args: ["/d", "/c", program, ...args] };
7803
+ }
7804
+ return { file: program, args };
7805
+ }
7806
+ function localCommandRunner(opts) {
7807
+ const mine = /* @__PURE__ */ new Set();
7808
+ const trustAll = opts.trustAll ?? process.env["FLOWRIKI_TRUST_COMMANDS"] === "1";
7809
+ const run = async (request) => {
7810
+ const record = {
7811
+ fingerprint: request.fingerprint,
7812
+ name: request.name,
7813
+ program: request.program,
7814
+ args: request.args,
7815
+ folder: request.folder,
7816
+ env: Object.keys(request.env),
7817
+ mode: request.mode,
7818
+ at: (/* @__PURE__ */ new Date()).toISOString()
7819
+ };
7820
+ if (!trustAll) {
7821
+ const approvals = readApprovals();
7822
+ if (!approvals.allowed[request.fingerprint]) {
7823
+ approvals.waiting[request.fingerprint] = record;
7824
+ try {
7825
+ writeApprovals(approvals);
7826
+ } catch {
7827
+ }
7828
+ throw new CommandNotAllowed(record);
7829
+ }
7830
+ }
7831
+ const cwd = request.folder ? path24.resolve(opts.root, request.folder) : opts.root;
7832
+ if (!fs24.existsSync(cwd)) throw new HelperFailure(`the folder ${cwd} does not exist on this computer`);
7833
+ const blank = (text) => request.redact.reduce((out, secret2) => secret2 ? out.split(secret2).join("\u2022\u2022\u2022") : out, text);
7834
+ const line = launchLine(request.program, request.args);
7835
+ return await new Promise((resolve2, reject) => {
7836
+ let stdout = "", stderr = "", settled = false;
7837
+ let child;
7838
+ try {
7839
+ child = spawn2(line.file, line.args, { cwd, env: { ...process.env, ...request.env }, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7840
+ } catch (error) {
7841
+ reject(new HelperFailure(`${request.program} could not be started: ${error.message}`));
7842
+ return;
7843
+ }
7844
+ running.add(child);
7845
+ mine.add(child);
7846
+ const forget = () => {
7847
+ running.delete(child);
7848
+ mine.delete(child);
7849
+ };
7850
+ const finish = (outcome) => {
7851
+ if (settled) return;
7852
+ settled = true;
7853
+ clearTimeout(timer);
7854
+ if (outcome instanceof Error) reject(outcome);
7855
+ else resolve2(outcome);
7856
+ };
7857
+ const ready = request.readyPattern ? new RegExp(request.readyPattern) : null;
7858
+ const take = (into) => (chunk) => {
7859
+ const text = chunk.toString();
7860
+ if (into === "out") stdout = (stdout + text).slice(-KEEP_BYTES);
7861
+ else stderr = (stderr + text).slice(-KEEP_BYTES);
7862
+ if (request.mode === "background" && ready && ready.test(into === "out" ? stdout : stderr)) {
7863
+ finish({ exitCode: null, stdout: blank(stdout), stderr: blank(stderr) });
7864
+ }
7865
+ };
7866
+ child.stdout?.on("data", take("out"));
7867
+ child.stderr?.on("data", take("err"));
7868
+ child.on("error", (error) => {
7869
+ forget();
7870
+ finish(new HelperFailure(error.code === "ENOENT" ? `${request.program} was not found on this computer \u2014 install it, or give its full path` : `${request.program} could not be started: ${error.message}`));
7871
+ });
7872
+ child.on("close", (code) => {
7873
+ forget();
7874
+ if (request.mode === "background" && !settled) {
7875
+ const why = (stderr || stdout).trim().split(/\r?\n/).pop() ?? "";
7876
+ finish(new HelperFailure(`the command stopped before it was ready (exit ${code ?? "?"})${why ? `: ${blank(why).slice(0, 300)}` : ""}`));
7877
+ return;
7878
+ }
7879
+ finish({ exitCode: code, stdout: blank(stdout), stderr: blank(stderr) });
7880
+ });
7881
+ const timer = request.mode === "background" && !ready ? setTimeout(() => finish({ exitCode: null, stdout: blank(stdout), stderr: blank(stderr) }), Math.min(SETTLE_MS, request.timeoutMs)) : setTimeout(() => {
7882
+ stop(child);
7883
+ finish(new HelperFailure(request.mode === "background" ? `the command did not say it was ready within ${Math.round(request.timeoutMs / 1e3)}s` : `the command did not finish within ${Math.round(request.timeoutMs / 1e3)}s, so it was stopped`));
7884
+ }, request.timeoutMs);
7885
+ if (request.mode === "background") {
7886
+ const limit = setTimeout(() => stop(child), BACKGROUND_LIMIT_MS);
7887
+ limit.unref();
7888
+ }
7889
+ });
7890
+ };
7891
+ return {
7892
+ run,
7893
+ /** Stop every background command this runner started — the test is over. */
7894
+ close: () => {
7895
+ for (const child of mine) stop(child);
7896
+ mine.clear();
7897
+ }
7898
+ };
7899
+ }
7521
7900
  var CLOCK_SLACK_MS = 1e4;
7522
- function helperRunner(delivered, secrets, startedAt = /* @__PURE__ */ new Date()) {
7901
+ function helperRunner(delivered, secrets, startedAt = /* @__PURE__ */ new Date(), root = process.cwd()) {
7523
7902
  if (!Array.isArray(delivered) || delivered.length === 0) return void 0;
7524
7903
  const helpers = new Map(
7525
7904
  DeliveredHelperSchema.array().parse(delivered).map((helper) => [helper.id, helper])
7526
7905
  );
7527
7906
  const since = new Date(startedAt.getTime() - CLOCK_SLACK_MS);
7528
- return async (helperId, inputs) => {
7907
+ const commands = [...helpers.values()].some((h) => h.config.kind === "command") ? localCommandRunner({ root }) : void 0;
7908
+ const run = (async (helperId, inputs) => {
7529
7909
  const helper = helpers.get(helperId);
7530
7910
  if (!helper) throw new Error("that helper was deleted, or was not sent with this run \u2014 choose another");
7531
7911
  try {
7532
- const { value, detail } = await runHelper(helper.config, {
7912
+ const { value, detail } = await runHelper(helper, {
7533
7913
  resolveSecret: (ref) => secrets?.[ref],
7534
7914
  inputs,
7535
- since
7915
+ since,
7916
+ ...commands ? { commands } : {}
7536
7917
  });
7537
7918
  return { value, detail: `${helper.name}: ${detail}` };
7538
7919
  } catch (error) {
7539
7920
  throw new Error(`${helper.name}: ${error.message}`);
7540
7921
  }
7541
- };
7922
+ });
7923
+ run.close = () => commands?.close();
7924
+ return run;
7542
7925
  }
7543
7926
  var LOCAL_CHROMIUM2 = { id: "chromium", provider: "local", engine: "chromium" };
7544
7927
  async function summaryOf2(storage, runId) {
@@ -7712,6 +8095,7 @@ async function executeCase(opts) {
7712
8095
  };
7713
8096
  }
7714
8097
  await cloud?.close().catch(() => void 0);
8098
+ helpers?.close();
7715
8099
  if (lane) outcome = { ...outcome, artifacts: [...outcome.artifacts ?? [], ...await lane.close(outcome.status === "passed")] };
7716
8100
  await Promise.all(writes);
7717
8101
  if (storage && run) {
@@ -7733,10 +8117,11 @@ async function executeCase(opts) {
7733
8117
  masked: false
7734
8118
  });
7735
8119
  }
8120
+ void storage.pruneRuns(50).catch(() => void 0);
7736
8121
  await storage.finishRun(run.id, {
7737
8122
  status: outcome.status === "passed" ? "passed" : outcome.error === "the run was cancelled" ? "cancelled" : outcome.status === "failed" ? "failed" : "errored",
7738
8123
  errorMessage: outcome.error,
7739
- artifactsDir: runDir && fs24.existsSync(runDir) ? runDir : null
8124
+ artifactsDir: runDir && fs34.existsSync(runDir) ? runDir : null
7740
8125
  });
7741
8126
  }
7742
8127
  await emit({ type: "case.finished", runId: run?.id ?? "", caseId: testCase.id, outcome });
@@ -7750,6 +8135,10 @@ function redactPanel(state) {
7750
8135
  return { ...panel, steps: panel.steps.map((s2) => redact(s2)) };
7751
8136
  }
7752
8137
  var START_GRACE_MS = 3e3;
8138
+ var IDLE_MS = (() => {
8139
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
8140
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
8141
+ })();
7753
8142
  var RecordingCoordinator = class {
7754
8143
  constructor(deps) {
7755
8144
  this.deps = deps;
@@ -7773,6 +8162,33 @@ var RecordingCoordinator = class {
7773
8162
  * than rebuilding it from the event, which would either lose the values or
7774
8163
  * publish them. */
7775
8164
  lastResult = null;
8165
+ /** When somebody last did anything in the recording. */
8166
+ touchedAt = 0;
8167
+ panelSeen = "";
8168
+ idleTimer = null;
8169
+ stoppingForIdle = false;
8170
+ touch() {
8171
+ this.touchedAt = Date.now();
8172
+ }
8173
+ watchIdle() {
8174
+ const limit = this.deps.idleMs ?? IDLE_MS;
8175
+ this.stopWatchingIdle();
8176
+ if (limit <= 0) return;
8177
+ this.touch();
8178
+ this.idleTimer = setInterval(() => {
8179
+ if (!this.session || this.stopInFlight || Date.now() - this.touchedAt < limit) return;
8180
+ this.stopWatchingIdle();
8181
+ this.stoppingForIdle = true;
8182
+ void (this.draft?.steps.length ? this.stop() : this.discard()).finally(() => {
8183
+ this.stoppingForIdle = false;
8184
+ });
8185
+ }, Math.min(this.deps.idleCheckMs ?? 6e4, limit));
8186
+ this.idleTimer.unref?.();
8187
+ }
8188
+ stopWatchingIdle() {
8189
+ if (this.idleTimer) clearInterval(this.idleTimer);
8190
+ this.idleTimer = null;
8191
+ }
7776
8192
  /** What the last completed stop answered, for a caller that missed it —
7777
8193
  * a dropped socket, or a Stop pressed inside the recorded page. */
7778
8194
  lastStop() {
@@ -7796,6 +8212,7 @@ var RecordingCoordinator = class {
7796
8212
  }
7797
8213
  async answerDialog(accept, promptText) {
7798
8214
  if (!this.session) throw new Error("No recording is active");
8215
+ this.touch();
7799
8216
  await this.session.answerDialog(accept, promptText);
7800
8217
  }
7801
8218
  /** Open a headed browser and start capturing.
@@ -7871,18 +8288,29 @@ var RecordingCoordinator = class {
7871
8288
  // a file nothing ever read.
7872
8289
  environment: ExecutionEnvironmentSchema.parse(opts.environment ?? config.environment),
7873
8290
  headless: opts.headless ?? false,
7874
- onStep: (step) => this.upsert(step),
8291
+ onStep: (step) => {
8292
+ this.touch();
8293
+ this.upsert(step);
8294
+ },
7875
8295
  // The panel carries its OWN copy of every step, and it used to be
7876
8296
  // emitted exactly as the session built it — so while `recording.step`
7877
8297
  // was carefully redacted, the very next frame published the same step
7878
8298
  // with the password still in it. Found by a test that typed a real
7879
8299
  // password into a real browser; nothing about the redacted step event
7880
8300
  // hinted at it.
7881
- onState: (state) => this.deps.emit({ type: "recording.panel", state: redactPanel(state) }),
8301
+ onState: (state) => {
8302
+ const seen = JSON.stringify(state);
8303
+ if (seen !== this.panelSeen) {
8304
+ this.panelSeen = seen;
8305
+ this.touch();
8306
+ }
8307
+ this.deps.emit({ type: "recording.panel", state: redactPanel(state) });
8308
+ },
7882
8309
  // Stop pressed on the floating control inside the recorded page.
7883
8310
  onStopRequested: (commit) => void (commit ? this.stop() : this.discard()),
7884
8311
  onDisconnect: () => {
7885
8312
  this.session = null;
8313
+ this.stopWatchingIdle();
7886
8314
  this.deps.lease?.release("recording");
7887
8315
  this.deps.emit({ type: "recording.interrupted" });
7888
8316
  }
@@ -7898,6 +8326,7 @@ var RecordingCoordinator = class {
7898
8326
  const projectOrigin = configuredBase ? new URL(normalizeStartUrl(configuredBase)).origin : "";
7899
8327
  this.draft.baseUrl = opened.origin === projectOrigin ? "" : opened.origin;
7900
8328
  this.draft.startPath = opened.pathname + opened.search;
8329
+ this.watchIdle();
7901
8330
  this.deps.emit({ type: "recording.started", name: this.draft.name, url: this.session.startUrl });
7902
8331
  return null;
7903
8332
  }
@@ -7921,6 +8350,7 @@ var RecordingCoordinator = class {
7921
8350
  * followed by an await, so two stops arriving together could otherwise both
7922
8351
  * pass it and save the flow twice. */
7923
8352
  stop() {
8353
+ this.stopWatchingIdle();
7924
8354
  if (!this.stopInFlight) {
7925
8355
  this.deps.lease?.beginRelease("recording");
7926
8356
  this.stopInFlight = this.doStop().finally(() => {
@@ -7969,7 +8399,7 @@ var RecordingCoordinator = class {
7969
8399
  const file2 = this.deps.store.saveFlow(doc);
7970
8400
  this.draft = null;
7971
8401
  const saved = { ...this.deps.store.getFlow(doc.id) ?? doc, path: file2 };
7972
- this.deps.emit({ type: "recording.stopped", flow: saved, file: file2 });
8402
+ this.deps.emit({ type: "recording.stopped", flow: saved, file: file2, ...this.stoppingForIdle ? { reason: "idle" } : {} });
7973
8403
  return this.lastResult = { ok: true, flow: saved, secrets: captured };
7974
8404
  }
7975
8405
  /** Throw the recording away: close the browser, drop the draft, write
@@ -7980,6 +8410,7 @@ var RecordingCoordinator = class {
7980
8410
  * time — a password briefly reaches disk for a recording nobody wanted. */
7981
8411
  async discard() {
7982
8412
  if (!this.draft && !this.session) return { ok: false, error: "no recording in progress" };
8413
+ this.stopWatchingIdle();
7983
8414
  this.deps.lease?.beginRelease("recording");
7984
8415
  const ending = this.session;
7985
8416
  this.session = null;
@@ -8122,15 +8553,41 @@ var COMMANDS = /* @__PURE__ */ new Set([
8122
8553
  ]);
8123
8554
  var UnknownDebugCommand = class extends Error {
8124
8555
  };
8556
+ var DEBUG_IDLE_MS = (() => {
8557
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
8558
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
8559
+ })();
8125
8560
  var DebugCoordinator = class {
8126
8561
  constructor(deps) {
8127
8562
  this.deps = deps;
8128
8563
  }
8129
8564
  deps;
8565
+ touchedAt = 0;
8566
+ stateSeen = "";
8567
+ /** The steps as the session opened them: unchanged means nothing to lose. */
8568
+ openedWith = "";
8569
+ idleTimer = null;
8130
8570
  retainCompleted = false;
8131
8571
  lastState = null;
8132
8572
  lastEnd = null;
8133
8573
  current = null;
8574
+ watchIdle() {
8575
+ this.stopWatchingIdle();
8576
+ const limit = this.deps.idleMs ?? DEBUG_IDLE_MS;
8577
+ if (limit <= 0) return;
8578
+ this.touchedAt = Date.now();
8579
+ this.idleTimer = setInterval(() => {
8580
+ if (!this.current || Date.now() - this.touchedAt < limit) return;
8581
+ if (JSON.stringify(this.current.session.state().steps) !== this.openedWith) return;
8582
+ this.stopWatchingIdle();
8583
+ void this.end(false).catch(() => void 0);
8584
+ }, Math.min(this.deps.idleCheckMs ?? 6e4, limit));
8585
+ this.idleTimer.unref?.();
8586
+ }
8587
+ stopWatchingIdle() {
8588
+ if (this.idleTimer) clearInterval(this.idleTimer);
8589
+ this.idleTimer = null;
8590
+ }
8134
8591
  status() {
8135
8592
  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
8593
  }
@@ -8169,11 +8626,19 @@ var DebugCoordinator = class {
8169
8626
  // Secrets are read on THIS machine and handed to the browser here. A
8170
8627
  // hosted dashboard drives the session and never learns a value.
8171
8628
  resolveSecret: (ref) => secrets[ref],
8172
- onState: (state2) => this.deps.emit({ type: "debug.state", flowId: flow.id, state: state2 }),
8629
+ onState: (state2) => {
8630
+ const seen = JSON.stringify(state2);
8631
+ if (seen !== this.stateSeen) {
8632
+ this.stateSeen = seen;
8633
+ this.touchedAt = Date.now();
8634
+ }
8635
+ this.deps.emit({ type: "debug.state", flowId: flow.id, state: state2 });
8636
+ },
8173
8637
  // Save & close / Discard, pressed on the panel inside the page.
8174
8638
  onEndRequested: (commit) => void this.end(commit),
8175
8639
  onDisconnect: () => {
8176
8640
  this.current = null;
8641
+ this.stopWatchingIdle();
8177
8642
  this.deps.lease?.release("debug");
8178
8643
  this.deps.emit({ type: "debug.ended", flowId: flow.id, committed: false });
8179
8644
  }
@@ -8181,6 +8646,8 @@ var DebugCoordinator = class {
8181
8646
  this.deps.lease?.activate("debug");
8182
8647
  this.current = { flowId: flow.id, session };
8183
8648
  const state = session.state();
8649
+ this.openedWith = JSON.stringify(state.steps);
8650
+ this.watchIdle();
8184
8651
  this.deps.emit({ type: "debug.state", flowId: flow.id, state });
8185
8652
  return { flowId: flow.id, state };
8186
8653
  } catch (err) {
@@ -8191,6 +8658,7 @@ var DebugCoordinator = class {
8191
8658
  async command(command, args = {}) {
8192
8659
  if (!this.current) throw new Error("no debug session is running");
8193
8660
  if (!COMMANDS.has(command)) throw new UnknownDebugCommand(`unknown command: ${command}`);
8661
+ this.touchedAt = Date.now();
8194
8662
  const s2 = this.current.session;
8195
8663
  switch (command) {
8196
8664
  case "play":
@@ -8233,6 +8701,7 @@ var DebugCoordinator = class {
8233
8701
  }
8234
8702
  const { flowId, session } = this.current;
8235
8703
  this.current = null;
8704
+ this.stopWatchingIdle();
8236
8705
  this.deps.lease?.beginRelease("debug");
8237
8706
  try {
8238
8707
  if (!commit) {
@@ -8337,10 +8806,10 @@ async function verifyFlowSteps(deps, opts) {
8337
8806
  }
8338
8807
  function deliveredRun(delivered) {
8339
8808
  const { flow, secrets, config } = delivered;
8340
- const runHelper2 = helperRunner(delivered.helpers, secrets);
8809
+ const runHelper3 = helperRunner(delivered.helpers, secrets);
8341
8810
  return {
8342
8811
  resolveAuth: authenticationResolver(delivered.authentications),
8343
- ...runHelper2 ? { runHelper: runHelper2 } : {},
8812
+ ...runHelper3 ? { runHelper: runHelper3 } : {},
8344
8813
  readConfig: () => config,
8345
8814
  // By id, and only the one that was sent. A machine serving no checkout has
8346
8815
  // no other flow to confuse it with.
@@ -8388,7 +8857,7 @@ function resolveCheckouts(dirs) {
8388
8857
  // src/engine/session.ts
8389
8858
  import fs12 from "fs";
8390
8859
 
8391
- // ../agent-core/dist/chunk-YAPHJ4AT.js
8860
+ // ../agent-core/dist/chunk-CRKFBFSC.js
8392
8861
  import { WebSocket, WebSocketServer } from "ws";
8393
8862
  var HEARTBEAT_MS = 3e4;
8394
8863
  function adapt(socket) {
@@ -8565,6 +9034,27 @@ var EventJournal = class {
8565
9034
  }
8566
9035
  };
8567
9036
  var { DatabaseSync } = createRequire4(import.meta.url)("node:sqlite");
9037
+ var LOCAL_LIMIT = 64 * 1024;
9038
+ function fitted(value, limit = LOCAL_LIMIT) {
9039
+ const text = JSON.stringify(value);
9040
+ if (Buffer.byteLength(text) <= limit) return text;
9041
+ for (const [maxText, maxItems] of [[2e3, 400], [500, 120], [200, 40], [80, 12]]) {
9042
+ const smaller = JSON.stringify(shrink(value, maxText, maxItems));
9043
+ if (Buffer.byteLength(smaller) <= limit) return smaller;
9044
+ }
9045
+ throw new Error("event exceeds local size limit even when shortened");
9046
+ }
9047
+ function shrink(value, maxText, maxItems) {
9048
+ if (typeof value === "string") return value.length > maxText ? `${value.slice(0, maxText)}\u2026 (cut)` : value;
9049
+ if (Array.isArray(value)) {
9050
+ 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;
9051
+ return items.map((v) => shrink(v, maxText, maxItems));
9052
+ }
9053
+ if (value && typeof value === "object") {
9054
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shrink(v, maxText, maxItems)]));
9055
+ }
9056
+ return value;
9057
+ }
8568
9058
  var AgentState = class {
8569
9059
  constructor(file2, deviceId, commandLimit = 1e5) {
8570
9060
  this.commandLimit = commandLimit;
@@ -8635,6 +9125,10 @@ var AgentState = class {
8635
9125
  begin(command, fingerprint2) {
8636
9126
  return this.transaction(() => {
8637
9127
  if (this.get(command.id)) return false;
9128
+ if (Number(this.db.prepare("SELECT count(*) AS n FROM commands").get()?.n) >= this.commandLimit) {
9129
+ const cutoff = (command.issuedAt ?? command.expiresAt) - 30 * 24 * 60 * 6e4;
9130
+ if (cutoff > 0) this.db.prepare("DELETE FROM commands WHERE state IN ('completed','retired') AND finished_at < ?").run(cutoff);
9131
+ }
8638
9132
  if (Number(this.db.prepare("SELECT count(*) AS n FROM commands").get()?.n) >= this.commandLimit) {
8639
9133
  throw new Error("command ledger capacity reached; retain state and reconcile before further work");
8640
9134
  }
@@ -8643,8 +9137,7 @@ var AgentState = class {
8643
9137
  });
8644
9138
  }
8645
9139
  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");
9140
+ const text = fitted(AckSchema.parse(ack));
8648
9141
  const result = this.db.prepare("UPDATE commands SET state='completed', ack=?, finished_at=? WHERE id=? AND state='started'").run(text, now, id);
8649
9142
  if (Number(result.changes) !== 1) throw new Error("command intent is unavailable");
8650
9143
  }
@@ -8657,8 +9150,7 @@ var AgentState = class {
8657
9150
  this.db.prepare("UPDATE commands SET state='retired', ack=NULL WHERE state='completed' AND finished_at < ? AND expires_at <= ?").run(cutoff, now);
8658
9151
  }
8659
9152
  append(event, limit) {
8660
- const text = JSON.stringify(event);
8661
- if (Buffer.byteLength(text) > 64 * 1024) throw new Error("event exceeds local size limit");
9153
+ const text = fitted(event);
8662
9154
  return this.transaction(() => {
8663
9155
  const row = this.db.prepare("INSERT INTO events(payload) VALUES (?)").run(text);
8664
9156
  const seq = Number(row.lastInsertRowid) - 1;
@@ -8687,6 +9179,13 @@ var AgentState = class {
8687
9179
  this.db.close();
8688
9180
  }
8689
9181
  };
9182
+ function withoutSecrets(value) {
9183
+ if (Array.isArray(value)) return value.map(withoutSecrets);
9184
+ if (value && typeof value === "object") {
9185
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, k === "secrets" ? {} : withoutSecrets(v)]));
9186
+ }
9187
+ return value;
9188
+ }
8690
9189
  var Agent = class {
8691
9190
  constructor(opts) {
8692
9191
  this.opts = opts;
@@ -8785,10 +9284,10 @@ var Agent = class {
8785
9284
  if (!allowed.ok) return this.refuse(command.id, allowed.reason);
8786
9285
  if (this.closed) return this.refuse(command.id, "storage_unavailable", "the agent is closed");
8787
9286
  const fingerprint2 = AgentState.fingerprint(command);
8788
- const running = this.inFlight.get(command.id);
8789
- if (running) {
8790
- if (running.fingerprint !== fingerprint2) return this.refuse(command.id, "command_conflict", "this command ID names different work");
8791
- return { ...await running.promise, status: "duplicate" };
9287
+ const running2 = this.inFlight.get(command.id);
9288
+ if (running2) {
9289
+ if (running2.fingerprint !== fingerprint2) return this.refuse(command.id, "command_conflict", "this command ID names different work");
9290
+ return { ...await running2.promise, status: "duplicate" };
8792
9291
  }
8793
9292
  const previous = this.previous(command.id, fingerprint2);
8794
9293
  if (previous) return previous;
@@ -8824,7 +9323,7 @@ var Agent = class {
8824
9323
  ack = this.refuse(command.id, "failed", String(err instanceof Error ? err.message : err).split("\n")[0]);
8825
9324
  }
8826
9325
  if (EFFECTFUL.has(command.name)) {
8827
- this.state.finish(command.id, ack, this.now());
9326
+ this.state.finish(command.id, withoutSecrets(ack), this.now());
8828
9327
  }
8829
9328
  return ack;
8830
9329
  }
@@ -8989,18 +9488,19 @@ var AgentLink = class {
8989
9488
 
8990
9489
  // ../provider-local/dist/index.js
8991
9490
  import fs10 from "fs";
8992
- import os5 from "os";
9491
+ import os6 from "os";
8993
9492
  import path10 from "path";
8994
9493
  import fs25 from "fs";
8995
- import path24 from "path";
9494
+ import path25 from "path";
8996
9495
  import { randomUUID } from "crypto";
8997
9496
  import { execFile } from "child_process";
8998
- import fs34 from "fs";
9497
+ import fs35 from "fs";
8999
9498
  import path34 from "path";
9000
9499
  import { randomUUID as randomUUID2 } from "crypto";
9500
+ import os22 from "os";
9001
9501
  import { spawn as nodeSpawn } from "child_process";
9002
9502
  import fs43 from "fs";
9003
- import os22 from "os";
9503
+ import os32 from "os";
9004
9504
  import path42 from "path";
9005
9505
  var NO_CHECKOUT = {
9006
9506
  ok: false,
@@ -9011,7 +9511,7 @@ var scratchRoots = /* @__PURE__ */ new Map();
9011
9511
  function scratchRoot(projectId) {
9012
9512
  let root = scratchRoots.get(projectId);
9013
9513
  if (!root) {
9014
- root = fs10.mkdtempSync(path10.join(os5.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
9514
+ root = fs10.mkdtempSync(path10.join(os6.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
9015
9515
  scratchRoots.set(projectId, root);
9016
9516
  }
9017
9517
  return root;
@@ -9245,7 +9745,7 @@ function historyHandlers({ storage, artifactLimit }) {
9245
9745
  const artifact = run.artifacts.find((a) => a.id === artifactId);
9246
9746
  if (!artifact) return { ok: false, reason: "failed", detail: "no such artifact on that run" };
9247
9747
  const dir = run.artifactsDir ?? runArtifactsDir(runId, run.projectId);
9248
- const file2 = path24.join(dir, artifact.relPath);
9748
+ const file2 = path25.join(dir, artifact.relPath);
9249
9749
  if (!isInside(dir, file2) || !fs25.existsSync(file2) || !isInside(fs25.realpathSync(dir), fs25.realpathSync(file2))) {
9250
9750
  return { ok: false, reason: "failed", detail: "the artifact file is not on this machine" };
9251
9751
  }
@@ -9676,7 +10176,7 @@ function debuggingHandlers(ctx) {
9676
10176
  var file = () => path34.join(flowrikiHome(), "assistant-folders.json");
9677
10177
  function readAll() {
9678
10178
  try {
9679
- const parsed = JSON.parse(fs34.readFileSync(file(), "utf8"));
10179
+ const parsed = JSON.parse(fs35.readFileSync(file(), "utf8"));
9680
10180
  if (!parsed || typeof parsed !== "object") return {};
9681
10181
  const out = {};
9682
10182
  for (const [id, value] of Object.entries(parsed)) {
@@ -9689,18 +10189,18 @@ function readAll() {
9689
10189
  }
9690
10190
  }
9691
10191
  function writeAll(all) {
9692
- fs34.mkdirSync(path34.dirname(file()), { recursive: true, mode: 448 });
9693
- fs34.writeFileSync(file(), JSON.stringify(all, null, 2), { mode: 384 });
10192
+ fs35.mkdirSync(path34.dirname(file()), { recursive: true, mode: 448 });
10193
+ fs35.writeFileSync(file(), JSON.stringify(all, null, 2), { mode: 384 });
9694
10194
  }
9695
10195
  function gitHead(folder) {
9696
10196
  try {
9697
- const head = fs34.readFileSync(path34.join(folder, ".git", "HEAD"), "utf8").trim();
10197
+ const head = fs35.readFileSync(path34.join(folder, ".git", "HEAD"), "utf8").trim();
9698
10198
  const ref = /^ref: (.+)$/.exec(head)?.[1];
9699
10199
  if (!ref) return { commit: /^[0-9a-f]{40}$/.test(head) ? head : null, branch: null };
9700
10200
  const branch = ref.replace(/^refs\/heads\//, "");
9701
10201
  const loose = path34.join(folder, ".git", ref);
9702
- if (fs34.existsSync(loose)) return { commit: fs34.readFileSync(loose, "utf8").trim(), branch };
9703
- const packed = fs34.readFileSync(path34.join(folder, ".git", "packed-refs"), "utf8");
10202
+ if (fs35.existsSync(loose)) return { commit: fs35.readFileSync(loose, "utf8").trim(), branch };
10203
+ const packed = fs35.readFileSync(path34.join(folder, ".git", "packed-refs"), "utf8");
9704
10204
  const line = packed.split("\n").find((l) => l.endsWith(` ${ref}`));
9705
10205
  return { commit: line?.split(" ")[0] ?? null, branch };
9706
10206
  } catch {
@@ -9710,7 +10210,7 @@ function gitHead(folder) {
9710
10210
  function describeFolder(folder, name = path34.basename(folder)) {
9711
10211
  const exists = (() => {
9712
10212
  try {
9713
- return fs34.statSync(folder).isDirectory();
10213
+ return fs35.statSync(folder).isDirectory();
9714
10214
  } catch {
9715
10215
  return false;
9716
10216
  }
@@ -9859,6 +10359,12 @@ var runFile = (file2, args, timeoutMs) => new Promise((resolve2, reject) => {
9859
10359
  });
9860
10360
  var live = /* @__PURE__ */ new Map();
9861
10361
  var liveById = (sessionId) => [...live.values()].find((l) => l.id === sessionId);
10362
+ var IDLE_MS2 = (() => {
10363
+ const minutes = Number(process.env["FLOWRIKI_RECORDING_IDLE_MINUTES"] ?? 30);
10364
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes * 6e4 : 30 * 6e4;
10365
+ })();
10366
+ var parked = /* @__PURE__ */ new Map();
10367
+ var PARKED_FOR_MS = 24 * 60 * 6e4;
9862
10368
  var ownerOf = (l) => l.draft ? "recording" : "debug";
9863
10369
  var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|\bpin\b|cvv|cvc/i;
9864
10370
  async function screenOf(l, withImage = false) {
@@ -9886,12 +10392,31 @@ async function screenOf(l, withImage = false) {
9886
10392
  device: l.session.deviceName
9887
10393
  };
9888
10394
  }
10395
+ async function closeIdlePhoneSessions(release, limit = IDLE_MS2, now = Date.now()) {
10396
+ for (const [id, kept] of parked) if (now - kept.at > PARKED_FOR_MS) parked.delete(id);
10397
+ if (limit <= 0) return;
10398
+ for (const l of [...live.values()]) {
10399
+ if (now - l.touchedAt < limit) continue;
10400
+ live.delete(l.platform);
10401
+ release(ownerOf(l), l.platform);
10402
+ await l.session.close().catch(() => void 0);
10403
+ if (l.draft?.steps.length) parked.set(l.id, { l, at: now });
10404
+ }
10405
+ }
9889
10406
  function mobileHandlers(ctx) {
9890
10407
  const require2 = (command) => {
9891
10408
  const l = liveById(command.args["sessionId"]);
9892
- if (!l) throw new Error("that phone session has ended \u2014 open it again");
9893
- return l;
10409
+ if (l) {
10410
+ l.touchedAt = Date.now();
10411
+ return l;
10412
+ }
10413
+ if (parked.has(String(command.args["sessionId"]))) {
10414
+ throw new Error("this phone was let go after a long time with nothing done \u2014 press Stop to save what was recorded");
10415
+ }
10416
+ throw new Error("that phone session has ended \u2014 open it again");
9894
10417
  };
10418
+ const sweeper = setInterval(() => void closeIdlePhoneSessions((owner, platform) => ctx.lease?.release(owner, platform)), 6e4);
10419
+ sweeper.unref?.();
9895
10420
  return {
9896
10421
  // Everything this computer has for testing — web and phones — for the Guide.
9897
10422
  ...ctx.deps.machineCheck ? {
@@ -9965,6 +10490,7 @@ function mobileHandlers(ctx) {
9965
10490
  platform,
9966
10491
  values: /* @__PURE__ */ new Map(),
9967
10492
  typed,
10493
+ touchedAt: Date.now(),
9968
10494
  resolveSecret: (ref) => typed.get(ref) ?? secrets[ref],
9969
10495
  resolveAuth: authenticationResolver(command.args["authentications"]),
9970
10496
  runHelper: helperRunner(command.args["helpers"], secrets)
@@ -9994,6 +10520,12 @@ function mobileHandlers(ctx) {
9994
10520
  }
9995
10521
  return { ok: true, result: { sessionId: l2.id, screen: await screenOf(l2) } };
9996
10522
  }
10523
+ const resting = parked.get(String(command.args["sessionId"]));
10524
+ if (resting && (op === "stop" || op === "discard")) {
10525
+ parked.delete(String(command.args["sessionId"]));
10526
+ if (op === "discard") return { ok: true, result: { ended: true } };
10527
+ return { ok: true, result: finished(resting.l) };
10528
+ }
9997
10529
  const l = require2(command);
9998
10530
  if (op === "act" || op === "run") {
9999
10531
  const raw = command.args["step"] ?? {};
@@ -10069,24 +10601,7 @@ function mobileHandlers(ctx) {
10069
10601
  ctx.lease?.release(ownerOf(l), l.platform);
10070
10602
  await l.session.close().catch(() => void 0);
10071
10603
  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) } };
10604
+ return { ok: true, result: finished(l) };
10090
10605
  }
10091
10606
  return { ok: false, reason: "failed", detail: `unknown phone session operation "${op}"` };
10092
10607
  } catch (error) {
@@ -10096,6 +10611,52 @@ function mobileHandlers(ctx) {
10096
10611
  }
10097
10612
  };
10098
10613
  }
10614
+ function finished(l) {
10615
+ const now = (/* @__PURE__ */ new Date()).toISOString();
10616
+ const d = l.draft;
10617
+ const flow = {
10618
+ schemaVersion: CURRENT_SCHEMA_VERSION,
10619
+ id: `flw_${randomUUID2().replace(/-/g, "").slice(0, 12)}`,
10620
+ featureId: d.featureId,
10621
+ name: d.name,
10622
+ slug: slugify(d.name) || "flow",
10623
+ startPath: d.startPath,
10624
+ platform: l.platform,
10625
+ recordedAt: now,
10626
+ recordedBy: d.recordedBy,
10627
+ updatedAt: now,
10628
+ revision: 0,
10629
+ steps: d.steps,
10630
+ ...d.signIn ? { signIn: d.signIn } : {}
10631
+ };
10632
+ return { flow, secrets: Object.fromEntries(l.typed) };
10633
+ }
10634
+ function helperHandlers(_ctx) {
10635
+ return {
10636
+ "helper.run": async (command, project) => {
10637
+ const parsed = DeliveredHelperSchema.safeParse(command.args["helper"]);
10638
+ if (!parsed.success) return { ok: false, reason: "failed", detail: "that helper could not be read" };
10639
+ const secrets = command.args["secrets"] ?? {};
10640
+ const inputs = command.args["inputs"] ?? {};
10641
+ const commands = localCommandRunner({ root: project.root || os22.homedir() });
10642
+ const started = Date.now();
10643
+ try {
10644
+ const result = await runHelper(parsed.data, { resolveSecret: (ref) => secrets[ref], inputs, commands });
10645
+ return { ok: true, result: { ...result, tookMs: Date.now() - started } };
10646
+ } catch (error) {
10647
+ const notAllowed = error instanceof CommandNotAllowed;
10648
+ return {
10649
+ ok: false,
10650
+ reason: "failed",
10651
+ detail: error instanceof HelperFailure ? error.message : `the helper failed: ${error.message}`,
10652
+ ...notAllowed ? { code: "command_not_allowed" } : {}
10653
+ };
10654
+ } finally {
10655
+ commands.close();
10656
+ }
10657
+ }
10658
+ };
10659
+ }
10099
10660
  function buildHandlers(deps) {
10100
10661
  const context = {
10101
10662
  deps,
@@ -10124,6 +10685,7 @@ function buildHandlers(deps) {
10124
10685
  ...recordingHandlers(context),
10125
10686
  ...assistantHandlers(context),
10126
10687
  ...mobileHandlers(context),
10688
+ ...helperHandlers(context),
10127
10689
  ...debug
10128
10690
  };
10129
10691
  }
@@ -10154,6 +10716,20 @@ function toolArgs(tool, input) {
10154
10716
  if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
10155
10717
  }
10156
10718
  if (record["newBranch"] === true) out["newBranch"] = true;
10719
+ if (typeof record["case"] === "string" && record["case"]) out["case"] = clip(record["case"], 40);
10720
+ if (Array.isArray(record["features"])) {
10721
+ const cases = [];
10722
+ for (const feature of record["features"]) {
10723
+ if (!feature || typeof feature !== "object") continue;
10724
+ const f = feature;
10725
+ const folder = typeof f["feature"] === "string" ? f["feature"] : typeof f["name"] === "string" ? f["name"] : "";
10726
+ for (const c of Array.isArray(f["cases"]) ? f["cases"] : []) {
10727
+ const name = c && typeof c === "object" ? c["name"] : void 0;
10728
+ if (typeof name === "string" && name && cases.length < 40) cases.push({ folder: clip(folder), name: clip(name) });
10729
+ }
10730
+ }
10731
+ if (cases.length) out["cases"] = cases;
10732
+ }
10157
10733
  if (tool === "add_check" && typeof record["value"] === "string") out["value"] = clip(record["value"], 120);
10158
10734
  if (Array.isArray(record["after"])) {
10159
10735
  out["after"] = record["after"].filter((v) => typeof v === "string").slice(0, 5).map((v) => clip(v));
@@ -10185,13 +10761,65 @@ var ASK_MODE_TOOLS = [
10185
10761
  "list_my_branches",
10186
10762
  "run_insights"
10187
10763
  ];
10764
+ var NOT_RECORDING = [
10765
+ "create_project",
10766
+ "list_projects",
10767
+ "read_import_file",
10768
+ "save_import_cases",
10769
+ "read_update_request",
10770
+ "propose_case_updates",
10771
+ "write_test_cases",
10772
+ "gather_branches",
10773
+ "discard_branch"
10774
+ ];
10775
+ var NOT_SAVING_TESTS = [
10776
+ "save_flow",
10777
+ "save_case",
10778
+ "preview_case",
10779
+ "start_case",
10780
+ "continue_flow",
10781
+ "remove_step",
10782
+ "replay_flow",
10783
+ "add_check",
10784
+ "use_helper",
10785
+ "create_sign_in",
10786
+ "create_account",
10787
+ "create_helper",
10788
+ "report_blocker"
10789
+ ];
10790
+ var JOB_TOOLS_EXCEPT = {
10791
+ heal: [...NOT_RECORDING, "create_sign_in", "create_account"],
10792
+ automate: NOT_RECORDING,
10793
+ reautomate: NOT_RECORDING,
10794
+ update: NOT_RECORDING.filter((t) => t !== "read_update_request" && t !== "propose_case_updates"),
10795
+ explore: [...NOT_RECORDING, ...NOT_SAVING_TESTS],
10796
+ // Writing cases may still set up a sign-in it finds missing.
10797
+ write: [
10798
+ "create_project",
10799
+ "list_projects",
10800
+ "gather_branches",
10801
+ "discard_branch",
10802
+ "read_update_request",
10803
+ "propose_case_updates",
10804
+ ...NOT_SAVING_TESTS.filter((t) => !t.startsWith("create_"))
10805
+ ]
10806
+ };
10807
+ function toolsExceptFor(prompt, briefGiven) {
10808
+ const except = [...JOB_TOOLS_EXCEPT[usageKindOf(prompt)] ?? []];
10809
+ if (briefGiven) except.push("project_brief");
10810
+ return except;
10811
+ }
10188
10812
  function buildAskArgs(opts) {
10189
10813
  const ask = opts.mode === "ask";
10190
10814
  const folders = opts.folders ?? [];
10191
10815
  const brief = opts.brief?.trim();
10192
10816
  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.",
10817
+ `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."),
10818
+ 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.",
10819
+ // SPENDING. Every turn re-reads the whole conversation, so turns are the
10820
+ // cost: do several steps per act call, don't re-read a page a step already
10821
+ // returned, and stop instead of retrying the same failing step.
10822
+ 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
10823
  "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
10824
  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
10825
  brief ? `
@@ -10221,9 +10849,25 @@ ${brief}` : ""
10221
10849
  "dontAsk",
10222
10850
  "--append-system-prompt",
10223
10851
  guidance,
10852
+ // THE MODEL, SAID — not whatever the person's default is. Recording and
10853
+ // automating tests is what Sonnet does well; a default of Opus costs
10854
+ // several times as much for the same tests. FLOWRIKI_ASSISTANT_MODEL
10855
+ // overrides it.
10856
+ "--model",
10857
+ opts.model ?? (process.env["FLOWRIKI_ASSISTANT_MODEL"]?.trim() || "sonnet"),
10858
+ // A ceiling, not a target: a job that has taken this many turns is going
10859
+ // round in circles. What it saved stays; "continue" carries on.
10860
+ "--max-turns",
10861
+ String(opts.maxTurns ?? (ask ? 40 : 160)),
10224
10862
  ...opts.sessionId ? ["--resume", opts.sessionId] : []
10225
10863
  ];
10226
10864
  }
10865
+ var tokensOf = (u) => ({
10866
+ input: u.input_tokens ?? 0,
10867
+ output: u.output_tokens ?? 0,
10868
+ cacheRead: u.cache_read_input_tokens ?? 0,
10869
+ cacheWrite: u.cache_creation_input_tokens ?? 0
10870
+ });
10227
10871
  function clearOld(dir) {
10228
10872
  try {
10229
10873
  for (const name of fs43.readdirSync(dir)) {
@@ -10248,11 +10892,13 @@ function eventsFromLine(askId, line) {
10248
10892
  if (parsed.session_id) out.push({ name: "assistant.session", data: { askId, sessionId: parsed.session_id } });
10249
10893
  }
10250
10894
  if (parsed.type === "assistant") {
10895
+ const tools = [];
10251
10896
  for (const block of parsed.message?.content ?? []) {
10252
10897
  if (block.type === "text" && block.text?.trim()) {
10253
10898
  out.push({ name: "assistant.text", data: { askId, text: block.text } });
10254
10899
  } else if (block.type === "tool_use" && block.name) {
10255
10900
  const tool = block.name.replace(TOOL_PREFIX, "");
10901
+ tools.push(tool);
10256
10902
  out.push({
10257
10903
  name: "assistant.tool",
10258
10904
  data: {
@@ -10265,6 +10911,12 @@ function eventsFromLine(askId, line) {
10265
10911
  });
10266
10912
  }
10267
10913
  }
10914
+ if (parsed.message?.usage && parsed.message.id) {
10915
+ out.push({
10916
+ name: "assistant.usage",
10917
+ data: { askId, messageId: parsed.message.id, tools, ...tokensOf(parsed.message.usage), ...parsed.message.model ? { model: parsed.message.model } : {} }
10918
+ });
10919
+ }
10268
10920
  }
10269
10921
  if (parsed.type === "user") {
10270
10922
  for (const block of parsed.message?.content ?? []) {
@@ -10278,6 +10930,20 @@ function eventsFromLine(askId, line) {
10278
10930
  });
10279
10931
  }
10280
10932
  }
10933
+ if (parsed.type === "rate_limit_event" && parsed.rate_limit_info) {
10934
+ const info = parsed.rate_limit_info;
10935
+ out.push({
10936
+ name: "assistant.limits",
10937
+ data: {
10938
+ askId,
10939
+ ...typeof info.status === "string" ? { status: info.status } : {},
10940
+ ...typeof info.rateLimitType === "string" ? { window: info.rateLimitType } : {},
10941
+ ...typeof info.utilization === "number" ? { utilization: info.utilization } : {},
10942
+ ...typeof info.resetsAt === "number" ? { resetsAt: info.resetsAt } : {},
10943
+ ...info.isUsingOverage === true ? { overage: true } : {}
10944
+ }
10945
+ });
10946
+ }
10281
10947
  if (parsed.type === "result") {
10282
10948
  out.push({
10283
10949
  name: "assistant.done",
@@ -10285,20 +10951,43 @@ function eventsFromLine(askId, line) {
10285
10951
  askId,
10286
10952
  ok: parsed.is_error !== true,
10287
10953
  result: typeof parsed.result === "string" ? parsed.result : "",
10288
- ...parsed.session_id ? { sessionId: parsed.session_id } : {}
10954
+ ...parsed.session_id ? { sessionId: parsed.session_id } : {},
10955
+ // The whole job, as the model counted it.
10956
+ ...parsed.usage ? { usage: tokensOf(parsed.usage) } : {},
10957
+ ...typeof parsed.total_cost_usd === "number" ? { costUsd: parsed.total_cost_usd } : {},
10958
+ ...typeof parsed.num_turns === "number" ? { turns: parsed.num_turns } : {},
10959
+ ...typeof parsed.duration_ms === "number" ? { durationMs: parsed.duration_ms } : {}
10289
10960
  }
10290
10961
  });
10291
10962
  }
10292
10963
  return out;
10293
10964
  }
10965
+ function stop2(child) {
10966
+ child.kill("SIGTERM");
10967
+ const force = setTimeout(() => {
10968
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
10969
+ }, 5e3);
10970
+ force.unref?.();
10971
+ }
10294
10972
  function createClaudeCli(options) {
10295
- const spawn2 = options.spawn ?? nodeSpawn;
10973
+ const spawn3 = options.spawn ?? nodeSpawn;
10296
10974
  const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
10297
10975
  const maxAskMs = options.maxAskMs ?? 60 * 6e4;
10298
- const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
10299
- const running = /* @__PURE__ */ new Map();
10976
+ const scratchRoot2 = options.scratchRoot ?? path42.join(os32.tmpdir(), "flowriki-claude");
10977
+ const running2 = /* @__PURE__ */ new Map();
10300
10978
  const MAX_AT_ONCE = 3;
10301
- let cancelled = null;
10979
+ process.once("exit", () => {
10980
+ for (const child of running2.values()) {
10981
+ try {
10982
+ child.kill("SIGTERM");
10983
+ } catch {
10984
+ }
10985
+ }
10986
+ });
10987
+ const cancelled = /* @__PURE__ */ new Set();
10988
+ const retrying = /* @__PURE__ */ new Set();
10989
+ const sessionsInUse = /* @__PURE__ */ new Set();
10990
+ const occupied = () => running2.size + retrying.size;
10302
10991
  function capture(args, timeoutMs = 15e3) {
10303
10992
  return new Promise((resolve2) => {
10304
10993
  let out = "", err = "", settled = false;
@@ -10310,7 +10999,7 @@ function createClaudeCli(options) {
10310
10999
  };
10311
11000
  let child;
10312
11001
  try {
10313
- child = spawn2(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
11002
+ child = spawn3(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
10314
11003
  } catch {
10315
11004
  return resolve2({ code: null, out: "", err: "", failed: true });
10316
11005
  }
@@ -10328,8 +11017,8 @@ function createClaudeCli(options) {
10328
11017
  child.on("close", (code) => finish(code, false));
10329
11018
  });
10330
11019
  }
10331
- const LANE_FRESH_MS = 60 * 6e4;
10332
- const LANE_MAX_TOKENS = 15e4;
11020
+ const LANE_FRESH_MS = 5 * 6e4;
11021
+ const LANE_MAX_TOKENS = 4e4;
10333
11022
  const laneFile = (dir) => path42.join(dir, "lane-sessions.json");
10334
11023
  const readLanes = (dir) => {
10335
11024
  try {
@@ -10355,6 +11044,8 @@ function createClaudeCli(options) {
10355
11044
  }
10356
11045
  const kept = readLanes(dir)[ask.platform];
10357
11046
  if (!kept || Date.now() - kept.updatedAt > LANE_FRESH_MS || kept.tokens > LANE_MAX_TOKENS) return void 0;
11047
+ if (sessionsInUse.has(kept.sessionId)) return void 0;
11048
+ if ((kept.kind ?? "chat") !== usageKindOf(ask.prompt)) return void 0;
10358
11049
  return kept.sessionId;
10359
11050
  }
10360
11051
  return {
@@ -10368,17 +11059,17 @@ function createClaudeCli(options) {
10368
11059
  return { installed: true, version: version.out.trim().split(/\s+/)[0] ?? null, ...parsed };
10369
11060
  },
10370
11061
  connect(emit) {
10371
- if (running.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
11062
+ if (running2.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
10372
11063
  let child;
10373
11064
  try {
10374
- child = spawn2(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
11065
+ child = spawn3(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
10375
11066
  } catch {
10376
11067
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10377
11068
  }
10378
- let finished = false;
11069
+ let finished2 = false;
10379
11070
  const done = async (detail) => {
10380
- if (finished) return;
10381
- finished = true;
11071
+ if (finished2) return;
11072
+ finished2 = true;
10382
11073
  const status2 = await this.status();
10383
11074
  emit("assistant.connect.finished", { signedIn: status2.signedIn, ...detail ? { detail } : {} });
10384
11075
  };
@@ -10398,37 +11089,55 @@ function createClaudeCli(options) {
10398
11089
  return { started: true };
10399
11090
  },
10400
11091
  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.` };
11092
+ 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
11093
  fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
10403
11094
  const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
10404
11095
  fs43.mkdirSync(dir, { recursive: true, mode: 448 });
10405
11096
  clearOld(dir);
10406
11097
  const mcpConfigPath = path42.join(dir, `mcp-${ask.askId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
11098
+ const session = laneSession(ask, dir);
11099
+ const except = ask.mode === "ask" ? [] : toolsExceptFor(ask.prompt, Boolean(ask.brief && !session));
10407
11100
  fs43.writeFileSync(
10408
11101
  mcpConfigPath,
10409
- JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args, env: { FLOWRIKI_ASK_ID: ask.askId } } } }),
11102
+ JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args, env: {
11103
+ FLOWRIKI_ASK_ID: ask.askId,
11104
+ // The project, known from the start: no "which project?" turn.
11105
+ FLOWRIKI_PROJECT_ID: ask.projectId,
11106
+ // The agent that launched it decides its version: the tool server
11107
+ // does not update itself in the middle of somebody's job.
11108
+ FLOWRIKI_NO_UPDATE: "1",
11109
+ FLOWRIKI_MCP_CHILD: "1",
11110
+ // A question: only the reading tools are served at all.
11111
+ ...ask.mode === "ask" ? { FLOWRIKI_TOOLS: ASK_MODE_TOOLS.join(",") } : {},
11112
+ // A job of a known kind: the tools it never uses are not served.
11113
+ ...except.length ? { FLOWRIKI_TOOLS_EXCEPT: except.join(",") } : {}
11114
+ } } } }),
10410
11115
  { mode: 384 }
10411
11116
  );
10412
- return launch(ask, dir, mcpConfigPath, laneSession(ask, dir));
11117
+ return launch(ask, dir, mcpConfigPath, session);
10413
11118
  },
10414
11119
  cancel(askId) {
10415
- const child = running.get(askId);
11120
+ if (retrying.has(askId)) {
11121
+ cancelled.add(askId);
11122
+ return true;
11123
+ }
11124
+ const child = running2.get(askId);
10416
11125
  if (!child) return false;
10417
- cancelled = askId;
10418
- child.kill("SIGTERM");
11126
+ cancelled.add(askId);
11127
+ stop2(child);
10419
11128
  setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
10420
11129
  return true;
10421
11130
  },
10422
11131
  /** Full — no room for another question — rather than "something runs":
10423
11132
  * a second conversation is welcome while the first works. */
10424
11133
  busy() {
10425
- return running.size >= MAX_AT_ONCE;
11134
+ return occupied() >= MAX_AT_ONCE;
10426
11135
  }
10427
11136
  };
10428
11137
  function launch(ask, dir, mcpConfigPath, sessionId, attempt = 1) {
10429
11138
  let child;
10430
11139
  try {
10431
- child = spawn2(
11140
+ child = spawn3(
10432
11141
  binary,
10433
11142
  buildAskArgs({
10434
11143
  prompt: ask.prompt,
@@ -10438,20 +11147,26 @@ function createClaudeCli(options) {
10438
11147
  ...sessionId ? { sessionId } : {},
10439
11148
  ...ask.folders?.length ? { folders: ask.folders } : {},
10440
11149
  ...ask.mode ? { mode: ask.mode } : {},
10441
- ...ask.brief ? { brief: ask.brief } : {}
11150
+ // Resumed, the conversation already holds its brief: a changed
11151
+ // one in the system prompt would throw away the whole cache.
11152
+ ...ask.brief && !sessionId ? { brief: ask.brief } : {}
10442
11153
  }),
10443
11154
  // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10444
- { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
11155
+ // Thinking is output, priced at five times input: capped per turn,
11156
+ // unless the person set their own.
11157
+ { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, MAX_THINKING_TOKENS: process.env["MAX_THINKING_TOKENS"] ?? "6000" } }
10445
11158
  );
10446
11159
  } catch {
10447
11160
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10448
11161
  }
10449
- running.set(ask.askId, child);
11162
+ running2.set(ask.askId, child);
11163
+ if (sessionId) sessionsInUse.add(sessionId);
10450
11164
  let buffer = "", stderr = "", sawDone = false, ended = false;
10451
11165
  let silentFailure = false;
10452
11166
  let toolsMissing = false;
10453
11167
  let session;
10454
11168
  let spent = 0;
11169
+ let lastContext = 0;
10455
11170
  const handleLine = (line) => {
10456
11171
  if (toolsMissing) return;
10457
11172
  for (const event of eventsFromLine(ask.askId, line)) {
@@ -10464,6 +11179,7 @@ function createClaudeCli(options) {
10464
11179
  continue;
10465
11180
  }
10466
11181
  if (event.name === "assistant.session") session = String(event.data["sessionId"] ?? "") || session;
11182
+ 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
11183
  if (event.name === "assistant.done" && event.data["ok"] === false && !String(event.data["result"] ?? "").trim()) {
10468
11184
  silentFailure = true;
10469
11185
  continue;
@@ -10485,10 +11201,18 @@ function createClaudeCli(options) {
10485
11201
  ended = true;
10486
11202
  clearTimeout(timer);
10487
11203
  if (buffer.trim()) handleLine(buffer);
10488
- running.delete(ask.askId);
10489
- if (toolsMissing && cancelled !== ask.askId) {
11204
+ running2.delete(ask.askId);
11205
+ if (sessionId) sessionsInUse.delete(sessionId);
11206
+ const stopped = cancelled.has(ask.askId);
11207
+ if (toolsMissing && !stopped) {
10490
11208
  if (attempt < 3) {
11209
+ retrying.add(ask.askId);
10491
11210
  setTimeout(() => {
11211
+ retrying.delete(ask.askId);
11212
+ if (cancelled.delete(ask.askId)) {
11213
+ ask.emit("assistant.done", { askId: ask.askId, ok: false, result: "Stopped." });
11214
+ return;
11215
+ }
10492
11216
  const again = launch(ask, dir, mcpConfigPath, sessionId, attempt + 1);
10493
11217
  if (!again.started) ask.emit("assistant.done", { askId: ask.askId, ok: false, result: again.detail ?? "The assistant could not be started." });
10494
11218
  }, 1500 * attempt + Math.floor(Math.random() * 1e3)).unref?.();
@@ -10497,22 +11221,22 @@ function createClaudeCli(options) {
10497
11221
  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
11222
  return;
10499
11223
  }
10500
- if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
11224
+ if (silentFailure && sessionId && /no conversation found/i.test(stderr) && !stopped) {
10501
11225
  if (ask.platform) writeLane(dir, ask.platform, null);
10502
11226
  const again = launch(ask, dir, mcpConfigPath, void 0);
10503
11227
  if (again.started) return;
10504
11228
  }
10505
11229
  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) });
11230
+ writeLane(dir, ask.platform, { sessionId: session, updatedAt: Date.now(), tokens: lastContext || spent, kind: usageKindOf(ask.prompt) });
10508
11231
  }
10509
11232
  if (!sawDone) {
10510
11233
  const why = detail || stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || "The assistant stopped without saying why.";
10511
11234
  ask.emit("assistant.done", { askId: ask.askId, ok: ok && !silentFailure, result: ok && !silentFailure ? "" : why });
10512
11235
  }
11236
+ cancelled.delete(ask.askId);
10513
11237
  };
10514
11238
  const timer = setTimeout(() => {
10515
- child.kill("SIGTERM");
11239
+ stop2(child);
10516
11240
  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
11241
  }, maxAskMs);
10518
11242
  timer.unref?.();
@@ -10520,7 +11244,12 @@ function createClaudeCli(options) {
10520
11244
  buffer += chunk.toString();
10521
11245
  let at;
10522
11246
  while ((at = buffer.indexOf("\n")) >= 0) {
10523
- handleLine(buffer.slice(0, at));
11247
+ try {
11248
+ handleLine(buffer.slice(0, at));
11249
+ } catch (error) {
11250
+ stderr = `${stderr}
11251
+ ${error.message}`.slice(-2e3);
11252
+ }
10524
11253
  buffer = buffer.slice(at + 1);
10525
11254
  }
10526
11255
  });
@@ -10532,7 +11261,7 @@ function createClaudeCli(options) {
10532
11261
  "close",
10533
11262
  (code) => end(
10534
11263
  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 ?? "?"}).`
11264
+ 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
11265
  )
10537
11266
  );
10538
11267
  return { started: true };
@@ -10723,11 +11452,11 @@ function createAgentRuntime(options) {
10723
11452
 
10724
11453
  // src/lock.ts
10725
11454
  import fs11 from "fs";
10726
- import os6 from "os";
11455
+ import os7 from "os";
10727
11456
  function takeLock(env = process.env) {
10728
11457
  ensureDataDir(env);
10729
11458
  const file2 = lockPath(env);
10730
- const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os6.hostname() };
11459
+ const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os7.hostname() };
10731
11460
  for (let attempt = 0; attempt < 2; attempt += 1) {
10732
11461
  try {
10733
11462
  fs11.writeFileSync(file2, JSON.stringify(mine), { flag: "wx", mode: 384 });
@@ -10868,8 +11597,12 @@ async function start(options = {}) {
10868
11597
  if (stopped) return;
10869
11598
  stopped = true;
10870
11599
  link.stop();
10871
- agent.close();
10872
- lock.lock.release();
11600
+ try {
11601
+ agent.close();
11602
+ } catch {
11603
+ } finally {
11604
+ lock.lock.release();
11605
+ }
10873
11606
  }
10874
11607
  };
10875
11608
  }
@@ -10879,7 +11612,7 @@ function status(serviceArg) {
10879
11612
  const service = resolveService(serviceArg);
10880
11613
  const connection = connectionFor(service);
10881
11614
  const lock = takeLock();
10882
- const running = lock.ok ? null : lock.held;
11615
+ const running2 = lock.ok ? null : lock.held;
10883
11616
  if (lock.ok) lock.lock.release();
10884
11617
  const dirs = connection?.checkouts ?? [];
10885
11618
  const { projects, skipped } = resolveCheckouts(dirs);
@@ -10891,7 +11624,7 @@ function status(serviceArg) {
10891
11624
  ...projects.map((p) => ({ root: p.root, project: p.name })),
10892
11625
  ...skipped.map((s2) => ({ root: s2.root, project: null, why: s2.why }))
10893
11626
  ],
10894
- running
11627
+ running: running2
10895
11628
  };
10896
11629
  }
10897
11630
  async function doctor(serviceArg) {
@@ -10968,7 +11701,12 @@ export {
10968
11701
  executeStep,
10969
11702
  replayCase,
10970
11703
  fieldNames,
11704
+ backupsFor,
11705
+ withBackups,
10971
11706
  flowrikiHome,
11707
+ readApprovals,
11708
+ allowCommands,
11709
+ revokeCommands,
10972
11710
  helperRunner,
10973
11711
  resolveCheckouts,
10974
11712
  start,