@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/AGENTBOARD.html +196 -0
- package/AGENTS.template.md +21 -11
- package/CHANGELOG.md +37 -0
- package/README.md +58 -7
- package/bin/agentboard-hook.js +49 -4
- package/bin/agentboard-mcp.js +145 -25
- package/bin/agentboard.js +399 -61
- package/opencode/plugins/dm-watch.js +37 -9
- package/opencode/tools/dm-send.js +152 -27
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
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.
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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) ||
|
|
260
|
-
lastSeen:
|
|
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 " +
|
|
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 "..."
|
|
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
|
|
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
|
|
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
|
|
1245
|
+
const root = boardDir(args);
|
|
1246
|
+
refuseDriveRootBoard(root, args);
|
|
1247
|
+
const d = ensureBoard(root);
|
|
960
1248
|
const from = resolveAgent(args, "sender");
|
|
961
|
-
const
|
|
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
|
|
969
|
-
const
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|