@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
|
@@ -23,7 +23,20 @@ const POLL_MS = 1000;
|
|
|
23
23
|
|
|
24
24
|
function boardRoot(directory) {
|
|
25
25
|
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
26
|
-
return path.join(directory, ".agentboard");
|
|
26
|
+
return findBoardUpward(directory) || path.join(directory, ".agentboard");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Nearest ancestor (incl. start) containing a .agentboard dir, or null.
|
|
30
|
+
function findBoardUpward(start) {
|
|
31
|
+
let dir = path.resolve(start);
|
|
32
|
+
for (;;) {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
|
|
35
|
+
} catch {}
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
if (parent === dir) return null;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
function readJsonSafe(p) {
|
|
@@ -138,17 +151,28 @@ export const DmWatchPlugin = async ({ client, directory }) => {
|
|
|
138
151
|
return curIdx !== -1 && idx !== -1 && idx <= curIdx;
|
|
139
152
|
}
|
|
140
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
|
+
|
|
141
172
|
async function deliver(agent, sessionID, msg) {
|
|
142
173
|
if (!claim(agent, msg.id, sessionID)) return;
|
|
143
174
|
advanceCursor(agent, msg.id);
|
|
144
|
-
const text =
|
|
145
|
-
"[DM from " +
|
|
146
|
-
msg.from +
|
|
147
|
-
" @ " +
|
|
148
|
-
msg.at +
|
|
149
|
-
"]\n" +
|
|
150
|
-
msg.body +
|
|
151
|
-
"\n\n(Reply with dm-send if needed, or continue current work if unrelated.)";
|
|
175
|
+
const text = formatDm(msg);
|
|
152
176
|
try {
|
|
153
177
|
if (client.session && typeof client.session.promptAsync === "function") {
|
|
154
178
|
await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
|
|
@@ -213,6 +237,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
|
|
|
213
237
|
from: msg.from,
|
|
214
238
|
body: String(msg.body),
|
|
215
239
|
at: msg.at || "",
|
|
240
|
+
subject: msg.subject,
|
|
241
|
+
replyTo: msg.replyTo,
|
|
242
|
+
batch: msg.batch,
|
|
243
|
+
rev: msg.rev,
|
|
216
244
|
});
|
|
217
245
|
}
|
|
218
246
|
}
|
|
@@ -4,13 +4,22 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Usage from the agent (just a tool call, whenever you want):
|
|
6
6
|
// dm-send({ from: "alice", to: "bob", body: "the parser accepts ISO dates only" })
|
|
7
|
+
// dm-send({ from: "lead", to: "alice,bob,carol", subject: "brief: cards", body: "..." })
|
|
8
|
+
//
|
|
9
|
+
// Fanning out work ("assign N agents"): there is no task object — the DM *is*
|
|
10
|
+
// the task. `to` accepts a comma list (one copy each, shared batch id); each
|
|
11
|
+
// agent owns its scope and DMs a summary back. Thread answers with `replyTo`.
|
|
7
12
|
//
|
|
8
13
|
// What it does:
|
|
9
|
-
// 1. resolves the board (AGENTBOARD_DIR env, else
|
|
10
|
-
//
|
|
14
|
+
// 1. resolves the board (board arg, AGENTBOARD_DIR env, else walk-up from
|
|
15
|
+
// worktree/directory/cwd to the project .agentboard)
|
|
16
|
+
// 2. writes .agentboard/dm/<to>/<msg-id>.json per recipient (atomic
|
|
17
|
+
// write-then-rename, unique id each, shared batch id on fan-out)
|
|
11
18
|
// 3. upserts .agentboard/agents/<from>.json with { lastSeen, sessionId }
|
|
12
19
|
// so the watcher plugin can route pushes back to the right session.
|
|
13
|
-
// 4.
|
|
20
|
+
// 4. stamps the sender's git rev (when inside a checkout) so recipients
|
|
21
|
+
// can tell whether cited file:line numbers are stale.
|
|
22
|
+
// 5. returns "sent <id> -> <to> [board <path>]" for the calling agent.
|
|
14
23
|
//
|
|
15
24
|
// Delivery ("inserted into context") is done by ../plugins/dm-watch.js, which
|
|
16
25
|
// polls dm/ and injects via client.session.promptAsync. This tool never blocks
|
|
@@ -20,10 +29,43 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
20
29
|
import fs from "node:fs";
|
|
21
30
|
import path from "node:path";
|
|
22
31
|
import crypto from "node:crypto";
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
23
33
|
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
function findBoardUpward(start) {
|
|
35
|
+
let dir = path.resolve(start);
|
|
36
|
+
for (;;) {
|
|
37
|
+
try {
|
|
38
|
+
if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
|
|
39
|
+
} catch {}
|
|
40
|
+
const parent = path.dirname(dir);
|
|
41
|
+
if (parent === dir) return null;
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try every base the harness gives us (worktree, directory, cwd): harnesses
|
|
47
|
+
// sometimes run agents with a cwd below (or beside) the project, or with an
|
|
48
|
+
// empty worktree. First walk-up hit wins; otherwise fall back to
|
|
49
|
+
// <primary>/.agentboard so the caller gets the drive-root guard instead of a
|
|
50
|
+
// silent stray board.
|
|
51
|
+
function boardRoot(candidates, override) {
|
|
52
|
+
if (override) return { root: path.resolve(String(override)), tried: [path.resolve(String(override))] };
|
|
53
|
+
if (process.env.AGENTBOARD_DIR) return { root: path.resolve(process.env.AGENTBOARD_DIR), tried: [path.resolve(process.env.AGENTBOARD_DIR)] };
|
|
54
|
+
const tried = [];
|
|
55
|
+
for (const base of candidates) {
|
|
56
|
+
if (!base) continue;
|
|
57
|
+
let dir;
|
|
58
|
+
try {
|
|
59
|
+
dir = path.resolve(String(base));
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
tried.push(dir);
|
|
64
|
+
const hit = findBoardUpward(dir);
|
|
65
|
+
if (hit) return { root: hit, tried };
|
|
66
|
+
}
|
|
67
|
+
const primary = path.resolve(String(candidates[0] || process.cwd()));
|
|
68
|
+
return { root: path.join(primary, ".agentboard"), tried };
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
function clean(name, what) {
|
|
@@ -33,40 +75,122 @@ function clean(name, what) {
|
|
|
33
75
|
return c;
|
|
34
76
|
}
|
|
35
77
|
|
|
78
|
+
function parseRecipients(raw) {
|
|
79
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") {
|
|
80
|
+
throw new Error("missing to (recipient); or comma-separate for broadcast: alice,bob,carol");
|
|
81
|
+
}
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const part of String(raw).split(",")) {
|
|
84
|
+
if (part.trim() === "") continue;
|
|
85
|
+
const c = clean(part, "to");
|
|
86
|
+
if (!out.includes(c)) out.push(c);
|
|
87
|
+
}
|
|
88
|
+
if (out.length === 0) throw new Error("missing to (recipient)");
|
|
89
|
+
if (out.length > 20) throw new Error(`too many recipients (max 20, got ${out.length})`);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanSubject(raw) {
|
|
94
|
+
if (raw === undefined || raw === null) return undefined;
|
|
95
|
+
const s = String(raw).trim().slice(0, 120);
|
|
96
|
+
return s || undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cleanReply(raw) {
|
|
100
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
|
|
101
|
+
return String(raw).trim().slice(0, 80);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Best-effort git rev of the project containing the board. Never throws.
|
|
105
|
+
function gitRev(root) {
|
|
106
|
+
try {
|
|
107
|
+
const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
108
|
+
cwd: path.dirname(root),
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
timeout: 3000,
|
|
111
|
+
});
|
|
112
|
+
return String(out).trim().slice(0, 40) || undefined;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
36
118
|
function writeJsonAtomic(p, obj) {
|
|
37
119
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
-
const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
120
|
+
const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
39
121
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
40
|
-
|
|
122
|
+
try {
|
|
123
|
+
fs.renameSync(tmp, p);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
while (Date.now() - start < 50) { /* brief spin for Windows AV holds */ }
|
|
127
|
+
try {
|
|
128
|
+
fs.renameSync(tmp, p);
|
|
129
|
+
} catch (e2) {
|
|
130
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
131
|
+
throw e2;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function newId(prefix) {
|
|
137
|
+
const t = new Date();
|
|
138
|
+
const stamp =
|
|
139
|
+
String(t.getUTCFullYear()).slice(2) +
|
|
140
|
+
String(t.getUTCMonth() + 1).padStart(2, "0") +
|
|
141
|
+
String(t.getUTCDate()).padStart(2, "0") +
|
|
142
|
+
"-" +
|
|
143
|
+
String(t.getUTCHours()).padStart(2, "0") +
|
|
144
|
+
String(t.getUTCMinutes()).padStart(2, "0") +
|
|
145
|
+
String(t.getUTCSeconds()).padStart(2, "0");
|
|
146
|
+
return `${prefix}-${stamp}-${crypto.randomBytes(4).toString("hex")}`;
|
|
41
147
|
}
|
|
42
148
|
|
|
43
149
|
export default tool({
|
|
44
150
|
description:
|
|
45
|
-
"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).",
|
|
151
|
+
"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).",
|
|
46
152
|
args: {
|
|
47
153
|
from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
|
|
48
|
-
to: tool.schema.string().describe("Recipient agent name, e.g. bob. They receive it on inbox/listen even before registering."),
|
|
154
|
+
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."),
|
|
49
155
|
body: tool.schema.string().describe("Message text, 1..8000 chars."),
|
|
156
|
+
subject: tool.schema.string().optional().describe("Optional mission line, e.g. 'brief: borderless cards'. Shown above the body."),
|
|
157
|
+
replyTo: tool.schema.string().optional().describe("Optional message id you are answering (threads the reply)."),
|
|
158
|
+
board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
|
|
50
159
|
},
|
|
51
160
|
async execute(args, context) {
|
|
52
161
|
const from = clean(args.from, "from");
|
|
53
|
-
const
|
|
162
|
+
const recipients = parseRecipients(args.to);
|
|
54
163
|
const body = String(args.body || "").trim();
|
|
55
164
|
if (!body) return "error: empty body";
|
|
56
165
|
if (body.length > 8000) return "error: body too large (max 8000 chars)";
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
166
|
+
const subject = cleanSubject(args.subject);
|
|
167
|
+
const replyTo = cleanReply(args.replyTo);
|
|
168
|
+
const boardArg = args.board === undefined || args.board === null || String(args.board).trim() === "" ? undefined : String(args.board);
|
|
169
|
+
const worktree = context.worktree || context.directory || process.cwd();
|
|
170
|
+
const { root, tried } = boardRoot([worktree, context.directory, process.cwd()], boardArg);
|
|
171
|
+
if (!boardArg && !process.env.AGENTBOARD_DIR) {
|
|
172
|
+
let exists = false;
|
|
173
|
+
try {
|
|
174
|
+
exists = fs.statSync(root).isDirectory();
|
|
175
|
+
} catch {}
|
|
176
|
+
if (!exists && path.dirname(root) === path.parse(root).root) {
|
|
177
|
+
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.`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const rev = gitRev(root);
|
|
181
|
+
const at = new Date().toISOString();
|
|
182
|
+
const batch = recipients.length > 1 ? newId("batch") : undefined;
|
|
183
|
+
const sent = [];
|
|
184
|
+
for (const to of recipients) {
|
|
185
|
+
const id = newId("msg");
|
|
186
|
+
const msg = { id, from, to, body, at };
|
|
187
|
+
if (subject) msg.subject = subject;
|
|
188
|
+
if (replyTo) msg.replyTo = replyTo;
|
|
189
|
+
if (batch) msg.batch = batch;
|
|
190
|
+
if (rev) msg.rev = rev;
|
|
191
|
+
writeJsonAtomic(path.join(root, "dm", to, id + ".json"), msg);
|
|
192
|
+
sent.push(`${id} -> ${to}`);
|
|
193
|
+
}
|
|
70
194
|
// upsert sender with live session routing for the watcher plugin
|
|
71
195
|
const ap = path.join(root, "agents", from + ".json");
|
|
72
196
|
let prev = null;
|
|
@@ -75,11 +199,12 @@ export default tool({
|
|
|
75
199
|
} catch {}
|
|
76
200
|
writeJsonAtomic(ap, {
|
|
77
201
|
name: from,
|
|
78
|
-
firstSeen: (prev && prev.firstSeen) ||
|
|
79
|
-
lastSeen:
|
|
202
|
+
firstSeen: (prev && prev.firstSeen) || new Date().toISOString(),
|
|
203
|
+
lastSeen: new Date().toISOString(),
|
|
80
204
|
sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
|
|
81
205
|
lastDir: context.worktree || context.directory || undefined,
|
|
82
206
|
});
|
|
83
|
-
return "sent " +
|
|
207
|
+
if (sent.length === 1) return "sent " + sent[0] + " [board " + root + "]";
|
|
208
|
+
return `sent ${sent.length} messages [board ${root}]: ${sent.join(", ")}`;
|
|
84
209
|
},
|
|
85
210
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eamonpluto/agentboard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Zero-dependency local DM bus for AI coding agents: message another agent, inserted into context, just a tool call.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"bin/",
|
|
16
16
|
"opencode/",
|
|
17
17
|
"AGENTS.template.md",
|
|
18
|
+
"AGENTBOARD.html",
|
|
18
19
|
"README.md",
|
|
19
20
|
"CHANGELOG.md"
|
|
20
21
|
],
|