foldrun 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.mjs CHANGED
@@ -440,20 +440,34 @@ async function remoteListCmd(kind, flags, layout) {
440
440
  const url = platformFor(flags, `${one} ls`);
441
441
  const ws = platformWorkspace(flags, layout, `${one} ls`);
442
442
  const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/${kind}`);
443
- const list = r[kind] ?? [];
443
+ let list = r[kind] ?? [];
444
+ // --stale [days]: the agents nobody has run lately — with a number, not
445
+ // run in that many days (never run included); bare, the ones past the
446
+ // workspace's alerts: stale_days, as the platform counts them.
447
+ if (kind === "agents" && flags.stale !== undefined) {
448
+ const days = flags.stale === true ? null : Number(flags.stale);
449
+ if (days !== null && !(Number.isInteger(days) && days > 0)) throw new Error(`--stale takes a whole number of days, e.g. --stale 30 — not "${flags.stale}"`);
450
+ list = list.filter((x) => (days === null ? x.stale === true : !x.lastRunAt || Date.now() - Date.parse(x.lastRunAt) >= days * 86_400_000));
451
+ }
444
452
  if (flags.json === true) {
445
453
  console.log(JSON.stringify(list, null, 2));
446
454
  return 0;
447
455
  }
448
456
  if (!list.length) {
449
- console.log(`\n ${c.dim(`no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
457
+ console.log(`\n ${c.dim(flags.stale !== undefined ? `no stale agents in ${ws}` : `no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
450
458
  return 0;
451
459
  }
452
460
  const w = Math.max(...list.map((x) => x.name.length));
461
+ // Who answers for each one (owner:, or the workspace's, or the account's);
462
+ // the column only when someone is named, so a list with none is unchanged.
463
+ const ow = kind === "agents" ? Math.max(0, ...list.map((x) => (x.owner ?? "").length)) : 0;
453
464
  console.log();
454
465
  for (const x of list) {
455
466
  if (kind === "agents") {
456
- console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${c.dim(firstLine(x.description ?? "", 80))}`);
467
+ const owner = ow ? `${pad(x.owner ?? "—", Math.max(ow, 1))} ` : "";
468
+ // When it last ran, from a platform that says (lastRunAt is absent before it did).
469
+ const ran = "lastRunAt" in x ? `${pad(x.lastRunAt ? `${Math.floor((Date.now() - Date.parse(x.lastRunAt)) / 86_400_000)}d ago` : "never", 7)} ` : "";
470
+ console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${ran}${owner}${x.stale ? c.amber("stale ") : ""}${c.dim(firstLine(x.description ?? "", 80))}`);
457
471
  } else {
458
472
  const when = x.trigger === "schedule" && x.schedule ? `schedule ${x.schedule}${x.timezone ? ` ${x.timezone}` : ""}` : (x.trigger ?? "manual");
459
473
  console.log(` ${c.bold(pad(x.name, w))} ${c.dim(when)}`);
@@ -973,7 +987,7 @@ async function checkWorkspace(workspace, flags, opts) {
973
987
  let accountNetwork;
974
988
  try {
975
989
  const md = path.join(accountRoot, "AGENTS.md");
976
- if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8")).data?.network;
990
+ if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8"), {}).data?.network;
977
991
  } catch {
978
992
  // an unreadable account AGENTS.md is reported by its own check
979
993
  }
@@ -1228,12 +1242,17 @@ async function checkProblems(workspace, flags = {}) {
1228
1242
  if (webWarnings) {
1229
1243
  try {
1230
1244
  const matter = (await import("gray-matter")).default;
1231
- const front = matter(fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8")).data ?? {};
1245
+ // `{}`: gray-matter caches a failed parse as an empty success without it.
1246
+ const front = matter(fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8"), {}).data ?? {};
1232
1247
  for (const w of webWarnings(front, runnerBrowsers)) note("warn", `agents/${a.name}`, w);
1233
1248
  } catch {
1234
1249
  // an unreadable agent.md is already reported by the loader
1235
1250
  }
1236
1251
  }
1252
+ // agent.md whose YAML does not parse: core lists it with defaults and the
1253
+ // reason (core 2.5) rather than throwing; the file is unreadable to a run.
1254
+ const frontmatterError = /** @type {{ frontmatterError?: string }} */ (a).frontmatterError;
1255
+ if (frontmatterError) note("error", `agents/${a.name}`, frontmatterError);
1237
1256
  // `schedule:` on an agent fires nothing — only a flow has a clock.
1238
1257
  if (a.scheduleProblem) note("error", `agents/${a.name}`, a.scheduleProblem);
1239
1258
  // `language:` that is not a tag — "Persian" where `fa` was meant.
@@ -5250,10 +5269,11 @@ async function rerunCmd(runId, flags, layout) {
5250
5269
  const ws = await workspaceOfRun(url, flags, layout, runId);
5251
5270
  const body = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/rerun`, {
5252
5271
  method: "POST",
5253
- body: JSON.stringify(agent ? { agent } : { step: from }),
5272
+ // --instruction: what that step is told this once (FOL-72 step 6).
5273
+ body: JSON.stringify({ ...(agent ? { agent } : { step: from }), ...(typeof flags.instruction === "string" ? { instruction: flags.instruction } : {}) }),
5254
5274
  });
5255
5275
  const id = body.runId ?? body.id;
5256
- console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps`)}`);
5276
+ console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps${typeof flags.instruction === "string" ? " · with your instruction for that step" : ""}`)}`);
5257
5277
  console.log(` ${c.dim(`foldrun logs ${id} --to ${ws}${flags.wait === true ? "" : " · --wait follows it"}`)}\n`);
5258
5278
  if (flags.wait !== true) return 0;
5259
5279
  return logsCmd([id], { ...flags, follow: true, to: ws }, layout);
@@ -5573,6 +5593,119 @@ function printDefaults(d) {
5573
5593
  console.log(` notify ${notify}`);
5574
5594
  console.log(` budget ${d.budget ? `$${d.budget.usd} per ${d.budget.period}` : c.dim("no cap")}`);
5575
5595
  console.log(` concurrency ${d.concurrency ?? c.dim("the plan's number")}`);
5596
+ console.log(` deploy_block ${d.deployBlock && d.deployBlock !== "none" ? `${d.deployBlock} ${c.dim(d.deployBlock === "secrets" ? "— a written-in key refuses a deploy" : "— any security finding refuses a deploy")}` : c.dim("none — security findings only warn")}`);
5597
+ console.log(` network ${networkEnforceText(d.networkEnforce, "the install")}`);
5598
+ }
5599
+
5600
+ /** network_enforce: as a person reads it. Off is never "allowed" outright:
5601
+ * the level above (the account, or the install's own switch) may refuse
5602
+ * anyway, and this file cannot see either, so off says what it leaves to. */
5603
+ function networkEnforceText(on, above) {
5604
+ return on ? `refused ${c.dim("— a site off an agent's network: list gets a 403")}` : c.dim(`${above}'s — this file does not ask to refuse`);
5605
+ }
5606
+
5607
+ /** `on`/`off` (or true/false, yes/no) for network_enforce, or an error. */
5608
+ function onOff(key, value, how) {
5609
+ const v = String(value ?? "").toLowerCase();
5610
+ if (["on", "true", "yes"].includes(v)) return true;
5611
+ if (["off", "false", "no"].includes(v)) return false;
5612
+ throw new Error(`${key} is on or off — e.g. \`${how}\` — not "${value ?? ""}"`);
5613
+ }
5614
+
5615
+ /**
5616
+ * `foldrun account telemetry` — where each finished run goes as an
5617
+ * OpenTelemetry trace (the account's `telemetry:`). `set <url> --header
5618
+ * authorization=\${OTEL_TOKEN}`, `clear`, `test` (POST …/telemetry/test:
5619
+ * one made-up run, and what the collector answered).
5620
+ */
5621
+ async function telemetryCmd(url, args, flags) {
5622
+ const [sub, endpoint] = args;
5623
+ if (!sub) {
5624
+ const { defaults } = await remoteCall(url, flags, "/api/account");
5625
+ const t = defaults?.telemetry;
5626
+ if (!t) {
5627
+ console.log(`\n ${c.dim("runs are not sent anywhere — foldrun account telemetry set https://<collector>/v1/traces --header authorization=\${OTEL_TOKEN}")}\n`);
5628
+ return 0;
5629
+ }
5630
+ console.log(`\n ${c.bold("telemetry")} ${t.endpoint}`);
5631
+ for (const [k, v] of Object.entries(t.headers ?? {})) console.log(` ${c.dim(k)} ${v}`);
5632
+ console.log();
5633
+ return 0;
5634
+ }
5635
+ if (sub === "test") {
5636
+ const r = await remoteCall(url, flags, "/api/account/telemetry/test", { method: "POST", body: "{}" });
5637
+ if (r.ok) console.log(`\n ${c.green("✓")} ${r.endpoint} took it — ${r.spans} spans, HTTP ${r.status}. Look for the trace "run foldrun-telemetry-test".\n`);
5638
+ else console.log(`\n ${c.red("✗")} not delivered to ${r.endpoint} — ${r.error}\n`);
5639
+ return r.ok ? 0 : 1;
5640
+ }
5641
+ if (sub === "clear") {
5642
+ await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: null }) });
5643
+ console.log(`\n ${c.green("✓")} runs are no longer sent\n`);
5644
+ return 0;
5645
+ }
5646
+ if (sub !== "set") throw new Error(`unknown telemetry verb "${sub}" — set <url> [--header name=\${SECRET}], clear, test (bare: show it)`);
5647
+ if (!endpoint) throw new Error("`foldrun account telemetry set https://<collector>/v1/traces --header authorization=${OTEL_TOKEN}`");
5648
+ const headers = {};
5649
+ for (const h of [flags.header].flat().filter((x) => typeof x === "string")) {
5650
+ const i = h.indexOf("=");
5651
+ if (i < 1) throw new Error(`--header is name=value, e.g. --header authorization='\${OTEL_TOKEN}' — not "${h}"`);
5652
+ headers[h.slice(0, i).trim().toLowerCase()] = h.slice(i + 1).trim();
5653
+ }
5654
+ const r = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: { otlp: endpoint, ...(Object.keys(headers).length ? { headers } : {}) } }) });
5655
+ console.log(`\n ${c.green("✓")} every run that finishes is sent to ${r.defaults?.telemetry?.endpoint ?? endpoint} — \`foldrun account telemetry test\` proves it arrives\n`);
5656
+ return 0;
5657
+ }
5658
+
5659
+ /** A GET …/pause, or null from a platform older than the kill switch —
5660
+ * the settings still print, without the line. */
5661
+ async function pauseState(request) {
5662
+ try {
5663
+ return await request;
5664
+ } catch (err) {
5665
+ if (err instanceof HttpError && err.status === 404) return null;
5666
+ throw err;
5667
+ }
5668
+ }
5669
+
5670
+ /** One pause mark as a line: since when, by whom, why. */
5671
+ function pauseLine(m) {
5672
+ return `${m.at.slice(0, 16).replace("T", " ")} UTC by ${m.by}${m.reason ? ` — ${m.reason}` : ""}`;
5673
+ }
5674
+
5675
+ /**
5676
+ * `foldrun account pause|resume`, `foldrun workspace pause|resume` — the
5677
+ * kill switch (POST / DELETE …/pause). Pausing is undone by a resume, so it
5678
+ * asks nothing; --stop also stops the runs going now, which nothing undoes,
5679
+ * so that asks.
5680
+ */
5681
+ async function pauseCmd(flags, verb, what, { post, del }) {
5682
+ if (verb === "resume") {
5683
+ const r = await del();
5684
+ const still = r.paused;
5685
+ // An account resume leaves a workspace paused on its own paused.
5686
+ const own = Object.keys(r.workspaces ?? {});
5687
+ const held = still
5688
+ ? c.amber(` — but still held: ${still.scope === "account" ? "the account" : "this workspace"} is paused (${pauseLine(still)})`)
5689
+ : own.length
5690
+ ? c.amber(` — but ${own.join(", ")} ${own.length === 1 ? "is" : "are"} paused on ${own.length === 1 ? "its" : "their"} own (\`foldrun workspace resume --to <name>\`)`)
5691
+ : " — runs start again";
5692
+ console.log(`\n ${c.green("✓")} resumed ${what}${held}\n`);
5693
+ return 0;
5694
+ }
5695
+ const stop = flags.stop === true;
5696
+ if (stop && !(await confirmed(flags, "pause --stop", `Pause ${what} and stop every run going now? [y/N] `))) {
5697
+ console.log(`\n ${c.dim("nothing paused, nothing stopped")}\n`);
5698
+ return 1;
5699
+ }
5700
+ const body = { ...(typeof flags.reason === "string" && flags.reason.trim() ? { reason: flags.reason.trim() } : {}), ...(stop ? { stop: true } : {}) };
5701
+ const r = await post(JSON.stringify(body));
5702
+ console.log(`\n ${c.green("✓")} paused ${what} — no new run starts until \`resume\``);
5703
+ if (stop) console.log(` ${c.dim(`${r.stopped?.length ?? 0} run(s) stopped`)}`);
5704
+ // A stop that failed is a run that may still be going: named, and the
5705
+ // exit says so, rather than assumed to have finished on its own.
5706
+ for (const f of r.failed ?? []) console.log(` ${c.red("✗")} ${f.workspace}/${f.runId} not stopped — ${f.error}`);
5707
+ console.log();
5708
+ return r.failed?.length ? 1 : 0;
5576
5709
  }
5577
5710
 
5578
5711
  /**
@@ -5595,18 +5728,28 @@ async function accountCmd(positional, flags) {
5595
5728
  if (!verb) {
5596
5729
  const { defaults } = await remoteCall(url, flags, "/api/account");
5597
5730
  printDefaults(defaults ?? {});
5731
+ const halt = await pauseState(remoteCall(url, flags, "/api/account/pause"));
5732
+ const own = Object.keys(halt?.workspaces ?? {});
5733
+ if (halt) console.log(` paused ${halt.account ? c.red(pauseLine(halt.account)) : c.dim("no")}${own.length ? c.dim(` · on their own: ${own.join(", ")}`) : ""}`);
5598
5734
  console.log(`\n ${c.dim("foldrun account set timezone Australia/Sydney · set notify email you@example.com · set budget 60/month · clear budget")}\n`);
5599
5735
  return 0;
5600
5736
  }
5601
5737
  if (verb === "providers") return providersCmd(url, flags);
5738
+ if (verb === "telemetry") return telemetryCmd(url, positional.slice(1), flags);
5739
+ if (verb === "pause" || verb === "resume") {
5740
+ return pauseCmd(flags, verb, "every workspace in the account", {
5741
+ post: (body) => remoteCall(url, flags, "/api/account/pause", { method: "POST", body }),
5742
+ del: () => remoteCall(url, flags, "/api/account/pause", { method: "DELETE" }),
5743
+ });
5744
+ }
5602
5745
  if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
5603
5746
  if (verb === "export") return accountExportCmd(url, flags);
5604
5747
  if (verb !== "set" && verb !== "clear") {
5605
- throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything`);
5748
+ throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything, \`pause\`/\`resume\` the kill switch`);
5606
5749
  }
5607
5750
 
5608
5751
  const key = positional[1];
5609
- const KEYS = ["timezone", "notify", "budget", "concurrency"];
5752
+ const KEYS = ["timezone", "notify", "budget", "concurrency", "deploy_block", "network_enforce"];
5610
5753
  if (!KEYS.includes(key)) {
5611
5754
  throw new Error(`which setting? \`foldrun account ${verb} <${KEYS.join("|")}> …\``);
5612
5755
  }
@@ -5647,7 +5790,13 @@ async function accountCmd(positional, flags) {
5647
5790
  if (key === "concurrency" && !/^\d+$/.test(value)) {
5648
5791
  throw new Error(`concurrency is a whole number of runs at once, e.g. \`foldrun account set concurrency 4\` — not "${value}"`);
5649
5792
  }
5650
- patch = { [key]: key === "concurrency" ? Number(value) : value };
5793
+ // deploy_block (FOL-58): none | secrets | security.
5794
+ if (key === "deploy_block" && !["none", "secrets", "security"].includes(value)) {
5795
+ throw new Error(`deploy_block is none, secrets or security — e.g. \`foldrun account set deploy_block secrets\` — not "${value}"`);
5796
+ }
5797
+ patch = {
5798
+ [key]: key === "concurrency" ? Number(value) : key === "network_enforce" ? onOff(key, value, "foldrun account set network_enforce on") : value,
5799
+ };
5651
5800
  }
5652
5801
 
5653
5802
  const answer = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify(patch) });
@@ -5929,6 +6078,127 @@ const eventSteps = (run) =>
5929
6078
  * steps in one group is two decisions, and a list that folded them into one
5930
6079
  * line named one agent and asked neither question.
5931
6080
  */
6081
+ /** `foldrun runs views` — your saved run searches (FOL-72 step 9). */
6082
+ async function runViewsCmd(flags) {
6083
+ const url = platformFor(flags, "runs");
6084
+ const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
6085
+ if (flags.json === true) return console.log(JSON.stringify(runViews, null, 2)), 0;
6086
+ if (!runViews.length) return console.log(`\n ${c.dim("no saved views — save one from the runs page's search")}\n`), 0;
6087
+ console.log();
6088
+ for (const v of runViews) console.log(` ${c.bold(v.name)} ${c.dim(v.query)}`);
6089
+ console.log(`\n ${c.dim("foldrun runs --view <name> opens one")}\n`);
6090
+ return 0;
6091
+ }
6092
+
6093
+ // ---------------------------------------------------------------- insights
6094
+
6095
+ /** `foldrun insights` (FOL-72 step 8): recurring problems found in the runs,
6096
+ * with evidence, what they cost and what to do — GET /api/insights. */
6097
+ async function insightsCmd(flags) {
6098
+ const url = platformFor(flags, "insights");
6099
+ const qs = new URLSearchParams({ ...(flags.days !== undefined ? { days: String(flags.days) } : {}), ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
6100
+ const { insights = [], days = 7 } = await remoteCall(url, flags, `/api/insights${qs.size ? `?${qs}` : ""}`);
6101
+ if (flags.json === true) return console.log(JSON.stringify(insights, null, 2)), 0;
6102
+ if (!insights.length) {
6103
+ console.log(`\n ${c.dim(`nothing recurring in the last ${days} days`)}\n`);
6104
+ return 0;
6105
+ }
6106
+ console.log();
6107
+ for (const i of insights) {
6108
+ const cost = i.credits ? ` · about ${i.credits.toLocaleString()} credits` : "";
6109
+ console.log(` ${c.amber("◆")} ${c.bold(i.title)} ${c.dim(`${i.workspace} · ${i.steps} step${i.steps === 1 ? "" : "s"}${cost}`)}`);
6110
+ for (const line of wrap(i.why, 84)) console.log(` ${c.dim(line)}`);
6111
+ for (const [k, line] of wrap(i.fix, 80).entries()) console.log(` ${k === 0 ? "→ " : " "}${line}`);
6112
+ console.log(` ${c.dim(i.evidence.map((e) => `${e.runId}#${e.step}`).join(" "))}`);
6113
+ console.log();
6114
+ }
6115
+ return 0;
6116
+ }
6117
+
6118
+ // ---------------------------------------------------------------- issues
6119
+
6120
+ const ISSUE_VERBS = { ack: "acknowledge", acknowledge: "acknowledge", snooze: "snooze", resolve: "resolve", reopen: "reopen" };
6121
+
6122
+ /** What the run doctor reads for an issue: its newest run, that run's
6123
+ * workspace, and the question the run page's Doctor asks — core's
6124
+ * issueDoctor (issues.ts), repeated here as the cache note is until the
6125
+ * next core release. Null for a missed schedule, which has no run. */
6126
+ function issueDoctor(issue) {
6127
+ const run = (issue.runs ?? []).find((r) => !r.missed);
6128
+ if (!run || issue.kind === "missed") return null;
6129
+ const ended = issue.kind === "blocked" ? "end BLOCKED" : issue.kind === "bad" ? "end BAD" : "fail";
6130
+ return { workspace: run.workspace, runId: run.id, prompt: `Doctor: why did run ${run.id} ${ended}, and how do I fix it?` };
6131
+ }
6132
+
6133
+ /** `foldrun issues` (FOL-54 step 3): what went wrong, grouped, open until a
6134
+ * person resolves it; `foldrun issues ack|snooze|resolve|reopen <id>` acts
6135
+ * on one — the dashboard's Issues page and its buttons, same route.
6136
+ * `foldrun issues doctor <id>` is the card's Doctor: the run doctor on the
6137
+ * issue's newest run, in that run's workspace. */
6138
+ async function issuesCmd(positional, flags, layout) {
6139
+ const url = platformFor(flags, "issues");
6140
+ const [verb, id] = positional;
6141
+ if (verb === "doctor") {
6142
+ if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error("`foldrun issues doctor <issue-id>` — the id is the iss_… on each line of `foldrun issues`");
6143
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
6144
+ const doc = issueDoctor(issue);
6145
+ if (!doc) throw new Error(`${id} is a missed schedule: no run started, so there is nothing for the doctor to read — open the flow's schedule instead`);
6146
+ return assistantCmd([doc.prompt], { ...flags, to: doc.workspace }, layout);
6147
+ }
6148
+ if (verb && verb !== "show") {
6149
+ const action = ISSUE_VERBS[verb];
6150
+ if (!action) throw new Error(`\`foldrun issues\` lists them; its verbs are show, doctor, ack, snooze, resolve, reopen — not ${verb}`);
6151
+ if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error(`\`foldrun issues ${verb} <issue-id>\` — the id is the iss_… on each line of \`foldrun issues\``);
6152
+ if (action === "snooze" && typeof flags.for !== "string" && typeof flags.until !== "string" && flags.times === undefined) throw new Error("a snooze takes --for 30m|4h|1d|1w, --until <ISO time>, or --times <n> (until it happens n more times)");
6153
+ const body = {
6154
+ action,
6155
+ ...(typeof flags.for === "string" ? { for: flags.for } : {}),
6156
+ ...(typeof flags.until === "string" ? { until: flags.until } : {}),
6157
+ ...(flags.times !== undefined ? { times: Number(flags.times) } : {}),
6158
+ ...(typeof flags.note === "string" ? { note: flags.note } : {}),
6159
+ };
6160
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`, { method: "POST", body: JSON.stringify(body) });
6161
+ if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
6162
+ console.log(`\n ${c.green("✓")} ${issue.id} is ${issue.status}${issue.snoozedUntil ? ` until ${when(issue.snoozedUntil)}` : ""}${issue.snoozedFor ? ` for ${issue.snoozedFor} more` : ""} — ${issue.what}\n`);
6163
+ return 0;
6164
+ }
6165
+ if (verb === "show") {
6166
+ if (!id) throw new Error("`foldrun issues show <issue-id>`");
6167
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
6168
+ if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
6169
+ printIssue(issue, true);
6170
+ return 0;
6171
+ }
6172
+ const status = typeof flags.status === "string" ? flags.status : flags.all === true ? "all" : "active";
6173
+ const qs = new URLSearchParams({ status, ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
6174
+ const { issues = [] } = await remoteCall(url, flags, `/api/issues?${qs}`);
6175
+ if (flags.json === true) return console.log(JSON.stringify(issues, null, 2)), 0;
6176
+ if (!issues.length) {
6177
+ console.log(`\n ${c.dim(status === "active" ? "no open issues" : `no ${status} issues`)}\n`);
6178
+ return 0;
6179
+ }
6180
+ console.log();
6181
+ for (const i of issues) printIssue(i, false);
6182
+ console.log(` ${c.dim("foldrun issues show <id> for its runs and history; doctor <id> asks the run doctor why; ack, snooze --for 1d, resolve or reopen <id> act on one; --all lists resolved too")}\n`);
6183
+ return 0;
6184
+ }
6185
+
6186
+ function printIssue(i, full) {
6187
+ const mark = i.kind === "platform" ? c.bold("▲") : i.kind === "failed" ? c.red("✗") : i.kind === "blocked" ? c.red("⛔") : i.kind === "missed" ? c.amber("◷") : c.amber("!");
6188
+ const where = i.kind === "platform" ? i.workspaces.join(", ") : `${i.workspace} · ${i.what}${i.agent ? ` at ${i.agent}` : ""}`;
6189
+ const label = i.kind === "missed" ? "missed schedule" : i.kind;
6190
+ const state = (i.status === "open" ? "" : c.dim(` [${i.status}${i.snoozedUntil ? ` until ${when(i.snoozedUntil)}` : ""}${i.snoozedFor ? ` for ${i.snoozedFor} more` : ""}${i.resolved?.by?.startsWith("auto:") ? ", auto" : ""}]`)) + (i.regressed ? ` ${c.red(`regressed${i.sinceRevision ? ` since ${String(i.sinceRevision).slice(0, 7)}` : ""}`)}` : "");
6191
+ console.log(` ${mark} ${c.bold(i.kind === "platform" ? i.what : label)} ${where}${state} ${c.dim(i.id)}`);
6192
+ console.log(` ${c.dim(`${i.count}× · first ${ago(i.firstSeen)} ago · latest ${ago(i.lastSeen)} ago${i.recoveredAt ? ` · fine for ${ago(i.recoveredAt)}` : ""}`)}`);
6193
+ for (const line of wrap(i.reason, 84).slice(0, full ? 20 : 2)) console.log(` ${line}`);
6194
+ console.log(` ${c.dim("→")} ${i.action}`);
6195
+ if (full) {
6196
+ console.log(` ${c.dim(`runs: ${i.runs.map((r) => `${r.workspace}/${r.id}`).join(", ")}`)}`);
6197
+ for (const e of i.history) console.log(` ${c.dim(`${when(e.at)} ${e.action} by ${e.by}${e.until ? ` until ${when(e.until)}` : ""}${e.note ? ` — ${e.note}` : ""}`)}`);
6198
+ }
6199
+ console.log();
6200
+ }
6201
+
5932
6202
  async function approvalsCmd(flags, layout) {
5933
6203
  const url = platformFor(flags, "approvals");
5934
6204
  const only = typeof flags.to === "string" ? flags.to : null;
@@ -6151,6 +6421,23 @@ async function stopCmd(runId, flags, layout) {
6151
6421
  */
6152
6422
  async function runsCmd(flags, layout) {
6153
6423
  const url = platformFor(flags, "runs");
6424
+ // --view <name>: a saved search (the dashboard's) read first, so every
6425
+ // filter in it — status, since, workspace, flow, q and the search's own —
6426
+ // applies as if typed; a flag typed too wins (Codex review).
6427
+ if (typeof flags.view === "string") {
6428
+ const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
6429
+ const view = runViews.find((v) => v.name === flags.view);
6430
+ if (!view) throw new Error(`no saved view called ${flags.view}${runViews.length ? ` — yours: ${runViews.map((v) => v.name).join(", ")}` : " — save one on the runs page"}`);
6431
+ const as = { workspace: "to", min_tokens: "min-tokens", min_secs: "min-secs" };
6432
+ const fromView = {};
6433
+ for (const [k, v] of new URLSearchParams(view.query)) {
6434
+ if (k === "tool_error") fromView["tool-error"] = v === "1" || v === "true";
6435
+ // The dashboard's Today chip is its 24-hour window (Codex review).
6436
+ else if (k === "since" && v === "today") fromView.since = "24h";
6437
+ else fromView[as[k] ?? k] = v;
6438
+ }
6439
+ flags = { ...fromView, ...flags };
6440
+ }
6154
6441
  const names = typeof flags.to === "string" ? [flags.to] : await remoteWorkspaceNames(url, flags);
6155
6442
  if (!names.length) {
6156
6443
  console.log(`\n ${c.dim(`no workspaces on ${url}`)}\n`);
@@ -6162,6 +6449,17 @@ async function runsCmd(flags, layout) {
6162
6449
  typeof flags.status === "string" ? new Set(flags.status.split(",").map((s) => s.trim()).filter(Boolean)) : null;
6163
6450
  const verdicts =
6164
6451
  typeof flags.verdict === "string" ? new Set(flags.verdict.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean)) : null;
6452
+ // --outcome (FOL-54): the one word every surface shows — needs-you is the
6453
+ // dashboard's name for waiting. A platform from before it sends none.
6454
+ const OUTCOME_WORDS = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
6455
+ const outcomes =
6456
+ typeof flags.outcome === "string"
6457
+ ? new Set(flags.outcome.split(",").map((s) => s.trim().toLowerCase().replace(/^needs-?you$/, "waiting")).filter(Boolean))
6458
+ : null;
6459
+ if (outcomes) {
6460
+ const unknown = [...outcomes].filter((o) => !OUTCOME_WORDS.includes(o));
6461
+ if (unknown.length) throw new Error(`--outcome takes ${OUTCOME_WORDS.join(", ")} (or needs-you) — not ${unknown.join(", ")}`);
6462
+ }
6165
6463
 
6166
6464
  // Each desk is asked for its own newest rows before the merge, so one
6167
6465
  // busy workspace cannot crowd every quiet one out of the answer. With a
@@ -6169,11 +6467,23 @@ async function runsCmd(flags, layout) {
6169
6467
  // the filter runs here, after the fetch, and a desk with twenty recent
6170
6468
  // successes was hiding its older failures from `--status failed` because
6171
6469
  // only its newest twenty of any status ever arrived.
6172
- const perWorkspace = wanted || verdicts || cutoff !== null ? 500 : limit;
6470
+ // Search (FOL-72 step 9): by what happened inside a run, filtered by the
6471
+ // platform; --view <name> starts from a saved search (the dashboard's).
6472
+ const search = new URLSearchParams();
6473
+ for (const [flag, key] of [["tool", "tool"], ["agent", "agent"], ["model", "model"], ["min-tokens", "min_tokens"], ["min-secs", "min_secs"]]) {
6474
+ if (flags[flag] !== undefined && flags[flag] !== true) search.set(key, String(flags[flag]));
6475
+ }
6476
+ if (flags["tool-error"] === true) search.set("tool_error", "1");
6477
+ const searching = [...search.keys()].some((k) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k));
6478
+ // A view's flow or text filters here, after the fetch: ask for the ceiling
6479
+ // as for a status, or matches past the newest few go missing (Codex review).
6480
+ const localText = typeof flags.flow === "string" || typeof flags.q === "string";
6481
+ const perWorkspace = wanted || verdicts || outcomes || cutoff !== null || searching || localText ? 500 : limit;
6173
6482
  const unreachable = [];
6174
6483
  const lists = await pool(names, 8, async (ws) => {
6175
6484
  try {
6176
- const { runs = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=${perWorkspace}`);
6485
+ const forApi = new URLSearchParams([...search].filter(([k]) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k)));
6486
+ const { runs = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=${perWorkspace}${forApi.size ? `&${forApi}` : ""}`);
6177
6487
  return runs.map((r) => ({ ...r, workspace: ws }));
6178
6488
  } catch (err) {
6179
6489
  unreachable.push([ws, explain(err)]);
@@ -6183,14 +6493,34 @@ async function runsCmd(flags, layout) {
6183
6493
 
6184
6494
  const rows = lists
6185
6495
  .flat()
6186
- .filter((r) => (wanted ? wanted.has(r.status) : true))
6496
+ // The status the dashboard shows: a stopped run is "stopped", not the
6497
+ // "failed" its record says (Codex review — a saved status=stopped view).
6498
+ .filter((r) => (wanted ? wanted.has(r.outcome === "stopped" ? "stopped" : r.status) : true))
6187
6499
  .filter((r) => (verdicts ? r.status === "completed" && verdicts.has(verdictOf(r)) : true))
6500
+ .filter((r) => (outcomes ? typeof r.outcome === "string" && outcomes.has(r.outcome) : true))
6501
+ // A view's flow and free text (the runs page's): "agent: x" is a direct run.
6502
+ // The dashboard names a run by what ran (whatRan): a direct agent run's
6503
+ // or an eval's own name, without adhoc:/eval: (Codex review).
6504
+ .filter((r) => (typeof flags.flow === "string" ? r.flow === flags.flow || r.flow.replace(/^(adhoc|eval):/, "") === flags.flow : true))
6505
+ // The runs page's text search, field for field (its runSearchText): every
6506
+ // word, any order, across id, what ran, workspace, the status it shows,
6507
+ // verdict, summary and the failure (Codex review).
6508
+ .filter((r) => {
6509
+ if (typeof flags.q !== "string") return true;
6510
+ const words = flags.q.toLowerCase().replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
6511
+ const shown = r.outcome === "stopped" ? "stopped" : r.status;
6512
+ const hay = [r.id, r.flow.replace(/^(adhoc|eval):/, ""), r.workspace, shown, verdictOf(r) ?? "", r.summary ?? "", r.failure ? `${r.failure.agent} ${r.failure.reason}` : ""]
6513
+ .join(" ")
6514
+ .toLowerCase()
6515
+ .replace(/\s+/g, " ");
6516
+ return words.every((w) => hay.includes(w));
6517
+ })
6188
6518
  .filter((r) => (cutoff === null ? true : Date.parse(r.startedAt) >= cutoff))
6189
6519
  .sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1))
6190
6520
  .slice(0, limit);
6191
6521
 
6192
6522
  if (!rows.length) {
6193
- const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
6523
+ const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, outcomes ? `outcome ${[...outcomes].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
6194
6524
  .filter(Boolean)
6195
6525
  .join(" in ");
6196
6526
  console.log(`\n ${c.dim(`no runs${how ? ` matching ${how}` : ""}${flags.to ? ` in ${flags.to}` : ""}`)}\n`);
@@ -6210,7 +6540,9 @@ async function runsCmd(flags, layout) {
6210
6540
  verdictWidth: (r.status === "completed" ? verdictOf(r) ?? "" : "").length,
6211
6541
  steps: `${done}/${(r.steps ?? []).length}`,
6212
6542
  took: runDuration(r),
6213
- cost: `$${runCost(r).toFixed(2)}`,
6543
+ // FOL-54: credits charged, as the dashboard shows; a run not settled
6544
+ // yet (or a platform from before chargedUsd) shows its token cost, in dollars.
6545
+ cost: typeof r.chargedUsd === "number" ? `${Math.round(r.chargedUsd * 100)} credits` : `$${runCost(r).toFixed(2)}`,
6214
6546
  id: r.id,
6215
6547
  summary: firstLine(r.summary, 110),
6216
6548
  };
@@ -6354,6 +6686,28 @@ function measuredShare(list) {
6354
6686
  return all > 0 ? read / all : null;
6355
6687
  }
6356
6688
  const cachedLabel = (rate) => (rate == null ? null : `${Math.round(rate * 100)}% cached`);
6689
+ /** Why a step's prompt cache missed — core's rule (tokens.ts promptCacheNote),
6690
+ * repeated here as `cachedShare` is: a step that wrote a fifth or more of its
6691
+ * input to the cache says which layer of its prompt changed since the same
6692
+ * agent's previous step in the run. Nothing for a step without layers on
6693
+ * record (before 20 Oct 2026) or one the cache served. */
6694
+ function promptCacheNote(steps, index) {
6695
+ const step = steps[index];
6696
+ const t = step?.tokens;
6697
+ if (!step?.promptLayers || !t || t.cacheWrite === undefined || !(t.input > 0)) return null;
6698
+ const share = t.cacheWrite / t.input;
6699
+ if (share < 0.2) return null;
6700
+ const head = `${Math.round(share * 100)}% of input written to the cache`;
6701
+ let prev = -1;
6702
+ for (let j = index - 1; j >= 0; j--) if (steps[j].agent === step.agent && steps[j].promptLayers) { prev = j; break; }
6703
+ if (prev < 0) return `${head} — the agent's first step in this run`;
6704
+ const before = new Map(steps[prev].promptLayers.map((l) => [l.name, l.hash]));
6705
+ const after = new Map(step.promptLayers.map((l) => [l.name, l.hash]));
6706
+ const changed = [...after].filter(([name, hash]) => before.get(name) !== hash).map(([name]) => name);
6707
+ for (const name of before.keys()) if (!after.has(name)) changed.push(name);
6708
+ if (!changed.length) return `${head} — the same prompt as step ${prev + 1}; the entry had expired or was still being written`;
6709
+ return `${head} — ${changed.join(", ")} changed since step ${prev + 1}`;
6710
+ }
6357
6711
  function sumTokens(list) {
6358
6712
  const out = { input: 0, output: 0 };
6359
6713
  for (const t of list) {
@@ -6368,10 +6722,86 @@ function sumTokens(list) {
6368
6722
  return out;
6369
6723
  }
6370
6724
 
6725
+ /** `foldrun report <run-id> call <call-id>` — one tool call's recorded input
6726
+ * and output, secrets masked (FOL-54 step 4). */
6727
+ async function reportCallCmd(runId, callId, flags, layout) {
6728
+ const url = platformFor(flags, "report");
6729
+ if (!runId || !callId) throw new Error("`foldrun report <run-id> call <call-id>` — `foldrun report <run-id> --calls` lists the recorded calls");
6730
+ const ws = await workspaceOfRun(url, flags, layout, runId);
6731
+ const { call } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/calls/${encodeURIComponent(callId)}`);
6732
+ if (flags.json === true) return console.log(JSON.stringify(call, null, 2)), 0;
6733
+ console.log(`\n ${c.bold(call.tool)} ${c.dim(call.id)}`);
6734
+ for (const [label, text, whole, wasCut] of [["input", call.input, call.inputChars, call.inputCut], ["output", call.output, call.outputChars, call.outputCut]]) {
6735
+ if (text === undefined) continue;
6736
+ const cut = wasCut === true && typeof whole === "number" && whole > text.length ? c.dim(` (the first ${text.length.toLocaleString()} of ${whole.toLocaleString()} characters)`) : "";
6737
+ console.log(`\n ${c.dim(label)}${cut}`);
6738
+ for (const line of String(text).split("\n")) console.log(` ${line}`);
6739
+ }
6740
+ console.log();
6741
+ return 0;
6742
+ }
6743
+
6744
+ /** `foldrun report <run-id> --tree` — the run as one tree, run → step →
6745
+ * model call → tool call, from GET …/runs/<id>/trace (FOL-72 step 5). */
6746
+ async function reportTreeCmd(url, flags, ws, runId) {
6747
+ const { tree, failure = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/trace`);
6748
+ if (flags.json === true) return console.log(JSON.stringify({ tree, failure }, null, 2)), 0;
6749
+ // The deepest span on the way to the failure is the one to point at.
6750
+ const culprit = failure.length ? failure[failure.length - 1] : null;
6751
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n));
6752
+ const mark = { run: "▣", step: "◆", model: "✦", tool: "→", error: "✗" };
6753
+ const lines = [];
6754
+ const walk = (s, prefix, last, root) => {
6755
+ const dur = s.start != null && s.end != null ? humanDuration(Math.max(0, s.end - s.start)) : "";
6756
+ const facts = [
6757
+ s.kind === "model" && s.tokens ? `${k(s.tokens.input)} in · ${k(s.tokens.output)} out` : null,
6758
+ s.stop ?? null,
6759
+ s.subagent ? `↳ ${s.subagent}` : null,
6760
+ s.kind === "tool" && s.captured && s.call ? s.call : null,
6761
+ ].filter(Boolean);
6762
+ const text = s.kind === "error" ? firstLine(s.text ?? "", 90) : s.label;
6763
+ const head = `${root ? "" : `${prefix}${last ? "└ " : "├ "}`}${mark[s.kind] ?? "·"} ${s.status === "error" ? c.red(text) : s.kind === "step" || s.kind === "run" ? c.bold(text) : text}`;
6764
+ lines.push(` ${head}${facts.length ? ` ${c.dim(facts.join(" · "))}` : ""}${dur && s.kind !== "error" ? ` ${c.dim(dur)}` : ""}${s.id === culprit ? ` ${c.red("← what went wrong")}` : ""}`);
6765
+ const kids = s.children ?? [];
6766
+ kids.forEach((ch, i) => walk(ch, root ? "" : `${prefix}${last ? " " : "│ "}`, i === kids.length - 1, false));
6767
+ };
6768
+ walk(tree, "", true, true);
6769
+ console.log(`\n${lines.join("\n")}\n`);
6770
+ if ((tree.children ?? []).some((s) => (s.children ?? []).some((ch) => (ch.children ?? []).some((t) => t.captured)))) console.log(` ${c.dim(`\`foldrun report ${runId} call <id>\` shows a call's input and output`)}\n`);
6771
+ return 0;
6772
+ }
6773
+
6774
+ /** `foldrun report <run-id> --compare [<other>]` — this run beside the flow's
6775
+ * last good run (or the one named), from GET …/compare (FOL-72 step 6). */
6776
+ async function reportCompareCmd(url, flags, ws, runId) {
6777
+ const other = typeof flags.compare === "string" ? flags.compare : null;
6778
+ const { comparison: cmp, chosen } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/compare${other ? `?with=${encodeURIComponent(other)}` : ""}`);
6779
+ if (flags.json === true) return console.log(JSON.stringify({ comparison: cmp, chosen }, null, 2)), 0;
6780
+ console.log(`\n ${c.bold(runId)} ${c.dim(`(${cmp.run.status})`)} beside ${c.bold(cmp.base.id)} ${c.dim(`(${chosen}, ${cmp.base.status})`)}`);
6781
+ const files = !cmp.revisionChanged
6782
+ ? "both ran the same version of the files"
6783
+ : cmp.changedFiles === null
6784
+ ? "they ran different versions of the files"
6785
+ : cmp.changedFiles.length
6786
+ ? `files changed between them: ${cmp.changedFiles.slice(0, 8).join(", ")}${cmp.changedFiles.length > 8 ? ` and ${cmp.changedFiles.length - 8} more` : ""}`
6787
+ : "different revisions, no file changes between them";
6788
+ console.log(` ${c.dim(files)}\n`);
6789
+ const changed = cmp.steps.filter((s) => s.changes.length);
6790
+ if (!changed.length) console.log(` ${c.dim("no step differs in status, instruction, model, tools, errors, time or tokens")}`);
6791
+ for (const s of changed) {
6792
+ console.log(` ${c.bold(`step ${s.step} · ${s.agent}`)}`);
6793
+ for (const ch of s.changes) console.log(` ${/^(status|new error|tool errors)/.test(ch) ? c.red("•") : c.dim("•")} ${ch}`);
6794
+ }
6795
+ console.log();
6796
+ return 0;
6797
+ }
6798
+
6371
6799
  async function reportCmd(runId, flags, layout) {
6372
6800
  const url = platformFor(flags, "report");
6373
6801
  if (!runId) throw new Error("which run? `foldrun report <run-id>` — `foldrun runs` lists them");
6374
6802
  const ws = await workspaceOfRun(url, flags, layout, runId);
6803
+ if (flags.tree === true) return reportTreeCmd(url, flags, ws, runId);
6804
+ if (flags.compare !== undefined) return reportCompareCmd(url, flags, ws, runId);
6375
6805
  const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
6376
6806
 
6377
6807
  if (flags.json === true) {
@@ -6391,6 +6821,9 @@ async function reportCmd(runId, flags, layout) {
6391
6821
  console.log(` ${c.dim(`${tokens.input.toLocaleString()} in / ${tokens.output.toLocaleString()} out tokens${cached ? ` · ${cached}` : ""}`)}`);
6392
6822
  }
6393
6823
  if (run.budgetUsd) console.log(` ${c.dim(`budget $${Number(run.budgetUsd).toFixed(2)}`)}`);
6824
+ // Which files it ran and how long it waited for a worker (FOL-54 step 4).
6825
+ const ranOn = [run.revision ? `ran on ${String(run.revision).slice(0, 7)}` : null, run.drivenAt ? `queued ${humanDuration(Math.max(0, Date.parse(run.drivenAt) - Date.parse(run.startedAt)))}` : null].filter(Boolean);
6826
+ if (ranOn.length) console.log(` ${c.dim(ranOn.join(" · "))}`);
6394
6827
  if (run.summary) {
6395
6828
  console.log();
6396
6829
  for (const line of wrap(run.summary, 84)) console.log(` ${line}`);
@@ -6405,6 +6838,7 @@ async function reportCmd(runId, flags, layout) {
6405
6838
  startupLabel(step),
6406
6839
  step.costUsd ? `$${step.costUsd.toFixed(4)}` : null,
6407
6840
  cachedLabel(cachedShare(step.tokens)),
6841
+ typeof step.turns === "number" ? `${step.turns} turn${step.turns === 1 ? "" : "s"}` : null,
6408
6842
  step.startedAt && step.finishedAt
6409
6843
  ? humanDuration(Date.parse(step.finishedAt) - Date.parse(step.startedAt))
6410
6844
  : null,
@@ -6412,6 +6846,10 @@ async function reportCmd(runId, flags, layout) {
6412
6846
  console.log(
6413
6847
  ` ${statusMark(step.status)} ${pad(`${i + 1}.`, 4)}${c.dim(`g${step.group ?? 1}`)} ${c.bold(step.agent)} ${c.dim(bits.join(" · "))}`,
6414
6848
  );
6849
+ // A step the prompt cache did not serve says why — which layer of its
6850
+ // prompt changed since the agent's last step — the way the run page does.
6851
+ const cacheNote = promptCacheNote(run.steps ?? [], i);
6852
+ if (cacheNote) console.log(` ${c.dim("·")} ${c.dim(`prompt cache: ${cacheNote}`)}`);
6415
6853
  if (step.verify) {
6416
6854
  // A step that completed with a verify held it — the runtime fails the
6417
6855
  // step otherwise, so status IS the verdict, and saying it out loud is
@@ -6432,6 +6870,26 @@ async function reportCmd(runId, flags, layout) {
6432
6870
  if (step.status === "awaiting-approval") {
6433
6871
  console.log(` ${c.amber("⏸")} ${c.dim(step.waitFor === "event" ? "waiting on an event" : `waiting on a person: ${firstLine(step.ask ?? step.instruction, 70)}`)}`);
6434
6872
  }
6873
+ // Each model call (FOL-54 step 4): one line, or every call with --calls,
6874
+ // and the tool calls whose input and output were recorded.
6875
+ const calls = Array.isArray(step.calls) ? step.calls : [];
6876
+ if (calls.length) {
6877
+ const slowest = Math.max(...calls.map((m) => m.ms ?? 0));
6878
+ console.log(` ${c.dim(`${calls.length} model call${calls.length === 1 ? "" : "s"} · slowest ${humanDuration(slowest)} · largest ${Math.max(...calls.map((m) => m.tokens?.input ?? 0)).toLocaleString()} in`)}`);
6879
+ if (flags.calls === true) {
6880
+ for (const m of calls) {
6881
+ const t = m.tokens ?? {};
6882
+ const cached = t.input > 0 && t.cacheRead > 0 ? ` (${Math.round((100 * t.cacheRead) / t.input)}% cached)` : "";
6883
+ console.log(` ${c.dim(`${m.n}. ${m.model ?? "model"}${m.subagent ? ` ↳ ${m.subagent}` : ""} · ${humanDuration(m.ms ?? 0)} · ${(t.input ?? 0).toLocaleString()} in${cached} · ${(t.output ?? 0).toLocaleString()} out${m.stop ? ` · ${m.stop}` : ""}${m.tools ? ` · ${m.tools} tool${m.tools === 1 ? "" : "s"}` : ""}`)}`);
6884
+ }
6885
+ }
6886
+ }
6887
+ if (flags.calls === true) {
6888
+ // Either half recorded counts, once per call.
6889
+ const recorded = [...new Map((step.events ?? []).filter((e) => e.type === "tool" && e.captured && e.call).map((e) => [e.call, e])).values()];
6890
+ for (const e of recorded) console.log(` ${c.dim(`→ ${e.text} ${e.call}`)}`);
6891
+ if (recorded.length) console.log(` ${c.dim(`\`foldrun report ${run.id} call <id>\` shows one call's input and output`)}`);
6892
+ }
6435
6893
  const said = firstLine(step.conclusion ?? step.result, 96);
6436
6894
  if (said) console.log(` ${said}`);
6437
6895
  const err = stepError(step);
@@ -6633,6 +7091,38 @@ async function agentRunCmd(positional, flags, layout) {
6633
7091
  return reportCmd(runId, { ...flags, to: ws }, layout);
6634
7092
  }
6635
7093
 
7094
+ /**
7095
+ * `foldrun check --to <ws> --online` (FOL-58): the network checks, from the
7096
+ * platform — known vulnerabilities for exact runtime pins (OSV) and what
7097
+ * remote MCP servers tell the model about their tools — and, as loudly,
7098
+ * what could not be checked. Exit 1 on a finding at --fail-on or above
7099
+ * (warn under --security), as the local check does.
7100
+ */
7101
+ async function checkOnline(url, flags, ws) {
7102
+ const answer = await remoteCall(
7103
+ url,
7104
+ flags,
7105
+ `/api/workspaces/${enc(ws)}/check?online=1&rule=supply-chain/known-vulnerability,content/mcp-tool-text`,
7106
+ {},
7107
+ { seconds: Math.max(timeoutSeconds(flags), 120) },
7108
+ );
7109
+ // --rule narrows these as it narrows the offline ones (Codex review).
7110
+ const { rules } = checkOptions(flags);
7111
+ const want = new Set(rules);
7112
+ const findings = (answer.findings ?? []).filter(
7113
+ (f) => !rules.length || want.has(f.rule) || want.has(f.rule.split("/")[0]) || want.has(categoryOf(f.rule)),
7114
+ );
7115
+ const problems = answer.online?.problems ?? [];
7116
+ console.log(` ${c.bold("online checks")} ${c.dim("OSV for exact runtime pins · remote MCP servers' tool text")}`);
7117
+ for (const f of findings) console.log(` ${c.amber(" warn")} ${c.bold(`${f.file}${f.line ? `:${f.line}` : ""}`)} ${f.message} ${c.dim(`[${f.rule}]`)}`);
7118
+ if (!findings.length) console.log(` ${c.green("✓")} ${c.dim(problems.length ? "nothing found in what could be checked" : "nothing found")}`);
7119
+ for (const p of problems) console.log(` ${c.amber("!")} ${c.dim("not checked:")} ${p}`);
7120
+ if (answer.rescan?.checkedAt) console.log(` ${c.dim(`the daily rescan last ran ${answer.rescan.checkedAt}`)}`);
7121
+ console.log();
7122
+ const { failOn } = checkOptions(flags);
7123
+ return findings.some((f) => (RANK[f.level] ?? 0) >= FAIL_ON[failOn]) ? 1 : 0;
7124
+ }
7125
+
6636
7126
  /**
6637
7127
  * `foldrun check --to <workspace>` — validate the copy that is DEPLOYED.
6638
7128
  *
@@ -6678,6 +7168,7 @@ async function checkRemote(flags, layout) {
6678
7168
  // The baseline belongs to the folder it was written in, not to a temp copy.
6679
7169
  const code = await check(dir, { ...flags, "no-baseline": true });
6680
7170
  say(` ${c.dim(`checked what is deployed, not this folder — ${ws} · ${url}`)}\n`);
7171
+ if (flags.online === true) return Math.max(code, await checkOnline(url, flags, ws));
6681
7172
  return code;
6682
7173
  } finally {
6683
7174
  if (before.workspace === undefined) delete process.env.FOLDRUN_WORKSPACE;
@@ -7983,6 +8474,8 @@ async function promoteCmd(runId, flags, layout) {
7983
8474
  ...(typeof flags.eval === "string" ? { evalName: flags.eval } : {}),
7984
8475
  ...(typeof flags.case === "string" ? { caseName: flags.case } : {}),
7985
8476
  ...(expect.length ? { expect } : {}),
8477
+ // A run that went wrong: what it should have done (FOL-72 step 10).
8478
+ ...(typeof flags.should === "string" ? { should: flags.should } : {}),
7986
8479
  }),
7987
8480
  });
7988
8481
  const evalName = path.basename(String(r.file ?? ""), ".md");
@@ -8271,6 +8764,15 @@ const secs = (n) => (n == null ? "—" : humanDuration(n * 1000));
8271
8764
  const fine = (n) => (n == null ? "—" : n < 1 ? `${Math.round(n * 1000)}ms` : `${n.toFixed(2)}s`);
8272
8765
  const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "—");
8273
8766
 
8767
+ /** Characters as a reader sizes them: 52k, 1.2M. */
8768
+ const kchars = (v) => (v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1000 ? `${Math.round(v / 1000)}k` : String(v));
8769
+ /** p50/p95, or the worst case when there are too few samples, as the Observe page shows it. */
8770
+ const spreadOf = (s, unit) => {
8771
+ if (!s || !s.n) return "—";
8772
+ if (s.p50 == null) return `≤${unit(s.max)} (n=${s.n})`;
8773
+ return `${unit(s.p50)}/${unit(s.p95)}`;
8774
+ };
8775
+
8274
8776
  /** One workspace's observability report, as the Observe page reads it. */
8275
8777
  function printObservation(o) {
8276
8778
  console.log(`\n ${c.bold(o.workspace)} ${c.dim(`last ${o.sinceDays} days`)}`);
@@ -8283,8 +8785,8 @@ function printObservation(o) {
8283
8785
  for (const r of rows) console.log(` ${r.map((x, i) => (i === 0 ? c.bold(pad(x, w[i])) : pad(x, w[i]))).join(" ")}`);
8284
8786
  };
8285
8787
  table("flows", ["flow", "runs", "failed", "p50", "p95", "cost"], (o.flows ?? []).map((f) => [f.flow, f.runs, f.failed, secs(f.seconds?.p50), secs(f.seconds?.p95), `$${f.costUsd.toFixed(2)}`]));
8286
- table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "cached", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), a.cacheHitRate == null ? "—" : `${Math.round(a.cacheHitRate * 100)}%`, `$${a.costUsd.toFixed(2)}`]));
8287
- table("tools", ["tool", "calls", "errors", "p95", "used by"], (o.tools ?? []).map((t) => [t.name, t.calls, t.errors, t.ms?.p95 == null ? "—" : `${Math.round(t.ms.p95)}ms`, (t.agents ?? []).join(", ")]));
8788
+ table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "turns", "cached", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), spreadOf(a.turns, String), a.cacheHitRate == null ? "—" : `${Math.round(a.cacheHitRate * 100)}%`, `$${a.costUsd.toFixed(2)}`]));
8789
+ table("tools", ["tool", "calls", "errors", "p95", "result size", "heavy", "used by"], (o.tools ?? []).map((t) => [t.name, t.calls, t.errors, t.ms?.p95 == null ? "—" : `${Math.round(t.ms.p95)}ms`, spreadOf(t.resultSize, kchars), t.heavyCalls || "—", (t.agents ?? []).join(", ")]));
8288
8790
  // Where a step's first seconds go, phase by phase (FOL-29): each phase's
8289
8791
  // own share, and when it was done counted from the step's start.
8290
8792
  table("start-up", ["start-up", "n", "took p50", "p95", "done by p50", "p95"], (o.startup ?? []).map((p) => [p.phase, p.took?.n ?? 0, fine(p.took?.p50), fine(p.took?.p95), fine(p.at?.p50), fine(p.at?.p95)]));
@@ -8564,7 +9066,7 @@ async function workspaceCmd(positional, flags, layout) {
8564
9066
  const url = platformFor(flags, "workspace");
8565
9067
  const ws = platformWorkspace(flags, layout, "workspace");
8566
9068
  const [verb, key, ...rest] = positional;
8567
- const KEYS = ["name", "description", "timezone", "budget", "notify"];
9069
+ const KEYS = ["name", "description", "timezone", "budget", "notify", "network_enforce"];
8568
9070
  const show = (d, title) => {
8569
9071
  const notify = d.notify ? [d.notify.email, d.notify.url].filter(Boolean).join(", ") + c.dim(` on ${(d.notify.events ?? []).join(", ") || "nothing"}`) : c.dim("the account's");
8570
9072
  const budget = d.budget == null ? c.dim("the account's") : typeof d.budget === "object" ? `$${d.budget.usd} per ${d.budget.period}` : String(d.budget).includes("/") ? `$${String(d.budget).replace("/", " per ")}` : `$${d.budget} per month`;
@@ -8573,15 +9075,24 @@ async function workspaceCmd(positional, flags, layout) {
8573
9075
  console.log(` timezone ${d.timezone ?? c.dim("the account's")}`);
8574
9076
  console.log(` notify ${notify}`);
8575
9077
  console.log(` budget ${budget}`);
9078
+ if (d.networkEnforce !== undefined) console.log(` network ${networkEnforceText(d.networkEnforce, "the account")}`);
8576
9079
  };
8577
9080
 
8578
9081
  if (!verb) {
8579
9082
  const d = await workspaceDefaults(url, flags, ws);
8580
- show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null }, ws);
9083
+ show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null, networkEnforce: d.network_enforce === true }, ws);
9084
+ const halt = await pauseState(remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`));
9085
+ if (halt) console.log(` paused ${halt.paused ? c.red(`${halt.paused.scope === "account" ? "by the account: " : ""}${pauseLine(halt.paused)}`) : c.dim("no")}`);
8581
9086
  console.log(`\n ${c.dim(`foldrun workspace set timezone Australia/Sydney --to ${ws} · set budget 20/week · set notify email you@example.com · set name <new> · clear budget`)}\n`);
8582
9087
  return 0;
8583
9088
  }
8584
- if (verb !== "set" && verb !== "clear") throw new Error(`unknown workspace verb "${verb}" — set <key> <value>, clear <key> (bare: show them)`);
9089
+ if (verb === "pause" || verb === "resume") {
9090
+ return pauseCmd(flags, verb, `workspace ${ws}`, {
9091
+ post: (body) => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "POST", body }),
9092
+ del: () => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "DELETE" }),
9093
+ });
9094
+ }
9095
+ if (verb !== "set" && verb !== "clear") throw new Error(`unknown workspace verb "${verb}" — set <key> <value>, clear <key>, pause, resume (bare: show them)`);
8585
9096
  if (!KEYS.includes(key) || (verb === "clear" && key === "name")) {
8586
9097
  throw new Error(`which setting? \`foldrun workspace ${verb} <${(verb === "clear" ? KEYS.filter((k) => k !== "name") : KEYS).join("|")}> … --to <workspace>\``);
8587
9098
  }
@@ -8602,7 +9113,7 @@ async function workspaceCmd(positional, flags, layout) {
8602
9113
  } else {
8603
9114
  const value = rest.join(" ").trim();
8604
9115
  if (!value) throw new Error(`\`foldrun workspace set ${key} <value> --to ${ws}\`${key === "name" ? "" : ` — or \`clear ${key}\``}`);
8605
- patch = { [key]: value };
9116
+ patch = { [key]: key === "network_enforce" ? onOff(key, value, `foldrun workspace set network_enforce on --to ${ws}`) : value };
8606
9117
  }
8607
9118
 
8608
9119
  if (key === "name" && verb === "set") {
@@ -9276,6 +9787,7 @@ async function flowDraftCmd(positional, flags, layout) {
9276
9787
  const existing = typeof flags.flow === "string" ? flags.flow : undefined;
9277
9788
  console.log(`\n ${c.dim(`drafting in ${ws}${existing ? ` (redraft of ${existing})` : ""} — one model call, charged like a step…`)}`);
9278
9789
  const d = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/flows/draft`, { method: "POST", body: JSON.stringify({ description, ...(existing ? { flow: existing } : {}) }) }, { seconds: Math.max(timeoutSeconds(flags), 180) });
9790
+ /** @type {{ path: string, content: string, before: string | null, sha256?: string | null }[]} */
9279
9791
  const files = d.files ?? [];
9280
9792
  if (d.notes) console.log(`\n ${d.notes}`);
9281
9793
  console.log();
@@ -9646,11 +10158,14 @@ async function historyRestoreCmd(positional, flags, layout) {
9646
10158
  return 0;
9647
10159
  }
9648
10160
  // The dashboard's own message, so the two read alike in the history.
9649
- await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, {
10161
+ // A restore is an exact version someone picked: saved unchecked, and it
10162
+ // says so when today's checks would refuse it, as the dashboard does (FOL-57).
10163
+ const saved = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, {
9650
10164
  method: "PUT",
9651
10165
  body: JSON.stringify({ path: file, content: f.after, message: `restored ${label} from history`, ifMatch: current.sha256 ?? null }),
9652
10166
  });
9653
10167
  console.log(` ${c.green("✓")} ${file} ${c.dim(`reads as it did at ${label} — a new revision; what it said before is still in the history`)}\n`);
10168
+ for (const i of (saved?.issues ?? []).filter((x) => x.level === "error")) console.log(` ${c.amber("!")} ${c.dim("does not pass today's checks:")} ${i.where} — ${i.message}`);
9654
10169
  return 0;
9655
10170
  }
9656
10171
 
@@ -10165,12 +10680,13 @@ async function evalNewCmd(name, flags, layout) {
10165
10680
  /**
10166
10681
  * `foldrun team` — who is in the account, their role and which workspaces
10167
10682
  * they may open (GET /api/team). Read-only on purpose: inviting, changing a
10168
- * role, removing someone and transferring the account are a person's acts,
10169
- * and the platform refuses them from a key.
10683
+ * role or workspace access, removing someone and transferring the account are
10684
+ * a person's acts, and the platform refuses them from a key (requirePerson,
10685
+ * server/api.ts) — so there is nothing for a key-holding CLI to send.
10170
10686
  */
10171
10687
  async function teamCmd(positional, flags) {
10172
10688
  if (positional[0]) {
10173
- throw new Error("`foldrun team` lists the members — invites, role changes, removals and transfer are done by a signed-in person in Settings → Team; the platform refuses them from a key");
10689
+ throw new Error("`foldrun team` lists the members — inviting, changing a role or workspace access, removing a member and transferring the account are a signed-in person's acts, done in Settings → Team; the platform refuses them from a key");
10174
10690
  }
10175
10691
  const url = platformFor(flags, "team");
10176
10692
  const { members = [] } = await remoteCall(url, flags, "/api/team");
@@ -10392,6 +10908,14 @@ export async function run(command, positional, flags, workspace, layout) {
10392
10908
  say(` ${c.dim(`coding-agent rules written for ${codingAgent()} — AGENTS.md ${r.agentsMd}, CLAUDE.md ${r.claudeMd}; read \`foldrun docs\` before editing`)}`);
10393
10909
  }
10394
10910
  if (flags.rules === true) return checkRulesCmd(flags);
10911
+ // --online (FOL-58): the network checks run on the platform, which has
10912
+ // the guarded fetch and the deployed copy — so it needs --to.
10913
+ if (flags.online === true && typeof flags.to !== "string") {
10914
+ throw new Error("`foldrun check --to <workspace> --online` — the online checks (OSV, remote MCP tool text) run on the platform, against the deployed copy");
10915
+ }
10916
+ if (flags.online === true && (flags.json === true || (typeof flags.format === "string" && flags.format !== "text"))) {
10917
+ throw new Error("--online prints text; for JSON ask the API: GET /api/workspaces/<ws>/check?online=1");
10918
+ }
10395
10919
  // --to names a workspace on a PLATFORM, so there is nothing local to
10396
10920
  // check and the folder this terminal stands in is beside the point.
10397
10921
  return typeof flags.to === "string"
@@ -10475,14 +10999,28 @@ export async function run(command, positional, flags, workspace, layout) {
10475
10999
  // the local store has no table to draw across workspaces, because a
10476
11000
  // folder on a laptop is one workspace.
10477
11001
  if (positional[0] === "rm") return runRmCmd(positional[1], flags, layout);
11002
+ if (positional[0] === "views") return runViewsCmd(flags);
11003
+ // The run doctor (FOL-22): the assistant reads the run and its files,
11004
+ // says why it failed and proposes the fix as diffs — saved on yes.
11005
+ if (positional[0] === "doctor") {
11006
+ if (positional.length !== 2 || !/^[A-Za-z0-9_.-]{1,80}$/.test(positional[1])) {
11007
+ throw new Error("`foldrun runs doctor <run-id> --to <workspace>` — the run doctor reads that run and proposes the fix");
11008
+ }
11009
+ return assistantCmd([`Doctor: why did run ${positional[1]} fail, and how do I fix it?`], flags, layout);
11010
+ }
10478
11011
  return flags.local === true ? logsCmd(positional, flags, layout) : runsCmd(flags, layout);
10479
11012
  case "approvals":
10480
11013
  return approvalsCmd(flags, layout);
11014
+ case "issues":
11015
+ return issuesCmd(positional, flags, layout);
11016
+ case "insights":
11017
+ return insightsCmd(flags);
10481
11018
  case "approve":
10482
11019
  case "reject":
10483
11020
  return decideCmd(command, positional[0], flags, layout);
10484
11021
  case "report":
10485
11022
  if (positional[1] === "live") return reportLiveCmd(positional[0], flags, layout);
11023
+ if (positional[1] === "call") return reportCallCmd(positional[0], positional[2], flags, layout);
10486
11024
  return positional[1] === "get" ? reportGetCmd(positional[0], positional[2], flags, layout) : reportCmd(positional[0], flags, layout);
10487
11025
  case "stop":
10488
11026
  // No run id and a filter: every run the filter matches (runs/bulk).