@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/ws.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // A small RFC 6455 server, text frames only, no dependencies.
2
+ //
3
+ // The dashboard already received pushes over SSE rather than polling, but a
4
+ // websocket is cheaper per message (2–10 bytes of framing against SSE's
5
+ // "data: " plus base64-free UTF-8 reparse), survives proxies that buffer
6
+ // event-streams, and gives the client a way to talk back — used here for
7
+ // nothing more than pings and a "send me everything again" resync.
8
+ //
9
+ // Actions stay on plain HTTP. They are one-shot, they want status codes and
10
+ // retries, and multiplexing them over a socket would mean inventing request
11
+ // ids and error plumbing that fetch already has.
12
+
13
+ import { createHash, randomBytes } from "node:crypto"
14
+
15
+ const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
16
+
17
+ export function isWebSocketUpgrade(req) {
18
+ return (
19
+ req.headers.upgrade?.toLowerCase() === "websocket" &&
20
+ /\bupgrade\b/i.test(req.headers.connection ?? "") &&
21
+ !!req.headers["sec-websocket-key"]
22
+ )
23
+ }
24
+
25
+ export function accept(req, socket, { onMessage = () => {}, onClose = () => {} } = {}) {
26
+ const key = req.headers["sec-websocket-key"]
27
+ const digest = createHash("sha1").update(key + MAGIC).digest("base64")
28
+ socket.write(
29
+ "HTTP/1.1 101 Switching Protocols\r\n" +
30
+ "Upgrade: websocket\r\n" +
31
+ "Connection: Upgrade\r\n" +
32
+ `Sec-WebSocket-Accept: ${digest}\r\n\r\n`,
33
+ )
34
+ socket.setNoDelay(true)
35
+
36
+ const conn = new Connection(socket)
37
+ let buffer = Buffer.alloc(0)
38
+
39
+ socket.on("data", (chunk) => {
40
+ buffer = Buffer.concat([buffer, chunk])
41
+ while (true) {
42
+ const frame = decode(buffer)
43
+ if (!frame) break
44
+ buffer = buffer.subarray(frame.size)
45
+ if (frame.opcode === 0x8) return conn.close()
46
+ if (frame.opcode === 0x9) { socket.write(encode(frame.payload, 0xa)); continue }
47
+ if (frame.opcode === 0xa) { conn.alive = true; continue }
48
+ if (frame.opcode === 0x1) onMessage(frame.payload.toString("utf8"), conn)
49
+ }
50
+ })
51
+ socket.on("error", () => conn.close())
52
+ socket.on("close", () => { conn.open = false; onClose(conn) })
53
+ return conn
54
+ }
55
+
56
+ class Connection {
57
+ constructor(socket) {
58
+ this.socket = socket
59
+ this.open = true
60
+ this.alive = true
61
+ this.id = randomBytes(6).toString("hex")
62
+ // A half-open socket looks identical to a quiet one until you ping it.
63
+ this.heartbeat = setInterval(() => {
64
+ if (!this.open) return
65
+ if (!this.alive) return this.close()
66
+ this.alive = false
67
+ try { this.socket.write(encode(Buffer.alloc(0), 0x9)) } catch { this.close() }
68
+ }, 20000)
69
+ }
70
+ send(text) {
71
+ if (!this.open) return false
72
+ try {
73
+ this.socket.write(encode(Buffer.from(text, "utf8"), 0x1))
74
+ return true
75
+ } catch {
76
+ this.close()
77
+ return false
78
+ }
79
+ }
80
+ close() {
81
+ if (!this.open) return
82
+ this.open = false
83
+ clearInterval(this.heartbeat)
84
+ try { this.socket.end(encode(Buffer.alloc(0), 0x8)) } catch {}
85
+ try { this.socket.destroy() } catch {}
86
+ }
87
+ }
88
+
89
+ function encode(payload, opcode = 0x1) {
90
+ const len = payload.length
91
+ let header
92
+ if (len < 126) {
93
+ header = Buffer.alloc(2)
94
+ header[1] = len
95
+ } else if (len < 65536) {
96
+ header = Buffer.alloc(4)
97
+ header[1] = 126
98
+ header.writeUInt16BE(len, 2)
99
+ } else {
100
+ header = Buffer.alloc(10)
101
+ header[1] = 127
102
+ header.writeBigUInt64BE(BigInt(len), 2)
103
+ }
104
+ header[0] = 0x80 | opcode // FIN + opcode; server frames are never masked
105
+ return Buffer.concat([header, payload])
106
+ }
107
+
108
+ /** Returns null until a whole frame is buffered. */
109
+ function decode(buf) {
110
+ if (buf.length < 2) return null
111
+ const opcode = buf[0] & 0x0f
112
+ const masked = (buf[1] & 0x80) !== 0
113
+ let len = buf[1] & 0x7f
114
+ let offset = 2
115
+ if (len === 126) {
116
+ if (buf.length < 4) return null
117
+ len = buf.readUInt16BE(2)
118
+ offset = 4
119
+ } else if (len === 127) {
120
+ if (buf.length < 10) return null
121
+ len = Number(buf.readBigUInt64BE(2))
122
+ offset = 10
123
+ }
124
+ const maskKey = masked ? buf.subarray(offset, offset + 4) : null
125
+ if (masked) offset += 4
126
+ if (buf.length < offset + len) return null
127
+ const payload = Buffer.from(buf.subarray(offset, offset + len))
128
+ if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4]
129
+ return { opcode, payload, size: offset + len }
130
+ }
package/src/yaml.mjs ADDED
@@ -0,0 +1,266 @@
1
+ // A deliberately small YAML subset — enough for personas and pipelines, and
2
+ // nothing more. Supported: comments, nested maps by indentation, "- " lists
3
+ // (of scalars or maps), quoted and bare scalars, numbers, booleans, null, and
4
+ // the "|" / ">" block scalars that prompts need.
5
+ //
6
+ // Not supported: anchors, aliases, multiple documents, flow maps, tags. If a
7
+ // file uses them, parsing throws with a line number rather than silently
8
+ // producing something odd — these files are edited by hand, so a loud failure
9
+ // beats a quiet misreading.
10
+
11
+ export function parse(text) {
12
+ const lines = String(text).split(/\r?\n/)
13
+ const root = {}
14
+ const stack = [{ indent: -1, value: root }]
15
+ let i = 0
16
+
17
+ const fail = (msg) => {
18
+ throw new Error(`${msg} (line ${i + 1}: ${lines[i]?.trim()})`)
19
+ }
20
+
21
+ while (i < lines.length) {
22
+ const raw = lines[i]
23
+ const line = stripComment(raw)
24
+ if (!line.trim()) { i++; continue }
25
+ if (/^\s*(---|\.\.\.)\s*$/.test(line)) { i++; continue }
26
+ if (/[&*!]\S/.test(line.trim()) && /^\s*\w+:\s*[&*]/.test(line)) fail("anchors and aliases are not supported")
27
+
28
+ const indent = line.match(/^ */)[0].length
29
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
30
+ const parent = stack[stack.length - 1].value
31
+ const body = line.slice(indent)
32
+
33
+ // list item
34
+ if (body.startsWith("- ") || body === "-") {
35
+ if (!Array.isArray(parent)) fail("list item outside a list")
36
+ const rest = body.slice(2)
37
+ if (!rest.trim()) {
38
+ const obj = {}
39
+ parent.push(obj)
40
+ stack.push({ indent, value: obj })
41
+ i++
42
+ continue
43
+ }
44
+ const kv = splitKey(rest)
45
+ if (kv) {
46
+ const obj = {}
47
+ parent.push(obj)
48
+ stack.push({ indent, value: obj })
49
+ // re-handle the inline "key: value" at a deeper virtual indent
50
+ const [key, value] = kv
51
+ const block = readBlock(value, indent + 2)
52
+ if (block !== undefined) obj[key] = block
53
+ else if (value === "") {
54
+ const child = nextIsList(indent + 2) ? [] : {}
55
+ obj[key] = child
56
+ stack.push({ indent: indent + 1, value: child })
57
+ } else obj[key] = scalar(value)
58
+ i++
59
+ continue
60
+ }
61
+ parent.push(scalar(rest))
62
+ i++
63
+ continue
64
+ }
65
+
66
+ // key: value
67
+ const kv = splitKey(body)
68
+ if (!kv) fail("expected 'key: value' or '- item'")
69
+ const [key, value] = kv
70
+ if (Array.isArray(parent)) fail("mapping inside a list needs '- ' on its first key")
71
+
72
+ const block = readBlock(value, indent)
73
+ if (block !== undefined) {
74
+ parent[key] = block
75
+ continue // readBlock advanced i
76
+ }
77
+ if (value === "") {
78
+ const child = nextIsList(indent) ? [] : {}
79
+ parent[key] = child
80
+ stack.push({ indent, value: child })
81
+ i++
82
+ continue
83
+ }
84
+ parent[key] = scalar(value)
85
+ i++
86
+ }
87
+ return root
88
+
89
+ /** Look ahead past blanks for the next meaningful line's shape. */
90
+ function nextIsList(indent) {
91
+ for (let j = i + 1; j < lines.length; j++) {
92
+ const l = stripComment(lines[j])
93
+ if (!l.trim()) continue
94
+ const ind = l.match(/^ */)[0].length
95
+ if (ind <= indent) return false
96
+ return l.slice(ind).startsWith("- ") || l.slice(ind) === "-"
97
+ }
98
+ return false
99
+ }
100
+
101
+ /** "|" and ">" block scalars; returns undefined when `value` isn't one. */
102
+ function readBlock(value, indent) {
103
+ const m = /^([|>])([-+]?)$/.exec(value.trim())
104
+ if (!m) return undefined
105
+ const fold = m[1] === ">"
106
+ const chomp = m[2]
107
+ const out = []
108
+ let j = i + 1
109
+ let blockIndent = null
110
+ for (; j < lines.length; j++) {
111
+ const l = lines[j]
112
+ if (!l.trim()) { out.push(""); continue }
113
+ const ind = l.match(/^ */)[0].length
114
+ if (ind <= indent) break
115
+ if (blockIndent === null) blockIndent = ind
116
+ out.push(l.slice(blockIndent))
117
+ }
118
+ i = j
119
+ while (out.length && out[out.length - 1] === "") out.pop()
120
+ let text = fold ? foldLines(out) : out.join("\n")
121
+ if (chomp !== "-") text += "\n"
122
+ if (chomp === "-") text = text.replace(/\n+$/, "")
123
+ return text
124
+ }
125
+ }
126
+
127
+ function foldLines(lines) {
128
+ const out = []
129
+ for (const l of lines) {
130
+ if (l === "") out.push("\n")
131
+ else if (out.length && !out[out.length - 1].endsWith("\n")) out[out.length - 1] += " " + l
132
+ else out.push(l)
133
+ }
134
+ return out.join("").replace(/\n(?!\n)/g, "\n")
135
+ }
136
+
137
+ function stripComment(line) {
138
+ let out = ""
139
+ let quote = null
140
+ for (let k = 0; k < line.length; k++) {
141
+ const c = line[k]
142
+ if (quote) {
143
+ out += c
144
+ if (c === quote && line[k - 1] !== "\\") quote = null
145
+ continue
146
+ }
147
+ if (c === '"' || c === "'") { quote = c; out += c; continue }
148
+ if (c === "#" && (k === 0 || /\s/.test(line[k - 1]))) break
149
+ out += c
150
+ }
151
+ return out.replace(/\s+$/, "")
152
+ }
153
+
154
+ function splitKey(body) {
155
+ let quote = null
156
+ for (let k = 0; k < body.length; k++) {
157
+ const c = body[k]
158
+ if (quote) { if (c === quote) quote = null; continue }
159
+ if (c === '"' || c === "'") { quote = c; continue }
160
+ if (c === ":" && (k + 1 === body.length || /\s/.test(body[k + 1]))) {
161
+ return [unquote(body.slice(0, k).trim()), body.slice(k + 1).trim()]
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ function unquote(s) {
168
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
169
+ return s.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"')
170
+ }
171
+ return s
172
+ }
173
+
174
+ function scalar(v) {
175
+ const s = v.trim()
176
+ if (s === "" || s === "~" || s === "null") return null
177
+ if (s === "true" || s === "yes") return true
178
+ if (s === "false" || s === "no") return false
179
+ if (/^-?\d+$/.test(s)) return Number(s)
180
+ if (/^-?\d*\.\d+$/.test(s)) return Number(s)
181
+ if (s.startsWith("[") && s.endsWith("]")) {
182
+ const inner = s.slice(1, -1).trim()
183
+ return inner ? splitFlow(inner).map((x) => scalar(x)) : []
184
+ }
185
+ // One-line maps: { usd: 5, per: day }. Not "{{story}}" — that is a template.
186
+ if (s.startsWith("{") && s.endsWith("}") && s[1] !== "{") {
187
+ const inner = s.slice(1, -1).trim()
188
+ if (!inner) return {}
189
+ const pairs = splitFlow(inner).map((x) => splitKey(x.trim()) ?? (/^[\w-]+:$/.test(x.trim()) ? [x.trim().slice(0, -1), ""] : null))
190
+ if (pairs.every(Boolean)) return Object.fromEntries(pairs.map(([k, v]) => [k, scalar(v)]))
191
+ }
192
+ return unquote(s)
193
+ }
194
+
195
+ /** Split on top-level commas, leaving nested [], {} and quotes alone. */
196
+ function splitFlow(s) {
197
+ const out = []
198
+ let depth = 0, quote = null, start = 0
199
+ for (let k = 0; k < s.length; k++) {
200
+ const c = s[k]
201
+ if (quote) { if (c === quote && s[k - 1] !== "\\") quote = null; continue }
202
+ if (c === '"' || c === "'") quote = c
203
+ else if (c === "[" || c === "{") depth++
204
+ else if (c === "]" || c === "}") depth--
205
+ else if (c === "," && depth === 0) { out.push(s.slice(start, k)); start = k + 1 }
206
+ }
207
+ out.push(s.slice(start))
208
+ return out.map((x) => x.trim()).filter((x) => x !== "")
209
+ }
210
+
211
+ // --------------------------------------------------------------------------
212
+
213
+ export function stringify(value, indent = 0) {
214
+ const pad = " ".repeat(indent)
215
+ if (value === null || value === undefined) return "null"
216
+ if (Array.isArray(value)) {
217
+ if (!value.length) return "[]"
218
+ return value
219
+ .map((v) =>
220
+ isPlainObject(v)
221
+ ? listItem(v, indent, pad)
222
+ : `${pad}- ${scalarOut(v)}`,
223
+ )
224
+ .join("\n")
225
+ }
226
+ if (isPlainObject(value)) {
227
+ return Object.entries(value)
228
+ .map(([k, v]) => {
229
+ if (isPlainObject(v)) {
230
+ return Object.keys(v).length ? `${pad}${k}:\n${stringify(v, indent + 2)}` : `${pad}${k}: {}`
231
+ }
232
+ if (Array.isArray(v)) {
233
+ return v.length ? `${pad}${k}:\n${stringify(v, indent + 2)}` : `${pad}${k}: []`
234
+ }
235
+ if (typeof v === "string" && v.includes("\n")) {
236
+ const body = v.replace(/\n$/, "").split("\n").map((l) => " ".repeat(indent + 2) + l).join("\n")
237
+ return `${pad}${k}: |\n${body}`
238
+ }
239
+ return `${pad}${k}: ${scalarOut(v)}`
240
+ })
241
+ .join("\n")
242
+ }
243
+ return `${pad}${scalarOut(value)}`
244
+ }
245
+
246
+ // "- key: value" with the rest of the map under it. The first key must be a
247
+ // scalar, so move one to the front; a map with none uses a bare "-" line.
248
+ function listItem(v, indent, pad) {
249
+ const keys = Object.keys(v)
250
+ const scalar = keys.find((k) => !isPlainObject(v[k]) && !Array.isArray(v[k]) && !(typeof v[k] === "string" && v[k].includes("\n")))
251
+ if (scalar === undefined) return `${pad}-\n${stringify(v, indent + 2)}`
252
+ const ordered = { [scalar]: v[scalar], ...v }
253
+ return `${pad}- ${stringify(ordered, indent + 2).slice(indent + 2)}`
254
+ }
255
+
256
+ const isPlainObject = (v) => v && typeof v === "object" && !Array.isArray(v)
257
+
258
+ function scalarOut(v) {
259
+ if (v === null || v === undefined) return "null"
260
+ if (typeof v === "boolean" || typeof v === "number") return String(v)
261
+ const s = String(v)
262
+ if (s === "" || /^[\s#&*!|>%@`]/.test(s) || /:\s/.test(s) || /^(true|false|null|yes|no|~|-?\d+(\.\d+)?)$/.test(s)) {
263
+ return JSON.stringify(s)
264
+ }
265
+ return s
266
+ }
package/test/board.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // The board is a registry, not a listing: ownership, groups, cost, tools.
2
+ import { spawn as spawnProc } from "node:child_process"
3
+ import { Supervisor } from "../src/supervisor.mjs"
4
+ import { startServer } from "../src/server.mjs"
5
+ import { estimate, normalise, rateFor } from "../src/pricing.mjs"
6
+ import { handleFor, avatarFor, GLYPH } from "../src/identity.mjs"
7
+
8
+ const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
9
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
10
+ let failed = 0
11
+ const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
12
+
13
+ console.log("identities")
14
+ ok(GLYPH.size >= 64, `the creature roster is ${GLYPH.size} strong`)
15
+ ok(new Set(GLYPH.values()).size === GLYPH.size, "every creature has its own glyph — no two share a face")
16
+ const taken = new Set()
17
+ const creatures = new Set()
18
+ for (let i = 0; i < 300; i++) {
19
+ const h = handleFor("ses_test_" + i, taken)
20
+ taken.add(h)
21
+ creatures.add(h.split("-").slice(1).join("-"))
22
+ }
23
+ ok(taken.size === 300, "300 sessions get 300 distinct handles")
24
+ ok(creatures.size > 40, `spread across ${creatures.size} different creatures`)
25
+ const svg = avatarFor("ses_test_1", { handle: [...taken][0] })
26
+ ok(/viewBox="0 0 32 32"/.test(svg) && /<text/.test(svg), "and each renders as a glyph avatar")
27
+ ok(avatarFor("x", { handle: "brisk-otter" }) === avatarFor("x", { handle: "brisk-otter" }), "deterministic across calls")
28
+
29
+ console.log("\npricing")
30
+ ok(normalise("us.anthropic.claude-sonnet-4-20250514-v1:0") === "claude-sonnet-4", "a Bedrock id normalises to its family")
31
+ ok(normalise("anthropic/claude-opus-4-1-20250805") === "claude-opus-4-1", "a provider-prefixed id normalises too")
32
+ ok(rateFor("us.anthropic.claude-sonnet-4-20250514-v1:0")?.input === 3, "and finds a rate")
33
+ const bedrock = estimate({
34
+ model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
35
+ totals: { input: 100_000, output: 20_000, cacheRead: 1_000_000, cacheWrite: 50_000 },
36
+ reported: 0,
37
+ })
38
+ ok(bedrock.usd > 0 && bedrock.estimated, `Bedrock sessions get a price anyway ($${bedrock.usd.toFixed(3)})`)
39
+ ok(Math.abs(bedrock.usd - (0.3 + 0.3 + 0.3 + 0.1875)) < 0.01, "cache reads are a tenth and writes a quarter more")
40
+ ok(estimate({ model: "x", totals: {}, reported: 4.2 }).estimated === false, "a reported cost is used as-is")
41
+ ok(estimate({ model: "some-new-model", totals: { input: 5 } }).unknownModel === "some-new-model", "an unpriced model says so rather than guessing")
42
+ ok(estimate({ model: "gpt-4o", totals: { input: 1e6 }, overrides: { "gpt-4o": { input: 99, output: 1 } } }).usd === 99, "config overrides win")
43
+
44
+ await sleep(600)
45
+ const sup = new Supervisor({
46
+ url: "http://127.0.0.1:4096",
47
+ statePath: "/tmp/botfarm-reg-state-" + Date.now() + ".json",
48
+ registryPath: "/tmp/botfarm-reg-" + Date.now() + ".json",
49
+ mcpBase: "http://127.0.0.1:4795",
50
+ })
51
+ await sup.start()
52
+ const { url } = startServer({ supervisor: sup, port: 4795 })
53
+ const get = async (p) => (await fetch(url + p)).json()
54
+ const post = async (p, body) =>
55
+ (await fetch(url + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) })).json()
56
+
57
+ console.log("\nan empty board")
58
+ let state = await get("/api/state")
59
+ ok(state.sessions.length === 0, "sessions opencode knows about do not appear on their own")
60
+ ok(state.unmanaged === 2, `they are offered instead (${state.unmanaged} available)`)
61
+ let loose = await get("/api/unmanaged")
62
+ ok(loose.sessions.length === 2 && loose.sessions[0].title, "the adoption list carries enough to choose by")
63
+ ok((await get("/api/unmanaged?q=flaky")).sessions.length === 1, "the list is searchable")
64
+
65
+ console.log("\nadopting")
66
+ await post("/api/adopt", { ids: [loose.sessions[0].id], project: "auth-work" })
67
+ await sleep(1400)
68
+ state = await get("/api/state")
69
+ ok(state.sessions.length === 1, "only the adopted session is on the board")
70
+ ok(state.unmanaged === 1, "the rest stay out of the way")
71
+ ok(state.projects.some((p) => p.id === "auth-work"), "the project is created and counted")
72
+ ok(!!state.sessions[0].handle, `it gets an identity (@${state.sessions[0].handle})`)
73
+
74
+ console.log("\ncost on a card")
75
+ const adopted = state.sessions[0]
76
+ ok(adopted.cost && typeof adopted.cost.usd === "number", "every card carries a cost")
77
+ const detail = await get(`/api/sessions/${adopted.id}`)
78
+ ok(detail.cost.estimated === true, "which is marked as an estimate when the provider gave us none")
79
+
80
+ console.log("\nlast message on the card")
81
+ ok(typeof adopted.lastText === "string" && adopted.lastText.length > 0, `the card shows what you would be continuing ("${adopted.lastText.slice(0, 40)}…")`)
82
+
83
+ console.log("\ntools")
84
+ ok(detail.tools.servers.length === 2, `MCP servers are listed (${detail.tools.servers.map((s) => s.name).join(", ")})`)
85
+ ok(detail.tools.servers.find((s) => s.name === "playwright")?.connected === false, "with their connection state")
86
+ ok(detail.tools.builtin.length > 0 && detail.tools.builtin.every((t) => t.enabled), "built-in tools start enabled")
87
+ await post(`/api/sessions/${adopted.id}/tools`, { name: "bash", enabled: false })
88
+ const after = await get(`/api/sessions/${adopted.id}`)
89
+ ok(after.tools.builtin.find((t) => t.name === "bash")?.enabled === false, "a tool can be switched off")
90
+ ok((await post(`/api/sessions/${adopted.id}/mcp`, { name: "playwright", connected: true })).ok, "an MCP server can be connected")
91
+
92
+ console.log("\ngroups and release")
93
+ await post(`/api/sessions/${adopted.id}/group`, { project: "default" })
94
+ state = await get("/api/state")
95
+ ok(state.sessions[0].project === "default", "a session can be moved between projects")
96
+ await post(`/api/sessions/${adopted.id}/release`, {})
97
+ state = await get("/api/state")
98
+ ok(state.sessions.length === 0 && state.unmanaged === 2, "releasing puts it back in the pool without deleting it")
99
+
100
+ console.log("\nrestart")
101
+ await sup.registry.load()
102
+ const reopened = sup.projects.get("auth-work")
103
+ ok(!!reopened && reopened.name === "auth-work", "the project itself outlives the sessions in it")
104
+
105
+ await sup.stop()
106
+ mock.kill()
107
+ console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
108
+ process.exit(failed ? 1 : 0)