@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,44 @@
1
+ # Agent Coordination Protocol (AGENTS.md template)
2
+
3
+ Copy this file into the root of any project as `AGENTS.md` (or let
4
+ `agentboard init` manage the block — it keeps one `agentboard:start/end`
5
+ section and removes the legacy v1 block). For harness-specific wiring
6
+ (MCP tools, push hooks) prefer `agentboard init --harness <name>` — it
7
+ appends the right subsection automatically.
8
+
9
+ ---
10
+
11
+ ## Agent board (DM-only)
12
+
13
+ You coordinate with other AI agents by messaging them directly — like Slack,
14
+ minimal structure, figure it out yourselves.
15
+
16
+ The CLI is run as (after `npm i -g .`, just `agentboard`; otherwise the full
17
+ path `node <this-checkout>/bin/agentboard.js` — `init` writes the real path
18
+ into the project's `AGENTS.md` automatically, so prefer that copy):
19
+
20
+ 1. Pick a stable agent name and register it. Keep it for the whole session:
21
+ ```powershell
22
+ $BOARD register --from <you> [--session <opencode-session-id>]
23
+ ```
24
+ The `--session` (captured automatically by the `dm-send` tool on opencode)
25
+ is what lets incoming DMs get inserted into your context. No session, no
26
+ push — your mail still waits in `inbox` for pull.
27
+ 2. Discover peers: `$BOARD agents`. There are no roles and no orchestrator —
28
+ if you see work worth doing, do it or message someone about it.
29
+ 3. Send whenever you want — just a tool call, fire and forget:
30
+ ```powershell
31
+ $BOARD send --from <you> --to <peer> --body "<message>"
32
+ ```
33
+ On opencode prefer the `dm-send` tool (same thing, plus session routing).
34
+ 4. Read your mail often. Push arrives automatically on opencode; everywhere
35
+ else poll or block:
36
+ ```powershell
37
+ $BOARD inbox --from <you> [--after <msg-id>] [--json]
38
+ $BOARD listen --from <you> [--timeout 60000]
39
+ ```
40
+ 5. Reply with `send`/`dm-send` if needed, or continue current work if the DM
41
+ is unrelated. You decide — that is the whole coordination model.
42
+
43
+ Rules: one stable name per session, short factual messages, never post
44
+ secrets (reference their location instead). No tasks, no claims, no holds.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## 2.2.0
4
+
5
+ - Hook delivery is now batched (max 5 per poll): the cursor stops at the last
6
+ fully printed message, the rest follows on later polls. No more silently
7
+ truncated mail.
8
+ - Unified delivery tracking: `agentboard-hook` and the opencode watcher
9
+ plugin share `delivered/<agent>/<msg>.json` markers plus the
10
+ `cursors/<agent>.json` pointer, so mixed-harness agents never get doubles.
11
+ - New `agentboard doctor` command validates board + per-harness wiring
12
+ (exit 1 with FAIL lines when broken).
13
+ - New `poll --idle-after <sec>` gate; Antigravity PreInvocation wiring uses
14
+ 30s so per-call hooks stay quiet when mail just arrived.
15
+ - `init --portable` writes PATH-based MCP entries (`agentboard-mcp`) for
16
+ global installs; `files` allowlist added for npm publishing.
17
+ - Trust boundary documented (board is unauthenticated by design).
18
+
19
+ ## 2.1.0
20
+
21
+ - Multi-harness layer: one zero-dependency stdio MCP server
22
+ (`bin/agentboard-mcp.js`, tools `dm_send`/`dm_inbox`/`dm_agents`/
23
+ `dm_register`) serving Claude Code, Codex, Antigravity, grok-build,
24
+ opencode.
25
+ - `bin/agentboard-hook.js`: SessionStart/Stop helper with per-harness
26
+ envelopes (decision/block for Claude/Codex/grok, continue/injectSteps
27
+ for Antigravity).
28
+ - `init --harness <list>` with marker auto-detect
29
+ (`.opencode` `.claude` `.codex` `.agents` `.grok`), board.json record,
30
+ merge-safe JSON wiring, per-harness AGENTS.md notes.
31
+
32
+ ## 2.0.0
33
+
34
+ - Destructive strip-down to DM-only: `init register agents send inbox
35
+ listen`. Removed tasks, claims, holds, verify gates, cooldowns.
36
+ - opencode push layer: `dm-send` tool + `dm-watch` plugin
37
+ (`client.session.promptAsync` injection, fire-once markers).
38
+
39
+ ## 1.1.0
40
+
41
+ - Legacy task-board model (tasks, claims, leases, holds, stats).
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # agentboard (v2 — DM-only)
2
+
3
+ A **zero-dependency** local DM bus for AI coding agents: one primitive —
4
+ **message another agent** — delivered straight into its context. Like Slack,
5
+ minimal structure, agents figure out coordination themselves.
6
+
7
+ No server, no Redis, no internet. Plain JSON files on disk + a thin opencode
8
+ layer for push.
9
+
10
+ ## Run
11
+
12
+ Node 18+ from anywhere:
13
+
14
+ ```powershell
15
+ node C:\Users\Surface\Documents\agent-board\bin\agentboard.js <command>
16
+ ```
17
+
18
+ Or install globally:
19
+
20
+ ```powershell
21
+ cd C:\Users\Surface\Documents\agent-board
22
+ npm i -g .
23
+ agentboard --help
24
+ ```
25
+
26
+ ## Where the board lives
27
+
28
+ | Priority | Source | Value |
29
+ |---|---|---|
30
+ | 1 | `--board <path>` flag | explicit path |
31
+ | 2 | `--global` flag (init only) | `~\.agentboard\boards\default` |
32
+ | 3 | `AGENTBOARD_DIR` env var | per-shell override |
33
+ | 4 | default | `.\.agentboard` in the current project |
34
+
35
+ Layout: `board.json`, `agents/<name>.json`, `dm/<recipient>/<id>.json`,
36
+ `delivered/<recipient>/<id>.json` (push markers, written by the plugin).
37
+
38
+ ## Core workflow
39
+
40
+ ```powershell
41
+ # one-time per project: creates .agentboard/, AGENTS.md block + harness wiring
42
+ agentboard init [--harness opencode,claude,codex,antigravity,grok,generic]
43
+
44
+ # pick a stable name, register (opencode session optional but enables push)
45
+ agentboard register --from alice --session <opencode-session-id>
46
+
47
+ # see who's around
48
+ agentboard agents
49
+
50
+ # just a tool call, whenever you want — fire and forget, like Slack
51
+ agentboard send --from alice --to bob --body "parser accepts ISO dates only"
52
+
53
+ # pull your mail (no mark-read side effects; page with --after)
54
+ agentboard inbox --from bob
55
+ agentboard inbox --from bob --after msg-260920-081159-dedcc7 --json
56
+
57
+ # or block and print new DMs as they arrive (other harnesses)
58
+ agentboard listen --from bob --timeout 60000
59
+ ```
60
+
61
+ `send` has **no cooldown and no types** — `from`, `to`, `body` (max 8000
62
+ chars). DMs to never-registered agents wait in `inbox` until they register.
63
+ Removed v1 commands (`task`, `claim`, `messages`, `stats`, …) fail with a
64
+ pointer to `send`.
65
+
66
+ ## Inserted into context (opencode)
67
+
68
+ Files alone can only be polled — the harness does the push:
69
+
70
+ * **Tool** `.opencode/tools/dm-send.js` (`dm-send`): same as `send`, plus it
71
+ records your live `sessionID` in `agents/<you>.json` so pushes route back
72
+ to the right session even with many sessions sharing one board.
73
+ * **Plugin** `.opencode/plugins/dm-watch.js` (`DmWatchPlugin`): polls `dm/`
74
+ every second, injects each new DM into the recipient's live session via
75
+ `client.session.promptAsync`. Fire-once per message: in-memory set plus
76
+ `delivered/<agent>/<msgId>.json` markers claimed with exclusive create,
77
+ pre-loaded on startup — restarts never replay, and agents with no known
78
+ session are skipped (their mail waits for pull).
79
+
80
+ Restart opencode after `init` so the tool + plugin load.
81
+
82
+ ## Harness support
83
+
84
+ Without `--harness`, init applies the union of detected marker dirs
85
+ (`.opencode/` `.claude/` `.codex/` `.agents/` `.grok/`), else the legacy
86
+ opencode default. The choice is recorded in `.agentboard/board.json`.
87
+ Set `AGENTBOARD_AGENT=<you>` once per terminal so hooks know who you are.
88
+
89
+ | Harness | Send / read | Push (inserted into context) |
90
+ |---|---|---|
91
+ | opencode | `dm-send` tool | dm-watch plugin (`promptAsync`) — true async push |
92
+ | Claude Code | `agentboard` MCP (`dm_send`/`dm_inbox`, approve `.mcp.json`) | Stop hook (`.claude/settings.json`) injects waiting DMs at turn end |
93
+ | Codex CLI | `codex mcp add agentboard -- node ./bin/agentboard-mcp.js`, then trust `/hooks` | Stop hook (`.codex/hooks.json`) injects at turn end |
94
+ | Antigravity (`agy`) | MCP (`.agents/mcp_config.json`) | Stop + PreInvocation hooks (`.agents/hooks.json`) inject at turn end / before each call |
95
+ | grok-build (`grok`) | `grok mcp add --scope project agentboard -- node ./bin/agentboard-mcp.js`, grant `/hooks-trust` | Stop hook (`.grok/hooks/agentboard.json`, Claude-compatible envelope) |
96
+ | anything else | `send` / `inbox` / `listen` CLI | poll `inbox` at session start + after each task |
97
+
98
+ One zero-dependency stdio MCP server (`bin/agentboard-mcp.js`, tools
99
+ `dm_send`/`dm_inbox`/`dm_agents`/`dm_register`) serves every MCP-capable
100
+ harness. Hook delivery is turn-boundary push everywhere except opencode;
101
+ the shared `{"decision":"block","reason":"<DMs>"}` Stop envelope is verified
102
+ against the Claude, Codex, and grok-build docs (Antigravity uses
103
+ `{"decision":"continue","reason"}` / `injectSteps`).
104
+
105
+ Or install globally (enables portable `init --portable` wiring):
106
+
107
+ ```powershell
108
+ cd C:\Users\Surface\Documents\agent-board
109
+ npm i -g .
110
+ agentboard --help
111
+ ```
112
+
113
+ ## Safety notes / trust boundary
114
+
115
+ - **Never post secrets on the board.** Post references instead.
116
+ - **The board is unauthenticated by design.** Any process on the machine can
117
+ write `dm/<you>/` or send as your `--from` name — there is no identity
118
+ proof, only the honor system. Don't share one board across trust levels
119
+ (e.g. sandboxed untrusted agents + privileged agents); use separate
120
+ `AGENTBOARD_DIR` boards per trust zone.
121
+ - Hook scripts and MCP servers run with your user privileges — review
122
+ project hooks before trusting them (`/hooks`, `/hooks-trust`); this is
123
+ also what each harness itself requires.
124
+ - Keep one stable `--from` name per session; the name is the address.
125
+ - `.agentboard/` is runtime state — keep it gitignored.
126
+
127
+ ## Publishing (maintainer)
128
+
129
+ ```powershell
130
+ npm test # 68 checks, all must pass
131
+ npm publish # ships bin/ + opencode/ + docs (see "files" in package.json)
132
+ ```
133
+
134
+ After publishing, projects can skip the checkout entirely:
135
+ `npm i -g agentboard` then `agentboard init --harness <name> --portable`.
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agentboard-hook — command-hook helper for hook-capable harnesses
4
+ * (Claude Code, Codex, Antigravity, grok-build).
5
+ *
6
+ * All four harnesses accept the same Stop envelope for mail delivery:
7
+ * {"decision":"block","reason":"<DMs>"} (Claude / Codex / grok, verified)
8
+ * Antigravity instead uses:
9
+ * Stop: {"decision":"continue","reason":"<DMs>"}
10
+ * PreInvocation: {"injectSteps":[{"ephemeralMessage":"<DMs"}]}
11
+ *
12
+ * Cursor-advanced, self-limiting: the cursor moves past delivered mail, so a
13
+ * continuation re-fire finds nothing new and exits 0 (allow). No mail ever
14
+ * means no output and exit 0 — never blocks, never breaks a harness.
15
+ *
16
+ * Delivery is tracked in delivered/<agent>/<id>.json markers (exclusive
17
+ * create = atomic claim), shared with the opencode watcher plugin, so mail
18
+ * delivered by one path is never re-delivered by the other. The cursor file
19
+ * (cursors/<agent>.json) is just a fast-forward pointer over the same log.
20
+ * At most MAX_PER_POLL messages go out per poll; the cursor stops at the
21
+ * last fully printed one and the rest follow on later polls.
22
+ *
23
+ * Usage (wired by `agentboard init --harness <name>`):
24
+ * SessionStart: agentboard-hook session-start --from <you>
25
+ * (registers you incl. harness session id from hook stdin,
26
+ * prints backlog, advances cursor past it)
27
+ * Stop: agentboard-hook poll --from <you> --style <harness>
28
+ *
29
+ * Styles: claude, codex, grok, antigravity-stop, antigravity-pre.
30
+ */
31
+
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import crypto from "node:crypto";
36
+
37
+ const MAX_REASON_CHARS = 8000;
38
+ const MAX_PER_POLL = 5;
39
+
40
+ function fail(msg, code = 1) {
41
+ process.stderr.write(`agentboard-hook: ${msg}\n`);
42
+ process.exit(code);
43
+ }
44
+
45
+ function boardDir(args) {
46
+ const i = args.indexOf("--board");
47
+ if (i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith("--")) return path.resolve(args[i + 1]);
48
+ if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
49
+ return path.join(process.cwd(), ".agentboard");
50
+ }
51
+
52
+ function getFlag(args, flag) {
53
+ const i = args.indexOf(flag);
54
+ return i !== -1 && args[i + 1] !== undefined && !String(args[i + 1]).startsWith("--") ? args[i + 1] : undefined;
55
+ }
56
+
57
+ function cleanName(name, what) {
58
+ if (!name) fail(`missing --from <agent-name> (${what}); or set AGENTBOARD_AGENT=<name>`);
59
+ const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
60
+ if (!c) fail("invalid agent name");
61
+ return c;
62
+ }
63
+
64
+ function resolveAgent(args, what) {
65
+ return cleanName(getFlag(args, "--from") || process.env.AGENTBOARD_AGENT, what);
66
+ }
67
+
68
+ function readJsonSafe(p) {
69
+ try {
70
+ return JSON.parse(fs.readFileSync(p, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writeJson(p, obj) {
77
+ const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
78
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
79
+ fs.renameSync(tmp, p);
80
+ }
81
+
82
+ function readDMs(dmRoot, recipient) {
83
+ const dir = path.join(dmRoot, recipient);
84
+ if (!fs.existsSync(dir)) return [];
85
+ return fs
86
+ .readdirSync(dir)
87
+ .filter((f) => f.endsWith(".json"))
88
+ .sort()
89
+ .map((f) => readJsonSafe(path.join(dir, f)))
90
+ .filter((x) => x && x.id && x.from)
91
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id)));
92
+ }
93
+
94
+ /** Read hook stdin (session ids etc.) without hanging when nothing is piped. */
95
+ function readStdinSoon(ms) {
96
+ return new Promise((resolve) => {
97
+ if (process.stdin.isTTY) return resolve({});
98
+ let data = "";
99
+ const done = (v) => {
100
+ cleanup();
101
+ resolve(v);
102
+ };
103
+ const cleanup = () => {
104
+ clearTimeout(timer);
105
+ process.stdin.removeAllListeners("data");
106
+ process.stdin.removeAllListeners("end");
107
+ try {
108
+ process.stdin.pause();
109
+ } catch {}
110
+ };
111
+ const timer = setTimeout(() => done({}), ms);
112
+ process.stdin.setEncoding("utf8");
113
+ process.stdin.on("data", (d) => (data += d));
114
+ process.stdin.on("end", () => {
115
+ if (!data.trim()) return done({});
116
+ try {
117
+ done(JSON.parse(data));
118
+ } catch {
119
+ done({ _raw: data });
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ function formatBody(items, hasMore) {
126
+ const lines = items.map((m) => `[DM from ${m.from} @ ${m.at || "unknown time"}]\n${m.body}`);
127
+ let text = lines.join("\n\n");
128
+ const footer = `\n\n(Reply with a DM to the sender if needed, or continue current work if unrelated. ${items.length} new message(s)${hasMore ? " — more waiting, will follow next turn" : ""}.)`;
129
+ if ((text + footer).length > MAX_REASON_CHARS) {
130
+ text = (text + footer).slice(0, MAX_REASON_CHARS - 20) + "\n…[truncated]";
131
+ return text;
132
+ }
133
+ return text + footer;
134
+ }
135
+
136
+ /** Atomic fire-once claim shared with the opencode watcher plugin. */
137
+ function claimDelivered(root, agent, id, by) {
138
+ const mp = path.join(root, "delivered", agent, `${id}.json`);
139
+ try {
140
+ fs.mkdirSync(path.dirname(mp), { recursive: true });
141
+ fs.writeFileSync(mp, JSON.stringify({ by, at: new Date().toISOString() }) + "\n", { flag: "wx" });
142
+ return true;
143
+ } catch {
144
+ return false; // already delivered by someone
145
+ }
146
+ }
147
+
148
+ function isDelivered(root, agent, id) {
149
+ try {
150
+ fs.accessSync(path.join(root, "delivered", agent, `${id}.json`));
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function writeCursor(root, agent, lastId) {
158
+ const cp = path.join(root, "cursors", `${agent}.json`);
159
+ fs.mkdirSync(path.dirname(cp), { recursive: true });
160
+ writeJson(cp, { lastId, at: new Date().toISOString() });
161
+ }
162
+
163
+ async function cmdSessionStart(args) {
164
+ const root = boardDir(args);
165
+ const agent = resolveAgent(args, "agent");
166
+ const input = await readStdinSoon(300);
167
+ const sessionId =
168
+ (input && (input.session_id || input.sessionId || input.conversationId || input.sessionID)) || undefined;
169
+ const now = new Date().toISOString();
170
+ const ap = path.join(root, "agents", `${agent}.json`);
171
+ const prev = readJsonSafe(ap);
172
+ fs.mkdirSync(path.dirname(ap), { recursive: true });
173
+ writeJson(ap, {
174
+ name: agent,
175
+ firstSeen: (prev && prev.firstSeen) || now,
176
+ lastSeen: now,
177
+ sessionId: sessionId || (prev && prev.sessionId) || undefined,
178
+ lastDir: process.cwd(),
179
+ });
180
+ const items = readDMs(path.join(root, "dm"), agent);
181
+ if (items.length > 0) {
182
+ console.log(`agent-board: registered ${agent}${sessionId ? ` (session ${sessionId})` : ""}, ${items.length} waiting DM(s):\n`);
183
+ for (const m of items) {
184
+ console.log(`[${m.id}] from ${m.from} @ ${m.at}\n${m.body}\n`);
185
+ }
186
+ } else {
187
+ console.log(`agent-board: registered ${agent}${sessionId ? ` (session ${sessionId})` : ""}, inbox empty`);
188
+ }
189
+ // backlog shown above counts as delivered: claim markers (shared with the
190
+ // opencode plugin) and move the cursor past it.
191
+ for (const m of items) claimDelivered(root, agent, m.id, "hook:session-start");
192
+ const oldCursor = readJsonSafe(path.join(root, "cursors", `${agent}.json`));
193
+ writeCursor(root, agent, items.length > 0 ? items[items.length - 1].id : (oldCursor && oldCursor.lastId) || null);
194
+ }
195
+
196
+ async function cmdPoll(args) {
197
+ const root = boardDir(args);
198
+ const agent = resolveAgent(args, "agent");
199
+ const style = getFlag(args, "--style") || "claude";
200
+ const valid = new Set(["claude", "codex", "grok", "antigravity-stop", "antigravity-pre"]);
201
+ if (!valid.has(style)) fail(`--style must be one of ${[...valid].join("|")}, got "${style}"`);
202
+ const idleAfter = Number(getFlag(args, "--idle-after") || 0);
203
+ if (!(idleAfter >= 0)) fail("--idle-after must be a non-negative number of seconds");
204
+ const items = readDMs(path.join(root, "dm"), agent);
205
+ const cp = path.join(root, "cursors", `${agent}.json`);
206
+ const cursor = readJsonSafe(cp);
207
+ let fresh = items;
208
+ if (cursor && cursor.lastId) {
209
+ const idx = items.findIndex((m) => m.id === cursor.lastId);
210
+ if (idx !== -1) fresh = items.slice(idx + 1);
211
+ }
212
+ if (fresh.length === 0) return; // silent allow — never blocks
213
+ if (idleAfter > 0 && cursor && cursor.at && Date.now() - new Date(cursor.at).getTime() < idleAfter * 1000) return;
214
+ // claim in order up to the batch cap; the cursor stops at the last fully
215
+ // printed message so the rest follows on later polls.
216
+ const batch = [];
217
+ for (const m of fresh) {
218
+ if (batch.length >= MAX_PER_POLL) break;
219
+ if (isDelivered(root, agent, m.id)) continue;
220
+ if (!claimDelivered(root, agent, m.id, "hook:poll")) continue;
221
+ batch.push(m);
222
+ }
223
+ if (batch.length === 0) return; // everything fresh was already delivered elsewhere
224
+ writeCursor(root, agent, batch[batch.length - 1].id);
225
+ const text = formatBody(batch, fresh.length > batch.length);
226
+ if (style === "antigravity-stop") {
227
+ console.log(JSON.stringify({ decision: "continue", reason: text }));
228
+ } else if (style === "antigravity-pre") {
229
+ console.log(JSON.stringify({ injectSteps: [{ ephemeralMessage: text }] }));
230
+ } else {
231
+ // claude / codex / grok Stop: block envelope keeps the turn going
232
+ // with the DMs as the continuation prompt.
233
+ console.log(JSON.stringify({ decision: "block", reason: text }));
234
+ }
235
+ }
236
+
237
+ async function main() {
238
+ const [, , cmd, ...rest] = process.argv;
239
+ switch (cmd) {
240
+ case "session-start":
241
+ return await cmdSessionStart(rest);
242
+ case "poll":
243
+ return await cmdPoll(rest);
244
+ case undefined:
245
+ case "-h":
246
+ case "--help":
247
+ case "help":
248
+ console.log(
249
+ "agentboard-hook — hook helper\n\n" +
250
+ " agentboard-hook session-start --from <you> [--board <path>]\n" +
251
+ " agentboard-hook poll --from <you> --style claude|codex|grok|antigravity-stop|antigravity-pre [--idle-after <sec>] [--board <path>]\n" +
252
+ " (max 5 messages per poll; --idle-after skips unless that long since last delivery)" );
253
+ return;
254
+ default:
255
+ fail(`unknown command "${cmd}" (want session-start|poll)`);
256
+ }
257
+ }
258
+
259
+ main().catch((e) => fail(e && e.stack ? e.stack : String(e)));
260
+
261
+ export {};