@foldrun/core 0.3.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -0,0 +1,313 @@
1
+ // Slim browsing: a step whose only reason for the full runner image is that
2
+ // it browses may run on the slim image and drive the account's browser pod
3
+ // over Playwright's protocol. The pod can go away mid-step — a node drained,
4
+ // the idle reaper, an OOM — and a slim step has no browser of its own to fall
5
+ // back on. So:
6
+ //
7
+ // 1. The web tool reconnects first: a few tries, short backoff (the tool's
8
+ // run.mjs; each try is an event in FOLDRUN_BROWSER_POD_EVENTS).
9
+ // 2. Still gone: the step stops, and what it has done so far decides.
10
+ // Only reads — re-run the whole step from the start on the full image,
11
+ // and say so on the run ("browser pod lost; re-ran on full").
12
+ // 3. Anything written — fail the step: "browser pod died after the step had
13
+ // written <what>". Doing it again could send, post or charge twice, so
14
+ // retry:, on-fail: or a person decides.
15
+ //
16
+ // Read or write is decided from the calls the step made, as the model asked
17
+ // for them (step-exec.ts keeps the ledger). When a call could be either it
18
+ // counts as a write: the cost of being wrong that way is a failed step a
19
+ // retry can redo; the other way it is a duplicate email.
20
+ //
21
+ // Steps that act outward (a tool marked `outward: true`) never run slim; nor
22
+ // does anything the step start can already see needs a browser in the step
23
+ // (lightpanda, obscura, live sessions, extensions) — slimBrowseBlocker.
24
+
25
+ /** Set by the platform on a step it put on the slim image to browse
26
+ * through the pod. The web tool then never launches a browser of its own. */
27
+ export const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
28
+ /** Where the web tool appends one JSON line per reconnect, and the line
29
+ * that says the pod is lost or the call needs the full image. */
30
+ export const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
31
+ /** The file, inside the step's sandbox. */
32
+ export const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
33
+ /** The note the run carries when the fallback was taken. Worded once. */
34
+ export const RERAN_ON_FULL = "browser pod lost; re-ran on full";
35
+
36
+ /**
37
+ * Browse actions that can change something beyond the page in front of the
38
+ * model: anything that types, picks, submits, signs in, uploads, runs script
39
+ * or pays. A click is here because a click can submit and nothing before
40
+ * the click can tell. The web tool's run.mjs carries a copy (it decides
41
+ * whether a dropped call may be repeated); the gallery test holds the two
42
+ * equal.
43
+ */
44
+ export const BROWSE_WRITE_ACTIONS = [
45
+ "click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag",
46
+ "fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear",
47
+ "select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
48
+ ] as const;
49
+
50
+ /** Call arguments of web action=browse that are writes whatever the actions:
51
+ * page script, a paid captcha solver, a confirmation of a credential step. */
52
+ const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
53
+
54
+ /** Web actions that only read — when foldrun's own answers them. A paid
55
+ * provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
56
+ * it twice is paying twice. `monitor` keeps state, so it always writes. */
57
+ const WEB_READ_ACTIONS = new Set(["search", "fetch", "browse", "crawl", "map", "extract", "answer"]);
58
+
59
+ /** Built-ins that read and change nothing. */
60
+ const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "TodoWrite", "LS", "NotebookRead"]);
61
+ /** The platform's own tool groups that only read. Consults are a toolless
62
+ * answer; a delegation's own calls come through on their own. */
63
+ const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
64
+ const DELEGATE = new Set(["Agent", "Task"]);
65
+
66
+ export type CallKind = { write: false } | { write: true; what: string };
67
+
68
+ export interface ClassifyContext {
69
+ /** SDK tool name → HTTP method, for an API's typed operations (api-tools
70
+ * builds the map; a generic call_<api> carries its method in the input). */
71
+ methods?: Record<string, string>;
72
+ /** Web actions a paid provider answers this step — from the env's
73
+ * FOLDRUN_WEB_<ACTION>_VIA. */
74
+ paidWeb?: Record<string, string>;
75
+ }
76
+
77
+ /** The browse actions a call asked for, nested `if` branches included. */
78
+ export function browseActionNames(actions: unknown): string[] {
79
+ let steps: unknown = actions;
80
+ if (typeof steps === "string") {
81
+ try { steps = JSON.parse(steps); } catch { return ["(unreadable actions)"]; }
82
+ }
83
+ const out: string[] = [];
84
+ const walk = (v: unknown, depth: number) => {
85
+ if (depth > 8 || v === null || typeof v !== "object") return;
86
+ if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
87
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
88
+ out.push(k);
89
+ walk(x, depth + 1);
90
+ }
91
+ };
92
+ walk(steps, 0);
93
+ return out;
94
+ }
95
+
96
+ /** What a browse call's arguments would write, or null when it only reads. */
97
+ export function browseWrites(input: Record<string, unknown>): string | null {
98
+ const names = browseActionNames(input.actions);
99
+ if (names.includes("(unreadable actions)")) return "browse actions it could not read";
100
+ const writes = [...new Set(names.filter((n) => (BROWSE_WRITE_ACTIONS as readonly string[]).includes(n)))];
101
+ if (writes.length) return `browse ${writes.join("/")}`;
102
+ const arg = BROWSE_WRITE_ARGS.find((a) => input[a] !== undefined && input[a] !== null && input[a] !== "" && input[a] !== false);
103
+ if (arg) return `browse ${arg}=`;
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Whether one tool call can have changed anything outside the model's head,
109
+ * and if so, a few words saying what. Pure.
110
+ */
111
+ export function classifyCall(tool: string, input: Record<string, unknown> | undefined, ctx: ClassifyContext = {}): CallKind {
112
+ const args = input ?? {};
113
+ const write = (what: string): CallKind => ({ write: true, what });
114
+ if (READ_BUILTINS.has(tool) || DELEGATE.has(tool)) return { write: false };
115
+ if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
116
+ const raw = String(args.file_path ?? args.notebook_path ?? args.path ?? "");
117
+ const file = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : raw.split("/").slice(-3).join("/");
118
+ return write(file ? `${file} (${tool})` : `a file (${tool})`);
119
+ }
120
+ if (tool === "Bash") return write("a shell command");
121
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
122
+ if (!mcp) return write(tool); // unknown: a write until shown otherwise
123
+ const [, server, name] = mcp;
124
+ if (READ_SERVERS.has(server)) return { write: false };
125
+ if (server === "foldrun_ask") return write("a question to a person");
126
+ if (server === "foldrun_scripts") {
127
+ if (name !== "web") return write(`script ${name}`);
128
+ const action = String(args.action ?? "").trim().toLowerCase();
129
+ if (!WEB_READ_ACTIONS.has(action)) return write(action === "monitor" ? "web monitor state" : `web ${action || "call"}`);
130
+ const paid = ctx.paidWeb?.[action];
131
+ if (paid) return write(`a paid ${paid} ${action}`);
132
+ if (action === "browse") {
133
+ const w = browseWrites(args);
134
+ if (w) return write(w);
135
+ }
136
+ return { write: false };
137
+ }
138
+ if (server === "foldrun_apis") {
139
+ const method = String(
140
+ name.startsWith("call_") ? (args.method ?? "GET") : (ctx.methods?.[tool] ?? ""),
141
+ ).toUpperCase();
142
+ const api = name.replace(/^call_/, "");
143
+ if (method === "GET" || method === "HEAD") return { write: false };
144
+ return write(`${api} ${method || "call"}`);
145
+ }
146
+ // Any other MCP server: its tools could do anything.
147
+ return write(`${server} ${name}`);
148
+ }
149
+
150
+ /** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
151
+ export function paidWebActions(env: Record<string, string | undefined>): Record<string, string> {
152
+ const out: Record<string, string> = {};
153
+ for (const [k, v] of Object.entries(env)) {
154
+ const m = /^FOLDRUN_WEB_([A-Z]+)_VIA$/.exec(k);
155
+ if (m && v) out[m[1].toLowerCase()] = v;
156
+ }
157
+ if (env.FOLDRUN_BROWSER_VENDOR) out.browse = env.FOLDRUN_BROWSER_VENDOR;
158
+ return out;
159
+ }
160
+
161
+ /** One line of the web tool's pod log. */
162
+ export interface PodEvent {
163
+ kind: "reconnect" | "lost" | "needs-full";
164
+ /** reconnect: which try, of how many, and whether it got through. */
165
+ attempt?: number;
166
+ of?: number;
167
+ ok?: boolean;
168
+ /** lost: whether the call ran on the pod at all before it was lost — a
169
+ * call that never connected changed nothing, whatever it asked for. */
170
+ ran?: boolean;
171
+ error?: string;
172
+ why?: string;
173
+ /** When the web tool wrote the line (ISO). */
174
+ at?: string;
175
+ }
176
+
177
+ /** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
178
+ export function podAt(at: unknown): string {
179
+ if (typeof at !== "string") return "";
180
+ const t = Date.parse(at);
181
+ return Number.isNaN(t) ? "" : `, at ${new Date(t).toISOString().slice(11, 19)}Z`;
182
+ }
183
+
184
+ /** Lines of the pod log from byte `offset` on, and where the next read
185
+ * starts. A torn last line waits for the next read. */
186
+ export function readPodEvents(text: string, offset: number): { events: PodEvent[]; next: number } {
187
+ const chunk = text.slice(offset);
188
+ const end = chunk.lastIndexOf("\n");
189
+ if (end < 0) return { events: [], next: offset };
190
+ const events: PodEvent[] = [];
191
+ for (const line of chunk.slice(0, end).split("\n")) {
192
+ try {
193
+ const e = JSON.parse(line) as PodEvent;
194
+ if (e && (e.kind === "reconnect" || e.kind === "lost" || e.kind === "needs-full")) events.push(e);
195
+ } catch {
196
+ // not ours
197
+ }
198
+ }
199
+ return { events, next: offset + end + 1 };
200
+ }
201
+
202
+ /** What crosses back from a step that browsed through the pod. */
203
+ export interface BrowserPodOutcome {
204
+ /** Reconnect tries the web tool made, and how many got through: the pod
205
+ * answered and the call went on, rather than dropping again at once. */
206
+ reconnects: number;
207
+ reconnected: number;
208
+ /** Reconnects that reached the pod, after which the call dropped again at
209
+ * once — a pod still answering while it shut down. Never "got through". */
210
+ closedAgain?: number;
211
+ /** Set when the step stopped because the pod was gone (or a call needed
212
+ * a browser in the step, which slim does not have). */
213
+ lost?: { cause: "lost" | "needs-full"; detail: string };
214
+ /** What the step had written by then, worded — empty means reads only. */
215
+ writes?: string[];
216
+ }
217
+
218
+ /** "a.md (Write), crm POST and 2 more". */
219
+ export function describeWrites(writes: string[]): string {
220
+ const uniq = [...new Set(writes)];
221
+ if (uniq.length > 3) return `${uniq.slice(0, 3).join(", ")} and ${uniq.length - 3} more`;
222
+ if (uniq.length <= 1) return uniq[0] ?? "something";
223
+ return `${uniq.slice(0, -1).join(", ")} and ${uniq.at(-1)}`;
224
+ }
225
+
226
+ /** The decision, once the pod is gone: re-run on full, or fail and say why. */
227
+ export function podLossDecision(pod: BrowserPodOutcome): { rerun: true; note: string } | { rerun: false; message: string } {
228
+ const writes = pod.writes ?? [];
229
+ const cause = pod.lost?.cause === "needs-full" ? `a call needed a browser in the step (${pod.lost.detail})` : `browser pod lost (${pod.lost?.detail ?? "connection closed"})`;
230
+ if (!writes.length) return { rerun: true, note: `${RERAN_ON_FULL} — ${cause}; the step had only read, so it ran again from the start on the full image` };
231
+ const head = pod.lost?.cause === "needs-full"
232
+ ? `a call needed a browser in the step after the step had written ${describeWrites(writes)}`
233
+ : `browser pod died after the step had written ${describeWrites(writes)}`;
234
+ return { rerun: false, message: `${head} — not re-run, since doing it again could repeat that; retry:, on-fail: or a person decides (${pod.lost?.detail ?? "connection closed"})` };
235
+ }
236
+
237
+ /**
238
+ * Why a browsing step must have a browser in its own sandbox, from what the
239
+ * step start can see (the agent's `web.browse` block, as env), or null when
240
+ * the account's pod can serve every call it could make by default. A call
241
+ * that picks an in-step engine itself is caught at the call (needs-full).
242
+ */
243
+ export function slimBrowseBlocker(env: Record<string, string | undefined>): string | null {
244
+ if (env.FOLDRUN_BROWSER_VENDOR) return `web.browse names ${env.FOLDRUN_BROWSER_VENDOR}`;
245
+ if (env.FOLDRUN_BROWSER_LIVE === "1") return "web.browse live: sessions run in the step";
246
+ const engine = (env.FOLDRUN_BROWSER_ENGINE ?? "").trim().toLowerCase();
247
+ if (engine === "lightpanda" || engine === "obscura") return `engine ${engine} runs in the step`;
248
+ if (env.FOLDRUN_BROWSER_EXTENSIONS) return "extensions load into a browser in the step";
249
+ return null;
250
+ }
251
+
252
+ /** The outcome as it crosses back from the sandbox, checked field by field
253
+ * (the done line is the pod's word); null when absent or not this shape. */
254
+ export function parseBrowserPod(v: unknown): BrowserPodOutcome | null {
255
+ if (!v || typeof v !== "object") return null;
256
+ const o = v as Record<string, unknown>;
257
+ const n = (x: unknown) => (typeof x === "number" && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
258
+ const out: BrowserPodOutcome = { reconnects: n(o.reconnects), reconnected: n(o.reconnected) };
259
+ if (n(o.closedAgain)) out.closedAgain = n(o.closedAgain);
260
+ const lost = o.lost as Record<string, unknown> | undefined;
261
+ if (lost && typeof lost === "object" && (lost.cause === "lost" || lost.cause === "needs-full")) {
262
+ out.lost = { cause: lost.cause, detail: String(lost.detail ?? "").slice(0, 300) };
263
+ out.writes = Array.isArray(o.writes) ? o.writes.slice(0, 50).map((w) => String(w).slice(0, 120)) : [];
264
+ }
265
+ return out;
266
+ }
267
+
268
+ /** One line for a person: "2 reconnects (2 got through)", "browser pod
269
+ * lost; re-ran on full", or the failure. What the run page, `foldrun
270
+ * report` and an attempt row say. A pod that was lost in the end is never
271
+ * said to have let a reconnect through — "1 reconnect (1 got through); pod
272
+ * lost" read live as a contradiction — and a reconnect that reached the pod
273
+ * only for the call to drop again says that. */
274
+ export function browserPodLine(p: BrowserPodOutcome & { fallback?: string; failure?: string }): string {
275
+ const count = `${p.reconnects} reconnect${p.reconnects === 1 ? "" : "s"}`;
276
+ const tries = !p.reconnects
277
+ ? "no reconnects"
278
+ : !p.lost
279
+ ? `${count} (${p.reconnected} got through)`
280
+ : p.closedAgain
281
+ ? `${count}, ${p.reconnects === 1 ? "which" : "the last"} reached the pod but it closed again`
282
+ : count;
283
+ if (p.failure) return `${tries}; ${p.failure}`;
284
+ if (p.fallback) return `${tries}; ${p.fallback}${p.lost ? ` (${p.lost.detail})` : ""}`;
285
+ if (p.lost) return `${tries}; ${p.lost.cause === "needs-full" ? "needed a browser in the step" : "pod lost"}: ${p.lost.detail}`;
286
+ return tries;
287
+ }
288
+
289
+ /** What podTriesLine reads of a StepAttempt (store.ts) — structural, so
290
+ * this module stays free of the store's imports for the CLI and the web. */
291
+ export interface TryRow {
292
+ n: number;
293
+ status: string;
294
+ image?: string;
295
+ costUsd: number | null;
296
+ }
297
+
298
+ /** A step's tries in one line when the browser pod was lost under one of
299
+ * them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
300
+ * page and `foldrun report` show the slim go and the full re-run apart.
301
+ * `#n` leads each try when the step had more than one attempt. Null when
302
+ * no try was lost. */
303
+ export function podTriesLine(tries: readonly TryRow[] | null | undefined): string | null {
304
+ if (!tries?.some((t) => t.status === "lost")) return null;
305
+ const many = new Set(tries.map((t) => t.n)).size > 1;
306
+ return tries
307
+ .map((t) => [
308
+ `${many ? `#${t.n} ` : ""}${t.image ?? "?"}`,
309
+ t.status,
310
+ typeof t.costUsd === "number" ? `$${t.costUsd.toFixed(4)}` : null,
311
+ ].filter(Boolean).join(" · "))
312
+ .join(" → ");
313
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,178 @@
1
+ // A budget is a number and a period — what may be spent, and over what.
2
+ //
3
+ // One grammar, read in four places: an agent's `budget:` (the most that
4
+ // agent may spend in one run), a flow's (the most one run may spend), a
5
+ // workspace's and the account's (a cap over a window of time). The value
6
+ // was a bare number meaning "per month" in a workspace or account file and
7
+ // "per run" in a flow; it still is, and it can now also say which:
8
+ //
9
+ // budget: 60 a number alone: per run in a flow or an agent,
10
+ // per month in a workspace or the account
11
+ // budget: 60/day or 60/week, 60/month
12
+ // budget: unlimited no cap — the same as leaving the key out
13
+ //
14
+ // Unset means no limit. That is the platform's rule, not an accident of
15
+ // parsing: a customer who has not written a cap has not asked for one, and
16
+ // nothing here invents a default they would then have to find and raise.
17
+ //
18
+ // Windows follow the account's calendar (`timezone:` in AGENTS.md), the
19
+ // same one the scheduler and "today" already use, because a daily cap that
20
+ // resets at 10am Sydney time is not a daily cap anyone meant.
21
+
22
+ export type BudgetPeriod = "run" | "day" | "week" | "month";
23
+
24
+ export interface Budget {
25
+ usd: number;
26
+ period: BudgetPeriod;
27
+ }
28
+
29
+ /** Words that mean "no cap", case-insensitively. */
30
+ const UNLIMITED = new Set(["unlimited", "none", "no limit", "no-limit", "off", "-", "~"]);
31
+
32
+ const PERIODS: Record<string, BudgetPeriod> = {
33
+ run: "run",
34
+ day: "day",
35
+ daily: "day",
36
+ week: "week",
37
+ weekly: "week",
38
+ month: "month",
39
+ monthly: "month",
40
+ };
41
+
42
+ // `\d+(?:\.\d+)?` with an optional separator and one period word: every
43
+ // part has one way to match, so a hostile string cannot make it backtrack.
44
+ const FORM_RE = /^\$?(\d+(?:\.\d+)?)(?:\s*(?:\/|per|a|each)?\s*([a-z]+))?$/;
45
+
46
+ /**
47
+ * The budget a frontmatter value declares, or null for "no cap".
48
+ *
49
+ * Lenient on purpose: a value nobody can read is treated as no cap rather
50
+ * than as a cap of zero, because refusing every run over a typo is the worse
51
+ * failure. `budgetProblem` is the strict reading, for lint and the editor.
52
+ */
53
+ export function parseBudget(raw: unknown, defaultPeriod: BudgetPeriod): Budget | null {
54
+ if (raw === undefined || raw === null) return null;
55
+ if (typeof raw === "number") return Number.isFinite(raw) && raw > 0 ? { usd: raw, period: defaultPeriod } : null;
56
+ if (typeof raw !== "string") return null;
57
+ const s = raw.trim().toLowerCase();
58
+ if (!s || UNLIMITED.has(s)) return null;
59
+ const m = FORM_RE.exec(s);
60
+ if (!m) return null;
61
+ const usd = Number(m[1]);
62
+ if (!Number.isFinite(usd) || usd <= 0) return null;
63
+ const period = m[2] ? PERIODS[m[2]] : defaultPeriod;
64
+ if (!period) return null;
65
+ return { usd, period };
66
+ }
67
+
68
+ /**
69
+ * What is wrong with a `budget:` value, in a sentence, or null when it is
70
+ * fine. `allowed` names the periods this file may use — an agent's or a
71
+ * flow's budget is per run and nothing else; a workspace's or the account's
72
+ * is over time and never per run.
73
+ */
74
+ export function budgetProblem(raw: unknown, allowed: BudgetPeriod[]): string | null {
75
+ if (raw === undefined || raw === null) return null;
76
+ if (typeof raw === "number") {
77
+ if (!Number.isFinite(raw) || raw < 0) return `budget: ${String(raw)} is not an amount in USD`;
78
+ return null;
79
+ }
80
+ if (typeof raw !== "string") return `budget: must be a number, "<number>/<day|week|month>", or unlimited`;
81
+ const s = raw.trim().toLowerCase();
82
+ if (!s || UNLIMITED.has(s)) return null;
83
+ const m = FORM_RE.exec(s);
84
+ if (!m) return `budget: "${raw}" is not a number, "<number>/<day|week|month>", or unlimited`;
85
+ if (m[2]) {
86
+ const period = PERIODS[m[2]];
87
+ if (!period) return `budget: "${m[2]}" is not a period — day, week or month`;
88
+ if (!allowed.includes(period)) {
89
+ return period === "run"
90
+ ? `budget: is per run here already — write the number alone`
91
+ : `budget: "${period}" does not apply here — this cap is per run; a cap over time belongs in the workspace's or the account's AGENTS.md`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** "$60/day", "$500/month", "$6/run". */
98
+ export function formatBudget(b: Budget): string {
99
+ const usd = Number.isInteger(b.usd) ? String(b.usd) : b.usd.toFixed(2);
100
+ return `$${usd}/${b.period}`;
101
+ }
102
+
103
+ /** "today", "this week", "this month" — the window a spend is measured in. */
104
+ export function windowName(period: BudgetPeriod): string {
105
+ return period === "day" ? "today" : period === "week" ? "this week" : period === "month" ? "this month" : "this run";
106
+ }
107
+
108
+ /** What to wait for when the cap is reached. */
109
+ export function windowTurnsHint(period: BudgetPeriod): string {
110
+ return period === "day" ? "wait for tomorrow" : period === "week" ? "wait for next week" : "wait for the new month";
111
+ }
112
+
113
+ // ------------------------------------------------------------ the window
114
+
115
+ function partsIn(timeZone: string, at: Date): { y: number; m: number; d: number; h: number; min: number; s: number; weekday: number } {
116
+ const f = new Intl.DateTimeFormat("en-US", {
117
+ timeZone,
118
+ hourCycle: "h23",
119
+ year: "numeric",
120
+ month: "numeric",
121
+ day: "numeric",
122
+ hour: "numeric",
123
+ minute: "numeric",
124
+ second: "numeric",
125
+ weekday: "short",
126
+ });
127
+ const p: Record<string, string> = {};
128
+ for (const { type, value } of f.formatToParts(at)) p[type] = value;
129
+ const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
130
+ return {
131
+ y: Number(p.year),
132
+ m: Number(p.month),
133
+ d: Number(p.day),
134
+ h: Number(p.hour),
135
+ min: Number(p.minute),
136
+ s: Number(p.second),
137
+ weekday: weekdays.indexOf(p.weekday),
138
+ };
139
+ }
140
+
141
+ /** The zone's offset from UTC at an instant, in ms (positive east). */
142
+ function offsetMs(timeZone: string, atMs: number): number {
143
+ const p = partsIn(timeZone, new Date(atMs));
144
+ return Date.UTC(p.y, p.m - 1, p.d, p.h, p.min, p.s) - Math.floor(atMs / 1000) * 1000;
145
+ }
146
+
147
+ /** Midnight at the start of a local calendar date, as a UTC instant. */
148
+ function localMidnightMs(timeZone: string, y: number, m: number, d: number): number {
149
+ const naive = Date.UTC(y, m - 1, d);
150
+ let t = naive - offsetMs(timeZone, naive);
151
+ // Once more from the answer: across a DST change the first guess can be
152
+ // off by the shift, and the second pass lands on the real midnight.
153
+ const again = naive - offsetMs(timeZone, t);
154
+ if (again !== t) t = again;
155
+ return t;
156
+ }
157
+
158
+ /**
159
+ * When the current window began, as a UTC instant — the moment spend is
160
+ * summed from. Today's midnight, Monday's midnight, or the first of the
161
+ * month, all in the given calendar. A per-run budget has no window.
162
+ */
163
+ export function windowStartMs(period: BudgetPeriod, timeZone: string, now = new Date()): number | null {
164
+ if (period === "run") return null;
165
+ let tz = timeZone;
166
+ try {
167
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
168
+ } catch {
169
+ tz = "UTC";
170
+ }
171
+ const p = partsIn(tz, now);
172
+ if (period === "day") return localMidnightMs(tz, p.y, p.m, p.d);
173
+ if (period === "month") return localMidnightMs(tz, p.y, p.m, 1);
174
+ // Monday. Walk back on the naive calendar, then take that date's midnight.
175
+ const back = (p.weekday + 6) % 7;
176
+ const monday = new Date(Date.UTC(p.y, p.m - 1, p.d - back));
177
+ return localMidnightMs(tz, monday.getUTCFullYear(), monday.getUTCMonth() + 1, monday.getUTCDate());
178
+ }
package/src/catalog.ts CHANGED
@@ -23,6 +23,8 @@ import path from "node:path";
23
23
  import { dataRoot } from "./paths.ts";
24
24
  import { resolveModel, resolveTier, MODEL_TIERS, EFFORT_LEVELS, type Effort, type Tier, type FlowInfo } from "./store.ts";
25
25
  import type { FlowWarning } from "./flow-lint.ts";
26
+ import { trimSlashes } from "./paths.ts";
27
+ import { fetchUntrusted } from "./untrusted-fetch.ts";
26
28
 
27
29
  export interface CatalogModel {
28
30
  id: string;
@@ -115,7 +117,7 @@ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
115
117
  if (cached && Date.now() - Date.parse(cached.fetchedAt) < TTL_MS) return cached;
116
118
 
117
119
  try {
118
- const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
120
+ const res = await fetchUntrusted(`${trimSlashes(baseUrl)}/v1/models`, {
119
121
  signal: AbortSignal.timeout(10_000),
120
122
  });
121
123
  if (!res.ok) throw new Error(`${res.status}`);
package/src/clock.ts ADDED
@@ -0,0 +1,149 @@
1
+ // The clock an agent works to.
2
+ //
3
+ // A step used to run on whatever the pod's clock said, which is UTC, while
4
+ // the flow that fired it was scheduled in Australia/Sydney. A run at 08:00
5
+ // Sydney on the 16th wrote files stamped the 15th and the next step, looking
6
+ // for today's files, found none. The schedule and the work disagreed about
7
+ // what day it was.
8
+ //
9
+ // So the calendar is resolvable at every level, nearest wins:
10
+ //
11
+ // agent frontmatter → flow frontmatter → workspace AGENTS.md →
12
+ // account AGENTS.md → FOLDRUN_TIMEZONE → UTC
13
+ //
14
+ // Leaving `timezone:` out inherits the next level up; setting it anywhere
15
+ // wins for that level and everything under it. A value nothing can read
16
+ // never fails a step — it falls through to the next level and says so once.
17
+
18
+ /** A zone the runtime can actually use: the name Intl understands, and the
19
+ * value to put in `TZ` for the shell and Node inside the sandbox. They are
20
+ * the same for an IANA name and differ for a fixed offset. */
21
+ export interface Zone {
22
+ /** What `Intl.DateTimeFormat({ timeZone })` is given. */
23
+ name: string;
24
+ /** What `TZ` is set to. */
25
+ tz: string;
26
+ }
27
+
28
+ /** `UTC+10`, `GMT-3:30`, `+10:00`, `-0530`, or a bare `UTC`/`GMT`. */
29
+ const OFFSET_RE = /^(?:UTC|GMT)?\s*(?:([+-])\s*(\d{1,2})(?::?(\d{2}))?)?$/i;
30
+
31
+ /**
32
+ * Read a written timezone into something usable, or null if nothing can be.
33
+ *
34
+ * Two shapes are accepted, because both are what people type: an IANA name
35
+ * (`Australia/Sydney`) and a plain fixed offset (`UTC+10`, `+10:00`,
36
+ * `-05:30`, `GMT+5`, or `UTC` on its own).
37
+ */
38
+ export function normalizeZone(raw: unknown): Zone | null {
39
+ const text = typeof raw === "string" ? raw.trim() : "";
40
+ if (!text) return null;
41
+
42
+ const offset = text.match(OFFSET_RE);
43
+ if (offset) {
44
+ const [, sign, hh, mm] = offset;
45
+ // A bare UTC/GMT, or the empty match — no offset at all.
46
+ if (!sign) return { name: "UTC", tz: "UTC" };
47
+ const hours = Number(hh);
48
+ const minutes = Number(mm ?? "0");
49
+ // The real range of civil offsets. `UTC+99` is a typo, not a zone.
50
+ if (hours > 14 || minutes > 59) return null;
51
+ if (hours === 14 && minutes > 0) return null;
52
+ const total = hours * 60 + minutes;
53
+ if (total === 0) return { name: "UTC", tz: "UTC" };
54
+ const pad = (n: number) => String(n).padStart(2, "0");
55
+ const name = `${sign}${pad(hours)}:${pad(minutes)}`; // Intl reads this form
56
+ if (minutes === 0) {
57
+ // Etc/GMT zones have their sign INVERTED — POSIX counts degrees west of
58
+ // Greenwich as positive, so Etc/GMT-10 is ten hours AHEAD of UTC. It is
59
+ // worth the confusion: `Etc/GMT-10` is understood by ICU (so Node in
60
+ // the sandbox honours it) and by the shell's date, which the offset
61
+ // form `+10:00` is not.
62
+ return { name, tz: `Etc/GMT${sign === "+" ? "-" : "+"}${hours}` };
63
+ }
64
+ // No Etc zone exists for a half-hour offset, so `TZ` gets the POSIX
65
+ // spec — which the shell reads, and whose sign is inverted for the same
66
+ // reason. FOLDRUN_DATE is computed from `name` either way, so the date
67
+ // an agent is told is right whatever TZ a program makes of it.
68
+ const posix = `${sign === "+" ? "-" : "+"}${pad(hours)}:${pad(minutes)}`;
69
+ return { name, tz: `<${sign}${pad(hours)}${pad(minutes)}>${posix}` };
70
+ }
71
+
72
+ // Not an offset: an IANA name, if the platform knows it.
73
+ try {
74
+ new Intl.DateTimeFormat("en-CA", { timeZone: text });
75
+ return { name: text, tz: text };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Is this a value `timezone:` may carry? Used by the lint, so `foldrun
82
+ * check` refuses `Sydney/Australia` and `UTC+99` before a run does. */
83
+ export function isTimezone(raw: unknown): boolean {
84
+ return normalizeZone(raw) !== null;
85
+ }
86
+
87
+ /** The lint's sentence for a `timezone:` nobody can read. */
88
+ export function timezoneProblem(raw: unknown): string | null {
89
+ if (raw === undefined || raw === null || raw === "") return null;
90
+ if (isTimezone(raw)) return null;
91
+ return (
92
+ `timezone: ${String(raw)} — not a zone; use an IANA name (Australia/Sydney) ` +
93
+ `or a fixed offset (UTC+10, +10:00, -05:30)`
94
+ );
95
+ }
96
+
97
+ /** One level of the cascade: where the value was written, and what it said. */
98
+ export interface ZoneLevel {
99
+ /** The word the trace uses: "agent", "flow", "workspace", "account". */
100
+ level: string;
101
+ value: unknown;
102
+ }
103
+
104
+ export interface ClockChoice {
105
+ /** The zone, ready for Intl. */
106
+ timezone: string;
107
+ /** The value for `TZ`. */
108
+ tz: string;
109
+ /** Which level set it — or "FOLDRUN_TIMEZONE", or "default". */
110
+ source: string;
111
+ /** One sentence per unusable value found on the way down. Reported as a
112
+ * step event; never thrown, because a mistyped zone must not stop work. */
113
+ problems: string[];
114
+ }
115
+
116
+ /**
117
+ * Walk the levels, nearest first, and take the first zone that works.
118
+ *
119
+ * `FOLDRUN_TIMEZONE` is appended by the caller's level list or read here as
120
+ * the last written level; UTC is what remains when nothing said anything —
121
+ * the clock the pods actually run on.
122
+ */
123
+ export function resolveClock(levels: ZoneLevel[], env = process.env.FOLDRUN_TIMEZONE): ClockChoice {
124
+ const problems: string[] = [];
125
+ const bad: string[] = [];
126
+ const all = [...levels, { level: "FOLDRUN_TIMEZONE", value: env }];
127
+ for (const { level, value } of all) {
128
+ const written = typeof value === "string" ? value.trim() : "";
129
+ if (!written) continue;
130
+ const zone = normalizeZone(written);
131
+ if (zone) {
132
+ for (const b of bad) problems.push(`${b} — using ${zone.name} (${level}) instead`);
133
+ return { timezone: zone.name, tz: zone.tz, source: level, problems };
134
+ }
135
+ bad.push(`timezone: "${written}" (${level}) is not a zone this runtime knows`);
136
+ }
137
+ for (const b of bad) problems.push(`${b} — using UTC instead`);
138
+ return { timezone: "UTC", tz: "UTC", source: "default", problems };
139
+ }
140
+
141
+ /** YYYY-MM-DD in a timezone — the date the step believes it is. */
142
+ export function localDate(timeZone: string, now = new Date()): string {
143
+ return new Intl.DateTimeFormat("en-CA", {
144
+ timeZone,
145
+ year: "numeric",
146
+ month: "2-digit",
147
+ day: "2-digit",
148
+ }).format(now);
149
+ }