@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
package/src/tasks.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // Tasks are the primitive. A pipeline is a recipe that emits them.
2
+ //
3
+ // That is the whole architectural bet here. If stages were their own thing,
4
+ // the QA bot finding an unrelated defect would have nowhere to put it, and a
5
+ // "pipeline" would only ever be a straight line. Because every stage is just
6
+ // a task with dependencies, the board can hold pipeline work and ad-hoc work
7
+ // side by side, work can fan out in parallel, and a late defect is simply a
8
+ // new task that blocks an existing one.
9
+
10
+ export const COLUMNS = ["backlog", "blocked", "waiting", "queued", "active", "review", "done", "cancelled"]
11
+
12
+ let seq = 0
13
+ const newId = () => "tsk_" + Date.now().toString(36) + (++seq).toString(36)
14
+
15
+ export class Tasks {
16
+ constructor(db, { onChange = () => {} } = {}) {
17
+ this.db = db
18
+ this.onChange = onChange
19
+ }
20
+
21
+ /**
22
+ * Ownership has three shapes, and they are not the same thing:
23
+ *
24
+ * session a named session does this
25
+ * role whichever bot plays this part picks it up — so a dev task does
26
+ * not die because one particular dev session was closed
27
+ * human you do this, or you answer this; it is never dispatched
28
+ */
29
+ create({
30
+ title,
31
+ brief = "",
32
+ projectId = null,
33
+ runId = null,
34
+ stage = null,
35
+ persona = null,
36
+ assignee = null,
37
+ role = null,
38
+ owner = null,
39
+ kind = "work",
40
+ options = [],
41
+ askedBy = null,
42
+ dependsOn = [],
43
+ createdBy = "operator",
44
+ acceptance = [],
45
+ order = Date.now(),
46
+ }) {
47
+ if (!title?.trim()) throw new TaskError("bad_request", "A task needs a title.")
48
+ if (owner === "human" || kind === "question") owner = "human"
49
+ else if (assignee) owner = "session"
50
+ else if (role) owner = "role"
51
+ else owner = owner ?? "unassigned"
52
+ const task = {
53
+ id: newId(),
54
+ title: title.trim(),
55
+ brief,
56
+ projectId,
57
+ runId,
58
+ stage,
59
+ persona,
60
+ assignee, // session id, when one session in particular owns it
61
+ role, // or a part, played by whoever is playing it
62
+ owner, // session | role | human | unassigned
63
+ kind, // work | question
64
+ options, // for a question: the answers offered
65
+ askedBy, // session that asked
66
+ answer: null,
67
+ dependsOn,
68
+ acceptance,
69
+ createdBy,
70
+ status: dependsOn.length ? "blocked" : owner === "human" ? "waiting" : assignee || role ? "queued" : "backlog",
71
+ queuedAt: dependsOn.length ? null : Date.now(),
72
+ handoff: null,
73
+ notes: [],
74
+ files: [],
75
+ created: Date.now(),
76
+ updated: Date.now(),
77
+ order,
78
+ }
79
+ this.db.put("tasks", task)
80
+ this.onChange()
81
+ return task
82
+ }
83
+
84
+ get(id) {
85
+ return this.db.get("tasks", id)
86
+ }
87
+
88
+ all({ projectId, runId, assignee, status, owner, role } = {}) {
89
+ return this.db
90
+ .all("tasks")
91
+ .filter((t) => (!projectId || t.projectId === projectId) && (!runId || t.runId === runId))
92
+ .filter((t) => (!assignee || t.assignee === assignee) && (!status || t.status === status))
93
+ .filter((t) => (!owner || t.owner === owner) && (!role || t.role === role))
94
+ .sort((a, b) => a.order - b.order)
95
+ }
96
+
97
+ /** Everything waiting on the operator: questions first, they block someone. */
98
+ forHuman() {
99
+ return this.all({ owner: "human" })
100
+ .filter((t) => !["done", "cancelled"].includes(t.status))
101
+ .sort((a, b) => (a.kind === "question" ? -1 : 1) - (b.kind === "question" ? -1 : 1) || a.created - b.created)
102
+ }
103
+
104
+ save(task) {
105
+ task.updated = Date.now()
106
+ this.db.put("tasks", task)
107
+ this.onChange()
108
+ return task
109
+ }
110
+
111
+ update(id, patch) {
112
+ const task = this.get(id)
113
+ if (!task) throw new TaskError("unknown_task", `No task ${id}.`)
114
+ if (patch.status && !COLUMNS.includes(patch.status)) {
115
+ throw new TaskError("bad_status", `status must be one of: ${COLUMNS.join(", ")}`)
116
+ }
117
+ Object.assign(task, patch)
118
+ return this.save(task)
119
+ }
120
+
121
+ note(id, text, by = "operator") {
122
+ const task = this.get(id)
123
+ if (!task) return null
124
+ task.notes.push({ at: Date.now(), by, text: String(text).slice(0, 2000) })
125
+ if (task.notes.length > 50) task.notes.shift()
126
+ return this.save(task)
127
+ }
128
+
129
+ /** A task is ready when every dependency has landed in `done`. */
130
+ ready(task) {
131
+ return task.dependsOn.every((id) => this.get(id)?.status === "done")
132
+ }
133
+
134
+ /**
135
+ * Move anything whose dependencies just cleared into `queued`. Queued is
136
+ * deliberately not `active`: the assignee is told about it when it next goes
137
+ * idle, so a running agent is never interrupted mid-thought.
138
+ */
139
+ unblock() {
140
+ const freed = []
141
+ for (const task of this.all()) {
142
+ if (task.status !== "blocked") continue
143
+ if (!this.ready(task)) continue
144
+ task.status = task.owner === "human" ? "waiting" : task.assignee || task.role ? "queued" : "backlog"
145
+ task.queuedAt = Date.now()
146
+ this.save(task)
147
+ freed.push(task)
148
+ }
149
+ return freed
150
+ }
151
+
152
+ /**
153
+ * The next thing this session should pick up: work addressed to it by name,
154
+ * or work addressed to the part it plays that nobody has taken yet.
155
+ */
156
+ nextFor(sessionId, { role = null, projectId = null, alive = null, now = Date.now() } = {}) {
157
+ const mine = this.all({ assignee: sessionId })
158
+ // Work addressed to a part stays in its workstream: another workstream's
159
+ // dev bot must not pick up this one's cards. A card sent back for rework
160
+ // prefers the bot that did it first: others take it only once that bot is
161
+ // gone or has had its chance (preferUntil).
162
+ const mayTake = (t) => !t.prefer || t.prefer === sessionId || now > (t.preferUntil ?? 0) || (alive && !alive(t.prefer))
163
+ const byRole = role
164
+ ? this.all({ role }).filter((t) => !t.assignee && t.owner === "role" && (!projectId || !t.projectId || t.projectId === projectId) && mayTake(t))
165
+ : []
166
+ return [...mine, ...byRole]
167
+ .filter((t) => t.status === "queued" && this.ready(t))
168
+ .sort((a, b) => a.order - b.order)[0]
169
+ }
170
+
171
+ /** Kanban projection: columns with their cards, newest activity first. */
172
+ board({ projectId, runId } = {}) {
173
+ const tasks = this.all({ projectId, runId })
174
+ return COLUMNS.map((status) => ({
175
+ status,
176
+ tasks: tasks.filter((t) => t.status === status),
177
+ }))
178
+ }
179
+
180
+ remove(id) {
181
+ this.db.delete("tasks", id)
182
+ this.onChange()
183
+ }
184
+ }
185
+
186
+ export class TaskError extends Error {
187
+ constructor(code, message) {
188
+ super(message)
189
+ this.code = code
190
+ }
191
+ }
@@ -0,0 +1,86 @@
1
+ // Mustache subset, dependency-free. Supports {{var}} (HTML-safe is pointless
2
+ // here, so nothing is escaped), {{#section}}...{{/section}} for truthy values,
3
+ // lists and objects, {{^inverted}}, dotted paths, and {{.}} inside a list of
4
+ // scalars. Comments are {{! like this }}.
5
+ //
6
+ // This is how a pipeline step decides which earlier handoffs it receives: the
7
+ // template names them, so dropping a stage's output from a later prompt is an
8
+ // edit to one line of YAML rather than a code change.
9
+
10
+ const TAG = /\{\{([#^/!&]?)\s*([^}]*?)\s*\}\}/g
11
+
12
+ export function render(template, context = {}) {
13
+ const { out } = section(String(template ?? ""), 0, [context], true)
14
+ return out
15
+ }
16
+
17
+ function section(tpl, from, stack, emit) {
18
+ let out = ""
19
+ let i = from
20
+ TAG.lastIndex = from
21
+ let m
22
+ while ((m = TAG.exec(tpl))) {
23
+ if (emit) out += tpl.slice(i, m.index)
24
+ i = TAG.lastIndex
25
+ const [, sigil, name] = m
26
+
27
+ if (sigil === "!") continue
28
+ if (sigil === "/") return { out, next: i, closed: name }
29
+
30
+ if (sigil === "#" || sigil === "^") {
31
+ const value = lookup(stack, name)
32
+ const truthy = Array.isArray(value) ? value.length > 0 : !!value
33
+ const wanted = sigil === "#" ? truthy : !truthy
34
+
35
+ if (sigil === "#" && Array.isArray(value) && value.length) {
36
+ // Re-render the body once per item, then skip past it.
37
+ let last = null
38
+ for (const item of value) {
39
+ last = section(tpl, i, [...stack, item], true)
40
+ out += last.out
41
+ }
42
+ i = last.next
43
+ } else {
44
+ const body = section(tpl, i, sigil === "#" && isObject(value) ? [...stack, value] : stack, emit && wanted)
45
+ out += body.out
46
+ i = body.next
47
+ }
48
+ TAG.lastIndex = i
49
+ continue
50
+ }
51
+
52
+ if (emit) {
53
+ const v = lookup(stack, name)
54
+ out += v === null || v === undefined ? "" : Array.isArray(v) ? v.join(", ") : String(v)
55
+ }
56
+ }
57
+ if (emit) out += tpl.slice(i)
58
+ return { out, next: tpl.length }
59
+ }
60
+
61
+ function lookup(stack, name) {
62
+ if (name === ".") return stack[stack.length - 1]
63
+ const path = name.split(".")
64
+ for (let i = stack.length - 1; i >= 0; i--) {
65
+ let v = stack[i]
66
+ let ok = true
67
+ for (const key of path) {
68
+ if (v && typeof v === "object" && key in v) v = v[key]
69
+ else { ok = false; break }
70
+ }
71
+ if (ok) return v
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ const isObject = (v) => v && typeof v === "object" && !Array.isArray(v)
77
+
78
+ /** Names a template refers to — used to warn about typos in pipeline YAML. */
79
+ export function variables(template) {
80
+ const names = new Set()
81
+ for (const m of String(template ?? "").matchAll(TAG)) {
82
+ if (m[1] === "!" || m[1] === "/") continue
83
+ names.add(m[2].split(".")[0])
84
+ }
85
+ return [...names]
86
+ }
package/src/tools.mjs ADDED
@@ -0,0 +1,241 @@
1
+ // A workspace's own tools: every .js / .mjs file in <workspace>/botfarm/mcps/
2
+ // is loaded into BotFarm's MCP server and offered to the bots working in that
3
+ // workspace (as botfarm_<name>), next to the built-in ones.
4
+ //
5
+ // A tool file exports:
6
+ // name letters, digits, _ and - (defaults to the file name)
7
+ // description what the model reads to decide when to call it
8
+ // inputSchema JSON Schema for the arguments (default: no arguments)
9
+ // execute async (args, ctx) => result (also accepted: handler, default)
10
+ // timeoutMs optional, default 120000
11
+ //
12
+ // ctx: { repoRoot, workspace, bot, signal, log(...), exec(cmd, args, opts) }
13
+ //
14
+ // Files are re-imported when they change (fs.watch, plus a rescan as a safety
15
+ // net), so saving a file is enough; bots get the new list on their next turn.
16
+
17
+ import { readdir, readFile, writeFile, stat, mkdir, unlink } from "node:fs/promises"
18
+ import { watch } from "node:fs"
19
+ import { join, basename, extname } from "node:path"
20
+ import { pathToFileURL } from "node:url"
21
+ import { execFile } from "node:child_process"
22
+
23
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,62}$/
24
+ const FILE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,80}\.m?js$/
25
+
26
+ export class WorkspaceTools {
27
+ constructor({ supervisor, dirFor, reserved = [] }) {
28
+ this.sup = supervisor
29
+ this.dirFor = dirFor // (ws) => absolute tools folder
30
+ this.reserved = new Set(reserved) // built-in tool names a file may not take
31
+ this.sets = new Map() // workspace id -> { dir, tools: Map<name, tool>, files: Map<file, {mtime, name?, error?}>, watcher, version }
32
+ this.listeners = new Set()
33
+ }
34
+
35
+ onChange(fn) { this.listeners.add(fn) }
36
+
37
+ /** Load (or re-check) one workspace's tools. Returns true when the list changed. */
38
+ async load(ws) {
39
+ const dir = this.dirFor(ws)
40
+ let set = this.sets.get(ws.id)
41
+ if (!set || set.dir !== dir) {
42
+ set?.watcher?.close?.()
43
+ set = { dir, tools: new Map(), files: new Map(), watcher: null, version: 0, ws: ws.id }
44
+ this.sets.set(ws.id, set)
45
+ }
46
+ let names = []
47
+ try { names = (await readdir(dir)).filter((n) => FILE_RE.test(n)) } catch { names = [] }
48
+ let changed = false
49
+ const seen = new Set()
50
+ for (const file of names) {
51
+ seen.add(file)
52
+ const path = join(dir, file)
53
+ const mtime = await stat(path).then((s) => s.mtimeMs, () => 0)
54
+ const prev = set.files.get(file)
55
+ if (prev && prev.mtime === mtime) continue
56
+ changed = true
57
+ if (prev?.name) set.tools.delete(prev.name)
58
+ try {
59
+ const mod = await import(`${pathToFileURL(path).href}?v=${mtime}`)
60
+ const name = String(mod.name ?? mod.title ?? basename(file, extname(file)))
61
+ const exec = mod.execute ?? mod.handler ?? (typeof mod.default === "function" ? mod.default : null)
62
+ if (!NAME_RE.test(name)) throw new Error(`name "${name}" must be letters, digits, _ and - (starting with a letter)`)
63
+ if (this.reserved.has(name)) throw new Error(`"${name}" is a built-in BotFarm tool; pick another name`)
64
+ if (typeof exec !== "function") throw new Error("no execute(args, ctx) function exported")
65
+ const clash = [...set.tools.values()].find((t) => t.name === name && t.file !== file)
66
+ if (clash) throw new Error(`${clash.file} already defines "${name}"`)
67
+ const tool = {
68
+ name,
69
+ description: String(mod.description ?? "").trim() || `(no description in ${file})`,
70
+ inputSchema: mod.inputSchema && typeof mod.inputSchema === "object" ? mod.inputSchema : { type: "object", properties: {} },
71
+ timeoutMs: Number(mod.timeoutMs) > 0 ? Number(mod.timeoutMs) : 120_000,
72
+ execute: exec,
73
+ file,
74
+ }
75
+ set.tools.set(name, tool)
76
+ set.files.set(file, { mtime, name })
77
+ } catch (e) {
78
+ set.files.set(file, { mtime, error: e.message })
79
+ }
80
+ }
81
+ for (const [file, info] of [...set.files]) {
82
+ if (seen.has(file)) continue
83
+ set.files.delete(file)
84
+ if (info.name) set.tools.delete(info.name)
85
+ changed = true
86
+ }
87
+ if (!set.watcher) {
88
+ try {
89
+ let t = null
90
+ set.watcher = watch(dir, () => { clearTimeout(t); t = setTimeout(() => this.refresh(ws), 250) })
91
+ set.watcher.on("error", () => { set.watcher = null })
92
+ } catch { set.watcher = null } // no folder yet: the rescan picks it up
93
+ }
94
+ if (changed) set.version++
95
+ return changed
96
+ }
97
+
98
+ async refresh(ws) {
99
+ const changed = await this.load(ws).catch(() => false)
100
+ if (changed) for (const fn of this.listeners) fn(ws.id)
101
+ return changed
102
+ }
103
+
104
+ /** Rescan every workspace (the safety net for missed fs events). */
105
+ async rescan(workspaces) {
106
+ for (const ws of workspaces) await this.refresh(ws)
107
+ }
108
+
109
+ list(wsId) {
110
+ const set = this.sets.get(wsId)
111
+ if (!set) return { dir: null, tools: [], errors: [], version: 0 }
112
+ return {
113
+ dir: set.dir,
114
+ version: set.version,
115
+ tools: [...set.tools.values()].map(({ execute, ...t }) => t).sort((a, b) => a.name.localeCompare(b.name)),
116
+ errors: [...set.files].filter(([, f]) => f.error).map(([file, f]) => ({ file, error: f.error })),
117
+ }
118
+ }
119
+
120
+ get(wsId, name) {
121
+ return this.sets.get(wsId)?.tools.get(name) ?? null
122
+ }
123
+
124
+ /** MCP tool definitions for a workspace. */
125
+ mcpList(wsId) {
126
+ const set = this.sets.get(wsId)
127
+ if (!set) return []
128
+ return [...set.tools.values()].map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
129
+ }
130
+
131
+ /**
132
+ * Run a tool. Never throws: the outcome is { ok, result?, error?, ms, logs }.
133
+ * `repoRoot` is the checkout the tool works in (the bot's worktree).
134
+ */
135
+ async run(wsId, name, args = {}, { repoRoot, workspace = null, bot = null, signal = null } = {}) {
136
+ const tool = this.get(wsId, name)
137
+ const started = Date.now()
138
+ const logs = []
139
+ if (!tool) return { ok: false, error: `no tool "${name}" in this workspace`, ms: 0, logs }
140
+ const ctl = new AbortController()
141
+ const onAbort = () => ctl.abort()
142
+ signal?.addEventListener?.("abort", onAbort)
143
+ let timer
144
+ const ctx = {
145
+ repoRoot,
146
+ workspace,
147
+ bot,
148
+ signal: ctl.signal,
149
+ log: (...a) => { if (logs.length < 200) logs.push(a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ").slice(0, 2000)) },
150
+ exec: (cmd, argv = [], opts = {}) => new Promise((resolve) => {
151
+ execFile(cmd, argv, { cwd: opts.cwd ?? repoRoot, timeout: opts.timeoutMs ?? tool.timeoutMs, maxBuffer: 16 * 1024 * 1024, signal: ctl.signal, env: { ...process.env, ...(opts.env ?? {}) } }, (err, stdout, stderr) => {
152
+ resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") || (err && !stderr ? err.message : "") })
153
+ })
154
+ }),
155
+ }
156
+ try {
157
+ const result = await Promise.race([
158
+ Promise.resolve().then(() => tool.execute(args ?? {}, ctx)),
159
+ new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort(); reject(new Error(`timed out after ${Math.round(tool.timeoutMs / 1000)}s`)) }, tool.timeoutMs) }),
160
+ ])
161
+ return { ok: true, result: result === undefined ? null : result, ms: Date.now() - started, logs }
162
+ } catch (e) {
163
+ return { ok: false, error: e?.message ?? String(e), stack: String(e?.stack ?? "").split("\n").slice(1, 6).join("\n"), ms: Date.now() - started, logs }
164
+ } finally {
165
+ clearTimeout(timer)
166
+ signal?.removeEventListener?.("abort", onAbort)
167
+ }
168
+ }
169
+
170
+ // --- files, for the test bench ---------------------------------------------
171
+
172
+ checkFile(file) {
173
+ if (!FILE_RE.test(file ?? "")) throw new Error("a tool file is a name ending in .js or .mjs (letters, digits, _ - .)")
174
+ }
175
+
176
+ async source(ws, file) {
177
+ this.checkFile(file)
178
+ const path = join(this.dirFor(ws), file)
179
+ return { file, path, text: await readFile(path, "utf8").catch(() => "") }
180
+ }
181
+
182
+ async save(ws, file, text) {
183
+ this.checkFile(file)
184
+ const dir = this.dirFor(ws)
185
+ await mkdir(dir, { recursive: true })
186
+ await writeFile(join(dir, file), String(text ?? ""))
187
+ await this.refresh(ws)
188
+ return this.list(ws.id)
189
+ }
190
+
191
+ async create(ws, name) {
192
+ if (!NAME_RE.test(name ?? "")) throw new Error("a tool name is letters, digits, _ and - (starting with a letter)")
193
+ const file = `${name}.js`
194
+ const path = join(this.dirFor(ws), file)
195
+ if (await stat(path).then(() => true, () => false)) throw new Error(`${file} already exists`)
196
+ return { ...(await this.save(ws, file, template(name))), file }
197
+ }
198
+
199
+ async remove(ws, file) {
200
+ this.checkFile(file)
201
+ await unlink(join(this.dirFor(ws), file))
202
+ await this.refresh(ws)
203
+ return this.list(ws.id)
204
+ }
205
+
206
+ close() {
207
+ for (const s of this.sets.values()) s.watcher?.close?.()
208
+ }
209
+ }
210
+
211
+ /** Plain JSON for MCP: strings as text, everything else pretty-printed (and structured when it is an object). */
212
+ export function toolResult(out) {
213
+ if (!out.ok) return { content: [{ type: "text", text: `Tool error: ${out.error}${out.logs.length ? `\n\nlog:\n${out.logs.join("\n")}` : ""}` }], isError: true }
214
+ const r = out.result
215
+ if (typeof r === "string") return { content: [{ type: "text", text: r }] }
216
+ const text = JSON.stringify(r, null, 2) ?? "null"
217
+ return r && typeof r === "object" && !Array.isArray(r)
218
+ ? { content: [{ type: "text", text }], structuredContent: r }
219
+ : { content: [{ type: "text", text }] }
220
+ }
221
+
222
+ const template = (name) => `// BotFarm tool: offered to every bot in this workspace as botfarm_${name}.
223
+ // Saving this file reloads it. Try it from Tools in the BotFarm sidebar.
224
+
225
+ export const name = "${name}"
226
+ export const description = "Say what this does and when a bot should use it — this is what the model reads."
227
+ export const inputSchema = {
228
+ type: "object",
229
+ properties: {
230
+ path: { type: "string", description: "A path relative to the worktree." },
231
+ },
232
+ required: ["path"],
233
+ }
234
+
235
+ // ctx.repoRoot: the calling bot's worktree. ctx.exec(cmd, args): run a program there.
236
+ // ctx.log(...): lines shown in the test bench and returned with errors.
237
+ export async function execute({ path }, ctx) {
238
+ const { stdout } = await ctx.exec("ls", ["-la", path])
239
+ return { path, listing: stdout.split("\\n").filter(Boolean) }
240
+ }
241
+ `