@foldrun/core 0.4.0 → 0.6.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 (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -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/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
@@ -0,0 +1,568 @@
1
+ // Orchestration patterns as markdown edits.
2
+ //
3
+ // Each pattern a person can reach for — chain a step, run one in parallel,
4
+ // route on a verdict, fan out, loop until a critic approves, gate on a
5
+ // human, ask a question first, wait, rescue a failure — is a small, exact
6
+ // change to a flow file. So is each change to an agent's team: a sub-agent
7
+ // delegated to (`subagents:`), a colleague consulted (`agents:`), permission
8
+ // to ask a person mid-step (`ask` in `tools:`). This module is those edits,
9
+ // once, for every surface that makes them: the dashboard's flow canvas today,
10
+ // the CLI's verbs next.
11
+ //
12
+ // Every edit is line-level surgery. Flow edits compose core's own rewrites
13
+ // where one exists (addFlowStep, reorderFlowSteps), so every surface writes
14
+ // the same file for the same gesture; what has no rewrite yet — an option
15
+ // updateFlowStep does not manage (ask:, wait:, on-fail:, each: rows/items),
16
+ // the `!` marker — is done here one step's lines at a time and re-parsed: an
17
+ // edit the parser then flags on that step is refused, never written. Agent
18
+ // edits touch only the frontmatter lines they name, so comments, key order
19
+ // and the prose survive byte for byte — never a YAML re-dump.
20
+ //
21
+ // Pure: string in, string out. The caller reads and writes the file.
22
+ import { parseFlow, addFlowStep, reorderFlowSteps, parseWait, assertSafeName } from "./store.js";
23
+ import { joinGroup, splitToRail } from "./arrange.js";
24
+ import { lintFlow } from "./flow-lint.js";
25
+ import { toLF, asRaw } from "./eol.js";
26
+ import { removalImpact, stepLabel } from "./step-removal.js";
27
+ // ---------- flow files ----------
28
+ /** A flow's steps as groups of indices into its parsed steps — what
29
+ * reorderFlowSteps and the arrange helpers take. */
30
+ export function flowGroups(steps) {
31
+ const map = new Map();
32
+ steps.forEach((s, i) => map.set(s.group, [...(map.get(s.group) ?? []), i]));
33
+ return [...map.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
34
+ }
35
+ /** The step options a pattern writes. Everything else in a step stays the
36
+ * author's (and updateFlowStep's) business. */
37
+ export const PATTERN_OPTION_KEYS = [
38
+ "ask", "wait", "on-fail", "each", "max", "loop", "until", "verify", "case", "else", "when", "retry", "approve",
39
+ ];
40
+ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
41
+ const MARKER_RE = /^(\s*\d+)([?!])?(\.?\s+\[\[)/;
42
+ function stepAt(raw, index) {
43
+ const s = parseFlow("flow.md", raw).steps[index];
44
+ if (!s)
45
+ throw new Error(`no step ${index}`);
46
+ return s;
47
+ }
48
+ /** [first, end) of step `index`'s lines in raw.split("\n"): its step line
49
+ * through the line before the next step (or the end of the file). */
50
+ function stepSpan(raw, index) {
51
+ const steps = parseFlow("flow.md", raw).steps;
52
+ const s = steps[index];
53
+ if (!s?.line)
54
+ throw new Error(`no step ${index}`);
55
+ const first = s.line - 1;
56
+ const lines = raw.split("\n");
57
+ const later = steps.map((t) => (t.line ?? 0) - 1).filter((l) => l > first);
58
+ const end = later.length ? Math.min(...later) : lines.length;
59
+ return { lines, first, end };
60
+ }
61
+ const oneLine = (v) => v.replace(/[\r\n]+/g, " ").trim();
62
+ /** Check a value before it is written, in the words the parser would use. */
63
+ function checkOption(key, value) {
64
+ const n = Number(value);
65
+ switch (key) {
66
+ case "loop":
67
+ if (!Number.isInteger(n) || n < 1 || n > 5)
68
+ throw new Error("loop: is a whole number of extra cycles, 1 to 5");
69
+ break;
70
+ case "max":
71
+ if (!Number.isInteger(n) || n < 1 || n > 20)
72
+ throw new Error("max: is a whole number of items, 1 to 20");
73
+ break;
74
+ case "retry":
75
+ if (!Number.isInteger(n) || n < 0 || n > 5)
76
+ throw new Error("retry: is a whole number of extra attempts, at most 5");
77
+ break;
78
+ case "wait":
79
+ if (!/^event$/i.test(value) && parseWait(value) === undefined)
80
+ throw new Error("wait: is event, or a duration: 90s, 30m, 4h, 3d");
81
+ break;
82
+ case "each":
83
+ if (!/^(lines|items|rows of \S.*)$/.test(value))
84
+ throw new Error("each: is lines, items, or rows of <path>");
85
+ break;
86
+ case "on-fail":
87
+ assertSafeName(value.replace(/^\[\[|\]\]$/g, ""), "on-fail agent");
88
+ break;
89
+ }
90
+ }
91
+ /**
92
+ * Set (or clear, with null or "") options on one step. An option already
93
+ * there is rewritten on its own line, where it was; a new one goes after the
94
+ * step's run of indented lines, at the indent its siblings use — never after
95
+ * prose below the step.
96
+ */
97
+ function setStepOptionsLF(raw, index, set) {
98
+ const before = stepAt(raw, index).problems?.length ?? 0;
99
+ const { lines, first, end } = stepSpan(raw, index);
100
+ const block = lines.slice(first, end);
101
+ const indent = block.slice(1).map((l) => l.match(OPTION_RE)?.[1]).find(Boolean) ?? " ";
102
+ const keyOf = (l) => {
103
+ const k = l.match(OPTION_RE)?.[2];
104
+ return k === "onfail" ? "on-fail" : k;
105
+ };
106
+ for (const [key, given] of Object.entries(set)) {
107
+ if (!PATTERN_OPTION_KEYS.includes(key))
108
+ throw new Error(`a pattern does not write ${key}:`);
109
+ const at = block.findIndex((l, i) => i > 0 && keyOf(l) === key);
110
+ if (given === null || given === undefined || oneLine(given) === "") {
111
+ if (at !== -1)
112
+ block.splice(at, 1);
113
+ continue;
114
+ }
115
+ let value = oneLine(given);
116
+ if (key === "on-fail")
117
+ value = `[[${value.replace(/^\[\[|\]\]$/g, "")}]]`;
118
+ checkOption(key, value);
119
+ const line = `${indent}${key}: ${value}`;
120
+ if (at !== -1)
121
+ block[at] = line;
122
+ else {
123
+ let last = 0;
124
+ while (last + 1 < block.length && /^\s+\S/.test(block[last + 1]))
125
+ last++;
126
+ block.splice(last + 1, 0, line);
127
+ }
128
+ }
129
+ const out = [...lines.slice(0, first), ...block, ...lines.slice(end)].join("\n");
130
+ const after = stepAt(out, index).problems ?? [];
131
+ if (after.length > before)
132
+ throw new Error(after[after.length - 1]);
133
+ return out;
134
+ }
135
+ /** Turn the approval gate on or off: `!` on the step line where it can go;
136
+ * an optional step (`?`) already has its one marker, so it gets
137
+ * `approve: true` instead. Off removes either spelling. */
138
+ function setStepApproveLF(raw, index, on) {
139
+ const { lines, first } = stepSpan(raw, index);
140
+ const m = lines[first].match(MARKER_RE);
141
+ if (!m)
142
+ throw new Error(`step ${index} is not a step line`);
143
+ const marker = m[2] ?? "";
144
+ if (on) {
145
+ if (stepAt(raw, index).approve)
146
+ return raw;
147
+ if (marker === "?")
148
+ return setStepOptionsLF(raw, index, { approve: "true" });
149
+ lines[first] = lines[first].replace(MARKER_RE, "$1!$3");
150
+ return lines.join("\n");
151
+ }
152
+ let out = raw;
153
+ if (marker === "!") {
154
+ lines[first] = lines[first].replace(MARKER_RE, "$1$3");
155
+ out = lines.join("\n");
156
+ }
157
+ return setStepOptionsLF(out, index, { approve: null });
158
+ }
159
+ /** A new step, placed; the file, the new step's index and its column. */
160
+ function insertFlowStepLF(raw, o) {
161
+ let text = addFlowStep(raw, { target: o.target, subflow: o.subflow, instruction: o.instruction });
162
+ const steps = parseFlow("flow.md", text).steps;
163
+ const added = steps.length - 1;
164
+ let groups = flowGroups(steps);
165
+ if ("column" in o.at) {
166
+ const anchor = groups[o.at.column]?.[0];
167
+ if (anchor === undefined || anchor === added)
168
+ throw new Error(`no step ${o.at.column + 1} to run beside`);
169
+ groups = joinGroup(groups, added, anchor);
170
+ }
171
+ else {
172
+ groups = splitToRail(groups, added, o.at.rail);
173
+ }
174
+ text = reorderFlowSteps(text, groups);
175
+ const index = groups.flat().indexOf(added);
176
+ const column = groups.findIndex((g) => g.includes(added));
177
+ if (o.options && Object.keys(o.options).length)
178
+ text = setStepOptionsLF(text, index, o.options);
179
+ return { text, index, column };
180
+ }
181
+ /** The problems that make `check` fail, as a multiset of messages: the
182
+ * parser's own per-step ones and the lint's error-level ones. */
183
+ function errorsOf(raw) {
184
+ const flow = parseFlow("flow.md", raw);
185
+ return [
186
+ ...flow.steps.flatMap((s) => s.problems ?? []),
187
+ ...lintFlow(flow).filter((w) => w.level === "error").map((w) => w.message.replace(/step \d+/g, "step")),
188
+ ];
189
+ }
190
+ /**
191
+ * What else changes when step `index` goes — said before it goes. The rule
192
+ * lives in step-removal.ts, which imports no parser, so the canvas can say
193
+ * it in the browser from the steps it already has.
194
+ */
195
+ function removeStepImpactLF(raw, index) {
196
+ return removalImpact(parseFlow("flow.md", raw).steps, index);
197
+ }
198
+ /**
199
+ * Delete step `index` (its index in the parsed steps): its step line and the
200
+ * indented option lines under it, nothing else — prose after the step stays.
201
+ * When that empties its group, the groups after it renumber so there is no
202
+ * gap. The agent's file is not touched. Refused when the result would give
203
+ * `check` an error it did not have (a `case:` step left first, with nothing
204
+ * to route on).
205
+ */
206
+ function removeStepLF(raw, index) {
207
+ const steps = parseFlow("flow.md", raw).steps;
208
+ if (!Number.isInteger(index) || !steps[index])
209
+ throw new Error(`no step ${index + 1}`);
210
+ const emptied = flowGroups(steps).some((g) => g.length === 1 && g[0] === index);
211
+ const { lines, owned } = stepBlockLines(raw, index);
212
+ const drop = new Set(owned);
213
+ let out = lines.filter((_, i) => !drop.has(i)).join("\n");
214
+ if (emptied && steps.length > 1)
215
+ out = reorderFlowSteps(out, flowGroups(parseFlow("flow.md", out).steps));
216
+ assertNoNewErrors(raw, out, `removing ${stepLabel(steps, index)}`);
217
+ return out;
218
+ }
219
+ /** Throw when `out` has a `check` error `raw` did not — counted as a
220
+ * multiset, so a second copy of an existing error is a new one. */
221
+ function assertNoNewErrors(raw, out, what) {
222
+ const had = new Map();
223
+ for (const e of errorsOf(raw))
224
+ had.set(e, (had.get(e) ?? 0) + 1);
225
+ for (const e of errorsOf(out)) {
226
+ const n = had.get(e) ?? 0;
227
+ if (n)
228
+ had.set(e, n - 1);
229
+ else
230
+ throw new Error(`${what} would leave the flow with an error: ${e}`);
231
+ }
232
+ }
233
+ /** The lines of step `index`, by index into raw.split("\n"): its step line,
234
+ * the indented lines directly under it, and every later option line the
235
+ * parser still gives this step — an option after a blank line or a note
236
+ * is the step's too (with the deeper-indented lines of a `schema:` block
237
+ * under it). Never the prose or blank lines around them. */
238
+ function stepBlockLines(raw, index) {
239
+ const { lines, first, end } = stepSpan(raw, index);
240
+ const owned = [first];
241
+ let i = first + 1;
242
+ while (i < end && /^\s+\S/.test(lines[i]))
243
+ owned.push(i++);
244
+ for (; i < end; i++) {
245
+ const opt = lines[i].match(OPTION_RE);
246
+ if (!opt)
247
+ continue;
248
+ owned.push(i);
249
+ const depth = opt[1].length;
250
+ while (i + 1 < end && (lines[i + 1].match(/^(\s*)\S/)?.[1].length ?? 0) > depth)
251
+ owned.push(++i);
252
+ }
253
+ return { lines, owned };
254
+ }
255
+ /** Step `index` as markdown — what the canvas copies to the clipboard and
256
+ * pasteSteps reads back: the step line (group number, `?`/`!` marker,
257
+ * link, instruction) and its indented options. */
258
+ function stepSourceLF(raw, index) {
259
+ const { lines, owned } = stepBlockLines(raw, index);
260
+ return owned.map((i) => lines[i].replace(/\r$/, "")).join("\n");
261
+ }
262
+ /**
263
+ * Duplicate step `index`: the copy — step line with its marker, every
264
+ * indented option — goes directly under the original with the same group
265
+ * number, so it runs in parallel with it. Nothing else moves. Refused when
266
+ * `check` would gain an error.
267
+ */
268
+ function duplicateStepLF(raw, index) {
269
+ const steps = parseFlow("flow.md", raw).steps;
270
+ if (!Number.isInteger(index) || !steps[index])
271
+ throw new Error(`no step ${index + 1}`);
272
+ const { lines, owned } = stepBlockLines(raw, index);
273
+ const end = owned[owned.length - 1] + 1;
274
+ const out = [...lines.slice(0, end), ...owned.map((i) => lines[i]), ...lines.slice(end)].join("\n");
275
+ assertNoNewErrors(raw, out, `duplicating ${stepLabel(steps, index)}`);
276
+ return out;
277
+ }
278
+ const PASTE_STEP_RE = /^\s*(\d+)([?!])?(\.?\s+\[\[.*)$/;
279
+ /** Copied step markdown → blocks, each with the group number it was
280
+ * copied with. Blank lines between blocks are fine; anything else that is
281
+ * neither a step line nor an indented line under one is refused. */
282
+ function pastedBlocks(text) {
283
+ const blocks = [];
284
+ for (const rawLine of text.replace(/\r\n?/g, "\n").split("\n")) {
285
+ const line = rawLine.replace(/\s+$/, "");
286
+ if (!line.trim())
287
+ continue;
288
+ const m = line.match(PASTE_STEP_RE);
289
+ if (m) {
290
+ blocks.push({ group: Number(m[1]), lines: [`${m[1]}${m[2] ?? ""}${m[3]}`] });
291
+ }
292
+ else if (/^\s+\S/.test(line) && blocks.length) {
293
+ blocks[blocks.length - 1].lines.push(line);
294
+ }
295
+ else {
296
+ throw new Error(`not a copied step: "${line.trim().slice(0, 60)}" — paste step lines and their indented options`);
297
+ }
298
+ }
299
+ if (!blocks.length)
300
+ throw new Error("nothing to paste — no step line (\"1. [[agent]] — …\")");
301
+ return blocks;
302
+ }
303
+ /**
304
+ * Paste copied steps (stepSource output, one block or several) into a flow.
305
+ * Steps copied with the same group number stay parallel with each other.
306
+ * `{ rail }` places the pasted groups as their own consecutive groups there
307
+ * (0 is before every group); `{ column }` puts every pasted step in that
308
+ * existing group. Groups renumber; prose stays where it was. Returns the
309
+ * file and the pasted steps' indices in it. Refused when the paste does not
310
+ * parse as those steps, or when `check` would gain an error.
311
+ */
312
+ function pasteStepsLF(raw, text, at) {
313
+ const blocks = pastedBlocks(text);
314
+ const before = parseFlow("flow.md", raw).steps;
315
+ const top = before.reduce((m, s) => Math.max(m, s.group), 0);
316
+ // Pasted groups get numbers above every existing one, so they sort last
317
+ // and their indices are the tail of the parse; reorderFlowSteps then puts
318
+ // them where they belong and renumbers everything.
319
+ const order = [...new Set(blocks.map((b) => b.group))].sort((a, b) => a - b);
320
+ const renumbered = blocks.map((b) => {
321
+ const g = top + 1 + order.indexOf(b.group);
322
+ return [b.lines[0].replace(/^\d+/, String(g)), ...b.lines.slice(1)];
323
+ });
324
+ const added = renumbered.flat();
325
+ let merged;
326
+ if (before.length) {
327
+ // Above the first step line: whatever prose trails the last step stays
328
+ // with it instead of following the pasted ones.
329
+ const lines = raw.split("\n");
330
+ const first = Math.min(...before.map((s) => (s.line ?? 1) - 1));
331
+ merged = [...lines.slice(0, first), ...added, ...lines.slice(first)].join("\n");
332
+ }
333
+ else {
334
+ merged = `${raw.replace(/\s+$/, "")}\n\n${added.join("\n")}\n`;
335
+ }
336
+ const steps = parseFlow("flow.md", merged).steps;
337
+ if (steps.length !== before.length + blocks.length) {
338
+ throw new Error(`the paste parsed as ${steps.length - before.length} steps, not ${blocks.length}`);
339
+ }
340
+ const pasted = steps.map((_, i) => i).slice(before.length);
341
+ const groups = flowGroups(steps);
342
+ const old = groups.filter((g) => g.every((i) => i < before.length));
343
+ const fresh = groups.filter((g) => g.some((i) => i >= before.length));
344
+ let next;
345
+ if ("column" in at) {
346
+ if (!old[at.column])
347
+ throw new Error(`no step ${at.column + 1} to run beside`);
348
+ next = old.map((g, k) => (k === at.column ? [...g, ...pasted] : g));
349
+ }
350
+ else {
351
+ if (!Number.isInteger(at.rail) || at.rail < 0 || at.rail > old.length)
352
+ throw new Error(`no place ${at.rail} — 0 to ${old.length}`);
353
+ next = [...old.slice(0, at.rail), ...fresh, ...old.slice(at.rail)];
354
+ }
355
+ const out = reorderFlowSteps(merged, next);
356
+ assertNoNewErrors(raw, out, "pasting");
357
+ const flat = next.flat();
358
+ return { text: out, indices: pasted.map((i) => flat.indexOf(i)).sort((a, b) => a - b) };
359
+ }
360
+ /** One pattern, applied to a flow file. */
361
+ function applyPatternEditLF(raw, edit) {
362
+ switch (edit.op) {
363
+ case "insert":
364
+ return insertFlowStepLF(raw, edit).text;
365
+ case "router": {
366
+ const cases = (edit.cases ?? []).filter((c) => oneLine(c.value ?? "") && c.target);
367
+ if (!cases.length)
368
+ throw new Error("a router needs at least one case: and its agent");
369
+ const head = insertFlowStepLF(raw, { target: edit.router, instruction: edit.instruction, at: { rail: edit.rail } });
370
+ let text = head.text;
371
+ const branches = [
372
+ ...cases.map((c) => ({ target: c.target, options: { case: oneLine(c.value) } })),
373
+ ...(edit.else ? [{ target: edit.else, options: { else: "true" } }] : []),
374
+ ];
375
+ branches.forEach((b, k) => {
376
+ text = insertFlowStepLF(text, {
377
+ target: b.target,
378
+ at: k === 0 ? { rail: head.column + 1 } : { column: head.column + 1 },
379
+ options: b.options,
380
+ }).text;
381
+ });
382
+ return text;
383
+ }
384
+ case "options":
385
+ return setStepOptionsLF(raw, edit.step, edit.set);
386
+ case "approve":
387
+ return setStepApproveLF(raw, edit.step, edit.on);
388
+ case "remove":
389
+ return removeStepLF(raw, edit.step);
390
+ case "duplicate":
391
+ return duplicateStepLF(raw, edit.step);
392
+ case "paste":
393
+ return pasteStepsLF(raw, edit.text, edit.at).text;
394
+ default:
395
+ throw new Error(`unknown pattern edit ${edit.op}`);
396
+ }
397
+ }
398
+ // The flow edits work on LF text: the parser and every line rule here read
399
+ // "\n"-separated lines, and a CRLF line's trailing "\r" defeats them (an
400
+ // option line never matched, so setting it again added a second copy). So
401
+ // each edit takes a CRLF file as LF and gives it back as CRLF.
402
+ export const setStepOptions = (raw, index, set) => asRaw(raw, setStepOptionsLF(toLF(raw), index, set));
403
+ export const setStepApprove = (raw, index, on) => asRaw(raw, setStepApproveLF(toLF(raw), index, on));
404
+ export function insertFlowStep(...[raw, o]) {
405
+ const r = insertFlowStepLF(toLF(raw), o);
406
+ return { ...r, text: asRaw(raw, r.text) };
407
+ }
408
+ export const removeStepImpact = (raw, index) => removeStepImpactLF(toLF(raw), index);
409
+ export const removeStep = (raw, index) => asRaw(raw, removeStepLF(toLF(raw), index));
410
+ export const stepSource = (raw, index) => stepSourceLF(toLF(raw), index);
411
+ export const duplicateStep = (raw, index) => asRaw(raw, duplicateStepLF(toLF(raw), index));
412
+ export function pasteSteps(...[raw, text, at]) {
413
+ const r = pasteStepsLF(toLF(raw), text, at);
414
+ return { ...r, text: asRaw(raw, r.text) };
415
+ }
416
+ export const applyPatternEdit = (raw, edit) => asRaw(raw, applyPatternEditLF(toLF(raw), edit));
417
+ // ---------- agent files ----------
418
+ /** The frontmatter list fields an agent's team lives in: whom it delegates
419
+ * to, whom it consults, and what it may do (`ask` among them). */
420
+ export const TEAM_LIST_KEYS = ["subagents", "agents", "tools"];
421
+ function eolOf(raw) {
422
+ return raw.includes("\r\n") ? "\r\n" : "\n";
423
+ }
424
+ function frontBounds(lines) {
425
+ if (lines[0]?.trim() !== "---")
426
+ return null;
427
+ for (let i = 1; i < lines.length; i++)
428
+ if (lines[i].trim() === "---")
429
+ return { close: i };
430
+ return null;
431
+ }
432
+ /** A list item as the parser reads it: quotes and [[ ]] stripped. */
433
+ function itemName(raw) {
434
+ return raw.trim().replace(/^["']|["']$/g, "").replace(/^\[\[|\]\]$/g, "").trim();
435
+ }
436
+ function splitComment(value) {
437
+ const m = value.match(/^(.*?)(\s+#.*)$/);
438
+ return m ? { value: m[1], comment: m[2] } : { value, comment: "" };
439
+ }
440
+ function keyLine(lines, close, key) {
441
+ const re = new RegExp(`^${key.replace(/[-]/g, "\\-")}:(.*)$`);
442
+ for (let i = 1; i < close; i++) {
443
+ const m = lines[i].match(re);
444
+ if (m)
445
+ return { index: i, rest: m[1] };
446
+ }
447
+ return null;
448
+ }
449
+ /** Block-style item lines under a key: ` - name`, until anything else. */
450
+ function blockItems(lines, from, close) {
451
+ const items = [];
452
+ for (let i = from + 1; i < close; i++) {
453
+ const l = lines[i];
454
+ if (/^\s*-(\s|$)/.test(l))
455
+ items.push(i);
456
+ else if (/^\s*(#.*)?$/.test(l))
457
+ continue; // blank or comment inside the block
458
+ else
459
+ break;
460
+ }
461
+ return items;
462
+ }
463
+ const blockItemName = (line) => itemName(line.replace(/^\s*-\s*/, "").replace(/\s+#.*$/, ""));
464
+ /** The names a frontmatter list holds, read the way the file says them. */
465
+ export function frontmatterList(raw, key) {
466
+ const lines = raw.split(eolOf(raw));
467
+ const b = frontBounds(lines);
468
+ if (!b)
469
+ return [];
470
+ const k = keyLine(lines, b.close, key);
471
+ if (!k)
472
+ return [];
473
+ const v = splitComment(k.rest).value.trim();
474
+ if (v.startsWith("["))
475
+ return v.replace(/^\[|\]$/g, "").split(",").map(itemName).filter(Boolean);
476
+ if (v)
477
+ return [itemName(v)];
478
+ return blockItems(lines, k.index, b.close).map((i) => blockItemName(lines[i])).filter(Boolean);
479
+ }
480
+ /**
481
+ * Add `name` to, or remove it from, the list under `key`, touching only the
482
+ * lines that list is on. Flow style (`key: [a, b]`) stays flow style, block
483
+ * style stays block style, a trailing `# comment` stays put, and the other
484
+ * items keep their spelling. A list emptied by a removal is written
485
+ * `key: []` rather than deleted: the file still says "none", which for
486
+ * `tools:` is not the same as saying nothing.
487
+ */
488
+ export function editFrontmatterList(raw, key, name, action) {
489
+ const eol = eolOf(raw);
490
+ const lines = raw.split(eol);
491
+ const b = frontBounds(lines);
492
+ if (!b)
493
+ return action === "remove" ? raw : ["---", `${key}: [${name}]`, "---", ...lines].join(eol);
494
+ const has = frontmatterList(raw, key).includes(name);
495
+ if ((action === "add") === has)
496
+ return raw;
497
+ const k = keyLine(lines, b.close, key);
498
+ if (!k) {
499
+ lines.splice(b.close, 0, `${key}: [${name}]`);
500
+ return lines.join(eol);
501
+ }
502
+ const { value, comment } = splitComment(k.rest);
503
+ const v = value.trim();
504
+ if (v) {
505
+ // Flow style, or a lone scalar (read as a one-item list): that one line.
506
+ const tokens = v.startsWith("[") ? v.replace(/^\[|\]$/g, "").split(",").map((t) => t.trim()).filter(Boolean) : [v];
507
+ const next = action === "add" ? [...tokens, name] : tokens.filter((t) => itemName(t) !== name);
508
+ lines[k.index] = `${key}: [${next.join(", ")}]${comment}`;
509
+ return lines.join(eol);
510
+ }
511
+ const items = blockItems(lines, k.index, b.close);
512
+ if (action === "add") {
513
+ const indent = items.length ? lines[items[0]].match(/^(\s*)-/)[1] : " ";
514
+ lines.splice(items.length ? items[items.length - 1] + 1 : k.index + 1, 0, `${indent}- ${name}`);
515
+ return lines.join(eol);
516
+ }
517
+ const drop = items.filter((i) => blockItemName(lines[i]) === name);
518
+ for (const i of [...drop].reverse())
519
+ lines.splice(i, 1);
520
+ if (drop.length === items.length)
521
+ lines[k.index] = `${key}: []${comment}`;
522
+ return lines.join(eol);
523
+ }
524
+ const YAML_WORDS = /^(true|false|yes|no|on|off|null|~|y|n)$/i;
525
+ /** A YAML scalar for a one-line value: plain when plain reads back as the
526
+ * same string, double-quoted (JSON is valid YAML) when it would not. */
527
+ export function yamlScalar(value) {
528
+ const v = oneLine(value);
529
+ // Plain in block context may hold anything but ": " and " #", once it
530
+ // starts with a letter; YAML's words for true/false/null are not strings.
531
+ const plain = /^[A-Za-z(]/.test(v) && !/:(\s|$)|\s#|\t/.test(v) && !YAML_WORDS.test(v);
532
+ return plain ? v : JSON.stringify(v);
533
+ }
534
+ /** Set one scalar key — `description:`, `model:`. Replaces its line (and the
535
+ * indented lines of a `>`/`|` block under it); absent, it goes right after
536
+ * `name:`, or last. */
537
+ export function setFrontmatterScalar(raw, key, value) {
538
+ const eol = eolOf(raw);
539
+ const lines = raw.split(eol);
540
+ const b = frontBounds(lines);
541
+ const line = `${key}: ${yamlScalar(value)}`;
542
+ if (!b)
543
+ return ["---", line, "---", ...lines].join(eol);
544
+ const k = keyLine(lines, b.close, key);
545
+ if (k) {
546
+ const { value: v, comment } = splitComment(k.rest);
547
+ let end = k.index + 1;
548
+ if (/^\s*[|>][-+]?\d*\s*$/.test(v) || !v.trim()) {
549
+ while (end < b.close && /^\s+\S/.test(lines[end]))
550
+ end++;
551
+ }
552
+ lines.splice(k.index, end - k.index, `${line}${comment}`);
553
+ return lines.join(eol);
554
+ }
555
+ const name = keyLine(lines, b.close, "name");
556
+ lines.splice(name ? name.index + 1 : b.close, 0, line);
557
+ return lines.join(eol);
558
+ }
559
+ /** A new agent file: the template with the answers to "what is it for"
560
+ * and "which model" written into it, the rest as the template wrote it. */
561
+ export function newAgentFile(template, o) {
562
+ let out = template;
563
+ if (o.description?.trim())
564
+ out = setFrontmatterScalar(out, "description", o.description);
565
+ if (o.model?.trim())
566
+ out = setFrontmatterScalar(out, "model", o.model);
567
+ return out;
568
+ }
@@ -35,6 +35,24 @@ export interface RefInfo {
35
35
  at: string;
36
36
  }
37
37
  export declare function listRefs(tenant: string, scope: string, kind: "heads" | "tags"): RefInfo[];
38
+ /**
39
+ * The commit a restore means by `to`, on main's history:
40
+ *
41
+ * a sha or a prefix of one that commit
42
+ * a branch or tag name where it points
43
+ * an ISO date or time the last commit on main at or before it
44
+ * "3d", "12h", "90m", "2w" the same, that long ago
45
+ *
46
+ * Null when nothing matches — before the first commit, or a name that is
47
+ * not there. Throws on a `to` that cannot be any of these, so a typo is a
48
+ * 400 rather than a silent "nothing found".
49
+ */
50
+ export declare function resolveRestorePoint(tenant: string, scope: string, to: string, now?: number): {
51
+ sha: string;
52
+ at: string;
53
+ by: string;
54
+ message: string;
55
+ } | null;
38
56
  /** The files at a commit, for a browser or a deploy. */
39
57
  export declare function listTree(tenant: string, scope: string, ref?: string): {
40
58
  path: string;
@@ -207,6 +207,55 @@ export function listRefs(tenant, scope, kind) {
207
207
  return { name, sha, subject, at };
208
208
  });
209
209
  }
210
+ /**
211
+ * The commit a restore means by `to`, on main's history:
212
+ *
213
+ * a sha or a prefix of one that commit
214
+ * a branch or tag name where it points
215
+ * an ISO date or time the last commit on main at or before it
216
+ * "3d", "12h", "90m", "2w" the same, that long ago
217
+ *
218
+ * Null when nothing matches — before the first commit, or a name that is
219
+ * not there. Throws on a `to` that cannot be any of these, so a typo is a
220
+ * 400 rather than a silent "nothing found".
221
+ */
222
+ export function resolveRestorePoint(tenant, scope, to, now = Date.now()) {
223
+ const t = to.trim();
224
+ if (!t || t.startsWith("-"))
225
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
226
+ if (!headSha(tenant, scope))
227
+ return null;
228
+ const dir = repoDir(tenant, scope);
229
+ let sha = null;
230
+ const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
231
+ const moment = rel
232
+ ? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]]
233
+ : /^\d{4}-\d{2}-\d{2}/.test(t)
234
+ ? Date.parse(t)
235
+ : null;
236
+ if (moment !== null) {
237
+ if (Number.isNaN(moment))
238
+ throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
239
+ sha = git(dir, ["log", "-1", "--format=%H", `--until=@${Math.floor(moment / 1000)}`, "refs/heads/main"]).trim() || null;
240
+ }
241
+ else {
242
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$/.test(t))
243
+ throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
244
+ for (const ref of [/^[0-9a-f]{4,40}$/.test(t) ? `${t}^{commit}` : null, `refs/heads/${t}^{commit}`, `refs/tags/${t}^{commit}`]) {
245
+ if (!ref)
246
+ continue;
247
+ const r = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", ref]);
248
+ if (r.status === 0) {
249
+ sha = r.stdout.toString().trim();
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ if (!sha)
255
+ return null;
256
+ const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%cI${SEP}%ae${SEP}%s`, sha]).trim().split(SEP);
257
+ return { sha, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message: msg.join(SEP) };
258
+ }
210
259
  /** The files at a commit, for a browser or a deploy. */
211
260
  export function listTree(tenant, scope, ref = "refs/heads/main") {
212
261
  if (!headSha(tenant, scope, ref))
@@ -19,6 +19,11 @@
19
19
  const EXACT = new Set([
20
20
  "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
21
21
  "TZ", "TERM", "SHELL", "USER", "LOGNAME",
22
+ // Where Claude Code / the Agent SDK keeps its config and session
23
+ // transcripts — the same kind of thing as HOME (a path, not a secret), and
24
+ // without it an operator cannot keep them out of the host user's
25
+ // ~/.claude: the box's deploy tests set it to the job's temp (2026-10-02).
26
+ "CLAUDE_CONFIG_DIR",
22
27
  // The host's way to the network is the child's way to the network.
23
28
  "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
24
29
  "SSL_CERT_FILE", "SSL_CERT_DIR",
package/dist/src/kinds.js CHANGED
@@ -62,7 +62,7 @@ name: ${name}
62
62
  description: What this agent is for, in one line.
63
63
  model: fast
64
64
  effort: high
65
- tools: [files]
65
+ tools: [write]
66
66
  ---
67
67
 
68
68
  # ${name}