@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.
@@ -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 <worktree>/.agentboard)
10
- // 2. writes .agentboard/dm/<to>/<msg-id>.json (atomic write-then-rename)
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. returns "sent <id> -> <to>" for the calling agent to see.
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 boardRoot(worktree) {
25
- if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
26
- return path.join(worktree, ".agentboard");
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
- fs.renameSync(tmp, p);
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 to = clean(args.to, "to");
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 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 });
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) || now,
79
- lastSeen: now,
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 " + id + " -> " + to;
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.2.0",
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
  ],