@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.
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", "--replyTo", "--session", "--board", "--limit", "--after", "--timeout", "--id"]);
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
  `;
@@ -346,6 +503,13 @@ const OPENCODE_PLUGIN_DM_WATCH = `// .opencode/plugins/dm-watch.js — inject DM
346
503
  // mixing harnesses never get a message twice.
347
504
  // Polls every 1s; that poll is the source of truth (no fs.watch dependency).
348
505
  //
506
+ // Delivery is at-least-once: the claim wins the race between watcher
507
+ // instances, but the marker is released (and the cursor left alone) when
508
+ // promptAsync throws — e.g. pushing into a stale session from yesterday
509
+ // fails with \`encrypted_content was not issued to this caller\`. Stale
510
+ // session mappings are then invalidated so mail waits for pull until the
511
+ // live session re-registers, instead of being black-holed as delivered.
512
+ //
349
513
  // Agents with no known session are skipped — their mail waits in the inbox
350
514
  // for pull (\`inbox --from <you>\`), so one idle session never steals another
351
515
  // agent's mail.
@@ -442,6 +606,40 @@ export const DmWatchPlugin = async ({ client, directory }) => {
442
606
  }
443
607
  }
444
608
 
609
+ // Roll back a claim won above when the push itself fails: remove the
610
+ // on-disk marker and the in-memory entry so the next poll retries.
611
+ // The cursor is intentionally left alone here — it only advances on
612
+ // success, so a failed push never fast-forwards past undelivered mail.
613
+ function releaseClaim(agent, id) {
614
+ processed.delete(agent + "/" + id);
615
+ try {
616
+ fs.rmSync(deliveredMarker(agent, id), { force: true });
617
+ } catch {}
618
+ }
619
+
620
+ // promptAsync into a dead session throws provider errors like
621
+ // "[invalid_request_error] reasoning \`encrypted_content\` was not issued
622
+ // to this caller". Those mean the routing entry is stale, not the
623
+ // message — drop the mapping so mail waits for pull (\`inbox\`) until the
624
+ // live session re-registers via \`register --session\` or \`dm-send\`.
625
+ function isStaleSessionError(e) {
626
+ const s = String((e && e.message ? e.message : e) || "");
627
+ return /encrypted_content|invalid_request_error|unknown session|session not found|no such session|not issued to this caller/i.test(s);
628
+ }
629
+
630
+ function invalidateSession(agent, sessionID) {
631
+ agentToSession.delete(agent);
632
+ try {
633
+ const p = path.join(agentsDir, agent + ".json");
634
+ const doc = readJsonSafe(p);
635
+ if (doc && doc.sessionId === sessionID) {
636
+ delete doc.sessionId;
637
+ doc.lastSeen = new Date().toISOString();
638
+ fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\\n");
639
+ }
640
+ } catch {}
641
+ }
642
+
445
643
  // Cursor file shared with agentboard-hook: hook delivery moves it, and we
446
644
  // honor it (plus our markers) so mixed-harness agents never get doubles.
447
645
  // We also advance it on our own deliveries.
@@ -485,30 +683,44 @@ export const DmWatchPlugin = async ({ client, directory }) => {
485
683
  return curIdx !== -1 && idx !== -1 && idx <= curIdx;
486
684
  }
487
685
 
686
+ function formatDm(msg) {
687
+ let head = "[DM from " + msg.from + " @ " + (msg.at || "unknown time");
688
+ if (msg.rev) head += \` (rev \${msg.rev})\`;
689
+ if (msg.batch) head += \` [batch \${msg.batch}]\`;
690
+ if (msg.replyTo) head += \` re: \${msg.replyTo}\`;
691
+ head += "]";
692
+ const subj = msg.subject ? \`subj: \${msg.subject}\\n\` : "";
693
+ return (
694
+ head +
695
+ "\\n" +
696
+ subj +
697
+ msg.body +
698
+ "\\n\\n(Reply with dm-send (replyTo: \\"" +
699
+ msg.id +
700
+ "\\") 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.)"
701
+ );
702
+ }
703
+
488
704
  async function deliver(agent, sessionID, msg) {
489
705
  if (!claim(agent, msg.id, sessionID)) return;
490
- 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.)";
706
+ const text = formatDm(msg);
499
707
  try {
500
708
  if (client.session && typeof client.session.promptAsync === "function") {
501
709
  await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
502
710
  } else if (client.session && typeof client.session.prompt === "function") {
503
711
  await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
504
712
  }
713
+ advanceCursor(agent, msg.id);
505
714
  } catch (e) {
715
+ const detail = e && e.message ? e.message : String(e);
716
+ releaseClaim(agent, msg.id);
717
+ if (isStaleSessionError(e)) invalidateSession(agent, sessionID);
506
718
  try {
507
719
  await client.app.log({
508
720
  body: {
509
721
  service: "dm-watch",
510
722
  level: "warn",
511
- message: "DM wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
723
+ message: "DM push failed for " + agent + " (" + msg.id + "), released for retry: " + detail,
512
724
  },
513
725
  });
514
726
  } catch {}
@@ -560,6 +772,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
560
772
  from: msg.from,
561
773
  body: String(msg.body),
562
774
  at: msg.at || "",
775
+ subject: msg.subject,
776
+ replyTo: msg.replyTo,
777
+ batch: msg.batch,
778
+ rev: msg.rev,
563
779
  });
564
780
  }
565
781
  }
@@ -593,12 +809,20 @@ Binary: \`{CLI}\` (board lives in \`./.agentboard\`, or \`$env:AGENTBOARD_DIR\`)
593
809
 
594
810
  {CLI} register --from <you> [--session <opencode-session-id>]
595
811
  {CLI} agents
596
- {CLI} send --from <you> --to <peer> --body "..." # just a tool call, whenever you want
812
+ {CLI} send --from <you> --to <peer> --body "..." [--subject "..."] [--reply <msg-id>]
597
813
  {CLI} inbox --from <you> [--limit 20] [--after <msg-id>] [--json]
598
814
  {CLI} listen --from <you> [--timeout 60000] # block and print new DMs as they arrive
599
815
 
816
+ Fanning out work ("assign N agents"): there is no task object — the DM *is*
817
+ the task. \`--to alice,bob,carol\` sends one copy each (shared batch id),
818
+ each agent owns its scope, decides itself, and DMs a summary back. Use
819
+ \`--subject\` for the mission line, \`--reply <msg-id>\` to thread answers,
820
+ and re-read cited files before flagging (every DM stamps the sender's git
821
+ rev so you can spot stale file:line numbers).
822
+
600
823
  On opencode the \`dm-send\` tool does the same as \`send\` (and registers your session for push).
601
824
  Incoming DMs are inserted into your context automatically by the watcher plugin — otherwise poll \`inbox\` often.
825
+ Every send/inbox echoes \`[board <path>]\`: if two agents see different boards, export \`AGENTBOARD_DIR=<board>\` so all sessions share one.
602
826
 
603
827
  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
828
  <!-- agentboard:end -->
@@ -1018,8 +1242,31 @@ function cmdRegister(args) {
1018
1242
  console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""} [board ${d.root}]`);
1019
1243
  }
1020
1244
 
1245
+ // Read-side commands (agents/inbox/listen) must never plant a board: if the
1246
+ // resolved board has no board.json, fail loudly instead of showing an empty
1247
+ // room that hides a split-board misconfiguration.
1248
+ function requireBoard(root) {
1249
+ let meta = null;
1250
+ try {
1251
+ meta = readJson(path.join(root, "board.json"));
1252
+ } catch {}
1253
+ if (!meta || meta.version !== BOARD_VERSION) {
1254
+ fail(
1255
+ `no board at ${root} (cwd "${process.cwd()}"). ` +
1256
+ `Run from your project (the dir containing .agentboard/), pass --board <absolute path to .agentboard>, or set AGENTBOARD_DIR. ` +
1257
+ `If you just created one elsewhere, every send echoes [board <path>] — point all agents at the same one.`
1258
+ );
1259
+ }
1260
+ const d = dirs(root);
1261
+ for (const p of [d.root, d.agents, d.dm, d.delivered]) {
1262
+ fs.mkdirSync(p, { recursive: true });
1263
+ }
1264
+ return d;
1265
+ }
1266
+
1021
1267
  function cmdAgents(args) {
1022
- const d = ensureBoard(boardDir(args));
1268
+ const root = boardDir(args);
1269
+ const d = requireBoard(root);
1023
1270
  const items = listJson(d.agents)
1024
1271
  .map((e) => e.data)
1025
1272
  .filter((x) => x && x.name)
@@ -1035,6 +1282,7 @@ function cmdAgents(args) {
1035
1282
  for (const a of items) {
1036
1283
  console.log(`${a.name} (last seen ${relTime(a.lastSeen)}${a.sessionId ? `, session ${a.sessionId}` : ""})`);
1037
1284
  }
1285
+ console.log(`[board ${d.root}]`);
1038
1286
  }
1039
1287
 
1040
1288
  function cmdSend(args) {
@@ -1042,19 +1290,37 @@ function cmdSend(args) {
1042
1290
  refuseDriveRootBoard(root, args);
1043
1291
  const d = ensureBoard(root);
1044
1292
  const from = resolveAgent(args, "sender");
1045
- const toRaw = getFlag(args, "--to");
1046
- const to = sanitizeName(toRaw, "recipient");
1293
+ const recipients = parseRecipients(getFlag(args, "--to"));
1047
1294
  const body = getFlag(args, "--body") || restArgs(args).join(" ");
1048
1295
  if (!body || !body.trim()) fail('missing message body (--body "...")');
1049
1296
  if (body.length > MAX_BODY_CHARS) fail(`message body too large (max ${MAX_BODY_CHARS} chars)`);
1297
+ const subject = cleanSubject(getFlag(args, "--subject"));
1298
+ const replyTo = cleanReply(getFlag(args, "--reply"));
1050
1299
  const session = getFlag(args, "--session");
1051
1300
  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}]`);
1301
+ const rev = gitRevForBoard(root);
1302
+ const at = new Date().toISOString();
1303
+ // One shared batch id per fan-out so recipients can tell they got the same
1304
+ // brief; each copy keeps a unique message id.
1305
+ const batch = recipients.length > 1 ? newId("batch") : undefined;
1306
+ const sent = [];
1307
+ for (const to of recipients) {
1308
+ const id = newId("msg");
1309
+ const msg = { id, from, to, body: body.trim(), at };
1310
+ if (subject) msg.subject = subject;
1311
+ if (replyTo) msg.replyTo = replyTo;
1312
+ if (batch) msg.batch = batch;
1313
+ if (rev) msg.rev = rev;
1314
+ const dir = path.join(d.dm, to);
1315
+ fs.mkdirSync(dir, { recursive: true });
1316
+ writeJson(path.join(dir, `${id}.json`), msg);
1317
+ sent.push(`${id} -> ${to}`);
1318
+ }
1319
+ if (sent.length === 1) {
1320
+ console.log(`sent ${sent[0]} [board ${d.root}]`);
1321
+ } else {
1322
+ console.log(`sent ${sent.length} messages [board ${d.root}]: ${sent.join(", ")}`);
1323
+ }
1058
1324
  }
1059
1325
 
1060
1326
  function readDMs(d, recipient) {
@@ -1064,12 +1330,23 @@ function readDMs(d, recipient) {
1064
1330
  .sort((a, b) => (String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id))));
1065
1331
  }
1066
1332
 
1333
+ function msgHeader(m, showTo) {
1334
+ const bits = [`from ${m.from}`];
1335
+ if (showTo) bits.push(`-> ${m.to}`);
1336
+ bits.push(relTime(m.at));
1337
+ if (m.rev) bits.push(`rev ${m.rev}`);
1338
+ if (m.replyTo) bits.push(`re: ${m.replyTo}`);
1339
+ if (m.batch) bits.push(`batch ${m.batch}`);
1340
+ return `${m.id} (${bits.join(", ")})`;
1341
+ }
1342
+
1067
1343
  function printMsg(m, showTo, json) {
1068
1344
  if (json) {
1069
1345
  console.log(JSON.stringify(m));
1070
1346
  return;
1071
1347
  }
1072
- console.log(`${m.id} (from ${m.from}${showTo ? ` -> ${m.to}` : ""}, ${relTime(m.at)})`);
1348
+ console.log(msgHeader(m, showTo));
1349
+ if (m.subject) console.log(` subj: ${m.subject}`);
1073
1350
  console.log(` ${m.body}`);
1074
1351
  console.log("");
1075
1352
  }
@@ -1077,7 +1354,7 @@ function printMsg(m, showTo, json) {
1077
1354
  function cmdInbox(args) {
1078
1355
  const root = boardDir(args);
1079
1356
  refuseDriveRootBoard(root, args);
1080
- const d = ensureBoard(root);
1357
+ const d = requireBoard(root);
1081
1358
  const showAll = args.includes("--all");
1082
1359
  const limit = Number(getFlag(args, "--limit") || 20);
1083
1360
  const after = getFlag(args, "--after");
@@ -1118,7 +1395,9 @@ function cmdInbox(args) {
1118
1395
  return;
1119
1396
  }
1120
1397
  if (items.length === 0) {
1121
- console.log(showAll ? "no messages" : `no messages for ${resolveAgent(args, "reader")}`);
1398
+ // Always echo the board so an empty inbox reads as "nothing here" and
1399
+ // not "wrong board": compare with the sender's [board <path>].
1400
+ console.log(showAll ? `no messages [board ${d.root}]` : `no messages for ${resolveAgent(args, "reader")} [board ${d.root}]`);
1122
1401
  return;
1123
1402
  }
1124
1403
  for (const m of items) printMsg(m, showTo, false);
@@ -1127,7 +1406,7 @@ function cmdInbox(args) {
1127
1406
  async function cmdListen(args) {
1128
1407
  const root = boardDir(args);
1129
1408
  refuseDriveRootBoard(root, args);
1130
- const d = ensureBoard(root);
1409
+ const d = requireBoard(root);
1131
1410
  const agent = resolveAgent(args, "listener");
1132
1411
  const timeoutMs = Number(getFlag(args, "--timeout") || 0);
1133
1412
  const json = args.includes("--json");
@@ -1182,6 +1461,62 @@ async function cmdListen(args) {
1182
1461
  finish();
1183
1462
  }
1184
1463
 
1464
+ // ---------------------------------------------------------------------------
1465
+ // redeliver: recover mail a dead watcher consumed (claimed + cursor moved,
1466
+ // push failed). Clears delivered/<agent>/<id>.json markers and rewinds
1467
+ // cursors/<agent>.json so the next poll/push treats the message as fresh.
1468
+ // The DM itself is never touched — inbox always shows the full history.
1469
+ // ---------------------------------------------------------------------------
1470
+
1471
+ function cmdRedeliver(args) {
1472
+ const root = boardDir(args);
1473
+ refuseDriveRootBoard(root, args);
1474
+ const d = requireBoard(root);
1475
+ const agent = resolveAgent(args, "agent");
1476
+ const id = getFlag(args, "--id");
1477
+ const all = args.includes("--all");
1478
+ if (!id && !all) fail('missing --id <msg-id> (or --all to reset every delivery marker)');
1479
+ if (id && all) fail('pass --id <msg-id> or --all, not both');
1480
+ const order = readDMs(d, agent).map((m) => m.id);
1481
+ let ids;
1482
+ if (all) {
1483
+ ids = order.slice();
1484
+ if (ids.length === 0) fail(`no messages for ${agent}`);
1485
+ } else {
1486
+ if (!order.includes(id)) fail(`unknown message "${id}" for ${agent} (check inbox --from ${agent})`);
1487
+ ids = [id];
1488
+ }
1489
+ for (const mid of ids) {
1490
+ try {
1491
+ fs.rmSync(path.join(d.delivered, agent, `${mid}.json`), { force: true });
1492
+ } catch {}
1493
+ }
1494
+ // Rewind the cursor to the message before the earliest redelivered one so
1495
+ // hook polls and the opencode watcher see it as fresh again. If the
1496
+ // earliest redelivered message is the first in the log (or --all), drop
1497
+ // the cursor entirely.
1498
+ const cursorPath = path.join(d.root, "cursors", `${agent}.json`);
1499
+ if (all) {
1500
+ try {
1501
+ fs.rmSync(cursorPath, { force: true });
1502
+ } catch {}
1503
+ } else {
1504
+ const earliest = order.indexOf(ids[0]);
1505
+ if (earliest <= 0) {
1506
+ try {
1507
+ fs.rmSync(cursorPath, { force: true });
1508
+ } catch {}
1509
+ } else {
1510
+ writeJson(cursorPath, { lastId: order[earliest - 1], at: new Date().toISOString() });
1511
+ }
1512
+ }
1513
+ if (ids.length === 1) {
1514
+ console.log(`redelivered ${ids[0]} for ${agent} [board ${d.root}]`);
1515
+ } else {
1516
+ console.log(`redelivered ${ids.length} messages for ${agent} [board ${d.root}]`);
1517
+ }
1518
+ }
1519
+
1185
1520
  // ---------------------------------------------------------------------------
1186
1521
  // doctor: validate board + harness wiring
1187
1522
  // ---------------------------------------------------------------------------
@@ -1206,6 +1541,15 @@ function cmdDoctor(args) {
1206
1541
  if (meta && meta.version === 2) ok(`board at ${root} (v2)`);
1207
1542
  else no(`board at ${root}`, "run: agentboard init");
1208
1543
 
1544
+ // Split-board visibility: the most common multi-agent failure is two
1545
+ // sessions talking to two boards. Surface the resolution inputs.
1546
+ info(`cwd ${cwd}`);
1547
+ if (process.env.AGENTBOARD_DIR) info(`AGENTBOARD_DIR=${process.env.AGENTBOARD_DIR}`);
1548
+ else info(`AGENTBOARD_DIR unset (walk-up from cwd)`);
1549
+ const rev = gitRevForBoard(root);
1550
+ if (rev) info(`git rev ${rev} (sends stamp this so recipients spot stale file:line)`);
1551
+ else info(`not a git checkout (sends omit rev)`);
1552
+
1209
1553
  let ids = parseHarnessFlag(args);
1210
1554
  if (ids.length === 0) {
1211
1555
  if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
@@ -1311,18 +1655,28 @@ Identity:
1311
1655
  agentboard agents [--json]
1312
1656
 
1313
1657
  Messaging (primitive — just a tool call, whenever you want):
1314
- agentboard send --from <you> --to <peer> --body "..." [--session <id>]
1658
+ agentboard send --from <you> --to <peer> --body "..." [--subject "..."] [--reply <msg-id>] [--session <id>]
1659
+ (--to accepts a comma list for broadcast: --to alice,bob,carol — one DM
1660
+ each, same brief, shared batch id. Replies quote with --reply <msg-id>.
1661
+ Every send stamps the sender's git rev so recipients can spot stale
1662
+ file:line numbers.)
1315
1663
  agentboard inbox --from <you> [--limit 20] [--after <msg-id>] [--all] [--json]
1316
1664
  agentboard listen --from <you> [--timeout <ms>] [--json]
1317
1665
  (prints backlog, then blocks and prints new DMs as they arrive;
1318
1666
  opencode plugin injects into context automatically instead of polling)
1667
+ agentboard redeliver --from <you> (--id <msg-id> | --all)
1668
+ (recover mail a dead watcher consumed: clears delivered markers and
1669
+ rewinds the cursor so the next poll/push treats it as fresh;
1670
+ use after re-registering with the live session)
1319
1671
 
1320
1672
  agentboard doctor [--harness <list>] [--board <path>]
1321
1673
  (validate board + harness wiring; exit 1 with FAIL lines when broken)
1322
1674
 
1323
1675
  Tips:
1324
1676
  set AGENTBOARD_AGENT=<name> to skip --from on every command
1325
- set AGENTBOARD_DIR=<path> (or --board <path>) to pick the board`;
1677
+ set AGENTBOARD_DIR=<path> (or --board <path>) to pick the board
1678
+ every send/inbox echoes [board <path>] — if two agents see different
1679
+ boards, point them at the same one`;
1326
1680
 
1327
1681
  async function main() {
1328
1682
  const [, , cmd, ...rest] = process.argv;
@@ -1336,6 +1690,7 @@ async function main() {
1336
1690
  case "send": return cmdSend(rest);
1337
1691
  case "inbox": return cmdInbox(rest);
1338
1692
  case "listen": return await cmdListen(rest);
1693
+ case "redeliver": return cmdRedeliver(rest);
1339
1694
  case "doctor": return cmdDoctor(rest);
1340
1695
  case undefined:
1341
1696
  case "-h":