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