@eamonpluto/agentboard 2.3.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.
@@ -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,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
- 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")}`;
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 to = clean(args.to, "to");
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 root = boardRoot(context.worktree || context.directory || process.cwd(), boardArg);
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 now = new Date().toISOString();
87
- const t = new Date();
88
- const stamp =
89
- String(t.getUTCFullYear()).slice(2) +
90
- String(t.getUTCMonth() + 1).padStart(2, "0") +
91
- String(t.getUTCDate()).padStart(2, "0") +
92
- "-" +
93
- String(t.getUTCHours()).padStart(2, "0") +
94
- String(t.getUTCMinutes()).padStart(2, "0") +
95
- String(t.getUTCSeconds()).padStart(2, "0");
96
- const id = "msg-" + stamp + "-" + crypto.randomBytes(3).toString("hex");
97
- writeJsonAtomic(path.join(root, "dm", to, id + ".json"), { id, from, to, body, at: now });
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) || now,
107
- lastSeen: now,
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
- return "sent " + id + " -> " + to + " [board " + root + "]";
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.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
  ],