@eamonpluto/agentboard 2.3.0 → 2.4.1

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.
@@ -27,9 +27,10 @@ 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.1.0";
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
  // ---------------------------------------------------------------------------
@@ -82,9 +83,49 @@ function readJson(p) {
82
83
  }
83
84
 
84
85
  function writeJson(p, obj) {
85
- const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
86
+ const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
86
87
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
87
- fs.renameSync(tmp, p);
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;
88
129
  }
89
130
 
90
131
  function cleanName(name, what) {
@@ -105,7 +146,7 @@ function newId(prefix) {
105
146
  String(t.getUTCHours()).padStart(2, "0") +
106
147
  String(t.getUTCMinutes()).padStart(2, "0") +
107
148
  String(t.getUTCSeconds()).padStart(2, "0");
108
- return `${prefix}-${stamp}-${crypto.randomBytes(3).toString("hex")}`;
149
+ return `${prefix}-${stamp}-${crypto.randomBytes(4).toString("hex")}`;
109
150
  }
110
151
 
111
152
  function listDMs(d, recipient) {
@@ -150,13 +191,15 @@ const TOOLS = [
150
191
  {
151
192
  name: "dm_send",
152
193
  description:
153
- "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). Use whenever you want to coordinate, share a finding, or ask a peer. Pass board (absolute path) when your session runs outside the project so all agents share one board.",
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.",
154
195
  inputSchema: {
155
196
  type: "object",
156
197
  properties: {
157
198
  from: { type: "string", description: "Your stable agent name, e.g. alice. Keep it constant for the session." },
158
- 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." },
159
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)." },
160
203
  board: { type: "string", description: "Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection." },
161
204
  },
162
205
  required: ["from", "to", "body"],
@@ -215,31 +258,69 @@ function isDriveRootMissing(root, explicit) {
215
258
  return path.dirname(root) === path.parse(root).root;
216
259
  }
217
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
+
218
284
  function callTool(name, args) {
219
285
  const a = args && typeof args === "object" ? args : {};
220
286
  const boardArg = a.board === undefined || a.board === null || String(a.board).trim() === "" ? undefined : String(a.board);
221
287
  const root = boardRoot(boardArg);
222
288
  if (isDriveRootMissing(root, boardArg || process.env.AGENTBOARD_DIR)) {
223
289
  throw new Error(
224
- `refusing to create a board at drive root ${root} — pass board (absolute path) or set AGENTBOARD_DIR`
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.`
225
291
  );
226
292
  }
227
- const d = ensureBoard(root);
228
293
  switch (name) {
229
294
  case "dm_send": {
295
+ const d = ensureBoard(root);
230
296
  const from = cleanName(a.from, "from");
231
- const to = cleanName(a.to, "to");
297
+ const recipients = parseRecipients(a.to);
232
298
  const body = String(a.body ?? "").trim();
233
299
  if (!body) throw new Error("empty body");
234
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);
235
303
  touchAgent(d, from);
236
- const id = newId("msg");
237
- const msg = { id, from, to, body, at: new Date().toISOString() };
238
- fs.mkdirSync(path.join(d.dm, to), { recursive: true });
239
- writeJson(path.join(d.dm, to, `${id}.json`), msg);
240
- return toolResult(`sent ${id} -> ${to} [board ${d.root}]`);
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(", ")}`);
241
321
  }
242
322
  case "dm_inbox": {
323
+ const d = requireBoard(root);
243
324
  const agent = cleanName(a.agent, "agent");
244
325
  let items = listDMs(d, agent);
245
326
  if (a.after !== undefined && a.after !== null && String(a.after) !== "") {
@@ -249,12 +330,13 @@ function callTool(name, args) {
249
330
  const limit = a.limit === undefined || a.limit === null ? 20 : Number(a.limit);
250
331
  if (!(limit >= 0)) throw new Error("limit must be a non-negative number");
251
332
  items = items.slice(-limit);
252
- if (items.length === 0) return toolResult(`no messages for ${agent}`);
253
- return toolResult(items.map((m) => `[${m.id}] from ${m.from} @ ${m.at}\n${m.body}`).join("\n\n"));
333
+ if (items.length === 0) return toolResult(`no messages for ${agent} [board ${d.root}]`);
334
+ return toolResult(items.map(formatInbox).join("\n\n"));
254
335
  }
255
336
  case "dm_agents": {
337
+ const d = requireBoard(root);
256
338
  const dir = d.agents;
257
- if (!fs.existsSync(dir)) return toolResult("no agents registered");
339
+ if (!fs.existsSync(dir)) return toolResult(`no agents registered [board ${d.root}]`);
258
340
  const names = fs
259
341
  .readdirSync(dir)
260
342
  .filter((f) => f.endsWith(".json"))
@@ -267,14 +349,15 @@ function callTool(name, args) {
267
349
  })
268
350
  .filter(Boolean)
269
351
  .sort();
270
- if (names.length === 0) return toolResult("no agents registered (dm_register -- your name)");
271
- 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}]`);
272
354
  }
273
355
  case "dm_register": {
356
+ const d = ensureBoard(root);
274
357
  const agent = cleanName(a.agent, "agent");
275
358
  const session = a.session === undefined || a.session === null ? undefined : String(a.session);
276
359
  touchAgent(d, agent, session || undefined);
277
- return toolResult(`registered ${agent}${session ? ` (session ${session})` : ""}`);
360
+ return toolResult(`registered ${agent}${session ? ` (session ${session})` : ""} [board ${d.root}]`);
278
361
  }
279
362
  default:
280
363
  throw new Error(`unknown tool "${name}"`);