@dirwin517/bot-farm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
|
@@ -0,0 +1,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
|
+
}
|