@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,1803 @@
1
+ // The supervisor keeps the registry in sync with one opencode server:
2
+ // discovery poll -> event stream -> debounced message diffing -> metrics.
3
+
4
+ import { routeFor } from "./craft.mjs"
5
+ import * as patches from "./patches.mjs"
6
+ import { normalizeQuestions, formatAnswers } from "./questions.mjs"
7
+ import { writeFile, readFile, mkdir, stat, open, unlink } from "node:fs/promises"
8
+ import { homedir } from "node:os"
9
+ import { join, basename, resolve as resolveDir } from "node:path"
10
+ import { OpencodeClient, sessionIdOf } from "./api.mjs"
11
+ import { Store, ingestMessages, toolOf, partsOf, summarizeToolInput } from "./store.mjs"
12
+ import { Mesh } from "./mesh.mjs"
13
+ import { Rooms } from "./rooms.mjs"
14
+ import { Registry } from "./registry.mjs"
15
+ import { estimate, normalise, TABLE } from "./pricing.mjs"
16
+ import { openDb } from "./db.mjs"
17
+ import { Tasks } from "./tasks.mjs"
18
+ import { Pipelines } from "./pipelines.mjs"
19
+ import { Projects } from "./projects.mjs"
20
+ import { Workspaces, readManifest, readManifests, writeManifest, manifestPath, opencodeSetup, resolveBot, readJsonc } from "./workspaces.mjs"
21
+ import { mintToken } from "./identity.mjs"
22
+ import * as git from "./git.mjs"
23
+ import { WorkspaceTools } from "./tools.mjs"
24
+ import { Farmhands } from "./farmhands.mjs"
25
+ import { ModelLedger, overQuota } from "./quota.mjs"
26
+ import { Recorder, ReplayShelf, bundle, validBundle } from "./replay.mjs"
27
+ import { buildPacket, createPr } from "./packets.mjs"
28
+ import { TOOLS as BUILTIN_TOOLS } from "./mcp.mjs"
29
+ import { TOOLS_DIR } from "./workspaces.mjs"
30
+
31
+ export class Supervisor {
32
+ constructor({ url, statePath, onChange = () => {}, mcpBase = "http://127.0.0.1:4777", registryPath, pricing = {}, defaultWorkspace = null }) {
33
+ this.defaultWorkspace = defaultWorkspace
34
+ this.client = new OpencodeClient(url)
35
+ this.store = new Store(statePath)
36
+ this.onChange = onChange
37
+ this.dirty = new Set()
38
+ this.timers = []
39
+ this.mcpBase = mcpBase
40
+ this.streamHealthy = false
41
+ this.lastEventAt = 0
42
+ this.mesh = new Mesh(this)
43
+ this.rooms = new Rooms(this)
44
+ this.store.roomsSnapshot = () => this.rooms.snapshot()
45
+ this.store.extra = () => ({
46
+ board: this.tasks?.board() ?? [],
47
+ runs: (this.pipelines?.runs() ?? []).slice(0, 12).map((r) => ({
48
+ ...r,
49
+ tasks: Object.entries(r.taskIds).map(([stage, id]) => {
50
+ const t = this.tasks.get(id)
51
+ return { stage, id, status: t?.status ?? "gone", persona: t?.persona, title: t?.title }
52
+ }),
53
+ })),
54
+ unmanaged: this.unmanaged.length,
55
+ projects: (this.projects?.snapshot() ?? []).map((p) => ({
56
+ ...p,
57
+ workspaceId: p.workspaceId ?? this.workspaces?.forPath(p.repo)?.id ?? null,
58
+ budget: this.pipelines?.budgetView?.(p) ?? null,
59
+ })),
60
+ workspaces: this.workspaces?.list() ?? [],
61
+ tools: Object.fromEntries((this.workspaces?.list() ?? []).map((w) => [w.id, this.wsTools?.list(w.id) ?? null])),
62
+ levels: Object.fromEntries((this.workspaces?.list() ?? []).map((w) => [w.id, this.farmhands?.all(w.id) ?? []])),
63
+ tasks: (this.tasks?.all() ?? []).map((t) => (t.status === "active" && t.assignee && !t.children ? { ...t, usage: this.pipelines?.taskUsage?.(t) } : t)),
64
+ })
65
+ this.store.roomsPersist = () => this.rooms.persist()
66
+ this.tokens = new Map() // mcp token -> session id
67
+ this.mcpCalls = new Map() // session id -> when it last started an botfarm_ tool
68
+ // Live transcript plumbing, set by the web server: who is watching which
69
+ // session, and how to reach them.
70
+ this.isWatched = () => false
71
+ this.live = () => {}
72
+ this.registry = new Registry(registryPath ?? statePath.replace(/[^/]+$/, "registry.json"))
73
+ this.pricing = pricing
74
+ this.unmanaged = [] // sessions opencode knows about that botfarm is not running
75
+ this.home = (registryPath ?? statePath).replace(/[^/]+$/, "").replace(/\/$/, "")
76
+ this.store.pricing = () => pricing
77
+ }
78
+
79
+ // --- mesh plumbing -------------------------------------------------------
80
+
81
+ sessionForToken(token) {
82
+ const id = this.tokens.get(token)
83
+ if (!id) return null
84
+ const s = this.store.get(id)
85
+ if (s) return s
86
+ // One token per location: when several sessions share a worktree the
87
+ // caller is whichever one is actually running a step right now.
88
+ const dir = this.tokenDirs?.get(token)
89
+ const candidates = this.store.list().filter((x) => x.directory === dir)
90
+ return candidates.find((x) => x.status === "busy") ?? candidates[0] ?? null
91
+ }
92
+
93
+ /**
94
+ * Which session is making this MCP call. A token identifies a location,
95
+ * not a session: in a shared worktree we pick the session that has an
96
+ * botfarm_ tool in flight (from the event stream), or the only busy one. When
97
+ * neither settles it we wait briefly for the tool-start event to land.
98
+ */
99
+ async callerForToken(token, { wait = 400 } = {}) {
100
+ const owner = this.store.get(this.tokens.get(token)) ?? null
101
+ const dir = this.tokenDirs?.get(token) ?? owner?.directory
102
+ if (!dir) return owner
103
+ const deadline = Date.now() + wait
104
+ while (true) {
105
+ const pick = this.pickCaller(dir, owner)
106
+ if (pick.sure || Date.now() >= deadline) return pick.session
107
+ await new Promise((r) => setTimeout(r, 40))
108
+ }
109
+ }
110
+
111
+ pickCaller(dir, owner) {
112
+ const here = this.store.list().filter((s) => s.directory === dir)
113
+ if (here.length <= 1) return { session: here[0] ?? owner, sure: true }
114
+ const now = Date.now()
115
+ const calling = here
116
+ .map((s) => [s, this.mcpCalls.get(s.id) ?? 0])
117
+ .filter(([, t]) => now - t < 30_000)
118
+ .sort((a, b) => b[1] - a[1])
119
+ if (calling.length) return { session: calling[0][0], sure: true }
120
+ const busy = here.filter((s) => s.status === "busy")
121
+ if (busy.length === 1) return { session: busy[0], sure: true }
122
+ return { session: busy[0] ?? owner ?? here[0], sure: false }
123
+ }
124
+
125
+ mcpUrlFor(session) {
126
+ // opencode holds MCP config per location, so every session in a worktree
127
+ // ends up on whichever URL was written last. Share one token per
128
+ // directory on purpose, and work out the caller per call instead.
129
+ if (!session.mcpToken && session.directory) {
130
+ const sibling = this.store.list().find((x) => x.id !== session.id && x.mcpToken && x.directory === session.directory)
131
+ if (sibling) session.mcpToken = sibling.mcpToken
132
+ }
133
+ if (!session.mcpToken) session.mcpToken = mintToken()
134
+ this.tokens.set(session.mcpToken, session.id)
135
+ ;(this.tokenDirs ??= new Map()).set(session.mcpToken, session.directory)
136
+ return `${this.mcpBase}/mcp/${session.mcpToken}`
137
+ }
138
+
139
+ /**
140
+ * Give a session the botfarm tools. Project-level config is the reliable route
141
+ * (opencode reads it when the session's location loads); runtime
142
+ * registration is attempted first so an already-open session can pick the
143
+ * tools up without a restart.
144
+ */
145
+ async enableMesh(session) {
146
+ const url = this.mcpUrlFor(session)
147
+ const config = { type: "remote", url, enabled: true }
148
+ let live = false
149
+ try {
150
+ await this.client.addMcpServer(MCP_NAME, config, session.directory)
151
+ live = true
152
+ } catch {}
153
+ if (session.directory) await writeProjectConfig(session.directory, config).catch(() => {})
154
+ this.store.note(
155
+ live
156
+ ? `mesh tools enabled for @${session.handle}`
157
+ : `mesh tools written to ${session.directory}/.opencode — restart that session to load them`,
158
+ session.id,
159
+ )
160
+ return { url, live }
161
+ }
162
+
163
+ async setPolicy(id, patch) {
164
+ const s = this.store.get(id)
165
+ if (!s) throw new Error("unknown session")
166
+ const before = { ...s.policy }
167
+ Object.assign(s.policy, patch)
168
+ this.mesh.handles()
169
+ const wasOff = before.talk === "off" && !before.spawn
170
+ const isOn = s.policy.talk !== "off" || s.policy.spawn
171
+ let result = {}
172
+ if (wasOff && isOn) result = await this.enableMesh(s)
173
+ this.onChange()
174
+ return { policy: s.policy, ...result }
175
+ }
176
+
177
+ /** Put peer traffic into a session's transcript. */
178
+ /**
179
+ * An agent's `tools:` list, as the per-turn switch map opencode takes:
180
+ * every built-in tool not on the list is off. The botfarm tools are always on,
181
+ * or the bot could not hand off.
182
+ */
183
+ async toolSwitches(session) {
184
+ if (!session?.allowedTools?.length) return undefined
185
+ this.toolIdCache ??= new Map()
186
+ let ids = this.toolIdCache.get(session.directory)
187
+ if (!ids) {
188
+ ids = await this.client.toolIds(session.directory).catch(() => [])
189
+ if (ids.length) this.toolIdCache.set(session.directory, ids)
190
+ }
191
+ const allowed = new Set(session.allowedTools)
192
+ const off = Object.fromEntries(ids.filter((t) => !allowed.has(t) && !/^(botfarm|botfarm)[_-]/.test(t)).map((t) => [t, false]))
193
+ return Object.keys(off).length ? off : undefined
194
+ }
195
+
196
+ /** The agent, model and reasoning level every turn of this bot asks for. */
197
+ turnSettings(s) {
198
+ if (!s) return {}
199
+ // What the operator set on this bot while it runs beats what its agent file says.
200
+ // Then the card's routing (by difficulty), then the agent definition.
201
+ const o = s.override ?? {}
202
+ const r = s.route ?? {}
203
+ const model = o.model ?? r.model ?? s.configuredModel ?? undefined
204
+ const variant = ("variant" in o ? o.variant : "variant" in r ? r.variant : s.variantWanted) ?? undefined
205
+ return { agent: s.agentName ?? undefined, model, variant }
206
+ }
207
+
208
+ async deliver(session, { text, description }) {
209
+ await this.client.synthetic(session.id, text, description, { tools: await this.toolSwitches(session), ...this.turnSettings(session) })
210
+ session.invocations = (session.invocations ?? 0) + 1
211
+ session.updated = Date.now()
212
+ this.dirty.add(session.id)
213
+ this.onChange()
214
+ }
215
+
216
+ resumeChannel(a, b) {
217
+ this.mesh.blocked.delete(`${a}>${b}`)
218
+ this.mesh.blocked.delete(`${b}>${a}`)
219
+ this.mesh.chain.delete([a, b].sort().join("|"))
220
+ this.store.note("channel resumed by operator", a, "info")
221
+ this.onChange()
222
+ }
223
+
224
+ async start() {
225
+ await this.store.load()
226
+ await this.registry.load()
227
+ this.db = await openDb(`${this.home}/botfarm.db`)
228
+ try { this.config = JSON.parse(await readFile(join(this.home, "config.json"), "utf8")) } catch { this.config = {} }
229
+ this.tasks = new Tasks(this.db, { onChange: () => this.onChange() })
230
+ this.farmhands = new Farmhands({ db: this.db })
231
+ this.ledger = new ModelLedger(this.db)
232
+ this.shelf = new ReplayShelf(join(this.home, "replays"))
233
+ // Every visible change to a workstream's cards goes on its replay timeline.
234
+ const save = this.tasks.save.bind(this.tasks)
235
+ this.tasks.save = (task) => { const out = save(task); this.replay?.task(task); return out }
236
+ this.pipelines = new Pipelines({ dir: this.home, tasks: this.tasks, supervisor: this })
237
+ this.projects = new Projects({ db: this.db, supervisor: this })
238
+ this.workspaces = new Workspaces({ db: this.db, supervisor: this })
239
+ for (const ws of this.workspaces.list()) {
240
+ await this.workspaces.ensureFiles(ws).catch((e) => this.store.note(`${ws.name}: ${e.message}`, null, "warn"))
241
+ await this.workspaces.upgradeDefaults(ws).catch(() => {})
242
+ }
243
+ this.replay = new Recorder({ pathFor: (projectId) => this.replayPath(projectId) })
244
+ // Each workspace's own tools (botfarm/mcps/*.js), served next to the built-ins.
245
+ this.wsTools = new WorkspaceTools({ supervisor: this, dirFor: (ws) => join(ws.path, TOOLS_DIR), reserved: BUILTIN_TOOLS.map((t) => t.name) })
246
+ for (const ws of this.workspaces.list()) await this.wsTools.load(ws).catch(() => {})
247
+ this.wsTools.onChange((wsId) => this.toolsChanged(wsId).catch(() => {}))
248
+ if (!this.workspaces.list().length && this.defaultWorkspace) {
249
+ await this.workspaces.add({ path: this.defaultWorkspace }).catch((e) =>
250
+ this.store.note(`default workspace ${this.defaultWorkspace}: ${e.message}`, null, "warn"),
251
+ )
252
+ }
253
+ const loaded = await this.pipelines.load()
254
+ this.store.note(`loaded ${loaded.personas} persona(s) and ${loaded.pipelines} pipeline(s) from ${this.home}`)
255
+ for (const p of loaded.problems) this.store.note(`${p.file}: ${p.problems.join("; ")}`, null, "warn")
256
+ this.rooms.restore(this.store.savedState?.rooms)
257
+ const dialect = await this.client.connect()
258
+ this.store.serverStatus = "connected"
259
+ this.store.note(`connected to ${this.client.base} (api ${dialect})`)
260
+
261
+ await this.recoverFromManifests().catch((e) => this.store.note(`manifest recovery: ${e.message}`, null, "warn"))
262
+ await this.discover()
263
+ this.applyManifestBots()
264
+ await this.backfillBotSettings().catch(() => {})
265
+ // Every workstream gets a manifest, including ones started before they existed.
266
+ for (const p of this.projects.list()) if (p.id !== "default") await this.saveManifest(p.id).catch(() => {})
267
+ this.subscribe()
268
+
269
+ // Cadence depends on whether the event stream is carrying us. With a live
270
+ // stream these are reconciliation, not the mechanism: statuses come from
271
+ // events, and polling only catches what the stream missed.
272
+ this.timers.push(setInterval(() => this.discover().catch(() => {}), 30_000))
273
+ this.timers.push(setInterval(() => this.reconcile().catch(() => {}), 2_000))
274
+ this.timers.push(setInterval(() => this.flushDirty().catch(() => {}), 1_200))
275
+ this.timers.push(setInterval(() => { for (const s of this.store.list()) this.replay?.bot(s) }, 1_000))
276
+ // Minutes per model: busy time, counted every 5 seconds.
277
+ this.timers.push(setInterval(() => {
278
+ for (const s of this.store.list()) {
279
+ if (s.status !== "busy") continue
280
+ const model = this.turnSettings(s).model ?? s.model
281
+ if (model) this.ledger?.add(this.workspaceIdOf(s), model, s.project && s.project !== "default" ? s.project : null, { minutes: 5 / 60 }, Date.now(), s.persona ?? null)
282
+ }
283
+ }, 5_000))
284
+ this.timers.push(setInterval(() => this.refreshGit().catch(() => {}), 15_000))
285
+ // Every 20s while some bot has a failed MCP server (so one that comes up
286
+ // late is picked up quickly), otherwise once a minute.
287
+ this.timers.push(setInterval(() => {
288
+ const failing = this.store.list().some((s) => s.mcp?.servers?.some((m) => RETRYABLE_MCP.has(m.status)))
289
+ this.mcpTick = (this.mcpTick ?? 0) + 1
290
+ if (failing || this.mcpTick % 3 === 0) this.refreshMcp().catch(() => {})
291
+ this.watchWorkspaceConfig().catch(() => {})
292
+ this.wsTools?.rescan(this.workspaces.list()).catch(() => {})
293
+ this.flushToolRefresh().catch(() => {})
294
+ }, 20_000))
295
+ // Limits are checked every few seconds: a runaway bot burns a lot in a minute.
296
+ this.timers.push(setInterval(() => this.pipelines?.checkBudgets().catch((e) => this.store.note(`budget check: ${e.message}`, null, "warn")), 5_000))
297
+ this.watchWorkspaceConfig().catch(() => {})
298
+ this.timers.push(setInterval(() => this.watchOpencodeLog().catch(() => {}), 3_000))
299
+ this.refreshMcp().catch(() => {})
300
+ this.timers.push(setInterval(() => this.pipelines?.dispatch().catch(() => {}), 10_000))
301
+ this.timers.push(setInterval(() => this.store.save().catch(() => {}), 30_000))
302
+ return dialect
303
+ }
304
+
305
+ stop() {
306
+ this.replay?.stop().catch(() => {})
307
+ this.wsTools?.close()
308
+ this.registry.save().catch(() => {})
309
+ this.db?.close?.()
310
+ this.timers.forEach(clearInterval)
311
+ this.sub?.stop()
312
+ return this.store.save().catch(() => {})
313
+ }
314
+
315
+ subscribe() {
316
+ this.sub = this.client.subscribe({
317
+ onEvent: (payload) => {
318
+ const type = payload?.type ?? ""
319
+ const id = sessionIdOf(payload)
320
+ this.lastEventAt = Date.now()
321
+ // Keep the latest example of each event type for /api/debug.
322
+ this.recentEvents ??= []
323
+ if (type && !this.recentEvents.some((e) => e.type === type)) {
324
+ this.recentEvents.push({ type, at: Date.now(), payload: JSON.stringify(payload).slice(0, 3000) })
325
+ if (this.recentEvents.length > 40) this.recentEvents.shift()
326
+ }
327
+ if (id) {
328
+ this.dirty.add(id)
329
+ const s = this.store.get(id)
330
+ // Any traffic about a session means it is doing something. Event
331
+ // names change between releases; "there was an event" does not.
332
+ const inAbortGrace = s?.abortedAt && Date.now() - s.abortedAt < 2000
333
+ if (s && s.status === "idle" && !/idle|finished|completed|error|permission/.test(type) && !inAbortGrace) {
334
+ s.setStatus("busy")
335
+ }
336
+ if (s) s.lastEventAt = Date.now()
337
+ const live = livePart(payload)
338
+ if (live?.tool && /^(botfarm|botfarm)[_-]/.test(live.tool.name)) {
339
+ if (/pending|running/.test(live.tool.status)) this.mcpCalls.set(id, Date.now())
340
+ else this.mcpCalls.delete(id)
341
+ }
342
+ if (live && this.isWatched(id)) this.live(id, { t: "part", sessionId: id, ...live })
343
+ }
344
+
345
+ // Only a few event names get special treatment; everything else just
346
+ // marks the session dirty and the message diff does the real work.
347
+ // Instance engine: session.status carries { type: "busy" | "idle" | "retry" }.
348
+ if (type === "session.status" && id) {
349
+ const s = this.store.get(id)
350
+ const st = payload.properties?.status?.type ?? payload.properties?.status
351
+ if (s && st === "idle") { s.setStatus("idle"); s.currentTool = null; s.abortedAt = null; this.rooms.flushAll(s).catch(() => {}); this.pipelines?.dispatch().catch(() => {}); this.pipelines?.idle(s) }
352
+ else if (s && st && !(s.abortedAt && Date.now() - s.abortedAt < 2000)) { s.setStatus("busy"); s.updated = Date.now() }
353
+ this.onChange()
354
+ return
355
+ }
356
+ if (/session\.(created|updated)/.test(type)) {
357
+ const info = payload.properties?.info ?? payload.info ?? payload.session
358
+ if (info?.id) this.store.upsert(info)
359
+ }
360
+ if (/idle|completed|finished/.test(type) && id) {
361
+ const s = this.store.get(id)
362
+ if (s) {
363
+ s.setStatus("idle")
364
+ s.currentTool = null
365
+ s.updated = Date.now()
366
+ this.rooms.flushAll(s).catch(() => {})
367
+ this.pipelines?.dispatch().catch(() => {})
368
+ this.pipelines?.idle(s)
369
+ }
370
+ }
371
+ if (/busy|started|running|step\.start/.test(type) && id) {
372
+ const s = this.store.get(id)
373
+ if (s && !(s.abortedAt && Date.now() - s.abortedAt < 2000)) { s.setStatus("busy"); s.updated = Date.now() }
374
+ }
375
+ if (/permission/.test(type) && id) {
376
+ const s = this.store.get(id)
377
+ if (s) { s.setStatus("waiting"); this.store.note(`${label(s)} needs a permission decision`, id, "warn") }
378
+ }
379
+ if ((/error/.test(type) || /step\.failed/.test(type)) && id) {
380
+ const s = this.store.get(id)
381
+ if (s) {
382
+ s.lastError = errorText(payload.properties?.error ?? payload.error) ?? "error"
383
+ s.setStatus("error")
384
+ this.store.note(`@${s.handle ?? label(s)} failed: ${s.lastError}`, id, "error")
385
+ }
386
+ }
387
+ this.onChange()
388
+ },
389
+ onStatus: (status, err) => {
390
+ this.streamHealthy = status === "connected"
391
+ if (status === "connected") this.lastEventAt = Date.now()
392
+ this.store.serverStatus = status
393
+ if (status === "disconnected") this.store.note(`event stream lost: ${err?.message ?? "?"}`, null, "warn")
394
+ this.onChange()
395
+ },
396
+ })
397
+ }
398
+
399
+ async discover() {
400
+ const list = await this.client.listSessions({ limit: 200 })
401
+ const seen = new Set()
402
+ const loose = []
403
+ for (const info of list) {
404
+ if (!info?.id) continue
405
+ // The board shows what botfarm is running, not everything opencode has ever
406
+ // opened. Unadopted sessions are offered, not displayed.
407
+ if (!this.registry.has(info.id)) {
408
+ loose.push({
409
+ id: info.id,
410
+ title: info.title ?? "untitled",
411
+ directory: info.directory ?? info.location?.directory ?? null,
412
+ updated: info.time?.updated ?? info.time?.created ?? 0,
413
+ })
414
+ continue
415
+ }
416
+ seen.add(info.id)
417
+ const s = this.store.upsert(info)
418
+ if (s.engine === "v1") this.client.markV1(s.id, s.directory)
419
+ const entry = this.registry.get(info.id)
420
+ s.project = entry.project ?? entry.group ?? "default"
421
+ if (entry.label) s.label = entry.label
422
+ if (s.dirty) { this.dirty.add(s.id); s.dirty = false }
423
+ }
424
+ this.unmanaged = loose.sort((a, b) => b.updated - a.updated).slice(0, 300)
425
+ // The listing is one page. With a couple of hundred sessions in opencode,
426
+ // the ones botfarm manages can fall off it (this is how a freshly restarted
427
+ // team vanished from the board). Look those up one by one, and only drop
428
+ // a session opencode says no longer exists.
429
+ this.gone ??= new Map() // id -> when opencode last said it does not exist
430
+ const missing = [...this.registry.entries.keys()].filter((id) => !seen.has(id) && Date.now() - (this.gone.get(id) ?? 0) > 600_000)
431
+ await Promise.all(
432
+ missing.slice(0, 40).map(async (id) => {
433
+ try {
434
+ const info = await this.client.getSession(id)
435
+ if (!info?.id) throw new Error("-> 404")
436
+ seen.add(id)
437
+ this.gone.delete(id)
438
+ const s = this.store.upsert(info)
439
+ const entry = this.registry.get(id)
440
+ s.project = entry.project ?? entry.group ?? "default"
441
+ if (entry.label) s.label = entry.label
442
+ if (s.dirty) { this.dirty.add(s.id); s.dirty = false }
443
+ } catch (err) {
444
+ if (/-> 404/.test(err.message)) this.gone.set(id, Date.now())
445
+ else if (this.store.get(id)) seen.add(id) // a hiccup is not a deletion
446
+ }
447
+ }),
448
+ )
449
+ for (const id of [...this.store.sessions.keys()]) {
450
+ if (!seen.has(id)) this.store.sessions.delete(id)
451
+ }
452
+ this.mesh.handles()
453
+ for (const s of this.store.list()) if (s.mcpToken) this.tokens.set(s.mcpToken, s.id)
454
+ this.onChange()
455
+ }
456
+
457
+ /**
458
+ * Reconcile against the server. While the event stream is healthy this only
459
+ * runs when something looks stale, so a quiet board makes no requests at all;
460
+ * with the stream down it becomes the polling loop it used to be.
461
+ */
462
+ async reconcile() {
463
+ const quiet = Date.now() - (this.lastEventAt ?? 0)
464
+ if (this.streamHealthy) {
465
+ const suspicious = this.store.list().some((s) => s.status === "busy" && Date.now() - (s.lastEventAt ?? 0) > 12_000)
466
+ if (!suspicious && quiet < 12_000) return
467
+ if (!suspicious && this.lastReconcile && Date.now() - this.lastReconcile < 15_000) return
468
+ }
469
+ this.lastReconcile = Date.now()
470
+ await this.refreshStatuses()
471
+ }
472
+
473
+ async refreshStatuses() {
474
+ const active = await this.client.activeSessionIds()
475
+ if (!active) return
476
+ for (const s of this.store.list()) {
477
+ const busy = active.has(s.id)
478
+ if (busy && s.status !== "busy") { s.setStatus("busy"); this.dirty.add(s.id) }
479
+ else if (!busy && s.status === "busy") {
480
+ s.setStatus("idle")
481
+ s.currentTool = null
482
+ this.dirty.add(s.id)
483
+ this.rooms.flushAll(s).catch(() => {})
484
+ this.pipelines?.dispatch().catch(() => {})
485
+ this.pipelines?.idle(s)
486
+ }
487
+ }
488
+ // Only chase a busy session whose events have gone quiet — a healthy
489
+ // stream already marked it dirty when something happened.
490
+ for (const s of this.store.list()) {
491
+ if (s.status === "busy" && (!this.streamHealthy || Date.now() - (s.lastEventAt ?? 0) > 10_000)) this.dirty.add(s.id)
492
+ }
493
+ this.onChange()
494
+ }
495
+
496
+ async flushDirty() {
497
+ // Someone has this session open: keep its transcript moving even if the
498
+ // event stream only says "something happened" without the content.
499
+ for (const s of this.store.list()) if (s.status === "busy" && this.isWatched(s.id)) this.dirty.add(s.id)
500
+ if (this.dirty.size === 0) return
501
+ const ids = [...this.dirty]
502
+ this.dirty.clear()
503
+ await Promise.all(
504
+ ids.map(async (id) => {
505
+ const s = this.store.get(id)
506
+ if (!s) return
507
+ try {
508
+ const messages = await this.client.messages(id, { limit: 30, order: "desc" })
509
+ const { tokenDelta, toolDelta, modelDeltas } = ingestMessages(s, messages)
510
+ // Spend per model, for routing quotas ("$5 a day on Opus").
511
+ for (const { model, totals } of modelDeltas ?? []) {
512
+ const tokens = totals.input + totals.output + totals.reasoning + totals.cacheRead + totals.cacheWrite
513
+ const usd = estimate({ model, totals, reported: totals.cost, overrides: this.pricing }).usd
514
+ this.ledger?.add(this.workspaceIdOf(s), model, s.project && s.project !== "default" ? s.project : null, { usd, tokens }, Date.now(), s.persona ?? null)
515
+ }
516
+ if (tokenDelta || toolDelta) s.updated = Date.now()
517
+ if (this.isWatched(id)) this.pushTranscript(s, messages)
518
+ const [perms, asks] = await Promise.all([this.client.pendingPermissions(id), this.client.pendingQuestions(id).catch(() => [])])
519
+ s.pendingPermissions = perms.length
520
+ s.permissionList = perms
521
+ s.questionList = asks
522
+ if ((perms.length || asks.length) && s.status !== "error") s.setStatus("waiting")
523
+ else if (s.status === "waiting") s.setStatus("busy")
524
+ } catch {}
525
+ }),
526
+ )
527
+ this.onChange()
528
+ }
529
+
530
+ pushTranscript(s, messages) {
531
+ this.live(s.id, {
532
+ t: "transcript",
533
+ sessionId: s.id,
534
+ status: s.status,
535
+ currentTool: s.currentTool,
536
+ transcript: messages.slice(0, 40).map(summarizeMessage).filter(Boolean),
537
+ })
538
+ }
539
+
540
+ /** A viewer opened a session: send it the transcript straight away. */
541
+ async watchStarted(id) {
542
+ const s = this.store.get(id)
543
+ if (!s) return
544
+ const messages = await this.client.messages(id, { limit: 40, order: "desc" }).catch(() => null)
545
+ if (messages) this.pushTranscript(s, messages)
546
+ }
547
+
548
+ /** Raw shapes from opencode, for when parsing goes wrong. */
549
+ async raw(id, limit = 3) {
550
+ return {
551
+ messages: await this.client.messages(id, { limit, order: "desc" }).catch((e) => ({ error: e.message })),
552
+ events: this.recentEvents ?? [],
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Put a workstream away: stop its bots, and optionally delete their
558
+ * sessions and its worktree. The record, board and chat stay readable.
559
+ */
560
+ async archive(projectId, { removeSessions = false, removeWorktree = false } = {}) {
561
+ const project = this.projects.get(projectId)
562
+ if (!project) throw new Error("unknown workstream")
563
+ const sessions = this.projects.sessions(projectId)
564
+ for (const s of sessions) if (s.status === "busy") await this.abort(s.id).catch(() => {})
565
+ if (removeSessions) for (const s of sessions) await this.remove(s.id)
566
+ if (removeWorktree && project.worktree && project.worktree !== project.repo) {
567
+ await git.removeWorktreeSet(project.worktree, { force: true }).catch((e) => this.store.note(`worktree removal failed: ${e.message}`, null, "warn"))
568
+ }
569
+ for (const t of this.tasks.all({ projectId })) {
570
+ if (!["done", "cancelled"].includes(t.status)) this.tasks.update(t.id, { status: "cancelled" })
571
+ }
572
+ this.projects.save({ ...project, status: "archived" })
573
+ await this.saveManifest(projectId)
574
+ this.store.note(`archived ${project.name}`)
575
+ this.onChange()
576
+ }
577
+
578
+ replayPath(projectId) {
579
+ const p = this.projects?.get(projectId)
580
+ const ws = p?.workspaceId ? this.workspaces.get(p.workspaceId) : this.workspaces?.forPath(p?.repo)
581
+ return ws ? join(ws.path, ".botfarm", "workstreams", `${projectId}.replay.jsonl`) : join(this.home, "replays", "live", `${projectId}.jsonl`)
582
+ }
583
+
584
+ /** A workstream's replay as one portable document. */
585
+ async exportReplay(projectId) {
586
+ const project = this.projects.get(projectId)
587
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
588
+ const run = project.runId ? this.pipelines.run(project.runId) : null
589
+ const room = project.roomId ? this.rooms.rooms.get(project.roomId) : null
590
+ return bundle({
591
+ project,
592
+ run,
593
+ team: this.projects.sessions(projectId).map((s) => s.snapshot(this.pricing)),
594
+ tasks: this.tasks.all({ projectId }),
595
+ messages: room?.messages ?? [],
596
+ events: await this.replay.events(projectId),
597
+ harvest: project.harvest ?? null,
598
+ })
599
+ }
600
+
601
+ async importReplay(doc) {
602
+ if (!validBundle(doc)) throw new Error("that is not a BotFarm replay file")
603
+ return this.shelf.put({ ...doc, importedAt: new Date().toISOString() })
604
+ }
605
+
606
+ /** Write the workstream's PR packet next to its manifest and return it. */
607
+ async writePacket(projectId) {
608
+ const project = this.projects.get(projectId)
609
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
610
+ const run = project.runId ? this.pipelines.run(project.runId) : null
611
+ const stages = run ? Object.values(run.taskIds).map((id) => this.tasks.get(id)).filter(Boolean).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) : this.tasks.all({ projectId })
612
+ const sessions = this.projects.sessions(projectId)
613
+ const bots = sessions.map((s) => ({ handle: s.handle, persona: s.persona, model: s.model ?? s.configuredModel, tokens: tokensOfSession(s), usd: s.cost(this.pricing)?.usd ?? 0 }))
614
+ const totals = run ? this.pipelines.runUsage(run) : { tokens: bots.reduce((a, b) => a + b.tokens, 0), usd: bots.reduce((a, b) => a + b.usd, 0) }
615
+ const dir = project.worktree ?? sessions[0]?.directory ?? null
616
+ const packet = await buildPacket({ project, run, stages, bots, dir, totals })
617
+ const ws = project.workspaceId ? this.workspaces.get(project.workspaceId) : this.workspaces.forPath(project.repo)
618
+ const path = ws ? join(ws.path, ".botfarm", "workstreams", `${projectId}.pr.md`) : join(this.home, "packets", `${projectId}.pr.md`)
619
+ await mkdir(join(path, ".."), { recursive: true })
620
+ await writeFile(path, packet.markdown)
621
+ return { ...packet, path, dir, branch: project.branch ?? null }
622
+ }
623
+
624
+ async openPr(projectId) {
625
+ const p = await this.writePacket(projectId)
626
+ if (!p.dir || !p.branch) throw new Error("this workstream has no worktree branch to open a PR from")
627
+ const out = await createPr({ dir: p.dir, branch: p.branch, title: p.title, bodyFile: p.path, base: p.base })
628
+ this.store.note(`opened ${out.url}`, null, "info")
629
+ this.replay?.note(projectId, `PR opened: ${out.url}`)
630
+ const project = this.projects.get(projectId)
631
+ if (project) this.projects.save({ ...project, prUrl: out.url })
632
+ return out
633
+ }
634
+
635
+ /** The workspace a bot works for: its workstream's, or the one its checkout is in. */
636
+ workspaceIdOf(s) {
637
+ if (!s) return null
638
+ const p = s.project && s.project !== "default" ? this.projects.get(s.project) : null
639
+ return p?.workspaceId ?? this.workspaces?.forPath(p?.repo ?? s.repo ?? s.directory)?.id ?? null
640
+ }
641
+
642
+ /**
643
+ * A tool file in botfarm/mcps/ changed. opencode keeps the tool list it
644
+ * fetched when it connected, so reconnect BotFarm's MCP server for every
645
+ * worktree of that workspace — now where nobody is mid-turn, otherwise as
646
+ * soon as they are idle.
647
+ */
648
+ async toolsChanged(wsId) {
649
+ const { tools, errors } = this.wsTools.list(wsId)
650
+ this.store.note(`workspace tools reloaded: ${tools.map((t) => t.name).join(", ") || "none"}${errors.length ? ` · ${errors.length} file(s) with errors` : ""}`, null, errors.length ? "warn" : "info")
651
+ this.pendingToolRefresh ??= new Set()
652
+ for (const s of this.store.list()) if (s.directory && this.workspaceIdOf(s) === wsId) this.pendingToolRefresh.add(s.directory)
653
+ await this.flushToolRefresh()
654
+ this.onChange()
655
+ }
656
+
657
+ async flushToolRefresh() {
658
+ for (const dir of [...(this.pendingToolRefresh ?? [])]) {
659
+ if (this.store.list().some((x) => x.directory === dir && (x.status === "busy" || x.status === "waiting"))) continue
660
+ this.pendingToolRefresh.delete(dir)
661
+ await this.client.setMcpConnected(MCP_NAME, false, dir).catch(() => {})
662
+ await this.client.setMcpConnected(MCP_NAME, true, dir).catch(() => {})
663
+ this.toolIdCache?.delete(dir)
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Upgrade or downgrade a running bot: another model, another reasoning
669
+ * level. It applies from the bot's next turn (every prompt carries model and
670
+ * variant), survives restarts and config refreshes, and `null` goes back to
671
+ * what its agent definition says.
672
+ */
673
+ async setBotSettings(id, { model, variant } = {}) {
674
+ const s = this.store.get(id)
675
+ if (!s) throw Object.assign(new Error("unknown session"), { status: 404 })
676
+ const o = { ...(s.override ?? {}) }
677
+ if (model !== undefined) { if (model) o.model = String(model).trim(); else delete o.model }
678
+ if (variant !== undefined) { if (variant && variant !== "default") o.variant = String(variant).trim(); else if (variant === "default") o.variant = null; else delete o.variant }
679
+ s.override = Object.keys(o).length ? o : null
680
+ const t = this.turnSettings(s)
681
+ this.store.note(`@${s.handle} now runs on ${t.model ?? "opencode's default model"}${t.variant ? ` · ${t.variant}` : ""}${s.status === "busy" ? " from its next turn" : ""}`, s.id, "info")
682
+ this.dirty.add(s.id)
683
+ await this.store.save().catch(() => {})
684
+ if (s.project && s.project !== "default") await this.saveManifest(s.project).catch(() => {})
685
+ this.onChange()
686
+ return s.snapshot(this.pricing)
687
+ }
688
+
689
+ /** The routing file plus how much of each tier's quota is used right now. */
690
+ async routingView(wsId, projectId = null) {
691
+ const doc = (await this.workspaces.routing(wsId)) ?? { enabled: false, tiers: {} }
692
+ const usage = {}
693
+ for (const [level, t] of Object.entries(doc.tiers ?? {})) {
694
+ if (!t?.model || !t.quota) continue
695
+ const per = t.quota.per ?? "day"
696
+ const spent = this.ledger.spent(wsId, t.model, per, projectId)
697
+ usage[level] = { per, spent, over: overQuota(t.quota, spent) }
698
+ }
699
+ // What each agent actually gets per size: its own rule, else the workspace's.
700
+ const agents = []
701
+ const lib = await this.workspaces.library(wsId).catch(() => null)
702
+ for (const [id, a] of lib?.personas ?? []) {
703
+ const row = { id, title: a.title ?? a.name ?? id, model: a.model ?? null, levels: {} }
704
+ for (const level of ["easy", "normal", "hard"]) {
705
+ const t = routeFor(level, { agentTiers: a.tiers ?? null, routing: doc })
706
+ if (!t) { row.levels[level] = { source: "agent-default", model: a.model ?? null }; continue }
707
+ let quota = null
708
+ if (t.quota) {
709
+ const key = t.source === "agent" ? `${t.model}@${id}` : t.model
710
+ const spent = this.ledger.spent(wsId, key, t.quota.per, projectId)
711
+ quota = { ...t.quota, spent, over: overQuota(t.quota, spent), scope: t.source === "agent" ? "this agent" : "everyone" }
712
+ }
713
+ row.levels[level] = { source: t.source, model: t.model ?? a.model ?? null, variant: t.variant ?? null, quota, fallback: t.fallback ?? null }
714
+ }
715
+ agents.push(row)
716
+ }
717
+ return { ...doc, usage, agents }
718
+ }
719
+
720
+ /** Models on offer in a workspace, for the routing page and the agent editor. */
721
+ async workspaceModels(wsId) {
722
+ const ws = this.workspaces.get(wsId)
723
+ if (!ws) throw Object.assign(new Error("unknown workspace"), { status: 404 })
724
+ const offered = await this.client.providers(ws.path).catch(() => [])
725
+ const setup = await opencodeSetup(ws.path).catch(() => null)
726
+ const named = [setup?.model, ...Object.values(setup?.agent ?? {}).map((a) => a.model)].filter(Boolean)
727
+ const out = [...offered]
728
+ for (const m of named) if (!out.some((x) => x.id === m)) out.push({ id: m, name: m.split("/").pop(), provider: m.split("/")[0], variants: [], fromConfig: true })
729
+ const rank = (m) => (named.includes(m.id) ? 0 : 1)
730
+ return { default: setup?.model ?? null, models: out.sort((a, b) => rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider)) || String(a.name).localeCompare(String(b.name))).slice(0, 400) }
731
+ }
732
+
733
+ /** Models a bot could be switched to: what opencode offers there, plus the workspace config's. */
734
+ async modelsFor(id) {
735
+ const s = this.store.get(id)
736
+ if (!s) throw Object.assign(new Error("unknown session"), { status: 404 })
737
+ const offered = await this.client.providers(s.directory).catch(() => [])
738
+ const ws = this.workspaces?.forPath(s.repo ?? s.directory)
739
+ const setup = ws ? await opencodeSetup(ws.path).catch(() => null) : null
740
+ const named = [setup?.model, ...Object.values(setup?.agent ?? {}).map((a) => a.model), s.configuredModel, s.override?.model].filter(Boolean)
741
+ const out = [...offered]
742
+ for (const m of named) if (!out.some((x) => x.id === m)) out.push({ id: m, name: m.split("/").pop(), provider: m.split("/")[0], variants: [], fromConfig: true })
743
+ // The workspace's own models first, then the rest by provider.
744
+ const rank = (m) => (named.includes(m.id) ? 0 : 1)
745
+ return {
746
+ current: this.turnSettings(s),
747
+ override: s.override ?? null,
748
+ fromAgent: { model: s.configuredModel ?? null, variant: s.variantWanted ?? null },
749
+ models: out.sort((a, b) => rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider)) || String(a.name).localeCompare(String(b.name))).slice(0, 400),
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Bring a workstream's worktree up to date with its workspace: MCP servers
755
+ * (added, removed, re-pointed), the workspace's agents and settings, and
756
+ * each bot's BotFarm agent from the current botfarm-agents.yml. Then have
757
+ * opencode re-open the worktree, which reconnects every MCP server and
758
+ * fetches fresh tool lists — a tool deleted from a server stops being
759
+ * offered, a new one appears. Bots mid-turn are not cut off: the reload
760
+ * waits until the worktree is idle.
761
+ */
762
+ async syncWorkstreamConfig(projectId, { reason = "refresh" } = {}) {
763
+ const project = this.projects.get(projectId)
764
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
765
+ const sessions = this.projects.sessions(projectId)
766
+ const run = project.runId ? this.pipelines.run(project.runId) : null
767
+ const dir = project.worktree ?? sessions[0]?.directory
768
+ const repo = run?.repo ?? project.repo
769
+ if (!dir) return { reloaded: false, reason: "no directory" }
770
+ if (repo && dir !== repo) {
771
+ const setup = await opencodeSetup(repo).catch(() => null)
772
+ const lib = await this.pipelines.lib(run?.workspaceId ?? project.workspaceId).catch(() => null)
773
+ const agents = {}
774
+ for (const s of sessions) {
775
+ if (!s.persona) continue
776
+ const p = lib?.personas?.get(s.persona) ?? run?.personaDefs?.[s.persona]
777
+ if (!p) continue
778
+ agents[`botfarm-${s.persona}`] = botAgentConfig(setup, p)
779
+ const r = resolveBot(setup ?? {}, p)
780
+ s.configuredModel = r.model ?? s.configuredModel
781
+ s.variantWanted = p.variant ?? null
782
+ s.allowedTools = p.tools?.length ? p.tools : null
783
+ }
784
+ await writeProjectConfig(dir, null, agents, scopeLocalMcps(setup?.mcp ?? {}, dir), { source: repo })
785
+ }
786
+ return this.reloadDir(dir, reason)
787
+ }
788
+
789
+ /** Have opencode re-open a directory now, or as soon as nobody there is mid-turn. */
790
+ async reloadDir(dir, reason = "refresh") {
791
+ this.pendingReload ??= new Map()
792
+ const busy = this.store.list().some((x) => x.directory === dir && (x.status === "busy" || x.status === "waiting"))
793
+ if (busy) {
794
+ if (!this.pendingReload.has(dir)) this.store.note(`${reason}: ${dir} will reload its config and MCP tools once its bots are idle`, null, "info")
795
+ this.pendingReload.set(dir, reason)
796
+ this.onChange()
797
+ return { reloaded: false, pending: true }
798
+ }
799
+ this.pendingReload.delete(dir)
800
+ await this.client.disposeInstance(dir).catch(() => {})
801
+ await this.client.locationConfig(dir).catch(() => {})
802
+ this.toolIdCache?.delete(dir)
803
+ await this.refreshMcp(this.store.list().filter((x) => x.directory === dir), { force: true }).catch(() => {})
804
+ this.store.note(`${reason}: reloaded config and MCP tools for ${dir}`, null, "info")
805
+ this.onChange()
806
+ return { reloaded: true }
807
+ }
808
+
809
+ /**
810
+ * Notice the workspace's opencode config or botfarm-agents.yml changing and
811
+ * push the change into every open workstream, instead of each worktree
812
+ * keeping the copy it was made with.
813
+ */
814
+ async watchWorkspaceConfig() {
815
+ this.configSigs ??= new Map()
816
+ for (const ws of this.workspaces?.list?.() ?? []) {
817
+ const files = ["opencode.json", "opencode.jsonc", ".opencode/opencode.json", ".opencode/opencode.jsonc", "botfarm-agents.yml"].map((f) => join(ws.path, f))
818
+ const sig = (await Promise.all(files.map((f) => stat(f).then((st) => `${st.mtimeMs}:${st.size}`, () => "-")))).join("|")
819
+ const before = this.configSigs.get(ws.id)
820
+ this.configSigs.set(ws.id, sig)
821
+ if (!before || before === sig) continue
822
+ for (const p of this.projects.list()) {
823
+ if (p.id === "default" || p.status === "archived" || (p.workspaceId ?? this.workspaces.forPath(p.repo)?.id) !== ws.id) continue
824
+ if (!this.projects.sessions(p.id).length) continue
825
+ await this.syncWorkstreamConfig(p.id, { reason: "workspace config changed" }).catch((e) => this.store.note(`config sync for ${p.name}: ${e.message}`, null, "warn"))
826
+ }
827
+ }
828
+ for (const [dir, reason] of this.pendingReload ?? []) await this.reloadDir(dir, reason).catch(() => {})
829
+ }
830
+
831
+ /**
832
+ * What deleting a workstream would take with it, so the dashboard can say so
833
+ * before anything is gone: uncommitted files in the worktree, and commits on
834
+ * its branch that exist nowhere else.
835
+ */
836
+ async deletePreview(projectId) {
837
+ const project = this.projects.get(projectId)
838
+ if (!project || projectId === "default") return null
839
+ const worktree = project.worktree && project.worktree !== project.repo ? project.worktree : null
840
+ const exists = worktree ? await stat(worktree).then(() => true, () => false) : false
841
+ const changed = exists ? await git.changedFiles(worktree).catch(() => []) : []
842
+ const repos = await this.workstreamRepos(project)
843
+ let commits = 0
844
+ let hasBranch = false
845
+ for (const r of repos) {
846
+ const n = project.branch ? await git.unmergedCommits(r, project.branch) : null
847
+ if (n !== null) { hasBranch = true; commits += n }
848
+ }
849
+ return {
850
+ id: project.id,
851
+ name: project.name,
852
+ worktree: exists ? worktree : null,
853
+ changed: changed.length,
854
+ files: changed.slice(0, 12).map((f) => f.path),
855
+ branch: hasBranch ? project.branch : null,
856
+ // Only a branch BotFarm named itself can go: one you chose was yours first.
857
+ branchOwned: hasBranch && String(project.branch).startsWith("botfarm/"),
858
+ commits,
859
+ bots: this.projects.sessions(projectId).length,
860
+ cards: this.tasks.all({ projectId }).length,
861
+ messages: this.projects.room(projectId, { create: false })?.messages?.length ?? 0,
862
+ }
863
+ }
864
+
865
+ /** The workstream's repository plus the service repos that got a worktree. */
866
+ async workstreamRepos(project) {
867
+ const root = project.repo ? await git.repoRoot(project.repo).catch(() => null) : null
868
+ if (!root) return []
869
+ return [root, ...git.serviceSources(root, project.services ?? []).map((s) => s.source)]
870
+ }
871
+
872
+ /**
873
+ * Put another repository in a workstream that is already running: its own
874
+ * worktree on the workstream's branch, inside the workstream's worktree
875
+ * (a repo from outside the workspace is mounted under its folder name).
876
+ * The bots are told in the chat and in the team notes.
877
+ */
878
+ async addRepoToWorkstream(projectId, entry) {
879
+ const project = this.projects.get(projectId)
880
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
881
+ if (!project.worktree || !project.branch) throw new Error("this workstream has no worktree to add a repo to")
882
+ const root = await git.repoRoot(project.repo).catch(() => null)
883
+ const have = git.serviceSources(root, project.services ?? [])
884
+ const svc = await git.checkService(root, entry, { taken: have.map((h) => h.rel) })
885
+ if (have.some((h) => h.source === svc.source)) throw new Error(`${svc.source} is already in this workstream (at ${have.find((h) => h.source === svc.source).rel}/)`)
886
+ const wt = await git.addServiceWorktree(project.worktree, svc, project.branch)
887
+ if (wt.error && !wt.branch) throw new Error(`could not make a worktree for ${svc.rel}: ${wt.error}`)
888
+ await git.carryConfig(svc.source, wt.path).catch(() => {})
889
+ const fresh = this.projects.get(projectId)
890
+ fresh.services = [...(fresh.services ?? []), svc.external ? (svc.rel === basename(svc.source) ? svc.source : { path: svc.source, as: svc.rel }) : svc.rel]
891
+ this.projects.save(fresh)
892
+ await this.saveManifest(projectId).catch(() => {})
893
+ const line = `📁 ${svc.rel}/ is now in this worktree — a worktree of ${svc.source} on ${project.branch}${wt.error ? ` (note: ${wt.error})` : ""}.`
894
+ const room = this.projects.room(projectId, { create: false })
895
+ if (room) await this.rooms.post(room, null, line, { quiet: true }).catch(() => {})
896
+ const run = fresh.runId ? this.pipelines.run(fresh.runId) : null
897
+ if (run) this.pipelines.addNote(run.id, { topic: "Repos in this worktree", text: this.reposText(fresh), files: [], by: "botfarm" })
898
+ this.store.note(line)
899
+ this.onChange()
900
+ return { rel: svc.rel, path: wt.path, source: svc.source, branch: wt.branch ?? project.branch, warning: wt.error ?? null }
901
+ }
902
+
903
+ // --- the work as patches, applied to the real repos -----------------------
904
+
905
+ patchesDir(project) {
906
+ const ws = project.workspaceId ? this.workspaces.get(project.workspaceId) : this.workspaces.forPath(project.repo)
907
+ return ws ? join(ws.path, ".botfarm", "workstreams", `${project.id}.patches`) : join(this.home, "patches", project.id)
908
+ }
909
+
910
+ /** Each repo's patch and whether it would go in cleanly; writes the .patch files. */
911
+ async previewPatches(projectId) {
912
+ const project = this.projects.get(projectId)
913
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
914
+ if (!project.worktree) throw new Error("this workstream has no worktree")
915
+ const list = await patches.buildPatches(project, this.patchesDir(project))
916
+ const out = []
917
+ for (const p of list) out.push({ ...view(p), check: p.error ? null : await patches.checkApply(p) })
918
+ const busy = this.projects.sessions(projectId).filter((x) => x.status === "busy").map((x) => x.handle)
919
+ return { project: projectId, dir: this.patchesDir(project), busy, repos: out, applied: project.applied ?? null }
920
+ function view(p) {
921
+ const { patch, tree, ...rest } = p
922
+ return { ...rest, bytes: patch?.length ?? 0 }
923
+ }
924
+ }
925
+
926
+ /** Apply the chosen repos' patches (all with changes, by default). Never commits. */
927
+ async applyPatches(projectId, { repos = null } = {}) {
928
+ const project = this.projects.get(projectId)
929
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
930
+ const list = await patches.buildPatches(project, this.patchesDir(project))
931
+ const results = []
932
+ for (const p of list) {
933
+ if (repos && !repos.includes(p.rel)) continue
934
+ if (p.error) { results.push({ rel: p.rel, how: "failed", note: p.error }); continue }
935
+ if (!p.file) { results.push({ rel: p.rel, how: "nothing" }); continue }
936
+ results.push({ ...(await patches.applyPatch(p)), real: p.real, file: p.file, branch: p.realBranch, count: p.files.length })
937
+ }
938
+ const fresh = this.projects.get(projectId)
939
+ fresh.applied = { at: Date.now(), results }
940
+ this.projects.save(fresh)
941
+ const done = results.filter((r) => r.how !== "nothing")
942
+ const say = done.map((r) => `${r.rel || basename(project.repo)}: ${r.how === "clean" ? `${r.count} file(s) applied` : r.how === "conflicts" ? `applied with conflicts in ${r.conflicts.join(", ")}` : r.how === "3way" ? "applied (3-way, staged)" : r.how === "partial" ? "partly applied — see .rej files" : `not applied (${String(r.note ?? "").split("\n")[0]})`}`)
943
+ const room = this.projects.room(projectId, { create: false })
944
+ if (room && done.length) await this.rooms.post(room, null, `📦 Applied the work to your repos — ${say.join("; ")}. Nothing is committed.`, { quiet: true }).catch(() => {})
945
+ this.store.note(`${project.name}: applied patches — ${say.join("; ") || "nothing to apply"}`)
946
+ this.onChange()
947
+ return fresh.applied
948
+ }
949
+
950
+ /** Take back the patches that went in cleanly. */
951
+ async undoPatches(projectId) {
952
+ const project = this.projects.get(projectId)
953
+ if (!project?.applied) throw new Error("nothing has been applied from this workstream")
954
+ const repos = patches.workstreamRepos(project)
955
+ const out = []
956
+ for (const r of project.applied.results.filter((x) => x.how === "clean" && x.file)) {
957
+ const repo = repos.find((x) => x.rel === r.rel)
958
+ out.push(await patches.undoPatch({ rel: r.rel, real: repo?.real ?? r.real, file: r.file }))
959
+ }
960
+ const fresh = this.projects.get(projectId)
961
+ fresh.applied = { ...fresh.applied, undone: { at: Date.now(), results: out } }
962
+ this.projects.save(fresh)
963
+ this.onChange()
964
+ return out
965
+ }
966
+
967
+ /** "./ is X; ./spt is a worktree of ~/workspace/spt" — for prompts and notes. */
968
+ reposText(project) {
969
+ const root = project.repo ?? null
970
+ const extra = git.serviceSources(root, project.services ?? [])
971
+ if (!extra.length) return ""
972
+ return [`./ — ${basename(root ?? project.repo ?? "")}`, ...extra.map((s) => `./${s.rel}/ — ${s.external ? `a separate repo (${s.source})` : "nested repo"}, on ${project.branch}`)].join("\n")
973
+ }
974
+
975
+ /**
976
+ * Delete a workstream for good: its bots and their opencode sessions (old
977
+ * replaced ones too), its worktree, its cards, chat, run and manifest, and —
978
+ * if asked, and only for a botfarm/ branch — its branch. Uncommitted work
979
+ * in the worktree stops it unless `force` says you have seen that.
980
+ */
981
+ async deleteWorkstream(projectId, { deleteBranch = false, force = false } = {}) {
982
+ const project = this.projects.get(projectId)
983
+ if (!project) throw Object.assign(new Error("unknown workstream"), { status: 404 })
984
+ if (projectId === "default") throw new Error("the default workstream cannot be deleted")
985
+ const preview = await this.deletePreview(projectId)
986
+ if (preview.changed && !force) {
987
+ throw Object.assign(new Error(`${preview.changed} uncommitted file${preview.changed > 1 ? "s" : ""} in ${preview.worktree} would be lost`), { status: 409, code: "dirty" })
988
+ }
989
+ const ws = project.workspaceId ? this.workspaces.get(project.workspaceId) : this.workspaces?.forPath(project.repo)
990
+ const manifest = ws ? await readManifest(ws, projectId) : null
991
+
992
+ // Bots first, so nothing is mid-turn in a worktree that is about to vanish.
993
+ const sessions = this.projects.sessions(projectId)
994
+ for (const s of sessions) {
995
+ clearTimeout(this.pipelines.idleTimers?.get(s.id))
996
+ if (s.status === "busy" || s.status === "waiting") await this.abort(s.id).catch(() => {})
997
+ }
998
+ const ids = new Set([...sessions.map((s) => s.id), ...(manifest?.bots ?? []).map((b) => b?.session), ...(manifest?.previous ?? []).map((b) => b?.session)].filter(Boolean))
999
+ for (const [id, e] of this.registry.entries) if ((e.project ?? e.group) === projectId) ids.add(id)
1000
+ for (const id of ids) {
1001
+ await this.client.deleteSession(id).catch(() => {})
1002
+ this.store.sessions.delete(id)
1003
+ this.registry.release(id)
1004
+ }
1005
+ await this.registry.save().catch(() => {})
1006
+
1007
+ if (preview.worktree) {
1008
+ await git.removeWorktreeSet(preview.worktree, { force: true }).catch((e) => this.store.note(`worktree removal failed: ${e.message}`, null, "warn"))
1009
+ }
1010
+ let branchGone = false
1011
+ if (deleteBranch && preview.branchOwned) {
1012
+ for (const r of await this.workstreamRepos(project)) {
1013
+ if (await git.unmergedCommits(r, project.branch) === null) continue
1014
+ await git.deleteBranch(r, project.branch).then(() => (branchGone = true), (e) => this.store.note(`could not delete ${project.branch} in ${r}: ${e.message}`, null, "warn"))
1015
+ }
1016
+ }
1017
+
1018
+ for (const t of this.tasks.all({ projectId })) this.tasks.remove(t.id)
1019
+ for (const run of this.pipelines.runs().filter((r) => r.projectId === projectId || r.id === project.runId)) this.db.delete("runs", run.id)
1020
+ const room = project.roomId ? this.rooms.rooms.get(project.roomId) : null
1021
+ if (room) this.rooms.rooms.delete(room.id)
1022
+ if (ws) await unlink(manifestPath(ws, projectId)).catch(() => {})
1023
+ this.db.delete("projects", projectId)
1024
+
1025
+ this.store.note(`deleted workstream ${project.name}${branchGone ? ` and branch ${project.branch}` : project.branch ? ` (branch ${project.branch} kept)` : ""}`, null, "warn")
1026
+ this.onChange()
1027
+ return { ok: true, sessions: ids.size, worktree: !!preview.worktree, branch: branchGone ? project.branch : null }
1028
+ }
1029
+
1030
+ /**
1031
+ * Write the workstream's manifest: who is on the team, by opencode session
1032
+ * id, plus everyone they replaced. Called whenever the team changes.
1033
+ */
1034
+ async saveManifest(projectId) {
1035
+ const project = this.projects.get(projectId)
1036
+ const ws = project?.workspaceId ? this.workspaces.get(project.workspaceId) : this.workspaces?.forPath(project?.repo)
1037
+ if (!project || !ws) return null
1038
+ const before = (await readManifest(ws, projectId)) ?? {}
1039
+ const bot = (s) => ({
1040
+ persona: s.persona ?? null,
1041
+ title: s.personaTitle ?? null,
1042
+ role: s.role ?? null,
1043
+ handle: s.handle ?? null,
1044
+ session: s.id,
1045
+ model: s.override?.model ?? s.model ?? s.configuredModel ?? null,
1046
+ ...(s.override ? { override: s.override } : {}),
1047
+ })
1048
+ const team = this.projects.sessions(projectId).map(bot)
1049
+ // A bot that is still registered to this workstream but not loaded right
1050
+ // now (opencode down, not listed yet) stays on the team: only a bot that
1051
+ // was released or moved counts as replaced.
1052
+ for (const b of before.bots ?? []) {
1053
+ if (!b?.session || team.some((t) => t.session === b.session)) continue
1054
+ const entry = this.registry.get(b.session)
1055
+ if (entry && (entry.project ?? entry.group) === projectId) team.push(b)
1056
+ }
1057
+ const current = new Set(team.map((b) => b.session))
1058
+ const previous = [
1059
+ ...(before.previous ?? []),
1060
+ ...(before.bots ?? []).filter((b) => b?.session && !current.has(b.session)).map((b) => ({ ...b, replaced: new Date().toISOString() })),
1061
+ ].filter((b, i, all) => b?.session && !current.has(b.session) && all.findIndex((x) => x.session === b.session) === i)
1062
+ const run = project.runId ? this.pipelines.run(project.runId) : null
1063
+ const doc = {
1064
+ id: project.id,
1065
+ name: project.name,
1066
+ workspace: ws.id,
1067
+ pipeline: project.pipeline ?? null,
1068
+ pipelineTitle: project.pipelineTitle ?? null,
1069
+ run: project.runId ?? null,
1070
+ status: project.status ?? null,
1071
+ branch: project.branch ?? null,
1072
+ worktree: project.worktree ?? null,
1073
+ created: new Date(project.created ?? Date.now()).toISOString(),
1074
+ updated: new Date().toISOString(),
1075
+ bots: team,
1076
+ previous,
1077
+ stages: run ? Object.fromEntries(Object.entries(run.taskIds).map(([st, id]) => [st, this.tasks.get(id)?.status ?? "gone"])) : undefined,
1078
+ story: project.story ?? undefined,
1079
+ }
1080
+ if (!doc.stages) delete doc.stages
1081
+ if (!doc.story) delete doc.story
1082
+ return writeManifest(ws, doc).catch((e) => this.store.note(`could not write the manifest for ${project.name}: ${e.message}`, null, "warn"))
1083
+ }
1084
+
1085
+ /**
1086
+ * Bring workstreams back from their manifests: any bot session the
1087
+ * registry has forgotten is adopted again into its workstream, and a
1088
+ * workstream botfarm has no record of is recreated (its board and chat history
1089
+ * are gone, but the bots and their worktree are back).
1090
+ */
1091
+ async recoverFromManifests() {
1092
+ let adopted = 0
1093
+ let recreated = 0
1094
+ this.manifestBots = new Map()
1095
+ for (const ws of this.workspaces.list()) {
1096
+ for (const m of await readManifests(ws)) {
1097
+ if (!this.projects.get(m.id)) {
1098
+ this.db.put("projects", {
1099
+ id: m.id, name: m.name ?? m.id, repo: ws.path, branch: m.branch ?? null, worktree: m.worktree ?? null,
1100
+ services: [], workspaceId: ws.id, pipeline: m.pipeline ?? null, pipelineTitle: m.pipelineTitle ?? null,
1101
+ runId: m.run ?? null, story: m.story ?? null, status: m.status ?? "open", roomId: null,
1102
+ created: Date.parse(m.created) || Date.now(), recovered: true,
1103
+ })
1104
+ recreated++
1105
+ }
1106
+ for (const b of m.bots ?? []) {
1107
+ if (!b?.session) continue
1108
+ this.manifestBots.set(b.session, { ...b, project: m.id })
1109
+ if (!this.registry.has(b.session)) {
1110
+ this.registry.adopt(b.session, { createdByBotfarm: true, project: m.id, label: b.persona ?? null })
1111
+ adopted++
1112
+ }
1113
+ }
1114
+ }
1115
+ }
1116
+ if (adopted) await this.registry.save()
1117
+ if (adopted || recreated) this.store.note(`recovered from manifests: ${adopted} bot(s)${recreated ? `, ${recreated} workstream(s)` : ""}`)
1118
+ return { adopted, recreated }
1119
+ }
1120
+
1121
+ /** Once sessions are loaded, fill in what a manifest knows and the store lost. */
1122
+ applyManifestBots() {
1123
+ for (const [id, b] of this.manifestBots ?? []) {
1124
+ const s = this.store.get(id)
1125
+ if (!s) continue
1126
+ s.persona ??= b.persona
1127
+ s.personaTitle ??= b.title
1128
+ s.role ??= b.role ?? b.persona
1129
+ if (s.project !== b.project && (s.project ?? "default") === "default") {
1130
+ s.project = b.project
1131
+ this.projects.attach(id, b.project)
1132
+ }
1133
+ }
1134
+ this.manifestBots = null
1135
+ }
1136
+
1137
+ /**
1138
+ * opencode logs a turn that dies before it starts ("Failed to drain
1139
+ * Session …") but does not always send an event for it, so the bot just
1140
+ * sits there. Follow the log and put those errors on the bot.
1141
+ */
1142
+ async watchOpencodeLog() {
1143
+ const file = this.opencodeLog ?? join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "log", "opencode.log")
1144
+ let size
1145
+ try { size = (await stat(file)).size } catch { return }
1146
+ if (this.logOffset == null || size < this.logOffset) { this.logOffset = size; return } // start at the end; follow a rotation
1147
+ if (size === this.logOffset) return
1148
+ const fh = await open(file, "r")
1149
+ try {
1150
+ const len = Math.min(size - this.logOffset, 2_000_000)
1151
+ const buf = Buffer.alloc(len)
1152
+ await fh.read(buf, 0, len, this.logOffset)
1153
+ this.logOffset += len
1154
+ for (const line of buf.toString("utf8").split("\n")) {
1155
+ if (!line.includes("level=ERROR")) continue
1156
+ const id = /sessionID=(ses_[A-Za-z0-9]+)/.exec(line)?.[1]
1157
+ const s = id && this.store.get(id)
1158
+ if (!s) continue
1159
+ const cause = /cause="([^\n]*?)(?:\\n|"\s|$)/.exec(line)?.[1] ?? /message="([^"]+)"/.exec(line)?.[1] ?? "error"
1160
+ s.lastError = errorText(cause.replace(/\\"/g, '"'))
1161
+ s.setStatus("error")
1162
+ this.store.note(`@${s.handle} could not run: ${s.lastError}`, s.id, "error")
1163
+ }
1164
+ } finally {
1165
+ await fh.close()
1166
+ }
1167
+ this.onChange()
1168
+ }
1169
+
1170
+ /**
1171
+ * Bots started before botfarm passed model/agent/MCP explicitly: look their
1172
+ * workspace's opencode.json up now so their next turn runs on it.
1173
+ */
1174
+ async backfillBotSettings() {
1175
+ const dirs = new Map()
1176
+ for (const s of this.store.list()) {
1177
+ if (!s.persona || s.configuredModel) continue
1178
+ const ws = this.workspaces.forPath(s.repo ?? s.directory)
1179
+ if (!ws) continue
1180
+ const setup = await opencodeSetup(ws.path).catch(() => null)
1181
+ if (!setup?.found) continue
1182
+ const r = resolveBot(setup, {})
1183
+ s.agentName = r.agent
1184
+ s.configuredModel = r.model
1185
+ s.variantWanted = r.variant
1186
+ if (s.directory) dirs.set(s.directory, setup)
1187
+ }
1188
+ for (const [dir, setup] of dirs) {
1189
+ for (const [name, cfg] of Object.entries(setup.mcp)) if (cfg.enabled !== false) await this.client.addMcpServer(name, cfg, dir).catch(() => {})
1190
+ }
1191
+ if (dirs.size) this.store.note(`applied the workspace's opencode model, agent and MCP servers to bots in ${dirs.size} worktree(s)`)
1192
+ }
1193
+
1194
+ /** Set a price for a model the table does not know, and keep it in config.json. */
1195
+ async setPrice(model, { input, output }) {
1196
+ const key = normalise(model)
1197
+ if (!key) throw new Error("which model?")
1198
+ if (!(Number(input) >= 0 && Number(output) >= 0)) throw new Error("prices are USD per million tokens, input and output")
1199
+ this.pricing[key] = { input: Number(input), output: Number(output) }
1200
+ const file = join(this.home, "config.json")
1201
+ let config = {}
1202
+ try { config = JSON.parse(await readFile(file, "utf8")) } catch {}
1203
+ config.pricing = { ...(config.pricing ?? {}), [key]: this.pricing[key] }
1204
+ await writeFile(file, JSON.stringify(config, null, 2) + "\n")
1205
+ this.onChange()
1206
+ return { model: key, ...this.pricing[key] }
1207
+ }
1208
+
1209
+ pricingView() {
1210
+ const t = this.store.totals()
1211
+ return { table: TABLE, overrides: this.pricing, unknown: t.unknownModels }
1212
+ }
1213
+
1214
+ /**
1215
+ * Which MCP servers each session's location has, and whether ours is one
1216
+ * of them — asked once per directory, since sessions in a worktree share it.
1217
+ */
1218
+ async refreshMcp(sessions = this.store.list(), { reconnect = true, force = false } = {}) {
1219
+ const byDir = new Map()
1220
+ for (const s of sessions) if (s.directory) byDir.set(s.directory, [...(byDir.get(s.directory) ?? []), s])
1221
+ this.mcpRetry ??= new Map()
1222
+ for (const [dir, list] of byDir) {
1223
+ let servers = await this.client.mcpServers(dir).catch(() => null)
1224
+ if (!servers) continue
1225
+ // opencode tries a remote MCP server once, when it opens a directory. If
1226
+ // the server was down then (docker still starting, a restart), it stays
1227
+ // "failed" for that directory until something asks again — so ask.
1228
+ const failed = reconnect ? servers.filter((m) => RETRYABLE_MCP.has(m.status) && !BOTFARM_MCP_NAMES.has(m.name ?? m.id)) : []
1229
+ let retried = false
1230
+ for (const m of failed) {
1231
+ const name = m.name ?? m.id
1232
+ const key = `${dir}\n${name}`
1233
+ const last = this.mcpRetry.get(key) ?? 0
1234
+ if (!force && Date.now() - last < 30_000) continue
1235
+ this.mcpRetry.set(key, Date.now())
1236
+ retried = true
1237
+ const ok = await this.client.setMcpConnected(name, true, dir).then(() => true, () => false)
1238
+ if (ok) this.store.note(`reconnecting MCP ${name} for ${dir}`, null, "info")
1239
+ }
1240
+ if (retried) servers = (await this.client.mcpServers(dir).catch(() => null)) ?? servers
1241
+ const view = servers.map((m) => ({
1242
+ name: m.name ?? m.id,
1243
+ connected: m.status ? m.status === "connected" : !!m.connected,
1244
+ status: m.status ?? (m.connected ? "connected" : "disconnected"),
1245
+ error: m.error ? String(m.error).slice(0, 300) : null,
1246
+ tools: (m.tools ?? []).length,
1247
+ }))
1248
+ for (const v of view) {
1249
+ const key = `${dir}\n${v.name}`
1250
+ if (v.connected && this.mcpRetry.has(key)) {
1251
+ this.mcpRetry.delete(key)
1252
+ this.store.note(`MCP ${v.name} is connected again for ${dir}`, null, "info")
1253
+ }
1254
+ }
1255
+ const mcp = { total: view.length, connected: view.filter((v) => v.connected).length, botfarm: view.some((v) => BOTFARM_MCP_NAMES.has(v.name) && v.connected), servers: view, at: Date.now() }
1256
+ for (const s of list) s.mcp = mcp
1257
+ }
1258
+ this.onChange()
1259
+ }
1260
+
1261
+ async refreshGit() {
1262
+ await Promise.all(
1263
+ this.store.list().map(async (s) => {
1264
+ if (!s.directory) return
1265
+ const st = await git.status(s.directory)
1266
+ if (st) {
1267
+ s.branch = st.branch
1268
+ s.git = st
1269
+ }
1270
+ }),
1271
+ )
1272
+ }
1273
+
1274
+ // --- registry ------------------------------------------------------------
1275
+
1276
+ async adopt(ids, meta = {}) {
1277
+ for (const id of [].concat(ids)) {
1278
+ this.registry.adopt(id, { ...meta, project: meta.project ?? meta.group ?? "default" })
1279
+ this.dirty.add(id)
1280
+ }
1281
+ await this.registry.save()
1282
+ await this.discover()
1283
+ for (const id of [].concat(ids)) this.projects.attach(id, this.registry.get(id)?.project ?? "default")
1284
+ this.store.note(`added ${[].concat(ids).length} session(s) to the board`)
1285
+ return this.store.snapshot()
1286
+ }
1287
+
1288
+ async release(id, { disableMesh = true } = {}) {
1289
+ const s = this.store.get(id)
1290
+ if (s && disableMesh) s.policy.talk = "off"
1291
+ this.registry.release(id)
1292
+ this.store.sessions.delete(id)
1293
+ for (const r of this.rooms.rooms.values()) r.members.delete(id)
1294
+ await this.registry.save()
1295
+ // Re-discover straight away so it reappears in the adoption pool rather
1296
+ // than vanishing until the next poll.
1297
+ await this.discover().catch(() => {})
1298
+ this.store.note("removed a session from the board; the session itself is untouched", null, "info")
1299
+ this.onChange()
1300
+ }
1301
+
1302
+ async setProject(id, project) {
1303
+ this.registry.update(id, { project })
1304
+ this.projects.attach(id, project)
1305
+ await this.registry.save()
1306
+ this.onChange()
1307
+ }
1308
+
1309
+ /** The operator answering a question a bot asked. */
1310
+ async answer(taskId, text, answers = null) {
1311
+ const task = this.tasks.get(taskId)
1312
+ if (!task) throw new Error("unknown task")
1313
+ const questions = task.questions?.length ? task.questions : normalizeQuestions({ question: task.stage ? task.title : task.brief, options: task.options })
1314
+ if (answers) {
1315
+ task.answers = answers
1316
+ text = formatAnswers(questions, answers)
1317
+ }
1318
+ if (!String(text ?? "").trim()) throw new Error("an answer needs something in it")
1319
+ task.answer = String(text ?? "").slice(0, 8000)
1320
+ task.status = "done"
1321
+ this.tasks.save(task)
1322
+ const asker = task.askedBy ? this.store.get(task.askedBy) : null
1323
+ if (asker) {
1324
+ await this.deliver(asker, {
1325
+ text: [
1326
+ `[botfarm] your operator answered: ${task.brief}`,
1327
+ "",
1328
+ task.answer,
1329
+ "",
1330
+ "---",
1331
+ "This came from the human running this session, not from another agent.",
1332
+ ].join("\n"),
1333
+ description: "answer from the operator",
1334
+ })
1335
+ }
1336
+ this.store.note(`answered "${task.title.slice(0, 60)}"`, asker?.id ?? null)
1337
+ // A human stage in a pipeline is a question too: answering it is its
1338
+ // handoff, so whatever waits on it can start.
1339
+ if (task.stage) {
1340
+ task.handoff ??= { summary: task.answer, acceptance_criteria: [], artifacts: [], open_questions: [], at: Date.now(), by: "operator" }
1341
+ this.tasks.save(task)
1342
+ }
1343
+ this.tasks.unblock()
1344
+ await this.pipelines?.dispatch()
1345
+ this.pipelines?.checkRunDone(task.runId)
1346
+ this.onChange()
1347
+ return task
1348
+ }
1349
+
1350
+ // --- tools ---------------------------------------------------------------
1351
+
1352
+ async tools(session) {
1353
+ const [servers, ids] = await Promise.all([
1354
+ this.client.mcpServers(session.directory),
1355
+ this.client.toolIds(session.directory),
1356
+ ])
1357
+ const disabled = session.disabledTools ?? []
1358
+ return {
1359
+ servers: servers.map((m) => ({
1360
+ name: m.name ?? m.id,
1361
+ status: m.status ?? (m.connected ? "connected" : "disconnected"),
1362
+ connected: m.status ? m.status === "connected" : !!m.connected,
1363
+ tools: (m.tools ?? []).map((t) => t.name ?? t),
1364
+ })),
1365
+ builtin: ids.filter((t) => !t.includes("_")).map((t) => ({ name: t, enabled: !disabled.includes(t) })),
1366
+ disabled,
1367
+ }
1368
+ }
1369
+
1370
+ async setMcp(session, name, connected) {
1371
+ await this.client.setMcpConnected(name, connected, session.directory)
1372
+ this.store.note(`${connected ? "connected" : "disconnected"} MCP server "${name}" for ${session.directory}`, session.id)
1373
+ this.onChange()
1374
+ }
1375
+
1376
+ /**
1377
+ * Per-tool switches are config, not a runtime call: opencode reads them when
1378
+ * the location loads, so this takes effect on the session's next start.
1379
+ */
1380
+ async setTool(session, name, enabled) {
1381
+ session.disabledTools ??= []
1382
+ session.disabledTools = enabled
1383
+ ? session.disabledTools.filter((t) => t !== name)
1384
+ : [...new Set([...session.disabledTools, name])]
1385
+ if (session.directory) {
1386
+ await writeProjectTools(session.directory, session.disabledTools).catch(() => {})
1387
+ }
1388
+ this.store.note(`${enabled ? "enabled" : "disabled"} tool "${name}" — applies when that session restarts`, session.id, "warn")
1389
+ this.onChange()
1390
+ return session.disabledTools
1391
+ }
1392
+
1393
+ // --- actions -------------------------------------------------------------
1394
+
1395
+ async abort(id) {
1396
+ await this.client.interrupt(id)
1397
+ const s = this.store.get(id)
1398
+ if (s) { s.setStatus("idle"); s.currentTool = null; s.abortedAt = Date.now() }
1399
+ this.store.note(`aborted ${label(s ?? { id })}`, id, "warn")
1400
+ this.dirty.add(id)
1401
+ this.onChange()
1402
+ }
1403
+
1404
+ async send(id, text) {
1405
+ this.mesh.operatorTouched(id)
1406
+ for (const r of this.rooms.rooms.values()) if (r.members.has(id)) r.burst = 0
1407
+ const s = this.store.get(id)
1408
+ await this.client.prompt(id, text, { tools: await this.toolSwitches(s), ...this.turnSettings(s) })
1409
+ if (s) { s.setStatus("busy"); s.updated = Date.now(); s.invocations = (s.invocations ?? 0) + 1 }
1410
+ this.store.note(`queued "${text.slice(0, 60)}" -> ${label(s ?? { id })}`, id)
1411
+ this.dirty.add(id)
1412
+ this.onChange()
1413
+ }
1414
+
1415
+ async spawn({ repo, branch, worktreePath, task, agent, model, variant = null, team = null, title, label: name, group, services = [], persona = null, personaTitle = null, role = null, adjectives = null, tools = null, parentID = null, depth = 0, origin = null, policy = null }) {
1416
+ // A session can live in any directory opencode can open. Only a worktree
1417
+ // needs a repository, so that is the only case that insists on one.
1418
+ const where = await git.inspect(repo)
1419
+ if (!where.exists) throw new Error(`${where.path}: ${where.reason}`)
1420
+ if (branch && !where.isRepo) throw new Error(`${where.path}: ${where.reason}`)
1421
+ repo = where.root ?? where.path
1422
+ let directory = repo
1423
+ let createdBranch = null
1424
+ if (branch) {
1425
+ const wt = await git.addWorktreeSet(repo, branch, { path: worktreePath, include: services })
1426
+ directory = wt.path
1427
+ createdBranch = wt.branch
1428
+ const failed = wt.children.filter((c) => c.error)
1429
+ this.store.note(
1430
+ `${wt.reused ? "reusing" : "created"} worktree ${wt.path} on ${wt.branch}` +
1431
+ (wt.children.length ? ` plus ${wt.children.length - failed.length} service worktree(s)` : ""),
1432
+ )
1433
+ for (const f of failed) this.store.note(`could not create a worktree for ${f.rel}: ${f.error}`, null, "warn")
1434
+ // Bring the checkout's opencode setup (MCP servers, agents, rules) along.
1435
+ const carried = await git.carryConfig(repo, directory).catch(() => [])
1436
+ for (const c of wt.children.filter((c) => !c.error)) await git.carryConfig(c.source ?? join(repo, c.rel), c.path).catch(() => {})
1437
+ if (carried.length && !wt.reused) this.store.note(`copied ${carried.join(", ")} into the worktree`)
1438
+ }
1439
+ // How this bot runs is decided here, from the workspace's opencode.json,
1440
+ // and written into the worktree's own .opencode/opencode.json as an
1441
+ // opencode agent — "botfarm-<persona>": the base opencode agent (build,
1442
+ // unless the BotFarm agent names another) with this bot's model,
1443
+ // reasoning level and tool limits. That is the only per-session knob a
1444
+ // current opencode server honours; a prompt cannot carry model or tools.
1445
+ const setup = await opencodeSetup(repo).catch(() => null)
1446
+ const base = resolveBot(setup ?? {}, { agent, model, variant })
1447
+ const botAgents = {}
1448
+ for (const [key, p] of Object.entries(team ?? (persona ? { [persona]: { agent, model, variant, tools, title: personaTitle } } : {}))) {
1449
+ botAgents[`botfarm-${key}`] = botAgentConfig(setup, p)
1450
+ }
1451
+ const myAgent = persona ? `botfarm-${persona}` : base.agent
1452
+ const wantsMesh = (policy?.talk ?? "open") !== "off" || policy?.spawn
1453
+ let token = null
1454
+ if (wantsMesh) token = this.store.list().find((x) => x.mcpToken && x.directory === directory)?.mcpToken ?? mintToken()
1455
+ // Local MCP servers (localhost / host.docker.internal) are told which checkout this worktree
1456
+ // is, with an X-Repo-Root header on every connection — so their tools read, edit and build
1457
+ // the worktree, not the main checkout it was branched from.
1458
+ const scoped = directory !== repo ? scopeLocalMcps(setup?.mcp ?? {}, directory) : {}
1459
+ const added = await writeProjectConfig(directory, token ? { type: "remote", url: `${this.mcpBase}/mcp/${token}`, enabled: true } : null, botAgents, scoped, { source: repo }).catch((e) => {
1460
+ this.store.note(`could not write ${directory}/.opencode/opencode.json: ${e.message}`, null, "warn")
1461
+ return []
1462
+ })
1463
+ // opencode reads a directory's config once, when it first opens it. A new
1464
+ // agent entry for a directory it already has open needs it re-read —
1465
+ // safe only while nobody there is mid-turn.
1466
+ this.openedDirs ??= new Set()
1467
+ if (added.length && this.openedDirs.has(directory)) {
1468
+ const busy = this.store.list().some((x) => x.directory === directory && x.status === "busy")
1469
+ if (!busy) await this.client.disposeInstance(directory).catch(() => {})
1470
+ }
1471
+ const cfg = await this.client.locationConfig(directory)
1472
+ this.openedDirs.add(directory)
1473
+ let useAgent = myAgent
1474
+ if (cfg && myAgent && !cfg.agent?.[myAgent]) {
1475
+ this.store.note(`opencode did not load agent ${myAgent} for ${directory}; using ${base.agent ?? "its default"}`, null, "warn")
1476
+ useAgent = base.agent
1477
+ }
1478
+ if (!cfg) this.store.note(`opencode would not show the config it uses for ${directory}`, null, "warn")
1479
+ const want = persona ? botAgents[`botfarm-${persona}`] ?? {} : {}
1480
+ const runModel = want.model ?? base.model
1481
+ const runVariant = want.variant ?? null
1482
+ // The instance engine first — it is what the opencode TUI runs on, and it
1483
+ // can drive every provider the config sets up (the v2 session runner
1484
+ // cannot, e.g. Bedrock: "Unsupported API … aisdk:@ai-sdk/amazon-bedrock").
1485
+ // It also takes agent, model and tool switches on every turn.
1486
+ let info = null
1487
+ let engine = "v2"
1488
+ if (this.client.dialect?.name === "v2") {
1489
+ info = await this.client.createSessionV1({ title: title ?? name ?? branch ?? "botfarm session", directory, agent: useAgent, model: runModel }).catch((e) => {
1490
+ if (!/-> (404|405)\b/.test(e.message)) this.store.note(`instance-engine session failed (${e.message.slice(0, 120)}); trying the v2 API`, null, "warn")
1491
+ return null
1492
+ })
1493
+ if (info?.id) engine = "v1"
1494
+ }
1495
+ if (!info?.id) {
1496
+ info = await this.client.createSession({
1497
+ title: title ?? name ?? branch ?? "botfarm session",
1498
+ directory,
1499
+ agent: useAgent,
1500
+ model: runModel,
1501
+ variant: runVariant,
1502
+ })
1503
+ // Belt and braces: set them again on the session itself.
1504
+ if (useAgent) await this.client.setSessionAgent(info.id, useAgent).catch(() => {})
1505
+ if (runModel) await this.client.setSessionModel(info.id, runModel, runVariant).catch(() => {})
1506
+ }
1507
+ agent = useAgent
1508
+ model = runModel
1509
+ variant = runVariant
1510
+ const mcpNotes = cfg?.mcp ? Object.entries(cfg.mcp).filter(([, m]) => m?.enabled !== false).map(([n]) => n) : []
1511
+ const projectId = group ?? "default"
1512
+ this.registry.adopt(info.id, { createdByBotfarm: true, project: projectId, label: name ?? branch ?? null })
1513
+ await this.registry.save()
1514
+ const s = this.store.upsert({ ...info, directory })
1515
+ s.project = projectId
1516
+ s.label = name ?? branch ?? null
1517
+ s.branch = createdBranch
1518
+ s.repo = repo
1519
+ s.persona = persona
1520
+ s.personaTitle = personaTitle
1521
+ s.configuredModel = model ?? null
1522
+ s.engine = engine
1523
+ s.agentName = agent ?? null
1524
+ s.variantWanted = variant ?? null
1525
+ if (mcpNotes.length) this.store.note(`@${s.handle ?? "bot"} runs as ${agent ?? "default"} on ${model ?? "default model"}, MCP: ${mcpNotes.join(", ")}`, s.id)
1526
+ if (token) s.mcpToken = token
1527
+ s.role = role ?? persona
1528
+ s.adjectives = adjectives
1529
+ if (tools?.length) s.allowedTools = tools
1530
+ s.parentID = parentID
1531
+ s.depth = depth
1532
+ s.origin = origin
1533
+ if (policy) Object.assign(s.policy, policy)
1534
+ this.mesh.handles()
1535
+ this.projects.attach(s.id, projectId)
1536
+ if (s.policy.talk !== "off" || s.policy.spawn) await this.enableMesh(s)
1537
+ this.refreshMcp([s]).catch(() => {})
1538
+ if (task) await this.send(s.id, task)
1539
+ this.store.note(`started ${label(s)} in ${directory}`, s.id)
1540
+ this.onChange()
1541
+ return s.snapshot()
1542
+ }
1543
+
1544
+ async remove(id, { worktree = false, force = false } = {}) {
1545
+ const s = this.store.get(id)
1546
+ await this.client.deleteSession(id).catch(() => {})
1547
+ if (worktree && s?.directory) {
1548
+ const root = await git.commonDir(s.directory)
1549
+ if (root && root !== s.directory) {
1550
+ await git.removeWorktree(root, s.directory, { force }).catch((e) => this.store.note(`worktree removal failed: ${e.message}`, id, "warn"))
1551
+ }
1552
+ }
1553
+ this.store.sessions.delete(id)
1554
+ this.store.note(`removed ${label(s ?? { id })}`, id, "warn")
1555
+ this.onChange()
1556
+ }
1557
+
1558
+ async detail(id) {
1559
+ const s = this.store.get(id)
1560
+ if (!s) return null
1561
+ const [messages, perms, gitStatus, commit] = await Promise.all([
1562
+ this.client.messages(id, { limit: 40, order: "desc" }).catch(() => []),
1563
+ this.client.pendingPermissions(id).catch(() => []),
1564
+ s.directory ? git.status(s.directory) : null,
1565
+ s.directory ? git.lastCommit(s.directory) : null,
1566
+ ])
1567
+ return {
1568
+ ...s.snapshot(),
1569
+ mcpUrl: s.mcpToken ? `${this.mcpBase}/mcp/${s.mcpToken}` : null,
1570
+ tools: await this.tools(s).catch(() => ({ servers: [], builtin: [], disabled: [] })),
1571
+ peers: this.mesh.roster(s),
1572
+ rooms: this.rooms.visibleTo(s).map((r) => ({
1573
+ id: r.id, name: r.name, topic: r.topic, muted: r.muted, mode: r.mode,
1574
+ members: r.members.size, unread: r.unread.get(s.id) ?? 0,
1575
+ })),
1576
+ inbox: s.inbox.slice(-25).reverse(),
1577
+ outbox: s.outbox.slice(-25).reverse(),
1578
+ children: this.store.list().filter((x) => x.parentID === s.id).map((x) => ({ handle: x.handle, id: x.id, status: x.status, title: x.title })),
1579
+ git: gitStatus,
1580
+ lastCommit: commit,
1581
+ permissions: perms,
1582
+ toolLog: s.toolLog.slice(-40).reverse(),
1583
+ transcript: messages.slice(0, 40).map(summarizeMessage).filter(Boolean),
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ /** The useful part of an opencode error: its message, not just its class name. */
1589
+ export function errorText(e) {
1590
+ if (!e) return null
1591
+ if (typeof e === "string") return e.slice(0, 300)
1592
+ const msg = e.data?.message ?? e.message ?? e.data?.error?.message
1593
+ let text = msg ?? e.name ?? "error"
1594
+ // Provider errors embed a JSON body; pull its message out.
1595
+ const inner = /"message"\s*:\s*"([^"]+)"/.exec(text)
1596
+ if (inner) text = `${e.name ?? "error"}: ${inner[1]}`
1597
+ return String(text).slice(0, 300)
1598
+ }
1599
+
1600
+ function label(s) {
1601
+ return s?.label ?? s?.title ?? s?.id ?? "session"
1602
+ }
1603
+
1604
+ export function summarizeMessage(raw) {
1605
+ const info = raw.info ?? raw
1606
+ const all = partsOf(raw)
1607
+ const role = info.role ?? info.type
1608
+ const parts = all.map((p, i) => partView(p, info.id, i)).filter(Boolean)
1609
+ const text = parts.filter((p) => p.kind === "text").map((p) => p.text).join("\n")
1610
+ const tools = parts.filter((p) => p.kind === "tool").map((p) => ({ name: p.name, status: p.status }))
1611
+ if (!parts.length) return null
1612
+ return {
1613
+ id: info.id,
1614
+ role,
1615
+ at: info.time?.created ?? info.created ?? null,
1616
+ done: !!(info.time?.completed ?? info.finish),
1617
+ text: text.slice(0, 1200),
1618
+ tools,
1619
+ parts,
1620
+ }
1621
+ }
1622
+
1623
+ /** One part, trimmed to what a transcript view needs. */
1624
+ function partView(p, messageId, i = 0) {
1625
+ if (!p || typeof p !== "object") return null
1626
+ const id = p.id ?? `${messageId}:${i}`
1627
+ const tool = toolOf(p)
1628
+ if (tool) {
1629
+ const out = typeof tool.output === "string" ? tool.output : tool.output ? JSON.stringify(tool.output) : ""
1630
+ return {
1631
+ id, kind: "tool", name: tool.name, status: tool.status,
1632
+ summary: tool.title ?? summarizeToolInput(tool.input),
1633
+ output: (tool.error ? String(tool.error) : out).slice(0, 600),
1634
+ ms: tool.start && tool.end ? tool.end - tool.start : null,
1635
+ }
1636
+ }
1637
+ if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string" && p.text.trim()) {
1638
+ if (p.synthetic && p.type === "text" && p.ignored) return null
1639
+ return { id, kind: p.type, text: p.text.slice(-6000) }
1640
+ }
1641
+ return null
1642
+ }
1643
+
1644
+ /**
1645
+ * Pull a transcript-shaped update out of an event, if it carries one.
1646
+ * v1 sends message.part.updated with the whole part (and sometimes a
1647
+ * `delta`); newer servers send just { partID, delta }. Anything else is
1648
+ * left to the message diff.
1649
+ */
1650
+ export function livePart(payload) {
1651
+ const p = payload?.properties ?? payload ?? {}
1652
+ const part = p.part ?? (p.item?.type ? p.item : null)
1653
+ const messageId = part?.messageID ?? part?.messageId ?? p.messageID ?? p.messageId ?? null
1654
+ if (part && typeof part === "object") {
1655
+ const view = partView(part, messageId)
1656
+ if (!view) return null
1657
+ const out = { messageId, part: view }
1658
+ if (typeof p.delta === "string") out.delta = p.delta
1659
+ if (view.kind === "tool") out.tool = { name: view.name, status: view.status }
1660
+ return out
1661
+ }
1662
+ const partId = p.partID ?? p.partId
1663
+ if (partId && typeof p.delta === "string") {
1664
+ return { messageId, part: { id: partId, kind: p.field === "reasoning" ? "reasoning" : "text" }, delta: p.delta }
1665
+ }
1666
+ return null
1667
+ }
1668
+
1669
+
1670
+ /**
1671
+ * Merge the botfarm MCP entry into <dir>/.opencode/opencode.json, and keep that
1672
+ * directory out of git: a worktree the agent is about to commit from should
1673
+ * not gain a tracked file because the operator switched a toggle.
1674
+ */
1675
+ /** Copies of the workspace's local remote MCP servers that carry this worktree as X-Repo-Root. */
1676
+ /** BotFarm's own MCP server: what bots see as botfarm_task_complete etc. ("botfarm" is its old name). */
1677
+ export const MCP_NAME = "botfarm"
1678
+ const BOTFARM_MCP_NAMES = new Set([MCP_NAME, "botfarm"])
1679
+
1680
+ /** MCP states worth asking opencode to connect again ("disabled" is a choice). */
1681
+ const RETRYABLE_MCP = new Set(["failed", "error", "disconnected"])
1682
+
1683
+ export function scopeLocalMcps(mcp, directory) {
1684
+ const out = {}
1685
+ for (const [name, cfg] of Object.entries(mcp ?? {})) {
1686
+ if (cfg?.type !== "remote" || !cfg.url) continue
1687
+ let url
1688
+ try { url = new URL(cfg.url) } catch { continue }
1689
+ if (!["localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal", "::1"].includes(url.hostname)) continue
1690
+ // Belt and suspenders: opencode's SSE transport does not reliably forward custom
1691
+ // headers (EventSource-style clients can't set them at all), so the query param
1692
+ // is what actually pins the connection to this worktree - the header is kept for
1693
+ // any transport that does honour it. Without one of these, every tool call quietly
1694
+ // falls back to the server's default checkout (the main repo, not the worktree).
1695
+ url.searchParams.set("root", directory)
1696
+ out[name] = { ...cfg, url: url.toString(), headers: { ...(cfg.headers ?? {}), "X-Repo-Root": directory } }
1697
+ }
1698
+ return out
1699
+ }
1700
+
1701
+ /**
1702
+ * `source` is the workspace checkout. With it, everything that is not BotFarm's
1703
+ * own (MCP servers, the workspace's agents, other settings) is taken fresh from
1704
+ * the checkout's .opencode/opencode.json rather than merged into what the
1705
+ * worktree had — otherwise a server you removed or renamed in the workspace
1706
+ * lives on in every worktree made before the change.
1707
+ */
1708
+ async function writeProjectConfig(dir, botfarm, agents = {}, mcps = {}, { source = null } = {}) {
1709
+ const folder = join(dir, ".opencode")
1710
+ // Never write through a link into someone's shared config folder.
1711
+ await git.ensureOwnOpencodeDir(dir)
1712
+ const file = join(folder, "opencode.json")
1713
+ let existing = {}
1714
+ try {
1715
+ existing = JSON.parse(await readFile(file, "utf8"))
1716
+ } catch {}
1717
+ if (source && resolveDir(source) !== resolveDir(dir)) {
1718
+ const src = (await readJsonc(join(source, ".opencode", "opencode.json"))) ?? (await readJsonc(join(source, ".opencode", "opencode.jsonc"))) ?? {}
1719
+ const keepOwn = existing.mcp?.[MCP_NAME] ?? existing.mcp?.botfarm
1720
+ const botfarmAgents = Object.fromEntries(Object.entries(existing.agent ?? {}).filter(([k]) => k.startsWith("botfarm-")))
1721
+ const tools = existing.tools
1722
+ existing = { ...src, mcp: { ...(src.mcp ?? {}), ...(keepOwn ? { [MCP_NAME]: keepOwn } : {}) }, agent: { ...(src.agent ?? {}), ...botfarmAgents } }
1723
+ if (tools) existing.tools = tools
1724
+ }
1725
+ existing.$schema ??= "https://opencode.ai/config.json"
1726
+ // The tools used to be served as "botfarm"; a worktree keeps one entry, under the new name.
1727
+ if (existing.mcp?.botfarm) { existing.mcp[MCP_NAME] ??= existing.mcp.botfarm; delete existing.mcp.botfarm }
1728
+ if (botfarm) existing.mcp = { ...(existing.mcp ?? {}), [MCP_NAME]: botfarm }
1729
+ if (Object.keys(mcps).length) existing.mcp = { ...(existing.mcp ?? {}), ...mcps }
1730
+ if (existing.mcp && !Object.keys(existing.mcp).length) delete existing.mcp
1731
+ const added = []
1732
+ existing.agent ??= {}
1733
+ for (const [name, a] of Object.entries(agents)) {
1734
+ if (JSON.stringify(existing.agent[name]) !== JSON.stringify(a)) added.push(name)
1735
+ existing.agent[name] = a
1736
+ }
1737
+ if (!Object.keys(existing.agent).length) delete existing.agent
1738
+ await writeFile(file, JSON.stringify(existing, null, 2) + "\n")
1739
+ await git.excludeLocally(dir, ".opencode/").catch(() => {})
1740
+ return added
1741
+ }
1742
+
1743
+ /**
1744
+ * One BotFarm agent as an opencode agent: the workspace's opencode agent it
1745
+ * builds on (prompt, permissions), with its own model, reasoning level and
1746
+ * tool limits. The botfarm tools are always on, or it could not hand off.
1747
+ */
1748
+ function botAgentConfig(setup, p = {}) {
1749
+ const r = resolveBot(setup ?? {}, p)
1750
+ const base = (r.agent && setup?.agent?.[r.agent]) || {}
1751
+ const out = { ...base, mode: "primary", description: `BotFarm ${p.title ?? "agent"}` }
1752
+ delete out.disable
1753
+ if (r.model) out.model = r.model
1754
+ // A reasoning level only if the BotFarm agent asks for one: the session
1755
+ // runner refuses a variant its model does not list ("Variant unavailable
1756
+ // for amazon-bedrock/anthropic.claude-sonnet-5: medium") and the turn
1757
+ // never starts. The workspace agent's own variant is not inherited.
1758
+ delete out.variant
1759
+ if (p.variant) out.variant = p.variant
1760
+ if (p.tools?.length) {
1761
+ out.tools = { "*": false, ...Object.fromEntries(p.tools.map((t) => [t, true])), "botfarm*": true }
1762
+ // opencode turns `tools` into permissions, and a more specific allow in
1763
+ // the base agent (say "some-server*": "allow" on plan) beats "*": false — so
1764
+ // MCP servers the BotFarm agent does not list are denied by name, last,
1765
+ // where they win. Listed ones ("github_*", say) are left to the workspace's own
1766
+ // rules, which may still deny some of their tools.
1767
+ const listed = (name) => p.tools.some((t) => globMatch(t, `${name}_x`) || t === name || t.startsWith(`${name}_`) || t.startsWith(`${name}*`))
1768
+ const deny = {}
1769
+ for (const name of Object.keys(setup?.mcp ?? {})) if (!BOTFARM_MCP_NAMES.has(name) && !listed(name)) deny[`${name}_*`] = "deny"
1770
+ if (Object.keys(deny).length) {
1771
+ const perm = typeof out.permission === "object" && out.permission ? { ...out.permission } : {}
1772
+ for (const k of Object.keys(perm)) if (Object.keys(deny).some((d) => k.startsWith(d.slice(0, -2)))) delete perm[k]
1773
+ out.permission = { ...perm, ...deny }
1774
+ }
1775
+ } else if (out.tools) out.tools = { ...out.tools, "botfarm*": true }
1776
+ return out
1777
+ }
1778
+
1779
+ function globMatch(pattern, name) {
1780
+ const re = new RegExp("^" + String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$")
1781
+ return re.test(name)
1782
+ }
1783
+
1784
+ /** Tool switches live in the worktree's own opencode config. */
1785
+ async function writeProjectTools(dir, disabled) {
1786
+ const folder = join(dir, ".opencode")
1787
+ await git.ensureOwnOpencodeDir(dir)
1788
+ const file = join(folder, "opencode.json")
1789
+ let existing = {}
1790
+ try {
1791
+ existing = JSON.parse(await readFile(file, "utf8"))
1792
+ } catch {}
1793
+ existing.$schema ??= "https://opencode.ai/config.json"
1794
+ existing.tools = Object.fromEntries(disabled.map((t) => [t, false]))
1795
+ if (!disabled.length) delete existing.tools
1796
+ await writeFile(file, JSON.stringify(existing, null, 2) + "\n")
1797
+ await git.excludeLocally(dir, ".opencode/").catch(() => {})
1798
+ }
1799
+
1800
+ const tokensOfSession = (s) => {
1801
+ const t = s?.totals ?? {}
1802
+ return (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
1803
+ }