@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/AGENTBOARD.html +196 -0
- package/AGENTS.template.md +21 -11
- package/CHANGELOG.md +26 -0
- package/README.md +51 -10
- package/bin/agentboard-hook.js +13 -5
- package/bin/agentboard-mcp.js +103 -20
- package/bin/agentboard.js +316 -66
- package/opencode/plugins/dm-watch.js +23 -8
- package/opencode/tools/dm-send.js +132 -35
- 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;
|
|
@@ -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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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) ||
|
|
326
|
-
lastSeen:
|
|
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
|
-
|
|
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 "..."
|
|
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
|
|
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
|
|
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
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|