@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/bin/agentboard.js CHANGED
@@ -26,6 +26,7 @@ import fs from "node:fs";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import crypto from "node:crypto";
29
+ import { execFileSync } from "node:child_process";
29
30
 
30
31
  const MAX_BODY_CHARS = 8000;
31
32
  const BOARD_VERSION = 2;
@@ -47,7 +48,60 @@ function boardDir(args) {
47
48
  return path.join(os.homedir(), ".agentboard", "boards", "default");
48
49
  }
49
50
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
50
- return path.join(process.cwd(), ".agentboard");
51
+ // init always plants a board where you stand; every other command walks up
52
+ // so agents running from a subdirectory land on the project board instead
53
+ // of silently creating a stray one.
54
+ if (process.argv[2] === "init") return path.join(process.cwd(), ".agentboard");
55
+ return findBoardUpward(process.cwd()) || path.join(process.cwd(), ".agentboard");
56
+ }
57
+
58
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
59
+ function findBoardUpward(start) {
60
+ let dir = path.resolve(start);
61
+ for (;;) {
62
+ try {
63
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
64
+ } catch {}
65
+ const parent = path.dirname(dir);
66
+ if (parent === dir) return null;
67
+ dir = parent;
68
+ }
69
+ }
70
+
71
+ // Never silently plant a board at a drive root (e.g. C:\.agentboard): that
72
+ // means cwd resolution failed (detached harness worktree). Fail loudly so
73
+ // the agent sets --board/AGENTBOARD_DIR instead of talking to a stray board.
74
+ function isExplicitBoard(args) {
75
+ return args.includes("--board") || args.includes("--global") || !!process.env.AGENTBOARD_DIR;
76
+ }
77
+
78
+ function refuseDriveRootBoard(root, args) {
79
+ if (isExplicitBoard(args)) return;
80
+ let exists = false;
81
+ try {
82
+ exists = fs.statSync(root).isDirectory();
83
+ } catch {}
84
+ if (exists) return;
85
+ if (path.dirname(root) === path.parse(root).root) {
86
+ const cwd = process.cwd();
87
+ fail(
88
+ `refusing to create a board at drive root ${root} — no project board found above cwd "${cwd}". ` +
89
+ `Run from your project (the dir containing .agentboard/), pass --board <absolute path to .agentboard>, or set AGENTBOARD_DIR. ` +
90
+ `Every send echoes [board <path>] — if two agents see different boards, point them at the same one.`
91
+ );
92
+ }
93
+ }
94
+
95
+ // Best-effort git revision for the project containing the board, so recipients
96
+ // can tell whether cited file:line numbers are stale. Never throws.
97
+ function gitRevForBoard(root) {
98
+ try {
99
+ const cwd = path.dirname(root);
100
+ const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 3000 });
101
+ return String(out).trim().slice(0, 40) || undefined;
102
+ } catch {
103
+ return undefined;
104
+ }
51
105
  }
52
106
 
53
107
  function dirs(root) {
@@ -79,9 +133,22 @@ function readJson(p) {
79
133
  }
80
134
 
81
135
  function writeJson(p, obj) {
82
- const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
136
+ const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
83
137
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
84
- fs.renameSync(tmp, p);
138
+ // tmp+rename keeps each write atomic under parallel sends; Windows AV
139
+ // scanners can briefly hold the tmp file, so retry once before giving up.
140
+ try {
141
+ fs.renameSync(tmp, p);
142
+ } catch (e) {
143
+ const start = Date.now();
144
+ while (Date.now() - start < 50) { /* brief spin */ }
145
+ try {
146
+ fs.renameSync(tmp, p);
147
+ } catch (e2) {
148
+ try { fs.rmSync(tmp, { force: true }); } catch {}
149
+ throw e2;
150
+ }
151
+ }
85
152
  }
86
153
 
87
154
  /** Atomic fire-once claim: create file only if it does not exist. */
@@ -106,7 +173,9 @@ function newId(prefix) {
106
173
  String(t.getUTCHours()).padStart(2, "0") +
107
174
  String(t.getUTCMinutes()).padStart(2, "0") +
108
175
  String(t.getUTCSeconds()).padStart(2, "0");
109
- return `${prefix}-${stamp}-${crypto.randomBytes(3).toString("hex")}`;
176
+ // 4 random bytes (8 hex) + pid fragment: parallel sends in the same second
177
+ // from different processes still get unique ids.
178
+ return `${prefix}-${stamp}-${crypto.randomBytes(4).toString("hex")}`;
110
179
  }
111
180
 
112
181
  function sanitizeName(name, what) {
@@ -123,7 +192,36 @@ function getFlag(args, flag) {
123
192
 
124
193
  // Positional args with flag values removed (so `send --from alice --to bob`
125
194
  // with no body doesn't mistake "alice bob" for a message).
126
- const VALUE_FLAGS = new Set(["--from", "--to", "--body", "--session", "--board", "--limit", "--after", "--timeout"]);
195
+ const VALUE_FLAGS = new Set(["--from", "--to", "--body", "--subject", "--reply", "--session", "--board", "--limit", "--after", "--timeout"]);
196
+
197
+ // Comma-separated recipients: `--to alice,bob,carol` fans out one DM per
198
+ // recipient (same body/subject, unique id each). Keeps the DM-only model
199
+ // while covering "assign N agents" in a single call.
200
+ function parseRecipients(raw) {
201
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
202
+ fail(`missing --to <agent-name> (recipient); or comma-separate for broadcast: --to alice,bob,carol`);
203
+ }
204
+ const out = [];
205
+ for (const part of String(raw).split(",")) {
206
+ if (part.trim() === "") continue;
207
+ const clean = sanitizeName(part, "recipient");
208
+ if (!out.includes(clean)) out.push(clean);
209
+ }
210
+ if (out.length === 0) fail(`missing --to <agent-name> (recipient)`);
211
+ if (out.length > 20) fail(`too many recipients (max 20, got ${out.length})`);
212
+ return out;
213
+ }
214
+
215
+ function cleanSubject(raw) {
216
+ if (raw === undefined || raw === null) return undefined;
217
+ const s = String(raw).trim().slice(0, 120);
218
+ return s || undefined;
219
+ }
220
+
221
+ function cleanReply(raw) {
222
+ if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
223
+ return String(raw).trim().slice(0, 80);
224
+ }
127
225
  function restArgs(args) {
128
226
  const out = [];
129
227
  for (let i = 0; i < args.length; i++) {
@@ -185,13 +283,22 @@ const OPENCODE_TOOL_DM_SEND = `// .opencode/tools/dm-send.js — primitive DM to
185
283
  //
186
284
  // Usage from the agent (just a tool call, whenever you want):
187
285
  // dm-send({ from: "alice", to: "bob", body: "the parser accepts ISO dates only" })
286
+ // dm-send({ from: "lead", to: "alice,bob,carol", subject: "brief: cards", body: "..." })
287
+ //
288
+ // Fanning out work ("assign N agents"): there is no task object — the DM *is*
289
+ // the task. \`to\` accepts a comma list (one copy each, shared batch id); each
290
+ // agent owns its scope and DMs a summary back. Thread answers with \`replyTo\`.
188
291
  //
189
292
  // What it does:
190
- // 1. resolves the board (AGENTBOARD_DIR env, else <worktree>/.agentboard)
191
- // 2. writes .agentboard/dm/<to>/<msg-id>.json (atomic write-then-rename)
293
+ // 1. resolves the board (board arg, AGENTBOARD_DIR env, else walk-up from
294
+ // worktree/directory/cwd to the project .agentboard)
295
+ // 2. writes .agentboard/dm/<to>/<msg-id>.json per recipient (atomic
296
+ // write-then-rename, unique id each, shared batch id on fan-out)
192
297
  // 3. upserts .agentboard/agents/<from>.json with { lastSeen, sessionId }
193
298
  // so the watcher plugin can route pushes back to the right session.
194
- // 4. returns "sent <id> -> <to>" for the calling agent to see.
299
+ // 4. stamps the sender's git rev (when inside a checkout) so recipients
300
+ // can tell whether cited file:line numbers are stale.
301
+ // 5. returns "sent <id> -> <to> [board <path>]" for the calling agent.
195
302
  //
196
303
  // Delivery ("inserted into context") is done by ../plugins/dm-watch.js, which
197
304
  // polls dm/ and injects via client.session.promptAsync. This tool never blocks
@@ -201,10 +308,43 @@ import { tool } from "@opencode-ai/plugin";
201
308
  import fs from "node:fs";
202
309
  import path from "node:path";
203
310
  import crypto from "node:crypto";
311
+ import { execFileSync } from "node:child_process";
204
312
 
205
- function boardRoot(worktree) {
206
- if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
207
- return path.join(worktree, ".agentboard");
313
+ function findBoardUpward(start) {
314
+ let dir = path.resolve(start);
315
+ for (;;) {
316
+ try {
317
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
318
+ } catch {}
319
+ const parent = path.dirname(dir);
320
+ if (parent === dir) return null;
321
+ dir = parent;
322
+ }
323
+ }
324
+
325
+ // Try every base the harness gives us (worktree, directory, cwd): harnesses
326
+ // sometimes run agents with a cwd below (or beside) the project, or with an
327
+ // empty worktree. First walk-up hit wins; otherwise fall back to
328
+ // <primary>/.agentboard so the caller gets the drive-root guard instead of a
329
+ // silent stray board.
330
+ function boardRoot(candidates, override) {
331
+ if (override) return { root: path.resolve(String(override)), tried: [path.resolve(String(override))] };
332
+ if (process.env.AGENTBOARD_DIR) return { root: path.resolve(process.env.AGENTBOARD_DIR), tried: [path.resolve(process.env.AGENTBOARD_DIR)] };
333
+ const tried = [];
334
+ for (const base of candidates) {
335
+ if (!base) continue;
336
+ let dir;
337
+ try {
338
+ dir = path.resolve(String(base));
339
+ } catch {
340
+ continue;
341
+ }
342
+ tried.push(dir);
343
+ const hit = findBoardUpward(dir);
344
+ if (hit) return { root: hit, tried };
345
+ }
346
+ const primary = path.resolve(String(candidates[0] || process.cwd()));
347
+ return { root: path.join(primary, ".agentboard"), tried };
208
348
  }
209
349
 
210
350
  function clean(name, what) {
@@ -214,40 +354,122 @@ function clean(name, what) {
214
354
  return c;
215
355
  }
216
356
 
357
+ function parseRecipients(raw) {
358
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
359
+ throw new Error("missing to (recipient); or comma-separate for broadcast: alice,bob,carol");
360
+ }
361
+ const out = [];
362
+ for (const part of String(raw).split(",")) {
363
+ if (part.trim() === "") continue;
364
+ const c = clean(part, "to");
365
+ if (!out.includes(c)) out.push(c);
366
+ }
367
+ if (out.length === 0) throw new Error("missing to (recipient)");
368
+ if (out.length > 20) throw new Error(\`too many recipients (max 20, got \${out.length})\`);
369
+ return out;
370
+ }
371
+
372
+ function cleanSubject(raw) {
373
+ if (raw === undefined || raw === null) return undefined;
374
+ const s = String(raw).trim().slice(0, 120);
375
+ return s || undefined;
376
+ }
377
+
378
+ function cleanReply(raw) {
379
+ if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
380
+ return String(raw).trim().slice(0, 80);
381
+ }
382
+
383
+ // Best-effort git rev of the project containing the board. Never throws.
384
+ function gitRev(root) {
385
+ try {
386
+ const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
387
+ cwd: path.dirname(root),
388
+ stdio: ["ignore", "pipe", "ignore"],
389
+ timeout: 3000,
390
+ });
391
+ return String(out).trim().slice(0, 40) || undefined;
392
+ } catch {
393
+ return undefined;
394
+ }
395
+ }
396
+
217
397
  function writeJsonAtomic(p, obj) {
218
398
  fs.mkdirSync(path.dirname(p), { recursive: true });
219
- const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
399
+ const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
220
400
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\\n");
221
- fs.renameSync(tmp, p);
401
+ try {
402
+ fs.renameSync(tmp, p);
403
+ } catch (e) {
404
+ const start = Date.now();
405
+ while (Date.now() - start < 50) { /* brief spin for Windows AV holds */ }
406
+ try {
407
+ fs.renameSync(tmp, p);
408
+ } catch (e2) {
409
+ try { fs.rmSync(tmp, { force: true }); } catch {}
410
+ throw e2;
411
+ }
412
+ }
413
+ }
414
+
415
+ function newId(prefix) {
416
+ const t = new Date();
417
+ const stamp =
418
+ String(t.getUTCFullYear()).slice(2) +
419
+ String(t.getUTCMonth() + 1).padStart(2, "0") +
420
+ String(t.getUTCDate()).padStart(2, "0") +
421
+ "-" +
422
+ String(t.getUTCHours()).padStart(2, "0") +
423
+ String(t.getUTCMinutes()).padStart(2, "0") +
424
+ String(t.getUTCSeconds()).padStart(2, "0");
425
+ return \`\${prefix}-\${stamp}-\${crypto.randomBytes(4).toString("hex")}\`;
222
426
  }
223
427
 
224
428
  export default tool({
225
429
  description:
226
- "Send a direct message to another AI agent via agent-board. Use whenever you want to coordinate, share a finding, or ask a peer. Fire-and-forget like Slack — the peer's session gets it injected into context. Args: from (your stable agent name), to (peer's agent name), body (message text).",
430
+ "Send a direct message to another AI agent via agent-board. Use whenever you want to coordinate, share a finding, or ask a peer. Fire-and-forget like Slack — the peer's session gets it injected into context. \`to\` accepts a comma list (broadcast: one copy each, shared batch id) for fanning work out to N agents. Args: from (your stable agent name), to (peer's agent name), body (message text), subject (optional mission line), replyTo (optional msg id you are answering), board (optional absolute board path when your session runs outside the project).",
227
431
  args: {
228
432
  from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
229
- to: tool.schema.string().describe("Recipient agent name, e.g. bob. They receive it on inbox/listen even before registering."),
433
+ to: tool.schema.string().describe("Recipient agent name, e.g. bob — or comma list for broadcast: alice,bob,carol. They receive it on inbox/listen even before registering."),
230
434
  body: tool.schema.string().describe("Message text, 1..8000 chars."),
435
+ subject: tool.schema.string().optional().describe("Optional mission line, e.g. 'brief: borderless cards'. Shown above the body."),
436
+ replyTo: tool.schema.string().optional().describe("Optional message id you are answering (threads the reply)."),
437
+ board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
231
438
  },
232
439
  async execute(args, context) {
233
440
  const from = clean(args.from, "from");
234
- const to = clean(args.to, "to");
441
+ const recipients = parseRecipients(args.to);
235
442
  const body = String(args.body || "").trim();
236
443
  if (!body) return "error: empty body";
237
444
  if (body.length > 8000) return "error: body too large (max 8000 chars)";
238
- const root = boardRoot(context.worktree || context.directory || process.cwd());
239
- const now = new Date().toISOString();
240
- const t = new Date();
241
- const stamp =
242
- String(t.getUTCFullYear()).slice(2) +
243
- String(t.getUTCMonth() + 1).padStart(2, "0") +
244
- String(t.getUTCDate()).padStart(2, "0") +
245
- "-" +
246
- String(t.getUTCHours()).padStart(2, "0") +
247
- String(t.getUTCMinutes()).padStart(2, "0") +
248
- String(t.getUTCSeconds()).padStart(2, "0");
249
- const id = "msg-" + stamp + "-" + crypto.randomBytes(3).toString("hex");
250
- writeJsonAtomic(path.join(root, "dm", to, id + ".json"), { id, from, to, body, at: now });
445
+ const subject = cleanSubject(args.subject);
446
+ const replyTo = cleanReply(args.replyTo);
447
+ const boardArg = args.board === undefined || args.board === null || String(args.board).trim() === "" ? undefined : String(args.board);
448
+ const worktree = context.worktree || context.directory || process.cwd();
449
+ const { root, tried } = boardRoot([worktree, context.directory, process.cwd()], boardArg);
450
+ if (!boardArg && !process.env.AGENTBOARD_DIR) {
451
+ let exists = false;
452
+ try {
453
+ exists = fs.statSync(root).isDirectory();
454
+ } catch {}
455
+ if (!exists && path.dirname(root) === path.parse(root).root) {
456
+ return \`error: refusing to create a board at drive root \${root} — no project board found. Tried walk-up from: \${tried.join(" | ") || "(nothing)"}. Run from your project (the dir containing .agentboard/), pass board (absolute path to .agentboard), or set AGENTBOARD_DIR.\`;
457
+ }
458
+ }
459
+ const rev = gitRev(root);
460
+ const at = new Date().toISOString();
461
+ const batch = recipients.length > 1 ? newId("batch") : undefined;
462
+ const sent = [];
463
+ for (const to of recipients) {
464
+ const id = newId("msg");
465
+ const msg = { id, from, to, body, at };
466
+ if (subject) msg.subject = subject;
467
+ if (replyTo) msg.replyTo = replyTo;
468
+ if (batch) msg.batch = batch;
469
+ if (rev) msg.rev = rev;
470
+ writeJsonAtomic(path.join(root, "dm", to, id + ".json"), msg);
471
+ sent.push(\`\${id} -> \${to}\`);
472
+ }
251
473
  // upsert sender with live session routing for the watcher plugin
252
474
  const ap = path.join(root, "agents", from + ".json");
253
475
  let prev = null;
@@ -256,12 +478,13 @@ export default tool({
256
478
  } catch {}
257
479
  writeJsonAtomic(ap, {
258
480
  name: from,
259
- firstSeen: (prev && prev.firstSeen) || now,
260
- lastSeen: now,
481
+ firstSeen: (prev && prev.firstSeen) || new Date().toISOString(),
482
+ lastSeen: new Date().toISOString(),
261
483
  sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
262
484
  lastDir: context.worktree || context.directory || undefined,
263
485
  });
264
- return "sent " + id + " -> " + to;
486
+ if (sent.length === 1) return "sent " + sent[0] + " [board " + root + "]";
487
+ return \`sent \${sent.length} messages [board \${root}]: \${sent.join(", ")}\`;
265
488
  },
266
489
  });
267
490
  `;
@@ -291,7 +514,20 @@ const POLL_MS = 1000;
291
514
 
292
515
  function boardRoot(directory) {
293
516
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
294
- return path.join(directory, ".agentboard");
517
+ return findBoardUpward(directory) || path.join(directory, ".agentboard");
518
+ }
519
+
520
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
521
+ function findBoardUpward(start) {
522
+ let dir = path.resolve(start);
523
+ for (;;) {
524
+ try {
525
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
526
+ } catch {}
527
+ const parent = path.dirname(dir);
528
+ if (parent === dir) return null;
529
+ dir = parent;
530
+ }
295
531
  }
296
532
 
297
533
  function readJsonSafe(p) {
@@ -406,17 +642,28 @@ export const DmWatchPlugin = async ({ client, directory }) => {
406
642
  return curIdx !== -1 && idx !== -1 && idx <= curIdx;
407
643
  }
408
644
 
645
+ function formatDm(msg) {
646
+ let head = "[DM from " + msg.from + " @ " + (msg.at || "unknown time");
647
+ if (msg.rev) head += \` (rev \${msg.rev})\`;
648
+ if (msg.batch) head += \` [batch \${msg.batch}]\`;
649
+ if (msg.replyTo) head += \` re: \${msg.replyTo}\`;
650
+ head += "]";
651
+ const subj = msg.subject ? \`subj: \${msg.subject}\\n\` : "";
652
+ return (
653
+ head +
654
+ "\\n" +
655
+ subj +
656
+ msg.body +
657
+ "\\n\\n(Reply with dm-send (replyTo: \\"" +
658
+ msg.id +
659
+ "\\") if needed, or continue current work if unrelated. Re-read cited files vs your checkout before flagging — the rev above tells you if the sender's file:line numbers are stale.)"
660
+ );
661
+ }
662
+
409
663
  async function deliver(agent, sessionID, msg) {
410
664
  if (!claim(agent, msg.id, sessionID)) return;
411
665
  advanceCursor(agent, msg.id);
412
- const text =
413
- "[DM from " +
414
- msg.from +
415
- " @ " +
416
- msg.at +
417
- "]\\n" +
418
- msg.body +
419
- "\\n\\n(Reply with dm-send if needed, or continue current work if unrelated.)";
666
+ const text = formatDm(msg);
420
667
  try {
421
668
  if (client.session && typeof client.session.promptAsync === "function") {
422
669
  await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
@@ -481,6 +728,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
481
728
  from: msg.from,
482
729
  body: String(msg.body),
483
730
  at: msg.at || "",
731
+ subject: msg.subject,
732
+ replyTo: msg.replyTo,
733
+ batch: msg.batch,
734
+ rev: msg.rev,
484
735
  });
485
736
  }
486
737
  }
@@ -514,12 +765,20 @@ Binary: \`{CLI}\` (board lives in \`./.agentboard\`, or \`$env:AGENTBOARD_DIR\`)
514
765
 
515
766
  {CLI} register --from <you> [--session <opencode-session-id>]
516
767
  {CLI} agents
517
- {CLI} send --from <you> --to <peer> --body "..." # just a tool call, whenever you want
768
+ {CLI} send --from <you> --to <peer> --body "..." [--subject "..."] [--reply <msg-id>]
518
769
  {CLI} inbox --from <you> [--limit 20] [--after <msg-id>] [--json]
519
770
  {CLI} listen --from <you> [--timeout 60000] # block and print new DMs as they arrive
520
771
 
772
+ Fanning out work ("assign N agents"): there is no task object — the DM *is*
773
+ the task. \`--to alice,bob,carol\` sends one copy each (shared batch id),
774
+ each agent owns its scope, decides itself, and DMs a summary back. Use
775
+ \`--subject\` for the mission line, \`--reply <msg-id>\` to thread answers,
776
+ and re-read cited files before flagging (every DM stamps the sender's git
777
+ rev so you can spot stale file:line numbers).
778
+
521
779
  On opencode the \`dm-send\` tool does the same as \`send\` (and registers your session for push).
522
780
  Incoming DMs are inserted into your context automatically by the watcher plugin — otherwise poll \`inbox\` often.
781
+ Every send/inbox echoes \`[board <path>]\`: if two agents see different boards, export \`AGENTBOARD_DIR=<board>\` so all sessions share one.
523
782
 
524
783
  Rules: pick a stable \`--from\` name and keep it. Discover peers via \`agents\`. Send DMs anytime. No tasks, no claims, no holds — emergent coordination.
525
784
  <!-- agentboard:end -->
@@ -734,7 +993,8 @@ function mergeMcpServers(file, entry) {
734
993
  const HARNESS_SECTIONS = {
735
994
  opencode: (cli) =>
736
995
  `On opencode prefer the \`dm-send\` tool over \`${cli} send\` (same thing, plus it registers your session for push).\n` +
737
- `Incoming DMs are inserted into your context automatically by the dm-watch plugin. Restart opencode after \`init\` so the tool + plugin load.`,
996
+ `Incoming DMs are inserted into your context automatically by the dm-watch plugin. Restart opencode after \`init\` so the tool + plugin load.\n` +
997
+ `Every send echoes its board (\`[board <path>]\`): if two agents see different boards, export \`AGENTBOARD_DIR=<board>\` so all sessions share one.`,
738
998
  claude: (cli) =>
739
999
  `On Claude Code use the \`agentboard\` MCP tools (\`dm_send\` / \`dm_inbox\` / \`dm_agents\` / \`dm_register\`) — approve \`.mcp.json\` when prompted.\n` +
740
1000
  `A Stop hook (\`.claude/settings.json\`) injects waiting DMs at turn end. Set \`AGENTBOARD_AGENT=<you>\` once per terminal so hooks know who you are.`,
@@ -929,15 +1189,40 @@ function touchAgent(d, name, extra) {
929
1189
  }
930
1190
 
931
1191
  function cmdRegister(args) {
932
- const d = ensureBoard(boardDir(args));
1192
+ const root = boardDir(args);
1193
+ refuseDriveRootBoard(root, args);
1194
+ const d = ensureBoard(root);
933
1195
  const agent = resolveAgent(args, "agent");
934
1196
  const session = getFlag(args, "--session");
935
1197
  const doc = touchAgent(d, agent, { sessionId: session || undefined, lastDir: process.cwd() });
936
- console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""}`);
1198
+ console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""} [board ${d.root}]`);
1199
+ }
1200
+
1201
+ // Read-side commands (agents/inbox/listen) must never plant a board: if the
1202
+ // resolved board has no board.json, fail loudly instead of showing an empty
1203
+ // room that hides a split-board misconfiguration.
1204
+ function requireBoard(root) {
1205
+ let meta = null;
1206
+ try {
1207
+ meta = readJson(path.join(root, "board.json"));
1208
+ } catch {}
1209
+ if (!meta || meta.version !== BOARD_VERSION) {
1210
+ fail(
1211
+ `no board at ${root} (cwd "${process.cwd()}"). ` +
1212
+ `Run from your project (the dir containing .agentboard/), pass --board <absolute path to .agentboard>, or set AGENTBOARD_DIR. ` +
1213
+ `If you just created one elsewhere, every send echoes [board <path>] — point all agents at the same one.`
1214
+ );
1215
+ }
1216
+ const d = dirs(root);
1217
+ for (const p of [d.root, d.agents, d.dm, d.delivered]) {
1218
+ fs.mkdirSync(p, { recursive: true });
1219
+ }
1220
+ return d;
937
1221
  }
938
1222
 
939
1223
  function cmdAgents(args) {
940
- const d = ensureBoard(boardDir(args));
1224
+ const root = boardDir(args);
1225
+ const d = requireBoard(root);
941
1226
  const items = listJson(d.agents)
942
1227
  .map((e) => e.data)
943
1228
  .filter((x) => x && x.name)
@@ -953,24 +1238,45 @@ function cmdAgents(args) {
953
1238
  for (const a of items) {
954
1239
  console.log(`${a.name} (last seen ${relTime(a.lastSeen)}${a.sessionId ? `, session ${a.sessionId}` : ""})`);
955
1240
  }
1241
+ console.log(`[board ${d.root}]`);
956
1242
  }
957
1243
 
958
1244
  function cmdSend(args) {
959
- const d = ensureBoard(boardDir(args));
1245
+ const root = boardDir(args);
1246
+ refuseDriveRootBoard(root, args);
1247
+ const d = ensureBoard(root);
960
1248
  const from = resolveAgent(args, "sender");
961
- const toRaw = getFlag(args, "--to");
962
- const to = sanitizeName(toRaw, "recipient");
1249
+ const recipients = parseRecipients(getFlag(args, "--to"));
963
1250
  const body = getFlag(args, "--body") || restArgs(args).join(" ");
964
1251
  if (!body || !body.trim()) fail('missing message body (--body "...")');
965
1252
  if (body.length > MAX_BODY_CHARS) fail(`message body too large (max ${MAX_BODY_CHARS} chars)`);
1253
+ const subject = cleanSubject(getFlag(args, "--subject"));
1254
+ const replyTo = cleanReply(getFlag(args, "--reply"));
966
1255
  const session = getFlag(args, "--session");
967
1256
  touchAgent(d, from, { sessionId: session || undefined, lastDir: process.cwd() });
968
- const id = newId("msg");
969
- const msg = { id, from, to, body: body.trim(), at: new Date().toISOString() };
970
- const dir = path.join(d.dm, to);
971
- fs.mkdirSync(dir, { recursive: true });
972
- writeJson(path.join(dir, `${id}.json`), msg);
973
- console.log(`sent ${id} -> ${to}`);
1257
+ const rev = gitRevForBoard(root);
1258
+ const at = new Date().toISOString();
1259
+ // One shared batch id per fan-out so recipients can tell they got the same
1260
+ // brief; each copy keeps a unique message id.
1261
+ const batch = recipients.length > 1 ? newId("batch") : undefined;
1262
+ const sent = [];
1263
+ for (const to of recipients) {
1264
+ const id = newId("msg");
1265
+ const msg = { id, from, to, body: body.trim(), at };
1266
+ if (subject) msg.subject = subject;
1267
+ if (replyTo) msg.replyTo = replyTo;
1268
+ if (batch) msg.batch = batch;
1269
+ if (rev) msg.rev = rev;
1270
+ const dir = path.join(d.dm, to);
1271
+ fs.mkdirSync(dir, { recursive: true });
1272
+ writeJson(path.join(dir, `${id}.json`), msg);
1273
+ sent.push(`${id} -> ${to}`);
1274
+ }
1275
+ if (sent.length === 1) {
1276
+ console.log(`sent ${sent[0]} [board ${d.root}]`);
1277
+ } else {
1278
+ console.log(`sent ${sent.length} messages [board ${d.root}]: ${sent.join(", ")}`);
1279
+ }
974
1280
  }
975
1281
 
976
1282
  function readDMs(d, recipient) {
@@ -980,18 +1286,31 @@ function readDMs(d, recipient) {
980
1286
  .sort((a, b) => (String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id))));
981
1287
  }
982
1288
 
1289
+ function msgHeader(m, showTo) {
1290
+ const bits = [`from ${m.from}`];
1291
+ if (showTo) bits.push(`-> ${m.to}`);
1292
+ bits.push(relTime(m.at));
1293
+ if (m.rev) bits.push(`rev ${m.rev}`);
1294
+ if (m.replyTo) bits.push(`re: ${m.replyTo}`);
1295
+ if (m.batch) bits.push(`batch ${m.batch}`);
1296
+ return `${m.id} (${bits.join(", ")})`;
1297
+ }
1298
+
983
1299
  function printMsg(m, showTo, json) {
984
1300
  if (json) {
985
1301
  console.log(JSON.stringify(m));
986
1302
  return;
987
1303
  }
988
- console.log(`${m.id} (from ${m.from}${showTo ? ` -> ${m.to}` : ""}, ${relTime(m.at)})`);
1304
+ console.log(msgHeader(m, showTo));
1305
+ if (m.subject) console.log(` subj: ${m.subject}`);
989
1306
  console.log(` ${m.body}`);
990
1307
  console.log("");
991
1308
  }
992
1309
 
993
1310
  function cmdInbox(args) {
994
- const d = ensureBoard(boardDir(args));
1311
+ const root = boardDir(args);
1312
+ refuseDriveRootBoard(root, args);
1313
+ const d = requireBoard(root);
995
1314
  const showAll = args.includes("--all");
996
1315
  const limit = Number(getFlag(args, "--limit") || 20);
997
1316
  const after = getFlag(args, "--after");
@@ -1032,14 +1351,18 @@ function cmdInbox(args) {
1032
1351
  return;
1033
1352
  }
1034
1353
  if (items.length === 0) {
1035
- console.log(showAll ? "no messages" : `no messages for ${resolveAgent(args, "reader")}`);
1354
+ // Always echo the board so an empty inbox reads as "nothing here" and
1355
+ // not "wrong board": compare with the sender's [board <path>].
1356
+ console.log(showAll ? `no messages [board ${d.root}]` : `no messages for ${resolveAgent(args, "reader")} [board ${d.root}]`);
1036
1357
  return;
1037
1358
  }
1038
1359
  for (const m of items) printMsg(m, showTo, false);
1039
1360
  }
1040
1361
 
1041
1362
  async function cmdListen(args) {
1042
- const d = ensureBoard(boardDir(args));
1363
+ const root = boardDir(args);
1364
+ refuseDriveRootBoard(root, args);
1365
+ const d = requireBoard(root);
1043
1366
  const agent = resolveAgent(args, "listener");
1044
1367
  const timeoutMs = Number(getFlag(args, "--timeout") || 0);
1045
1368
  const json = args.includes("--json");
@@ -1118,6 +1441,15 @@ function cmdDoctor(args) {
1118
1441
  if (meta && meta.version === 2) ok(`board at ${root} (v2)`);
1119
1442
  else no(`board at ${root}`, "run: agentboard init");
1120
1443
 
1444
+ // Split-board visibility: the most common multi-agent failure is two
1445
+ // sessions talking to two boards. Surface the resolution inputs.
1446
+ info(`cwd ${cwd}`);
1447
+ if (process.env.AGENTBOARD_DIR) info(`AGENTBOARD_DIR=${process.env.AGENTBOARD_DIR}`);
1448
+ else info(`AGENTBOARD_DIR unset (walk-up from cwd)`);
1449
+ const rev = gitRevForBoard(root);
1450
+ if (rev) info(`git rev ${rev} (sends stamp this so recipients spot stale file:line)`);
1451
+ else info(`not a git checkout (sends omit rev)`);
1452
+
1121
1453
  let ids = parseHarnessFlag(args);
1122
1454
  if (ids.length === 0) {
1123
1455
  if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
@@ -1223,7 +1555,11 @@ Identity:
1223
1555
  agentboard agents [--json]
1224
1556
 
1225
1557
  Messaging (primitive — just a tool call, whenever you want):
1226
- agentboard send --from <you> --to <peer> --body "..." [--session <id>]
1558
+ agentboard send --from <you> --to <peer> --body "..." [--subject "..."] [--reply <msg-id>] [--session <id>]
1559
+ (--to accepts a comma list for broadcast: --to alice,bob,carol — one DM
1560
+ each, same brief, shared batch id. Replies quote with --reply <msg-id>.
1561
+ Every send stamps the sender's git rev so recipients can spot stale
1562
+ file:line numbers.)
1227
1563
  agentboard inbox --from <you> [--limit 20] [--after <msg-id>] [--all] [--json]
1228
1564
  agentboard listen --from <you> [--timeout <ms>] [--json]
1229
1565
  (prints backlog, then blocks and prints new DMs as they arrive;
@@ -1234,7 +1570,9 @@ Messaging (primitive — just a tool call, whenever you want):
1234
1570
 
1235
1571
  Tips:
1236
1572
  set AGENTBOARD_AGENT=<name> to skip --from on every command
1237
- set AGENTBOARD_DIR=<path> (or --board <path>) to pick the board`;
1573
+ set AGENTBOARD_DIR=<path> (or --board <path>) to pick the board
1574
+ every send/inbox echoes [board <path>] — if two agents see different
1575
+ boards, point them at the same one`;
1238
1576
 
1239
1577
  async function main() {
1240
1578
  const [, , cmd, ...rest] = process.argv;