@dirwin517/bot-farm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
package/src/server.mjs ADDED
@@ -0,0 +1,765 @@
1
+ import { createServer } from "node:http"
2
+ import { readFile } from "node:fs/promises"
3
+ import { fileURLToPath } from "node:url"
4
+ import { basename, dirname, join } from "node:path"
5
+ import * as git from "./git.mjs"
6
+ import { createMcpHandler } from "./mcp.mjs"
7
+ import { avatarFor } from "./identity.mjs"
8
+ import { isWebSocketUpgrade, accept } from "./ws.mjs"
9
+ import { listDirs } from "./workspaces.mjs"
10
+ import { cleanLimits } from "./pipelines.mjs"
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url))
13
+
14
+ export function startServer({ supervisor, port = 4777, host = "127.0.0.1", repos = [] }) {
15
+ const clients = new Set() // SSE responses
16
+ const sockets = new Set() // websocket connections
17
+ const mcp = createMcpHandler({ mesh: supervisor.mesh, supervisor })
18
+ let pending = null
19
+
20
+ const push = (conn) => {
21
+ const json = JSON.stringify(supervisor.store.snapshot())
22
+ if (conn) return conn.send(json)
23
+ for (const c of sockets) c.send(json)
24
+ const frame = `data: ${json}\n\n`
25
+ for (const res of clients) res.write(frame)
26
+ }
27
+
28
+ const broadcast = () => {
29
+ if (pending || (clients.size === 0 && sockets.size === 0)) return
30
+ pending = setTimeout(() => {
31
+ pending = null
32
+ push()
33
+ }, 200)
34
+ }
35
+
36
+ const server = createServer(async (req, res) => {
37
+ const url = new URL(req.url, `http://${req.headers.host}`)
38
+ const path = url.pathname
39
+ try {
40
+ if (req.method === "GET" && (path === "/" || path === "/index.html")) {
41
+ const html = await readFile(join(here, "public", "index.html"))
42
+ return send(res, 200, html, "text/html; charset=utf-8")
43
+ }
44
+ // The previous dashboard, kept for the things the new one leaves out.
45
+ if (req.method === "GET" && path === "/classic") {
46
+ const html = await readFile(join(here, "public", "classic.html"))
47
+ return send(res, 200, html, "text/html; charset=utf-8")
48
+ }
49
+
50
+ // --- workspaces: a folder, its agents and pipelines -----------------
51
+ if (path === "/api/pricing" && req.method === "GET") return json(res, 200, supervisor.pricingView())
52
+ if (path === "/api/pricing" && req.method === "PUT") {
53
+ const body = await readJson(req)
54
+ const out = await supervisor.setPrice(body.model, body)
55
+ broadcast()
56
+ return json(res, 200, out)
57
+ }
58
+ if (req.method === "GET" && path === "/api/fs") {
59
+ return json(res, 200, await listDirs(url.searchParams.get("path") ?? "~"))
60
+ }
61
+ if (path === "/api/workspaces" && req.method === "GET") {
62
+ return json(res, 200, supervisor.workspaces.list())
63
+ }
64
+ if (path === "/api/workspaces" && req.method === "POST") {
65
+ const body = await readJson(req)
66
+ const ws = await supervisor.workspaces.add({ path: body.path, name: body.name })
67
+ broadcast()
68
+ return json(res, 201, ws)
69
+ }
70
+ // The workspace's own tools (botfarm/mcps/*.js) and their test bench.
71
+ const toolsPath = /^\/api\/workspaces\/([^/]+)\/tools(?:\/([^/]+)(?:\/(run|source))?)?$/.exec(path)
72
+ if (toolsPath) {
73
+ const ws = supervisor.workspaces.get(decodeURIComponent(toolsPath[1]))
74
+ if (!ws) return json(res, 404, { error: "unknown workspace" })
75
+ const item = toolsPath[2] ? decodeURIComponent(toolsPath[2]) : null
76
+ const op = toolsPath[3]
77
+ try {
78
+ if (!item && req.method === "GET") { await supervisor.wsTools.load(ws); return json(res, 200, supervisor.wsTools.list(ws.id)) }
79
+ if (!item && req.method === "POST") { const body = await readJson(req); const out = await supervisor.wsTools.create(ws, body.name); broadcast(); return json(res, 201, out) }
80
+ if (item && op === "run" && req.method === "POST") {
81
+ const body = await readJson(req).catch(() => ({}))
82
+ // Run where a bot would: a workstream's worktree, or the workspace itself.
83
+ const dir = body.directory && (body.directory === ws.path || supervisor.store.list().some((s) => s.directory === body.directory) || supervisor.projects.list().some((p) => p.worktree === body.directory)) ? body.directory : ws.path
84
+ const out = await supervisor.wsTools.run(ws.id, item, body.args ?? {}, { repoRoot: dir, workspace: { id: ws.id, name: ws.name, path: ws.path }, bot: null })
85
+ return json(res, 200, { ...out, directory: dir })
86
+ }
87
+ if (item && op === "source" && req.method === "GET") return json(res, 200, await supervisor.wsTools.source(ws, item))
88
+ if (item && op === "source" && req.method === "PUT") { const body = await readJson(req); const out = await supervisor.wsTools.save(ws, item, body.text); broadcast(); return json(res, 200, out) }
89
+ if (item && !op && req.method === "DELETE") { const out = await supervisor.wsTools.remove(ws, item); broadcast(); return json(res, 200, out) }
90
+ } catch (err) {
91
+ return json(res, 400, { error: err.message })
92
+ }
93
+ }
94
+ const wsPath = /^\/api\/workspaces\/([^/]+)(?:\/(library|workstreams|agents|pipelines|file|repos)(?:\/([^/]+))?)?$/.exec(path)
95
+ if (wsPath) {
96
+ const id = decodeURIComponent(wsPath[1])
97
+ const ws = supervisor.workspaces.get(id)
98
+ if (!ws) return json(res, 404, { error: "unknown workspace" })
99
+ const [, , sub, rest] = wsPath
100
+ const entry = rest ? decodeURIComponent(rest) : null
101
+ if (!sub && req.method === "GET") return json(res, 200, ws)
102
+ if (!sub && req.method === "POST") {
103
+ const body = await readJson(req)
104
+ return json(res, 200, supervisor.workspaces.update(id, { name: body.name ?? ws.name, services: body.services ?? ws.services }))
105
+ }
106
+ if (!sub && req.method === "DELETE") {
107
+ supervisor.workspaces.remove(id)
108
+ broadcast()
109
+ return json(res, 200, { ok: true })
110
+ }
111
+ if (sub === "library" && req.method === "GET") {
112
+ const [lib, where] = await Promise.all([supervisor.workspaces.summary(id), git.inspect(ws.path)])
113
+ return json(res, 200, { ...lib, nested: where.nested ?? [], linked: ws.linked ?? [], branch: where.branch ?? null })
114
+ }
115
+ // Repos from outside the workspace its workstreams take along.
116
+ if (sub === "repos" && req.method === "POST") {
117
+ const body = await readJson(req)
118
+ try {
119
+ const out = await supervisor.workspaces.addLinked(id, { path: body.path, as: body.as || null })
120
+ // And, if asked, into the workstreams already running here.
121
+ const added = []
122
+ if (body.running) {
123
+ const l = out.linked.at(-1)
124
+ for (const p of supervisor.projects.list().filter((p) => p.workspaceId === id && p.status === "running" && p.worktree)) {
125
+ added.push(await supervisor.addRepoToWorkstream(p.id, l.as === basename(l.path) ? l.path : l).then((r) => ({ project: p.id, ...r }), (e) => ({ project: p.id, error: e.message })))
126
+ }
127
+ }
128
+ broadcast()
129
+ return json(res, 200, { ...out, added })
130
+ } catch (err) {
131
+ return json(res, 400, { error: err.message })
132
+ }
133
+ }
134
+ if (sub === "repos" && req.method === "DELETE") {
135
+ const out = supervisor.workspaces.removeLinked(id, url.searchParams.get("path"))
136
+ broadcast()
137
+ return json(res, 200, out)
138
+ }
139
+ if ((sub === "agents" || sub === "pipelines") && entry && (req.method === "PUT" || req.method === "DELETE")) {
140
+ const body = req.method === "PUT" ? await readJson(req) : null
141
+ const out = await supervisor.workspaces.saveEntry(id, sub, entry, body ? body.doc : null)
142
+ broadcast()
143
+ return json(res, 200, out)
144
+ }
145
+ // One definition's file: /file/agents?entry=dev, /file/pipelines?entry=story
146
+ if (sub === "file" && entry && req.method === "GET") return json(res, 200, await supervisor.workspaces.readRaw(id, entry, url.searchParams.get("entry")))
147
+ if (sub === "file" && entry && req.method === "PUT") {
148
+ const body = await readJson(req)
149
+ const out = await supervisor.workspaces.writeRaw(id, entry, body.text ?? "", url.searchParams.get("entry") ?? body.entry)
150
+ broadcast()
151
+ return json(res, 200, out)
152
+ }
153
+ if (sub === "workstreams" && req.method === "POST") {
154
+ const body = await readJson(req)
155
+ const run = await supervisor.pipelines.start({ ...body, workspace: id })
156
+ broadcast()
157
+ return json(res, 201, run)
158
+ }
159
+ }
160
+ const restart = /^\/api\/projects\/([^/]+)\/restart$/.exec(path)
161
+ if (req.method === "POST" && restart) {
162
+ const body = await readJson(req).catch(() => ({}))
163
+ const out = await supervisor.pipelines.restart(decodeURIComponent(restart[1]), { stage: body.stage ?? null, fresh: body.fresh !== false })
164
+ broadcast()
165
+ return json(res, 200, out)
166
+ }
167
+ const bots = /^\/api\/projects\/([^/]+)\/bots$/.exec(path)
168
+ if (req.method === "POST" && bots) {
169
+ const body = await readJson(req)
170
+ const s = await supervisor.pipelines.addBot(decodeURIComponent(bots[1]), body.agent)
171
+ broadcast()
172
+ return json(res, 201, s)
173
+ }
174
+ const archive = /^\/api\/projects\/([^/]+)\/archive$/.exec(path)
175
+ if (req.method === "POST" && archive) {
176
+ const body = await readJson(req).catch(() => ({}))
177
+ await supervisor.archive(decodeURIComponent(archive[1]), body)
178
+ broadcast()
179
+ return json(res, 200, { ok: true })
180
+ }
181
+
182
+ const botSettings = /^\/api\/sessions\/([^/]+)\/(settings|models)$/.exec(path)
183
+ if (botSettings) {
184
+ const id = decodeURIComponent(botSettings[1])
185
+ try {
186
+ if (botSettings[2] === "models" && req.method === "GET") return json(res, 200, await supervisor.modelsFor(id))
187
+ if (botSettings[2] === "settings" && req.method === "POST") {
188
+ const out = await supervisor.setBotSettings(id, await readJson(req).catch(() => ({})))
189
+ broadcast()
190
+ return json(res, 200, out)
191
+ }
192
+ } catch (err) {
193
+ return json(res, err.status ?? 400, { error: err.message })
194
+ }
195
+ }
196
+ const reconnect = /^\/api\/sessions\/([^/]+)\/mcp\/reconnect$/.exec(path)
197
+ if (req.method === "POST" && reconnect) {
198
+ const s = supervisor.store.get(decodeURIComponent(reconnect[1]))
199
+ if (!s) return json(res, 404, { error: "unknown session" })
200
+ await supervisor.refreshMcp(supervisor.store.list().filter((x) => x.directory === s.directory), { force: true })
201
+ broadcast()
202
+ return json(res, 200, s.mcp ?? {})
203
+ }
204
+
205
+ // PR packet: GET builds and saves it, POST pushes the branch and opens the PR with gh.
206
+ const pr = /^\/api\/projects\/([^/]+)\/pr$/.exec(path)
207
+ if (pr) {
208
+ try {
209
+ if (req.method === "GET") return json(res, 200, await supervisor.writePacket(decodeURIComponent(pr[1])))
210
+ if (req.method === "POST") { const out = await supervisor.openPr(decodeURIComponent(pr[1])); broadcast(); return json(res, 200, out) }
211
+ } catch (err) {
212
+ return json(res, err.status ?? 400, { error: err.message })
213
+ }
214
+ }
215
+ // Replays: export a workstream as one file; import, list and open saved ones.
216
+ const rexport = /^\/api\/projects\/([^/]+)\/replay$/.exec(path)
217
+ if (rexport && req.method === "GET") {
218
+ try {
219
+ const doc = await supervisor.exportReplay(decodeURIComponent(rexport[1]))
220
+ const name = `${String(doc.project.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 50)}.botfarm-replay.json`
221
+ const buf = Buffer.from(JSON.stringify(doc))
222
+ res.writeHead(200, { "content-type": "application/json", "content-length": buf.length, ...(url.searchParams.get("download") ? { "content-disposition": `attachment; filename="${name}"` } : {}) })
223
+ return res.end(buf)
224
+ } catch (err) {
225
+ return json(res, err.status ?? 400, { error: err.message })
226
+ }
227
+ }
228
+ const replays = /^\/api\/replays(?:\/([^/]+))?$/.exec(path)
229
+ if (replays) {
230
+ try {
231
+ if (!replays[1] && req.method === "GET") return json(res, 200, await supervisor.shelf.list())
232
+ if (!replays[1] && req.method === "POST") return json(res, 201, { id: await supervisor.importReplay(await readJson(req)) })
233
+ if (replays[1] && req.method === "GET") return json(res, 200, await supervisor.shelf.get(decodeURIComponent(replays[1])))
234
+ } catch (err) {
235
+ return json(res, 400, { error: err.message })
236
+ }
237
+ }
238
+ // Model routing by difficulty (botfarm/routing.botfarm.yml) and the models to pick from.
239
+ const routing = /^\/api\/workspaces\/([^/]+)\/(routing|models)$/.exec(path)
240
+ if (routing) {
241
+ const wsId = decodeURIComponent(routing[1])
242
+ try {
243
+ if (routing[2] === "models" && req.method === "GET") return json(res, 200, await supervisor.workspaceModels(wsId))
244
+ if (routing[2] === "routing" && req.method === "GET") return json(res, 200, await supervisor.routingView(wsId))
245
+ if (routing[2] === "routing" && req.method === "PUT") { const out = await supervisor.workspaces.saveRouting(wsId, await readJson(req)); broadcast(); return json(res, 200, out) }
246
+ } catch (err) {
247
+ return json(res, err.status ?? 400, { error: err.message })
248
+ }
249
+ }
250
+ const harvest = /^\/api\/workspaces\/([^/]+)\/harvest$/.exec(path)
251
+ if (harvest && req.method === "GET") {
252
+ const wsId = decodeURIComponent(harvest[1])
253
+ return json(res, 200, { ...supervisor.farmhands.totals(wsId), levels: supervisor.farmhands.all(wsId) })
254
+ }
255
+
256
+ // Pause / resume a whole workstream by hand.
257
+ const pr2 = /^\/api\/projects\/([^/]+)\/(pause|resume)$/.exec(path)
258
+ if (pr2 && req.method === "POST") {
259
+ try {
260
+ const pid = decodeURIComponent(pr2[1])
261
+ const run = pr2[2] === "pause" ? await supervisor.pipelines.pauseByHand(pid) : await supervisor.pipelines.budgetRun(pid, { action: "continue" })
262
+ broadcast()
263
+ return json(res, 200, { paused: run.paused ?? null, status: run.status })
264
+ } catch (err) {
265
+ return json(res, 400, { error: err.message })
266
+ }
267
+ }
268
+ // A finished card's report (diffs as they were when it finished) and its screenshots.
269
+ const rep = /^\/api\/tasks\/([^/]+)\/(report|image)(?:\/(\d+))?$/.exec(path)
270
+ if (rep && req.method === "GET") {
271
+ const task = supervisor.tasks.get(decodeURIComponent(rep[1]))
272
+ if (!task?.report) return json(res, 404, { error: "no report for that card" })
273
+ if (rep[2] === "report") return json(res, 200, { ...task.report, title: task.title, stage: task.stage ?? null, summary: task.handoff?.summary ?? "" })
274
+ const im = task.report.images?.[Number(rep[3] ?? 0)]
275
+ if (!im) return json(res, 404, { error: "no such image" })
276
+ try {
277
+ let file
278
+ if (im.task) {
279
+ const sub = supervisor.tasks.get(im.task)?.report?.images?.[im.n]
280
+ if (!sub) return json(res, 404, { error: "no such image" })
281
+ Object.assign(im, sub)
282
+ }
283
+ if (im.source === "shot") file = join(supervisor.home, "shots", im.file.replace(/[^\w.-]/g, ""))
284
+ else {
285
+ const dir = supervisor.store.get(task.assignee)?.directory ?? supervisor.projects.get(task.projectId)?.worktree
286
+ const full = join(dir, im.path)
287
+ if (!dir || !full.startsWith(dir + "/")) return json(res, 400, { error: "outside the worktree" })
288
+ file = full
289
+ }
290
+ const buf = await readFile(file)
291
+ const type = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }[file.split(".").pop().toLowerCase()] ?? "application/octet-stream"
292
+ res.writeHead(200, { "content-type": type, "content-length": buf.length, "cache-control": "max-age=3600" })
293
+ return res.end(buf)
294
+ } catch (err) {
295
+ return json(res, 404, { error: err.message })
296
+ }
297
+ }
298
+
299
+ const pbudget = /^\/api\/projects\/([^/]+)\/budget$/.exec(path)
300
+ if (req.method === "POST" && pbudget) {
301
+ const body = await readJson(req).catch(() => ({}))
302
+ try {
303
+ const run = await supervisor.pipelines.budgetRun(decodeURIComponent(pbudget[1]), body)
304
+ broadcast()
305
+ return json(res, 200, { limits: run.limits, paused: run.paused, status: run.status })
306
+ } catch (err) {
307
+ return json(res, 400, { error: err.message })
308
+ }
309
+ }
310
+ const tbudget = /^\/api\/tasks\/([^/]+)\/budget$/.exec(path)
311
+ if (req.method === "POST" && tbudget) {
312
+ const body = await readJson(req).catch(() => ({}))
313
+ const task = supervisor.tasks.get(decodeURIComponent(tbudget[1]))
314
+ if (!task) return json(res, 404, { error: "unknown task" })
315
+ try {
316
+ const out = body.limits !== undefined && !body.action
317
+ ? supervisor.tasks.update(task.id, { limits: cleanLimits(body.limits) })
318
+ : await supervisor.pipelines.budgetTask(task, body)
319
+ broadcast()
320
+ return json(res, 200, out)
321
+ } catch (err) {
322
+ return json(res, 400, { error: err.message })
323
+ }
324
+ }
325
+
326
+ const ppatch = /^\/api\/projects\/([^/]+)\/patches(?:\/(apply|undo|file))?$/.exec(path)
327
+ if (ppatch) {
328
+ const pid = decodeURIComponent(ppatch[1])
329
+ try {
330
+ if (!ppatch[2] && req.method === "GET") return json(res, 200, await supervisor.previewPatches(pid))
331
+ if (ppatch[2] === "file" && req.method === "GET") {
332
+ // One repo's patch, to download.
333
+ const view = await supervisor.previewPatches(pid)
334
+ const r = view.repos.find((x) => x.rel === (url.searchParams.get("rel") ?? ""))
335
+ if (!r?.file) return json(res, 404, { error: "no patch for that repo" })
336
+ const { readFile } = await import("node:fs/promises")
337
+ const body = await readFile(r.file)
338
+ res.writeHead(200, { "content-type": "text/x-diff; charset=utf-8", "content-disposition": `attachment; filename="${r.file.split("/").pop()}"` })
339
+ return res.end(body)
340
+ }
341
+ if (ppatch[2] === "apply" && req.method === "POST") {
342
+ const body = await readJson(req).catch(() => ({}))
343
+ const out = await supervisor.applyPatches(pid, { repos: Array.isArray(body.repos) ? body.repos : null })
344
+ broadcast()
345
+ return json(res, 200, out)
346
+ }
347
+ if (ppatch[2] === "undo" && req.method === "POST") {
348
+ const out = await supervisor.undoPatches(pid)
349
+ broadcast()
350
+ return json(res, 200, out)
351
+ }
352
+ } catch (err) {
353
+ return json(res, err.status ?? 400, { error: err.message })
354
+ }
355
+ }
356
+
357
+ const prepos = /^\/api\/projects\/([^/]+)\/repos$/.exec(path)
358
+ if (req.method === "POST" && prepos) {
359
+ const body = await readJson(req).catch(() => ({}))
360
+ try {
361
+ const entry = body.as ? { path: body.path, as: body.as } : body.path
362
+ const out = await supervisor.addRepoToWorkstream(decodeURIComponent(prepos[1]), entry)
363
+ broadcast()
364
+ return json(res, 200, out)
365
+ } catch (err) {
366
+ return json(res, err.status ?? 400, { error: err.message })
367
+ }
368
+ }
369
+
370
+ const sback = /^\/api\/tasks\/([^/]+)\/send-back$/.exec(path)
371
+ if (req.method === "POST" && sback) {
372
+ const body = await readJson(req).catch(() => ({}))
373
+ const task = supervisor.tasks.get(decodeURIComponent(sback[1]))
374
+ if (!task) return json(res, 404, { error: "unknown task" })
375
+ try {
376
+ // From the board: the operator sends a finished card back to its bot.
377
+ const out = await supervisor.pipelines.sendBack(null, { to: task.id, reason: body.reason, failures: body.failures ?? [], files: body.files ?? [] })
378
+ broadcast()
379
+ return json(res, 200, out)
380
+ } catch (err) {
381
+ return json(res, 400, { error: err.message })
382
+ }
383
+ }
384
+
385
+ const sync = /^\/api\/projects\/([^/]+)\/refresh-config$/.exec(path)
386
+ if (req.method === "POST" && sync) {
387
+ try {
388
+ const out = await supervisor.syncWorkstreamConfig(decodeURIComponent(sync[1]), { reason: "refresh requested" })
389
+ broadcast()
390
+ return json(res, 200, out)
391
+ } catch (err) {
392
+ return json(res, err.status ?? 400, { error: err.message })
393
+ }
394
+ }
395
+
396
+ const del = /^\/api\/projects\/([^/]+)\/delete$/.exec(path)
397
+ if (del && req.method === "GET") {
398
+ const out = await supervisor.deletePreview(decodeURIComponent(del[1]))
399
+ return out ? json(res, 200, out) : json(res, 404, { error: "unknown workstream" })
400
+ }
401
+ if (del && req.method === "POST") {
402
+ const body = await readJson(req).catch(() => ({}))
403
+ try {
404
+ const out = await supervisor.deleteWorkstream(decodeURIComponent(del[1]), body)
405
+ broadcast()
406
+ return json(res, 200, out)
407
+ } catch (err) {
408
+ return json(res, err.status ?? 400, { error: err.message, code: err.code })
409
+ }
410
+ }
411
+
412
+ const mcpPath = /^\/mcp\/([A-Za-z0-9_-]+)$/.exec(path)
413
+ if (mcpPath) return mcp(req, res, mcpPath[1])
414
+
415
+ if (req.method === "GET" && /^\/avatar\/[^/]+\.svg$/.test(path)) {
416
+ const id = decodeURIComponent(path.slice(8, -4))
417
+ const handle = url.searchParams.get("h") || supervisor.store.get(id)?.handle || null
418
+ return send(res, 200, avatarFor(id, { size: 128, handle }), "image/svg+xml")
419
+ }
420
+
421
+ if (req.method === "GET" && path === "/api/stream") {
422
+ res.writeHead(200, {
423
+ "content-type": "text/event-stream",
424
+ "cache-control": "no-cache",
425
+ connection: "keep-alive",
426
+ })
427
+ res.write(`data: ${JSON.stringify(supervisor.store.snapshot())}\n\n`)
428
+ clients.add(res)
429
+ const beat = setInterval(() => res.write(": ping\n\n"), 20000)
430
+ req.on("close", () => { clients.delete(res); clearInterval(beat) })
431
+ return
432
+ }
433
+
434
+ if (req.method === "GET" && path === "/api/state") {
435
+ return json(res, 200, supervisor.store.snapshot())
436
+ }
437
+
438
+ if (req.method === "GET" && path === "/api/repos") {
439
+ const out = []
440
+ for (const repo of repos) {
441
+ const root = await git.repoRoot(repo)
442
+ if (!root) continue
443
+ out.push({ path: root, worktrees: await git.listWorktrees(root).catch(() => []) })
444
+ }
445
+ return json(res, 200, out)
446
+ }
447
+
448
+ const raw = /^\/api\/sessions\/([^/]+)\/raw$/.exec(path)
449
+ if (req.method === "GET" && raw) {
450
+ return json(res, 200, await supervisor.raw(decodeURIComponent(raw[1]), Number(url.searchParams.get("limit") ?? 3)))
451
+ }
452
+ if (req.method === "GET" && path === "/api/debug/events") {
453
+ return json(res, 200, supervisor.recentEvents ?? [])
454
+ }
455
+
456
+ const detail = /^\/api\/sessions\/([^/]+)$/.exec(path)
457
+ if (req.method === "GET" && detail) {
458
+ const d = await supervisor.detail(decodeURIComponent(detail[1]))
459
+ return d ? json(res, 200, d) : json(res, 404, { error: "unknown session" })
460
+ }
461
+
462
+ if (req.method === "POST" && path === "/api/sessions") {
463
+ const body = await readJson(req)
464
+ const s = await supervisor.spawn(body)
465
+ return json(res, 201, s)
466
+ }
467
+
468
+ if (req.method === "GET" && path === "/api/repo") {
469
+ return json(res, 200, await git.inspect(url.searchParams.get("path") ?? ""))
470
+ }
471
+
472
+ // --- pipelines and tasks --------------------------------------------
473
+ if (req.method === "GET" && path === "/api/pipelines") {
474
+ return json(res, 200, supervisor.pipelines.list())
475
+ }
476
+ const defPath = /^\/api\/(pipelines|personas)\/([^/]+)$/.exec(path)
477
+ if (defPath && (req.method === "GET" || req.method === "PUT" || req.method === "DELETE")) {
478
+ const kind = defPath[1]
479
+ const id = decodeURIComponent(defPath[2])
480
+ if (req.method === "GET") {
481
+ const doc = supervisor.pipelines.definition(kind, id)
482
+ return doc ? json(res, 200, doc) : json(res, 404, { error: "not found" })
483
+ }
484
+ if (req.method === "PUT") {
485
+ const body = await readJson(req)
486
+ const saved = await supervisor.pipelines.saveDefinition(kind, id, body)
487
+ broadcast()
488
+ return json(res, saved.problems?.length ? 200 : 200, saved)
489
+ }
490
+ await supervisor.pipelines.deleteDefinition(kind, id)
491
+ broadcast()
492
+ return json(res, 200, { ok: true })
493
+ }
494
+ if (req.method === "POST" && path === "/api/pipelines/reload") {
495
+ return json(res, 200, await supervisor.pipelines.load())
496
+ }
497
+ if (req.method === "POST" && path === "/api/runs") {
498
+ const body = await readJson(req)
499
+ const run = await supervisor.pipelines.start(body)
500
+ broadcast()
501
+ return json(res, 201, run)
502
+ }
503
+ const runExport = /^\/api\/runs\/([^/]+)\/export$/.exec(path)
504
+ if (req.method === "GET" && runExport) {
505
+ return send(res, 200, supervisor.pipelines.exportRun(decodeURIComponent(runExport[1])), "text/yaml; charset=utf-8")
506
+ }
507
+ if (req.method === "POST" && path === "/api/tasks") {
508
+ const body = await readJson(req)
509
+ const task = supervisor.tasks.create({ ...body, createdBy: "operator" })
510
+ await supervisor.pipelines.dispatch()
511
+ broadcast()
512
+ return json(res, 201, task)
513
+ }
514
+ const taskPath = /^\/api\/tasks\/([^/]+)(\/(diff|note|answer|continue))?$/.exec(path)
515
+ if (taskPath) {
516
+ const id = decodeURIComponent(taskPath[1])
517
+ const task = supervisor.tasks.get(id)
518
+ if (!task) return json(res, 404, { error: "unknown task" })
519
+ if (req.method === "GET" && taskPath[3] === "diff") {
520
+ const run = task.runId ? supervisor.pipelines.run(task.runId) : null
521
+ const dir = supervisor.store.get(task.assignee)?.directory ?? run?.repo
522
+ const file = url.searchParams.get("file")
523
+ return json(res, 200, { files: await git.changedFiles(dir), diff: file ? await git.diffFile(dir, file) : null, dir })
524
+ }
525
+ if (req.method === "GET") return json(res, 200, task)
526
+ if (req.method === "POST" && taskPath[3] === "answer") {
527
+ const body = await readJson(req)
528
+ const answered = await supervisor.answer(id, body.answer, body.answers ?? null)
529
+ broadcast()
530
+ return json(res, 200, answered)
531
+ }
532
+ if (req.method === "POST" && taskPath[3] === "continue") {
533
+ const body = await readJson(req)
534
+ const out = await supervisor.pipelines.release(task, body.note, body.answers ?? null)
535
+ broadcast()
536
+ return json(res, 200, out)
537
+ }
538
+ if (req.method === "POST" && taskPath[3] === "note") {
539
+ const body = await readJson(req)
540
+ return json(res, 200, supervisor.tasks.note(id, body.note))
541
+ }
542
+ if (req.method === "POST") {
543
+ const body = await readJson(req)
544
+ const updated = supervisor.tasks.update(id, body)
545
+ supervisor.tasks.unblock()
546
+ await supervisor.pipelines.dispatch()
547
+ broadcast()
548
+ return json(res, 200, updated)
549
+ }
550
+ if (req.method === "DELETE") {
551
+ supervisor.tasks.remove(id)
552
+ broadcast()
553
+ return json(res, 200, { ok: true })
554
+ }
555
+ }
556
+
557
+ // --- registry ------------------------------------------------------
558
+ if (req.method === "GET" && path === "/api/unmanaged") {
559
+ const q = (url.searchParams.get("q") ?? "").toLowerCase()
560
+ const rows = supervisor.unmanaged.filter(
561
+ (s) => !q || `${s.title} ${s.directory ?? ""}`.toLowerCase().includes(q),
562
+ )
563
+ return json(res, 200, { sessions: rows.slice(0, 80), total: supervisor.unmanaged.length })
564
+ }
565
+ if (req.method === "POST" && path === "/api/adopt") {
566
+ const body = await readJson(req)
567
+ await supervisor.adopt(body.ids ?? [], { project: body.project ?? body.group ?? "default" })
568
+ broadcast()
569
+ return json(res, 200, { ok: true })
570
+ }
571
+ // --- projects --------------------------------------------------------
572
+ if (req.method === "POST" && path === "/api/projects") {
573
+ const body = await readJson(req)
574
+ const project = supervisor.projects.create(body)
575
+ if (body.sessions?.length) for (const id of body.sessions) supervisor.projects.attach(id, project.id)
576
+ await supervisor.registry.save()
577
+ broadcast()
578
+ return json(res, 201, project)
579
+ }
580
+ const projectPath = /^\/api\/projects\/([^/]+)(\/(chat|board))?$/.exec(path)
581
+ if (projectPath) {
582
+ const id = decodeURIComponent(projectPath[1])
583
+ if (req.method === "GET") {
584
+ const project = supervisor.projects.get(id)
585
+ if (!project) return json(res, 404, { error: "unknown project" })
586
+ return json(res, 200, {
587
+ ...project,
588
+ sessions: supervisor.projects.sessions(id).map((s) => s.snapshot()),
589
+ tasks: supervisor.projects.tasks(id),
590
+ })
591
+ }
592
+ if (req.method === "POST" && projectPath[3] === "chat") {
593
+ const body = await readJson(req)
594
+ const room = supervisor.projects.room(id)
595
+ await supervisor.rooms.post(room, null, body.message)
596
+ broadcast()
597
+ return json(res, 200, { ok: true, roomId: room.id })
598
+ }
599
+ if (req.method === "DELETE") {
600
+ supervisor.projects.remove(id)
601
+ await supervisor.registry.save()
602
+ broadcast()
603
+ return json(res, 200, { ok: true })
604
+ }
605
+ }
606
+
607
+ const meta = /^\/api\/sessions\/([^/]+)\/(group|release|tools|mcp)$/.exec(path)
608
+ if (req.method === "POST" && meta) {
609
+ const id = decodeURIComponent(meta[1])
610
+ const session = supervisor.store.get(id)
611
+ const body = await readJson(req).catch(() => ({}))
612
+ if (meta[2] === "group") await supervisor.setProject(id, body.project ?? body.group)
613
+ if (meta[2] === "release") await supervisor.release(id)
614
+ if (meta[2] === "tools") {
615
+ if (!session) return json(res, 404, { error: "unknown session" })
616
+ await supervisor.setTool(session, body.name, body.enabled)
617
+ }
618
+ if (meta[2] === "mcp") {
619
+ if (!session) return json(res, 404, { error: "unknown session" })
620
+ await supervisor.setMcp(session, body.name, body.connected)
621
+ }
622
+ broadcast()
623
+ return json(res, 200, { ok: true })
624
+ }
625
+
626
+ // --- rooms ---------------------------------------------------------
627
+ if (path === "/api/rooms" && req.method === "POST") {
628
+ const body = await readJson(req)
629
+ const members = (body.members ?? []).map((id) => supervisor.store.get(id)).filter(Boolean)
630
+ const room = supervisor.rooms.create({ name: body.name, topic: body.topic, members, mode: body.mode })
631
+ return json(res, 201, { id: room.id, name: room.name })
632
+ }
633
+
634
+ const room = /^\/api\/rooms\/([^/]+)\/(post|members|mute)$/.exec(path)
635
+ if (req.method === "POST" && room) {
636
+ const target = supervisor.rooms.find(decodeURIComponent(room[1]))
637
+ if (!target) return json(res, 404, { error: "unknown room" })
638
+ const body = await readJson(req)
639
+ if (room[2] === "post") await supervisor.rooms.post(target, null, body.message)
640
+ if (room[2] === "mute") supervisor.rooms.setMuted(target, !!body.muted)
641
+ if (room[2] === "members") {
642
+ const s = supervisor.store.get(body.sessionId)
643
+ if (!s) return json(res, 404, { error: "unknown session" })
644
+ if (body.remove) supervisor.rooms.leave(target, s)
645
+ else {
646
+ if (s.policy.rooms === "off") await supervisor.setPolicy(s.id, { rooms: "member" })
647
+ supervisor.rooms.join(target, s)
648
+ }
649
+ }
650
+ broadcast()
651
+ return json(res, 200, { ok: true })
652
+ }
653
+
654
+ if (req.method === "DELETE" && /^\/api\/rooms\/[^/]+$/.test(path)) {
655
+ const target = supervisor.rooms.find(decodeURIComponent(path.slice(11)))
656
+ if (target) supervisor.rooms.rooms.delete(target.id)
657
+ broadcast()
658
+ return json(res, 200, { ok: true })
659
+ }
660
+
661
+ if (req.method === "POST" && path === "/api/channels/resume") {
662
+ const body = await readJson(req)
663
+ supervisor.resumeChannel(body.a, body.b)
664
+ return json(res, 200, { ok: true })
665
+ }
666
+
667
+ const policy = /^\/api\/sessions\/([^/]+)\/policy$/.exec(path)
668
+ if (req.method === "POST" && policy) {
669
+ const body = await readJson(req)
670
+ const out = await supervisor.setPolicy(decodeURIComponent(policy[1]), body)
671
+ return json(res, 200, out)
672
+ }
673
+
674
+ const action = /^\/api\/sessions\/([^/]+)\/(abort|send|compact|permission|question)$/.exec(path)
675
+ if (req.method === "POST" && action) {
676
+ const id = decodeURIComponent(action[1])
677
+ const body = await readJson(req).catch(() => ({}))
678
+ if (action[2] === "abort") await supervisor.abort(id)
679
+ if (action[2] === "send") {
680
+ if (!body.text?.trim()) return json(res, 400, { error: "text is required" })
681
+ await supervisor.send(id, body.text.trim())
682
+ }
683
+ if (action[2] === "compact") await supervisor.client.compact(id)
684
+ if (action[2] === "permission") {
685
+ await supervisor.client.replyPermission(id, body.requestId, body.decision ?? "once")
686
+ const s = supervisor.store.get(id)
687
+ if (s) { s.permissionList = (s.permissionList ?? []).filter((p) => p.id !== body.requestId); s.pendingPermissions = s.permissionList.length; if (!s.pendingPermissions && !(s.questionList ?? []).length) s.setStatus("busy") }
688
+ supervisor.store.note(`${body.decision === "reject" ? "denied" : "allowed"} @${s?.handle ?? "bot"} ${body.decision === "always" ? "(always) " : ""}${body.what ?? ""}`.trim(), id)
689
+ supervisor.dirty.add(id)
690
+ }
691
+ if (action[2] === "question") {
692
+ if (body.reject) await supervisor.client.rejectQuestion(id, body.requestId)
693
+ else await supervisor.client.replyQuestion(id, body.requestId, body.answers ?? [])
694
+ const s = supervisor.store.get(id)
695
+ if (s) { s.questionList = (s.questionList ?? []).filter((q) => q.id !== body.requestId); if (!(s.permissionList ?? []).length && !s.questionList.length) s.setStatus("busy") }
696
+ supervisor.dirty.add(id)
697
+ }
698
+ broadcast()
699
+ return json(res, 200, { ok: true })
700
+ }
701
+
702
+ if (req.method === "DELETE" && detail) {
703
+ await supervisor.remove(decodeURIComponent(detail[1]), {
704
+ worktree: url.searchParams.get("worktree") === "1",
705
+ force: url.searchParams.get("force") === "1",
706
+ })
707
+ return json(res, 200, { ok: true })
708
+ }
709
+
710
+ json(res, 404, { error: "not found" })
711
+ } catch (err) {
712
+ json(res, 500, { error: err.message })
713
+ }
714
+ })
715
+
716
+ // Websocket clients come in through the upgrade handshake rather than a route.
717
+ server.on("upgrade", (req, socket) => {
718
+ const { pathname } = new URL(req.url, `http://${req.headers.host}`)
719
+ if (pathname !== "/api/socket" || !isWebSocketUpgrade(req)) return socket.destroy()
720
+ const conn = accept(req, socket, {
721
+ onMessage: (text, c) => {
722
+ if (text === "resync") return push(c)
723
+ // "watch:<id>" / "unwatch:<id>": live transcript for an open session.
724
+ const m = /^(watch|unwatch):(.+)$/.exec(text)
725
+ if (!m) return
726
+ c.watch ??= new Set()
727
+ if (m[1] === "watch") {
728
+ c.watch.add(m[2])
729
+ supervisor.watchStarted(m[2]).catch(() => {})
730
+ } else c.watch.delete(m[2])
731
+ },
732
+ onClose: (c) => sockets.delete(c),
733
+ })
734
+ sockets.add(conn)
735
+ push(conn)
736
+ })
737
+
738
+ supervisor.onChange = broadcast
739
+ // Live frames go only to the sockets looking at that session, unbatched:
740
+ // the point is to see the text as it is written.
741
+ supervisor.isWatched = (id) => {
742
+ for (const c of sockets) if (c.watch?.has(id)) return true
743
+ return false
744
+ }
745
+ supervisor.live = (id, frame) => {
746
+ const text = JSON.stringify(frame)
747
+ for (const c of sockets) if (c.watch?.has(id)) c.send(text)
748
+ }
749
+ server.listen(port, host)
750
+ return { server, url: `http://${host}:${port}`, broadcast, sockets, clients }
751
+ }
752
+
753
+ function send(res, status, body, type) {
754
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" })
755
+ res.end(body)
756
+ }
757
+ function json(res, status, obj) {
758
+ send(res, status, JSON.stringify(obj), "application/json")
759
+ }
760
+ async function readJson(req) {
761
+ const chunks = []
762
+ for await (const c of req) chunks.push(c)
763
+ if (chunks.length === 0) return {}
764
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"))
765
+ }