@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
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
// mixing harnesses never get a message twice.
|
|
13
13
|
// Polls every 1s; that poll is the source of truth (no fs.watch dependency).
|
|
14
14
|
//
|
|
15
|
+
// Delivery is at-least-once: the claim wins the race between watcher
|
|
16
|
+
// instances, but the marker is released (and the cursor left alone) when
|
|
17
|
+
// promptAsync throws — e.g. pushing into a stale session from yesterday
|
|
18
|
+
// fails with `encrypted_content was not issued to this caller`. Stale
|
|
19
|
+
// session mappings are then invalidated so mail waits for pull until the
|
|
20
|
+
// live session re-registers, instead of being black-holed as delivered.
|
|
21
|
+
//
|
|
15
22
|
// Agents with no known session are skipped — their mail waits in the inbox
|
|
16
23
|
// for pull (`inbox --from <you>`), so one idle session never steals another
|
|
17
24
|
// agent's mail.
|
|
@@ -108,6 +115,40 @@ export const DmWatchPlugin = async ({ client, directory }) => {
|
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
// Roll back a claim won above when the push itself fails: remove the
|
|
119
|
+
// on-disk marker and the in-memory entry so the next poll retries.
|
|
120
|
+
// The cursor is intentionally left alone here — it only advances on
|
|
121
|
+
// success, so a failed push never fast-forwards past undelivered mail.
|
|
122
|
+
function releaseClaim(agent, id) {
|
|
123
|
+
processed.delete(agent + "/" + id);
|
|
124
|
+
try {
|
|
125
|
+
fs.rmSync(deliveredMarker(agent, id), { force: true });
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// promptAsync into a dead session throws provider errors like
|
|
130
|
+
// "[invalid_request_error] reasoning `encrypted_content` was not issued
|
|
131
|
+
// to this caller". Those mean the routing entry is stale, not the
|
|
132
|
+
// message — drop the mapping so mail waits for pull (`inbox`) until the
|
|
133
|
+
// live session re-registers via `register --session` or `dm-send`.
|
|
134
|
+
function isStaleSessionError(e) {
|
|
135
|
+
const s = String((e && e.message ? e.message : e) || "");
|
|
136
|
+
return /encrypted_content|invalid_request_error|unknown session|session not found|no such session|not issued to this caller/i.test(s);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function invalidateSession(agent, sessionID) {
|
|
140
|
+
agentToSession.delete(agent);
|
|
141
|
+
try {
|
|
142
|
+
const p = path.join(agentsDir, agent + ".json");
|
|
143
|
+
const doc = readJsonSafe(p);
|
|
144
|
+
if (doc && doc.sessionId === sessionID) {
|
|
145
|
+
delete doc.sessionId;
|
|
146
|
+
doc.lastSeen = new Date().toISOString();
|
|
147
|
+
fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\n");
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
111
152
|
// Cursor file shared with agentboard-hook: hook delivery moves it, and we
|
|
112
153
|
// honor it (plus our markers) so mixed-harness agents never get doubles.
|
|
113
154
|
// We also advance it on our own deliveries.
|
|
@@ -151,30 +192,44 @@ export const DmWatchPlugin = async ({ client, directory }) => {
|
|
|
151
192
|
return curIdx !== -1 && idx !== -1 && idx <= curIdx;
|
|
152
193
|
}
|
|
153
194
|
|
|
195
|
+
function formatDm(msg) {
|
|
196
|
+
let head = "[DM from " + msg.from + " @ " + (msg.at || "unknown time");
|
|
197
|
+
if (msg.rev) head += ` (rev ${msg.rev})`;
|
|
198
|
+
if (msg.batch) head += ` [batch ${msg.batch}]`;
|
|
199
|
+
if (msg.replyTo) head += ` re: ${msg.replyTo}`;
|
|
200
|
+
head += "]";
|
|
201
|
+
const subj = msg.subject ? `subj: ${msg.subject}\n` : "";
|
|
202
|
+
return (
|
|
203
|
+
head +
|
|
204
|
+
"\n" +
|
|
205
|
+
subj +
|
|
206
|
+
msg.body +
|
|
207
|
+
"\n\n(Reply with dm-send (replyTo: \"" +
|
|
208
|
+
msg.id +
|
|
209
|
+
"\") 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.)"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
154
213
|
async function deliver(agent, sessionID, msg) {
|
|
155
214
|
if (!claim(agent, msg.id, sessionID)) return;
|
|
156
|
-
|
|
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.)";
|
|
215
|
+
const text = formatDm(msg);
|
|
165
216
|
try {
|
|
166
217
|
if (client.session && typeof client.session.promptAsync === "function") {
|
|
167
218
|
await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
|
|
168
219
|
} else if (client.session && typeof client.session.prompt === "function") {
|
|
169
220
|
await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
|
|
170
221
|
}
|
|
222
|
+
advanceCursor(agent, msg.id);
|
|
171
223
|
} catch (e) {
|
|
224
|
+
const detail = e && e.message ? e.message : String(e);
|
|
225
|
+
releaseClaim(agent, msg.id);
|
|
226
|
+
if (isStaleSessionError(e)) invalidateSession(agent, sessionID);
|
|
172
227
|
try {
|
|
173
228
|
await client.app.log({
|
|
174
229
|
body: {
|
|
175
230
|
service: "dm-watch",
|
|
176
231
|
level: "warn",
|
|
177
|
-
message: "DM
|
|
232
|
+
message: "DM push failed for " + agent + " (" + msg.id + "), released for retry: " + detail,
|
|
178
233
|
},
|
|
179
234
|
});
|
|
180
235
|
} catch {}
|
|
@@ -226,6 +281,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
|
|
|
226
281
|
from: msg.from,
|
|
227
282
|
body: String(msg.body),
|
|
228
283
|
at: msg.at || "",
|
|
284
|
+
subject: msg.subject,
|
|
285
|
+
replyTo: msg.replyTo,
|
|
286
|
+
batch: msg.batch,
|
|
287
|
+
rev: msg.rev,
|
|
229
288
|
});
|
|
230
289
|
}
|
|
231
290
|
}
|
|
@@ -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,17 +29,8 @@ 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 boardRoot(worktree, override) {
|
|
25
|
-
if (override) return path.resolve(String(override));
|
|
26
|
-
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
27
|
-
const base = worktree || process.cwd();
|
|
28
|
-
return findBoardUpward(base) || path.join(base, ".agentboard");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Nearest ancestor (incl. start) containing a .agentboard dir, or null.
|
|
32
|
-
// Harnesses sometimes run agents with a cwd below (or beside) the project;
|
|
33
|
-
// walk-up keeps every session on the same board.
|
|
34
34
|
function findBoardUpward(start) {
|
|
35
35
|
let dir = path.resolve(start);
|
|
36
36
|
for (;;) {
|
|
@@ -43,6 +43,31 @@ function findBoardUpward(start) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
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 };
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
function clean(name, what) {
|
|
47
72
|
if (!name) throw new Error("missing " + what);
|
|
48
73
|
const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
|
|
@@ -50,51 +75,122 @@ function clean(name, what) {
|
|
|
50
75
|
return c;
|
|
51
76
|
}
|
|
52
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
|
+
|
|
53
118
|
function writeJsonAtomic(p, obj) {
|
|
54
119
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
55
|
-
const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
120
|
+
const tmp = p + "." + process.pid + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
56
121
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
57
|
-
|
|
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")}`;
|
|
58
147
|
}
|
|
59
148
|
|
|
60
149
|
export default tool({
|
|
61
150
|
description:
|
|
62
|
-
"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).",
|
|
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).",
|
|
63
152
|
args: {
|
|
64
153
|
from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
|
|
65
|
-
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."),
|
|
66
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)."),
|
|
67
158
|
board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
|
|
68
159
|
},
|
|
69
160
|
async execute(args, context) {
|
|
70
161
|
const from = clean(args.from, "from");
|
|
71
|
-
const
|
|
162
|
+
const recipients = parseRecipients(args.to);
|
|
72
163
|
const body = String(args.body || "").trim();
|
|
73
164
|
if (!body) return "error: empty body";
|
|
74
165
|
if (body.length > 8000) return "error: body too large (max 8000 chars)";
|
|
166
|
+
const subject = cleanSubject(args.subject);
|
|
167
|
+
const replyTo = cleanReply(args.replyTo);
|
|
75
168
|
const boardArg = args.board === undefined || args.board === null || String(args.board).trim() === "" ? undefined : String(args.board);
|
|
76
|
-
const
|
|
169
|
+
const worktree = context.worktree || context.directory || process.cwd();
|
|
170
|
+
const { root, tried } = boardRoot([worktree, context.directory, process.cwd()], boardArg);
|
|
77
171
|
if (!boardArg && !process.env.AGENTBOARD_DIR) {
|
|
78
172
|
let exists = false;
|
|
79
173
|
try {
|
|
80
174
|
exists = fs.statSync(root).isDirectory();
|
|
81
175
|
} catch {}
|
|
82
176
|
if (!exists && path.dirname(root) === path.parse(root).root) {
|
|
83
|
-
return `error: refusing to create a board at drive root ${root} — pass board (absolute path) or set AGENTBOARD_DIR
|
|
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.`;
|
|
84
178
|
}
|
|
85
179
|
}
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|
|
98
194
|
// upsert sender with live session routing for the watcher plugin
|
|
99
195
|
const ap = path.join(root, "agents", from + ".json");
|
|
100
196
|
let prev = null;
|
|
@@ -103,11 +199,12 @@ export default tool({
|
|
|
103
199
|
} catch {}
|
|
104
200
|
writeJsonAtomic(ap, {
|
|
105
201
|
name: from,
|
|
106
|
-
firstSeen: (prev && prev.firstSeen) ||
|
|
107
|
-
lastSeen:
|
|
202
|
+
firstSeen: (prev && prev.firstSeen) || new Date().toISOString(),
|
|
203
|
+
lastSeen: new Date().toISOString(),
|
|
108
204
|
sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
|
|
109
205
|
lastDir: context.worktree || context.directory || undefined,
|
|
110
206
|
});
|
|
111
|
-
|
|
207
|
+
if (sent.length === 1) return "sent " + sent[0] + " [board " + root + "]";
|
|
208
|
+
return `sent ${sent.length} messages [board ${root}]: ${sent.join(", ")}`;
|
|
112
209
|
},
|
|
113
210
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eamonpluto/agentboard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
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
|
],
|