@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.
@@ -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
- advanceCursor(agent, msg.id);
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 wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
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 <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.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
  ],