@foldrun/core 0.2.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 (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,347 @@
1
+ // Two tools over what an agent already has and cannot easily reach at scale:
2
+ // its documents, and its own past.
3
+ //
4
+ // tools: [search] search_files — ranked full-text search over knowledge,
5
+ // memory, state and storage, all three scopes
6
+ // tools: [history] recall_runs / read_run — what this workspace's earlier
7
+ // runs concluded, and what their steps said
8
+ // tools: [desks] recall_desk_runs / read_desk_run — the same, for every
9
+ // OTHER workspace in the account
10
+ //
11
+ // Both take values, not stores: a list of directories, a list of run
12
+ // digests. That is what lets the same servers be rebuilt inside a run
13
+ // container, where the vault, the run journal and the account do not exist
14
+ // — the host gathers, the boundary is crossed as JSON, the container serves.
15
+ //
16
+ // Search is lexical (BM25 over tokens), not embeddings. It runs anywhere,
17
+ // needs no vendor, and nothing leaves the box — which is the property the
18
+ // search-on-our-box work already paid for. Behind the tool is an inverted
19
+ // index built once per step and refreshed by mtime: a call tokenises only
20
+ // the files that changed since the last one (the agent writing to state/
21
+ // mid-step is the common case) and scores only the postings of the query's
22
+ // terms, so the tenth search over ten thousand files costs what the first
23
+ // one over ten did. Nothing is persisted: a step's index dies with the
24
+ // step, so it can never be stale across a deploy or a hand edit.
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { z } from "zod";
28
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
29
+ const TEXT_EXT = new Set([".md", ".txt", ".csv", ".json", ".yaml", ".yml", ".html", ".tsv"]);
30
+ const MAX_FILE_BYTES = 512_000;
31
+ const MAX_FILES = 5_000;
32
+ function docFrom(root, abs, st) {
33
+ try {
34
+ const text = fs.readFileSync(abs, "utf8");
35
+ const front = text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
36
+ const head = [...front.matchAll(/^(?:name|title|description):\s*(.+)$/gm)].map((m) => m[1]).join(" ");
37
+ const body = tokens(head + " " + head + " " + head + " " + text);
38
+ const tf = new Map();
39
+ for (const t of body)
40
+ tf.set(t, (tf.get(t) ?? 0) + 1);
41
+ return { label: root.label, rel: path.relative(root.dir, abs).replaceAll("\\", "/"), text, head, abs, size: st.size, mtimeMs: st.mtimeMs, tf, length: body.length };
42
+ }
43
+ catch {
44
+ return null; // unreadable: not a hit
45
+ }
46
+ }
47
+ /** Every indexable file under the roots, with the stat the index compares against. */
48
+ function listFiles(roots) {
49
+ const out = [];
50
+ for (const root of roots) {
51
+ if (!fs.existsSync(root.dir))
52
+ continue;
53
+ const walk = (dir) => {
54
+ let entries;
55
+ try {
56
+ entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ for (const e of entries) {
62
+ if (out.length >= MAX_FILES)
63
+ return;
64
+ const abs = path.join(dir, e.name);
65
+ if (e.isDirectory()) {
66
+ if (e.name.startsWith(".") || e.name === "node_modules")
67
+ continue;
68
+ walk(abs);
69
+ continue;
70
+ }
71
+ if (!e.isFile() || !TEXT_EXT.has(path.extname(e.name).toLowerCase()))
72
+ continue;
73
+ // OKF's generated files describe the bundle, not a fact in it.
74
+ if (e.name === "index.md" || e.name === "log.md")
75
+ continue;
76
+ try {
77
+ const st = fs.statSync(abs);
78
+ if (st.size > MAX_FILE_BYTES)
79
+ continue;
80
+ out.push({ root, abs, st });
81
+ }
82
+ catch {
83
+ // vanished between readdir and stat
84
+ }
85
+ }
86
+ };
87
+ walk(root.dir);
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * The inverted index over a set of roots, scored BM25's way — term
93
+ * frequency saturating, rare terms weighing more, long documents not
94
+ * winning by bulk — with the frontmatter head counted three times, because
95
+ * a memory file's name and description are what its author thought it was
96
+ * about. `refresh()` walks the tree (a
97
+ * stat per file — cheap), re-reads only files whose size or mtime moved,
98
+ * drops the ones that went, and rebuilds the postings from the per-file
99
+ * token maps it already holds. `search()` touches only the query terms'
100
+ * postings.
101
+ */
102
+ export class SearchIndex {
103
+ docs = new Map();
104
+ postings = new Map();
105
+ avg = 1;
106
+ /** How many files the last refresh read from disk — what a test watches. */
107
+ lastRead = 0;
108
+ roots;
109
+ constructor(roots) {
110
+ this.roots = roots;
111
+ }
112
+ refresh() {
113
+ const seen = new Set();
114
+ let changed = false;
115
+ this.lastRead = 0;
116
+ for (const { root, abs, st } of listFiles(this.roots)) {
117
+ seen.add(abs);
118
+ const have = this.docs.get(abs);
119
+ if (have && have.size === st.size && have.mtimeMs === st.mtimeMs)
120
+ continue;
121
+ const doc = docFrom(root, abs, st);
122
+ this.lastRead++;
123
+ if (doc)
124
+ this.docs.set(abs, doc);
125
+ else
126
+ this.docs.delete(abs);
127
+ changed = true;
128
+ }
129
+ for (const abs of [...this.docs.keys()]) {
130
+ if (!seen.has(abs)) {
131
+ this.docs.delete(abs);
132
+ changed = true;
133
+ }
134
+ }
135
+ if (changed)
136
+ this.rebuild();
137
+ return this;
138
+ }
139
+ rebuild() {
140
+ this.postings = new Map();
141
+ let total = 0;
142
+ for (const doc of this.docs.values()) {
143
+ total += doc.length;
144
+ for (const [term, tf] of doc.tf) {
145
+ let list = this.postings.get(term);
146
+ if (!list)
147
+ this.postings.set(term, (list = []));
148
+ list.push({ doc, tf });
149
+ }
150
+ }
151
+ this.avg = total / this.docs.size || 1;
152
+ }
153
+ get size() {
154
+ return this.docs.size;
155
+ }
156
+ search(query, limit = 10) {
157
+ const q = [...new Set(tokens(query))];
158
+ if (q.length === 0 || this.docs.size === 0)
159
+ return [];
160
+ const n = this.docs.size;
161
+ const k1 = 1.4;
162
+ const bParam = 0.75;
163
+ const scores = new Map();
164
+ for (const term of q) {
165
+ const list = this.postings.get(term);
166
+ if (!list)
167
+ continue;
168
+ const idf = Math.log(1 + (n - list.length + 0.5) / (list.length + 0.5));
169
+ for (const { doc, tf } of list) {
170
+ const part = idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - bParam + bParam * (doc.length / this.avg))));
171
+ scores.set(doc, (scores.get(doc) ?? 0) + part);
172
+ }
173
+ }
174
+ return [...scores.entries()]
175
+ .sort((a, b) => b[1] - a[1])
176
+ .slice(0, Math.min(Math.max(1, limit), 30))
177
+ .map(([d, score]) => ({ path: `${d.label}${d.rel}`, score: Math.round(score * 100) / 100, snippet: snippetFor(d.text, q) }));
178
+ }
179
+ }
180
+ function tokens(text) {
181
+ return text
182
+ .toLowerCase()
183
+ .split(/[^a-z0-9$@._-]+/)
184
+ .map((t) => t.replace(/^[._-]+|[._-]+$/g, ""))
185
+ .filter((t) => t.length > 1);
186
+ }
187
+ /** The line that mentions the most query terms, trimmed — enough to tell
188
+ * whether to open the file, not a substitute for opening it. */
189
+ function snippetFor(text, q) {
190
+ let best = "";
191
+ let bestN = 0;
192
+ for (const line of text.split("\n")) {
193
+ const l = line.toLowerCase();
194
+ const n = q.filter((t) => l.includes(t)).length;
195
+ if (n > bestN && line.trim()) {
196
+ bestN = n;
197
+ best = line.trim();
198
+ }
199
+ }
200
+ return best.length > 200 ? `${best.slice(0, 197)}…` : best;
201
+ }
202
+ export function searchRoots(roots, query, limit = 10) {
203
+ return new SearchIndex(roots).refresh().search(query, limit);
204
+ }
205
+ export function buildSearchTools(roots) {
206
+ if (roots.length === 0)
207
+ return { server: null, toolNames: [], promptLines: [] };
208
+ // One index per server — per step. Built on the first call, not at
209
+ // start-up, so a step that never searches never pays for it.
210
+ const index = new SearchIndex(roots);
211
+ const search = tool("search_files", `Full-text search over this agent's knowledge, memory, state and stored files (all scopes: own, workspace, account). ` +
212
+ `Returns the best-matching paths with a one-line snippet each — open a hit with Read to use it. ` +
213
+ `Use it instead of guessing which of many files holds a fact.`, {
214
+ query: z.string().describe("Words to look for — names, ids, phrases"),
215
+ limit: z.number().int().min(1).max(30).optional().describe("How many hits (default 10)"),
216
+ }, async (args) => {
217
+ const hits = index.refresh().search(args.query, args.limit ?? 10);
218
+ const text = hits.length
219
+ ? hits.map((h) => `${h.path} (${h.score})\n ${h.snippet}`).join("\n")
220
+ : "no files mention that";
221
+ return { content: [{ type: "text", text }] };
222
+ });
223
+ return {
224
+ server: createSdkMcpServer({ name: "foldrun_search", version: "1.0.0", tools: [search] }),
225
+ toolNames: ["mcp__foldrun_search__search_files"],
226
+ promptLines: [
227
+ `- **search_files(query)** — ranked search across ${roots.map((r) => r.label).join(", ")}. Search first, then Read the hit.`,
228
+ ],
229
+ };
230
+ }
231
+ const STEP_RESULT_CHARS = 6_000;
232
+ /** The digest of a workspace's recent runs — host-side, from the records. */
233
+ export function digestRuns(runs, limit = 30, exceptRunId) {
234
+ return runs
235
+ .filter((r) => r.id !== exceptRunId && (r.status === "completed" || r.status === "failed"))
236
+ .sort((a, b) => (b.startedAt > a.startedAt ? 1 : -1))
237
+ .slice(0, limit)
238
+ .map((r) => ({
239
+ id: r.id,
240
+ flow: r.flow,
241
+ status: r.status,
242
+ startedAt: r.startedAt,
243
+ finishedAt: r.finishedAt,
244
+ summary: r.summary ?? null,
245
+ costUsd: r.steps.reduce((n, s) => n + (s.costUsd ?? 0), 0),
246
+ tags: r.tags ?? [],
247
+ steps: r.steps
248
+ .filter((s) => s.status === "completed" || s.status === "failed")
249
+ .map((s) => ({
250
+ agent: s.agent,
251
+ status: s.status,
252
+ result: s.result ? (s.result.length > STEP_RESULT_CHARS ? `${s.result.slice(0, STEP_RESULT_CHARS)}…` : s.result) : null,
253
+ })),
254
+ }));
255
+ }
256
+ export function buildHistoryTools(digest) {
257
+ const recall = tool("recall_runs", `What this workspace's earlier runs concluded — newest first, one line each (id, flow, when, status, cost, summary). ` +
258
+ `Filter by flow, status or a word. Use it to avoid redoing yesterday's work, to continue where a run left off, ` +
259
+ `or to see what a scheduled flow found last time. Then read_run(id) for the detail.`, {
260
+ limit: z.number().int().min(1).max(50).optional().describe("How many (default 10)"),
261
+ flow: z.string().optional().describe("Only runs of this flow"),
262
+ status: z.enum(["completed", "failed"]).optional(),
263
+ query: z.string().optional().describe("Only runs whose summary or results mention this"),
264
+ }, async (args) => {
265
+ const q = (args.query ?? "").toLowerCase();
266
+ const rows = digest
267
+ .filter((r) => !args.flow || r.flow === args.flow)
268
+ .filter((r) => !args.status || r.status === args.status)
269
+ .filter((r) => !q ||
270
+ (r.summary ?? "").toLowerCase().includes(q) ||
271
+ r.steps.some((s) => (s.result ?? "").toLowerCase().includes(q)))
272
+ .slice(0, args.limit ?? 10)
273
+ .map((r) => `${r.id} ${r.flow} ${r.startedAt.slice(0, 16)} ${r.status} $${r.costUsd.toFixed(3)} ${r.summary ?? "(no summary)"}`);
274
+ return { content: [{ type: "text", text: rows.length ? rows.join("\n") : "no earlier runs match" }] };
275
+ });
276
+ const read = tool("read_run", "One earlier run in full: each step's agent, status and what it replied (long replies trimmed).", { id: z.string().describe("A run id from recall_runs") }, async (args) => {
277
+ const r = digest.find((d) => d.id === args.id);
278
+ if (!r)
279
+ return { content: [{ type: "text", text: `no run ${args.id} in the recent history` }], isError: true };
280
+ const text = `${r.flow} · ${r.status} · started ${r.startedAt}${r.finishedAt ? ` · finished ${r.finishedAt}` : ""} · $${r.costUsd.toFixed(4)}\n` +
281
+ (r.summary ? `summary: ${r.summary}\n` : "") +
282
+ r.steps.map((s) => `\n## ${s.agent} (${s.status})\n${s.result ?? "(no reply)"}`).join("\n");
283
+ return { content: [{ type: "text", text }] };
284
+ });
285
+ return {
286
+ server: createSdkMcpServer({ name: "foldrun_history", version: "1.0.0", tools: [recall, read] }),
287
+ toolNames: ["mcp__foldrun_history__recall_runs", "mcp__foldrun_history__read_run"],
288
+ promptLines: [
289
+ `- **recall_runs()** / **read_run(id)** — this workspace's last ${digest.length} finished runs and what they concluded. ` +
290
+ `Check before repeating work a recent run already did.`,
291
+ ],
292
+ };
293
+ }
294
+ /**
295
+ * The same two verbs across the account's OTHER workspaces.
296
+ *
297
+ * A digest agent has to read what six desks concluded this week, and it
298
+ * cannot: a run pod's network policy denies the platform's own API, and the
299
+ * account library is mounted read-only, so there is no path from inside a
300
+ * sandbox to another workspace's files. This is that path, built the way
301
+ * `history` already is — the host gathers the records, the values cross the
302
+ * boundary as JSON, and the container serves them. Nothing new is reachable
303
+ * from a run; what crosses is what the host decided to send.
304
+ *
305
+ * Read-only and summary-shaped on purpose: run records, not files. An agent
306
+ * that needs another desk's *file* is asking for something this platform
307
+ * deliberately does not have.
308
+ */
309
+ export function buildDeskTools(digest) {
310
+ const workspaces = [...new Set(digest.map((d) => d.workspace ?? "?"))].sort();
311
+ const recall = tool("recall_desk_runs", `What the account's OTHER workspaces' runs concluded — newest first, one line each ` +
312
+ `(workspace, id, flow, when, status, cost, summary). The summary is the run's headline: ` +
313
+ `since every desk's last step opens with its verdict, this is how the week reads at a glance. ` +
314
+ `Then read_desk_run(id) for a run's steps in full.`, {
315
+ workspace: z.string().optional().describe("Only this workspace"),
316
+ flow: z.string().optional().describe("Only runs of this flow"),
317
+ status: z.enum(["completed", "failed"]).optional(),
318
+ since: z.string().optional().describe("ISO date; only runs started on or after it"),
319
+ limit: z.number().int().min(1).max(200).optional().describe("How many (default 30)"),
320
+ }, async (args) => {
321
+ const rows = digest
322
+ .filter((r) => !args.workspace || r.workspace === args.workspace)
323
+ .filter((r) => !args.flow || r.flow === args.flow)
324
+ .filter((r) => !args.status || r.status === args.status)
325
+ .filter((r) => !args.since || r.startedAt >= args.since)
326
+ .slice(0, args.limit ?? 30)
327
+ .map((r) => `${(r.workspace ?? "?").padEnd(12)} ${r.id} ${r.flow} ${r.startedAt.slice(0, 16)} ${r.status} $${r.costUsd.toFixed(3)} ${r.summary ?? "(no summary)"}`);
328
+ return { content: [{ type: "text", text: rows.length ? rows.join("\n") : "no runs match" }] };
329
+ });
330
+ const read = tool("read_desk_run", "One run from another workspace in full: each step's agent, status and what it replied (long replies trimmed).", { id: z.string().describe("A run id from recall_desk_runs") }, async (args) => {
331
+ const r = digest.find((d) => d.id === args.id);
332
+ if (!r)
333
+ return { content: [{ type: "text", text: `no run ${args.id} in the account's recent history` }], isError: true };
334
+ const text = `${r.workspace ?? "?"} · ${r.flow} · ${r.status} · started ${r.startedAt}${r.finishedAt ? ` · finished ${r.finishedAt}` : ""} · $${r.costUsd.toFixed(4)}\n` +
335
+ (r.summary ? `summary: ${r.summary}\n` : "") +
336
+ r.steps.map((s) => `\n## ${s.agent} (${s.status})\n${s.result ?? "(no reply)"}`).join("\n");
337
+ return { content: [{ type: "text", text }] };
338
+ });
339
+ return {
340
+ server: createSdkMcpServer({ name: "foldrun_desks", version: "1.0.0", tools: [recall, read] }),
341
+ toolNames: ["mcp__foldrun_desks__recall_desk_runs", "mcp__foldrun_desks__read_desk_run"],
342
+ promptLines: [
343
+ `- **recall_desk_runs()** / **read_desk_run(id)** — what the account's other workspaces ` +
344
+ `(${workspaces.join(", ") || "none yet"}) concluded, newest first. Their run records, not their files.`,
345
+ ],
346
+ };
347
+ }
@@ -0,0 +1,100 @@
1
+ import { type DeployFile } from "./store.ts";
2
+ export interface DeployIssue {
3
+ where: string;
4
+ message: string;
5
+ }
6
+ export interface DeployPlan {
7
+ files: DeployFile[];
8
+ /** Paths present now that the push does not ship — they will be removed. */
9
+ removed: string[];
10
+ added: string[];
11
+ updated: string[];
12
+ /** Empty when the deploy is safe to apply. */
13
+ issues: DeployIssue[];
14
+ /** Set when a run is in flight, which blocks the swap. */
15
+ blockedBy: string[];
16
+ }
17
+ export interface DeployResult extends DeployPlan {
18
+ applied: boolean;
19
+ /** The commit this workspace is now running, when the caller knows it. */
20
+ commit: string | null;
21
+ preserved: number;
22
+ }
23
+ /**
24
+ * Read a directory into the file list a deploy ships.
25
+ *
26
+ * Only what a workspace is made of: AGENTS.md and the tracked directories.
27
+ * Everything else in the repo — a README, .github/, a package.json — is
28
+ * skipped rather than rejected, because a workspace living in a repo alongside
29
+ * other things is the normal case, not an error.
30
+ */
31
+ export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
32
+ export declare function readTree(dir: string): DeployFile[];
33
+ /**
34
+ * Everything wrong with an incoming workspace, before any of it is live.
35
+ *
36
+ * Deliberately the same questions `foldrun check` asks, against files that are
37
+ * not on disk yet. A deploy that would leave the workspace failing its own
38
+ * checker should not be a deploy.
39
+ */
40
+ export declare function deployIssues(files: DeployFile[]): DeployIssue[];
41
+ /**
42
+ * Runs that make this a bad moment to swap the files under them.
43
+ *
44
+ * A flow reads its agents step by step. Replacing them mid-run means step 3
45
+ * runs against a definition step 1 never saw, and the trace becomes a record
46
+ * of two different workspaces. A run waiting on a person counts too: they are
47
+ * about to approve work that the deploy would silently redefine.
48
+ */
49
+ export declare function runsInFlight(tenant: string, workspace: string): string[];
50
+ /** What a deploy would do, without doing it. */
51
+ export declare function planDeploy(tenant: string, workspace: string, files: DeployFile[]): DeployPlan;
52
+ export interface DeployOptions {
53
+ /** The commit these files came from, recorded so the dashboard can show it. */
54
+ commit?: string | null;
55
+ /** Attribution for the revision this deploy records. */
56
+ by?: string;
57
+ message?: string;
58
+ /** Apply even while runs are in flight. */
59
+ force?: boolean;
60
+ }
61
+ /**
62
+ * Deploy a file list to a workspace, if it checks out.
63
+ *
64
+ * Returns the plan either way, with `applied` saying whether anything changed.
65
+ * A refused deploy is a normal outcome, not an exception: the caller is a
66
+ * webhook or a CLI that needs to report *why*, and every reason is already in
67
+ * the plan.
68
+ */
69
+ /** What a push recorded about itself, where the Repository page reads it. */
70
+ export interface PushNote {
71
+ commit: string;
72
+ at: string;
73
+ applied: boolean;
74
+ issues?: unknown[];
75
+ blockedBy?: string[];
76
+ /** Set when a blocked push was applied later, by the retry. */
77
+ appliedAt?: string;
78
+ }
79
+ export declare const pushNotePath: (tenant: string, workspace: string) => string;
80
+ export declare function readPushNote(tenant: string, workspace: string): PushNote | null;
81
+ export declare function writePushNote(tenant: string, workspace: string, note: PushNote): void;
82
+ /**
83
+ * A push whose deploy was refused only because a run was in flight is not a
84
+ * failure — it is a deploy that has not happened yet. Git already said the
85
+ * push succeeded (the deploy happens after the client is gone), so nothing
86
+ * would ever tell anyone the workspace on disk is behind `main`. This applies
87
+ * it once the runs are done.
88
+ *
89
+ * Only `blockedBy`: a push refused for `issues` is a broken workspace and
90
+ * wants a person, not a retry.
91
+ */
92
+ export declare function applyPendingPush(tenant: string, workspace: string, filesAt: (tenant: string, scope: string, ref: string) => DeployFile[]): {
93
+ commit: string;
94
+ } | null;
95
+ export declare function deployWorkspace(tenant: string, workspace: string, files: DeployFile[], opts?: DeployOptions): DeployResult;
96
+ export declare function writeDeployedCommit(tenant: string, workspace: string, commit: string): void;
97
+ export declare function deployedCommit(tenant: string, workspace: string): {
98
+ commit: string;
99
+ at: string;
100
+ } | null;