@foldrun/core 0.6.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.
Files changed (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. package/src/zip.ts +189 -0
package/src/step-exec.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
10
 
11
+ import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
11
12
  import fs from "node:fs";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
@@ -57,6 +58,14 @@ export interface ExecOutcome {
57
58
  }
58
59
 
59
60
  export interface ExecOptions {
61
+ /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
62
+ * inside its sandbox. `hookBase` is what every hook event carries. */
63
+ hooks?: HookDef[];
64
+ /** Or the names (`hooks:` from the account, workspace and agent), resolved
65
+ * here against this step's own roots — in a sandbox those are not the
66
+ * host's paths. A name that resolves to nothing refuses the step. */
67
+ hookNames?: string[];
68
+ hookBase?: Record<string, unknown>;
60
69
  agentDir: string;
61
70
  workspaceRoot: string;
62
71
  libraryRoot: string;
@@ -158,7 +167,7 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
158
167
  export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
159
168
 
160
169
  /** The pairing fields on a tool event — see RunEvent in store.ts. */
161
- export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
170
+ export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
162
171
 
163
172
  /** The tag on every event a failed check writes — `verify:`, `output: json`,
164
173
  * a schema — headline and detail alike. The runner's no-retry rule for
@@ -310,8 +319,51 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
310
319
  // fan-out instance each start from zero.
311
320
  const counter = new CallCounter(opts.limits);
312
321
 
322
+ // Hooks (hooks.ts): the person's own code at this dispatch, same on every
323
+ // model. Each runs in the agent's directory with a bare environment.
324
+ let hooks = opts.hooks ?? [];
325
+ if (opts.hookNames?.length) {
326
+ const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
327
+ const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
328
+ if (bad.length) {
329
+ emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
330
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
331
+ }
332
+ hooks = [...hooks, ...r.hooks];
333
+ }
334
+ const hookBase = { ...(opts.hookBase ?? {}) };
335
+ const callHook = (hook: HookDef, event: Record<string, unknown>) => runHook(hook, event, { cwd: agentDir, env: opts.env });
336
+ const record = (r: HookRecord, via?: string) =>
337
+ emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
338
+
339
+ // step.start — a gate: context it returns goes in front of the task; a
340
+ // hook that fails refuses the step rather than run it unguarded.
341
+ let prompt = opts.prompt;
342
+ for (const hook of hooks.filter((h) => h.on === "step.start")) {
343
+ const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
344
+ if (!a.ok) {
345
+ record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
346
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
347
+ }
348
+ // Exit 2 is a deny on every event (runHook). On a gate that means the
349
+ // step does not run — it used to come back ok with no context, and the
350
+ // step went ahead as if the hook had agreed.
351
+ if (a.decision === "deny") {
352
+ record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
353
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
354
+ }
355
+ if (a.context) {
356
+ prompt = `${prompt}\n\n${a.context}`;
357
+ record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
358
+ } else if (a.note) {
359
+ record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
360
+ }
361
+ }
362
+ const toolHooks = hooks.filter((h) => h.on === "tool.call");
363
+ const resultHooks = hooks.filter((h) => h.on === "tool.result");
364
+
313
365
  const q = runQuery({
314
- prompt: opts.prompt,
366
+ prompt,
315
367
  options: {
316
368
  cwd: agentDir,
317
369
  abortController: abort,
@@ -329,6 +381,13 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
329
381
  // declares no tools gets none, instead of seeing the full Claude
330
382
  // Code toolset and burning turns on denied calls.
331
383
  tools: opts.allowed,
384
+ // Stated, never left to the SDK. Unset, it chose its own mode for the
385
+ // login it ran under, and in auto mode its classifier refused a granted
386
+ // script before canUseTool saw it: "denied by the Claude Code auto mode
387
+ // classifier. Reason: [Irreversible Local Destruction]" for backlink-
388
+ // desk's outreach_reset, which only marks old bodies STALE (2026-10-04
389
+ // and 05). The decision is ours: canUseTool and the PreToolUse hook.
390
+ permissionMode: "default",
332
391
  // Deliberately NOT allowedTools: listing them there auto-approves and
333
392
  // skips canUseTool entirely, which is where confinement happens. The
334
393
  // toolset is still restricted by `tools` above.
@@ -404,6 +463,44 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
404
463
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
405
464
  }
406
465
  }
466
+ // tool.call hooks — after foldrun's own checks, so a hook never
467
+ // sees a call that was going to be refused anyway, and handed the
468
+ // input as the tool would get it (the workspace/ prefix expanded).
469
+ if (toolHooks.length) {
470
+ const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {}) as Record<string, unknown>;
471
+ // A sub-agent's call says so: a hook that guards the step's own
472
+ // model differently from its delegates can tell them apart.
473
+ const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
474
+ for (const r of v.records) record(r, via);
475
+ const refundCall = () => { if (counter.active) counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
476
+ if (v.decision === "deny") {
477
+ refundCall();
478
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
479
+ }
480
+ if (v.input) {
481
+ // A rewritten call is answered "allow", and an allow skips
482
+ // canUseTool — where the shell and path confinement live. So
483
+ // the rewrite goes through the same checks here: a hook can
484
+ // narrow what the agent does, never widen it.
485
+ const again = hookInput.tool_name === "Bash"
486
+ ? checkBash(String(v.input.command ?? ""))
487
+ : isFilesystemTool(hookInput.tool_name)
488
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
489
+ : { ok: true as const };
490
+ const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
491
+ // The sub-agent guard read the ORIGINAL input: a rewrite of a
492
+ // delegation could otherwise swap subagent_type for one this
493
+ // step never declared (or the SDK's general-purpose).
494
+ const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
495
+ if (!again.ok || !granted || delegation) {
496
+ refundCall();
497
+ const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
498
+ emit("error", why, via ? { subagent: via } : undefined);
499
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
500
+ }
501
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: again.updatedInput ?? v.input } };
502
+ }
503
+ }
407
504
  if (!verdict?.updatedInput) return {};
408
505
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
409
506
  }],
@@ -412,11 +509,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
412
509
  // after its next tool call, as context — never as a change to what it
413
510
  // may do. A model that is only writing, calling no tools, hears it at
414
511
  // the next call it makes.
415
- ...(opts.inbox
512
+ ...(opts.inbox || resultHooks.length
416
513
  ? {
417
514
  PostToolUse: [{
418
515
  hooks: [async (hookInput: HookInput) => {
419
516
  if (hookInput.hook_event_name !== "PostToolUse") return {};
517
+ // tool.result hooks watch: the call has happened, so one
518
+ // that fails is recorded and the step goes on.
519
+ for (const hook of resultHooks) {
520
+ if (!matchesTool(hook.tool, hookInput.tool_name)) continue;
521
+ const response = (hookInput as { tool_response?: unknown }).tool_response;
522
+ const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
523
+ if (!a.ok) record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
524
+ else if (a.note) record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
525
+ }
526
+ if (!opts.inbox) return {};
420
527
  // A sub-agent's call: the message is for the step's own
421
528
  // model, and draining it here would hand it to a context
422
529
  // that ends with the delegation — and lose it.
@@ -632,11 +739,23 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
632
739
  ...(block.is_error ? { err: true } : {}),
633
740
  ...(open.subagent ? { subagent: open.subagent } : {}),
634
741
  });
742
+ // Why it failed, on the trail. The span above carried only `err`,
743
+ // so a refused call left the agent's own guess as the only account
744
+ // of it: backlink-desk's outreach_reset "denied by the permission
745
+ // classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
746
+ if (block.is_error) {
747
+ const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
748
+ if (why) emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
749
+ }
635
750
  readPod(block.tool_use_id);
636
751
  }
637
752
  }
638
753
  } else if (message.type === "result") {
639
754
  status = message.subtype === "success" ? "completed" : "failed";
755
+ // Calls the SDK itself refused, which canUseTool never saw.
756
+ for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
757
+ emit("error", `refused before it ran: ${String((d as { tool_name?: unknown }).tool_name ?? "a tool")}`);
758
+ }
640
759
  if (message.subtype === "error_max_turns") {
641
760
  emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
642
761
  }
@@ -711,6 +830,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
711
830
  "the model stream ended without a result — the model process likely died (out of memory?)",
712
831
  );
713
832
  }
833
+ // step.end hooks watch the reply, and may rewrite it. One that fails is
834
+ // recorded and the reply stands: the work it describes is done.
835
+ if (status === "completed") {
836
+ for (const hook of hooks.filter((h) => h.on === "step.end")) {
837
+ const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
838
+ if (!a.ok) record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
839
+ else if (a.reply !== undefined) {
840
+ texts.splice(0, texts.length, a.reply);
841
+ record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
842
+ } else if (a.note) record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
843
+ }
844
+ }
714
845
  const result = texts.join("\n").trim() || null;
715
846
  // A step's reply is not one message: the model narrates ("Now let me read
716
847
  // the outputs…"), calls tools, and answers last. Joining those and reading
@@ -877,8 +1008,8 @@ export async function checkVerify(
877
1008
  * and `matches:` test this, because it is also what the run's headline
878
1009
  * is read from: a reporter that narrated between tool calls ("Now let
879
1010
  * me write the report…") failed `matches: ^BAD\b` with a correct
880
- * headline while the check read every turn joined. `judge:` still grades
881
- * the whole result. */
1011
+ * headline while the check read every turn joined. `judge:` grades this
1012
+ * too, as "the reply", with the whole result beside it as the work. */
882
1013
  conclusion?: string | null;
883
1014
  data?: unknown;
884
1015
  /** The step's own model environment, for `judge:` — it grades on the
@@ -941,7 +1072,13 @@ export async function checkVerify(
941
1072
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
942
1073
  }
943
1074
  case "judge": {
944
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
1075
+ // The reply is the final turn, as everywhere else; every turn goes
1076
+ // beside it as the work, so a claim about what the step did can still
1077
+ // be checked. Both as one <reply> failed `judge: the reply opens with
1078
+ // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
1079
+ // text opened with the narration before it.
1080
+ const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
1081
+ const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
945
1082
  const ok = /^\s*PASS\b/i.test(verdict);
946
1083
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
947
1084
  }
@@ -954,9 +1091,24 @@ export async function checkVerify(
954
1091
  * Same shape as the eval judge, and answered with one word first so the
955
1092
  * verdict is a prefix test rather than a reading.
956
1093
  */
1094
+ export function judgePrompt(rubric: string, reply: string, work: string): string {
1095
+ const shown = reply.slice(0, 20_000);
1096
+ const context = work.trim() && work.trim() !== reply.trim()
1097
+ ? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
1098
+ `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
1099
+ : "";
1100
+ return (
1101
+ `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
1102
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
1103
+ context +
1104
+ `<reply>\n${shown}\n</reply>`
1105
+ );
1106
+ }
1107
+
957
1108
  async function judgeReply(
958
1109
  rubric: string,
959
- output: string,
1110
+ reply: string,
1111
+ work: string,
960
1112
  env: Record<string, string | undefined>,
961
1113
  signal?: AbortSignal,
962
1114
  ): Promise<string> {
@@ -966,10 +1118,7 @@ async function judgeReply(
966
1118
  signal?.addEventListener("abort", () => abortController.abort(), { once: true });
967
1119
  try {
968
1120
  const q = query({
969
- prompt:
970
- `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
971
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
972
- `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
1121
+ prompt: judgePrompt(rubric, reply, work),
973
1122
  options: {
974
1123
  model: "haiku",
975
1124
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
@@ -1060,3 +1209,14 @@ function runVerify(
1060
1209
  export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
1061
1210
  return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
1062
1211
  }
1212
+
1213
+ /** The text of a tool result, whichever shape it came in: a string, or a
1214
+ * list of content blocks of which only the text ones say anything. */
1215
+ export function toolResultText(content: unknown): string {
1216
+ if (typeof content === "string") return content;
1217
+ if (!Array.isArray(content)) return "";
1218
+ return content
1219
+ .filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string")
1220
+ .map((b) => b.text)
1221
+ .join(" ");
1222
+ }
package/src/storage.ts CHANGED
@@ -839,6 +839,14 @@ export async function materializeFiles(tenant: string, workspace: string): Promi
839
839
  return brought;
840
840
  }
841
841
 
842
+ /** path → sha of every stored file: what `storage/` holds once
843
+ * materializeFiles has run. A run keeps it to tell its own writes from the
844
+ * files it was handed. */
845
+ export function storageBaseline(tenant: string, workspace: string): Record<string, string> {
846
+ if (!fileStoreEnabled()) return {};
847
+ return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
848
+ }
849
+
842
850
  /**
843
851
  * Take what the run left in `storage/` back into the store, stamped with the
844
852
  * run that wrote it.
@@ -851,6 +859,7 @@ export async function harvestFiles(
851
859
  tenant: string,
852
860
  workspace: string,
853
861
  by: string,
862
+ opts: { changedFrom?: Record<string, string> } = {},
854
863
  ): Promise<{ saved: string[]; errors: string[] }> {
855
864
  if (!fileStoreEnabled()) return { saved: [], errors: [] };
856
865
  adoptLegacyFilesDir(workspaceDir(tenant, workspace));
@@ -885,6 +894,10 @@ export async function harvestFiles(
885
894
  // Same size is not proof, so confirm by hash before skipping a write.
886
895
  if (sha256(fs.readFileSync(abs)) === previous.sha) continue;
887
896
  }
897
+ // Only what the run itself changed: a file still holding the bytes it
898
+ // was handed is not this run's work, and the index may have moved on
899
+ // since (an upload while the run was going) — that must win.
900
+ if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs))) continue;
888
901
  await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
889
902
  saved.push(norm);
890
903
  } catch (err) {
package/src/store.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
+ import { expandPlatformVars } from "./host-env.ts";
23
24
  import type { BrowserPodOutcome } from "./browser-pod.ts";
24
25
  import fs from "node:fs";
25
26
  import path from "node:path";
@@ -57,7 +58,7 @@ import {
57
58
  } from "./okf.ts";
58
59
  import { readTransport, KINDS } from "./kinds.ts";
59
60
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
60
- import { starterFiles, accountFiles } from "./starter.ts";
61
+ import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.ts";
61
62
  import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
62
63
 
63
64
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
@@ -83,6 +84,7 @@ export const WORKSPACE_DIRS = [
83
84
  "knowledge",
84
85
  "tools",
85
86
  "scripts",
87
+ "hooks",
86
88
  "state",
87
89
  ] as const;
88
90
 
@@ -317,6 +319,19 @@ export function assertCanonicalCase(rel: string) {
317
319
  // fire (trigger-log.ts). Before it was listed here every deploy erased it.
318
320
  export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
319
321
 
322
+ // The agent engine's own home files. Each step's sandbox runs the Claude
323
+ // Agent SDK — the engine, whatever model answers — with its home in the
324
+ // agent's folder, and the engine writes its settings (.claude.json, its
325
+ // backups), plugin caches and the full transcript of every session
326
+ // (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
327
+ // back after every step, they had piled up to 5,274 files and 19 MB in one
328
+ // agent's folder on the box by 6 Oct 2026, carried every past conversation
329
+ // into every sandbox, and their clashing rewrites were the "write-back
330
+ // conflict on .claude.json" lines (and the big merges) of that day. A
331
+ // workspace's own .claude/agents/*.md — Claude Code subagents an author
332
+ // writes, imported as agents — sits at the workspace root and is untouched.
333
+ const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
334
+
320
335
  /** True for anything the platform, not the author or the agent, writes. */
321
336
  export function isPlatformPath(rel: string): boolean {
322
337
  const norm = rel.replaceAll("\\", "/");
@@ -325,6 +340,7 @@ export function isPlatformPath(rel: string): boolean {
325
340
  // are the vault, as far as anything outside the platform is concerned.
326
341
  if (/(^|\/)secrets\.json\./.test(norm)) return true;
327
342
  if (norm === "trigger-log.jsonl.tmp") return true;
343
+ if (ENGINE_HOME_RE.test(norm)) return true;
328
344
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
329
345
  }
330
346
 
@@ -625,6 +641,27 @@ export function toolIsOutward(data: Record<string, unknown>): boolean {
625
641
  return data.outward === true;
626
642
  }
627
643
 
644
+ /** How much of a tool.md body rides to the model with the tool. */
645
+ export const TOOL_GUIDE_MAX = 6000;
646
+
647
+ /**
648
+ * The prose under a tool.md's frontmatter: how to call it, what it refuses,
649
+ * what its output means. Only `description:` used to reach the model, so
650
+ * agents invented what the body documented (5 Oct 2026). The fenced program
651
+ * of a single-file tool is code, not guidance, and stays out.
652
+ */
653
+ export function toolGuide(body: string | undefined, inlineProgram = false): string | undefined {
654
+ if (!body) return undefined;
655
+ let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
656
+ // Only a single-file tool's program is cut; in a `run:` tool every fenced
657
+ // block is an example of a call, which is exactly what the model needs.
658
+ const inline = inlineProgram ? fencedCodeBlock(text) : null;
659
+ if (inline) text = text.slice(0, inline.start) + text.slice(inline.end);
660
+ text = text.trim();
661
+ if (!text) return undefined;
662
+ return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
663
+ }
664
+
628
665
  export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
629
666
  const name = typeof data.name === "string" ? data.name : fallbackName;
630
667
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -659,16 +696,24 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
659
696
  };
660
697
  }
661
698
  if (kind === "script") {
699
+ // `guide` is the body's, never the frontmatter's: an author's `guide:`
700
+ // would otherwise ride to the model in place of the manual.
701
+ if ("guide" in data) { data = { ...data }; delete data.guide; }
662
702
  // The program is a run: path, or the file's own fenced code block — the
663
703
  // single-file form, which is what lets a script tool read and edit like
664
704
  // every other markdown document.
665
- if (typeof data.run === "string") return { kind: "script", name, spec: { ...data, name } };
705
+ if (typeof data.run === "string") {
706
+ const guide = toolGuide(body);
707
+ return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
708
+ }
666
709
  const inline = body ? fencedCode(body) : null;
667
710
  if (!inline) return null;
668
- return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext } };
711
+ const guide = toolGuide(body, true);
712
+ return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
669
713
  }
670
714
  const [spec] = parseApis([{ ...data, name }]);
671
- return spec ? { kind: "http", name, spec } : null;
715
+ const guide = toolGuide(body);
716
+ return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
672
717
  }
673
718
 
674
719
  /**
@@ -864,6 +909,8 @@ export interface ApiSpec {
864
909
  name: string;
865
910
  base: string;
866
911
  description: string;
912
+ /** The tool.md body, for the model (see toolGuide). */
913
+ guide?: string;
867
914
  headers: Record<string, string>; // values may contain ${SECRET_NAME}
868
915
  query: Record<string, string>; // always-appended query params (e.g. api keys)
869
916
  methods: string[]; // allowed HTTP methods; default GET only
@@ -1136,7 +1183,9 @@ export function parseApis(raw: unknown): ApiSpec[] {
1136
1183
  out.push({
1137
1184
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
1138
1185
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
1139
- base: trimSlashes(base),
1186
+ // `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
1187
+ // addresses, filled here on the host so a desk never names one.
1188
+ base: trimSlashes(expandPlatformVars(base)),
1140
1189
  description: typeof e.description === "string" ? e.description : "",
1141
1190
  headers: asRecordLocal(e.headers),
1142
1191
  query: asRecordLocal(e.query),
@@ -2875,6 +2924,12 @@ export function setWorkspaceDescription(tenant: string, workspace: string, descr
2875
2924
  * this copy still said `type: Agent` after the CLI's had moved to `kind:`,
2876
2925
  * so every workspace made from the dashboard was born in the old format.
2877
2926
  */
2927
+ /** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
2928
+ * minus the laptop-only files, for the same reason as templateFiles. */
2929
+ export function blankTemplateFiles(workspace: string): DeployFile[] {
2930
+ return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
2931
+ }
2932
+
2878
2933
  export function templateFiles(workspace: string): DeployFile[] {
2879
2934
  // The starter minus what only matters on a laptop's disk: .gitignore
2880
2935
  // guards a local clone's secrets, but the hosted store never keeps
@@ -2970,6 +3025,9 @@ export interface RunEvent {
2970
3025
  * detail), `output: json`, a schema. What the runner reads to decline a
2971
3026
  * retry of an outward step whose tools already ran. */
2972
3027
  check?: boolean;
3028
+ /** Set on the line a hook wrote — a deny, a rewrite, context, a failure
3029
+ * (hooks.ts). The run page and the Hooks page read these. */
3030
+ hook?: { hook: string; event: string; did: string; tool?: string; reason?: string; ms: number };
2973
3031
  }
2974
3032
 
2975
3033
  /** A question an agent asked a person while its step ran (tools: [ask]). */
@@ -3388,7 +3446,23 @@ export function runOutputPath(
3388
3446
 
3389
3447
  export function readRun(tenant: string, workspace: string, runId: string): RunRecord | null {
3390
3448
  const p = runFilePath(tenant, workspace, runId);
3391
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
3449
+ // EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
3450
+ // here can land on the just-unlinked inode and come back ESTALE (errno
3451
+ // -116, surfaced by Node as "Unknown system error -116", syscall read).
3452
+ // That isn't corruption — the next open() by path sees the committed new
3453
+ // inode — so re-read a few times on ESTALE and rethrow anything else.
3454
+ // (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
3455
+ // from waitForRun's poll while the run itself completed fine.)
3456
+ const attempts = 5;
3457
+ for (let i = 1; ; i++) {
3458
+ try {
3459
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
3460
+ } catch (e) {
3461
+ const err = e as NodeJS.ErrnoException;
3462
+ if ((err.code === "ESTALE" || err.errno === -116) && i < attempts) continue;
3463
+ throw e;
3464
+ }
3465
+ }
3392
3466
  }
3393
3467
 
3394
3468
  export function writeRun(tenant: string, workspace: string, run: RunRecord) {
@@ -3706,6 +3780,12 @@ export function runCost(run: RunRecord): number {
3706
3780
  * fan-out template is a row in the trace rather than work — billing any of
3707
3781
  * them would charge for steps the customer can see never executed, which is
3708
3782
  * the fastest way to lose an argument about an invoice.
3783
+ *
3784
+ * Tokens count only for steps that ran on the platform's own model
3785
+ * credential. A step on the account's own key (`credential` "provider: …" —
3786
+ * a provider: block, or Settings → Model) was paid for at the provider, and
3787
+ * billing it again here charged a BYOK customer twice. And a carried step's
3788
+ * tokens were billed where it ran, like its compute.
3709
3789
  */
3710
3790
  export function runMeter(run: RunRecord): {
3711
3791
  tokenCostUsd: number;
@@ -3720,17 +3800,19 @@ export function runMeter(run: RunRecord): {
3720
3800
  let computeSecs = 0;
3721
3801
  let smallSecs = 0;
3722
3802
  let netBytes = 0;
3803
+ let tokenCostUsd = 0;
3723
3804
  for (const s of run.steps) {
3724
3805
  if (s.status !== "completed" && s.status !== "failed") continue;
3725
3806
  // A carried step ran — and was billed — in the run it was carried from.
3726
3807
  if (s.carriedFrom) continue;
3727
3808
  steps += 1;
3809
+ if (!(s.credential ?? "").startsWith("provider:")) tokenCostUsd += s.costUsd ?? 0;
3728
3810
  const secs = s.computeSecs ?? 0;
3729
3811
  computeSecs += secs;
3730
3812
  if (s.size === "small") smallSecs += secs;
3731
3813
  netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
3732
3814
  }
3733
- return { tokenCostUsd: runCost(run), steps, computeSecs, smallSecs, netBytes };
3815
+ return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
3734
3816
  }
3735
3817
 
3736
3818
  /**
package/src/tool-names.ts CHANGED
@@ -49,8 +49,10 @@ export const BUILTIN_TOOLS = new Set([
49
49
  "NotebookEdit", "TodoWrite",
50
50
  ]);
51
51
 
52
- /** The platform's own groups, served in-process rather than by the SDK. */
53
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
52
+ /** The platform's own groups, served in-process rather than by the SDK.
53
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
54
+ * accepted so a file that says so is not flagged. */
55
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
54
56
 
55
57
  /** Would this name resolve to something the runtime provides? Built-ins win a
56
58
  * clash, so a tool file of the same name is shadowed rather than granted. */
package/src/tool-test.ts CHANGED
@@ -277,7 +277,14 @@ export async function testTool(
277
277
  });
278
278
  }
279
279
 
280
- const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
280
+ // The tool's own secrets, and the calling agent's `secrets:` — a run
281
+ // hands a script both. Testing gbp_read (whose agent, not the tool,
282
+ // declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
283
+ // same call worked (9 Oct 2026). Only an agent that grants the tool: the
284
+ // agents[0] stand-in above is there for its depth, not its keys.
285
+ const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
286
+ const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
287
+ const names = [...new Set([...own, ...granting])];
281
288
  const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
282
289
  const live = await liveSecrets(stored, { tenant, workspace });
283
290
  if ("error" in live) {