@dirwin517/bot-farm 0.1.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 (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
@@ -0,0 +1,605 @@
1
+ // A workspace is a folder and its opencode config: the thing you open.
2
+ //
3
+ // It owns the definitions for the bots that work in it, one file per
4
+ // definition in a botfarm/ folder at its root, so they travel with the
5
+ // repository, can be reviewed like code and edited without scrolling past
6
+ // everything else:
7
+ //
8
+ // botfarm/<id>.agent.botfarm.yml one agent (persona): prompt, model, tools
9
+ // botfarm/<id>.pipeline.botfarm.yml one pipeline: stages naming those agents
10
+ // botfarm/mcps/*.js extra tools served to every bot (see tools.mjs)
11
+ //
12
+ // The file name is the id. Everything else — the workstreams started from
13
+ // those pipelines, with their own worktree, team, channel and board — hangs
14
+ // off a workspace.
15
+
16
+ import { readFile, writeFile, stat, readdir, mkdir, rename, unlink } from "node:fs/promises"
17
+ import { join, basename, dirname } from "node:path"
18
+ import { homedir } from "node:os"
19
+ import * as YAML from "./yaml.mjs"
20
+ import * as git from "./git.mjs"
21
+ import { DEFAULTS, checkPersona, checkPipelineDoc } from "./pipelines.mjs"
22
+ import { LEGACY } from "./legacy-defaults.mjs"
23
+
24
+ export const BOTFARM_DIR = "botfarm"
25
+ export const TOOLS_DIR = join(BOTFARM_DIR, "mcps")
26
+ /** The single files of earlier versions, split into botfarm/ once and kept as .bak. */
27
+ export const AGENTS_FILE = "botfarm-agents.yml"
28
+ export const PIPELINES_FILE = "botfarm-pipeline.yml"
29
+ const KINDS = { agents: "agent", pipelines: "pipeline" }
30
+ const SUFFIX = ".botfarm.yml"
31
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i
32
+
33
+ const slug = (s) =>
34
+ String(s ?? "")
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, "-")
37
+ .replace(/^-|-$/g, "")
38
+ .slice(0, 40) || "workspace"
39
+
40
+ const HEADERS = {
41
+ agents: (id) => `# BotFarm agent "${id}" — a bot template: who it is, what it is told, and
42
+ # optionally which model, reasoning level and tools it gets. The file name is the id.
43
+ # Edit here or in the BotFarm app; both write this file.
44
+ `,
45
+ pipelines: (id) => `# BotFarm pipeline "${id}" — ordered stages, each naming an agent and which earlier
46
+ # stages' handoffs it receives. Starting it creates a workstream: its own branch,
47
+ # team, chat and board. The file name is the id.
48
+ `,
49
+ }
50
+
51
+ /** botfarm/<id>.<kind>.botfarm.yml → { kind, id } */
52
+ export function parseEntryFile(name) {
53
+ const m = /^(.+)\.(agent|pipeline)\.botfarm\.ya?ml$/.exec(name)
54
+ if (!m) return null
55
+ return { id: m[1], kind: m[2] === "agent" ? "agents" : "pipelines" }
56
+ }
57
+ const entryFile = (kind, id) => `${id}.${KINDS[kind]}${SUFFIX}`
58
+
59
+ /** The built-in definitions, as the documents the files hold. */
60
+ export function defaultLibrary() {
61
+ const agents = Object.values(DEFAULTS.personas).map((y) => YAML.parse(y))
62
+ const pipelines = Object.values(DEFAULTS.pipelines).map((y) => YAML.parse(y))
63
+ return { agents, pipelines }
64
+ }
65
+
66
+ /** Tool names changed from botfarm_* to botfarm_*: carry prompts along. */
67
+ const renameTools = (v) => (typeof v === "string" ? v.replace(/\bbotfarm_(?=[a-z])/g, "botfarm_") : Array.isArray(v) ? v.map(renameTools) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, renameTools(x)])) : v)
68
+
69
+ export class Workspaces {
70
+ constructor({ db, supervisor }) {
71
+ this.db = db
72
+ this.sup = supervisor
73
+ this.cache = new Map() // id -> { sig, library }
74
+ }
75
+
76
+ list() {
77
+ return this.db.all("workspaces").sort((a, b) => a.created - b.created)
78
+ }
79
+
80
+ get(id) {
81
+ return id ? this.db.get("workspaces", id) : null
82
+ }
83
+
84
+ /** The workspace a path belongs to, if any. */
85
+ forPath(path) {
86
+ if (!path) return null
87
+ return this.list()
88
+ .filter((w) => path === w.path || path.startsWith(w.path + "/"))
89
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null
90
+ }
91
+
92
+ async add({ path, name }) {
93
+ const where = await git.inspect(path)
94
+ if (!where.exists) throw new Error(`${where.path}: ${where.reason}`)
95
+ const root = where.root ?? where.path
96
+ const existing = this.list().find((w) => w.path === root)
97
+ if (existing) return existing
98
+ let id = slug(name ?? basename(root))
99
+ for (let n = 2; this.get(id); n++) id = `${slug(name ?? basename(root))}-${n}`
100
+ const ws = { id, name: name ?? basename(root), path: root, isRepo: where.isRepo, services: [], created: Date.now() }
101
+ this.db.put("workspaces", ws)
102
+ await this.ensureFiles(ws)
103
+ await this.upgradeDefaults(ws)
104
+ await this.sup.wsTools?.load(ws).catch(() => {})
105
+ this.sup.store.note(`workspace ${ws.name} added (${root})`)
106
+ this.sup.onChange()
107
+ return ws
108
+ }
109
+
110
+ update(id, patch) {
111
+ const ws = this.get(id)
112
+ if (!ws) throw new Error("unknown workspace")
113
+ Object.assign(ws, patch, { id })
114
+ this.db.put("workspaces", ws)
115
+ this.sup.onChange()
116
+ return ws
117
+ }
118
+
119
+ /**
120
+ * A repository from anywhere (~/workspace/spt) that this workspace's
121
+ * workstreams can take along: each gets a worktree of it on the workstream's
122
+ * branch, mounted in the workstream's worktree under `as` (its folder name).
123
+ */
124
+ async addLinked(id, { path, as = null } = {}) {
125
+ const ws = this.get(id)
126
+ if (!ws) throw new Error("unknown workspace")
127
+ const where = await git.inspect(ws.path).catch(() => ({ nested: [] }))
128
+ const linked = ws.linked ?? []
129
+ const taken = [...(where.nested ?? []).map((n) => n.rel), ...git.serviceSources(ws.path, linked).map((l) => l.rel)]
130
+ const raw = String(path ?? "").trim()
131
+ const pre = git.serviceSources(ws.path, [raw])[0]
132
+ if (pre && git.serviceSources(ws.path, linked).some((l) => l.source === pre.source)) throw new Error(`${pre.source} is already linked`)
133
+ const svc = await git.checkService(ws.path, as ? { path: raw, as } : raw, { taken })
134
+ if (!svc.external) throw new Error(`${svc.source} is inside this workspace already — tick it under Repos instead`)
135
+ if (git.serviceSources(ws.path, linked).some((l) => l.source === svc.source)) throw new Error(`${svc.source} is already linked`)
136
+ ws.linked = [...linked, { path: svc.source, as: svc.rel }]
137
+ ws.services = [...new Set([...(ws.services ?? []).filter((x) => typeof x === "string"), svc.source])]
138
+ this.db.put("workspaces", ws)
139
+ this.sup.store.note(`workspace ${ws.name}: linked ${svc.source} (mounted as ${svc.rel}/)`)
140
+ this.sup.onChange()
141
+ return ws
142
+ }
143
+
144
+ removeLinked(id, path) {
145
+ const ws = this.get(id)
146
+ if (!ws) throw new Error("unknown workspace")
147
+ ws.linked = (ws.linked ?? []).filter((l) => l.path !== path)
148
+ ws.services = (ws.services ?? []).filter((x) => x !== path)
149
+ this.db.put("workspaces", ws)
150
+ this.sup.onChange()
151
+ return ws
152
+ }
153
+
154
+ /** A workspace's services as start() wants them: linked repos carry their mount name. */
155
+ serviceEntries(ws, picked = ws?.services ?? []) {
156
+ const linked = new Map((ws?.linked ?? []).map((l) => [l.path, l]))
157
+ return picked.map((p) => (typeof p === "string" && linked.has(p) ? { path: p, as: linked.get(p).as } : p))
158
+ }
159
+
160
+ remove(id) {
161
+ this.db.delete("workspaces", id)
162
+ this.cache.delete(id)
163
+ this.sup.onChange()
164
+ }
165
+
166
+ files(ws) {
167
+ const dir = join(ws.path, BOTFARM_DIR)
168
+ return { dir, agents: dir, pipelines: dir, tools: join(ws.path, TOOLS_DIR) }
169
+ }
170
+
171
+ entryPath(ws, kind, id) {
172
+ return join(ws.path, BOTFARM_DIR, entryFile(kind, id))
173
+ }
174
+
175
+ async entryFiles(ws) {
176
+ const dir = join(ws.path, BOTFARM_DIR)
177
+ let names = []
178
+ try { names = await readdir(dir) } catch { return [] }
179
+ return names.map((n) => ({ name: n, path: join(dir, n), ...parseEntryFile(n) })).filter((f) => f.kind)
180
+ }
181
+
182
+ async writeEntry(ws, kind, doc) {
183
+ const id = doc.id
184
+ const clean = { id, ...doc }
185
+ delete clean.file
186
+ await mkdir(join(ws.path, BOTFARM_DIR), { recursive: true })
187
+ await writeFile(this.entryPath(ws, kind, id), HEADERS[kind](id) + "\n" + YAML.stringify(clean) + "\n")
188
+ }
189
+
190
+ /**
191
+ * Make sure botfarm/ has definitions: the old single files split into one
192
+ * file per entry (and renamed to .bak), or the built-ins on a new
193
+ * workspace. Also the tools folder, with one example tool.
194
+ */
195
+ async ensureFiles(ws) {
196
+ const have = await this.entryFiles(ws)
197
+ const note = (m) => this.sup.store.note(m, null, "info")
198
+ if (!have.length) {
199
+ const legacy = {}
200
+ for (const [kind, file] of [["agents", AGENTS_FILE], ["pipelines", PIPELINES_FILE]]) {
201
+ try { legacy[kind] = YAML.parse(await readFile(join(ws.path, file), "utf8"))?.[kind] ?? null } catch { legacy[kind] = null }
202
+ }
203
+ const lib = defaultLibrary()
204
+ const moved = []
205
+ for (const kind of ["agents", "pipelines"]) {
206
+ const list = (legacy[kind] ?? lib[kind]).filter((x) => x?.id && ID_RE.test(x.id))
207
+ for (const doc of list) {
208
+ await this.writeEntry(ws, kind, renameTools(doc)).catch((e) => this.sup.store.note(`could not write ${entryFile(kind, doc.id)}: ${e.message}`, null, "warn"))
209
+ }
210
+ if (legacy[kind]) {
211
+ const from = join(ws.path, kind === "agents" ? AGENTS_FILE : PIPELINES_FILE)
212
+ let to = from + ".bak"
213
+ if (await stat(to).then(() => true, () => false)) to = `${from}.${Date.now()}.bak`
214
+ await rename(from, to).then(() => moved.push(`${basename(from)} → ${basename(to)}`), () => {})
215
+ }
216
+ }
217
+ if (moved.length) note(`${ws.name}: split ${moved.map((m) => m.split(" ")[0]).join(" and ")} into one file per definition in ${BOTFARM_DIR}/ (old files kept: ${moved.map((m) => m.split(" → ")[1]).join(", ")})`)
218
+ }
219
+ const routing = join(ws.path, BOTFARM_DIR, ROUTING_FILE)
220
+ if (!(await stat(routing).then(() => true, () => false))) await writeFile(routing, ROUTING_EXAMPLE).catch(() => {})
221
+ const tools = join(ws.path, TOOLS_DIR)
222
+ if (!(await stat(tools).then(() => true, () => false))) {
223
+ await mkdir(tools, { recursive: true }).catch(() => {})
224
+ await writeFile(join(tools, "worktree_status.js"), EXAMPLE_TOOL).catch(() => {})
225
+ }
226
+ await git.excludeLocally(ws.path, `${BOTFARM_DIR}/*.bak`).catch(() => {})
227
+ }
228
+
229
+ /**
230
+ * Move built-in definitions that are still exactly as they shipped to the
231
+ * current built-ins. Only untouched entries change; an edit, however
232
+ * small, means the definition is yours and it is left alone.
233
+ */
234
+ async upgradeDefaults(ws) {
235
+ const same = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim() === String(b ?? "").replace(/\s+/g, " ").trim()
236
+ const now = defaultLibrary()
237
+ const changed = []
238
+ for (const f of await this.entryFiles(ws)) {
239
+ let doc
240
+ try { doc = YAML.parse(await readFile(f.path, "utf8")) } catch { continue }
241
+ if (!doc) continue
242
+ if (f.kind === "agents") {
243
+ const olds = (LEGACY.agents[f.id] ?? []).map((y) => renameTools(YAML.parse(y)))
244
+ const current = now.agents.find((x) => x.id === f.id)
245
+ if (current && olds.some((o) => same(o.prompt, doc.prompt) && !doc.tools && !doc.model)) {
246
+ await this.writeEntry(ws, "agents", current)
247
+ changed.push(`agent ${f.id}`)
248
+ }
249
+ } else {
250
+ let hit = false
251
+ for (const [i, st] of (doc.stages ?? []).entries()) {
252
+ const olds = (LEGACY.stages[`${f.id}/${st.id}`] ?? []).map((y) => renameTools(YAML.parse(y).stages[0]))
253
+ const current = now.pipelines.find((x) => x.id === f.id)?.stages?.find((x) => x.id === st.id)
254
+ if (current && olds.some((o) => same(o.prompt, st.prompt))) {
255
+ doc.stages[i] = { ...st, title: current.title, prompt: current.prompt }
256
+ changed.push(`stage ${f.id}/${st.id}`)
257
+ hit = true
258
+ }
259
+ }
260
+ if (hit) await this.writeEntry(ws, "pipelines", { ...doc, id: f.id })
261
+ }
262
+ }
263
+ if (changed.length) { this.cache.delete(ws.id); this.sup.store.note(`updated unedited built-ins in ${ws.name}: ${changed.join(", ")}`) }
264
+ return changed
265
+ }
266
+
267
+ /**
268
+ * The agents and pipelines defined in this workspace, re-read whenever a
269
+ * file in botfarm/ changes, appears or goes — so a hand edit shows up
270
+ * without a reload.
271
+ */
272
+ async library(id) {
273
+ const ws = this.get(id)
274
+ if (!ws) throw new Error("unknown workspace")
275
+ const f = this.files(ws)
276
+ const entries = await this.entryFiles(ws)
277
+ const stats = await Promise.all(entries.map((e) => stat(e.path).then((s) => `${e.name}:${s.mtimeMs}`).catch(() => e.name)))
278
+ const sig = stats.sort().join("|")
279
+ const hit = this.cache.get(id)
280
+ if (hit && hit.sig === sig) return hit.library
281
+ const library = { workspace: id, personas: new Map(), definitions: new Map(), problems: [], files: f, missing: [] }
282
+ if (!entries.length) library.missing.push("agents", "pipelines")
283
+ const docs = { agents: [], pipelines: [] }
284
+ for (const e of entries) {
285
+ try {
286
+ const doc = YAML.parse(await readFile(e.path, "utf8")) ?? {}
287
+ if (doc.id && doc.id !== e.id) library.problems.push({ file: `${BOTFARM_DIR}/${e.name}`, id: e.id, problems: [`says id: ${doc.id}, but the file name makes it "${e.id}" — the file name wins`] })
288
+ docs[e.kind].push({ ...doc, id: e.id, file: e.path })
289
+ } catch (err) {
290
+ library.problems.push({ file: `${BOTFARM_DIR}/${e.name}`, id: e.id, problems: [err.message] })
291
+ }
292
+ }
293
+ for (const a of docs.agents.sort((x, y) => x.id.localeCompare(y.id))) {
294
+ const problems = checkPersona(a)
295
+ if (problems.length) library.problems.push({ file: `${BOTFARM_DIR}/${basename(a.file)}`, id: a.id, problems })
296
+ library.personas.set(a.id, a)
297
+ }
298
+ for (const p of docs.pipelines.sort((x, y) => x.id.localeCompare(y.id))) {
299
+ const problems = checkPipelineDoc(p, library.personas)
300
+ if (problems.length) library.problems.push({ file: `${BOTFARM_DIR}/${basename(p.file)}`, id: p.id, problems })
301
+ library.definitions.set(p.id, p)
302
+ }
303
+ this.cache.set(id, { sig, library })
304
+ return library
305
+ }
306
+
307
+ async summary(id) {
308
+ const lib = await this.library(id)
309
+ const ws = this.get(id)
310
+ const setup = await opencodeSetup(ws.path)
311
+ const rel = (p) => (p ? p.slice(ws.path.length + 1) : null)
312
+ return {
313
+ opencode: {
314
+ found: setup.found,
315
+ model: setup.model ?? null,
316
+ defaultAgent: setup.default_agent ?? null,
317
+ agents: Object.entries(setup.agent).map(([name, a]) => ({ name, model: a.model ?? null, variant: a.variant ?? null, mode: a.mode ?? null, description: a.description ?? null })),
318
+ mcp: Object.entries(setup.mcp).map(([name, m]) => ({ name, type: m.type, enabled: m.enabled !== false })),
319
+ },
320
+ files: lib.files,
321
+ missing: lib.missing,
322
+ problems: lib.problems,
323
+ agents: [...lib.personas.values()].map(({ file, ...a }) => ({ ...a, file: rel(file) })),
324
+ pipelines: [...lib.definitions.values()].map(({ file, ...p }) => ({
325
+ ...p,
326
+ file: rel(file),
327
+ stages: (p.stages ?? []).map((s) => ({ ...s, receives: s.receives ?? [] })),
328
+ })),
329
+ }
330
+ }
331
+
332
+ /** Replace, add, rename or delete one entry: its own file. */
333
+ async saveEntry(id, kind, entryId, doc) {
334
+ const ws = this.get(id)
335
+ if (!ws) throw new Error("unknown workspace")
336
+ kind = kind === "agents" ? "agents" : "pipelines"
337
+ if (doc === null) {
338
+ await unlink(this.entryPath(ws, kind, entryId)).catch((e) => { if (e.code !== "ENOENT") throw e })
339
+ } else {
340
+ const next = String(doc.id ?? entryId).trim()
341
+ if (!ID_RE.test(next)) throw new Error(`"${next}" cannot be an id: letters, digits, dots, dashes and underscores only (it becomes the file name)`)
342
+ await this.writeEntry(ws, kind, { ...doc, id: next })
343
+ if (next !== entryId && entryId && entryId !== "+new") await unlink(this.entryPath(ws, kind, entryId)).catch(() => {})
344
+ }
345
+ this.cache.delete(id)
346
+ this.sup.onChange()
347
+ return this.summary(id)
348
+ }
349
+
350
+ /**
351
+ * botfarm/routing.botfarm.yml: which model and reasoning level a card gets by
352
+ * how hard it looks. { enabled, tiers: { easy, normal, hard } }, or null.
353
+ */
354
+ async routing(id) {
355
+ const ws = this.get(id)
356
+ if (!ws) return null
357
+ const path = join(ws.path, BOTFARM_DIR, ROUTING_FILE)
358
+ const mtime = await stat(path).then((s) => s.mtimeMs, () => 0)
359
+ this.routingCache ??= new Map()
360
+ const hit = this.routingCache.get(id)
361
+ if (hit && hit.mtime === mtime) return hit.doc
362
+ let doc = null
363
+ try { doc = YAML.parse(await readFile(path, "utf8")) ?? null } catch { doc = null }
364
+ this.routingCache.set(id, { mtime, doc })
365
+ return doc
366
+ }
367
+
368
+ /** Save routing from the app: { enabled, tiers: { easy, normal, hard } }, keeping the header comment. */
369
+ async saveRouting(id, doc) {
370
+ const ws = this.get(id)
371
+ if (!ws) throw new Error("unknown workspace")
372
+ const clean = { enabled: !!doc?.enabled, ...(doc?.switch === "next-turn" ? { switch: "next-turn" } : {}), tiers: {} }
373
+ for (const level of ["easy", "normal", "hard"]) clean.tiers[level] = cleanTier(doc?.tiers?.[level])
374
+ const header = ROUTING_EXAMPLE.split("\n").filter((l) => l.startsWith("#")).join("\n")
375
+ await mkdir(join(ws.path, BOTFARM_DIR), { recursive: true })
376
+ await writeFile(join(ws.path, BOTFARM_DIR, ROUTING_FILE), header + "\n\n" + YAML.stringify(clean) + "\n")
377
+ this.routingCache?.delete(id)
378
+ this.sup.onChange()
379
+ return clean
380
+ }
381
+
382
+ /** One entry's file as text, for the YAML tab. */
383
+ async readRaw(id, kind, entryId) {
384
+ const ws = this.get(id)
385
+ kind = kind === "agents" ? "agents" : "pipelines"
386
+ const path = this.entryPath(ws, kind, entryId)
387
+ return { path, text: await readFile(path, "utf8").catch(() => "") }
388
+ }
389
+
390
+ async writeRaw(id, kind, text, entryId) {
391
+ const doc = YAML.parse(text) // refuse to write something we cannot read back
392
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new Error("the file must be one definition (a map), not a list")
393
+ const ws = this.get(id)
394
+ kind = kind === "agents" ? "agents" : "pipelines"
395
+ if (!ID_RE.test(entryId ?? "")) throw new Error("which definition? (no id)")
396
+ await mkdir(join(ws.path, BOTFARM_DIR), { recursive: true })
397
+ await writeFile(this.entryPath(ws, kind, entryId), text.endsWith("\n") ? text : text + "\n")
398
+ this.cache.delete(id)
399
+ this.sup.onChange()
400
+ return this.summary(id)
401
+ }
402
+ }
403
+
404
+ /** One routing tier as it is saved: model, variant, and optionally a quota on that model with a fallback. */
405
+ export function cleanTier(t = {}) {
406
+ const out = {}
407
+ if (t?.model && String(t.model).trim()) out.model = String(t.model).trim()
408
+ if (t?.variant && String(t.variant).trim() && t.variant !== "default") out.variant = String(t.variant).trim()
409
+ if (out.model && t?.quota) {
410
+ const q = {}
411
+ for (const k of ["usd", "tokens", "minutes"]) if (Number(t.quota[k]) > 0) q[k] = Number(t.quota[k])
412
+ if (Object.keys(q).length) {
413
+ q.per = ["day", "week", "month", "workstream"].includes(t.quota.per) ? t.quota.per : "day"
414
+ out.quota = q
415
+ const fb = {}
416
+ if (t.fallback?.model && String(t.fallback.model).trim()) fb.model = String(t.fallback.model).trim()
417
+ if (t.fallback?.variant && t.fallback.variant !== "default") fb.variant = String(t.fallback.variant)
418
+ out.fallback = fb
419
+ }
420
+ }
421
+ return out
422
+ }
423
+
424
+ export const ROUTING_FILE = "routing.botfarm.yml"
425
+ const ROUTING_EXAMPLE = `# BotFarm model routing: each card gets a model and reasoning level by how hard it
426
+ # looks — from its words (refactor, migration, security… vs typo, docs, rename…),
427
+ # its length and its number of criteria, or a "difficulty:" set on a stage or on a
428
+ # split piece. No model is used to decide. A bot's own Model… choice always wins,
429
+ # and an agent can have its own "tiers:" with the same shape.
430
+ #
431
+ # A tier can cap its model: quota: { usd, tokens, minutes, per: day|week|month|workstream }
432
+ # and name a fallback: { model, variant } to use once the quota is spent, e.g.
433
+ # hard: { model: …opus…, variant: high, quota: { usd: 5, per: day }, fallback: { model: …sonnet…, variant: high } }
434
+ # switch: next-turn moves running bots to the fallback on their next turn instead of at once.
435
+ #
436
+ # Off until you set enabled: true. Leave a key out to keep the agent's own setting.
437
+
438
+ enabled: false
439
+ tiers:
440
+ easy:
441
+ variant: low
442
+ # model: amazon-bedrock/anthropic.claude-haiku-4-5
443
+ normal: {}
444
+ hard:
445
+ variant: high
446
+ # model: amazon-bedrock/anthropic.claude-opus-5
447
+ `
448
+
449
+ const EXAMPLE_TOOL = `// A BotFarm tool. Every .js file in botfarm/mcps/ is loaded into BotFarm's own MCP
450
+ // server and offered to every bot in this workspace as botfarm_<name>. Save the
451
+ // file and it is live — no restart. Try it from Tools in the BotFarm sidebar.
452
+ //
453
+ // name the tool's name (letters, digits, _ and -)
454
+ // description what it does and when to use it: this is what the model reads
455
+ // inputSchema JSON Schema for the arguments
456
+ // execute async (args, ctx) => anything JSON-serialisable (or a string)
457
+ //
458
+ // ctx.repoRoot is the calling bot's worktree (or the workspace, from the test
459
+ // bench); ctx.bot is { handle, persona, id } or null; ctx.exec(cmd, args, opts)
460
+ // runs a program in ctx.repoRoot and returns { code, stdout, stderr };
461
+ // ctx.signal aborts when the call is cancelled.
462
+
463
+ export const name = "worktree_status"
464
+ export const description = "Show what has changed in your worktree: branch, changed files and a short diff summary."
465
+ export const inputSchema = {
466
+ type: "object",
467
+ properties: {
468
+ diff: { type: "boolean", description: "Include the diff stat per file (default true)." },
469
+ },
470
+ }
471
+
472
+ export async function execute({ diff = true } = {}, ctx) {
473
+ const branch = (await ctx.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim()
474
+ const status = (await ctx.exec("git", ["status", "--short"])).stdout.trim()
475
+ const stat = diff ? (await ctx.exec("git", ["diff", "--stat", "HEAD"])).stdout.trim() : undefined
476
+ return { worktree: ctx.repoRoot, branch, changed: status ? status.split("\\n") : [], diff: stat }
477
+ }
478
+ `
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // The workspace's own opencode setup: model, agents, MCP servers.
482
+ //
483
+ // A bot runs in a worktree, and opencode does not reliably read the
484
+ // checkout's config for it (a newer server boots a location without loading
485
+ // project config at all — the bot then falls back to opencode's free tier).
486
+ // So botfarm reads the workspace's opencode.json itself and passes the model,
487
+ // agent and MCP servers explicitly.
488
+
489
+ function parseJsonc(text) {
490
+ const noComments = String(text)
491
+ .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m, str) => str ?? "")
492
+ .replace(/,(\s*[}\]])/g, "$1")
493
+ return JSON.parse(noComments)
494
+ }
495
+
496
+ export async function readJsonc(path) {
497
+ try { return parseJsonc(await readFile(path, "utf8")) } catch { return null }
498
+ }
499
+
500
+ export async function opencodeSetup(dir) {
501
+ const layers = []
502
+ for (const f of ["opencode.json", "opencode.jsonc", join(".opencode", "opencode.json"), join(".opencode", "opencode.jsonc")]) {
503
+ const doc = await readJsonc(join(dir, f))
504
+ if (doc) layers.push(doc)
505
+ }
506
+ const merged = { agent: {}, mcp: {} }
507
+ for (const d of layers) {
508
+ for (const k of ["model", "small_model", "default_agent"]) if (d[k]) merged[k] = d[k]
509
+ for (const [name, a] of Object.entries(d.agent ?? {})) merged.agent[name] = { ...(merged.agent[name] ?? {}), ...a }
510
+ for (const [name, m] of Object.entries(d.mcp ?? {})) if (name !== "botfarm" && name !== "botfarm") merged.mcp[name] = { ...(merged.mcp[name] ?? {}), ...m }
511
+ }
512
+ merged.found = layers.length > 0
513
+ return merged
514
+ }
515
+
516
+ /**
517
+ * Which opencode agent, model and reasoning level a BotFarm agent runs on:
518
+ * its own setting first, then the opencode agent's, then the workspace
519
+ * default. The opencode agent defaults to "build" rather than the
520
+ * workspace's default_agent, which may be one that switches most tools off
521
+ * (the botfarm tools included, and a bot that cannot hand off is stuck).
522
+ */
523
+ export function resolveBot(setup, persona = {}) {
524
+ const agent = persona.agent ?? (setup?.agent?.build ? "build" : null)
525
+ const a = (agent && setup?.agent?.[agent]) || {}
526
+ return {
527
+ agent,
528
+ model: persona.model ?? a.model ?? setup?.model ?? null,
529
+ variant: persona.variant ?? a.variant ?? null,
530
+ }
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Workstream manifests: <workspace>/.botfarm/workstreams/<id>.yml
535
+ //
536
+ // One small file per workstream naming the opencode sessions behind its bots
537
+ // (and the ones they replaced), so the team can be found again after botfarm's
538
+ // own state is lost, moved to another machine, or a session falls off
539
+ // opencode's listing. Machine-specific, so it is excluded from git locally.
540
+
541
+ export const MANIFEST_DIR = join(".botfarm", "workstreams")
542
+
543
+ const MANIFEST_HEADER = `# BotFarm workstream: the opencode sessions behind this workstream's bots.
544
+ # Written by BotFarm whenever the team changes; used to bring the bots back.
545
+ `
546
+
547
+ export function manifestPath(ws, projectId) {
548
+ return join(ws.path, MANIFEST_DIR, `${projectId}.yml`)
549
+ }
550
+
551
+ export async function readManifest(ws, projectId) {
552
+ try {
553
+ return YAML.parse(await readFile(manifestPath(ws, projectId), "utf8"))
554
+ } catch {
555
+ return null
556
+ }
557
+ }
558
+
559
+ export async function readManifests(ws) {
560
+ const dir = join(ws.path, MANIFEST_DIR)
561
+ let files = []
562
+ try { files = (await readdir(dir)).filter((f) => f.endsWith(".yml")) } catch { return [] }
563
+ const out = []
564
+ for (const f of files) {
565
+ try {
566
+ const doc = YAML.parse(await readFile(join(dir, f), "utf8"))
567
+ if (doc?.id) out.push(doc)
568
+ } catch {}
569
+ }
570
+ return out
571
+ }
572
+
573
+ export async function writeManifest(ws, doc) {
574
+ const file = manifestPath(ws, doc.id)
575
+ await mkdir(dirname(file), { recursive: true })
576
+ await git.excludeLocally(ws.path, ".botfarm/").catch(() => {})
577
+ await writeFile(file, MANIFEST_HEADER + "\n" + YAML.stringify(doc) + "\n")
578
+ return file
579
+ }
580
+
581
+ /**
582
+ * A folder browser for the dashboard. A web page cannot ask the OS for a
583
+ * folder's path, so the server lists directories instead. Hidden folders and
584
+ * the usual build output are left out.
585
+ */
586
+ export async function listDirs(input) {
587
+ const path = git.expandPath(input || "~")
588
+ let entries = []
589
+ try {
590
+ entries = await readdir(path, { withFileTypes: true })
591
+ } catch (err) {
592
+ return { path, parent: dirname(path), home: homedir(), error: err.code === "ENOENT" ? "no such folder" : err.message, dirs: [] }
593
+ }
594
+ const skip = /^(\.|node_modules$|target$|build$|dist$|out$)/
595
+ const dirs = []
596
+ for (const e of entries) {
597
+ if (!e.isDirectory() || skip.test(e.name)) continue
598
+ const full = join(path, e.name)
599
+ const isRepo = await stat(join(full, ".git")).then(() => true).catch(() => false)
600
+ dirs.push({ name: e.name, path: full, isRepo })
601
+ }
602
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
603
+ const isRepo = await stat(join(path, ".git")).then(() => true).catch(() => false)
604
+ return { path, parent: dirname(path) === path ? null : dirname(path), home: homedir(), isRepo, dirs: dirs.slice(0, 400) }
605
+ }