foldrun 0.9.0 → 1.1.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) {
@@ -881,6 +889,10 @@ async function checkProblems(workspace, flags = {}) {
881
889
  const { readAgentsMd } = await core();
882
890
  const { readLimits } = await import("@foldrun/core/limits");
883
891
  for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
892
+ // `network:` here is every agent's default: a value nobody can read
893
+ // leaves them on the account's, or the platform's.
894
+ const net = await import("@foldrun/core/network").catch(() => null);
895
+ for (const w of net ? net.networkProblems(readAgentsMd(workspace)?.data?.network) : []) note("error", "AGENTS.md", w);
884
896
  }
885
897
 
886
898
  const { unopenedFrontmatter } = /** @type {any} */ (await core());
@@ -904,6 +916,9 @@ async function checkProblems(workspace, flags = {}) {
904
916
  // written into the file. The run would say so in its trail and carry
905
917
  // on; the deploy refuses it; this is where a person hears it first.
906
918
  for (const w of a.webProblems ?? []) note("error", `agents/${a.name}`, w);
919
+ // A network: value that cannot be read leaves the agent on the level
920
+ // above it — the deploy refuses it, so check says so first.
921
+ for (const w of a.networkProblems ?? []) note("error", `agents/${a.name}`, w);
907
922
  // What works but should change: the old `session:` key, and a pinned
908
923
  // user_agent whose Chrome is not the runner image's (the platform's
909
924
  // /api/version says which Chrome; FOLDRUN_RUNNER_ENGINES on a server).
@@ -1053,6 +1068,8 @@ async function checkProblems(workspace, flags = {}) {
1053
1068
  }
1054
1069
  }
1055
1070
 
1071
+ await checkHooks(workspace, agents, libraryDir(T), platform, note);
1072
+
1056
1073
  // A script tool whose `run:` resolves to nothing parses, counts, and is
1057
1074
  // offered to the agent — then fails inside a turn. Checking it here is the
1058
1075
  // difference between a typo found in CI and a flow that quietly stops
@@ -1162,6 +1179,68 @@ async function checkProblems(workspace, flags = {}) {
1162
1179
  return { problems, summary };
1163
1180
  }
1164
1181
 
1182
+ /**
1183
+ * Hooks, checked with core's own reader so check and a run agree: a hook
1184
+ * folder that is not a hook, a `hooks:` name that resolves to nothing (a run
1185
+ * refuses that step rather than run it unguarded), and a tool.call hook on an
1186
+ * agent whose web search or fetch the model provider runs on its own servers
1187
+ * — those calls never pass foldrun's dispatch, so the hook never sees them.
1188
+ * A core from before hooks has no reader, and then there is nothing to say.
1189
+ */
1190
+ async function checkHooks(workspace, agents, libraryRoot, platform, note) {
1191
+ const h = await hooksCore();
1192
+ if (!h) return;
1193
+ for (const p of h.checkHooksDir(path.join(workspace, "hooks"), "workspace")) {
1194
+ const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
1195
+ note("error", m ? m[1] : "hooks/", m ? m[2] : p);
1196
+ }
1197
+ const matter = (await import("gray-matter")).default;
1198
+ const front = (file) => {
1199
+ try {
1200
+ return fs.existsSync(file) ? matter(fs.readFileSync(file, "utf8")).data ?? {} : {};
1201
+ } catch {
1202
+ return {}; // an unreadable file is reported by its own check
1203
+ }
1204
+ };
1205
+ const exists = (n) =>
1206
+ fs.existsSync(path.join(workspace, "hooks", n)) || fs.existsSync(path.join(libraryRoot, "hooks", n)) || platform.hooks?.has(n);
1207
+ const missing = (where, names) => {
1208
+ for (const n of names) {
1209
+ if (!exists(n)) note("error", where, `hooks: [${n}] — no hooks/${n}/hook.md in this workspace or the account library`);
1210
+ }
1211
+ };
1212
+ const accountNames = h.hookNames(front(path.join(path.dirname(libraryRoot), "AGENTS.md")).hooks);
1213
+ const workspaceNames = h.hookNames(front(path.join(workspace, "AGENTS.md")).hooks);
1214
+ missing("account AGENTS.md", accountNames);
1215
+ missing("AGENTS.md", workspaceNames);
1216
+
1217
+ let resolveSearch = null;
1218
+ try {
1219
+ ({ resolveSearch } = /** @type {any} */ (await import("@foldrun/core/providers")));
1220
+ } catch {
1221
+ // no provider table: the provider-side search warning is skipped
1222
+ }
1223
+ for (const a of agents) {
1224
+ const f = front(path.join(workspace, "agents", a.name, "agent.md"));
1225
+ const own = h.hookNames(f.hooks);
1226
+ missing(`agents/${a.name}`, own);
1227
+ if (!resolveSearch || !f.web || typeof f.web !== "object" || Array.isArray(f.web)) continue;
1228
+ const { hooks } = h.resolveHooks({ workspaceRoot: workspace, libraryRoot, names: [...accountNames, ...workspaceNames, ...own] });
1229
+ const gates = hooks.filter((x) => x.on === "tool.call").map((x) => x.name);
1230
+ if (!gates.length) continue;
1231
+ for (const action of ["search", "fetch"]) {
1232
+ if (f.web[action] === undefined) continue;
1233
+ const r = resolveSearch(f.web[action], action);
1234
+ if (!r?.provider || r.error || r.shape === "direct") continue;
1235
+ note(
1236
+ "warn",
1237
+ `agents/${a.name}`,
1238
+ `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`,
1239
+ );
1240
+ }
1241
+ }
1242
+ }
1243
+
1165
1244
  /** The web tool the platform's gallery serves: built in, and the only
1166
1245
  * built-in that takes a provider. */
1167
1246
  const WEB_TOOL = "web";
@@ -1907,7 +1986,47 @@ export function semverNewer(a, b) {
1907
1986
  for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
1908
1987
  if (x.pre && !y.pre) return false;
1909
1988
  if (!x.pre && y.pre) return true;
1910
- return (x.pre ?? "") > (y.pre ?? "");
1989
+ if (!x.pre) return false;
1990
+ // SemVer 2.0.0 §11: dot-separated identifiers compared one by one — numbers
1991
+ // as numbers (rc.10 > rc.9), a number before a word, and a shorter list
1992
+ // before a longer one it begins.
1993
+ const xs = x.pre.slice(1).split(".");
1994
+ const ys = y.pre.slice(1).split(".");
1995
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
1996
+ if (xs[i] == null || ys[i] == null) return ys[i] == null;
1997
+ const xn = /^\d+$/.test(xs[i]);
1998
+ const yn = /^\d+$/.test(ys[i]);
1999
+ if (xn && yn) {
2000
+ if (Number(xs[i]) !== Number(ys[i])) return Number(xs[i]) > Number(ys[i]);
2001
+ } else if (xn !== yn) return yn;
2002
+ else if (xs[i] !== ys[i]) return xs[i] > ys[i];
2003
+ }
2004
+ return false;
2005
+ }
2006
+
2007
+ /**
2008
+ * Is `latest` an update worth telling someone running `current` about? Newer,
2009
+ * and not a pre-release offered to someone on a stable release: npm keeps
2010
+ * pre-releases off `latest`, so `npm i -g foldrun@latest` would not even
2011
+ * install it.
2012
+ * @param {string} latest @param {string} current
2013
+ */
2014
+ export function updateTo(latest, current) {
2015
+ // The whole header must be a version: it is printed, and a platform that
2016
+ // could append to it could write to the terminal.
2017
+ if (!/^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(latest ?? "").trim())) return false;
2018
+ const pre = (v) => /^v?\d+\.\d+\.\d+-/.test(String(v ?? "").trim());
2019
+ return semverNewer(latest, current) && !(pre(latest) && !pre(current));
2020
+ }
2021
+
2022
+ /**
2023
+ * Text from a platform, made safe to print: no escape sequences or other
2024
+ * control characters, so a hostile or broken server cannot move the cursor,
2025
+ * retitle the terminal, write the clipboard (OSC 52) or disguise a link.
2026
+ * @param {unknown} s
2027
+ */
2028
+ export function printable(s) {
2029
+ return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
1911
2030
  }
1912
2031
 
1913
2032
  const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -1927,14 +2046,14 @@ function updateNotice(res) {
1927
2046
  if (env("FOLDRUN_NO_UPDATE_NOTICE")) return;
1928
2047
  if (!(process.stderr.isTTY || env("FOLDRUN_FORCE_TTY") === "1")) return;
1929
2048
  const latest = res.headers.get("x-foldrun-cli");
1930
- if (!latest || !semverNewer(latest, CLI_VERSION)) return;
2049
+ if (!latest || !updateTo(latest, CLI_VERSION)) return;
1931
2050
  noticeDone = true;
1932
2051
  const file = path.join(credentialsDir(), "version-check.json");
1933
2052
  try {
1934
2053
  const last = JSON.parse(fs.readFileSync(file, "utf8"));
1935
2054
  if (Date.now() - Date.parse(last.notifiedAt) < UPDATE_EVERY_MS) return;
1936
2055
  } catch {}
1937
- const platform = res.headers.get("x-foldrun-version");
2056
+ const platform = printable(res.headers.get("x-foldrun-version") ?? "") || null;
1938
2057
  console.error(` ${c.dim(`foldrun ${latest} is out (this is ${CLI_VERSION})${platform ? `, released with platform ${platform}` : ""} — npm i -g foldrun@latest`)}`);
1939
2058
  fs.mkdirSync(credentialsDir(), { recursive: true });
1940
2059
  fs.writeFileSync(file, JSON.stringify({ notifiedAt: new Date().toISOString(), cli: latest, platform }) + "\n");
@@ -1968,7 +2087,7 @@ async function versionCmd(flags) {
1968
2087
  }
1969
2088
  }
1970
2089
  const latestCli = platform?.packages?.cli ?? null;
1971
- const behind = latestCli ? semverNewer(latestCli, CLI_VERSION) : false;
2090
+ const behind = latestCli ? updateTo(latestCli, CLI_VERSION) : false;
1972
2091
  if (flags.json === true) {
1973
2092
  console.log(JSON.stringify({ cli: CLI_VERSION, core, platform: platform ?? (url ? { url, error: unreachable } : null) }, null, 2));
1974
2093
  if (behind) console.error(`foldrun ${latestCli} is out (this is ${CLI_VERSION}) — npm i -g foldrun@latest`);
@@ -2686,6 +2805,19 @@ async function readStorage(page, origin) {
2686
2805
  .catch(() => empty);
2687
2806
  }
2688
2807
 
2808
+ /** Bind on this machine's vault — a core from before site binding says so. */
2809
+ async function localSetSites(name, sites, scope) {
2810
+ const k = await core();
2811
+ if (typeof k.setSecretSites !== "function") throw new Error("binding a secret to sites needs @foldrun/core 1.1 or later — update foldrun");
2812
+ return k.setSecretSites("default", name, sites, scope);
2813
+ }
2814
+
2815
+ /** `--site a.com --site "*.b.org"` or `--site a.com,b.org`, as one list. */
2816
+ function siteFlags(flags) {
2817
+ const raw = flags.site === undefined ? [] : Array.isArray(flags.site) ? flags.site : [flags.site];
2818
+ return raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
2819
+ }
2820
+
2689
2821
  async function secretsCmd(positional, flags, layout) {
2690
2822
  // --local: this machine's store even when signed in, the same escape
2691
2823
  // hatch deploy and keys have. Without it, a signed-in laptop sent every
@@ -2716,7 +2848,7 @@ async function secretsCmd(positional, flags, layout) {
2716
2848
  console.log();
2717
2849
  for (const s of entries) {
2718
2850
  console.log(
2719
- ` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}`)}`,
2851
+ ` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}${s.sites?.length ? ` · sent only to ${s.sites.join(", ")}` : ""}`)}`,
2720
2852
  );
2721
2853
  }
2722
2854
  console.log();
@@ -2751,6 +2883,31 @@ async function secretsCmd(positional, flags, layout) {
2751
2883
 
2752
2884
  if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
2753
2885
 
2886
+ // A secret bound to sites never enters a sandbox: steps hold `${NAME}` and
2887
+ // the egress proxy fills it in requests to those sites alone.
2888
+ if (verb === "sites") {
2889
+ const given = [...positional.slice(2), ...siteFlags(flags)];
2890
+ if (!given.length && flags.clear !== true) {
2891
+ const entries = url
2892
+ ? (await remoteCall(url, flags, `/api/secrets${scope ? `?workspace=${encodeURIComponent(scope)}` : ""}`)).secrets
2893
+ : (await core()).listSecrets("default", scope);
2894
+ const hit = entries.find((s) => s.name === name);
2895
+ if (!hit) throw new Error(`no secret ${name}${scope ? ` in workspace ${scope}` : " in the account"}`);
2896
+ console.log(hit.sites?.length
2897
+ ? `\n ${c.bold(name)} is sent only to ${hit.sites.join(", ")} — on the platform it never enters a sandbox\n`
2898
+ : `\n ${c.bold(name)} is not bound to sites — a step with a script or Bash gets its real value. Bind it: foldrun secrets sites ${name} api.example.com\n`);
2899
+ return 0;
2900
+ }
2901
+ const sites = flags.clear === true ? [] : given;
2902
+ const stored = url
2903
+ ? (await remoteCall(url, flags, "/api/secrets", { method: "PATCH", body: JSON.stringify({ name, sites, workspace: scope }) })).sites
2904
+ : await localSetSites(name, sites, scope);
2905
+ console.log(stored.length
2906
+ ? `\n ${c.green("✓")} ${name} is sent only to ${stored.join(", ")} — on the platform the egress proxy fills it, and it never enters a sandbox\n`
2907
+ : `\n ${c.green("✓")} ${name} unbound — a step with a script or Bash gets its real value again\n`);
2908
+ return 0;
2909
+ }
2910
+
2754
2911
  if (verb === "set") {
2755
2912
  // --kind: the other shapes the Connections form stores — a file, an SSH
2756
2913
  // login, API headers, a service account's key, a client-credentials app.
@@ -2786,15 +2943,18 @@ async function secretsCmd(positional, flags, layout) {
2786
2943
  const value =
2787
2944
  typeof flags.value === "string" ? flags.value : await promptHidden(` value for ${name}: `);
2788
2945
  if (!value) throw new Error("empty value — nothing stored");
2946
+ const sites = siteFlags(flags);
2789
2947
  if (url) {
2790
2948
  await remoteCall(url, flags, "/api/secrets", {
2791
2949
  method: "PUT",
2792
- body: JSON.stringify({ name, value, workspace: scope }),
2950
+ body: JSON.stringify({ name, value, workspace: scope, ...(sites.length ? { sites } : {}) }),
2793
2951
  });
2794
2952
  } else {
2795
- (await core()).setSecret("default", name, value, scope);
2953
+ const k = await core();
2954
+ k.setSecret("default", name, value, scope);
2955
+ if (sites.length) await localSetSites(name, sites, scope);
2796
2956
  }
2797
- console.log(`\n ${c.green("✓")} ${name} stored — declare it in agent.md under \`secrets:\` to use it\n`);
2957
+ console.log(`\n ${c.green("✓")} ${name} stored${sites.length ? ` and sent only to ${sites.join(", ")}` : ""} — declare it in agent.md under \`secrets:\` to use it\n`);
2798
2958
  return 0;
2799
2959
  }
2800
2960
 
@@ -3625,7 +3785,7 @@ async function auditCmd(positional, flags) {
3625
3785
  q.set("format", "csv");
3626
3786
  const { bytes, name } = await remoteBytes(url, flags, `/api/audit?${q}`, "the audit log");
3627
3787
  if (typeof flags.file !== "string") {
3628
- process.stdout.write(bytes);
3788
+ writeExact(bytes);
3629
3789
  return 0;
3630
3790
  }
3631
3791
  return writeDownload(bytes, name ?? "audit.csv", flags, "the audit log");
@@ -3701,7 +3861,7 @@ async function sourceCmd(positional, flags) {
3701
3861
  if (verb === "cat" || verb === "read" || verb === "get") {
3702
3862
  if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
3703
3863
  const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
3704
- process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
3864
+ writeExact(content.endsWith("\n") ? content : `${content}\n`);
3705
3865
  return 0;
3706
3866
  }
3707
3867
  if (verb === "put" || verb === "write") {
@@ -4706,7 +4866,7 @@ async function storageCmd(positional, flags, layout) {
4706
4866
  if (bytes.includes(0)) {
4707
4867
  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
4868
  }
4709
- process.stdout.write(bytes.toString("utf8"));
4869
+ writeExact(bytes);
4710
4870
  return 0;
4711
4871
  }
4712
4872
 
@@ -6122,6 +6282,308 @@ async function checkRemote(flags, layout) {
6122
6282
  }
6123
6283
  }
6124
6284
 
6285
+ // ---------------------------------------------------------------- hooks
6286
+
6287
+ /**
6288
+ * Core's hooks module — readHookDef, runHook, the event list — or null on a
6289
+ * core from before hooks, where `check` then says nothing about them and the
6290
+ * `hook` verbs say which core they need.
6291
+ */
6292
+ async function hooksCore() {
6293
+ try {
6294
+ return /** @type {any} */ (await import("@foldrun/core/hooks"));
6295
+ } catch {
6296
+ return null;
6297
+ }
6298
+ }
6299
+ async function needHooksCore() {
6300
+ const h = await hooksCore();
6301
+ if (!h) throw new Error("this CLI's @foldrun/core has no hooks — update it (npm i -g foldrun@latest)");
6302
+ return h;
6303
+ }
6304
+
6305
+ /** The account library a hook verb works on: the folder beside workspaces/. */
6306
+ const libraryRootOf = (layout) => path.join(layout.accountRoot, "library");
6307
+
6308
+ /** What a new run.mjs answers, per event: a gate allows, a watcher notes nothing. */
6309
+ const HOOK_ANSWER = {
6310
+ "tool.call": `{ decision: "allow" }`,
6311
+ "tool.result": `{}`,
6312
+ "step.start": `{}`,
6313
+ "step.end": `{}`,
6314
+ };
6315
+
6316
+ /** A sample event per kind, for `hook test` with no --event. */
6317
+ const HOOK_SAMPLE = {
6318
+ "tool.call": { tool: { name: "Bash", input: { command: "ls" } } },
6319
+ "tool.result": { tool: { name: "Bash", input: { command: "ls" }, result: "agent.md\n" } },
6320
+ "step.start": { prompt: "Find three sources." },
6321
+ "step.end": { reply: "Done." },
6322
+ };
6323
+
6324
+ function hookRunTemplate(name, on) {
6325
+ return `// ${name} — a foldrun hook (on: ${on}). Self-contained: Node's built-ins only,
6326
+ // no relative imports — it runs from a copy taken when the step starts.
6327
+ //
6328
+ // In: one JSON object on stdin — the event:
6329
+ // { event, hook, run_id, step, agent, workspace,
6330
+ // tool: { name, input } (tool.call, tool.result; + result on tool.result)
6331
+ // prompt (step.start)
6332
+ // reply (step.end) }
6333
+ // Out: one JSON object on stdout, the last line printed:
6334
+ // tool.call { decision: "allow" | "deny", reason, input, note }
6335
+ // input replaces the tool's whole input; a deny needs a reason
6336
+ // step.start { context, note } context is added to the step's prompt
6337
+ // step.end { reply, note } reply replaces the step's reply
6338
+ // tool.result { note } a line on the run's trail
6339
+ // Exit 0 with an answer. Exit 2 denies, with stderr as the reason.
6340
+ // Anything else is a failure: a gate (tool.call, step.start) that fails
6341
+ // refuses; a watcher (tool.result, step.end) that fails is recorded.
6342
+ // No secrets and no model key reach this process.
6343
+
6344
+ let raw = "";
6345
+ for await (const chunk of process.stdin) raw += chunk;
6346
+ const event = JSON.parse(raw || "{}");
6347
+
6348
+ // Decide here. This one allows everything.
6349
+ void event;
6350
+ console.log(JSON.stringify(${HOOK_ANSWER[on]}));
6351
+ `;
6352
+ }
6353
+
6354
+ /** `foldrun hook <ls|add|test|rm>` — hooks in this workspace and the library. */
6355
+ async function hookCmd(positional, flags, layout) {
6356
+ const [verb = "ls"] = positional;
6357
+ if (verb === "ls" || verb === "list") return hookLsCmd(flags, layout);
6358
+ if (verb === "add" || verb === "new") return hookAddCmd(positional, flags, layout);
6359
+ if (verb === "test") return hookTestCmd(positional, flags, layout);
6360
+ if (verb === "rm") return hookRmCmd(positional, flags, layout);
6361
+ throw new Error(`unknown hook verb "${verb}" — ls, add <name> --on <event>, test <name>, rm <name>`);
6362
+ }
6363
+
6364
+ /** Every hook folder here, the workspace's first, each read the way a run reads it. */
6365
+ async function hooksHere(flags, layout) {
6366
+ const h = await needHooksCore();
6367
+ const ws = workspaceDirFor(flags, layout);
6368
+ const out = [];
6369
+ const seen = new Set();
6370
+ for (const [root, scope] of [[ws, "workspace"], [libraryRootOf(layout), "account"]]) {
6371
+ for (const dir of h.listHookDirs(path.join(root, "hooks"))) {
6372
+ const name = path.basename(dir);
6373
+ const def = h.readHookDef(dir, scope);
6374
+ const row = "error" in def
6375
+ ? { name, scope, error: def.error }
6376
+ : { name, scope, on: def.on, tool: def.tool ?? null, timeout: def.timeoutSec, description: def.description ?? null };
6377
+ // A workspace hook of the same name is the one a run resolves.
6378
+ out.push({ ...row, ...(seen.has(name) ? { shadowed: true } : {}) });
6379
+ seen.add(name);
6380
+ }
6381
+ }
6382
+ return { h, ws, hooks: out };
6383
+ }
6384
+
6385
+ /** `hook ls --to <ws>`: the DEPLOYED workspace's hooks and what they did
6386
+ * lately — GET /api/workspaces/<ws>/hooks, the dashboard's Hooks panel. */
6387
+ async function hookLsRemote(flags) {
6388
+ const url = platformFor(flags, "hook ls");
6389
+ const ws = String(flags.to);
6390
+ const q = new URLSearchParams();
6391
+ if (flags.all === true) q.set("all", "1");
6392
+ if (flags.runs !== undefined) q.set("runs", String(flags.runs));
6393
+ const r = /** @type {{ hooks: any[], recent: any[] }} */ (await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/hooks${q.size ? `?${q}` : ""}`));
6394
+ if (flags.json === true) {
6395
+ console.log(JSON.stringify(r, null, 2));
6396
+ return 0;
6397
+ }
6398
+ const hooks = r.hooks ?? [];
6399
+ if (!hooks.length) console.log(`\n ${c.dim(`no hooks in ${ws} or the account library`)}`);
6400
+ else {
6401
+ console.log();
6402
+ const w = Math.max(...hooks.map((x) => x.name.length));
6403
+ for (const x of hooks) {
6404
+ const where = x.scope === "account" ? "library" : "workspace";
6405
+ if (x.error) {
6406
+ console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
6407
+ continue;
6408
+ }
6409
+ const on = (x.onFor ?? []).length ? `on for ${x.onFor.join(", ")}` : "turned on by nobody";
6410
+ const note = x.shadowed ? "(the workspace's own of this name is the one that runs)" : `${on}${x.description ? ` — ${x.description}` : ""}`;
6411
+ 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)}`);
6412
+ }
6413
+ }
6414
+ const recent = r.recent ?? [];
6415
+ 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)")}`);
6416
+ for (const e of recent.slice(0, 20)) {
6417
+ const rec = e.record ?? {};
6418
+ 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)}` : ""}`);
6419
+ }
6420
+ console.log(`\n ${c.dim("--runs <n> reads further back (default 50) · --all for every decision · --json")}\n`);
6421
+ return hooks.some((x) => x.error) ? 1 : 0;
6422
+ }
6423
+
6424
+ async function hookLsCmd(flags, layout) {
6425
+ if (typeof flags.to === "string") return hookLsRemote(flags);
6426
+ const { hooks } = await hooksHere(flags, layout);
6427
+ if (flags.json === true) {
6428
+ console.log(JSON.stringify(hooks, null, 2));
6429
+ return 0;
6430
+ }
6431
+ if (!hooks.length) {
6432
+ console.log(`\n ${c.dim("no hooks — `foldrun hook add <name> --on tool.call` makes one")}\n`);
6433
+ return 0;
6434
+ }
6435
+ console.log();
6436
+ const w = Math.max(...hooks.map((x) => x.name.length));
6437
+ for (const x of hooks) {
6438
+ const where = x.scope === "account" ? "library" : "workspace";
6439
+ if (x.error) {
6440
+ console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
6441
+ continue;
6442
+ }
6443
+ const what = `${x.on.padEnd(11)} ${(x.tool ?? "*").padEnd(12)}`;
6444
+ const note = x.shadowed ? c.dim("(the workspace's own of this name is the one that runs)") : c.dim(x.description ?? "");
6445
+ console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${what} ${note}`);
6446
+ }
6447
+ 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`);
6448
+ return hooks.some((x) => x.error) ? 1 : 0;
6449
+ }
6450
+
6451
+ async function hookAddCmd(positional, flags, layout) {
6452
+ const h = await needHooksCore();
6453
+ const name = positional[1];
6454
+ if (!name) throw new Error("which hook? `foldrun hook add <name> --on tool.call|tool.result|step.start|step.end`");
6455
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
6456
+ throw new Error(`"${name}" is not a hook name — lowercase letters, digits and dashes, e.g. no-rm-rf`);
6457
+ }
6458
+ const on = flags.on;
6459
+ if (typeof on !== "string" || !h.HOOK_EVENTS.includes(on)) {
6460
+ throw new Error(`--on takes ${h.HOOK_EVENTS.join(", ")}${typeof on === "string" ? ` — not "${on}"` : ""}`);
6461
+ }
6462
+ const tool = typeof flags.tool === "string" ? flags.tool.trim() : undefined;
6463
+ if (flags.tool !== undefined && !tool) throw new Error("--tool takes a tool name or pattern, like Bash, Write|Edit or mcp__*");
6464
+ if (tool && on !== "tool.call" && on !== "tool.result") {
6465
+ throw new Error(`--tool only means something on tool.call and tool.result, not ${on}`);
6466
+ }
6467
+ const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
6468
+ const rel = `hooks/${name}`;
6469
+ const front = [
6470
+ "---",
6471
+ `on: ${on}`,
6472
+ ...(tool ? [`tool: ${/[|*:]/.test(tool) ? JSON.stringify(tool) : tool}`] : []),
6473
+ `description: What ${name} checks, in one line.`,
6474
+ "---",
6475
+ "",
6476
+ `What ${name} is for, and what it refuses or rewrites. The code is run.mjs beside this file.`,
6477
+ "",
6478
+ ].join("\n");
6479
+ writeFiles(root, [
6480
+ { path: `${rel}/hook.md`, content: front },
6481
+ { path: `${rel}/run.mjs`, content: hookRunTemplate(name, on) },
6482
+ ], flags.library === true ? "the library" : path.basename(root));
6483
+ const where = path.relative(process.cwd(), path.join(root, rel)) || rel;
6484
+ console.log(`\n ${c.green("created")} hook ${c.bold(name)} ${c.dim(`(${on}${tool ? ` · ${tool}` : ""})`)} in ${where}\n`);
6485
+ console.log(` ${c.dim(`${rel}/hook.md`)}\n ${c.dim(`${rel}/run.mjs`)}`);
6486
+ console.log(`\n ${c.bold("Next")}
6487
+ edit ${path.join(where, "run.mjs")}
6488
+ hooks: [${name}]${" ".repeat(4)}${c.dim("in an agent's frontmatter — or in AGENTS.md for every agent")}
6489
+ foldrun hook test ${name}${" ".repeat(4)}${c.dim("run it on a sample event, here")}\n`);
6490
+ return 0;
6491
+ }
6492
+
6493
+ /** --event: inline JSON, or @file. */
6494
+ function readHookEvent(raw, on) {
6495
+ if (raw === undefined) return { event: HOOK_SAMPLE[on] ?? {}, sample: true };
6496
+ if (typeof raw !== "string") throw new Error("--event takes a JSON object, or @file");
6497
+ const text = raw.startsWith("@") ? fs.readFileSync(raw.slice(1), "utf8") : raw;
6498
+ let event;
6499
+ try { event = JSON.parse(text); } catch (err) {
6500
+ throw new Error(`--event is not JSON (${err instanceof Error ? err.message : err})`);
6501
+ }
6502
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("--event is one JSON object — the event the hook reads on stdin");
6503
+ return { event, sample: false };
6504
+ }
6505
+
6506
+ function printHookAnswer(name, on, a, where) {
6507
+ const mark = !a.ok ? c.red("✗") : a.decision === "deny" ? c.amber("■") : c.green("✓");
6508
+ console.log(`\n ${mark} ${c.bold(name)} ${c.dim(`${on ?? "?"} · ${where} · ${a.ms ?? 0}ms`)}`);
6509
+ 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") : ""}`);
6510
+ if (a.decision) console.log(` ${pad("decision", 9)} ${a.decision === "deny" ? c.amber("deny") : "allow"}`);
6511
+ if (a.reason) console.log(` ${pad("reason", 9)} ${a.reason}`);
6512
+ if (a.input) console.log(` ${pad("input", 9)} ${JSON.stringify(a.input)}`);
6513
+ if (a.context) console.log(` ${pad("context", 9)} ${a.context}`);
6514
+ if (a.reply !== undefined) console.log(` ${pad("reply", 9)} ${a.reply}`);
6515
+ if (a.note) console.log(` ${pad("note", 9)} ${a.note}`);
6516
+ console.log();
6517
+ return a.ok ? 0 : 1;
6518
+ }
6519
+
6520
+ /**
6521
+ * `foldrun hook test <name> [--event '<json>']` — run one hook on one event,
6522
+ * here, the way a step runs it (core's runHook: the event on stdin, a
6523
+ * stripped environment, its timeout). `--to <workspace>` asks the platform
6524
+ * to run the deployed one instead.
6525
+ */
6526
+ async function hookTestCmd(positional, flags, layout) {
6527
+ const name = positional[1];
6528
+ if (!name) throw new Error("which hook? `foldrun hook test <name> --event '<json>'` — `foldrun hook ls` lists them");
6529
+ if (typeof flags.to === "string") {
6530
+ const url = platformFor(flags, "hook test");
6531
+ const ws = flags.to;
6532
+ let event = {};
6533
+ if (flags.event !== undefined) event = readHookEvent(flags.event, "tool.call").event;
6534
+ const r = /** @type {any} */ (await remoteCall(
6535
+ url,
6536
+ flags,
6537
+ `/api/workspaces/${enc(ws)}/hooks/${enc(name)}/test`,
6538
+ { method: "POST", body: JSON.stringify({ event }) },
6539
+ ));
6540
+ const a = r?.answer ?? r ?? {};
6541
+ return printHookAnswer(name, r?.on ?? a.on, { ok: a.ok !== false, ...a }, ws);
6542
+ }
6543
+ const h = await needHooksCore();
6544
+ const ws = workspaceDirFor({ ...flags, to: undefined }, layout);
6545
+ const r = h.resolveHooks({ workspaceRoot: ws, libraryRoot: libraryRootOf(layout), names: [name] });
6546
+ if (r.missing.length) throw new Error(`no hook named ${name} in hooks/ or the library — \`foldrun hook ls\` lists them`);
6547
+ if (r.errors.length) throw new Error(r.errors[0]);
6548
+ const def = r.hooks[0];
6549
+ const { event, sample } = readHookEvent(flags.event, def.on);
6550
+ if (sample) console.log(`\n ${c.dim(`no --event, so a sample ${def.on} event: ${JSON.stringify(event)}`)}`);
6551
+ const full = { event: def.on, hook: def.name, run_id: null, step: null, agent: null, workspace: path.basename(ws), ...event };
6552
+ const a = await h.runHook(def, full, { cwd: ws, env: process.env });
6553
+ return printHookAnswer(name, def.on, a, def.scope === "account" ? "library" : path.basename(ws));
6554
+ }
6555
+
6556
+ async function hookRmCmd(positional, flags, layout) {
6557
+ const h = await needHooksCore();
6558
+ const name = positional[1];
6559
+ if (!name) throw new Error("which hook? `foldrun hook rm <name>` (--library for the account's)");
6560
+ const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
6561
+ const dir = path.join(root, "hooks", name);
6562
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name) || !fs.existsSync(dir)) {
6563
+ throw new Error(`no hooks/${name} in ${flags.library === true ? "the library" : path.basename(root)} — \`foldrun hook ls\` lists them`);
6564
+ }
6565
+ // Who still turns it on: they fail check, and their steps are refused.
6566
+ const users = [];
6567
+ const wsRoots = flags.library === true
6568
+ ? (layout.workspacesDir ? layout.workspaces.map((w) => path.join(layout.workspacesDir, w)) : [layout.workspaceDir].filter(Boolean))
6569
+ : [root];
6570
+ const matter = (await import("gray-matter")).default;
6571
+ const names = (file) => {
6572
+ try { return h.hookNames(matter(fs.readFileSync(file, "utf8")).data?.hooks); } catch { return []; }
6573
+ };
6574
+ for (const w of wsRoots) {
6575
+ if (names(path.join(w, "AGENTS.md")).includes(name)) users.push(`${path.basename(w)}/AGENTS.md`);
6576
+ for (const a of ls(path.join(w, "agents"))) {
6577
+ if (names(path.join(w, "agents", a, "agent.md")).includes(name)) users.push(`${path.basename(w)}/agents/${a}`);
6578
+ }
6579
+ }
6580
+ const still = users.length ? ` — still turned on by ${users.join(", ")}, whose steps will be refused until it goes from their hooks:` : "";
6581
+ if (!(await sureToDelete(flags, "hook rm", `${flags.library === true ? "library/" : ""}hooks/${name}${still}`))) return 1;
6582
+ fs.rmSync(dir, { recursive: true, force: true });
6583
+ console.log(`\n ${c.green("✓")} removed hooks/${name}${users.length ? c.dim(` — take it out of: ${users.join(", ")}`) : ""}\n`);
6584
+ return 0;
6585
+ }
6586
+
6125
6587
  // ------------------------------------------- flows and teams, as the canvas edits them
6126
6588
  //
6127
6589
  // The dashboard's flow canvas turns every palette block into a small, exact
@@ -6829,6 +7291,84 @@ async function agentLinkCmd(positional, flags, layout) {
6829
7291
  return 0;
6830
7292
  }
6831
7293
 
7294
+ /**
7295
+ * `foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]…
7296
+ * [--remove <site>]…` — which sites the agent's sandbox may reach, its
7297
+ * `network:` line, edited the way the dashboard's Network card edits it
7298
+ * (core network.ts setNetworkSetting: that one line, nothing else). With no
7299
+ * level and no site it prints what a run resolves.
7300
+ */
7301
+ async function agentNetworkCmd(positional, flags, layout) {
7302
+ const name = positional[1];
7303
+ const word = positional[2];
7304
+ const usage = "`foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]… [--remove <site>]…`";
7305
+ if (!name) throw new Error(usage);
7306
+ if (word !== undefined && !["open", "trusted", "none", "inherit"].includes(word)) throw new Error(`${usage} — "${word}" is not a level`);
7307
+ const net = await import("@foldrun/core/network").catch(() => null);
7308
+ if (!net) throw new Error("agent network needs @foldrun/core 1.1 or later — update foldrun");
7309
+ const desk = await openDesk(flags, layout, `agent network ${name}`);
7310
+ const rel = `agents/${name}/agent.md`;
7311
+ if (!desk.files.has(rel)) {
7312
+ const agents = [...desk.files.keys()].map((r) => /^agents\/([^/]+)\/agent\.md$/.exec(r)?.[1]).filter(Boolean);
7313
+ throw new Error(`no agent "${name}" in ${desk.label} — it has ${agents.join(", ") || "none"}`);
7314
+ }
7315
+ const resolve = (files) =>
7316
+ inTree(desk, files, async (dir) => {
7317
+ const { listAgents, readFrontmatter } = await core();
7318
+ return {
7319
+ info: listAgents("default", "workspace").find((a) => a.name === name),
7320
+ own: net.readNetwork(readFrontmatter(path.join(dir, rel)).network).setting,
7321
+ };
7322
+ });
7323
+ const show = (info) => {
7324
+ const p = info?.network;
7325
+ if (!p) return;
7326
+ console.log(` ${net.networkLine(p, true)}`);
7327
+ if (p.level !== "open") console.log(` ${c.dim("a run also allows its MCP servers, its web providers and the sites its secrets are bound to")}`);
7328
+ };
7329
+ const raw = desk.files.get(rel);
7330
+ const now = await resolve(desk.files);
7331
+ const list = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim().toLowerCase()).filter(Boolean);
7332
+ const adds = list(flags.add);
7333
+ const removes = list(flags.remove);
7334
+ if (word === undefined && !adds.length && !removes.length) {
7335
+ console.log(`\n ${c.bold(name)} ${c.dim(`· ${desk.label}`)}`);
7336
+ show(now.info);
7337
+ console.log();
7338
+ return 0;
7339
+ }
7340
+ const own = now.own ?? { allow: [] };
7341
+ const level = word === "inherit" ? undefined : word ?? own.level;
7342
+ const allow = [...new Set([...own.allow, ...adds])].filter((x) => !removes.includes(x));
7343
+ const setting = level || allow.length || own.defaults === false
7344
+ ? { ...(level ? { level } : {}), allow, ...(own.defaults === false ? { defaults: false } : {}) }
7345
+ : null;
7346
+ const problems = setting ? net.readNetwork(setting).problems : [];
7347
+ if (problems.length) throw new Error(problems.join(" "));
7348
+ const next = net.setNetworkSetting(raw, setting);
7349
+ if (next === raw) {
7350
+ console.log(`\n ${c.dim(`${name}'s network: already says that — nothing to change`)}\n`);
7351
+ return 0;
7352
+ }
7353
+ console.log(`\n ${c.bold(name)} ${c.dim(`· network · ${desk.label}`)}\n`);
7354
+ await printDiff(rel, raw, next);
7355
+ if (flags["dry-run"] === true) {
7356
+ console.log(`\n ${c.dim("--dry-run: nothing written")}\n`);
7357
+ return 0;
7358
+ }
7359
+ if (desk.platform) {
7360
+ await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(name)}`, { method: "PATCH", body: JSON.stringify({ network: setting }) });
7361
+ } else {
7362
+ assertUnchanged(desk, rel, raw);
7363
+ writeIfUnchanged(desk, rel, raw, next);
7364
+ }
7365
+ const after = await resolve(new Map([...desk.files, [rel, next]]));
7366
+ console.log();
7367
+ show(after.info);
7368
+ console.log(`\n ${c.green("✓")} ${rel} ${c.dim(desk.platform ? `in ${desk.ws} — a revision on the platform` : "— `foldrun deploy` ships it")}\n`);
7369
+ return 0;
7370
+ }
7371
+
6832
7372
  /**
6833
7373
  * `foldrun flow show <flow>` — the canvas, in a terminal: the trigger, one
6834
7374
  * block per group, each step's chips, the team under its agent, and what
@@ -7138,7 +7678,7 @@ async function runRmCmd(runId, flags, layout) {
7138
7678
  /** Where downloaded bytes go: --file (or - for stdout), else a default name. Refuses to clobber. */
7139
7679
  function writeDownload(bytes, fallback, flags, from) {
7140
7680
  if (flags.file === "-") {
7141
- process.stdout.write(bytes);
7681
+ writeExact(bytes);
7142
7682
  return 0;
7143
7683
  }
7144
7684
  const to = typeof flags.file === "string" ? flags.file : fallback;
@@ -7174,7 +7714,7 @@ async function apiCmd(positional, flags) {
7174
7714
  const out = typeof flags.out === "string" ? flags.out : typeof flags.file === "string" ? flags.file : "-";
7175
7715
  const pretty = JSON.stringify(doc, null, 2) + "\n";
7176
7716
  if (out === "-") {
7177
- process.stdout.write(pretty);
7717
+ writeExact(pretty);
7178
7718
  return 0;
7179
7719
  }
7180
7720
  fs.writeFileSync(out, pretty);
@@ -7461,7 +8001,7 @@ async function billingVerbCmd(positional, flags) {
7461
8001
  if (flags.csv === true) {
7462
8002
  const { bytes, name } = await remoteBytes(url, flags, `/api/billing/statement?month=${month}&format=csv`, `statement ${month}`);
7463
8003
  if (typeof flags.file !== "string") {
7464
- process.stdout.write(bytes);
8004
+ writeExact(bytes);
7465
8005
  return 0;
7466
8006
  }
7467
8007
  return writeDownload(bytes, name ?? `foldrun-${month}.csv`, flags, `the ${month} statement`);
@@ -8441,6 +8981,295 @@ async function flowDraftCmd(positional, flags, layout) {
8441
8981
  return 0;
8442
8982
  }
8443
8983
 
8984
+ /** `foldrun assistant history` — your kept conversations, newest first
8985
+ * (`--q` searches them, `--workspace` narrows to one, `--json`). */
8986
+ async function assistantHistoryCmd(url, flags) {
8987
+ const q = new URLSearchParams();
8988
+ if (typeof flags.q === "string" && flags.q.trim()) q.set("q", flags.q.trim());
8989
+ if (typeof flags.workspace === "string") q.set("workspace", flags.workspace);
8990
+ if (typeof flags.limit === "string") q.set("limit", flags.limit);
8991
+ const d = await remoteCall(url, flags, `/api/assistant/conversations${q.size ? `?${q}` : ""}`);
8992
+ const list = d.conversations ?? [];
8993
+ if (flags.json) {
8994
+ console.log(JSON.stringify(d, null, 2));
8995
+ return 0;
8996
+ }
8997
+ if (!list.length) {
8998
+ console.log(`\n ${c.dim(flags.q ? "nothing matches" : "no conversations yet")}\n`);
8999
+ return 0;
9000
+ }
9001
+ console.log();
9002
+ for (const x of list) {
9003
+ console.log(` ${c.bold(x.id)} ${x.workspace} ${c.dim(`${ago(x.updatedAt)} ago · ${x.messages} message${x.messages === 1 ? "" : "s"}${x.unsaved ? ` · ${x.unsaved} unsaved` : ""}`)}`);
9004
+ console.log(` ${x.title}`);
9005
+ }
9006
+ console.log(`\n ${c.dim("foldrun assistant show <id> · foldrun assistant --conversation <id> \"…\" · foldrun assistant rm <id>")}\n`);
9007
+ return 0;
9008
+ }
9009
+
9010
+ /** `foldrun assistant show <id>` — one kept conversation, whole. */
9011
+ async function assistantShowCmd(url, flags, id) {
9012
+ const d = await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`);
9013
+ if (flags.json) {
9014
+ console.log(JSON.stringify(d, null, 2));
9015
+ return 0;
9016
+ }
9017
+ console.log(`\n ${c.bold(d.title)} ${c.dim(`${d.workspace} · ${ago(d.updatedAt)} ago`)}\n`);
9018
+ for (const m of d.messages ?? []) {
9019
+ console.log(` ${m.role === "user" ? c.bold("you ›") : c.dim("assistant ›")}`);
9020
+ console.log(`${String(m.content).split("\n").map((l) => ` ${l}`).join("\n")}\n`);
9021
+ }
9022
+ if ((d.files ?? []).length) console.log(` ${c.dim(`${d.files.length} proposed file${d.files.length === 1 ? "" : "s"} not saved: ${d.files.map((f) => f.path).join(", ")}`)}\n`);
9023
+ return 0;
9024
+ }
9025
+
9026
+ /** `foldrun assistant rm <id>` — delete one kept conversation (asks; --yes). */
9027
+ async function assistantRmCmd(url, flags, id) {
9028
+ if (!(await sureToDelete(flags, "assistant rm", `Delete the conversation ${id}`))) return 0;
9029
+ await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`, { method: "DELETE" });
9030
+ console.log(` ${c.green("✓")} deleted ${id}\n`);
9031
+ return 0;
9032
+ }
9033
+
9034
+ /**
9035
+ * `foldrun assistant ["<message>"] --to <ws>` (also `foldrun ai`) — the
9036
+ * dashboard's assistant from a terminal: it answers from foldrun's docs and
9037
+ * the deployed workspace's files only, and writes agents, flows, tools and
9038
+ * skills as proposals (POST …/assistant). With a message it answers once;
9039
+ * with none, in a terminal, it is a conversation (/save, /files, /drop
9040
+ * <path>, /new, /quit). Proposals are shown as diffs with what the check
9041
+ * finds and saved only on yes (--yes; --dry-run never), each through
9042
+ * `PUT …/source` with ifMatch, so a file that changed meanwhile is refused.
9043
+ * --json prints the platform's answer as it is.
9044
+ */
9045
+ async function assistantCmd(positional, flags, layout) {
9046
+ const url = platformFor(flags, "assistant");
9047
+ // The kept conversations: `history`, `show <id>`, `rm <id>`.
9048
+ const sub = positional[0];
9049
+ if (sub === "history" && positional.length === 1) return assistantHistoryCmd(url, flags);
9050
+ if ((sub === "show" || sub === "rm") && positional.length === 2 && /^c_[a-z0-9]{8,40}$/.test(positional[1])) {
9051
+ return sub === "show" ? assistantShowCmd(url, flags, positional[1]) : assistantRmCmd(url, flags, positional[1]);
9052
+ }
9053
+ // A kept conversation knows its workspace; --to may still say another.
9054
+ let ws = typeof flags.conversation === "string" && typeof flags.to !== "string" ? "" : platformWorkspace(flags, layout, "assistant");
9055
+ const first = positional.join(" ").trim();
9056
+ /** A new workspace the last reply proposed.
9057
+ * @type {{ name: string, purpose: string } | null} */
9058
+ let proposedWs = null;
9059
+ if (!first && !process.stdin.isTTY) {
9060
+ throw new Error('`foldrun assistant "<question or what to build>" --to <workspace>` — with no message it needs a terminal to talk in');
9061
+ }
9062
+ /** @type {{ role: "user" | "assistant", content: string }[]} */
9063
+ let messages = [];
9064
+ /** @type {{ path: string, content: string, before: string | null, sha256: string | null }[]} */
9065
+ let files = [];
9066
+ /** @type {{ where: string, message: string, level: string }[]} */
9067
+ let issues = [];
9068
+ /** The kept conversation this one continues; null until the first reply. */
9069
+ let conversation = typeof flags.conversation === "string" ? flags.conversation : null;
9070
+ if (conversation) {
9071
+ // Carry a kept one on: its messages and its unsaved files come back.
9072
+ const kept = await remoteCall(url, flags, `/api/assistant/conversations/${enc(conversation)}`);
9073
+ messages = (kept.messages ?? []).map((m) => ({ role: m.role, content: m.content }));
9074
+ files = kept.files ?? [];
9075
+ if (!ws) ws = kept.workspace;
9076
+ if (!flags.json) console.log(`\n ${c.dim(`carrying on "${kept.title}" in ${kept.workspace} — ${messages.length} message${messages.length === 1 ? "" : "s"}`)}`);
9077
+ }
9078
+
9079
+ const showFiles = async () => {
9080
+ for (const f of files) {
9081
+ console.log(` ${c.dim(f.before === null ? "new" : "replaces")}`);
9082
+ await printDiff(f.path, f.before ?? "", f.content);
9083
+ console.log();
9084
+ }
9085
+ for (const i of issues) console.log(` ${i.level === "error" ? c.red("error") : c.amber(" warn")} ${c.bold(i.where)} ${i.message}`);
9086
+ if (issues.length) console.log();
9087
+ };
9088
+
9089
+ const ask = async (text) => {
9090
+ messages.push({ role: "user", content: text });
9091
+ if (!flags.json) console.log(`\n ${c.dim("reading the docs and the workspace — one reply, charged like a step…")}`);
9092
+ let d;
9093
+ try {
9094
+ d = await remoteCall(
9095
+ url,
9096
+ flags,
9097
+ `/api/workspaces/${enc(ws)}/assistant`,
9098
+ { method: "POST", body: JSON.stringify({ messages, files: files.map(({ path: p, content }) => ({ path: p, content })), ...(conversation ? { conversation } : {}) }) },
9099
+ { seconds: Math.max(timeoutSeconds(flags), 300) },
9100
+ );
9101
+ } catch (err) {
9102
+ messages.pop();
9103
+ throw err;
9104
+ }
9105
+ messages.push({ role: "assistant", content: d.reply ?? "" });
9106
+ files = d.files ?? [];
9107
+ issues = d.issues ?? [];
9108
+ proposedWs = d.workspace ?? null;
9109
+ if (typeof d.conversation === "string" && d.conversation) conversation = d.conversation;
9110
+ if (flags.json) {
9111
+ console.log(JSON.stringify(d, null, 2));
9112
+ return;
9113
+ }
9114
+ console.log(`\n${(d.reply ?? "").split("\n").map((l) => ` ${l}`).join("\n")}\n`);
9115
+ if ((d.sources ?? []).length) console.log(` ${c.dim(`read: ${d.sources.map((s) => `${s.doc} › ${s.section}`).join(" · ")}`)}\n`);
9116
+ if (conversation) console.log(` ${c.dim(`kept as ${conversation} — carry on with --conversation ${conversation}`)}\n`);
9117
+ if (d.usage) {
9118
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
9119
+ const paid = d.supply === "provider" ? "on your own model key" : typeof d.chargedUsd === "number" ? `$${d.chargedUsd.toFixed(d.chargedUsd > 0 && d.chargedUsd < 0.01 ? 4 : 2)} charged` : "";
9120
+ console.log(` ${c.dim(`${k(d.usage.inputTokens)} tokens in · ${k(d.usage.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
9121
+ }
9122
+ if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
9123
+ await showFiles();
9124
+ };
9125
+
9126
+ /** What the last reply proposed — read through a call, because ask()
9127
+ * sets it and a type checker narrowing the variable cannot see that. */
9128
+ const proposal = () => proposedWs;
9129
+
9130
+ /** Create the proposed workspace, blank, as the dashboard does — after a
9131
+ * yes, and never over one that exists (the same POST deploys over it).
9132
+ * @param {{ name: string, purpose: string }} p */
9133
+ const createProposed = async (p) => {
9134
+ const name = p.name;
9135
+ if (!(await confirmed(flags, "assistant", `Create the workspace ${name}? [y/N] `))) {
9136
+ console.log(`\n ${c.dim("not created")}\n`);
9137
+ return false;
9138
+ }
9139
+ // ifAbsent: the server refuses a name that exists (409) rather than
9140
+ // deploying over it — atomically, where a list-then-create could race.
9141
+ try {
9142
+ await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ name, template: true, ifAbsent: true }) });
9143
+ } catch (err) {
9144
+ if (err instanceof HttpError && err.status === 409) {
9145
+ console.log(` ${c.red("✗")} a workspace called ${name} exists — nothing created\n`);
9146
+ return false;
9147
+ }
9148
+ throw err;
9149
+ }
9150
+ console.log(` ${c.green("✓")} created ${name}\n`);
9151
+ proposedWs = null;
9152
+ return name;
9153
+ };
9154
+
9155
+ const save = async (note) => {
9156
+ if (!files.length) {
9157
+ console.log(` ${c.dim("nothing proposed to save")}\n`);
9158
+ return true;
9159
+ }
9160
+ if (!(await confirmed(flags, "assistant", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
9161
+ console.log(`\n ${c.dim("nothing saved")}\n`);
9162
+ return true;
9163
+ }
9164
+ const message = `written with the assistant: ${note.replace(/\s+/g, " ").slice(0, 80)}`;
9165
+ for (const f of [...files]) {
9166
+ try {
9167
+ await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, { method: "PUT", body: JSON.stringify({ path: f.path, content: f.content, message, ifMatch: f.sha256 ?? null }) });
9168
+ console.log(` ${c.green("✓")} ${f.path}`);
9169
+ files = files.filter((x) => x.path !== f.path);
9170
+ issues = issues.filter((i) => !(i.where === f.path || i.where.startsWith(`${f.path}:`)));
9171
+ } catch (err) {
9172
+ console.log(` ${c.red("✗")} ${f.path} — ${err instanceof Error ? err.message : err}`);
9173
+ return false;
9174
+ }
9175
+ }
9176
+ console.log(`\n ${c.dim(`each is a revision — \`foldrun history --to ${ws}\` · \`foldrun check --to ${ws}\``)}\n`);
9177
+ return true;
9178
+ };
9179
+
9180
+ if (first) {
9181
+ await ask(first);
9182
+ const p = proposal();
9183
+ if (p && !flags.json) {
9184
+ if (flags["dry-run"] === true) {
9185
+ console.log(` ${c.dim("--dry-run: no workspace created")}\n`);
9186
+ return 0;
9187
+ }
9188
+ if (flags.yes !== true && !process.stdin.isTTY) {
9189
+ console.log(` ${c.dim(`not a terminal to ask — no workspace created; run it again with --yes, or create it with \`foldrun new ${p.name}\` and deploy`)}\n`);
9190
+ return 1;
9191
+ }
9192
+ const made = await createProposed(p);
9193
+ if (made) console.log(` ${c.dim(`carry on there: foldrun assistant --to ${made} "<what it should do>"`)}\n`);
9194
+ return made ? 0 : 1;
9195
+ }
9196
+ if (!files.length || flags.json) return 0;
9197
+ if (flags["dry-run"] === true) {
9198
+ console.log(` ${c.dim("--dry-run: nothing saved")}\n`);
9199
+ return 0;
9200
+ }
9201
+ if (flags.yes !== true && !process.stdin.isTTY) {
9202
+ console.log(` ${c.dim(`the reply was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`}, or save them with \`foldrun source put\``)}\n`);
9203
+ return 1;
9204
+ }
9205
+ return (await save(first)) ? 0 : 1;
9206
+ }
9207
+
9208
+ const { createInterface } = await import("node:readline/promises");
9209
+ console.log(`\n ${c.bold("foldrun assistant")} ${c.dim(`· ${ws} · answers from the foldrun docs; nothing is saved until you /save`)}`);
9210
+ console.log(` ${c.dim("/save /files /drop <path> /new /quit")}\n`);
9211
+ for (;;) {
9212
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9213
+ let line;
9214
+ try {
9215
+ line = (await rl.question(`${c.bold("you ›")} `)).trim();
9216
+ } catch {
9217
+ line = "/quit"; // the input closed (Ctrl-D)
9218
+ } finally {
9219
+ rl.close();
9220
+ }
9221
+ if (!line) continue;
9222
+ if (line === "/quit" || line === "/exit") {
9223
+ if (files.length) console.log(`\n ${c.dim(`${files.length} proposed file${files.length === 1 ? "" : "s"} not saved`)}`);
9224
+ console.log();
9225
+ return 0;
9226
+ }
9227
+ if (line === "/new") {
9228
+ messages = [];
9229
+ files = [];
9230
+ issues = [];
9231
+ conversation = null;
9232
+ console.log(` ${c.dim("a new conversation")}\n`);
9233
+ continue;
9234
+ }
9235
+ if (line === "/files") {
9236
+ if (files.length) await showFiles();
9237
+ else console.log(` ${c.dim("nothing proposed")}\n`);
9238
+ continue;
9239
+ }
9240
+ if (line.startsWith("/drop")) {
9241
+ const target = line.slice(5).trim();
9242
+ const before = files.length;
9243
+ files = files.filter((f) => f.path !== target);
9244
+ issues = issues.filter((i) => !(i.where === target || i.where.startsWith(`${target}:`)));
9245
+ console.log(` ${c.dim(files.length < before ? `dropped ${target}` : `${target || "(no path)"} is not proposed`)}\n`);
9246
+ continue;
9247
+ }
9248
+ if (line === "/save") {
9249
+ await save(messages.find((m) => m.role === "user")?.content ?? "");
9250
+ continue;
9251
+ }
9252
+ try {
9253
+ await ask(line);
9254
+ const p = proposal();
9255
+ if (p) {
9256
+ const made = await createProposed(p);
9257
+ if (made) {
9258
+ // The conversation moves into the new workspace and carries on.
9259
+ ws = made;
9260
+ files = [];
9261
+ issues = [];
9262
+ messages.push({ role: "assistant", content: `Created the workspace \`${made}\`.` });
9263
+ console.log(` ${c.dim(`now working in ${made}`)}`);
9264
+ await ask(`The workspace "${made}" now exists and you are working in it. Carry on with what I asked for: write its agents, flows, tools and skills.`);
9265
+ }
9266
+ }
9267
+ } catch (err) {
9268
+ console.log(`\n ${c.red("✗")} ${err instanceof Error ? err.message : err}\n`);
9269
+ }
9270
+ }
9271
+ }
9272
+
8444
9273
  /**
8445
9274
  * `foldrun history restore <path> --id <revision> --to <ws>` — the History
8446
9275
  * drawer's "Restore this version": the file is rewritten with that
@@ -8495,7 +9324,7 @@ async function scheduleTickCmd(flags) {
8495
9324
  return 0;
8496
9325
  }
8497
9326
 
8498
- const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge", "memory"];
9327
+ const LIBRARY_KINDS_CLI = ["skills", "tools", "hooks", "scripts", "knowledge", "memory"];
8499
9328
 
8500
9329
  /**
8501
9330
  * `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
@@ -8515,9 +9344,16 @@ async function libraryCmd(positional, flags) {
8515
9344
  if (verb === "ls" || verb === "list") {
8516
9345
  const kinds = spec ? [spec] : LIBRARY_KINDS_CLI;
8517
9346
  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}`)));
9347
+ // Listing everything, a kind an older platform does not have yet (hooks
9348
+ // before 11 Oct 2026) is left out rather than failing the whole list;
9349
+ // asked for by name, it fails with the platform's own answer.
9350
+ const lists = await Promise.all(kinds.map((k) => remoteCall(url, flags, `/api/library/${k}`).catch((err) => {
9351
+ if (spec) throw err;
9352
+ return null;
9353
+ })));
8519
9354
  console.log();
8520
9355
  lists.forEach((l, n) => {
9356
+ if (!l) return;
8521
9357
  const entries = l.entries ?? [];
8522
9358
  console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
8523
9359
  for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
@@ -8528,7 +9364,7 @@ async function libraryCmd(positional, flags) {
8528
9364
  if (verb === "cat") {
8529
9365
  const { kind, rel } = split(spec);
8530
9366
  const { content } = await remoteCall(url, flags, `/api/library/${kind}?path=${enc(rel)}`);
8531
- process.stdout.write(content ?? "");
9367
+ writeExact(content ?? "");
8532
9368
  return 0;
8533
9369
  }
8534
9370
  if (verb === "put") {
@@ -8797,10 +9633,29 @@ async function apiVersionCmd(positional, flags) {
8797
9633
  console.log(`\n ${c.bold("API version")} ${v.effective} ${c.dim(v.pinned ? `pinned (current is ${v.current})` : "current — not pinned")}`);
8798
9634
  console.log(` ${c.dim(`served: ${(v.supported ?? []).join(", ") || "—"} · each for ${v.supportMonths ?? "?"} months after the next · this CLI sends ${API_VERSION}`)}`);
8799
9635
  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`)}`);
9636
+ apiUsageLines(facts.usage);
8800
9637
  console.log(`\n ${c.dim("foldrun api version pin YYYY-MM-DD · unpin")}\n`);
8801
9638
  return 0;
8802
9639
  }
8803
9640
 
9641
+ /** The account's use of the API, last 30 days: requests by version, and any
9642
+ * deprecated route it still calls, by whom, and when it stops. An older
9643
+ * platform sends no `usage`; null means it could not be read. */
9644
+ function apiUsageLines(usage) {
9645
+ if (usage === undefined) return;
9646
+ if (usage === null) {
9647
+ console.log(` ${c.bold("your use")} ${c.dim("could not be read just now")}`);
9648
+ return;
9649
+ }
9650
+ const total = (usage.versions ?? []).reduce((s, v) => s + (v.requests ?? 0), 0);
9651
+ const day = (iso) => String(iso ?? "").slice(0, 10);
9652
+ 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`)}`);
9653
+ for (const d of usage.deprecated ?? []) {
9654
+ 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}` : ""}`);
9655
+ for (const who of d.callers ?? []) console.log(` ${c.dim(`${who.credential || "an unknown caller"}: ${who.requests}, last ${day(who.lastSeen)}`)}`);
9656
+ }
9657
+ }
9658
+
8804
9659
  const THEMES_CLI = ["system", "light", "dark"];
8805
9660
 
8806
9661
  /**
@@ -8828,7 +9683,8 @@ async function preferencesCmd(positional, flags) {
8828
9683
  return 0;
8829
9684
  }
8830
9685
 
8831
- /** `foldrun changelog [--limit n]` — What's new: each release and what it changed. */
9686
+ /** `foldrun changelog [--limit n]` — What's new: the API's versions, what each
9687
+ * broke and what is deprecated, then each release and what it changed. */
8832
9688
  async function changelogCmd(flags) {
8833
9689
  const url = platformFor(flags, "changelog");
8834
9690
  const limit = Number(flags.limit) > 0 ? Math.min(50, Math.floor(Number(flags.limit))) : 5;
@@ -8837,6 +9693,7 @@ async function changelogCmd(flags) {
8837
9693
  console.log(JSON.stringify(r, null, 2));
8838
9694
  return 0;
8839
9695
  }
9696
+ if (r.api) apiChangelogLines(r.api);
8840
9697
  const releases = r.releases ?? [];
8841
9698
  if (!releases.length) {
8842
9699
  console.log(`\n ${c.dim(`no release notes on this install (it runs ${r.version ?? "an unreleased build"})`)}\n`);
@@ -8859,6 +9716,25 @@ async function changelogCmd(flags) {
8859
9716
  return 0;
8860
9717
  }
8861
9718
 
9719
+ /** The API part of What's new: the current version, the breaking changes by
9720
+ * version, and routes on their way out. Older platforms send no `api`. */
9721
+ function apiChangelogLines(api) {
9722
+ const p = printable;
9723
+ const broke = (api.versions ?? []).filter((v) => v.changes?.length);
9724
+ const deps = api.deprecations ?? [];
9725
+ 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>`)}`);
9726
+ if (!broke.length && !deps.length) {
9727
+ console.log(` ${c.dim("nothing existing code would break on, and nothing deprecated")}`);
9728
+ return;
9729
+ }
9730
+ if (broke.length) console.log(` ${c.dim("breaking changes")}`);
9731
+ for (const v of broke) for (const ch of v.changes) console.log(` · ${p(v.version)} ${firstLine(p(ch), 100)}`);
9732
+ if (deps.length) console.log(` ${c.dim("deprecated")}`);
9733
+ for (const d of deps) {
9734
+ 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)}` : ""}`);
9735
+ }
9736
+ }
9737
+
8862
9738
  /**
8863
9739
  * `foldrun find <query>` — the ⌘K search: workspaces, agents, flows, tools,
8864
9740
  * skills, knowledge, memory and runs across the account, ranked as the
@@ -9065,7 +9941,7 @@ function printPreview(p, rel) {
9065
9941
  case "text":
9066
9942
  case "html":
9067
9943
  case "svg":
9068
- process.stdout.write(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
9944
+ writeExact(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
9069
9945
  if (p.truncated) out(c.dim("… (truncated)"));
9070
9946
  return;
9071
9947
  case "table":
@@ -9308,11 +10184,15 @@ export async function run(command, positional, flags, workspace, layout) {
9308
10184
  // `new` scaffolds one here; `run` runs one there. The noun is the same
9309
10185
  // thing in both cases, which is why they share a command.
9310
10186
  if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
10187
+ if (positional[0] === "network") return agentNetworkCmd(positional, flags, layout);
9311
10188
  if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
9312
10189
  if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
9313
10190
  return positional[0] === "run"
9314
10191
  ? agentRunCmd(positional, flags, layout)
9315
10192
  : scaffoldCmd("agents", positional, flags, layout);
10193
+ case "assistant":
10194
+ case "ai":
10195
+ return assistantCmd(positional, flags, layout);
9316
10196
  case "flow":
9317
10197
  // `new` scaffolds a file; `add`, `rm-step` and `show` are the canvas; `run` is
9318
10198
  // `invoke`, spelled the way `agent run` is; `rotate-hook` a new URL.
@@ -9335,6 +10215,9 @@ export async function run(command, positional, flags, workspace, layout) {
9335
10215
  return positional[0] === "test"
9336
10216
  ? toolTestCmd(positional, flags, layout)
9337
10217
  : scaffoldCmd("tools", positional, flags, layout);
10218
+ case "hook":
10219
+ case "hooks":
10220
+ return hookCmd(positional, flags, layout);
9338
10221
  case "invoke":
9339
10222
  return invoke(positional[0], flags);
9340
10223
  case "source":