@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
@@ -7,6 +7,7 @@
7
7
  // assembles the system prompt and builds MCP servers, then hands this the
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
+ import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -169,8 +170,49 @@ async function executeStepInner(opts, runQuery) {
169
170
  // `limits:` — this step's counts. A new step, a retry of one, or a
170
171
  // fan-out instance each start from zero.
171
172
  const counter = new CallCounter(opts.limits);
173
+ // Hooks (hooks.ts): the person's own code at this dispatch, same on every
174
+ // model. Each runs in the agent's directory with a bare environment.
175
+ let hooks = opts.hooks ?? [];
176
+ if (opts.hookNames?.length) {
177
+ const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
178
+ const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
179
+ if (bad.length) {
180
+ emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
181
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
182
+ }
183
+ hooks = [...hooks, ...r.hooks];
184
+ }
185
+ const hookBase = { ...(opts.hookBase ?? {}) };
186
+ const callHook = (hook, event) => runHook(hook, event, { cwd: agentDir, env: opts.env });
187
+ const record = (r, via) => emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
188
+ // step.start — a gate: context it returns goes in front of the task; a
189
+ // hook that fails refuses the step rather than run it unguarded.
190
+ let prompt = opts.prompt;
191
+ for (const hook of hooks.filter((h) => h.on === "step.start")) {
192
+ const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
193
+ if (!a.ok) {
194
+ record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
195
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
196
+ }
197
+ // Exit 2 is a deny on every event (runHook). On a gate that means the
198
+ // step does not run — it used to come back ok with no context, and the
199
+ // step went ahead as if the hook had agreed.
200
+ if (a.decision === "deny") {
201
+ record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
202
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
203
+ }
204
+ if (a.context) {
205
+ prompt = `${prompt}\n\n${a.context}`;
206
+ record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
207
+ }
208
+ else if (a.note) {
209
+ record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
210
+ }
211
+ }
212
+ const toolHooks = hooks.filter((h) => h.on === "tool.call");
213
+ const resultHooks = hooks.filter((h) => h.on === "tool.result");
172
214
  const q = runQuery({
173
- prompt: opts.prompt,
215
+ prompt,
174
216
  options: {
175
217
  cwd: agentDir,
176
218
  abortController: abort,
@@ -188,6 +230,13 @@ async function executeStepInner(opts, runQuery) {
188
230
  // declares no tools gets none, instead of seeing the full Claude
189
231
  // Code toolset and burning turns on denied calls.
190
232
  tools: opts.allowed,
233
+ // Stated, never left to the SDK. Unset, it chose its own mode for the
234
+ // login it ran under, and in auto mode its classifier refused a granted
235
+ // script before canUseTool saw it: "denied by the Claude Code auto mode
236
+ // classifier. Reason: [Irreversible Local Destruction]" for backlink-
237
+ // desk's outreach_reset, which only marks old bodies STALE (2026-10-04
238
+ // and 05). The decision is ours: canUseTool and the PreToolUse hook.
239
+ permissionMode: "default",
191
240
  // Deliberately NOT allowedTools: listing them there auto-approves and
192
241
  // skips canUseTool entirely, which is where confinement happens. The
193
242
  // toolset is still restricted by `tools` above.
@@ -265,6 +314,46 @@ async function executeStepInner(opts, runQuery) {
265
314
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
266
315
  }
267
316
  }
317
+ // tool.call hooks — after foldrun's own checks, so a hook never
318
+ // sees a call that was going to be refused anyway, and handed the
319
+ // input as the tool would get it (the workspace/ prefix expanded).
320
+ if (toolHooks.length) {
321
+ const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {});
322
+ // A sub-agent's call says so: a hook that guards the step's own
323
+ // model differently from its delegates can tell them apart.
324
+ const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
325
+ for (const r of v.records)
326
+ record(r, via);
327
+ const refundCall = () => { if (counter.active)
328
+ counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
329
+ if (v.decision === "deny") {
330
+ refundCall();
331
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
332
+ }
333
+ if (v.input) {
334
+ // A rewritten call is answered "allow", and an allow skips
335
+ // canUseTool — where the shell and path confinement live. So
336
+ // the rewrite goes through the same checks here: a hook can
337
+ // narrow what the agent does, never widen it.
338
+ const again = hookInput.tool_name === "Bash"
339
+ ? checkBash(String(v.input.command ?? ""))
340
+ : isFilesystemTool(hookInput.tool_name)
341
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
342
+ : { ok: true };
343
+ const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
344
+ // The sub-agent guard read the ORIGINAL input: a rewrite of a
345
+ // delegation could otherwise swap subagent_type for one this
346
+ // step never declared (or the SDK's general-purpose).
347
+ const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
348
+ if (!again.ok || !granted || delegation) {
349
+ refundCall();
350
+ const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
351
+ emit("error", why, via ? { subagent: via } : undefined);
352
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
353
+ }
354
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: again.updatedInput ?? v.input } };
355
+ }
356
+ }
268
357
  if (!verdict?.updatedInput)
269
358
  return {};
270
359
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
@@ -274,12 +363,26 @@ async function executeStepInner(opts, runQuery) {
274
363
  // after its next tool call, as context — never as a change to what it
275
364
  // may do. A model that is only writing, calling no tools, hears it at
276
365
  // the next call it makes.
277
- ...(opts.inbox
366
+ ...(opts.inbox || resultHooks.length
278
367
  ? {
279
368
  PostToolUse: [{
280
369
  hooks: [async (hookInput) => {
281
370
  if (hookInput.hook_event_name !== "PostToolUse")
282
371
  return {};
372
+ // tool.result hooks watch: the call has happened, so one
373
+ // that fails is recorded and the step goes on.
374
+ for (const hook of resultHooks) {
375
+ if (!matchesTool(hook.tool, hookInput.tool_name))
376
+ continue;
377
+ const response = hookInput.tool_response;
378
+ const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
379
+ if (!a.ok)
380
+ record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
381
+ else if (a.note)
382
+ record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
383
+ }
384
+ if (!opts.inbox)
385
+ return {};
283
386
  // A sub-agent's call: the message is for the step's own
284
387
  // model, and draining it here would hand it to a context
285
388
  // that ends with the delegation — and lose it.
@@ -511,12 +614,25 @@ async function executeStepInner(opts, runQuery) {
511
614
  ...(block.is_error ? { err: true } : {}),
512
615
  ...(open.subagent ? { subagent: open.subagent } : {}),
513
616
  });
617
+ // Why it failed, on the trail. The span above carried only `err`,
618
+ // so a refused call left the agent's own guess as the only account
619
+ // of it: backlink-desk's outreach_reset "denied by the permission
620
+ // classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
621
+ if (block.is_error) {
622
+ const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
623
+ if (why)
624
+ emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
625
+ }
514
626
  readPod(block.tool_use_id);
515
627
  }
516
628
  }
517
629
  }
518
630
  else if (message.type === "result") {
519
631
  status = message.subtype === "success" ? "completed" : "failed";
632
+ // Calls the SDK itself refused, which canUseTool never saw.
633
+ for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
634
+ emit("error", `refused before it ran: ${String(d.tool_name ?? "a tool")}`);
635
+ }
520
636
  if (message.subtype === "error_max_turns") {
521
637
  emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
522
638
  }
@@ -594,6 +710,21 @@ async function executeStepInner(opts, runQuery) {
594
710
  status = "failed";
595
711
  emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
596
712
  }
713
+ // step.end hooks watch the reply, and may rewrite it. One that fails is
714
+ // recorded and the reply stands: the work it describes is done.
715
+ if (status === "completed") {
716
+ for (const hook of hooks.filter((h) => h.on === "step.end")) {
717
+ const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
718
+ if (!a.ok)
719
+ record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
720
+ else if (a.reply !== undefined) {
721
+ texts.splice(0, texts.length, a.reply);
722
+ record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
723
+ }
724
+ else if (a.note)
725
+ record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
726
+ }
727
+ }
597
728
  const result = texts.join("\n").trim() || null;
598
729
  // A step's reply is not one message: the model narrates ("Now let me read
599
730
  // the outputs…"), calls tools, and answers last. Joining those and reading
@@ -801,7 +932,13 @@ export async function checkVerify(agentDir, verify, ctx) {
801
932
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
802
933
  }
803
934
  case "judge": {
804
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
935
+ // The reply is the final turn, as everywhere else; every turn goes
936
+ // beside it as the work, so a claim about what the step did can still
937
+ // be checked. Both as one <reply> failed `judge: the reply opens with
938
+ // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
939
+ // text opened with the narration before it.
940
+ const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
941
+ const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
805
942
  const ok = /^\s*PASS\b/i.test(verdict);
806
943
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
807
944
  }
@@ -813,7 +950,18 @@ export async function checkVerify(agentDir, verify, ctx) {
813
950
  * Same shape as the eval judge, and answered with one word first so the
814
951
  * verdict is a prefix test rather than a reading.
815
952
  */
816
- async function judgeReply(rubric, output, env, signal) {
953
+ export function judgePrompt(rubric, reply, work) {
954
+ const shown = reply.slice(0, 20_000);
955
+ const context = work.trim() && work.trim() !== reply.trim()
956
+ ? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
957
+ `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
958
+ : "";
959
+ return (`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
960
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
961
+ context +
962
+ `<reply>\n${shown}\n</reply>`);
963
+ }
964
+ async function judgeReply(rubric, reply, work, env, signal) {
817
965
  const texts = [];
818
966
  const abortController = new AbortController();
819
967
  if (signal?.aborted)
@@ -821,9 +969,7 @@ async function judgeReply(rubric, output, env, signal) {
821
969
  signal?.addEventListener("abort", () => abortController.abort(), { once: true });
822
970
  try {
823
971
  const q = query({
824
- prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
825
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
826
- `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
972
+ prompt: judgePrompt(rubric, reply, work),
827
973
  options: {
828
974
  model: "haiku",
829
975
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
@@ -914,3 +1060,15 @@ function runVerify(agentDir, command, env, data, signal) {
914
1060
  export function sdkEnv(base, extra = {}) {
915
1061
  return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
916
1062
  }
1063
+ /** The text of a tool result, whichever shape it came in: a string, or a
1064
+ * list of content blocks of which only the text ones say anything. */
1065
+ export function toolResultText(content) {
1066
+ if (typeof content === "string")
1067
+ return content;
1068
+ if (!Array.isArray(content))
1069
+ return "";
1070
+ return content
1071
+ .filter((b) => !!b && b.type === "text" && typeof b.text === "string")
1072
+ .map((b) => b.text)
1073
+ .join(" ");
1074
+ }
@@ -128,6 +128,10 @@ export declare function uploadUrl(tenant: string, workspace: string, rel: string
128
128
  * is worth writing when a workspace actually outgrows its quota, not before.
129
129
  */
130
130
  export declare function materializeFiles(tenant: string, workspace: string): Promise<string[]>;
131
+ /** path → sha of every stored file: what `storage/` holds once
132
+ * materializeFiles has run. A run keeps it to tell its own writes from the
133
+ * files it was handed. */
134
+ export declare function storageBaseline(tenant: string, workspace: string): Record<string, string>;
131
135
  /**
132
136
  * Take what the run left in `storage/` back into the store, stamped with the
133
137
  * run that wrote it.
@@ -136,7 +140,9 @@ export declare function materializeFiles(tenant: string, workspace: string): Pro
136
140
  * derived from the index, so anything on disk that the index does not already
137
141
  * describe with the same size and hash is, by construction, new work.
138
142
  */
139
- export declare function harvestFiles(tenant: string, workspace: string, by: string): Promise<{
143
+ export declare function harvestFiles(tenant: string, workspace: string, by: string, opts?: {
144
+ changedFrom?: Record<string, string>;
145
+ }): Promise<{
140
146
  saved: string[];
141
147
  errors: string[];
142
148
  }>;
@@ -686,6 +686,14 @@ export async function materializeFiles(tenant, workspace) {
686
686
  fs.mkdirSync(root, { recursive: true });
687
687
  return brought;
688
688
  }
689
+ /** path → sha of every stored file: what `storage/` holds once
690
+ * materializeFiles has run. A run keeps it to tell its own writes from the
691
+ * files it was handed. */
692
+ export function storageBaseline(tenant, workspace) {
693
+ if (!fileStoreEnabled())
694
+ return {};
695
+ return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
696
+ }
689
697
  /**
690
698
  * Take what the run left in `storage/` back into the store, stamped with the
691
699
  * run that wrote it.
@@ -694,7 +702,7 @@ export async function materializeFiles(tenant, workspace) {
694
702
  * derived from the index, so anything on disk that the index does not already
695
703
  * describe with the same size and hash is, by construction, new work.
696
704
  */
697
- export async function harvestFiles(tenant, workspace, by) {
705
+ export async function harvestFiles(tenant, workspace, by, opts = {}) {
698
706
  if (!fileStoreEnabled())
699
707
  return { saved: [], errors: [] };
700
708
  adoptLegacyFilesDir(workspaceDir(tenant, workspace));
@@ -729,6 +737,11 @@ export async function harvestFiles(tenant, workspace, by) {
729
737
  if (sha256(fs.readFileSync(abs)) === previous.sha)
730
738
  continue;
731
739
  }
740
+ // Only what the run itself changed: a file still holding the bytes it
741
+ // was handed is not this run's work, and the index may have moved on
742
+ // since (an upload while the run was going) — that must win.
743
+ if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs)))
744
+ continue;
732
745
  await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
733
746
  saved.push(norm);
734
747
  }
@@ -20,7 +20,7 @@ import { type WireFormat, type AuthShape } from "./providers.ts";
20
20
  * core needs to know about which one it's in.
21
21
  */
22
22
  export { singleWorkspace };
23
- export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "state"];
23
+ export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "hooks", "state"];
24
24
  export declare const STORAGE_DIR = "storage";
25
25
  /** What this directory was called before. Read-only: nothing writes here any
26
26
  * more, but installs created before the rename have bytes under it, and a
@@ -188,6 +188,15 @@ export declare function fencedCodeBlock(body: string): {
188
188
  * failure costs something that cannot be taken back. See `lintFlow`.
189
189
  */
190
190
  export declare function toolIsOutward(data: Record<string, unknown>): boolean;
191
+ /** How much of a tool.md body rides to the model with the tool. */
192
+ export declare const TOOL_GUIDE_MAX = 6000;
193
+ /**
194
+ * The prose under a tool.md's frontmatter: how to call it, what it refuses,
195
+ * what its output means. Only `description:` used to reach the model, so
196
+ * agents invented what the body documented (5 Oct 2026). The fenced program
197
+ * of a single-file tool is code, not guidance, and stays out.
198
+ */
199
+ export declare function toolGuide(body: string | undefined, inlineProgram?: boolean): string | undefined;
191
200
  export declare function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null;
192
201
  /**
193
202
  * Tools in a directory, in both shapes.
@@ -234,6 +243,8 @@ export interface ApiSpec {
234
243
  name: string;
235
244
  base: string;
236
245
  description: string;
246
+ /** The tool.md body, for the model (see toolGuide). */
247
+ guide?: string;
237
248
  headers: Record<string, string>;
238
249
  query: Record<string, string>;
239
250
  methods: string[];
@@ -846,6 +857,9 @@ export declare function setWorkspaceDescription(tenant: string, workspace: strin
846
857
  * this copy still said `type: Agent` after the CLI's had moved to `kind:`,
847
858
  * so every workspace made from the dashboard was born in the old format.
848
859
  */
860
+ /** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
861
+ * minus the laptop-only files, for the same reason as templateFiles. */
862
+ export declare function blankTemplateFiles(workspace: string): DeployFile[];
849
863
  export declare function templateFiles(workspace: string): DeployFile[];
850
864
  export declare const AGENT_TEMPLATE: (name: string) => string;
851
865
  export declare const FLOW_TEMPLATE: (name: string, firstAgent: string) => string;
@@ -879,6 +893,16 @@ export interface RunEvent {
879
893
  * detail), `output: json`, a schema. What the runner reads to decline a
880
894
  * retry of an outward step whose tools already ran. */
881
895
  check?: boolean;
896
+ /** Set on the line a hook wrote — a deny, a rewrite, context, a failure
897
+ * (hooks.ts). The run page and the Hooks page read these. */
898
+ hook?: {
899
+ hook: string;
900
+ event: string;
901
+ did: string;
902
+ tool?: string;
903
+ reason?: string;
904
+ ms: number;
905
+ };
882
906
  }
883
907
  /** A question an agent asked a person while its step ran (tools: [ask]). */
884
908
  export interface StepQuestion {
@@ -1341,6 +1365,12 @@ export declare function runCost(run: RunRecord): number;
1341
1365
  * fan-out template is a row in the trace rather than work — billing any of
1342
1366
  * them would charge for steps the customer can see never executed, which is
1343
1367
  * the fastest way to lose an argument about an invoice.
1368
+ *
1369
+ * Tokens count only for steps that ran on the platform's own model
1370
+ * credential. A step on the account's own key (`credential` "provider: …" —
1371
+ * a provider: block, or Settings → Model) was paid for at the provider, and
1372
+ * billing it again here charged a BYOK customer twice. And a carried step's
1373
+ * tokens were billed where it ran, like its compute.
1344
1374
  */
1345
1375
  export declare function runMeter(run: RunRecord): {
1346
1376
  tokenCostUsd: number;
package/dist/src/store.js CHANGED
@@ -19,6 +19,7 @@
19
19
  // or an agent, and belongs in git.
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
+ import { expandPlatformVars } from "./host-env.js";
22
23
  import fs from "node:fs";
23
24
  import path from "node:path";
24
25
  import { recordRevision, registerTreeReader } from "./history.js";
@@ -50,7 +51,7 @@ import { localeProblems } from "./locale.js";
50
51
  import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
51
52
  import { readTransport, KINDS } from "./kinds.js";
52
53
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
53
- import { starterFiles, accountFiles } from "./starter.js";
54
+ import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.js";
54
55
  import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
55
56
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
56
57
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
@@ -73,6 +74,7 @@ export const WORKSPACE_DIRS = [
73
74
  "knowledge",
74
75
  "tools",
75
76
  "scripts",
77
+ "hooks",
76
78
  "state",
77
79
  ];
78
80
  const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
@@ -288,6 +290,18 @@ export function assertCanonicalCase(rel) {
288
290
  // trigger-log.jsonl: the scheduler's record of why a flow did or did not
289
291
  // fire (trigger-log.ts). Before it was listed here every deploy erased it.
290
292
  export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
293
+ // The agent engine's own home files. Each step's sandbox runs the Claude
294
+ // Agent SDK — the engine, whatever model answers — with its home in the
295
+ // agent's folder, and the engine writes its settings (.claude.json, its
296
+ // backups), plugin caches and the full transcript of every session
297
+ // (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
298
+ // back after every step, they had piled up to 5,274 files and 19 MB in one
299
+ // agent's folder on the box by 6 Oct 2026, carried every past conversation
300
+ // into every sandbox, and their clashing rewrites were the "write-back
301
+ // conflict on .claude.json" lines (and the big merges) of that day. A
302
+ // workspace's own .claude/agents/*.md — Claude Code subagents an author
303
+ // writes, imported as agents — sits at the workspace root and is untouched.
304
+ const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
291
305
  /** True for anything the platform, not the author or the agent, writes. */
292
306
  export function isPlatformPath(rel) {
293
307
  const norm = rel.replaceAll("\\", "/");
@@ -299,6 +313,8 @@ export function isPlatformPath(rel) {
299
313
  return true;
300
314
  if (norm === "trigger-log.jsonl.tmp")
301
315
  return true;
316
+ if (ENGINE_HOME_RE.test(norm))
317
+ return true;
302
318
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
303
319
  }
304
320
  /** What a deploy keeps whatever it ships — the rule above saveWorkspace's
@@ -547,6 +563,28 @@ export function fencedCodeBlock(body) {
547
563
  export function toolIsOutward(data) {
548
564
  return data.outward === true;
549
565
  }
566
+ /** How much of a tool.md body rides to the model with the tool. */
567
+ export const TOOL_GUIDE_MAX = 6000;
568
+ /**
569
+ * The prose under a tool.md's frontmatter: how to call it, what it refuses,
570
+ * what its output means. Only `description:` used to reach the model, so
571
+ * agents invented what the body documented (5 Oct 2026). The fenced program
572
+ * of a single-file tool is code, not guidance, and stays out.
573
+ */
574
+ export function toolGuide(body, inlineProgram = false) {
575
+ if (!body)
576
+ return undefined;
577
+ let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
578
+ // Only a single-file tool's program is cut; in a `run:` tool every fenced
579
+ // block is an example of a call, which is exactly what the model needs.
580
+ const inline = inlineProgram ? fencedCodeBlock(text) : null;
581
+ if (inline)
582
+ text = text.slice(0, inline.start) + text.slice(inline.end);
583
+ text = text.trim();
584
+ if (!text)
585
+ return undefined;
586
+ return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
587
+ }
550
588
  export function parseToolDef(data, fallbackName, body) {
551
589
  const name = typeof data.name === "string" ? data.name : fallbackName;
552
590
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -580,18 +618,28 @@ export function parseToolDef(data, fallbackName, body) {
580
618
  };
581
619
  }
582
620
  if (kind === "script") {
621
+ // `guide` is the body's, never the frontmatter's: an author's `guide:`
622
+ // would otherwise ride to the model in place of the manual.
623
+ if ("guide" in data) {
624
+ data = { ...data };
625
+ delete data.guide;
626
+ }
583
627
  // The program is a run: path, or the file's own fenced code block — the
584
628
  // single-file form, which is what lets a script tool read and edit like
585
629
  // every other markdown document.
586
- if (typeof data.run === "string")
587
- return { kind: "script", name, spec: { ...data, name } };
630
+ if (typeof data.run === "string") {
631
+ const guide = toolGuide(body);
632
+ return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
633
+ }
588
634
  const inline = body ? fencedCode(body) : null;
589
635
  if (!inline)
590
636
  return null;
591
- return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext } };
637
+ const guide = toolGuide(body, true);
638
+ return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
592
639
  }
593
640
  const [spec] = parseApis([{ ...data, name }]);
594
- return spec ? { kind: "http", name, spec } : null;
641
+ const guide = toolGuide(body);
642
+ return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
595
643
  }
596
644
  /**
597
645
  * Tools in a directory, in both shapes.
@@ -955,7 +1003,9 @@ export function parseApis(raw) {
955
1003
  out.push({
956
1004
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
957
1005
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
958
- base: trimSlashes(base),
1006
+ // `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
1007
+ // addresses, filled here on the host so a desk never names one.
1008
+ base: trimSlashes(expandPlatformVars(base)),
959
1009
  description: typeof e.description === "string" ? e.description : "",
960
1010
  headers: asRecordLocal(e.headers),
961
1011
  query: asRecordLocal(e.query),
@@ -2430,6 +2480,11 @@ export function setWorkspaceDescription(tenant, workspace, description) {
2430
2480
  * this copy still said `type: Agent` after the CLI's had moved to `kind:`,
2431
2481
  * so every workspace made from the dashboard was born in the old format.
2432
2482
  */
2483
+ /** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
2484
+ * minus the laptop-only files, for the same reason as templateFiles. */
2485
+ export function blankTemplateFiles(workspace) {
2486
+ return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
2487
+ }
2433
2488
  export function templateFiles(workspace) {
2434
2489
  // The starter minus what only matters on a laptop's disk: .gitignore
2435
2490
  // guards a local clone's secrets, but the hosted store never keeps
@@ -2579,7 +2634,25 @@ export function runOutputPath(tenant, workspace, runId, agent, rel) {
2579
2634
  }
2580
2635
  export function readRun(tenant, workspace, runId) {
2581
2636
  const p = runFilePath(tenant, workspace, runId);
2582
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
2637
+ // EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
2638
+ // here can land on the just-unlinked inode and come back ESTALE (errno
2639
+ // -116, surfaced by Node as "Unknown system error -116", syscall read).
2640
+ // That isn't corruption — the next open() by path sees the committed new
2641
+ // inode — so re-read a few times on ESTALE and rethrow anything else.
2642
+ // (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
2643
+ // from waitForRun's poll while the run itself completed fine.)
2644
+ const attempts = 5;
2645
+ for (let i = 1;; i++) {
2646
+ try {
2647
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
2648
+ }
2649
+ catch (e) {
2650
+ const err = e;
2651
+ if ((err.code === "ESTALE" || err.errno === -116) && i < attempts)
2652
+ continue;
2653
+ throw e;
2654
+ }
2655
+ }
2583
2656
  }
2584
2657
  export function writeRun(tenant, workspace, run) {
2585
2658
  const p = runFilePath(tenant, workspace, run.id);
@@ -2825,12 +2898,19 @@ export function runCost(run) {
2825
2898
  * fan-out template is a row in the trace rather than work — billing any of
2826
2899
  * them would charge for steps the customer can see never executed, which is
2827
2900
  * the fastest way to lose an argument about an invoice.
2901
+ *
2902
+ * Tokens count only for steps that ran on the platform's own model
2903
+ * credential. A step on the account's own key (`credential` "provider: …" —
2904
+ * a provider: block, or Settings → Model) was paid for at the provider, and
2905
+ * billing it again here charged a BYOK customer twice. And a carried step's
2906
+ * tokens were billed where it ran, like its compute.
2828
2907
  */
2829
2908
  export function runMeter(run) {
2830
2909
  let steps = 0;
2831
2910
  let computeSecs = 0;
2832
2911
  let smallSecs = 0;
2833
2912
  let netBytes = 0;
2913
+ let tokenCostUsd = 0;
2834
2914
  for (const s of run.steps) {
2835
2915
  if (s.status !== "completed" && s.status !== "failed")
2836
2916
  continue;
@@ -2838,13 +2918,15 @@ export function runMeter(run) {
2838
2918
  if (s.carriedFrom)
2839
2919
  continue;
2840
2920
  steps += 1;
2921
+ if (!(s.credential ?? "").startsWith("provider:"))
2922
+ tokenCostUsd += s.costUsd ?? 0;
2841
2923
  const secs = s.computeSecs ?? 0;
2842
2924
  computeSecs += secs;
2843
2925
  if (s.size === "small")
2844
2926
  smallSecs += secs;
2845
2927
  netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
2846
2928
  }
2847
- return { tokenCostUsd: runCost(run), steps, computeSecs, smallSecs, netBytes };
2929
+ return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
2848
2930
  }
2849
2931
  /**
2850
2932
  * The compute meter for hold+work pricing: core-seconds and GiB-seconds
@@ -12,7 +12,9 @@ export declare const RETIRED_TOOLS: Record<string, string>;
12
12
  * and a model provider's own search (`web: {search: zai}`) is the runtime's
13
13
  * to swap in, not the author's to name. */
14
14
  export declare const BUILTIN_TOOLS: Set<string>;
15
- /** The platform's own groups, served in-process rather than by the SDK. */
15
+ /** The platform's own groups, served in-process rather than by the SDK.
16
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
17
+ * accepted so a file that says so is not flagged. */
16
18
  export declare const PLATFORM_GROUPS: Set<string>;
17
19
  /** Would this name resolve to something the runtime provides? Built-ins win a
18
20
  * clash, so a tool file of the same name is shadowed rather than granted. */
@@ -44,8 +44,10 @@ export const BUILTIN_TOOLS = new Set([
44
44
  "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
45
45
  "NotebookEdit", "TodoWrite",
46
46
  ]);
47
- /** The platform's own groups, served in-process rather than by the SDK. */
48
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
47
+ /** The platform's own groups, served in-process rather than by the SDK.
48
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
49
+ * accepted so a file that says so is not flagged. */
50
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
49
51
  /** Would this name resolve to something the runtime provides? Built-ins win a
50
52
  * clash, so a tool file of the same name is shadowed rather than granted. */
51
53
  export function isRuntimeTool(name) {
@@ -229,7 +229,14 @@ export async function testTool(tenant, workspace, def, input = {}) {
229
229
  detail: `looked for ${file}\n\nThis is the commonest way to get a tool that silently never loads.`,
230
230
  });
231
231
  }
232
- const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
232
+ // The tool's own secrets, and the calling agent's `secrets:` — a run
233
+ // hands a script both. Testing gbp_read (whose agent, not the tool,
234
+ // declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
235
+ // same call worked (9 Oct 2026). Only an agent that grants the tool: the
236
+ // agents[0] stand-in above is there for its depth, not its keys.
237
+ const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
238
+ const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
239
+ const names = [...new Set([...own, ...granting])];
233
240
  const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
234
241
  const live = await liveSecrets(stored, { tenant, workspace });
235
242
  if ("error" in live) {
@@ -0,0 +1,17 @@
1
+ export interface ZipEntry {
2
+ path: string;
3
+ data: Buffer;
4
+ }
5
+ export declare class ZipError extends Error {
6
+ }
7
+ export interface UnzipLimits {
8
+ /** Most entries the archive may hold. */
9
+ maxEntries?: number;
10
+ /** Largest one file, uncompressed. */
11
+ maxFileBytes?: number;
12
+ /** All files together, uncompressed. */
13
+ maxTotalBytes?: number;
14
+ }
15
+ export declare function crc32(buf: Buffer): number;
16
+ export declare function zip(entries: ZipEntry[], now?: Date): Buffer;
17
+ export declare function unzip(buf: Buffer, limits?: UnzipLimits): ZipEntry[];