@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.
@@ -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.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
  // ---------------------------------------------------------------------------
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
- 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;
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(3).toString("hex")}`;
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). Use whenever you want to coordinate, share a finding, or ask a peer.",
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: { type: "object", properties: {} },
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 to = cleanName(a.to, "to");
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 id = newId("msg");
200
- const msg = { id, from, to, body, at: new Date().toISOString() };
201
- fs.mkdirSync(path.join(d.dm, to), { recursive: true });
202
- writeJson(path.join(d.dm, to, `${id}.json`), msg);
203
- return toolResult(`sent ${id} -> ${to}`);
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((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"));
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("no agents registered");
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("no agents registered (dm_register -- your name)");
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}"`);