@eamonpluto/agentboard 2.3.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;
@@ -82,13 +83,27 @@ function refuseDriveRootBoard(root, args) {
82
83
  } catch {}
83
84
  if (exists) return;
84
85
  if (path.dirname(root) === path.parse(root).root) {
86
+ const cwd = process.cwd();
85
87
  fail(
86
- `refusing to create a board at drive root ${root} — no project board found above cwd. ` +
87
- `Run from your project, pass --board <path>, or set AGENTBOARD_DIR.`
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.`
88
91
  );
89
92
  }
90
93
  }
91
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
+ }
105
+ }
106
+
92
107
  function dirs(root) {
93
108
  return {
94
109
  root,
@@ -102,7 +117,8 @@ function ensureBoard(root) {
102
117
  const d = dirs(root);
103
118
  for (const p of [d.root, d.agents, d.dm, d.delivered]) {
104
119
  fs.mkdirSync(p, { recursive: true });
105
- } const metaPath = path.join(d.root, "board.json");
120
+ }
121
+ const metaPath = path.join(d.root, "board.json");
106
122
  if (!fs.existsSync(metaPath)) {
107
123
  fs.writeFileSync(
108
124
  metaPath,
@@ -117,9 +133,22 @@ function readJson(p) {
117
133
  }
118
134
 
119
135
  function writeJson(p, obj) {
120
- const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
136
+ const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
121
137
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
122
- 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
+ }
123
152
  }
124
153
 
125
154
  /** Atomic fire-once claim: create file only if it does not exist. */
@@ -144,7 +173,9 @@ function newId(prefix) {
144
173
  String(t.getUTCHours()).padStart(2, "0") +
145
174
  String(t.getUTCMinutes()).padStart(2, "0") +
146
175
  String(t.getUTCSeconds()).padStart(2, "0");
147
- 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")}`;
148
179
  }
149
180
 
150
181
  function sanitizeName(name, what) {
@@ -161,7 +192,36 @@ function getFlag(args, flag) {
161
192
 
162
193
  // Positional args with flag values removed (so `send --from alice --to bob`
163
194
  // with no body doesn't mistake "alice bob" for a message).
164
- 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
+ }
165
225
  function restArgs(args) {
166
226
  const out = [];
167
227
  for (let i = 0; i < args.length; i++) {
@@ -223,13 +283,22 @@ const OPENCODE_TOOL_DM_SEND = `// .opencode/tools/dm-send.js — primitive DM to
223
283
  //
224
284
  // Usage from the agent (just a tool call, whenever you want):
225
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\`.
226
291
  //
227
292
  // What it does:
228
- // 1. resolves the board (AGENTBOARD_DIR env, else <worktree>/.agentboard)
229
- // 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)
230
297
  // 3. upserts .agentboard/agents/<from>.json with { lastSeen, sessionId }
231
298
  // so the watcher plugin can route pushes back to the right session.
232
- // 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.
233
302
  //
234
303
  // Delivery ("inserted into context") is done by ../plugins/dm-watch.js, which
235
304
  // polls dm/ and injects via client.session.promptAsync. This tool never blocks
@@ -239,17 +308,8 @@ import { tool } from "@opencode-ai/plugin";
239
308
  import fs from "node:fs";
240
309
  import path from "node:path";
241
310
  import crypto from "node:crypto";
311
+ import { execFileSync } from "node:child_process";
242
312
 
243
- function boardRoot(worktree, override) {
244
- if (override) return path.resolve(String(override));
245
- if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
246
- const base = worktree || process.cwd();
247
- return findBoardUpward(base) || path.join(base, ".agentboard");
248
- }
249
-
250
- // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
251
- // Harnesses sometimes run agents with a cwd below (or beside) the project;
252
- // walk-up keeps every session on the same board.
253
313
  function findBoardUpward(start) {
254
314
  let dir = path.resolve(start);
255
315
  for (;;) {
@@ -262,6 +322,31 @@ function findBoardUpward(start) {
262
322
  }
263
323
  }
264
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 };
348
+ }
349
+
265
350
  function clean(name, what) {
266
351
  if (!name) throw new Error("missing " + what);
267
352
  const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
@@ -269,51 +354,122 @@ function clean(name, what) {
269
354
  return c;
270
355
  }
271
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
+
272
397
  function writeJsonAtomic(p, obj) {
273
398
  fs.mkdirSync(path.dirname(p), { recursive: true });
274
- const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
399
+ const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
275
400
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\\n");
276
- 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")}\`;
277
426
  }
278
427
 
279
428
  export default tool({
280
429
  description:
281
- "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), board (optional absolute board path when your session runs outside the project).",
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).",
282
431
  args: {
283
432
  from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
284
- 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."),
285
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)."),
286
437
  board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
287
438
  },
288
439
  async execute(args, context) {
289
440
  const from = clean(args.from, "from");
290
- const to = clean(args.to, "to");
441
+ const recipients = parseRecipients(args.to);
291
442
  const body = String(args.body || "").trim();
292
443
  if (!body) return "error: empty body";
293
444
  if (body.length > 8000) return "error: body too large (max 8000 chars)";
445
+ const subject = cleanSubject(args.subject);
446
+ const replyTo = cleanReply(args.replyTo);
294
447
  const boardArg = args.board === undefined || args.board === null || String(args.board).trim() === "" ? undefined : String(args.board);
295
- const root = boardRoot(context.worktree || context.directory || process.cwd(), boardArg);
448
+ const worktree = context.worktree || context.directory || process.cwd();
449
+ const { root, tried } = boardRoot([worktree, context.directory, process.cwd()], boardArg);
296
450
  if (!boardArg && !process.env.AGENTBOARD_DIR) {
297
451
  let exists = false;
298
452
  try {
299
453
  exists = fs.statSync(root).isDirectory();
300
454
  } catch {}
301
455
  if (!exists && path.dirname(root) === path.parse(root).root) {
302
- return \`error: refusing to create a board at drive root \${root} — pass board (absolute path) or set AGENTBOARD_DIR\`;
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.\`;
303
457
  }
304
458
  }
305
- const now = new Date().toISOString();
306
- const t = new Date();
307
- const stamp =
308
- String(t.getUTCFullYear()).slice(2) +
309
- String(t.getUTCMonth() + 1).padStart(2, "0") +
310
- String(t.getUTCDate()).padStart(2, "0") +
311
- "-" +
312
- String(t.getUTCHours()).padStart(2, "0") +
313
- String(t.getUTCMinutes()).padStart(2, "0") +
314
- String(t.getUTCSeconds()).padStart(2, "0");
315
- const id = "msg-" + stamp + "-" + crypto.randomBytes(3).toString("hex");
316
- writeJsonAtomic(path.join(root, "dm", to, id + ".json"), { id, from, to, body, at: now });
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
+ }
317
473
  // upsert sender with live session routing for the watcher plugin
318
474
  const ap = path.join(root, "agents", from + ".json");
319
475
  let prev = null;
@@ -322,12 +478,13 @@ export default tool({
322
478
  } catch {}
323
479
  writeJsonAtomic(ap, {
324
480
  name: from,
325
- firstSeen: (prev && prev.firstSeen) || now,
326
- lastSeen: now,
481
+ firstSeen: (prev && prev.firstSeen) || new Date().toISOString(),
482
+ lastSeen: new Date().toISOString(),
327
483
  sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
328
484
  lastDir: context.worktree || context.directory || undefined,
329
485
  });
330
- return "sent " + id + " -> " + to + " [board " + root + "]";
486
+ if (sent.length === 1) return "sent " + sent[0] + " [board " + root + "]";
487
+ return \`sent \${sent.length} messages [board \${root}]: \${sent.join(", ")}\`;
331
488
  },
332
489
  });
333
490
  `;
@@ -485,17 +642,28 @@ export const DmWatchPlugin = async ({ client, directory }) => {
485
642
  return curIdx !== -1 && idx !== -1 && idx <= curIdx;
486
643
  }
487
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
+
488
663
  async function deliver(agent, sessionID, msg) {
489
664
  if (!claim(agent, msg.id, sessionID)) return;
490
665
  advanceCursor(agent, msg.id);
491
- const text =
492
- "[DM from " +
493
- msg.from +
494
- " @ " +
495
- msg.at +
496
- "]\\n" +
497
- msg.body +
498
- "\\n\\n(Reply with dm-send if needed, or continue current work if unrelated.)";
666
+ const text = formatDm(msg);
499
667
  try {
500
668
  if (client.session && typeof client.session.promptAsync === "function") {
501
669
  await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
@@ -560,6 +728,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
560
728
  from: msg.from,
561
729
  body: String(msg.body),
562
730
  at: msg.at || "",
731
+ subject: msg.subject,
732
+ replyTo: msg.replyTo,
733
+ batch: msg.batch,
734
+ rev: msg.rev,
563
735
  });
564
736
  }
565
737
  }
@@ -593,12 +765,20 @@ Binary: \`{CLI}\` (board lives in \`./.agentboard\`, or \`$env:AGENTBOARD_DIR\`)
593
765
 
594
766
  {CLI} register --from <you> [--session <opencode-session-id>]
595
767
  {CLI} agents
596
- {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>]
597
769
  {CLI} inbox --from <you> [--limit 20] [--after <msg-id>] [--json]
598
770
  {CLI} listen --from <you> [--timeout 60000] # block and print new DMs as they arrive
599
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
+
600
779
  On opencode the \`dm-send\` tool does the same as \`send\` (and registers your session for push).
601
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.
602
782
 
603
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.
604
784
  <!-- agentboard:end -->
@@ -1018,8 +1198,31 @@ function cmdRegister(args) {
1018
1198
  console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""} [board ${d.root}]`);
1019
1199
  }
1020
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;
1221
+ }
1222
+
1021
1223
  function cmdAgents(args) {
1022
- const d = ensureBoard(boardDir(args));
1224
+ const root = boardDir(args);
1225
+ const d = requireBoard(root);
1023
1226
  const items = listJson(d.agents)
1024
1227
  .map((e) => e.data)
1025
1228
  .filter((x) => x && x.name)
@@ -1035,6 +1238,7 @@ function cmdAgents(args) {
1035
1238
  for (const a of items) {
1036
1239
  console.log(`${a.name} (last seen ${relTime(a.lastSeen)}${a.sessionId ? `, session ${a.sessionId}` : ""})`);
1037
1240
  }
1241
+ console.log(`[board ${d.root}]`);
1038
1242
  }
1039
1243
 
1040
1244
  function cmdSend(args) {
@@ -1042,19 +1246,37 @@ function cmdSend(args) {
1042
1246
  refuseDriveRootBoard(root, args);
1043
1247
  const d = ensureBoard(root);
1044
1248
  const from = resolveAgent(args, "sender");
1045
- const toRaw = getFlag(args, "--to");
1046
- const to = sanitizeName(toRaw, "recipient");
1249
+ const recipients = parseRecipients(getFlag(args, "--to"));
1047
1250
  const body = getFlag(args, "--body") || restArgs(args).join(" ");
1048
1251
  if (!body || !body.trim()) fail('missing message body (--body "...")');
1049
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"));
1050
1255
  const session = getFlag(args, "--session");
1051
1256
  touchAgent(d, from, { sessionId: session || undefined, lastDir: process.cwd() });
1052
- const id = newId("msg");
1053
- const msg = { id, from, to, body: body.trim(), at: new Date().toISOString() };
1054
- const dir = path.join(d.dm, to);
1055
- fs.mkdirSync(dir, { recursive: true });
1056
- writeJson(path.join(dir, `${id}.json`), msg);
1057
- console.log(`sent ${id} -> ${to} [board ${d.root}]`);
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
+ }
1058
1280
  }
1059
1281
 
1060
1282
  function readDMs(d, recipient) {
@@ -1064,12 +1286,23 @@ function readDMs(d, recipient) {
1064
1286
  .sort((a, b) => (String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id))));
1065
1287
  }
1066
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
+
1067
1299
  function printMsg(m, showTo, json) {
1068
1300
  if (json) {
1069
1301
  console.log(JSON.stringify(m));
1070
1302
  return;
1071
1303
  }
1072
- 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}`);
1073
1306
  console.log(` ${m.body}`);
1074
1307
  console.log("");
1075
1308
  }
@@ -1077,7 +1310,7 @@ function printMsg(m, showTo, json) {
1077
1310
  function cmdInbox(args) {
1078
1311
  const root = boardDir(args);
1079
1312
  refuseDriveRootBoard(root, args);
1080
- const d = ensureBoard(root);
1313
+ const d = requireBoard(root);
1081
1314
  const showAll = args.includes("--all");
1082
1315
  const limit = Number(getFlag(args, "--limit") || 20);
1083
1316
  const after = getFlag(args, "--after");
@@ -1118,7 +1351,9 @@ function cmdInbox(args) {
1118
1351
  return;
1119
1352
  }
1120
1353
  if (items.length === 0) {
1121
- 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}]`);
1122
1357
  return;
1123
1358
  }
1124
1359
  for (const m of items) printMsg(m, showTo, false);
@@ -1127,7 +1362,7 @@ function cmdInbox(args) {
1127
1362
  async function cmdListen(args) {
1128
1363
  const root = boardDir(args);
1129
1364
  refuseDriveRootBoard(root, args);
1130
- const d = ensureBoard(root);
1365
+ const d = requireBoard(root);
1131
1366
  const agent = resolveAgent(args, "listener");
1132
1367
  const timeoutMs = Number(getFlag(args, "--timeout") || 0);
1133
1368
  const json = args.includes("--json");
@@ -1206,6 +1441,15 @@ function cmdDoctor(args) {
1206
1441
  if (meta && meta.version === 2) ok(`board at ${root} (v2)`);
1207
1442
  else no(`board at ${root}`, "run: agentboard init");
1208
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
+
1209
1453
  let ids = parseHarnessFlag(args);
1210
1454
  if (ids.length === 0) {
1211
1455
  if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
@@ -1311,7 +1555,11 @@ Identity:
1311
1555
  agentboard agents [--json]
1312
1556
 
1313
1557
  Messaging (primitive — just a tool call, whenever you want):
1314
- 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.)
1315
1563
  agentboard inbox --from <you> [--limit 20] [--after <msg-id>] [--all] [--json]
1316
1564
  agentboard listen --from <you> [--timeout <ms>] [--json]
1317
1565
  (prints backlog, then blocks and prints new DMs as they arrive;
@@ -1322,7 +1570,9 @@ Messaging (primitive — just a tool call, whenever you want):
1322
1570
 
1323
1571
  Tips:
1324
1572
  set AGENTBOARD_AGENT=<name> to skip --from on every command
1325
- 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`;
1326
1576
 
1327
1577
  async function main() {
1328
1578
  const [, , cmd, ...rest] = process.argv;
@@ -151,17 +151,28 @@ export const DmWatchPlugin = async ({ client, directory }) => {
151
151
  return curIdx !== -1 && idx !== -1 && idx <= curIdx;
152
152
  }
153
153
 
154
+ function formatDm(msg) {
155
+ let head = "[DM from " + msg.from + " @ " + (msg.at || "unknown time");
156
+ if (msg.rev) head += ` (rev ${msg.rev})`;
157
+ if (msg.batch) head += ` [batch ${msg.batch}]`;
158
+ if (msg.replyTo) head += ` re: ${msg.replyTo}`;
159
+ head += "]";
160
+ const subj = msg.subject ? `subj: ${msg.subject}\n` : "";
161
+ return (
162
+ head +
163
+ "\n" +
164
+ subj +
165
+ msg.body +
166
+ "\n\n(Reply with dm-send (replyTo: \"" +
167
+ msg.id +
168
+ "\") 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.)"
169
+ );
170
+ }
171
+
154
172
  async function deliver(agent, sessionID, msg) {
155
173
  if (!claim(agent, msg.id, sessionID)) return;
156
174
  advanceCursor(agent, msg.id);
157
- const text =
158
- "[DM from " +
159
- msg.from +
160
- " @ " +
161
- msg.at +
162
- "]\n" +
163
- msg.body +
164
- "\n\n(Reply with dm-send if needed, or continue current work if unrelated.)";
175
+ const text = formatDm(msg);
165
176
  try {
166
177
  if (client.session && typeof client.session.promptAsync === "function") {
167
178
  await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
@@ -226,6 +237,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
226
237
  from: msg.from,
227
238
  body: String(msg.body),
228
239
  at: msg.at || "",
240
+ subject: msg.subject,
241
+ replyTo: msg.replyTo,
242
+ batch: msg.batch,
243
+ rev: msg.rev,
229
244
  });
230
245
  }
231
246
  }