@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/AGENTBOARD.html +207 -0
- package/AGENTS.template.md +21 -11
- package/CHANGELOG.md +40 -0
- package/README.md +51 -10
- package/bin/agentboard-hook.js +13 -5
- package/bin/agentboard-mcp.js +103 -20
- package/bin/agentboard.js +423 -68
- package/opencode/plugins/dm-watch.js +69 -10
- 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", "--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
|
|
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
|
`;
|
|
@@ -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
|
-
|
|
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
|
|
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 "..."
|
|
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
|
|
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
|
|
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
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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":
|