@foldrun/core 0.4.0 → 0.5.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 (155) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/account.d.ts +45 -0
  4. package/dist/src/account.js +130 -0
  5. package/dist/src/api-tools.d.ts +8 -0
  6. package/dist/src/api-tools.js +22 -4
  7. package/dist/src/browser-pod.d.ts +129 -0
  8. package/dist/src/browser-pod.js +284 -0
  9. package/dist/src/catalog.js +2 -1
  10. package/dist/src/clock.d.ts +49 -0
  11. package/dist/src/clock.js +121 -0
  12. package/dist/src/completions.js +17 -8
  13. package/dist/src/confine.d.ts +22 -0
  14. package/dist/src/confine.js +218 -3
  15. package/dist/src/deploy.d.ts +33 -0
  16. package/dist/src/deploy.js +145 -6
  17. package/dist/src/egress.d.ts +7 -0
  18. package/dist/src/egress.js +6 -0
  19. package/dist/src/eol.d.ts +2 -0
  20. package/dist/src/eol.js +7 -0
  21. package/dist/src/evals.d.ts +5 -0
  22. package/dist/src/evals.js +6 -2
  23. package/dist/src/flow-lint.d.ts +8 -0
  24. package/dist/src/flow-lint.js +223 -1
  25. package/dist/src/flow-patterns.d.ts +141 -0
  26. package/dist/src/flow-patterns.js +568 -0
  27. package/dist/src/gitrepo.d.ts +18 -0
  28. package/dist/src/gitrepo.js +49 -0
  29. package/dist/src/kinds.js +1 -1
  30. package/dist/src/language.d.ts +17 -0
  31. package/dist/src/language.js +55 -0
  32. package/dist/src/layout.d.ts +69 -0
  33. package/dist/src/layout.js +200 -0
  34. package/dist/src/library.d.ts +9 -0
  35. package/dist/src/library.js +30 -3
  36. package/dist/src/limits.d.ts +84 -0
  37. package/dist/src/limits.js +287 -0
  38. package/dist/src/locale.d.ts +47 -0
  39. package/dist/src/locale.js +177 -0
  40. package/dist/src/mail.d.ts +88 -0
  41. package/dist/src/mail.js +245 -0
  42. package/dist/src/model-probe.d.ts +3 -0
  43. package/dist/src/model-probe.js +1 -0
  44. package/dist/src/notify.d.ts +53 -7
  45. package/dist/src/notify.js +194 -126
  46. package/dist/src/openapi.js +5 -3
  47. package/dist/src/operator.d.ts +201 -0
  48. package/dist/src/operator.js +251 -0
  49. package/dist/src/paths.d.ts +28 -0
  50. package/dist/src/paths.js +55 -0
  51. package/dist/src/platform.d.ts +43 -3
  52. package/dist/src/platform.js +9 -1
  53. package/dist/src/provider-health.js +3 -2
  54. package/dist/src/providers.d.ts +481 -0
  55. package/dist/src/providers.js +1105 -6
  56. package/dist/src/refusal.d.ts +83 -0
  57. package/dist/src/refusal.js +281 -0
  58. package/dist/src/run-container.d.ts +149 -13
  59. package/dist/src/run-container.js +403 -42
  60. package/dist/src/runner.d.ts +142 -9
  61. package/dist/src/runner.js +1027 -122
  62. package/dist/src/runtime.d.ts +52 -0
  63. package/dist/src/runtime.js +493 -58
  64. package/dist/src/script-tools.d.ts +3 -2
  65. package/dist/src/script-tools.js +35 -3
  66. package/dist/src/secret-files.d.ts +6 -5
  67. package/dist/src/secret-files.js +24 -11
  68. package/dist/src/secret-health.d.ts +30 -6
  69. package/dist/src/secret-health.js +42 -20
  70. package/dist/src/secrets.d.ts +19 -0
  71. package/dist/src/secrets.js +155 -86
  72. package/dist/src/starter.d.ts +9 -0
  73. package/dist/src/starter.js +92 -11
  74. package/dist/src/step-exec.d.ts +61 -2
  75. package/dist/src/step-exec.js +414 -36
  76. package/dist/src/step-removal.d.ts +15 -0
  77. package/dist/src/step-removal.js +49 -0
  78. package/dist/src/storage.d.ts +23 -4
  79. package/dist/src/storage.js +125 -23
  80. package/dist/src/store.d.ts +218 -37
  81. package/dist/src/store.js +347 -60
  82. package/dist/src/subagents.d.ts +75 -0
  83. package/dist/src/subagents.js +149 -0
  84. package/dist/src/tool-names.d.ts +13 -1
  85. package/dist/src/tool-names.js +38 -11
  86. package/dist/src/tool-programs.d.ts +26 -0
  87. package/dist/src/tool-programs.js +172 -0
  88. package/dist/src/tool-test.js +104 -12
  89. package/dist/src/translator.d.ts +8 -2
  90. package/dist/src/translator.js +69 -23
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/triggers.d.ts +3 -0
  93. package/dist/src/triggers.js +10 -2
  94. package/dist/src/untrusted-fetch.d.ts +11 -0
  95. package/dist/src/untrusted-fetch.js +77 -0
  96. package/dist/src/web-actions.d.ts +91 -0
  97. package/dist/src/web-actions.js +199 -0
  98. package/dist/src/webhook.d.ts +4 -0
  99. package/dist/src/webhook.js +39 -0
  100. package/index.ts +3 -0
  101. package/package.json +3 -3
  102. package/src/account.ts +149 -0
  103. package/src/api-tools.ts +32 -4
  104. package/src/browser-pod.ts +313 -0
  105. package/src/catalog.ts +2 -1
  106. package/src/clock.ts +149 -0
  107. package/src/completions.ts +17 -8
  108. package/src/confine.ts +218 -3
  109. package/src/deploy.ts +137 -6
  110. package/src/egress.ts +13 -1
  111. package/src/eol.ts +7 -0
  112. package/src/evals.ts +11 -2
  113. package/src/flow-lint.ts +253 -1
  114. package/src/flow-patterns.ts +597 -0
  115. package/src/gitrepo.ts +48 -0
  116. package/src/kinds.ts +1 -1
  117. package/src/language.ts +62 -0
  118. package/src/layout.ts +228 -0
  119. package/src/library.ts +37 -3
  120. package/src/limits.ts +290 -0
  121. package/src/locale.ts +217 -0
  122. package/src/mail.ts +316 -0
  123. package/src/model-probe.ts +4 -0
  124. package/src/notify.ts +189 -103
  125. package/src/openapi.ts +5 -3
  126. package/src/operator.ts +300 -0
  127. package/src/paths.ts +54 -0
  128. package/src/platform.ts +52 -3
  129. package/src/provider-health.ts +3 -2
  130. package/src/providers.ts +1368 -6
  131. package/src/refusal.ts +320 -0
  132. package/src/run-container.ts +439 -43
  133. package/src/runner.ts +1050 -122
  134. package/src/runtime.ts +487 -55
  135. package/src/script-tools.ts +38 -5
  136. package/src/secret-files.ts +22 -10
  137. package/src/secret-health.ts +64 -19
  138. package/src/secrets.ts +149 -87
  139. package/src/starter.ts +93 -11
  140. package/src/step-exec.ts +429 -37
  141. package/src/step-removal.ts +59 -0
  142. package/src/storage.ts +141 -22
  143. package/src/store.ts +500 -68
  144. package/src/subagents.ts +189 -0
  145. package/src/tool-names.ts +40 -11
  146. package/src/tool-programs.ts +179 -0
  147. package/src/tool-test.ts +110 -12
  148. package/src/translator.ts +72 -22
  149. package/src/trigger-log.ts +1 -0
  150. package/src/triggers.ts +10 -2
  151. package/src/untrusted-fetch.ts +88 -0
  152. package/src/web-actions.ts +237 -0
  153. package/src/webhook.ts +41 -0
  154. package/templates/hello/agents/curator/agent.md +1 -1
  155. package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/step-exec.ts CHANGED
@@ -9,15 +9,20 @@
9
9
  // library and the account do not exist.
10
10
 
11
11
  import fs from "node:fs";
12
+ import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { query } from "@anthropic-ai/claude-agent-sdk";
14
- import type { McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
15
+ import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
15
16
  import type { Effort } from "./store.ts";
16
17
  import type { TestEffect } from "./test-mode.ts";
18
+ import type { OperatorEvent } from "./operator.ts";
17
19
  import { spawn } from "node:child_process";
18
- import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
20
+ import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.ts";
21
+ import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions, type SubagentSpec } from "./subagents.ts";
19
22
  import { hostSafeEnv } from "./host-env.ts";
20
23
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
24
+ import { CallCounter, limitKeysFor, type Limits } from "./limits.ts";
25
+ import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
21
26
 
22
27
  export interface ExecOutcome {
23
28
  status: "completed" | "failed";
@@ -35,6 +40,20 @@ export interface ExecOutcome {
35
40
  * Anthropic's table, which is wrong for a routed model — these are the
36
41
  * raw numbers a caller with a gateway's own prices can reprice from. */
37
42
  usage: { inputTokens: number; outputTokens: number } | null;
43
+ /** Set only when some turns ran on a different model from the step's — a
44
+ * sub-agent with its own `model:`. The sum of every turn, each priced at
45
+ * its own model. `usage` above is one lump and cannot be repriced at the
46
+ * step's model without charging the sub-agent's turns at the wrong rate;
47
+ * the runner reads this instead (repriced in runner.ts). */
48
+ turnsCostUsd?: number;
49
+ /** Why the model's last turn ended, as the SDK's result message says:
50
+ * end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal or
51
+ * model_context_window_exceeded. Null when no result arrived. */
52
+ stopReason?: string | null;
53
+ /** Set when the step browsed through the account's browser pod from the
54
+ * slim image (ExecOptions.browserPod): its reconnects, and — when the pod
55
+ * was lost — why, and what the step had written by then. */
56
+ browserPod?: BrowserPodOutcome;
38
57
  }
39
58
 
40
59
  export interface ExecOptions {
@@ -90,11 +109,45 @@ export interface ExecOptions {
90
109
  * runner answers from the run record (stopRun writes there from another
91
110
  * process); a container has a sandbox that is destroyed instead. */
92
111
  stopRequested?: () => boolean;
112
+ /** Reads the run's inbox (operator.ts inboxReader): what a person wrote
113
+ * in while the step runs, handed to the model after its next tool call.
114
+ * Absent where nobody can write in (no egress proxy). */
115
+ inbox?: () => Promise<string | null>;
116
+ /** `subagents:` — colleagues the model may delegate to, each with its own
117
+ * context and tools no wider than this step's (subagents.ts). Absent or
118
+ * empty: no Agent tool, as before. */
119
+ subagents?: SubagentSpec[];
120
+ /** `limits:` — the most calls this step may make, per tool and in all,
121
+ * resolved host-side (account, workspace, agent, step: nearest wins per
122
+ * key). Counted in the PreToolUse hook below; see limits.ts. */
123
+ limits?: Limits;
124
+ /** SDK tool name → the foldrun name it counts under, for the tools whose
125
+ * SDK name does not say (limits.ts toolOwners). */
126
+ toolOwners?: Record<string, string>;
127
+ /** The step runs on the slim image and browses through the account's
128
+ * browser pod (browser-pod.ts). `events` is the file the web tool writes
129
+ * its reconnects to; the classify context says which API operations and
130
+ * web actions are writes. A lost pod stops the step, and the outcome
131
+ * says what it had written, so the runner can re-run it or fail it. */
132
+ browserPod?: { events: string } & ClassifyContext;
93
133
  emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
94
134
  }
95
135
 
96
136
  /** How often the stop flag is read while a step runs. */
97
137
  export const STOP_POLL_MS = 2000;
138
+ /** The least a `verify:` gets when the step used up most of its `timeout:` —
139
+ * capped by the timeout itself, so the step and its check together never
140
+ * run past timeout + this. Under the isolated path's backstop (+60 s). */
141
+ export const VERIFY_FLOOR_MS = 30_000;
142
+
143
+ /** How long a `verify:` may run: what is left of the step's `timeout:`,
144
+ * with a floor. Null when the flow set no timeout — `timeout:` is the only
145
+ * clock there is, and a stop still ends the check. */
146
+ export function verifyBudgetMs(timeoutSec: number | undefined, elapsedMs: number): number | null {
147
+ if (!timeoutSec || timeoutSec <= 0) return null;
148
+ const total = timeoutSec * 1000;
149
+ return Math.max(total - elapsedMs, Math.min(VERIFY_FLOOR_MS, total));
150
+ }
98
151
  /** After an interrupt is asked for, how long the model loop gets to wind
99
152
  * down on its own before the query is aborted outright. */
100
153
  export const INTERRUPT_GRACE_MS = 10_000;
@@ -105,7 +158,13 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
105
158
  export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
106
159
 
107
160
  /** The pairing fields on a tool event — see RunEvent in store.ts. */
108
- export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect };
161
+ export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
162
+
163
+ /** The tag on every event a failed check writes — `verify:`, `output: json`,
164
+ * a schema — headline and detail alike. The runner's no-retry rule for
165
+ * outward steps reads it (actedThenFailedCheck); reading the wording of the
166
+ * last error missed a check whose detail line came after its headline. */
167
+ const CHECK = { check: true } as const;
109
168
 
110
169
 
111
170
  /** Conservative per-token rates for a model nothing else can price — an
@@ -179,7 +238,11 @@ export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: numb
179
238
  *
180
239
  * Two caps can apply to one step: the flow's `budget:` gives it an equal
181
240
  * share of what the run has left; the agent's own `budget:` is the most that
182
- * agent may spend in one run, less what its earlier steps in this run cost.
241
+ * agent may spend in one run, less what its steps in this run have cost or
242
+ * may still cost, shared across `sharing` — this step and the agent's steps
243
+ * launched with it that have not started yet. Without the share, twenty
244
+ * fan-out copies of one agent each got the whole remainder, because none of
245
+ * them has recorded a cent when the next one starts.
183
246
  * The tighter one wins, and the note says which, because "over budget" with
184
247
  * no line to go and raise is a message that sends someone to the wrong file.
185
248
  */
@@ -188,10 +251,11 @@ export function stepCeilingFor(
188
251
  agentBudgetUsd: number | null | undefined,
189
252
  agentSpentUsd: number,
190
253
  agent: string,
254
+ sharing = 1,
191
255
  ): { ceilingUsd: number | null; note: string } {
192
256
  const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
193
257
  if (!agentBudgetUsd || agentBudgetUsd <= 0) return flow;
194
- const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
258
+ const left = Math.max(0, agentBudgetUsd - agentSpentUsd) / Math.max(1, sharing);
195
259
  if (flowShareUsd !== null && flowShareUsd <= left) return flow;
196
260
  return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
197
261
  }
@@ -202,10 +266,30 @@ export async function executeStep(
202
266
  * without a model; production passes nothing and gets the SDK. */
203
267
  runQuery: QueryFn = query as unknown as QueryFn,
204
268
  ): Promise<ExecOutcome> {
269
+ // `workspace/…` for the shell, the scripts and a shell `verify:` — the
270
+ // file tools expand the prefix themselves. Held for the step, verify
271
+ // included, and gone before anyone reads the step's files back
272
+ // (confine.ts#linkWorkspace says why it is not permanent).
273
+ fs.mkdirSync(opts.agentDir, { recursive: true });
274
+ const link = linkWorkspace(opts.agentDir, opts.workspaceRoot);
275
+ if (link.note) opts.emit("info", `workspace/: ${link.note}`);
276
+ try {
277
+ // And a Node program started through it is still its own main module
278
+ // (confine.ts#workspaceLinkEnv) — for the model's Bash, which inherits
279
+ // the SDK's env; the shell verify: and the scripts get it where they
280
+ // spawn.
281
+ return await executeStepInner({ ...opts, env: workspaceLinkEnv(opts.env, opts.agentDir) }, runQuery);
282
+ } finally {
283
+ link.release();
284
+ }
285
+ }
286
+
287
+ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<ExecOutcome> {
205
288
  const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
206
289
  let status: "running" | "completed" | "failed" = "running";
207
290
  let costUsd: number | null = null;
208
291
  let usage: ExecOutcome["usage"] = null;
292
+ let stopReason: string | null = null;
209
293
  const texts: string[] = [];
210
294
 
211
295
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
@@ -215,6 +299,16 @@ export async function executeStep(
215
299
  // stop both ask the query to interrupt, and abort it outright if it has
216
300
  // not wound down within the grace period.
217
301
  const abort = new AbortController();
302
+ const subagents = opts.subagents?.length ? opts.subagents : null;
303
+ const guardSubagent = subagents ? subagentGuard(subagents) : null;
304
+ // A sub-agent's calls reach both the hook (which sees its id AND name) and
305
+ // canUseTool (which sees only the id). Remembered here so every refusal —
306
+ // its own list, a path outside the workspace, a shell command — names the
307
+ // agent that tried, not just the step.
308
+ const subagentNames = new Map<string, string>();
309
+ // `limits:` — this step's counts. A new step, a retry of one, or a
310
+ // fan-out instance each start from zero.
311
+ const counter = new CallCounter(opts.limits);
218
312
 
219
313
  const q = runQuery({
220
314
  prompt: opts.prompt,
@@ -228,6 +322,9 @@ export async function executeStep(
228
322
  ...(opts.effort ? { effort: opts.effort } : {}),
229
323
  systemPrompt: opts.systemPrompt,
230
324
  ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
325
+ // Sub-agents: the SDK's own delegation. Every definition carries an
326
+ // explicit tool list (an absent one inherits all of ours).
327
+ ...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
231
328
  // Restrict the toolset itself, not just approval: an agent that
232
329
  // declares no tools gets none, instead of seeing the full Claude
233
330
  // Code toolset and burning turns on denied calls.
@@ -254,13 +351,90 @@ export async function executeStep(
254
351
  autoAllowBashIfSandboxed: false,
255
352
  failIfUnavailable: false,
256
353
  },
257
- env: opts.env,
354
+ // With sub-agents, the SDK would also offer its built-in ones
355
+ // (general-purpose has every tool the session has). Only the declared
356
+ // ones may run; the guard in the hook below refuses the rest as well.
357
+ env: subagents ? { ...(opts.env ?? process.env), CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : opts.env,
258
358
  mcpServers: opts.mcpServers,
259
- canUseTool: async (toolName: string, input: Record<string, unknown>) => {
359
+ // canUseTool is only asked when the SDK wants permission, and it never
360
+ // asks for a read inside the cwd — so `Read workspace/storage/x` went
361
+ // to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
362
+ // 2026-09-24 that read a stray copy a broken write had left there,
363
+ // with no error. A PreToolUse hook runs on every call: expand the
364
+ // virtual prefix (and refuse an escape) here for every filesystem tool,
365
+ // and leave the rest of the decision to canUseTool.
366
+ hooks: {
367
+ PreToolUse: [{
368
+ hooks: [async (hookInput: HookInput) => {
369
+ if (hookInput.hook_event_name !== "PreToolUse") return {};
370
+ // A sub-agent's calls come through here too, tagged with its
371
+ // name; anything outside its own list — or from a sub-agent this
372
+ // step did not declare — is refused before the shared checks
373
+ // below (which apply to it unchanged). So is a delegation from
374
+ // the main thread to anything but a declared sub-agent.
375
+ const agentId = "agent_id" in hookInput && hookInput.agent_id ? String(hookInput.agent_id) : undefined;
376
+ const agentType = agentId ? (hookInput as { agent_type?: string }).agent_type : undefined;
377
+ const via = agentId ? (agentType ?? "subagent") : undefined;
378
+ if (agentId && via) subagentNames.set(agentId, via);
379
+ if (guardSubagent) {
380
+ const why = guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: (hookInput.tool_input ?? {}) as Record<string, unknown> });
381
+ if (why) {
382
+ emit("error", why, { subagent: via });
383
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
384
+ }
385
+ }
386
+ const verdict = isFilesystemTool(hookInput.tool_name)
387
+ ? checkPaths(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, { agentDir, workspaceRoot, libraryRoot })
388
+ : null;
389
+ if (verdict && !verdict.ok) {
390
+ emit("error", verdict.reason!, via ? { subagent: via } : undefined);
391
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: verdict.reason! } };
392
+ }
393
+ // `limits:` — last, so a call another check refused counts
394
+ // toward nothing. Every call passes here, the step's own and its
395
+ // sub-agents', so one count covers both. A call past a limit is
396
+ // refused before it runs: the tool, and any paid API behind it,
397
+ // never sees it.
398
+ if (counter.active) {
399
+ const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners));
400
+ if (refusal) {
401
+ emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
402
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
403
+ }
404
+ }
405
+ if (!verdict?.updatedInput) return {};
406
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
407
+ }],
408
+ }],
409
+ // A message a person sent into the running step reaches the model
410
+ // after its next tool call, as context — never as a change to what it
411
+ // may do. A model that is only writing, calling no tools, hears it at
412
+ // the next call it makes.
413
+ ...(opts.inbox
414
+ ? {
415
+ PostToolUse: [{
416
+ hooks: [async (hookInput: HookInput) => {
417
+ if (hookInput.hook_event_name !== "PostToolUse") return {};
418
+ // A sub-agent's call: the message is for the step's own
419
+ // model, and draining it here would hand it to a context
420
+ // that ends with the delegation — and lose it.
421
+ if ("agent_id" in hookInput && hookInput.agent_id) return {};
422
+ const context = await opts.inbox!().catch(() => null);
423
+ return context ? { hookSpecificOutput: { hookEventName: "PostToolUse" as const, additionalContext: context } } : {};
424
+ }],
425
+ }],
426
+ }
427
+ : {}),
428
+ },
429
+ canUseTool: async (toolName: string, input: Record<string, unknown>, options?: { agentID?: string }) => {
430
+ const via = options?.agentID ? (subagentNames.get(options.agentID) ?? "subagent") : undefined;
260
431
  // The toolset was already narrowed to what the agent declared, so
261
432
  // anything outside it is a denial with a reason the model can act on.
262
433
  const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
434
+ // A call the hook counted and this refuses never ran: give it back.
435
+ const refund = () => { if (counter.active) counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
263
436
  if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
437
+ refund();
264
438
  return {
265
439
  behavior: "deny" as const,
266
440
  message: `Tool ${toolName} is not enabled for this agent.`,
@@ -273,7 +447,8 @@ export async function executeStep(
273
447
  ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
274
448
  : { ok: true as const };
275
449
  if (!verdict.ok) {
276
- emit("error", verdict.reason!);
450
+ refund();
451
+ emit("error", verdict.reason!, via ? { subagent: via } : undefined);
277
452
  return { behavior: "deny" as const, message: verdict.reason! };
278
453
  }
279
454
  return { behavior: "allow" as const, updatedInput: verdict.updatedInput ?? input };
@@ -286,9 +461,10 @@ export async function executeStep(
286
461
  // call — a crawl, a build — sailed past its timeout: with no message there
287
462
  // was nothing to check it against. The backstop timer fires regardless,
288
463
  // and a stop is read on a clock rather than between groups.
289
- let ended: "timeout" | "stopped" | null = null;
464
+ let ended: "timeout" | "stopped" | "pod-lost" | null = null;
465
+ const startedAt = Date.now();
290
466
  const timers: NodeJS.Timeout[] = [];
291
- const endWith = (why: "timeout" | "stopped") => {
467
+ const endWith = (why: "timeout" | "stopped" | "pod-lost") => {
292
468
  if (ended) return;
293
469
  ended = why;
294
470
  void q.interrupt().catch(() => {});
@@ -316,31 +492,126 @@ export async function executeStep(
316
492
  // that is the honest direction to be wrong in.
317
493
  const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
318
494
  let spentUsd = 0;
495
+ // Every turn's usage, by the model message's id. The SDK can emit one
496
+ // turn as several assistant messages that repeat the same usage, so a
497
+ // turn is counted once — and the sum is the step's cost whenever the
498
+ // closing `result` never arrives: a timeout or a stop ends the loop before
499
+ // it, and a step that made 193 model calls was recorded as $0 (lawyer-desk,
500
+ // 2026-09-25 09:00).
501
+ // Each with the rate it is priced at: a sub-agent with its own `model:`
502
+ // runs its turns on that model, and pricing them at the parent's put a
503
+ // max sub-agent under a fast parent at a fifth of its cost — against the
504
+ // ceiling and on the bill.
505
+ const turns = new Map<string, { u: Record<string, number | undefined>; price: { input: number; output: number } | null }>();
506
+ let anonymousTurns = 0;
507
+ const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
508
+ const priceOf = (message: unknown): { input: number; output: number } | null => {
509
+ const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
510
+ if (!parent) return opts.price ?? null;
511
+ const declared = specByName.get(delegations.get(parent) ?? "")?.model;
512
+ if (declared === undefined || declared === "inherit" || declared === opts.model) return opts.price ?? null;
513
+ const model = (message as { message?: { model?: unknown } }).message?.model;
514
+ return knownPrice(typeof model === "string" ? model : undefined) ?? knownPrice(declared);
515
+ };
516
+ const turnsTotal = () => [...turns.values()].reduce((sum, t) => sum + priceTurn(t.u, t.price), 0);
517
+ const mixedModels = () => [...turns.values()].some((t) => t.price !== (opts.price ?? null));
319
518
 
320
519
  // Open tool calls, by the provider's id, so the result can be paired with
321
520
  // its call and the trace can say how long each tool ran.
322
- const openCalls = new Map<string, { name: string; at: number }>();
521
+ const openCalls = new Map<string, { name: string; at: number; subagent?: string }>();
522
+ // Delegations in flight: the parent's Agent call id → the sub-agent's name.
523
+ // The SDK tags every message a sub-agent produces with that call's id
524
+ // (parent_tool_use_id), which is how its tool calls are labelled here.
525
+ const delegations = new Map<string, string>();
526
+ const subagentOf = (message: unknown): string | undefined => {
527
+ const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
528
+ return parent ? (delegations.get(parent) ?? "subagent") : undefined;
529
+ };
530
+
531
+ // Slim browsing (browser-pod.ts): every call the step makes, read or
532
+ // write, in the order the model asked for them — the step's own and its
533
+ // sub-agents'. Classified when asked for, not when answered: a call in
534
+ // flight when the pod goes is counted as made. And the web tool's pod log,
535
+ // read after every tool result.
536
+ const pod = opts.browserPod ?? null;
537
+ const ledger = new Map<string, CallKind>();
538
+ const podState: BrowserPodOutcome = { reconnects: 0, reconnected: 0 };
539
+ let podOffset = 0;
540
+ const readPod = (callId: string) => {
541
+ if (!pod) return;
542
+ let text: string;
543
+ try {
544
+ text = fs.readFileSync(pod.events, "utf8");
545
+ } catch {
546
+ return; // nothing written yet
547
+ }
548
+ const { events, next } = readPodEvents(text, podOffset);
549
+ podOffset = next;
550
+ let closedAgain = false;
551
+ for (const [i, e] of events.entries()) {
552
+ if (e.kind === "reconnect") {
553
+ podState.reconnects += 1;
554
+ // A reconnect that reached the pod, and then the call dropped again
555
+ // at once (the web tool's next line is "lost"), did not get
556
+ // through: the pod answered while it shut down. Live, 2026-10-01,
557
+ // "reconnected (try 1 of 3)" was followed by "lost after 1
558
+ // reconnect try" and the step read "1 got through".
559
+ closedAgain = !!e.ok && events[i + 1]?.kind === "lost";
560
+ if (closedAgain) podState.closedAgain = (podState.closedAgain ?? 0) + 1;
561
+ else if (e.ok) podState.reconnected += 1;
562
+ // `at` is when the try happened: these lines reach the run when the
563
+ // call returns, so the run's own stamps on them are all the same.
564
+ emit("info", `browser pod: ${e.ok ? "reconnected" : "reconnect failed"} (try ${e.attempt ?? "?"} of ${e.of ?? "?"}${podAt(e.at)})${!e.ok && e.error ? ` — ${e.error}` : ""}${closedAgain ? ", but the pod closed again" : ""}`);
565
+ } else if (!podState.lost) {
566
+ // A call that never reached the pod changed nothing, whatever its
567
+ // arguments asked for.
568
+ if (e.ran === false) ledger.delete(callId);
569
+ podState.lost = { cause: e.kind, detail: (e.kind === "needs-full" ? e.why : e.error) ?? "connection closed" };
570
+ emit("info", e.kind === "needs-full"
571
+ ? `browser pod: this call needs a browser in the step, which the slim image has not (${podState.lost.detail}) — stopping the step`
572
+ : closedAgain
573
+ ? `browser pod: lost — the call dropped again after the reconnect (${podState.lost.detail}${podAt(e.at)}) — stopping the step`
574
+ : `browser pod: lost after ${podState.reconnects} reconnect tr${podState.reconnects === 1 ? "y" : "ies"} (${podState.lost.detail}${podAt(e.at)}) — stopping the step`);
575
+ endWith("pod-lost");
576
+ }
577
+ }
578
+ };
323
579
 
324
580
  try {
325
581
  for await (const message of q as AsyncIterable<SDKMessage>) {
326
582
  if (ended) break;
327
583
  if (message.type === "assistant") {
328
- const u = (message.message as unknown as { usage?: Record<string, number | undefined> }).usage;
584
+ const m = message.message as unknown as { id?: string; usage?: Record<string, number | undefined> };
585
+ const u = m.usage;
586
+ if (u) turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
329
587
  if (ceiling && u) {
330
- spentUsd += priceTurn(u, opts.price ?? null);
588
+ spentUsd = turnsTotal();
331
589
  if (spentUsd >= ceiling) {
332
590
  emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (${opts.budgetNote ?? "budget: in the flow file"})`);
333
591
  status = "failed";
334
592
  break;
335
593
  }
336
594
  }
595
+ const via = subagentOf(message);
337
596
  for (const block of message.message.content) {
338
597
  if (block.type === "text" && block.text.trim()) {
598
+ // A sub-agent's own words are its business; the parent's reply is
599
+ // the step's result. (The SDK forwards only tool blocks by default.)
600
+ if (via) continue;
339
601
  texts.push(block.text);
340
602
  emit("text", block.text);
341
603
  } else if (block.type === "tool_use") {
342
- openCalls.set(block.id, { name: block.name, at: Date.now() });
343
- emit("tool", block.name, { call: block.id });
604
+ if (!via && (DELEGATE_TOOLS as readonly string[]).includes(block.name)) {
605
+ const input = block.input as { subagent_type?: unknown; description?: unknown; prompt?: unknown } | undefined;
606
+ const to = String(input?.subagent_type ?? "subagent");
607
+ delegations.set(block.id, to);
608
+ // One line a person can read: who got the job, and what it was.
609
+ const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
610
+ emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
611
+ }
612
+ openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
613
+ if (pod) ledger.set(block.id, classifyCall(block.name, block.input as Record<string, unknown> | undefined, pod));
614
+ emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
344
615
  }
345
616
  }
346
617
  } else if (message.type === "user") {
@@ -357,7 +628,9 @@ export async function executeStep(
357
628
  call: block.tool_use_id,
358
629
  ms: Date.now() - open.at,
359
630
  ...(block.is_error ? { err: true } : {}),
631
+ ...(open.subagent ? { subagent: open.subagent } : {}),
360
632
  });
633
+ readPod(block.tool_use_id);
361
634
  }
362
635
  }
363
636
  } else if (message.type === "result") {
@@ -366,6 +639,7 @@ export async function executeStep(
366
639
  emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
367
640
  }
368
641
  costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
642
+ stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
369
643
  if ("usage" in message && message.usage) {
370
644
  const u = message.usage as unknown as Record<string, number | undefined>;
371
645
  usage = {
@@ -387,12 +661,42 @@ export async function executeStep(
387
661
  } finally {
388
662
  for (const t of timers) clearTimeout(t);
389
663
  }
664
+ // What the step used of each limit, on the trace whenever any is set —
665
+ // so "it stopped searching at 40" is read off the run, not guessed.
666
+ const limitLine = counter.summary();
667
+ if (limitLine) emit("info", limitLine);
668
+ // No result message — a timeout, a stop, a budget stop, a stream that
669
+ // died — means no total from the SDK. The turns it did take were billed by
670
+ // the provider all the same, so the step's cost is their sum.
671
+ if (costUsd === null && turns.size) {
672
+ let input = 0;
673
+ let output = 0;
674
+ let priced = 0;
675
+ for (const { u: t, price } of turns.values()) {
676
+ input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
677
+ output += t.output_tokens ?? 0;
678
+ priced += priceTurn(t, price);
679
+ }
680
+ costUsd = priced;
681
+ usage ??= { inputTokens: input, outputTokens: output };
682
+ emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
683
+ }
390
684
  if (ended === "timeout") {
391
685
  emit("error", `timed out after ${opts.timeoutSec}s (timeout: in the flow file) — the step was stopped with the files it wrote so far kept`);
392
686
  status = "failed";
393
687
  } else if (ended === "stopped") {
394
688
  emit("error", "stopped by a person mid-step");
395
689
  status = "failed";
690
+ } else if (ended === "pod-lost") {
691
+ // The runner says what happens next — a re-run on the full image, or a
692
+ // failure naming what was written. Not an error of the step's own.
693
+ status = "failed";
694
+ }
695
+ if (pod) {
696
+ readPod("");
697
+ if (podState.lost) {
698
+ podState.writes = [...ledger.values()].flatMap((k) => (k.write ? [k.what] : []));
699
+ }
396
700
  }
397
701
  // The SDK ends a healthy run with a `result` message. A stream that just
398
702
  // stops — subprocess OOM-killed, crashed, or torn down — used to fall
@@ -427,30 +731,77 @@ export async function executeStep(
427
731
  // failure as no value, and the message names the field.
428
732
  const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
429
733
  if (errors.length) {
430
- emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
734
+ emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`, CHECK);
431
735
  status = "failed";
432
736
  }
433
737
  } else {
434
- emit("error", `output: json — ${extracted.reason}`);
738
+ emit("error", `output: json — ${extracted.reason}`, CHECK);
435
739
  status = "failed";
436
740
  }
437
741
  }
438
742
 
439
743
  // "Done" should mean a check passed, not that the model stopped talking.
744
+ // The loop's clocks are cleared by now, so the check gets its own: what is
745
+ // left of the step's `timeout:` (never less than VERIFY_FLOOR_MS of it),
746
+ // and a stop read on the same clock the loop used. No `timeout:`, no clock:
747
+ // it is the only one there is, here as in the loop. A `verify:` that hangs
748
+ // — a build waiting on a lock, a judge that never answers — used to hold
749
+ // the step, and the run, with nothing to end it.
440
750
  if (status === "completed" && opts.verify) {
441
- const verdict = await checkVerify(agentDir, opts.verify, {
442
- env: opts.verifyEnv ?? {},
443
- result,
444
- conclusion,
445
- data,
446
- modelEnv: opts.env,
447
- });
448
- emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
449
- if (verdict.detail.trim()) emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
450
- if (!verdict.ok) status = "failed";
751
+ const verifyMs = verifyBudgetMs(opts.timeoutSec, Date.now() - startedAt);
752
+ const halt = new AbortController();
753
+ let cut: "timeout" | "stopped" | null = null;
754
+ const clocks: NodeJS.Timeout[] = [];
755
+ if (verifyMs !== null) {
756
+ clocks.push(setTimeout(() => {
757
+ cut ??= "timeout";
758
+ halt.abort();
759
+ }, verifyMs));
760
+ }
761
+ if (opts.stopRequested) {
762
+ clocks.push(setInterval(() => {
763
+ try {
764
+ if (opts.stopRequested!()) {
765
+ cut ??= "stopped";
766
+ halt.abort();
767
+ }
768
+ } catch {
769
+ // an unreadable record is not a stop
770
+ }
771
+ }, STOP_POLL_MS));
772
+ }
773
+ let verdict: VerifyVerdict;
774
+ try {
775
+ verdict = await checkVerify(agentDir, opts.verify, {
776
+ env: opts.verifyEnv ?? {},
777
+ result,
778
+ conclusion,
779
+ data,
780
+ modelEnv: opts.env,
781
+ signal: halt.signal,
782
+ });
783
+ } finally {
784
+ for (const t of clocks) clearTimeout(t);
785
+ }
786
+ if (cut === "timeout") {
787
+ emit("error", `verify \`${opts.verify}\` → timed out after ${Math.round(verifyMs! / 1000)}s (what was left of timeout: in the flow file) — it was stopped`, CHECK);
788
+ status = "failed";
789
+ } else if (cut === "stopped") {
790
+ emit("error", `verify \`${opts.verify}\` → stopped by a person mid-check`, CHECK);
791
+ status = "failed";
792
+ } else {
793
+ emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`, verdict.ok ? undefined : CHECK);
794
+ if (verdict.detail.trim()) emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000), verdict.ok ? undefined : CHECK);
795
+ if (!verdict.ok) status = "failed";
796
+ }
451
797
  }
452
798
 
453
- return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
799
+ return {
800
+ status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
801
+ ...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
802
+ stopReason,
803
+ ...(pod ? { browserPod: podState } : {}),
804
+ };
454
805
  }
455
806
 
456
807
  // ------------------------------------------------------------ output: json
@@ -531,12 +882,27 @@ export async function checkVerify(
531
882
  /** The step's own model environment, for `judge:` — it grades on the
532
883
  * fast tier through the same credential the step rode. */
533
884
  modelEnv?: Record<string, string | undefined>;
885
+ /** Ends a shell check or a judge mid-way: the step's clock ran out, or a
886
+ * person asked the run to stop. */
887
+ signal?: AbortSignal;
534
888
  },
535
889
  ): Promise<VerifyVerdict> {
536
890
  const m = verify.trim().match(VERIFY_ASSERTION);
537
891
  if (!m) {
538
- const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
539
- return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
892
+ // The step's final turn, as a file named in FOLDRUN_REPLY_FILE — so one
893
+ // command can check the reply and the artefact together
894
+ // (`grep -q '^READY' "$FOLDRUN_REPLY_FILE" && test -s out.json`). A step
895
+ // has one `verify:`, and a shell check that could not see the reply
896
+ // made authors choose between the two.
897
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-reply-"));
898
+ const replyFile = path.join(dir, "reply.md");
899
+ fs.writeFileSync(replyFile, ctx.conclusion ?? ctx.result ?? "");
900
+ try {
901
+ const { code, out } = await runVerify(agentDir, verify, { ...ctx.env, FOLDRUN_REPLY_FILE: replyFile }, ctx.data, ctx.signal);
902
+ return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
903
+ } finally {
904
+ fs.rmSync(dir, { recursive: true, force: true });
905
+ }
540
906
  }
541
907
  const [, kind, rawValue] = m;
542
908
  const value = rawValue.trim().replace(/^["']|["']$/g, "");
@@ -560,15 +926,20 @@ export async function checkVerify(
560
926
  }
561
927
  }
562
928
  case "file": {
563
- const target = path.resolve(agentDir, value);
564
- if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
565
- return { ok: false, headline: "path escapes the agent directory", detail: "" };
929
+ // Agent-relative like every path in a flow, or `workspace/…` from the
930
+ // workspace root; confined to the workspace. It was confined to the
931
+ // agent's own folder, so the one place a step's deliverable belongs —
932
+ // `workspace/storage/x`, or `../../storage/x` — was always "escapes".
933
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
934
+ const target = resolveAgentPath(workspaceRoot, agentDir, value);
935
+ if (!isWithin(workspaceRoot, target) || target === workspaceRoot) {
936
+ return { ok: false, headline: "path escapes the workspace", detail: "" };
566
937
  }
567
938
  const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
568
939
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
569
940
  }
570
941
  case "judge": {
571
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
942
+ const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
572
943
  const ok = /^\s*PASS\b/i.test(verdict);
573
944
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
574
945
  }
@@ -585,8 +956,12 @@ async function judgeReply(
585
956
  rubric: string,
586
957
  output: string,
587
958
  env: Record<string, string | undefined>,
959
+ signal?: AbortSignal,
588
960
  ): Promise<string> {
589
961
  const texts: string[] = [];
962
+ const abortController = new AbortController();
963
+ if (signal?.aborted) abortController.abort();
964
+ signal?.addEventListener("abort", () => abortController.abort(), { once: true });
590
965
  try {
591
966
  const q = query({
592
967
  prompt:
@@ -600,6 +975,7 @@ async function judgeReply(
600
975
  settingSources: [],
601
976
  env,
602
977
  maxTurns: 1,
978
+ abortController,
603
979
  },
604
980
  });
605
981
  for await (const message of q) {
@@ -622,19 +998,35 @@ function runVerify(
622
998
  command: string,
623
999
  env: Record<string, string>,
624
1000
  data?: unknown,
1001
+ signal?: AbortSignal,
625
1002
  ): Promise<{ code: number | null; out: string }> {
626
1003
  return new Promise((resolve) => {
627
- // No clock of the platform's: a verify like `npm run build` takes what
628
- // it takes, and the step's own `timeout:` is the bound if the flow set one.
1004
+ // The clock is the caller's (executeStep): what is left of the step's
1005
+ // `timeout:`, ended through `signal`, which a stop also fires.
629
1006
  // The allowlisted host base plus what the caller passed: the step's
630
1007
  // secrets and identifiers on the in-process path (runner.ts), the
631
1008
  // container's own environment on the isolated one (run-container.ts).
632
1009
  // Never process.env whole — see host-env.ts.
633
1010
  const child = spawn("bash", ["-lc", command], {
634
1011
  cwd: agentDir,
635
- env: { ...hostSafeEnv(), ...env },
1012
+ // workspaceLinkEnv: `node workspace/tools/x/check.mjs` must run its
1013
+ // check, not pass for having skipped it (confine.ts says how).
1014
+ env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, agentDir),
636
1015
  stdio: ["pipe", "pipe", "pipe"],
1016
+ // Its own process group, so an abort ends what the shell started
1017
+ // (`npm run build` and its children) and not only bash.
1018
+ detached: true,
637
1019
  });
1020
+ const kill = () => {
1021
+ try {
1022
+ process.kill(-child.pid!, "SIGKILL");
1023
+ } catch {
1024
+ child.kill("SIGKILL");
1025
+ }
1026
+ };
1027
+ if (signal?.aborted) kill();
1028
+ else signal?.addEventListener("abort", kill, { once: true });
1029
+ child.on("close", () => signal?.removeEventListener("abort", kill));
638
1030
  // An `output: json` step's data arrives on stdin, so a check can be
639
1031
  // `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
640
1032
  // the step actually returned rather than re-parsing its prose.