@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.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- 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
|
+
}
|