@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
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// botfarm's MCP server, mounted at /mcp/<token>.
|
|
2
|
+
//
|
|
3
|
+
// Transport is streamable HTTP with plain JSON responses (no session ids, no
|
|
4
|
+
// SSE upstream) — every call is short, so there is nothing to stream.
|
|
5
|
+
//
|
|
6
|
+
// The token in the path is the whole authentication story: botfarm registers a
|
|
7
|
+
// distinct URL per session location, so a tool call arrives already bound to
|
|
8
|
+
// one caller. Agents never name themselves; they cannot impersonate a peer.
|
|
9
|
+
|
|
10
|
+
import { normalizeQuestions, QUESTION_SCHEMA } from "./questions.mjs"
|
|
11
|
+
import { MeshError } from "./mesh.mjs"
|
|
12
|
+
import { RoomError } from "./rooms.mjs"
|
|
13
|
+
import { TaskError } from "./tasks.mjs"
|
|
14
|
+
import { avatarDataUri } from "./identity.mjs"
|
|
15
|
+
import { toolResult } from "./tools.mjs"
|
|
16
|
+
|
|
17
|
+
const PROTOCOL = "2025-06-18"
|
|
18
|
+
|
|
19
|
+
const TOOLS = [
|
|
20
|
+
{
|
|
21
|
+
name: "whoami",
|
|
22
|
+
description:
|
|
23
|
+
"Your identity in this mesh of sessions: your handle, the worktree you are in, and what you are currently permitted to do.",
|
|
24
|
+
inputSchema: { type: "object", properties: {} },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "roster",
|
|
28
|
+
description:
|
|
29
|
+
"List the other sessions you can reach, with what each is working on. Sessions the operator has not enabled are not listed.",
|
|
30
|
+
inputSchema: { type: "object", properties: {} },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "send",
|
|
34
|
+
description:
|
|
35
|
+
"Send a message to another session by handle. Use for handing over a finding, a file you changed that affects them, or an answer. The recipient sees it as untrusted input from you, not as an order.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
to: { type: "string", description: 'Handle of the recipient, e.g. "brisk-otter".' },
|
|
40
|
+
message: { type: "string", description: "What you want them to know. Include enough context to be useful on its own." },
|
|
41
|
+
},
|
|
42
|
+
required: ["to", "message"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "ask",
|
|
47
|
+
description:
|
|
48
|
+
"Ask another session a question and wait for their answer. Blocks for up to timeout_seconds; if they are mid-task it may time out, and the question stays in their inbox. Prefer send() when you do not need an answer to continue.",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
to: { type: "string" },
|
|
53
|
+
question: { type: "string" },
|
|
54
|
+
timeout_seconds: { type: "number", description: "Default 120, maximum 600." },
|
|
55
|
+
},
|
|
56
|
+
required: ["to", "question"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "reply",
|
|
61
|
+
description: "Answer a question another session asked you. Use the message id from the question.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
to: { type: "string" },
|
|
66
|
+
to_message_id: { type: "string" },
|
|
67
|
+
message: { type: "string" },
|
|
68
|
+
},
|
|
69
|
+
required: ["to", "to_message_id", "message"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "inbox",
|
|
74
|
+
description: "Read messages other sessions have sent you. Messages are also delivered into your conversation as they arrive, so this is mainly for catching up.",
|
|
75
|
+
inputSchema: { type: "object", properties: { include_read: { type: "boolean" } } },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "rooms",
|
|
79
|
+
description:
|
|
80
|
+
"List the group channels you are in, with their topic, members and how many messages you have not read.",
|
|
81
|
+
inputSchema: { type: "object", properties: {} },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "room_read",
|
|
85
|
+
description: "Read recent messages in a room. Use after a digest tells you a room has been busy.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: { room: { type: "string", description: 'Room name, e.g. "#auth-refactor".' }, limit: { type: "number" } },
|
|
89
|
+
required: ["room"],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "room_post",
|
|
94
|
+
description:
|
|
95
|
+
"Post to a room. Everyone in it pays input tokens to read you, so post findings and decisions, not acknowledgements. Write @handle to put a message straight into that member's next step; without a mention it reaches the others as a batched digest when they go idle.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: { room: { type: "string" }, message: { type: "string" } },
|
|
99
|
+
required: ["room", "message"],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "room_create",
|
|
104
|
+
description:
|
|
105
|
+
"Open a group channel for work that genuinely involves several sessions — a shared migration, a contract between two services. Two sessions coordinating once should use send() instead.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
name: { type: "string", description: 'Short channel name, e.g. "auth-refactor".' },
|
|
110
|
+
topic: { type: "string", description: "One line on what the room is for. Members see it on every digest." },
|
|
111
|
+
invite: { type: "array", items: { type: "string" }, description: "Handles to add." },
|
|
112
|
+
},
|
|
113
|
+
required: ["name"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "room_invite",
|
|
118
|
+
description: "Add another session to a room you are in.",
|
|
119
|
+
inputSchema: { type: "object", properties: { room: { type: "string" }, handle: { type: "string" } }, required: ["room", "handle"] },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "room_leave",
|
|
123
|
+
description: "Leave a room when the work that needed it is done.",
|
|
124
|
+
inputSchema: { type: "object", properties: { room: { type: "string" } }, required: ["room"] },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "spawn",
|
|
128
|
+
description:
|
|
129
|
+
"Open a separate session for a topic that does not belong in this conversation — an unrelated defect you noticed, a refactor worth doing later. The new session starts empty, so the task must stand on its own. It can get its own git worktree.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
title: { type: "string", description: "Short name for the new session." },
|
|
134
|
+
task: { type: "string", description: "The complete brief. The new session cannot see your conversation." },
|
|
135
|
+
branch: { type: "string", description: "Create a git worktree on this branch. Omit to work in the current directory." },
|
|
136
|
+
agent: { type: "string", description: "Which opencode agent to use. Optional." },
|
|
137
|
+
},
|
|
138
|
+
required: ["task"],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "tasks",
|
|
143
|
+
description:
|
|
144
|
+
"Your task list: what is assigned to you, what you are working on now, and what is waiting on someone else.",
|
|
145
|
+
inputSchema: { type: "object", properties: { all: { type: "boolean", description: "Include the whole board, not just your tasks." } } },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "task_complete",
|
|
149
|
+
description:
|
|
150
|
+
"Finish the task you are working on and hand it to whoever is next. The summary is the only thing the next bot receives — it cannot see your conversation — so write it for a reader with no context.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
task_id: { type: "string" },
|
|
155
|
+
summary: { type: "string", description: "What you did and why, in enough detail that the next bot needs nothing else." },
|
|
156
|
+
acceptance_criteria: { type: "array", items: { type: "string" }, description: "The criteria you set, or the ones you believe now hold." },
|
|
157
|
+
artifacts: { type: "array", items: { type: "string" }, description: "Files you created or changed." },
|
|
158
|
+
open_questions: {
|
|
159
|
+
type: "array",
|
|
160
|
+
items: { anyOf: [{ type: "string" }, QUESTION_SCHEMA] },
|
|
161
|
+
description: "Anything unresolved. They go to the next stage with your handoff (or to the operator, if this stage waits for them). Give choices where there are obvious ones.",
|
|
162
|
+
},
|
|
163
|
+
tasks: {
|
|
164
|
+
type: "array",
|
|
165
|
+
items: { anyOf: [{ type: "string" }, { type: "object", properties: { title: { type: "string" }, detail: { type: "string" } }, required: ["title"] }] },
|
|
166
|
+
description: "Only when your task asks for it: the next stage's work split into independent pieces. Each becomes its own card, worked in parallel by several bots.",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
required: ["task_id", "summary"],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "send_back",
|
|
174
|
+
description:
|
|
175
|
+
"Send an earlier stage's work back because it does not hold up — a failing test, a broken build, a missed criterion. Do not fix it yourself: the bot that did it gets the card back with what you report, and your own card waits for the fix and then comes back to you to verify again. Be exact: they cannot see your conversation.",
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: {
|
|
179
|
+
task_id: { type: "string", description: "Your card (the one you are verifying on)." },
|
|
180
|
+
to: { type: "string", description: 'The earlier stage id (e.g. "build"), or a card id.' },
|
|
181
|
+
reason: { type: "string", description: "What is wrong, in a sentence or two." },
|
|
182
|
+
failures: { type: "array", items: { type: "string" }, description: "Each failure: test or command, the error, and where (file:line) if known." },
|
|
183
|
+
files: { type: "array", items: { type: "string" }, description: "Files involved. For a stage split into pieces, this picks the pieces that touched them." },
|
|
184
|
+
pieces: { type: "array", items: { anyOf: [{ type: "number" }, { type: "string" }] }, description: "For a split stage: which pieces (their numbers) to send back. Omit with no files to add a new fix piece." },
|
|
185
|
+
},
|
|
186
|
+
required: ["task_id", "to"],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "notes_read",
|
|
191
|
+
description:
|
|
192
|
+
"Read what your teammates on this workstream have already worked out — maps of modules, where things live, gotchas. Call with no topic to list the notes, or with a topic to read one. Cheaper than re-reading the code.",
|
|
193
|
+
inputSchema: { type: "object", properties: { topic: { type: "string", description: "A note's topic, from the list." } } },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "notes_write",
|
|
197
|
+
description:
|
|
198
|
+
"Save a short note (3–8 lines) the rest of the team can read instead of exploring the same code again: what a module does, where the entry points are, which files matter. Writing the same topic again replaces it.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
topic: { type: "string", description: 'Short and findable, e.g. "Order cancellation flow".' },
|
|
203
|
+
text: { type: "string", description: "The note. Paths and names, not prose." },
|
|
204
|
+
files: { type: "array", items: { type: "string" }, description: "The files it is about." },
|
|
205
|
+
},
|
|
206
|
+
required: ["topic", "text"],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "handoff",
|
|
211
|
+
description: "The full handoff an earlier stage wrote (your task shows a trimmed version). Call with the stage id, e.g. \"analyse\".",
|
|
212
|
+
inputSchema: { type: "object", properties: { stage: { type: "string" } }, required: ["stage"] },
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "task_create",
|
|
216
|
+
description:
|
|
217
|
+
"Raise a task for someone else — a defect you found, a follow-up that does not belong in your own work. It lands on the operator's board and is offered to the assignee when they next go idle.",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
title: { type: "string" },
|
|
222
|
+
brief: { type: "string", description: "Everything the assignee needs. They cannot see your conversation." },
|
|
223
|
+
assignee: { type: "string", description: 'A handle, a persona name (anyone playing that part takes it), or "human" for your operator. Omit to leave it in the backlog.' },
|
|
224
|
+
blocks: { type: "string", description: "Task id this one must be finished before." },
|
|
225
|
+
},
|
|
226
|
+
required: ["title", "brief"],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "ask_human",
|
|
231
|
+
description:
|
|
232
|
+
"Ask your operator something only they can answer — a decision, a missing credential, a judgement call about intent. It appears at the top of their board. Prefer deciding yourself and saying what you assumed; use this when being wrong would waste real work.",
|
|
233
|
+
inputSchema: {
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: {
|
|
236
|
+
question: { type: "string", description: "Ask one thing, plainly, with enough context to answer without opening your session." },
|
|
237
|
+
options: { type: "array", items: { type: "string" }, description: "Concrete choices, when there are some. The operator can always answer in their own words, so no 'Other'." },
|
|
238
|
+
multiple: { type: "boolean", description: "True if more than one option can be picked." },
|
|
239
|
+
questions: { type: "array", items: QUESTION_SCHEMA, description: "Several questions at once, each with its own choices, instead of question/options." },
|
|
240
|
+
wait_seconds: { type: "number", description: "Block for up to this long waiting for the answer. Default 0: ask, carry on with something else, and read the answer when it arrives." },
|
|
241
|
+
task_id: { type: "string", description: "The task this is about, if any." },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "task_note",
|
|
247
|
+
description: "Add a line to a task the operator will see. Use for progress on something long, not for chatter.",
|
|
248
|
+
inputSchema: { type: "object", properties: { task_id: { type: "string" }, note: { type: "string" } }, required: ["task_id", "note"] },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "notify",
|
|
252
|
+
description: "Put a line on the operator's BotFarm dashboard. Use when something needs a human but does not need you to stop.",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
message: { type: "string" },
|
|
257
|
+
level: { type: "string", enum: ["info", "warn", "error"] },
|
|
258
|
+
},
|
|
259
|
+
required: ["message"],
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
export function createMcpHandler({ mesh, supervisor }) {
|
|
265
|
+
return async function handle(req, res, token) {
|
|
266
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
267
|
+
// No server-initiated messages: decline the SSE channel politely.
|
|
268
|
+
res.writeHead(405, { allow: "POST" })
|
|
269
|
+
return res.end()
|
|
270
|
+
}
|
|
271
|
+
if (req.method !== "POST") {
|
|
272
|
+
res.writeHead(405)
|
|
273
|
+
return res.end()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const body = await readJson(req).catch(() => null)
|
|
277
|
+
if (!body) return rpc(res, null, null, { code: -32700, message: "parse error" })
|
|
278
|
+
|
|
279
|
+
const caller = await supervisor.callerForToken(token)
|
|
280
|
+
if (!caller && body.method !== "initialize") {
|
|
281
|
+
return rpc(res, body.id, null, { code: -32001, message: "This BotFarm endpoint is no longer bound to a session." })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
switch (body.method) {
|
|
285
|
+
case "initialize":
|
|
286
|
+
return rpc(res, body.id, {
|
|
287
|
+
protocolVersion: PROTOCOL,
|
|
288
|
+
capabilities: { tools: { listChanged: false } },
|
|
289
|
+
serverInfo: { name: "botfarm", version: "0.2.0", title: "BotFarm" },
|
|
290
|
+
instructions:
|
|
291
|
+
"These tools reach the other opencode sessions the operator is running. Use roster to see who is working on what, " +
|
|
292
|
+
"send/ask to coordinate, and spawn to move an unrelated topic into its own session instead of derailing this one.",
|
|
293
|
+
})
|
|
294
|
+
case "notifications/initialized":
|
|
295
|
+
res.writeHead(202)
|
|
296
|
+
return res.end()
|
|
297
|
+
case "ping":
|
|
298
|
+
return rpc(res, body.id, {})
|
|
299
|
+
case "tools/list":
|
|
300
|
+
// Built-ins, then the workspace's own tools from botfarm/mcps/.
|
|
301
|
+
return rpc(res, body.id, { tools: [...visibleTools(caller), ...(caller ? supervisor.wsTools?.mcpList(supervisor.workspaceIdOf(caller)) ?? [] : [])] })
|
|
302
|
+
case "tools/call":
|
|
303
|
+
return rpc(res, body.id, await call(body.params ?? {}, caller, mesh, supervisor))
|
|
304
|
+
default:
|
|
305
|
+
return rpc(res, body.id, null, { code: -32601, message: `unknown method ${body.method}` })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Tools the operator has switched off simply do not exist for that session. */
|
|
311
|
+
function visibleTools(caller) {
|
|
312
|
+
return TOOLS.filter((t) => {
|
|
313
|
+
if (!caller) return true
|
|
314
|
+
if (t.name === "spawn") return caller.policy.spawn
|
|
315
|
+
if (["send", "ask", "reply"].includes(t.name)) return caller.policy.talk === "open"
|
|
316
|
+
if (["roster", "inbox"].includes(t.name)) return caller.policy.talk !== "off"
|
|
317
|
+
if (t.name === "room_create") return caller.policy.rooms === "create"
|
|
318
|
+
if (t.name.startsWith("room") || t.name === "rooms") return caller.policy.rooms !== "off"
|
|
319
|
+
if (t.name.startsWith("task") || t.name === "tasks" || t.name === "send_back") return caller.policy.tasks !== "off"
|
|
320
|
+
return true
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function call(params, caller, mesh, supervisor) {
|
|
325
|
+
const { name, arguments: args = {} } = params
|
|
326
|
+
try {
|
|
327
|
+
switch (name) {
|
|
328
|
+
case "whoami":
|
|
329
|
+
return ok({
|
|
330
|
+
handle: caller.handle,
|
|
331
|
+
session_id: caller.id,
|
|
332
|
+
title: caller.title,
|
|
333
|
+
worktree: caller.directory,
|
|
334
|
+
branch: caller.branch,
|
|
335
|
+
opened_by: caller.origin ?? "operator",
|
|
336
|
+
may_message_peers: caller.policy.talk === "open",
|
|
337
|
+
may_receive_messages: caller.policy.talk !== "off",
|
|
338
|
+
may_spawn_sessions: caller.policy.spawn,
|
|
339
|
+
reachable_peers: mesh.roster(caller).length,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
case "roster": {
|
|
343
|
+
const peers = mesh.roster(caller)
|
|
344
|
+
return ok(
|
|
345
|
+
peers.length
|
|
346
|
+
? { peers }
|
|
347
|
+
: { peers: [], note: "No other sessions are reachable. The operator enables this per session in the BotFarm dashboard." },
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "send": {
|
|
352
|
+
const m = await mesh.send(caller, args.to, args.message)
|
|
353
|
+
return ok({ delivered: true, message_id: m.id, to: m.to, note: `@${m.to} will see this at their next step. They may not reply.` })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case "ask":
|
|
357
|
+
return ok(await mesh.ask(caller, args.to, args.question, args.timeout_seconds ?? 120))
|
|
358
|
+
|
|
359
|
+
case "reply": {
|
|
360
|
+
const target = mesh.resolve(args.to)
|
|
361
|
+
if (!target) throw new MeshError("unknown_peer", `No session named "${args.to}".`)
|
|
362
|
+
const m = await mesh.send(caller, args.to, args.message, { kind: "reply", askId: args.to_message_id })
|
|
363
|
+
return ok({ delivered: true, message_id: m.id })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
case "inbox": {
|
|
367
|
+
const items = args.include_read ? caller.inbox : mesh.unread(caller)
|
|
368
|
+
return ok({
|
|
369
|
+
messages: items.map((m) => ({ id: m.id, from: m.from, at: new Date(m.at).toISOString(), kind: m.kind, text: m.text })),
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case "rooms": {
|
|
374
|
+
const list = supervisor.rooms.visibleTo(caller).map((r) => ({
|
|
375
|
+
name: r.name,
|
|
376
|
+
topic: r.topic,
|
|
377
|
+
muted: r.muted,
|
|
378
|
+
delivery: r.mode === "push" ? "every message is delivered" : "mentions are delivered, the rest arrive as a digest when you go idle",
|
|
379
|
+
members: supervisor.rooms.members(r).map((m) => (m.id === caller.id ? "you" : "@" + m.handle)),
|
|
380
|
+
unread: r.unread.get(caller.id) ?? 0,
|
|
381
|
+
}))
|
|
382
|
+
return ok(list.length ? { rooms: list } : { rooms: [], note: "You are not in any rooms. The operator adds sessions to rooms from the dashboard." })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case "room_read": {
|
|
386
|
+
const room = requireRoom(supervisor, caller, args.room)
|
|
387
|
+
return ok({ room: room.name, topic: room.topic, messages: supervisor.rooms.read(room, caller, args.limit ?? 20) })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case "room_post": {
|
|
391
|
+
const room = requireRoom(supervisor, caller, args.room)
|
|
392
|
+
const m = await supervisor.rooms.post(room, caller, args.message)
|
|
393
|
+
const others = room.members.size - 1
|
|
394
|
+
return ok({
|
|
395
|
+
posted: true,
|
|
396
|
+
message_id: m.id,
|
|
397
|
+
room: room.name,
|
|
398
|
+
reached: m.mentions.length ? `delivered now to ${m.mentions.map((h) => "@" + h).join(", ")}` : `queued for ${others} member(s), delivered as they go idle`,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case "room_create": {
|
|
403
|
+
const invited = (args.invite ?? []).map((h) => mesh.resolve(h)).filter(Boolean)
|
|
404
|
+
const room = supervisor.rooms.create({
|
|
405
|
+
name: args.name,
|
|
406
|
+
topic: args.topic,
|
|
407
|
+
createdBy: caller.handle,
|
|
408
|
+
members: [caller, ...invited],
|
|
409
|
+
})
|
|
410
|
+
return ok({ created: true, room: room.name, members: supervisor.rooms.members(room).map((m) => "@" + m.handle) })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case "room_invite": {
|
|
414
|
+
const room = requireRoom(supervisor, caller, args.room)
|
|
415
|
+
const target = mesh.resolve(args.handle)
|
|
416
|
+
if (!target) throw new RoomError("unknown", `No session named "${args.handle}".`)
|
|
417
|
+
if (target.policy.rooms === "off") throw new RoomError("not_allowed", `@${target.handle} is not allowed in rooms. The operator can change that.`)
|
|
418
|
+
supervisor.rooms.join(room, target)
|
|
419
|
+
return ok({ room: room.name, added: "@" + target.handle })
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
case "room_leave": {
|
|
423
|
+
const room = requireRoom(supervisor, caller, args.room)
|
|
424
|
+
supervisor.rooms.leave(room, caller)
|
|
425
|
+
return ok({ room: room.name, left: true })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
case "spawn": {
|
|
429
|
+
const s = await mesh.spawn(caller, args)
|
|
430
|
+
return ok({
|
|
431
|
+
created: true,
|
|
432
|
+
handle: s.handle,
|
|
433
|
+
session_id: s.id,
|
|
434
|
+
worktree: s.directory,
|
|
435
|
+
note: `@${s.handle} is now working on this separately. It cannot see this conversation. You can botfarm_ask it for a status later.`,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case "notes_read": {
|
|
440
|
+
const runId = runOfCaller(supervisor, caller)
|
|
441
|
+
if (!runId) return ok({ notes: [], note: "You are not on a pipeline workstream, so there are no team notes." })
|
|
442
|
+
const notes = supervisor.pipelines.notes(runId)
|
|
443
|
+
if (!args.topic) return ok({ notes: notes.map((n) => ({ topic: n.topic, by: "@" + n.by, files: n.files.length })) })
|
|
444
|
+
const want = String(args.topic).toLowerCase()
|
|
445
|
+
const n = notes.find((x) => x.topic.toLowerCase() === want) ?? notes.find((x) => x.topic.toLowerCase().includes(want))
|
|
446
|
+
return n ? ok(n) : err(`No note about "${args.topic}". Topics: ${notes.map((x) => x.topic).join(", ") || "none yet"}.`)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
case "notes_write": {
|
|
450
|
+
const runId = runOfCaller(supervisor, caller)
|
|
451
|
+
if (!runId) return err("You are not on a pipeline workstream, so there is nowhere to keep team notes.")
|
|
452
|
+
const n = supervisor.pipelines.addNote(runId, { topic: args.topic, text: args.text, files: args.files, by: caller.handle })
|
|
453
|
+
return ok({ saved: n.topic, note: "Your teammates see this topic in their next task and can read it with botfarm_notes_read." })
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
case "handoff": {
|
|
457
|
+
const runId = runOfCaller(supervisor, caller)
|
|
458
|
+
const run = runId ? supervisor.pipelines.run(runId) : null
|
|
459
|
+
const t = run ? supervisor.tasks.get(run.taskIds?.[args.stage]) : null
|
|
460
|
+
if (!t) return err(`No stage "${args.stage}" on your workstream.${run ? ` Stages: ${Object.keys(run.taskIds).join(", ")}.` : ""}`)
|
|
461
|
+
if (!t.handoff) return err(`${args.stage} has not handed off yet.`)
|
|
462
|
+
const { brief, questions, ...full } = t.handoff
|
|
463
|
+
return ok({ stage: args.stage, by: t.persona, ...full })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
case "tasks": {
|
|
467
|
+
const role = caller.role ?? caller.persona
|
|
468
|
+
const mine = [
|
|
469
|
+
...supervisor.tasks.all({ assignee: caller.id }),
|
|
470
|
+
...(role ? supervisor.tasks.all({ role }).filter((t) => !t.assignee) : []),
|
|
471
|
+
...supervisor.tasks.all({ owner: "human" }).filter((t) => t.askedBy === caller.id),
|
|
472
|
+
]
|
|
473
|
+
const out = {
|
|
474
|
+
yours: mine.map(brief),
|
|
475
|
+
working_on: mine.filter((t) => t.status === "active").map((t) => t.id),
|
|
476
|
+
}
|
|
477
|
+
if (args.all) out.board = supervisor.tasks.board().map((c) => ({ status: c.status, tasks: c.tasks.map(brief) }))
|
|
478
|
+
return ok(out)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
case "task_complete": {
|
|
482
|
+
const task = supervisor.tasks.get(args.task_id)
|
|
483
|
+
if (!task) throw new TaskError("unknown_task", `No task ${args.task_id}. Call botfarm_tasks to see yours.`)
|
|
484
|
+
if (task.assignee && task.assignee !== caller.id) {
|
|
485
|
+
// Sessions sharing a worktree share an MCP endpoint and cannot
|
|
486
|
+
// always be told apart. The task id is the stronger evidence: if
|
|
487
|
+
// its assignee works in the same place, it is the one calling.
|
|
488
|
+
const owner = supervisor.store.get(task.assignee)
|
|
489
|
+
if (!owner || owner.directory !== caller.directory) {
|
|
490
|
+
throw new TaskError("not_yours", `${task.id} is assigned to someone else.`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const done = await supervisor.pipelines.complete(task, args)
|
|
494
|
+
const next = supervisor.tasks.all({ runId: task.runId }).filter((t) => t.dependsOn.includes(task.id))
|
|
495
|
+
return ok({
|
|
496
|
+
completed: done.id,
|
|
497
|
+
status: done.status,
|
|
498
|
+
note: done.status === "review"
|
|
499
|
+
? "This stage waits for the operator when it has open questions, so it is in review until they let it go on."
|
|
500
|
+
: next.length
|
|
501
|
+
? `Handed to ${next.map((t) => "@" + (supervisor.store.get(t.assignee)?.handle ?? t.persona)).join(", ")}.`
|
|
502
|
+
: "Nothing was waiting on this.",
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
case "send_back": {
|
|
507
|
+
const task = supervisor.tasks.get(args.task_id)
|
|
508
|
+
if (!task) throw new TaskError("unknown_task", `No task ${args.task_id}. Call botfarm_tasks to see yours.`)
|
|
509
|
+
if (task.assignee && task.assignee !== caller.id) {
|
|
510
|
+
const owner = supervisor.store.get(task.assignee)
|
|
511
|
+
if (!owner || owner.directory !== caller.directory) throw new TaskError("not_yours", `${task.id} is assigned to someone else.`)
|
|
512
|
+
}
|
|
513
|
+
if (task.status !== "active") throw new TaskError("bad_request", `${task.id} is ${task.status}, not the card you are working on.`)
|
|
514
|
+
const out = await supervisor.pipelines.sendBack(task, { to: args.to, reason: args.reason, failures: args.failures, files: args.files, pieces: args.pieces })
|
|
515
|
+
if (out.paused) return ok({ sent: false, paused: true, why: out.why, note: "Not sent: the operator decides what happens next. Stop here and end your turn; you will hear back." })
|
|
516
|
+
return ok({
|
|
517
|
+
sent: out.sent.map((t) => `${t.title}${t.to ? ` → @${t.to}` : ""}`),
|
|
518
|
+
fix: `${out.round} of ${out.max}`,
|
|
519
|
+
note: "Sent. Your card now waits for the fix and comes back to you to verify. Stop here and end your turn — do not start fixing it yourself.",
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
case "ask_human": {
|
|
524
|
+
const questions = normalizeQuestions(args.questions?.length ? args.questions : { question: args.question, options: args.options, multiple: args.multiple })
|
|
525
|
+
if (!questions.length) throw new TaskError("bad_request", "Ask something: question, or questions: [{ question, options }].")
|
|
526
|
+
args.question = questions.map((q) => q.question).join("\n")
|
|
527
|
+
const task = supervisor.tasks.create({
|
|
528
|
+
title: questions[0].question.slice(0, 90) + (questions.length > 1 ? ` (+${questions.length - 1})` : ""),
|
|
529
|
+
brief: args.question,
|
|
530
|
+
kind: "question",
|
|
531
|
+
owner: "human",
|
|
532
|
+
options: questions[0].options.map((o) => o.label),
|
|
533
|
+
askedBy: caller.id,
|
|
534
|
+
projectId: caller.project ?? null,
|
|
535
|
+
runId: currentRunId(supervisor, caller),
|
|
536
|
+
createdBy: caller.handle,
|
|
537
|
+
})
|
|
538
|
+
task.questions = questions
|
|
539
|
+
supervisor.tasks.save(task)
|
|
540
|
+
if (args.task_id) supervisor.tasks.note(args.task_id, `asked the operator: ${args.question}`, caller.handle)
|
|
541
|
+
supervisor.store.note(`@${caller.handle} needs you: ${String(args.question).slice(0, 90)}`, caller.id, "warn")
|
|
542
|
+
supervisor.onChange()
|
|
543
|
+
|
|
544
|
+
const wait = Math.min(600, Math.max(0, args.wait_seconds ?? 0))
|
|
545
|
+
const deadline = Date.now() + wait * 1000
|
|
546
|
+
while (wait && Date.now() < deadline) {
|
|
547
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
548
|
+
const fresh = supervisor.tasks.get(task.id)
|
|
549
|
+
if (fresh?.answer) return ok({ answered: true, answer: fresh.answer })
|
|
550
|
+
}
|
|
551
|
+
return ok({
|
|
552
|
+
asked: true,
|
|
553
|
+
task_id: task.id,
|
|
554
|
+
note: wait
|
|
555
|
+
? "No answer yet. It is on their board; carry on with something else and the answer will arrive in this session."
|
|
556
|
+
: "Asked. The answer will arrive here as a message — carry on with what you can do meanwhile.",
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
case "task_create": {
|
|
561
|
+
const target = args.assignee ? resolveAssignee(supervisor, mesh, caller, args.assignee) : null
|
|
562
|
+
const blocked = args.blocks ? supervisor.tasks.get(args.blocks) : null
|
|
563
|
+
const forHuman = ["human", "operator", "you", "me"].includes(String(args.assignee ?? "").toLowerCase())
|
|
564
|
+
const asRole = !target && !forHuman && args.assignee ? String(args.assignee).replace(/^@/, "") : null
|
|
565
|
+
const task = supervisor.tasks.create({
|
|
566
|
+
title: args.title,
|
|
567
|
+
brief: args.brief,
|
|
568
|
+
projectId: caller.project ?? null,
|
|
569
|
+
runId: currentRunId(supervisor, caller),
|
|
570
|
+
assignee: target?.id ?? null,
|
|
571
|
+
role: asRole,
|
|
572
|
+
owner: forHuman ? "human" : target ? "session" : asRole ? "role" : "unassigned",
|
|
573
|
+
askedBy: caller.id,
|
|
574
|
+
createdBy: caller.handle,
|
|
575
|
+
})
|
|
576
|
+
if (blocked) {
|
|
577
|
+
blocked.dependsOn = [...new Set([...blocked.dependsOn, task.id])]
|
|
578
|
+
if (blocked.status !== "active") blocked.status = "blocked"
|
|
579
|
+
supervisor.tasks.save(blocked)
|
|
580
|
+
}
|
|
581
|
+
await supervisor.pipelines.dispatch()
|
|
582
|
+
supervisor.store.note(`@${caller.handle} raised "${task.title}"${target ? ` for @${target.handle}` : ""}`, caller.id, "warn")
|
|
583
|
+
return ok({
|
|
584
|
+
created: task.id,
|
|
585
|
+
assigned_to: forHuman ? "your operator" : target ? "@" + target.handle : asRole ? `whoever is playing "${asRole}"` : "the backlog",
|
|
586
|
+
note: forHuman
|
|
587
|
+
? "It is on their board."
|
|
588
|
+
: target || asRole
|
|
589
|
+
? "It will be handed over when they next finish what they are doing."
|
|
590
|
+
: "Nobody is assigned; the operator will triage it.",
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case "task_note": {
|
|
595
|
+
const task = supervisor.tasks.get(args.task_id)
|
|
596
|
+
if (!task) throw new TaskError("unknown_task", `No task ${args.task_id}.`)
|
|
597
|
+
supervisor.tasks.note(task.id, args.note, caller.handle)
|
|
598
|
+
return ok({ noted: true })
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case "notify":
|
|
602
|
+
supervisor.store.note(`@${caller.handle}: ${String(args.message).slice(0, 200)}`, caller.id, args.level === "error" ? "error" : args.level === "warn" ? "warn" : "info")
|
|
603
|
+
supervisor.onChange()
|
|
604
|
+
return ok({ posted: true })
|
|
605
|
+
|
|
606
|
+
default: {
|
|
607
|
+
const wsId = supervisor.workspaceIdOf(caller)
|
|
608
|
+
if (wsId && supervisor.wsTools?.get(wsId, name)) {
|
|
609
|
+
const ws = supervisor.workspaces.get(wsId)
|
|
610
|
+
const out = await supervisor.wsTools.run(wsId, name, args, {
|
|
611
|
+
repoRoot: caller.directory ?? ws?.path,
|
|
612
|
+
workspace: ws ? { id: ws.id, name: ws.name, path: ws.path } : null,
|
|
613
|
+
bot: { id: caller.id, handle: caller.handle, persona: caller.persona ?? null, project: caller.project ?? null },
|
|
614
|
+
})
|
|
615
|
+
supervisor.store.note(`@${caller.handle} ran ${name} (${out.ok ? `${out.ms}ms` : `failed: ${out.error}`})`, caller.id, out.ok ? "info" : "warn")
|
|
616
|
+
return toolResult(out)
|
|
617
|
+
}
|
|
618
|
+
return err(`unknown tool "${name}"`)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
622
|
+
// Tool errors go back as results, not protocol errors, so the model can read
|
|
623
|
+
// and act on them instead of seeing an opaque transport failure.
|
|
624
|
+
return err(e instanceof MeshError || e instanceof RoomError || e instanceof TaskError ? `${e.code}: ${e.message}` : e.message)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const brief = (t) => ({
|
|
629
|
+
id: t.id,
|
|
630
|
+
title: t.title,
|
|
631
|
+
status: t.status,
|
|
632
|
+
stage: t.stage,
|
|
633
|
+
owner: t.owner === "human" ? "your operator" : t.owner === "role" ? `anyone playing "${t.role}"` : t.owner,
|
|
634
|
+
kind: t.kind,
|
|
635
|
+
answer: t.answer ?? undefined,
|
|
636
|
+
blocked_by: t.dependsOn,
|
|
637
|
+
raised_by: t.createdBy,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
/** Accept a handle, or a persona name within the caller's own pipeline run. */
|
|
641
|
+
function resolveAssignee(supervisor, mesh, caller, ref) {
|
|
642
|
+
const direct = mesh.resolve(ref)
|
|
643
|
+
if (direct) return direct
|
|
644
|
+
const runId = currentRunId(supervisor, caller)
|
|
645
|
+
const run = runId ? supervisor.pipelines.run(runId) : null
|
|
646
|
+
const id = run?.sessions?.[String(ref).replace(/^@/, "")]
|
|
647
|
+
return id ? supervisor.store.get(id) : null
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function runOfCaller(supervisor, caller) {
|
|
651
|
+
return currentRunId(supervisor, caller) ?? supervisor.projects.get(caller.project)?.runId ?? null
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function currentRunId(supervisor, caller) {
|
|
655
|
+
return supervisor.tasks.all({ assignee: caller.id }).find((t) => t.runId)?.runId ?? null
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function requireRoom(supervisor, caller, ref) {
|
|
659
|
+
const room = supervisor.rooms.find(ref)
|
|
660
|
+
if (!room) throw new RoomError("unknown_room", `No room called "${ref}". Call botfarm_rooms to see the ones you are in.`)
|
|
661
|
+
if (!room.members.has(caller.id)) throw new RoomError("not_member", `@${caller.handle} is not in ${room.name}.`)
|
|
662
|
+
return room
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const ok = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }], structuredContent: obj })
|
|
666
|
+
const err = (message) => ({ content: [{ type: "text", text: message }], isError: true })
|
|
667
|
+
|
|
668
|
+
function rpc(res, id, result, error) {
|
|
669
|
+
const payload = error ? { jsonrpc: "2.0", id, error } : { jsonrpc: "2.0", id, result }
|
|
670
|
+
const buf = Buffer.from(JSON.stringify(payload))
|
|
671
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": buf.length })
|
|
672
|
+
res.end(buf)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function readJson(req) {
|
|
676
|
+
const chunks = []
|
|
677
|
+
for await (const c of req) chunks.push(c)
|
|
678
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export { TOOLS, avatarDataUri }
|