@eamonpluto/agentboard 2.2.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.
@@ -0,0 +1,233 @@
1
+ // .opencode/plugins/dm-watch.js — inject DMs into context (agent-board DM-only v2).
2
+ // Watches <board>/dm/<agent>/*.json and delivers new messages to the live
3
+ // opencode session registered for <agent> via client.session.promptAsync.
4
+ //
5
+ // Routing: .agentboard/agents/<name>.json holds { sessionId }. The dm-send
6
+ // tool writes it on every send; register --session writes it from the CLI.
7
+ // Fire-once: in-memory Set + on-disk delivered/<agent>/<msgId>.json markers
8
+ // claimed with exclusive create ('wx'), pre-populated on startup (survives
9
+ // restarts, same idea as bgrun's .notify -> .notified rename). Markers are
10
+ // shared with agentboard-hook, and the hook's cursors/<agent>.json fast-
11
+ // forward pointer is honored (and advanced on our deliveries), so agents
12
+ // mixing harnesses never get a message twice.
13
+ // Polls every 1s; that poll is the source of truth (no fs.watch dependency).
14
+ //
15
+ // Agents with no known session are skipped — their mail waits in the inbox
16
+ // for pull (`inbox --from <you>`), so one idle session never steals another
17
+ // agent's mail.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ const POLL_MS = 1000;
23
+
24
+ function boardRoot(directory) {
25
+ if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
26
+ return path.join(directory, ".agentboard");
27
+ }
28
+
29
+ function readJsonSafe(p) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(p, "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export const DmWatchPlugin = async ({ client, directory }) => {
38
+ const root = boardRoot(directory);
39
+ const dmDir = path.join(root, "dm");
40
+ const agentsDir = path.join(root, "agents");
41
+ const deliveredDir = path.join(root, "delivered");
42
+
43
+ const agentToSession = new Map(); // agent -> sessionID
44
+ const processed = new Set(); // "<agent>/<msgId>"
45
+
46
+ try {
47
+ fs.mkdirSync(deliveredDir, { recursive: true });
48
+ } catch {}
49
+
50
+ // pre-populate from delivered markers so restarts don't replay
51
+ try {
52
+ for (const agent of fs.readdirSync(deliveredDir)) {
53
+ const ad = path.join(deliveredDir, agent);
54
+ try {
55
+ if (!fs.statSync(ad).isDirectory()) continue;
56
+ } catch {
57
+ continue;
58
+ }
59
+ for (const f of fs.readdirSync(ad)) {
60
+ if (f.endsWith(".json")) processed.add(agent + "/" + f.replace(/\.json$/, ""));
61
+ }
62
+ }
63
+ } catch {}
64
+
65
+ function refreshAgentMap() {
66
+ let files = [];
67
+ try {
68
+ files = fs.readdirSync(agentsDir);
69
+ } catch {
70
+ return;
71
+ }
72
+ for (const f of files) {
73
+ if (!f.endsWith(".json")) continue;
74
+ const doc = readJsonSafe(path.join(agentsDir, f));
75
+ if (doc && doc.name && doc.sessionId) agentToSession.set(doc.name, doc.sessionId);
76
+ }
77
+ }
78
+
79
+ function deliveredMarker(agent, id) {
80
+ return path.join(deliveredDir, agent, id + ".json");
81
+ }
82
+
83
+ function claim(agent, id, sessionID) {
84
+ const key = agent + "/" + id;
85
+ if (processed.has(key)) return false;
86
+ processed.add(key);
87
+ try {
88
+ fs.mkdirSync(path.dirname(deliveredMarker(agent, id)), { recursive: true });
89
+ fs.writeFileSync(deliveredMarker(agent, id), JSON.stringify({ sessionID, at: new Date().toISOString() }) + "\n", {
90
+ flag: "wx",
91
+ });
92
+ return true;
93
+ } catch {
94
+ return false; // already delivered by another instance
95
+ }
96
+ }
97
+
98
+ // Cursor file shared with agentboard-hook: hook delivery moves it, and we
99
+ // honor it (plus our markers) so mixed-harness agents never get doubles.
100
+ // We also advance it on our own deliveries.
101
+ function readCursor(agent) {
102
+ try {
103
+ return JSON.parse(fs.readFileSync(path.join(root, "cursors", agent + ".json"), "utf8"));
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function orderIds(agent) {
110
+ try {
111
+ return fs.readdirSync(path.join(dmDir, agent)).filter((f) => f.endsWith(".json")).sort().map((f) => f.replace(/\.json$/, ""));
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ function advanceCursor(agent, id) {
118
+ try {
119
+ const order = orderIds(agent);
120
+ const cur = readCursor(agent);
121
+ const curIdx = cur && cur.lastId ? order.indexOf(cur.lastId) : -1;
122
+ if (order.indexOf(id) > curIdx) {
123
+ fs.mkdirSync(path.join(root, "cursors"), { recursive: true });
124
+ fs.writeFileSync(
125
+ path.join(root, "cursors", agent + ".json"),
126
+ JSON.stringify({ lastId: id, at: new Date().toISOString() }) + "\n"
127
+ );
128
+ }
129
+ } catch {}
130
+ }
131
+
132
+ function coveredByCursor(agent, id) {
133
+ const order = orderIds(agent);
134
+ const cur = readCursor(agent);
135
+ if (!cur || !cur.lastId) return false;
136
+ const curIdx = order.indexOf(cur.lastId);
137
+ const idx = order.indexOf(id);
138
+ return curIdx !== -1 && idx !== -1 && idx <= curIdx;
139
+ }
140
+
141
+ async function deliver(agent, sessionID, msg) {
142
+ if (!claim(agent, msg.id, sessionID)) return;
143
+ 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.)";
152
+ try {
153
+ if (client.session && typeof client.session.promptAsync === "function") {
154
+ await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
155
+ } else if (client.session && typeof client.session.prompt === "function") {
156
+ await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
157
+ }
158
+ } catch (e) {
159
+ try {
160
+ await client.app.log({
161
+ body: {
162
+ service: "dm-watch",
163
+ level: "warn",
164
+ message: "DM wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
165
+ },
166
+ });
167
+ } catch {}
168
+ }
169
+ }
170
+
171
+ async function poll() {
172
+ refreshAgentMap();
173
+ let agents = [];
174
+ try {
175
+ agents = fs.readdirSync(dmDir).filter((e) => {
176
+ try {
177
+ return fs.statSync(path.join(dmDir, e)).isDirectory();
178
+ } catch {
179
+ return false;
180
+ }
181
+ });
182
+ } catch {
183
+ return;
184
+ }
185
+ for (const agent of agents) {
186
+ const sessionID = agentToSession.get(agent);
187
+ if (!sessionID) continue; // nobody live for this name — mail waits for pull
188
+ let files = [];
189
+ try {
190
+ files = fs.readdirSync(path.join(dmDir, agent)).filter((f) => f.endsWith(".json")).sort();
191
+ } catch {
192
+ continue;
193
+ }
194
+ for (const f of files) {
195
+ const id = f.replace(/\.json$/, "");
196
+ const key = agent + "/" + id;
197
+ if (processed.has(key)) continue;
198
+ if (fs.existsSync(deliveredMarker(agent, id))) {
199
+ processed.add(key);
200
+ continue;
201
+ }
202
+ if (coveredByCursor(agent, id)) {
203
+ processed.add(key);
204
+ continue;
205
+ }
206
+ const msg = readJsonSafe(path.join(dmDir, agent, f));
207
+ if (!msg || !msg.id || !msg.from || !msg.body) {
208
+ processed.add(key);
209
+ continue;
210
+ }
211
+ await deliver(agent, agentToSession.get(agent) || sessionID, {
212
+ id: msg.id,
213
+ from: msg.from,
214
+ body: String(msg.body),
215
+ at: msg.at || "",
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ const timer = setInterval(() => {
222
+ poll().catch(() => {});
223
+ }, POLL_MS);
224
+ poll().catch(() => {});
225
+
226
+ return {
227
+ dispose: async () => {
228
+ clearInterval(timer);
229
+ },
230
+ };
231
+ };
232
+
233
+ export default DmWatchPlugin;
@@ -0,0 +1,85 @@
1
+ // .opencode/tools/dm-send.js — primitive DM tool for agent-board (DM-only v2).
2
+ // Filename becomes the tool name: dm-send.
3
+ // Loaded by opencode alongside built-in tools. Zero extra deps.
4
+ //
5
+ // Usage from the agent (just a tool call, whenever you want):
6
+ // dm-send({ from: "alice", to: "bob", body: "the parser accepts ISO dates only" })
7
+ //
8
+ // What it does:
9
+ // 1. resolves the board (AGENTBOARD_DIR env, else <worktree>/.agentboard)
10
+ // 2. writes .agentboard/dm/<to>/<msg-id>.json (atomic write-then-rename)
11
+ // 3. upserts .agentboard/agents/<from>.json with { lastSeen, sessionId }
12
+ // so the watcher plugin can route pushes back to the right session.
13
+ // 4. returns "sent <id> -> <to>" for the calling agent to see.
14
+ //
15
+ // Delivery ("inserted into context") is done by ../plugins/dm-watch.js, which
16
+ // polls dm/ and injects via client.session.promptAsync. This tool never blocks
17
+ // waiting for a reply — fire and forget, like Slack.
18
+
19
+ import { tool } from "@opencode-ai/plugin";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import crypto from "node:crypto";
23
+
24
+ function boardRoot(worktree) {
25
+ if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
26
+ return path.join(worktree, ".agentboard");
27
+ }
28
+
29
+ function clean(name, what) {
30
+ if (!name) throw new Error("missing " + what);
31
+ const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
32
+ if (!c) throw new Error("invalid " + what);
33
+ return c;
34
+ }
35
+
36
+ function writeJsonAtomic(p, obj) {
37
+ fs.mkdirSync(path.dirname(p), { recursive: true });
38
+ const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
39
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
40
+ fs.renameSync(tmp, p);
41
+ }
42
+
43
+ export default tool({
44
+ 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).",
46
+ args: {
47
+ 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."),
49
+ body: tool.schema.string().describe("Message text, 1..8000 chars."),
50
+ },
51
+ async execute(args, context) {
52
+ const from = clean(args.from, "from");
53
+ const to = clean(args.to, "to");
54
+ const body = String(args.body || "").trim();
55
+ if (!body) return "error: empty body";
56
+ if (body.length > 8000) return "error: body too large (max 8000 chars)";
57
+ const root = boardRoot(context.worktree || context.directory || process.cwd());
58
+ const now = new Date().toISOString();
59
+ const t = new Date();
60
+ const stamp =
61
+ String(t.getUTCFullYear()).slice(2) +
62
+ String(t.getUTCMonth() + 1).padStart(2, "0") +
63
+ String(t.getUTCDate()).padStart(2, "0") +
64
+ "-" +
65
+ String(t.getUTCHours()).padStart(2, "0") +
66
+ String(t.getUTCMinutes()).padStart(2, "0") +
67
+ String(t.getUTCSeconds()).padStart(2, "0");
68
+ const id = "msg-" + stamp + "-" + crypto.randomBytes(3).toString("hex");
69
+ writeJsonAtomic(path.join(root, "dm", to, id + ".json"), { id, from, to, body, at: now });
70
+ // upsert sender with live session routing for the watcher plugin
71
+ const ap = path.join(root, "agents", from + ".json");
72
+ let prev = null;
73
+ try {
74
+ prev = JSON.parse(fs.readFileSync(ap, "utf8"));
75
+ } catch {}
76
+ writeJsonAtomic(ap, {
77
+ name: from,
78
+ firstSeen: (prev && prev.firstSeen) || now,
79
+ lastSeen: now,
80
+ sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
81
+ lastDir: context.worktree || context.directory || undefined,
82
+ });
83
+ return "sent " + id + " -> " + to;
84
+ },
85
+ });
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@eamonpluto/agentboard",
3
+ "version": "2.2.0",
4
+ "description": "Zero-dependency local DM bus for AI coding agents: message another agent, inserted into context, just a tool call.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentboard": "bin/agentboard.js",
8
+ "agentboard-mcp": "bin/agentboard-mcp.js",
9
+ "agentboard-hook": "bin/agentboard-hook.js"
10
+ },
11
+ "scripts": {
12
+ "test": "node test/agentboard.smoke.mjs && node test/agentboard.harness.mjs"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "opencode/",
17
+ "AGENTS.template.md",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "MIT"
25
+ }