@eamonpluto/agentboard 2.2.0 → 2.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.0 (unpublished)
4
+
5
+ - Board resolution walks up to the project board (CLI, hook helper, MCP
6
+ server, opencode tool + plugin), so agents running from subdirectories or
7
+ detached worktrees stop planting stray boards.
8
+ - Every send echoes its board (`[board <path>]`); optional `board` param on
9
+ `dm-send` and all MCP tools for an explicit anchor.
10
+ - Writers refuse to auto-create a board at a drive root and fail loudly
11
+ (set `AGENTBOARD_DIR` / pass `board` instead).
12
+ - Troubleshooting section: split-board recovery, `doctor` flow.
13
+
3
14
  ## 2.2.0
4
15
 
5
16
  - Hook delivery is now batched (max 5 per poll): the cursor stops at the last
package/README.md CHANGED
@@ -105,11 +105,21 @@ against the Claude, Codex, and grok-build docs (Antigravity uses
105
105
  Or install globally (enables portable `init --portable` wiring):
106
106
 
107
107
  ```powershell
108
- cd C:\Users\Surface\Documents\agent-board
109
- npm i -g .
108
+ npm i -g @eamonpluto/agentboard
110
109
  agentboard --help
111
110
  ```
112
111
 
112
+ ## Troubleshooting
113
+
114
+ - **Two agents see different boards** (every send echoes `[board <path>]`):
115
+ export `AGENTBOARD_DIR=<board>` so all sessions share one — for in-process
116
+ tools (opencode `dm-send`) set it where the harness process launches, or
117
+ pass `board` explicitly per call (`dm-send({..., board: "C:/proj/.agentboard"})`).
118
+ Writers refuse to auto-create a board at a drive root and fail loudly instead.
119
+ - **`doctor` reports FAIL**: re-run `agentboard init --harness <name>` (merges,
120
+ never overwrites your own hooks), then follow the printed follow-ups
121
+ (trust approvals, `AGENTBOARD_AGENT`, restarts).
122
+
113
123
  ## Safety notes / trust boundary
114
124
 
115
125
  - **Never post secrets on the board.** Post references instead.
@@ -127,9 +137,9 @@ agentboard --help
127
137
  ## Publishing (maintainer)
128
138
 
129
139
  ```powershell
130
- npm test # 68 checks, all must pass
140
+ npm test # 88 checks, all must pass
131
141
  npm publish # ships bin/ + opencode/ + docs (see "files" in package.json)
132
142
  ```
133
143
 
134
144
  After publishing, projects can skip the checkout entirely:
135
- `npm i -g agentboard` then `agentboard init --harness <name> --portable`.
145
+ `npm i -g @eamonpluto/agentboard` then `agentboard init --harness <name> --portable`.
@@ -46,7 +46,20 @@ function boardDir(args) {
46
46
  const i = args.indexOf("--board");
47
47
  if (i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith("--")) return path.resolve(args[i + 1]);
48
48
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
49
- return path.join(process.cwd(), ".agentboard");
49
+ return findBoardUpward(process.cwd()) || path.join(process.cwd(), ".agentboard");
50
+ }
51
+
52
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
53
+ function findBoardUpward(start) {
54
+ let dir = path.resolve(start);
55
+ for (;;) {
56
+ try {
57
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
58
+ } catch {}
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) return null;
61
+ dir = parent;
62
+ }
50
63
  }
51
64
 
52
65
  function getFlag(args, flag) {
@@ -54,6 +67,24 @@ function getFlag(args, flag) {
54
67
  return i !== -1 && args[i + 1] !== undefined && !String(args[i + 1]).startsWith("--") ? args[i + 1] : undefined;
55
68
  }
56
69
 
70
+ // Never silently plant a board at a drive root: fail loudly instead so the
71
+ // misconfiguration surfaces instead of mail landing on a stray board.
72
+ function refuseDriveRootBoard(root, args) {
73
+ const explicit = args.includes("--board") || !!process.env.AGENTBOARD_DIR;
74
+ if (explicit) return;
75
+ let exists = false;
76
+ try {
77
+ exists = fs.statSync(root).isDirectory();
78
+ } catch {}
79
+ if (exists) return;
80
+ if (path.dirname(root) === path.parse(root).root) {
81
+ fail(
82
+ `refusing to create a board at drive root ${root} — no project board found above cwd. ` +
83
+ `Pass --board <path> or set AGENTBOARD_DIR.`
84
+ );
85
+ }
86
+ }
87
+
57
88
  function cleanName(name, what) {
58
89
  if (!name) fail(`missing --from <agent-name> (${what}); or set AGENTBOARD_AGENT=<name>`);
59
90
  const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
@@ -162,6 +193,7 @@ function writeCursor(root, agent, lastId) {
162
193
 
163
194
  async function cmdSessionStart(args) {
164
195
  const root = boardDir(args);
196
+ refuseDriveRootBoard(root, args);
165
197
  const agent = resolveAgent(args, "agent");
166
198
  const input = await readStdinSoon(300);
167
199
  const sessionId =
@@ -195,6 +227,11 @@ async function cmdSessionStart(args) {
195
227
 
196
228
  async function cmdPoll(args) {
197
229
  const root = boardDir(args);
230
+ try {
231
+ if (!fs.statSync(root).isDirectory()) return; // no board, no mail — silent allow
232
+ } catch {
233
+ return;
234
+ }
198
235
  const agent = resolveAgent(args, "agent");
199
236
  const style = getFlag(args, "--style") || "claude";
200
237
  const valid = new Set(["claude", "codex", "grok", "antigravity-stop", "antigravity-pre"]);
@@ -36,9 +36,23 @@ const KNOWN_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18
36
36
  // board (mirrors bin/agentboard.js layout v2; no import to stay dep-free)
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- function boardRoot() {
39
+ function boardRoot(override) {
40
+ if (override) return path.resolve(String(override));
40
41
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
41
- return path.join(process.cwd(), ".agentboard");
42
+ return findBoardUpward(process.cwd()) || path.join(process.cwd(), ".agentboard");
43
+ }
44
+
45
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
46
+ function findBoardUpward(start) {
47
+ let dir = path.resolve(start);
48
+ for (;;) {
49
+ try {
50
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
51
+ } catch {}
52
+ const parent = path.dirname(dir);
53
+ if (parent === dir) return null;
54
+ dir = parent;
55
+ }
42
56
  }
43
57
 
44
58
  function dirs(root) {
@@ -136,13 +150,14 @@ const TOOLS = [
136
150
  {
137
151
  name: "dm_send",
138
152
  description:
139
- "Send a direct message to another AI agent via agent-board. Fire-and-forget like Slack: the peer reads it via dm_inbox (or gets it pushed by their harness hook). Use whenever you want to coordinate, share a finding, or ask a peer.",
153
+ "Send a direct message to another AI agent via agent-board. Fire-and-forget like Slack: the peer reads it via dm_inbox (or gets it pushed by their harness hook). Use whenever you want to coordinate, share a finding, or ask a peer. Pass board (absolute path) when your session runs outside the project so all agents share one board.",
140
154
  inputSchema: {
141
155
  type: "object",
142
156
  properties: {
143
157
  from: { type: "string", description: "Your stable agent name, e.g. alice. Keep it constant for the session." },
144
158
  to: { type: "string", description: "Recipient agent name, e.g. bob." },
145
159
  body: { type: "string", description: "Message text, 1..8000 chars." },
160
+ board: { type: "string", description: "Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection." },
146
161
  },
147
162
  required: ["from", "to", "body"],
148
163
  },
@@ -150,31 +165,38 @@ const TOOLS = [
150
165
  {
151
166
  name: "dm_inbox",
152
167
  description:
153
- "Read your direct messages on agent-board. No mark-read side effects; page with after. Poll this often when your harness has no push hook.",
168
+ "Read your direct messages on agent-board. No mark-read side effects; page with after. Poll this often when your harness has no push hook. Pass board (absolute path) when your session runs outside the project so all agents share one board.",
154
169
  inputSchema: {
155
170
  type: "object",
156
171
  properties: {
157
172
  agent: { type: "string", description: "Your agent name." },
158
173
  limit: { type: "number", description: "Max messages, newest last. Default 20." },
159
174
  after: { type: "string", description: "Only messages after this message id." },
175
+ board: { type: "string", description: "Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection." },
160
176
  },
161
177
  required: ["agent"],
162
178
  },
163
179
  },
164
180
  {
165
181
  name: "dm_agents",
166
- description: "List agents known to this agent-board (registered names for addressing DMs).",
167
- inputSchema: { type: "object", properties: {} },
182
+ description: "List agents known to this agent-board (registered names for addressing DMs). Pass board when your session runs outside the project.",
183
+ inputSchema: {
184
+ type: "object",
185
+ properties: {
186
+ board: { type: "string", description: "Optional absolute board path. Overrides AGENTBOARD_DIR and auto-detection." },
187
+ },
188
+ },
168
189
  },
169
190
  {
170
191
  name: "dm_register",
171
192
  description:
172
- "Register your agent name on this agent-board (optionally binding a harness session id for push routing). Do this once per session.",
193
+ "Register your agent name on this agent-board (optionally binding a harness session id for push routing). Do this once per session. Pass board when your session runs outside the project.",
173
194
  inputSchema: {
174
195
  type: "object",
175
196
  properties: {
176
197
  agent: { type: "string", description: "Your stable agent name." },
177
198
  session: { type: "string", description: "Optional harness session/conversation id for push routing." },
199
+ board: { type: "string", description: "Optional absolute board path. Overrides AGENTBOARD_DIR and auto-detection." },
178
200
  },
179
201
  required: ["agent"],
180
202
  },
@@ -185,9 +207,24 @@ function toolResult(text) {
185
207
  return { content: [{ type: "text", text }] };
186
208
  }
187
209
 
210
+ function isDriveRootMissing(root, explicit) {
211
+ if (explicit) return false;
212
+ try {
213
+ if (fs.statSync(root).isDirectory()) return false;
214
+ } catch {}
215
+ return path.dirname(root) === path.parse(root).root;
216
+ }
217
+
188
218
  function callTool(name, args) {
189
- const d = ensureBoard(boardRoot());
190
219
  const a = args && typeof args === "object" ? args : {};
220
+ const boardArg = a.board === undefined || a.board === null || String(a.board).trim() === "" ? undefined : String(a.board);
221
+ const root = boardRoot(boardArg);
222
+ if (isDriveRootMissing(root, boardArg || process.env.AGENTBOARD_DIR)) {
223
+ throw new Error(
224
+ `refusing to create a board at drive root ${root} — pass board (absolute path) or set AGENTBOARD_DIR`
225
+ );
226
+ }
227
+ const d = ensureBoard(root);
191
228
  switch (name) {
192
229
  case "dm_send": {
193
230
  const from = cleanName(a.from, "from");
@@ -200,7 +237,7 @@ function callTool(name, args) {
200
237
  const msg = { id, from, to, body, at: new Date().toISOString() };
201
238
  fs.mkdirSync(path.join(d.dm, to), { recursive: true });
202
239
  writeJson(path.join(d.dm, to, `${id}.json`), msg);
203
- return toolResult(`sent ${id} -> ${to}`);
240
+ return toolResult(`sent ${id} -> ${to} [board ${d.root}]`);
204
241
  }
205
242
  case "dm_inbox": {
206
243
  const agent = cleanName(a.agent, "agent");
package/bin/agentboard.js CHANGED
@@ -47,7 +47,46 @@ function boardDir(args) {
47
47
  return path.join(os.homedir(), ".agentboard", "boards", "default");
48
48
  }
49
49
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
50
- return path.join(process.cwd(), ".agentboard");
50
+ // init always plants a board where you stand; every other command walks up
51
+ // so agents running from a subdirectory land on the project board instead
52
+ // of silently creating a stray one.
53
+ if (process.argv[2] === "init") return path.join(process.cwd(), ".agentboard");
54
+ return findBoardUpward(process.cwd()) || path.join(process.cwd(), ".agentboard");
55
+ }
56
+
57
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
58
+ function findBoardUpward(start) {
59
+ let dir = path.resolve(start);
60
+ for (;;) {
61
+ try {
62
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
63
+ } catch {}
64
+ const parent = path.dirname(dir);
65
+ if (parent === dir) return null;
66
+ dir = parent;
67
+ }
68
+ }
69
+
70
+ // Never silently plant a board at a drive root (e.g. C:\.agentboard): that
71
+ // means cwd resolution failed (detached harness worktree). Fail loudly so
72
+ // the agent sets --board/AGENTBOARD_DIR instead of talking to a stray board.
73
+ function isExplicitBoard(args) {
74
+ return args.includes("--board") || args.includes("--global") || !!process.env.AGENTBOARD_DIR;
75
+ }
76
+
77
+ function refuseDriveRootBoard(root, args) {
78
+ if (isExplicitBoard(args)) return;
79
+ let exists = false;
80
+ try {
81
+ exists = fs.statSync(root).isDirectory();
82
+ } catch {}
83
+ if (exists) return;
84
+ if (path.dirname(root) === path.parse(root).root) {
85
+ fail(
86
+ `refusing to create a board at drive root ${root} — no project board found above cwd. ` +
87
+ `Run from your project, pass --board <path>, or set AGENTBOARD_DIR.`
88
+ );
89
+ }
51
90
  }
52
91
 
53
92
  function dirs(root) {
@@ -63,8 +102,7 @@ function ensureBoard(root) {
63
102
  const d = dirs(root);
64
103
  for (const p of [d.root, d.agents, d.dm, d.delivered]) {
65
104
  fs.mkdirSync(p, { recursive: true });
66
- }
67
- const metaPath = path.join(d.root, "board.json");
105
+ } const metaPath = path.join(d.root, "board.json");
68
106
  if (!fs.existsSync(metaPath)) {
69
107
  fs.writeFileSync(
70
108
  metaPath,
@@ -202,9 +240,26 @@ import fs from "node:fs";
202
240
  import path from "node:path";
203
241
  import crypto from "node:crypto";
204
242
 
205
- function boardRoot(worktree) {
243
+ function boardRoot(worktree, override) {
244
+ if (override) return path.resolve(String(override));
206
245
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
207
- return path.join(worktree, ".agentboard");
246
+ const base = worktree || process.cwd();
247
+ return findBoardUpward(base) || path.join(base, ".agentboard");
248
+ }
249
+
250
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
251
+ // Harnesses sometimes run agents with a cwd below (or beside) the project;
252
+ // walk-up keeps every session on the same board.
253
+ function findBoardUpward(start) {
254
+ let dir = path.resolve(start);
255
+ for (;;) {
256
+ try {
257
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
258
+ } catch {}
259
+ const parent = path.dirname(dir);
260
+ if (parent === dir) return null;
261
+ dir = parent;
262
+ }
208
263
  }
209
264
 
210
265
  function clean(name, what) {
@@ -223,11 +278,12 @@ function writeJsonAtomic(p, obj) {
223
278
 
224
279
  export default tool({
225
280
  description:
226
- "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).",
281
+ "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).",
227
282
  args: {
228
283
  from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
229
284
  to: tool.schema.string().describe("Recipient agent name, e.g. bob. They receive it on inbox/listen even before registering."),
230
285
  body: tool.schema.string().describe("Message text, 1..8000 chars."),
286
+ board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
231
287
  },
232
288
  async execute(args, context) {
233
289
  const from = clean(args.from, "from");
@@ -235,7 +291,17 @@ export default tool({
235
291
  const body = String(args.body || "").trim();
236
292
  if (!body) return "error: empty body";
237
293
  if (body.length > 8000) return "error: body too large (max 8000 chars)";
238
- const root = boardRoot(context.worktree || context.directory || process.cwd());
294
+ const boardArg = args.board === undefined || args.board === null || String(args.board).trim() === "" ? undefined : String(args.board);
295
+ const root = boardRoot(context.worktree || context.directory || process.cwd(), boardArg);
296
+ if (!boardArg && !process.env.AGENTBOARD_DIR) {
297
+ let exists = false;
298
+ try {
299
+ exists = fs.statSync(root).isDirectory();
300
+ } catch {}
301
+ if (!exists && path.dirname(root) === path.parse(root).root) {
302
+ return \`error: refusing to create a board at drive root \${root} — pass board (absolute path) or set AGENTBOARD_DIR\`;
303
+ }
304
+ }
239
305
  const now = new Date().toISOString();
240
306
  const t = new Date();
241
307
  const stamp =
@@ -261,7 +327,7 @@ export default tool({
261
327
  sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
262
328
  lastDir: context.worktree || context.directory || undefined,
263
329
  });
264
- return "sent " + id + " -> " + to;
330
+ return "sent " + id + " -> " + to + " [board " + root + "]";
265
331
  },
266
332
  });
267
333
  `;
@@ -291,7 +357,20 @@ const POLL_MS = 1000;
291
357
 
292
358
  function boardRoot(directory) {
293
359
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
294
- return path.join(directory, ".agentboard");
360
+ return findBoardUpward(directory) || path.join(directory, ".agentboard");
361
+ }
362
+
363
+ // Nearest ancestor (incl. start) containing a .agentboard dir, or null.
364
+ function findBoardUpward(start) {
365
+ let dir = path.resolve(start);
366
+ for (;;) {
367
+ try {
368
+ if (fs.statSync(path.join(dir, ".agentboard")).isDirectory()) return path.join(dir, ".agentboard");
369
+ } catch {}
370
+ const parent = path.dirname(dir);
371
+ if (parent === dir) return null;
372
+ dir = parent;
373
+ }
295
374
  }
296
375
 
297
376
  function readJsonSafe(p) {
@@ -734,7 +813,8 @@ function mergeMcpServers(file, entry) {
734
813
  const HARNESS_SECTIONS = {
735
814
  opencode: (cli) =>
736
815
  `On opencode prefer the \`dm-send\` tool over \`${cli} send\` (same thing, plus it registers your session for push).\n` +
737
- `Incoming DMs are inserted into your context automatically by the dm-watch plugin. Restart opencode after \`init\` so the tool + plugin load.`,
816
+ `Incoming DMs are inserted into your context automatically by the dm-watch plugin. Restart opencode after \`init\` so the tool + plugin load.\n` +
817
+ `Every send echoes its board (\`[board <path>]\`): if two agents see different boards, export \`AGENTBOARD_DIR=<board>\` so all sessions share one.`,
738
818
  claude: (cli) =>
739
819
  `On Claude Code use the \`agentboard\` MCP tools (\`dm_send\` / \`dm_inbox\` / \`dm_agents\` / \`dm_register\`) — approve \`.mcp.json\` when prompted.\n` +
740
820
  `A Stop hook (\`.claude/settings.json\`) injects waiting DMs at turn end. Set \`AGENTBOARD_AGENT=<you>\` once per terminal so hooks know who you are.`,
@@ -929,11 +1009,13 @@ function touchAgent(d, name, extra) {
929
1009
  }
930
1010
 
931
1011
  function cmdRegister(args) {
932
- const d = ensureBoard(boardDir(args));
1012
+ const root = boardDir(args);
1013
+ refuseDriveRootBoard(root, args);
1014
+ const d = ensureBoard(root);
933
1015
  const agent = resolveAgent(args, "agent");
934
1016
  const session = getFlag(args, "--session");
935
1017
  const doc = touchAgent(d, agent, { sessionId: session || undefined, lastDir: process.cwd() });
936
- console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""}`);
1018
+ console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""} [board ${d.root}]`);
937
1019
  }
938
1020
 
939
1021
  function cmdAgents(args) {
@@ -956,7 +1038,9 @@ function cmdAgents(args) {
956
1038
  }
957
1039
 
958
1040
  function cmdSend(args) {
959
- const d = ensureBoard(boardDir(args));
1041
+ const root = boardDir(args);
1042
+ refuseDriveRootBoard(root, args);
1043
+ const d = ensureBoard(root);
960
1044
  const from = resolveAgent(args, "sender");
961
1045
  const toRaw = getFlag(args, "--to");
962
1046
  const to = sanitizeName(toRaw, "recipient");
@@ -970,7 +1054,7 @@ function cmdSend(args) {
970
1054
  const dir = path.join(d.dm, to);
971
1055
  fs.mkdirSync(dir, { recursive: true });
972
1056
  writeJson(path.join(dir, `${id}.json`), msg);
973
- console.log(`sent ${id} -> ${to}`);
1057
+ console.log(`sent ${id} -> ${to} [board ${d.root}]`);
974
1058
  }
975
1059
 
976
1060
  function readDMs(d, recipient) {
@@ -991,7 +1075,9 @@ function printMsg(m, showTo, json) {
991
1075
  }
992
1076
 
993
1077
  function cmdInbox(args) {
994
- const d = ensureBoard(boardDir(args));
1078
+ const root = boardDir(args);
1079
+ refuseDriveRootBoard(root, args);
1080
+ const d = ensureBoard(root);
995
1081
  const showAll = args.includes("--all");
996
1082
  const limit = Number(getFlag(args, "--limit") || 20);
997
1083
  const after = getFlag(args, "--after");
@@ -1039,7 +1125,9 @@ function cmdInbox(args) {
1039
1125
  }
1040
1126
 
1041
1127
  async function cmdListen(args) {
1042
- const d = ensureBoard(boardDir(args));
1128
+ const root = boardDir(args);
1129
+ refuseDriveRootBoard(root, args);
1130
+ const d = ensureBoard(root);
1043
1131
  const agent = resolveAgent(args, "listener");
1044
1132
  const timeoutMs = Number(getFlag(args, "--timeout") || 0);
1045
1133
  const json = args.includes("--json");
@@ -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) {
@@ -21,9 +21,26 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import crypto from "node:crypto";
23
23
 
24
- function boardRoot(worktree) {
24
+ function boardRoot(worktree, override) {
25
+ if (override) return path.resolve(String(override));
25
26
  if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
26
- return path.join(worktree, ".agentboard");
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
+ 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
+ }
27
44
  }
28
45
 
29
46
  function clean(name, what) {
@@ -42,11 +59,12 @@ function writeJsonAtomic(p, obj) {
42
59
 
43
60
  export default tool({
44
61
  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).",
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).",
46
63
  args: {
47
64
  from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
48
65
  to: tool.schema.string().describe("Recipient agent name, e.g. bob. They receive it on inbox/listen even before registering."),
49
66
  body: tool.schema.string().describe("Message text, 1..8000 chars."),
67
+ board: tool.schema.string().optional().describe("Optional absolute board path, e.g. C:/proj/.agentboard. Overrides AGENTBOARD_DIR and auto-detection."),
50
68
  },
51
69
  async execute(args, context) {
52
70
  const from = clean(args.from, "from");
@@ -54,7 +72,17 @@ export default tool({
54
72
  const body = String(args.body || "").trim();
55
73
  if (!body) return "error: empty body";
56
74
  if (body.length > 8000) return "error: body too large (max 8000 chars)";
57
- const root = boardRoot(context.worktree || context.directory || process.cwd());
75
+ 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);
77
+ if (!boardArg && !process.env.AGENTBOARD_DIR) {
78
+ let exists = false;
79
+ try {
80
+ exists = fs.statSync(root).isDirectory();
81
+ } catch {}
82
+ 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`;
84
+ }
85
+ }
58
86
  const now = new Date().toISOString();
59
87
  const t = new Date();
60
88
  const stamp =
@@ -80,6 +108,6 @@ export default tool({
80
108
  sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
81
109
  lastDir: context.worktree || context.directory || undefined,
82
110
  });
83
- return "sent " + id + " -> " + to;
111
+ return "sent " + id + " -> " + to + " [board " + root + "]";
84
112
  },
85
113
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eamonpluto/agentboard",
3
- "version": "2.2.0",
3
+ "version": "2.3.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": {