foldrun 0.9.0 → 1.0.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
@@ -1,6 +1,7 @@
1
1
  // CLI commands. Kept separate from bin/ so the environment is set before the
2
2
  // core is imported — single-workspace mode is read at module load.
3
3
 
4
+ import { writeExact } from "./terminal.mjs";
4
5
  import dns from "node:dns";
5
6
  import fs from "node:fs";
6
7
  import crypto from "node:crypto";
@@ -710,8 +711,10 @@ async function platformLibrary(flags) {
710
711
  return new Set((body.entries ?? []).map((e) => e.name).filter(Boolean));
711
712
  };
712
713
  try {
713
- const [tools, skills] = await Promise.all([names("tools"), names("skills")]);
714
- return { url, tools, skills, warning: null };
714
+ // Hooks are newer than the library route on some platforms: a platform
715
+ // that does not list them leaves the set empty rather than failing check.
716
+ const [tools, skills, hooks] = await Promise.all([names("tools"), names("skills"), names("hooks").catch(() => new Set())]);
717
+ return { url, tools, skills, hooks, warning: null };
715
718
  } catch (err) {
716
719
  return { url, tools: new Set(), skills: new Set(), warning: `could not read the library on ${url} (${err instanceof Error ? err.message : err}) — tools defined there will be reported missing` };
717
720
  }
@@ -772,6 +775,11 @@ async function checkAccount(layout, flags) {
772
775
  const problems = [];
773
776
  const note = (level, where, message) => problems.push({ level, where, message });
774
777
  validateSkills(path.join(layout.accountRoot, "library"), note);
778
+ const hc = await hooksCore();
779
+ for (const p of hc ? hc.checkHooksDir(path.join(layout.accountRoot, "library", "hooks"), "account") : []) {
780
+ const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
781
+ note("error", m ? m[1] : "hooks/", m ? m[2] : p);
782
+ }
775
783
  const counts = LIBRARY_KINDS.map((k) => ({ kind: k, n: files.filter((f) => f.kind === k).length })).filter((x) => x.n > 0);
776
784
  console.log(`\n ${c.bold("library")} ${c.dim(counts.length ? counts.map((x) => `${x.n} ${x.kind}`).join(" · ") : "empty")}`);
777
785
  for (const p of problems) {
@@ -1053,6 +1061,8 @@ async function checkProblems(workspace, flags = {}) {
1053
1061
  }
1054
1062
  }
1055
1063
 
1064
+ await checkHooks(workspace, agents, libraryDir(T), platform, note);
1065
+
1056
1066
  // A script tool whose `run:` resolves to nothing parses, counts, and is
1057
1067
  // offered to the agent — then fails inside a turn. Checking it here is the
1058
1068
  // difference between a typo found in CI and a flow that quietly stops
@@ -1162,6 +1172,68 @@ async function checkProblems(workspace, flags = {}) {
1162
1172
  return { problems, summary };
1163
1173
  }
1164
1174
 
1175
+ /**
1176
+ * Hooks, checked with core's own reader so check and a run agree: a hook
1177
+ * folder that is not a hook, a `hooks:` name that resolves to nothing (a run
1178
+ * refuses that step rather than run it unguarded), and a tool.call hook on an
1179
+ * agent whose web search or fetch the model provider runs on its own servers
1180
+ * — those calls never pass foldrun's dispatch, so the hook never sees them.
1181
+ * A core from before hooks has no reader, and then there is nothing to say.
1182
+ */
1183
+ async function checkHooks(workspace, agents, libraryRoot, platform, note) {
1184
+ const h = await hooksCore();
1185
+ if (!h) return;
1186
+ for (const p of h.checkHooksDir(path.join(workspace, "hooks"), "workspace")) {
1187
+ const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
1188
+ note("error", m ? m[1] : "hooks/", m ? m[2] : p);
1189
+ }
1190
+ const matter = (await import("gray-matter")).default;
1191
+ const front = (file) => {
1192
+ try {
1193
+ return fs.existsSync(file) ? matter(fs.readFileSync(file, "utf8")).data ?? {} : {};
1194
+ } catch {
1195
+ return {}; // an unreadable file is reported by its own check
1196
+ }
1197
+ };
1198
+ const exists = (n) =>
1199
+ fs.existsSync(path.join(workspace, "hooks", n)) || fs.existsSync(path.join(libraryRoot, "hooks", n)) || platform.hooks?.has(n);
1200
+ const missing = (where, names) => {
1201
+ for (const n of names) {
1202
+ if (!exists(n)) note("error", where, `hooks: [${n}] — no hooks/${n}/hook.md in this workspace or the account library`);
1203
+ }
1204
+ };
1205
+ const accountNames = h.hookNames(front(path.join(path.dirname(libraryRoot), "AGENTS.md")).hooks);
1206
+ const workspaceNames = h.hookNames(front(path.join(workspace, "AGENTS.md")).hooks);
1207
+ missing("account AGENTS.md", accountNames);
1208
+ missing("AGENTS.md", workspaceNames);
1209
+
1210
+ let resolveSearch = null;
1211
+ try {
1212
+ ({ resolveSearch } = /** @type {any} */ (await import("@foldrun/core/providers")));
1213
+ } catch {
1214
+ // no provider table: the provider-side search warning is skipped
1215
+ }
1216
+ for (const a of agents) {
1217
+ const f = front(path.join(workspace, "agents", a.name, "agent.md"));
1218
+ const own = h.hookNames(f.hooks);
1219
+ missing(`agents/${a.name}`, own);
1220
+ if (!resolveSearch || !f.web || typeof f.web !== "object" || Array.isArray(f.web)) continue;
1221
+ const { hooks } = h.resolveHooks({ workspaceRoot: workspace, libraryRoot, names: [...accountNames, ...workspaceNames, ...own] });
1222
+ const gates = hooks.filter((x) => x.on === "tool.call").map((x) => x.name);
1223
+ if (!gates.length) continue;
1224
+ for (const action of ["search", "fetch"]) {
1225
+ if (f.web[action] === undefined) continue;
1226
+ const r = resolveSearch(f.web[action], action);
1227
+ if (!r?.provider || r.error || r.shape === "direct") continue;
1228
+ note(
1229
+ "warn",
1230
+ `agents/${a.name}`,
1231
+ `web.${action}: ${r.provider} runs on the model provider's servers, so tool.call hook${gates.length === 1 ? "" : "s"} ${gates.join(", ")} never see${gates.length === 1 ? "s" : ""} those calls — leave web.${action}: unset (or name a ${action} API) for foldrun's own dispatch, which hooks guard`,
1232
+ );
1233
+ }
1234
+ }
1235
+ }
1236
+
1165
1237
  /** The web tool the platform's gallery serves: built in, and the only
1166
1238
  * built-in that takes a provider. */
1167
1239
  const WEB_TOOL = "web";
@@ -1907,7 +1979,47 @@ export function semverNewer(a, b) {
1907
1979
  for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
1908
1980
  if (x.pre && !y.pre) return false;
1909
1981
  if (!x.pre && y.pre) return true;
1910
- return (x.pre ?? "") > (y.pre ?? "");
1982
+ if (!x.pre) return false;
1983
+ // SemVer 2.0.0 §11: dot-separated identifiers compared one by one — numbers
1984
+ // as numbers (rc.10 > rc.9), a number before a word, and a shorter list
1985
+ // before a longer one it begins.
1986
+ const xs = x.pre.slice(1).split(".");
1987
+ const ys = y.pre.slice(1).split(".");
1988
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
1989
+ if (xs[i] == null || ys[i] == null) return ys[i] == null;
1990
+ const xn = /^\d+$/.test(xs[i]);
1991
+ const yn = /^\d+$/.test(ys[i]);
1992
+ if (xn && yn) {
1993
+ if (Number(xs[i]) !== Number(ys[i])) return Number(xs[i]) > Number(ys[i]);
1994
+ } else if (xn !== yn) return yn;
1995
+ else if (xs[i] !== ys[i]) return xs[i] > ys[i];
1996
+ }
1997
+ return false;
1998
+ }
1999
+
2000
+ /**
2001
+ * Is `latest` an update worth telling someone running `current` about? Newer,
2002
+ * and not a pre-release offered to someone on a stable release: npm keeps
2003
+ * pre-releases off `latest`, so `npm i -g foldrun@latest` would not even
2004
+ * install it.
2005
+ * @param {string} latest @param {string} current
2006
+ */
2007
+ export function updateTo(latest, current) {
2008
+ // The whole header must be a version: it is printed, and a platform that
2009
+ // could append to it could write to the terminal.
2010
+ if (!/^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(latest ?? "").trim())) return false;
2011
+ const pre = (v) => /^v?\d+\.\d+\.\d+-/.test(String(v ?? "").trim());
2012
+ return semverNewer(latest, current) && !(pre(latest) && !pre(current));
2013
+ }
2014
+
2015
+ /**
2016
+ * Text from a platform, made safe to print: no escape sequences or other
2017
+ * control characters, so a hostile or broken server cannot move the cursor,
2018
+ * retitle the terminal, write the clipboard (OSC 52) or disguise a link.
2019
+ * @param {unknown} s
2020
+ */
2021
+ export function printable(s) {
2022
+ return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
1911
2023
  }
1912
2024
 
1913
2025
  const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -1927,14 +2039,14 @@ function updateNotice(res) {
1927
2039
  if (env("FOLDRUN_NO_UPDATE_NOTICE")) return;
1928
2040
  if (!(process.stderr.isTTY || env("FOLDRUN_FORCE_TTY") === "1")) return;
1929
2041
  const latest = res.headers.get("x-foldrun-cli");
1930
- if (!latest || !semverNewer(latest, CLI_VERSION)) return;
2042
+ if (!latest || !updateTo(latest, CLI_VERSION)) return;
1931
2043
  noticeDone = true;
1932
2044
  const file = path.join(credentialsDir(), "version-check.json");
1933
2045
  try {
1934
2046
  const last = JSON.parse(fs.readFileSync(file, "utf8"));
1935
2047
  if (Date.now() - Date.parse(last.notifiedAt) < UPDATE_EVERY_MS) return;
1936
2048
  } catch {}
1937
- const platform = res.headers.get("x-foldrun-version");
2049
+ const platform = printable(res.headers.get("x-foldrun-version") ?? "") || null;
1938
2050
  console.error(` ${c.dim(`foldrun ${latest} is out (this is ${CLI_VERSION})${platform ? `, released with platform ${platform}` : ""} — npm i -g foldrun@latest`)}`);
1939
2051
  fs.mkdirSync(credentialsDir(), { recursive: true });
1940
2052
  fs.writeFileSync(file, JSON.stringify({ notifiedAt: new Date().toISOString(), cli: latest, platform }) + "\n");
@@ -1968,7 +2080,7 @@ async function versionCmd(flags) {
1968
2080
  }
1969
2081
  }
1970
2082
  const latestCli = platform?.packages?.cli ?? null;
1971
- const behind = latestCli ? semverNewer(latestCli, CLI_VERSION) : false;
2083
+ const behind = latestCli ? updateTo(latestCli, CLI_VERSION) : false;
1972
2084
  if (flags.json === true) {
1973
2085
  console.log(JSON.stringify({ cli: CLI_VERSION, core, platform: platform ?? (url ? { url, error: unreachable } : null) }, null, 2));
1974
2086
  if (behind) console.error(`foldrun ${latestCli} is out (this is ${CLI_VERSION}) — npm i -g foldrun@latest`);
@@ -3625,7 +3737,7 @@ async function auditCmd(positional, flags) {
3625
3737
  q.set("format", "csv");
3626
3738
  const { bytes, name } = await remoteBytes(url, flags, `/api/audit?${q}`, "the audit log");
3627
3739
  if (typeof flags.file !== "string") {
3628
- process.stdout.write(bytes);
3740
+ writeExact(bytes);
3629
3741
  return 0;
3630
3742
  }
3631
3743
  return writeDownload(bytes, name ?? "audit.csv", flags, "the audit log");
@@ -3701,7 +3813,7 @@ async function sourceCmd(positional, flags) {
3701
3813
  if (verb === "cat" || verb === "read" || verb === "get") {
3702
3814
  if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
3703
3815
  const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
3704
- process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
3816
+ writeExact(content.endsWith("\n") ? content : `${content}\n`);
3705
3817
  return 0;
3706
3818
  }
3707
3819
  if (verb === "put" || verb === "write") {
@@ -4706,7 +4818,7 @@ async function storageCmd(positional, flags, layout) {
4706
4818
  if (bytes.includes(0)) {
4707
4819
  throw new Error(`${rel} is not text (${humanBytes(bytes.length)}) — \`foldrun storage cat ${rel} --preview\` reads what is in it, \`foldrun storage get ${rel}\` writes it to a file`);
4708
4820
  }
4709
- process.stdout.write(bytes.toString("utf8"));
4821
+ writeExact(bytes);
4710
4822
  return 0;
4711
4823
  }
4712
4824
 
@@ -6122,6 +6234,308 @@ async function checkRemote(flags, layout) {
6122
6234
  }
6123
6235
  }
6124
6236
 
6237
+ // ---------------------------------------------------------------- hooks
6238
+
6239
+ /**
6240
+ * Core's hooks module — readHookDef, runHook, the event list — or null on a
6241
+ * core from before hooks, where `check` then says nothing about them and the
6242
+ * `hook` verbs say which core they need.
6243
+ */
6244
+ async function hooksCore() {
6245
+ try {
6246
+ return /** @type {any} */ (await import("@foldrun/core/hooks"));
6247
+ } catch {
6248
+ return null;
6249
+ }
6250
+ }
6251
+ async function needHooksCore() {
6252
+ const h = await hooksCore();
6253
+ if (!h) throw new Error("this CLI's @foldrun/core has no hooks — update it (npm i -g foldrun@latest)");
6254
+ return h;
6255
+ }
6256
+
6257
+ /** The account library a hook verb works on: the folder beside workspaces/. */
6258
+ const libraryRootOf = (layout) => path.join(layout.accountRoot, "library");
6259
+
6260
+ /** What a new run.mjs answers, per event: a gate allows, a watcher notes nothing. */
6261
+ const HOOK_ANSWER = {
6262
+ "tool.call": `{ decision: "allow" }`,
6263
+ "tool.result": `{}`,
6264
+ "step.start": `{}`,
6265
+ "step.end": `{}`,
6266
+ };
6267
+
6268
+ /** A sample event per kind, for `hook test` with no --event. */
6269
+ const HOOK_SAMPLE = {
6270
+ "tool.call": { tool: { name: "Bash", input: { command: "ls" } } },
6271
+ "tool.result": { tool: { name: "Bash", input: { command: "ls" }, result: "agent.md\n" } },
6272
+ "step.start": { prompt: "Find three sources." },
6273
+ "step.end": { reply: "Done." },
6274
+ };
6275
+
6276
+ function hookRunTemplate(name, on) {
6277
+ return `// ${name} — a foldrun hook (on: ${on}). Self-contained: Node's built-ins only,
6278
+ // no relative imports — it runs from a copy taken when the step starts.
6279
+ //
6280
+ // In: one JSON object on stdin — the event:
6281
+ // { event, hook, run_id, step, agent, workspace,
6282
+ // tool: { name, input } (tool.call, tool.result; + result on tool.result)
6283
+ // prompt (step.start)
6284
+ // reply (step.end) }
6285
+ // Out: one JSON object on stdout, the last line printed:
6286
+ // tool.call { decision: "allow" | "deny", reason, input, note }
6287
+ // input replaces the tool's whole input; a deny needs a reason
6288
+ // step.start { context, note } context is added to the step's prompt
6289
+ // step.end { reply, note } reply replaces the step's reply
6290
+ // tool.result { note } a line on the run's trail
6291
+ // Exit 0 with an answer. Exit 2 denies, with stderr as the reason.
6292
+ // Anything else is a failure: a gate (tool.call, step.start) that fails
6293
+ // refuses; a watcher (tool.result, step.end) that fails is recorded.
6294
+ // No secrets and no model key reach this process.
6295
+
6296
+ let raw = "";
6297
+ for await (const chunk of process.stdin) raw += chunk;
6298
+ const event = JSON.parse(raw || "{}");
6299
+
6300
+ // Decide here. This one allows everything.
6301
+ void event;
6302
+ console.log(JSON.stringify(${HOOK_ANSWER[on]}));
6303
+ `;
6304
+ }
6305
+
6306
+ /** `foldrun hook <ls|add|test|rm>` — hooks in this workspace and the library. */
6307
+ async function hookCmd(positional, flags, layout) {
6308
+ const [verb = "ls"] = positional;
6309
+ if (verb === "ls" || verb === "list") return hookLsCmd(flags, layout);
6310
+ if (verb === "add" || verb === "new") return hookAddCmd(positional, flags, layout);
6311
+ if (verb === "test") return hookTestCmd(positional, flags, layout);
6312
+ if (verb === "rm") return hookRmCmd(positional, flags, layout);
6313
+ throw new Error(`unknown hook verb "${verb}" — ls, add <name> --on <event>, test <name>, rm <name>`);
6314
+ }
6315
+
6316
+ /** Every hook folder here, the workspace's first, each read the way a run reads it. */
6317
+ async function hooksHere(flags, layout) {
6318
+ const h = await needHooksCore();
6319
+ const ws = workspaceDirFor(flags, layout);
6320
+ const out = [];
6321
+ const seen = new Set();
6322
+ for (const [root, scope] of [[ws, "workspace"], [libraryRootOf(layout), "account"]]) {
6323
+ for (const dir of h.listHookDirs(path.join(root, "hooks"))) {
6324
+ const name = path.basename(dir);
6325
+ const def = h.readHookDef(dir, scope);
6326
+ const row = "error" in def
6327
+ ? { name, scope, error: def.error }
6328
+ : { name, scope, on: def.on, tool: def.tool ?? null, timeout: def.timeoutSec, description: def.description ?? null };
6329
+ // A workspace hook of the same name is the one a run resolves.
6330
+ out.push({ ...row, ...(seen.has(name) ? { shadowed: true } : {}) });
6331
+ seen.add(name);
6332
+ }
6333
+ }
6334
+ return { h, ws, hooks: out };
6335
+ }
6336
+
6337
+ /** `hook ls --to <ws>`: the DEPLOYED workspace's hooks and what they did
6338
+ * lately — GET /api/workspaces/<ws>/hooks, the dashboard's Hooks panel. */
6339
+ async function hookLsRemote(flags) {
6340
+ const url = platformFor(flags, "hook ls");
6341
+ const ws = String(flags.to);
6342
+ const q = new URLSearchParams();
6343
+ if (flags.all === true) q.set("all", "1");
6344
+ if (flags.runs !== undefined) q.set("runs", String(flags.runs));
6345
+ const r = /** @type {{ hooks: any[], recent: any[] }} */ (await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/hooks${q.size ? `?${q}` : ""}`));
6346
+ if (flags.json === true) {
6347
+ console.log(JSON.stringify(r, null, 2));
6348
+ return 0;
6349
+ }
6350
+ const hooks = r.hooks ?? [];
6351
+ if (!hooks.length) console.log(`\n ${c.dim(`no hooks in ${ws} or the account library`)}`);
6352
+ else {
6353
+ console.log();
6354
+ const w = Math.max(...hooks.map((x) => x.name.length));
6355
+ for (const x of hooks) {
6356
+ const where = x.scope === "account" ? "library" : "workspace";
6357
+ if (x.error) {
6358
+ console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
6359
+ continue;
6360
+ }
6361
+ const on = (x.onFor ?? []).length ? `on for ${x.onFor.join(", ")}` : "turned on by nobody";
6362
+ const note = x.shadowed ? "(the workspace's own of this name is the one that runs)" : `${on}${x.description ? ` — ${x.description}` : ""}`;
6363
+ console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${String(x.on).padEnd(11)} ${String(x.tool ?? "*").padEnd(12)} ${c.dim(note)}`);
6364
+ }
6365
+ }
6366
+ const recent = r.recent ?? [];
6367
+ console.log(`\n ${c.bold("lately")} ${c.dim(recent.length ? `${recent.length} ${flags.all === true ? "hook decision" : "deny, rewrite or failure"}${recent.length === 1 ? "" : "s"}, newest first` : flags.all === true ? "no hook ran" : "nothing denied, rewritten or failed (--all for every decision)")}`);
6368
+ for (const e of recent.slice(0, 20)) {
6369
+ const rec = e.record ?? {};
6370
+ console.log(` ${c.dim(String(e.t).slice(0, 16).replace("T", " "))} ${rec.hook} ${rec.did} ${rec.event}${rec.tool ? ` ${rec.tool}` : ""} ${c.dim(`${e.runId} step ${e.step} ${e.agent}`)}${rec.reason ? ` — ${firstLine(rec.reason, 80)}` : ""}`);
6371
+ }
6372
+ console.log(`\n ${c.dim("--runs <n> reads further back (default 50) · --all for every decision · --json")}\n`);
6373
+ return hooks.some((x) => x.error) ? 1 : 0;
6374
+ }
6375
+
6376
+ async function hookLsCmd(flags, layout) {
6377
+ if (typeof flags.to === "string") return hookLsRemote(flags);
6378
+ const { hooks } = await hooksHere(flags, layout);
6379
+ if (flags.json === true) {
6380
+ console.log(JSON.stringify(hooks, null, 2));
6381
+ return 0;
6382
+ }
6383
+ if (!hooks.length) {
6384
+ console.log(`\n ${c.dim("no hooks — `foldrun hook add <name> --on tool.call` makes one")}\n`);
6385
+ return 0;
6386
+ }
6387
+ console.log();
6388
+ const w = Math.max(...hooks.map((x) => x.name.length));
6389
+ for (const x of hooks) {
6390
+ const where = x.scope === "account" ? "library" : "workspace";
6391
+ if (x.error) {
6392
+ console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
6393
+ continue;
6394
+ }
6395
+ const what = `${x.on.padEnd(11)} ${(x.tool ?? "*").padEnd(12)}`;
6396
+ const note = x.shadowed ? c.dim("(the workspace's own of this name is the one that runs)") : c.dim(x.description ?? "");
6397
+ console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${what} ${note}`);
6398
+ }
6399
+ console.log(`\n ${c.dim("an agent turns one on with hooks: [name]; AGENTS.md hooks: turns it on for every agent under it")}\n`);
6400
+ return hooks.some((x) => x.error) ? 1 : 0;
6401
+ }
6402
+
6403
+ async function hookAddCmd(positional, flags, layout) {
6404
+ const h = await needHooksCore();
6405
+ const name = positional[1];
6406
+ if (!name) throw new Error("which hook? `foldrun hook add <name> --on tool.call|tool.result|step.start|step.end`");
6407
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
6408
+ throw new Error(`"${name}" is not a hook name — lowercase letters, digits and dashes, e.g. no-rm-rf`);
6409
+ }
6410
+ const on = flags.on;
6411
+ if (typeof on !== "string" || !h.HOOK_EVENTS.includes(on)) {
6412
+ throw new Error(`--on takes ${h.HOOK_EVENTS.join(", ")}${typeof on === "string" ? ` — not "${on}"` : ""}`);
6413
+ }
6414
+ const tool = typeof flags.tool === "string" ? flags.tool.trim() : undefined;
6415
+ if (flags.tool !== undefined && !tool) throw new Error("--tool takes a tool name or pattern, like Bash, Write|Edit or mcp__*");
6416
+ if (tool && on !== "tool.call" && on !== "tool.result") {
6417
+ throw new Error(`--tool only means something on tool.call and tool.result, not ${on}`);
6418
+ }
6419
+ const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
6420
+ const rel = `hooks/${name}`;
6421
+ const front = [
6422
+ "---",
6423
+ `on: ${on}`,
6424
+ ...(tool ? [`tool: ${/[|*:]/.test(tool) ? JSON.stringify(tool) : tool}`] : []),
6425
+ `description: What ${name} checks, in one line.`,
6426
+ "---",
6427
+ "",
6428
+ `What ${name} is for, and what it refuses or rewrites. The code is run.mjs beside this file.`,
6429
+ "",
6430
+ ].join("\n");
6431
+ writeFiles(root, [
6432
+ { path: `${rel}/hook.md`, content: front },
6433
+ { path: `${rel}/run.mjs`, content: hookRunTemplate(name, on) },
6434
+ ], flags.library === true ? "the library" : path.basename(root));
6435
+ const where = path.relative(process.cwd(), path.join(root, rel)) || rel;
6436
+ console.log(`\n ${c.green("created")} hook ${c.bold(name)} ${c.dim(`(${on}${tool ? ` · ${tool}` : ""})`)} in ${where}\n`);
6437
+ console.log(` ${c.dim(`${rel}/hook.md`)}\n ${c.dim(`${rel}/run.mjs`)}`);
6438
+ console.log(`\n ${c.bold("Next")}
6439
+ edit ${path.join(where, "run.mjs")}
6440
+ hooks: [${name}]${" ".repeat(4)}${c.dim("in an agent's frontmatter — or in AGENTS.md for every agent")}
6441
+ foldrun hook test ${name}${" ".repeat(4)}${c.dim("run it on a sample event, here")}\n`);
6442
+ return 0;
6443
+ }
6444
+
6445
+ /** --event: inline JSON, or @file. */
6446
+ function readHookEvent(raw, on) {
6447
+ if (raw === undefined) return { event: HOOK_SAMPLE[on] ?? {}, sample: true };
6448
+ if (typeof raw !== "string") throw new Error("--event takes a JSON object, or @file");
6449
+ const text = raw.startsWith("@") ? fs.readFileSync(raw.slice(1), "utf8") : raw;
6450
+ let event;
6451
+ try { event = JSON.parse(text); } catch (err) {
6452
+ throw new Error(`--event is not JSON (${err instanceof Error ? err.message : err})`);
6453
+ }
6454
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("--event is one JSON object — the event the hook reads on stdin");
6455
+ return { event, sample: false };
6456
+ }
6457
+
6458
+ function printHookAnswer(name, on, a, where) {
6459
+ const mark = !a.ok ? c.red("✗") : a.decision === "deny" ? c.amber("■") : c.green("✓");
6460
+ console.log(`\n ${mark} ${c.bold(name)} ${c.dim(`${on ?? "?"} · ${where} · ${a.ms ?? 0}ms`)}`);
6461
+ if (!a.ok) console.log(` ${c.red("failed")} ${a.failure ?? "no reason given"}${on === "tool.call" || on === "step.start" ? c.dim(" — a gate that fails refuses") : ""}`);
6462
+ if (a.decision) console.log(` ${pad("decision", 9)} ${a.decision === "deny" ? c.amber("deny") : "allow"}`);
6463
+ if (a.reason) console.log(` ${pad("reason", 9)} ${a.reason}`);
6464
+ if (a.input) console.log(` ${pad("input", 9)} ${JSON.stringify(a.input)}`);
6465
+ if (a.context) console.log(` ${pad("context", 9)} ${a.context}`);
6466
+ if (a.reply !== undefined) console.log(` ${pad("reply", 9)} ${a.reply}`);
6467
+ if (a.note) console.log(` ${pad("note", 9)} ${a.note}`);
6468
+ console.log();
6469
+ return a.ok ? 0 : 1;
6470
+ }
6471
+
6472
+ /**
6473
+ * `foldrun hook test <name> [--event '<json>']` — run one hook on one event,
6474
+ * here, the way a step runs it (core's runHook: the event on stdin, a
6475
+ * stripped environment, its timeout). `--to <workspace>` asks the platform
6476
+ * to run the deployed one instead.
6477
+ */
6478
+ async function hookTestCmd(positional, flags, layout) {
6479
+ const name = positional[1];
6480
+ if (!name) throw new Error("which hook? `foldrun hook test <name> --event '<json>'` — `foldrun hook ls` lists them");
6481
+ if (typeof flags.to === "string") {
6482
+ const url = platformFor(flags, "hook test");
6483
+ const ws = flags.to;
6484
+ let event = {};
6485
+ if (flags.event !== undefined) event = readHookEvent(flags.event, "tool.call").event;
6486
+ const r = /** @type {any} */ (await remoteCall(
6487
+ url,
6488
+ flags,
6489
+ `/api/workspaces/${enc(ws)}/hooks/${enc(name)}/test`,
6490
+ { method: "POST", body: JSON.stringify({ event }) },
6491
+ ));
6492
+ const a = r?.answer ?? r ?? {};
6493
+ return printHookAnswer(name, r?.on ?? a.on, { ok: a.ok !== false, ...a }, ws);
6494
+ }
6495
+ const h = await needHooksCore();
6496
+ const ws = workspaceDirFor({ ...flags, to: undefined }, layout);
6497
+ const r = h.resolveHooks({ workspaceRoot: ws, libraryRoot: libraryRootOf(layout), names: [name] });
6498
+ if (r.missing.length) throw new Error(`no hook named ${name} in hooks/ or the library — \`foldrun hook ls\` lists them`);
6499
+ if (r.errors.length) throw new Error(r.errors[0]);
6500
+ const def = r.hooks[0];
6501
+ const { event, sample } = readHookEvent(flags.event, def.on);
6502
+ if (sample) console.log(`\n ${c.dim(`no --event, so a sample ${def.on} event: ${JSON.stringify(event)}`)}`);
6503
+ const full = { event: def.on, hook: def.name, run_id: null, step: null, agent: null, workspace: path.basename(ws), ...event };
6504
+ const a = await h.runHook(def, full, { cwd: ws, env: process.env });
6505
+ return printHookAnswer(name, def.on, a, def.scope === "account" ? "library" : path.basename(ws));
6506
+ }
6507
+
6508
+ async function hookRmCmd(positional, flags, layout) {
6509
+ const h = await needHooksCore();
6510
+ const name = positional[1];
6511
+ if (!name) throw new Error("which hook? `foldrun hook rm <name>` (--library for the account's)");
6512
+ const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
6513
+ const dir = path.join(root, "hooks", name);
6514
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name) || !fs.existsSync(dir)) {
6515
+ throw new Error(`no hooks/${name} in ${flags.library === true ? "the library" : path.basename(root)} — \`foldrun hook ls\` lists them`);
6516
+ }
6517
+ // Who still turns it on: they fail check, and their steps are refused.
6518
+ const users = [];
6519
+ const wsRoots = flags.library === true
6520
+ ? (layout.workspacesDir ? layout.workspaces.map((w) => path.join(layout.workspacesDir, w)) : [layout.workspaceDir].filter(Boolean))
6521
+ : [root];
6522
+ const matter = (await import("gray-matter")).default;
6523
+ const names = (file) => {
6524
+ try { return h.hookNames(matter(fs.readFileSync(file, "utf8")).data?.hooks); } catch { return []; }
6525
+ };
6526
+ for (const w of wsRoots) {
6527
+ if (names(path.join(w, "AGENTS.md")).includes(name)) users.push(`${path.basename(w)}/AGENTS.md`);
6528
+ for (const a of ls(path.join(w, "agents"))) {
6529
+ if (names(path.join(w, "agents", a, "agent.md")).includes(name)) users.push(`${path.basename(w)}/agents/${a}`);
6530
+ }
6531
+ }
6532
+ const still = users.length ? ` — still turned on by ${users.join(", ")}, whose steps will be refused until it goes from their hooks:` : "";
6533
+ if (!(await sureToDelete(flags, "hook rm", `${flags.library === true ? "library/" : ""}hooks/${name}${still}`))) return 1;
6534
+ fs.rmSync(dir, { recursive: true, force: true });
6535
+ console.log(`\n ${c.green("✓")} removed hooks/${name}${users.length ? c.dim(` — take it out of: ${users.join(", ")}`) : ""}\n`);
6536
+ return 0;
6537
+ }
6538
+
6125
6539
  // ------------------------------------------- flows and teams, as the canvas edits them
6126
6540
  //
6127
6541
  // The dashboard's flow canvas turns every palette block into a small, exact
@@ -7138,7 +7552,7 @@ async function runRmCmd(runId, flags, layout) {
7138
7552
  /** Where downloaded bytes go: --file (or - for stdout), else a default name. Refuses to clobber. */
7139
7553
  function writeDownload(bytes, fallback, flags, from) {
7140
7554
  if (flags.file === "-") {
7141
- process.stdout.write(bytes);
7555
+ writeExact(bytes);
7142
7556
  return 0;
7143
7557
  }
7144
7558
  const to = typeof flags.file === "string" ? flags.file : fallback;
@@ -7174,7 +7588,7 @@ async function apiCmd(positional, flags) {
7174
7588
  const out = typeof flags.out === "string" ? flags.out : typeof flags.file === "string" ? flags.file : "-";
7175
7589
  const pretty = JSON.stringify(doc, null, 2) + "\n";
7176
7590
  if (out === "-") {
7177
- process.stdout.write(pretty);
7591
+ writeExact(pretty);
7178
7592
  return 0;
7179
7593
  }
7180
7594
  fs.writeFileSync(out, pretty);
@@ -7461,7 +7875,7 @@ async function billingVerbCmd(positional, flags) {
7461
7875
  if (flags.csv === true) {
7462
7876
  const { bytes, name } = await remoteBytes(url, flags, `/api/billing/statement?month=${month}&format=csv`, `statement ${month}`);
7463
7877
  if (typeof flags.file !== "string") {
7464
- process.stdout.write(bytes);
7878
+ writeExact(bytes);
7465
7879
  return 0;
7466
7880
  }
7467
7881
  return writeDownload(bytes, name ?? `foldrun-${month}.csv`, flags, `the ${month} statement`);
@@ -8495,7 +8909,7 @@ async function scheduleTickCmd(flags) {
8495
8909
  return 0;
8496
8910
  }
8497
8911
 
8498
- const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge", "memory"];
8912
+ const LIBRARY_KINDS_CLI = ["skills", "tools", "hooks", "scripts", "knowledge", "memory"];
8499
8913
 
8500
8914
  /**
8501
8915
  * `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
@@ -8515,9 +8929,16 @@ async function libraryCmd(positional, flags) {
8515
8929
  if (verb === "ls" || verb === "list") {
8516
8930
  const kinds = spec ? [spec] : LIBRARY_KINDS_CLI;
8517
8931
  for (const k of kinds) if (!LIBRARY_KINDS_CLI.includes(k)) throw new Error(`no library kind "${k}" — ${LIBRARY_KINDS_CLI.join(", ")}`);
8518
- const lists = await Promise.all(kinds.map((k) => remoteCall(url, flags, `/api/library/${k}`)));
8932
+ // Listing everything, a kind an older platform does not have yet (hooks
8933
+ // before 11 Oct 2026) is left out rather than failing the whole list;
8934
+ // asked for by name, it fails with the platform's own answer.
8935
+ const lists = await Promise.all(kinds.map((k) => remoteCall(url, flags, `/api/library/${k}`).catch((err) => {
8936
+ if (spec) throw err;
8937
+ return null;
8938
+ })));
8519
8939
  console.log();
8520
8940
  lists.forEach((l, n) => {
8941
+ if (!l) return;
8521
8942
  const entries = l.entries ?? [];
8522
8943
  console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
8523
8944
  for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
@@ -8528,7 +8949,7 @@ async function libraryCmd(positional, flags) {
8528
8949
  if (verb === "cat") {
8529
8950
  const { kind, rel } = split(spec);
8530
8951
  const { content } = await remoteCall(url, flags, `/api/library/${kind}?path=${enc(rel)}`);
8531
- process.stdout.write(content ?? "");
8952
+ writeExact(content ?? "");
8532
8953
  return 0;
8533
8954
  }
8534
8955
  if (verb === "put") {
@@ -8797,10 +9218,29 @@ async function apiVersionCmd(positional, flags) {
8797
9218
  console.log(`\n ${c.bold("API version")} ${v.effective} ${c.dim(v.pinned ? `pinned (current is ${v.current})` : "current — not pinned")}`);
8798
9219
  console.log(` ${c.dim(`served: ${(v.supported ?? []).join(", ") || "—"} · each for ${v.supportMonths ?? "?"} months after the next · this CLI sends ${API_VERSION}`)}`);
8799
9220
  if (l.perCredential) console.log(` ${c.bold("rate limits")} ${c.dim(`${l.plan} · per key ${l.perCredential.read} reads / ${l.perCredential.write} writes, per account ${l.perAccount?.read} / ${l.perAccount?.write} — every ${l.windowSeconds}s`)}`);
9221
+ apiUsageLines(facts.usage);
8800
9222
  console.log(`\n ${c.dim("foldrun api version pin YYYY-MM-DD · unpin")}\n`);
8801
9223
  return 0;
8802
9224
  }
8803
9225
 
9226
+ /** The account's use of the API, last 30 days: requests by version, and any
9227
+ * deprecated route it still calls, by whom, and when it stops. An older
9228
+ * platform sends no `usage`; null means it could not be read. */
9229
+ function apiUsageLines(usage) {
9230
+ if (usage === undefined) return;
9231
+ if (usage === null) {
9232
+ console.log(` ${c.bold("your use")} ${c.dim("could not be read just now")}`);
9233
+ return;
9234
+ }
9235
+ const total = (usage.versions ?? []).reduce((s, v) => s + (v.requests ?? 0), 0);
9236
+ const day = (iso) => String(iso ?? "").slice(0, 10);
9237
+ console.log(` ${c.bold("your use")} ${c.dim(total ? `last ${usage.days} days: ` + usage.versions.map((v) => `${v.version} ${v.requests} (last ${day(v.lastSeen)})`).join(" · ") : `no API requests in the last ${usage.days} days`)}`);
9238
+ for (const d of usage.deprecated ?? []) {
9239
+ console.log(` ${c.amber("deprecated")} ${d.route} — ${d.requests} request${d.requests === 1 ? "" : "s"}, last ${day(d.lastSeen)}; ${d.sunsetAt ? `stops answering ${day(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${d.link}` : ""}`);
9240
+ for (const who of d.callers ?? []) console.log(` ${c.dim(`${who.credential || "an unknown caller"}: ${who.requests}, last ${day(who.lastSeen)}`)}`);
9241
+ }
9242
+ }
9243
+
8804
9244
  const THEMES_CLI = ["system", "light", "dark"];
8805
9245
 
8806
9246
  /**
@@ -8828,7 +9268,8 @@ async function preferencesCmd(positional, flags) {
8828
9268
  return 0;
8829
9269
  }
8830
9270
 
8831
- /** `foldrun changelog [--limit n]` — What's new: each release and what it changed. */
9271
+ /** `foldrun changelog [--limit n]` — What's new: the API's versions, what each
9272
+ * broke and what is deprecated, then each release and what it changed. */
8832
9273
  async function changelogCmd(flags) {
8833
9274
  const url = platformFor(flags, "changelog");
8834
9275
  const limit = Number(flags.limit) > 0 ? Math.min(50, Math.floor(Number(flags.limit))) : 5;
@@ -8837,6 +9278,7 @@ async function changelogCmd(flags) {
8837
9278
  console.log(JSON.stringify(r, null, 2));
8838
9279
  return 0;
8839
9280
  }
9281
+ if (r.api) apiChangelogLines(r.api);
8840
9282
  const releases = r.releases ?? [];
8841
9283
  if (!releases.length) {
8842
9284
  console.log(`\n ${c.dim(`no release notes on this install (it runs ${r.version ?? "an unreleased build"})`)}\n`);
@@ -8859,6 +9301,25 @@ async function changelogCmd(flags) {
8859
9301
  return 0;
8860
9302
  }
8861
9303
 
9304
+ /** The API part of What's new: the current version, the breaking changes by
9305
+ * version, and routes on their way out. Older platforms send no `api`. */
9306
+ function apiChangelogLines(api) {
9307
+ const p = printable;
9308
+ const broke = (api.versions ?? []).filter((v) => v.changes?.length);
9309
+ const deps = api.deprecations ?? [];
9310
+ console.log(`\n ${c.bold(`API ${p(api.current)}`)} ${c.dim(`each version works ${Number(api.supportMonths) || 12} months or more after the next · pin: foldrun api version pin <date>`)}`);
9311
+ if (!broke.length && !deps.length) {
9312
+ console.log(` ${c.dim("nothing existing code would break on, and nothing deprecated")}`);
9313
+ return;
9314
+ }
9315
+ if (broke.length) console.log(` ${c.dim("breaking changes")}`);
9316
+ for (const v of broke) for (const ch of v.changes) console.log(` · ${p(v.version)} ${firstLine(p(ch), 100)}`);
9317
+ if (deps.length) console.log(` ${c.dim("deprecated")}`);
9318
+ for (const d of deps) {
9319
+ console.log(` · ${p(d.route)} — since ${p(d.deprecatedAt)}, ${d.sunsetAt ? `stops answering ${p(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${p(d.link)}` : ""}`);
9320
+ }
9321
+ }
9322
+
8862
9323
  /**
8863
9324
  * `foldrun find <query>` — the ⌘K search: workspaces, agents, flows, tools,
8864
9325
  * skills, knowledge, memory and runs across the account, ranked as the
@@ -9065,7 +9526,7 @@ function printPreview(p, rel) {
9065
9526
  case "text":
9066
9527
  case "html":
9067
9528
  case "svg":
9068
- process.stdout.write(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
9529
+ writeExact(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
9069
9530
  if (p.truncated) out(c.dim("… (truncated)"));
9070
9531
  return;
9071
9532
  case "table":
@@ -9335,6 +9796,9 @@ export async function run(command, positional, flags, workspace, layout) {
9335
9796
  return positional[0] === "test"
9336
9797
  ? toolTestCmd(positional, flags, layout)
9337
9798
  : scaffoldCmd("tools", positional, flags, layout);
9799
+ case "hook":
9800
+ case "hooks":
9801
+ return hookCmd(positional, flags, layout);
9338
9802
  case "invoke":
9339
9803
  return invoke(positional[0], flags);
9340
9804
  case "source":