@eamonpluto/agentboard 2.2.0 → 2.4.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/AGENTBOARD.html +196 -0
- package/AGENTS.template.md +21 -11
- package/CHANGELOG.md +37 -0
- package/README.md +58 -7
- package/bin/agentboard-hook.js +49 -4
- package/bin/agentboard-mcp.js +145 -25
- package/bin/agentboard.js +399 -61
- package/opencode/plugins/dm-watch.js +37 -9
- package/opencode/tools/dm-send.js +152 -27
- package/package.json +2 -1
package/bin/agentboard-mcp.js
CHANGED
|
@@ -27,18 +27,33 @@ import os from "node:os";
|
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import crypto from "node:crypto";
|
|
29
29
|
import readline from "node:readline";
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
30
31
|
|
|
31
32
|
const MAX_BODY_CHARS = 8000;
|
|
32
|
-
const SERVER_VERSION = "2.
|
|
33
|
+
const SERVER_VERSION = "2.2.0";
|
|
33
34
|
const KNOWN_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// board (mirrors bin/agentboard.js layout v2; no import to stay dep-free)
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
|
|
39
|
-
function boardRoot() {
|
|
40
|
+
function boardRoot(override) {
|
|
41
|
+
if (override) return path.resolve(String(override));
|
|
40
42
|
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
41
|
-
return path.join(process.cwd(), ".agentboard");
|
|
43
|
+
return findBoardUpward(process.cwd()) || path.join(process.cwd(), ".agentboard");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Nearest ancestor (incl. start) containing a .agentboard dir, or null.
|
|
47
|
+
function findBoardUpward(start) {
|
|
48
|
+
let dir = path.resolve(start);
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
|
|
52
|
+
} catch {}
|
|
53
|
+
const parent = path.dirname(dir);
|
|
54
|
+
if (parent === dir) return null;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
function dirs(root) {
|
|
@@ -68,9 +83,49 @@ function readJson(p) {
|
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
function writeJson(p, obj) {
|
|
71
|
-
const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
86
|
+
const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
72
87
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
73
|
-
|
|
88
|
+
try {
|
|
89
|
+
fs.renameSync(tmp, p);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
while (Date.now() - start < 50) { /* brief spin for Windows AV holds */ }
|
|
93
|
+
try {
|
|
94
|
+
fs.renameSync(tmp, p);
|
|
95
|
+
} catch (e2) {
|
|
96
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
97
|
+
throw e2;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Best-effort git rev of the project containing the board. Never throws.
|
|
103
|
+
function gitRev(root) {
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
106
|
+
cwd: path.dirname(root),
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
timeout: 3000,
|
|
109
|
+
});
|
|
110
|
+
return String(out).trim().slice(0, 40) || undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseRecipients(raw) {
|
|
117
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") {
|
|
118
|
+
throw new Error("missing to (recipient); or comma-separate for broadcast: alice,bob,carol");
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const part of String(raw).split(",")) {
|
|
122
|
+
if (part.trim() === "") continue;
|
|
123
|
+
const c = cleanName(part, "to");
|
|
124
|
+
if (!out.includes(c)) out.push(c);
|
|
125
|
+
}
|
|
126
|
+
if (out.length === 0) throw new Error("missing to (recipient)");
|
|
127
|
+
if (out.length > 20) throw new Error(`too many recipients (max 20, got ${out.length})`);
|
|
128
|
+
return out;
|
|
74
129
|
}
|
|
75
130
|
|
|
76
131
|
function cleanName(name, what) {
|
|
@@ -91,7 +146,7 @@ function newId(prefix) {
|
|
|
91
146
|
String(t.getUTCHours()).padStart(2, "0") +
|
|
92
147
|
String(t.getUTCMinutes()).padStart(2, "0") +
|
|
93
148
|
String(t.getUTCSeconds()).padStart(2, "0");
|
|
94
|
-
return `${prefix}-${stamp}-${crypto.randomBytes(
|
|
149
|
+
return `${prefix}-${stamp}-${crypto.randomBytes(4).toString("hex")}`;
|
|
95
150
|
}
|
|
96
151
|
|
|
97
152
|
function listDMs(d, recipient) {
|
|
@@ -136,13 +191,16 @@ const TOOLS = [
|
|
|
136
191
|
{
|
|
137
192
|
name: "dm_send",
|
|
138
193
|
description:
|
|
139
|
-
"Send a direct message to another AI agent via agent-board. Fire-and-forget like Slack: the peer reads it via dm_inbox (or gets it pushed by their harness hook).
|
|
194
|
+
"Send a direct message to another AI agent via agent-board. Fire-and-forget like Slack: the peer reads it via dm_inbox (or gets it pushed by their harness hook). `to` accepts a comma list for broadcast (one copy each, shared batch id) to fan work out to N agents — the DM is the task. Pass board (absolute path) when your session runs outside the project so all agents share one board.",
|
|
140
195
|
inputSchema: {
|
|
141
196
|
type: "object",
|
|
142
197
|
properties: {
|
|
143
198
|
from: { type: "string", description: "Your stable agent name, e.g. alice. Keep it constant for the session." },
|
|
144
|
-
to: { type: "string", description: "Recipient agent name, e.g. bob." },
|
|
199
|
+
to: { type: "string", description: "Recipient agent name, e.g. bob — or comma list for broadcast: alice,bob,carol." },
|
|
145
200
|
body: { type: "string", description: "Message text, 1..8000 chars." },
|
|
201
|
+
subject: { type: "string", description: "Optional mission line, e.g. 'brief: borderless cards'. Shown above the body." },
|
|
202
|
+
replyTo: { type: "string", description: "Optional message id you are answering (threads the reply)." },
|
|
203
|
+
board: { type: "string", description: "Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection." },
|
|
146
204
|
},
|
|
147
205
|
required: ["from", "to", "body"],
|
|
148
206
|
},
|
|
@@ -150,31 +208,38 @@ const TOOLS = [
|
|
|
150
208
|
{
|
|
151
209
|
name: "dm_inbox",
|
|
152
210
|
description:
|
|
153
|
-
"Read your direct messages on agent-board. No mark-read side effects; page with after. Poll this often when your harness has no push hook.",
|
|
211
|
+
"Read your direct messages on agent-board. No mark-read side effects; page with after. Poll this often when your harness has no push hook. Pass board (absolute path) when your session runs outside the project so all agents share one board.",
|
|
154
212
|
inputSchema: {
|
|
155
213
|
type: "object",
|
|
156
214
|
properties: {
|
|
157
215
|
agent: { type: "string", description: "Your agent name." },
|
|
158
216
|
limit: { type: "number", description: "Max messages, newest last. Default 20." },
|
|
159
217
|
after: { type: "string", description: "Only messages after this message id." },
|
|
218
|
+
board: { type: "string", description: "Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection." },
|
|
160
219
|
},
|
|
161
220
|
required: ["agent"],
|
|
162
221
|
},
|
|
163
222
|
},
|
|
164
223
|
{
|
|
165
224
|
name: "dm_agents",
|
|
166
|
-
description: "List agents known to this agent-board (registered names for addressing DMs).",
|
|
167
|
-
inputSchema: {
|
|
225
|
+
description: "List agents known to this agent-board (registered names for addressing DMs). Pass board when your session runs outside the project.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
board: { type: "string", description: "Optional absolute board path. Overrides AGENTBOARD_DIR and auto-detection." },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
168
232
|
},
|
|
169
233
|
{
|
|
170
234
|
name: "dm_register",
|
|
171
235
|
description:
|
|
172
|
-
"Register your agent name on this agent-board (optionally binding a harness session id for push routing). Do this once per session.",
|
|
236
|
+
"Register your agent name on this agent-board (optionally binding a harness session id for push routing). Do this once per session. Pass board when your session runs outside the project.",
|
|
173
237
|
inputSchema: {
|
|
174
238
|
type: "object",
|
|
175
239
|
properties: {
|
|
176
240
|
agent: { type: "string", description: "Your stable agent name." },
|
|
177
241
|
session: { type: "string", description: "Optional harness session/conversation id for push routing." },
|
|
242
|
+
board: { type: "string", description: "Optional absolute board path. Overrides AGENTBOARD_DIR and auto-detection." },
|
|
178
243
|
},
|
|
179
244
|
required: ["agent"],
|
|
180
245
|
},
|
|
@@ -185,24 +250,77 @@ function toolResult(text) {
|
|
|
185
250
|
return { content: [{ type: "text", text }] };
|
|
186
251
|
}
|
|
187
252
|
|
|
253
|
+
function isDriveRootMissing(root, explicit) {
|
|
254
|
+
if (explicit) return false;
|
|
255
|
+
try {
|
|
256
|
+
if (fs.statSync(root).isDirectory()) return false;
|
|
257
|
+
} catch {}
|
|
258
|
+
return path.dirname(root) === path.parse(root).root;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Read-side tools must never plant a board: without board.json, report the
|
|
262
|
+
// resolved path so the caller spots the split-board instead of an empty room.
|
|
263
|
+
function requireBoard(root) {
|
|
264
|
+
let meta = null;
|
|
265
|
+
try {
|
|
266
|
+
meta = readJson(path.join(root, "board.json"));
|
|
267
|
+
} catch {}
|
|
268
|
+
if (!meta || meta.version !== 2) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`no board at ${root} (cwd "${process.cwd()}"). Run from your project, pass board (absolute path to .agentboard), or set AGENTBOARD_DIR.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return dirs(root);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatInbox(m) {
|
|
277
|
+
const bits = [`from ${m.from}`, `@ ${m.at || "unknown time"}`];
|
|
278
|
+
if (m.rev) bits.push(`rev ${m.rev}`);
|
|
279
|
+
if (m.replyTo) bits.push(`re: ${m.replyTo}`);
|
|
280
|
+
if (m.batch) bits.push(`batch ${m.batch}`);
|
|
281
|
+
return `[${m.id}] ${bits.join(" ")}${m.subject ? `\nsubj: ${m.subject}` : ""}\n${m.body}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
188
284
|
function callTool(name, args) {
|
|
189
|
-
const d = ensureBoard(boardRoot());
|
|
190
285
|
const a = args && typeof args === "object" ? args : {};
|
|
286
|
+
const boardArg = a.board === undefined || a.board === null || String(a.board).trim() === "" ? undefined : String(a.board);
|
|
287
|
+
const root = boardRoot(boardArg);
|
|
288
|
+
if (isDriveRootMissing(root, boardArg || process.env.AGENTBOARD_DIR)) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`refusing to create a board at drive root ${root} (cwd "${process.cwd()}") — no project board found above cwd. Pass board (absolute path to .agentboard) or set AGENTBOARD_DIR.`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
191
293
|
switch (name) {
|
|
192
294
|
case "dm_send": {
|
|
295
|
+
const d = ensureBoard(root);
|
|
193
296
|
const from = cleanName(a.from, "from");
|
|
194
|
-
const
|
|
297
|
+
const recipients = parseRecipients(a.to);
|
|
195
298
|
const body = String(a.body ?? "").trim();
|
|
196
299
|
if (!body) throw new Error("empty body");
|
|
197
300
|
if (body.length > MAX_BODY_CHARS) throw new Error(`body too large (max ${MAX_BODY_CHARS} chars)`);
|
|
301
|
+
const subject = a.subject === undefined || a.subject === null || String(a.subject).trim() === "" ? undefined : String(a.subject).trim().slice(0, 120);
|
|
302
|
+
const replyTo = a.replyTo === undefined || a.replyTo === null || String(a.replyTo).trim() === "" ? undefined : String(a.replyTo).trim().slice(0, 80);
|
|
198
303
|
touchAgent(d, from);
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
304
|
+
const rev = gitRev(root);
|
|
305
|
+
const at = new Date().toISOString();
|
|
306
|
+
const batch = recipients.length > 1 ? newId("batch") : undefined;
|
|
307
|
+
const sent = [];
|
|
308
|
+
for (const to of recipients) {
|
|
309
|
+
const id = newId("msg");
|
|
310
|
+
const msg = { id, from, to, body, at };
|
|
311
|
+
if (subject) msg.subject = subject;
|
|
312
|
+
if (replyTo) msg.replyTo = replyTo;
|
|
313
|
+
if (batch) msg.batch = batch;
|
|
314
|
+
if (rev) msg.rev = rev;
|
|
315
|
+
fs.mkdirSync(path.join(d.dm, to), { recursive: true });
|
|
316
|
+
writeJson(path.join(d.dm, to, `${id}.json`), msg);
|
|
317
|
+
sent.push(`${id} -> ${to}`);
|
|
318
|
+
}
|
|
319
|
+
if (sent.length === 1) return toolResult(`sent ${sent[0]} [board ${d.root}]`);
|
|
320
|
+
return toolResult(`sent ${sent.length} messages [board ${d.root}]: ${sent.join(", ")}`);
|
|
204
321
|
}
|
|
205
322
|
case "dm_inbox": {
|
|
323
|
+
const d = requireBoard(root);
|
|
206
324
|
const agent = cleanName(a.agent, "agent");
|
|
207
325
|
let items = listDMs(d, agent);
|
|
208
326
|
if (a.after !== undefined && a.after !== null && String(a.after) !== "") {
|
|
@@ -212,12 +330,13 @@ function callTool(name, args) {
|
|
|
212
330
|
const limit = a.limit === undefined || a.limit === null ? 20 : Number(a.limit);
|
|
213
331
|
if (!(limit >= 0)) throw new Error("limit must be a non-negative number");
|
|
214
332
|
items = items.slice(-limit);
|
|
215
|
-
if (items.length === 0) return toolResult(`no messages for ${agent}`);
|
|
216
|
-
return toolResult(items.map(
|
|
333
|
+
if (items.length === 0) return toolResult(`no messages for ${agent} [board ${d.root}]`);
|
|
334
|
+
return toolResult(items.map(formatInbox).join("\n\n"));
|
|
217
335
|
}
|
|
218
336
|
case "dm_agents": {
|
|
337
|
+
const d = requireBoard(root);
|
|
219
338
|
const dir = d.agents;
|
|
220
|
-
if (!fs.existsSync(dir)) return toolResult(
|
|
339
|
+
if (!fs.existsSync(dir)) return toolResult(`no agents registered [board ${d.root}]`);
|
|
221
340
|
const names = fs
|
|
222
341
|
.readdirSync(dir)
|
|
223
342
|
.filter((f) => f.endsWith(".json"))
|
|
@@ -230,14 +349,15 @@ function callTool(name, args) {
|
|
|
230
349
|
})
|
|
231
350
|
.filter(Boolean)
|
|
232
351
|
.sort();
|
|
233
|
-
if (names.length === 0) return toolResult(
|
|
234
|
-
return toolResult(names.join("\n"));
|
|
352
|
+
if (names.length === 0) return toolResult(`no agents registered (dm_register -- your name) [board ${d.root}]`);
|
|
353
|
+
return toolResult(names.join("\n") + `\n[board ${d.root}]`);
|
|
235
354
|
}
|
|
236
355
|
case "dm_register": {
|
|
356
|
+
const d = ensureBoard(root);
|
|
237
357
|
const agent = cleanName(a.agent, "agent");
|
|
238
358
|
const session = a.session === undefined || a.session === null ? undefined : String(a.session);
|
|
239
359
|
touchAgent(d, agent, session || undefined);
|
|
240
|
-
return toolResult(`registered ${agent}${session ? ` (session ${session})` : ""}`);
|
|
360
|
+
return toolResult(`registered ${agent}${session ? ` (session ${session})` : ""} [board ${d.root}]`);
|
|
241
361
|
}
|
|
242
362
|
default:
|
|
243
363
|
throw new Error(`unknown tool "${name}"`);
|