@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.
- package/AGENTS.template.md +44 -0
- package/CHANGELOG.md +41 -0
- package/README.md +135 -0
- package/bin/agentboard-hook.js +261 -0
- package/bin/agentboard-mcp.js +317 -0
- package/bin/agentboard.js +1265 -0
- package/opencode/plugins/dm-watch.js +233 -0
- package/opencode/tools/dm-send.js +85 -0
- package/package.json +25 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agentboard — DM-only minimal message bus for AI coding agents.
|
|
4
|
+
*
|
|
5
|
+
* v2: destructive strip-down to the primitive described as "message another
|
|
6
|
+
* agent, inserted into context, just a tool call, whenever it wants".
|
|
7
|
+
*
|
|
8
|
+
* Storage layout (default: <project>/.agentboard/, override with
|
|
9
|
+
* AGENTBOARD_DIR or --board <path>):
|
|
10
|
+
*
|
|
11
|
+
* .agentboard/
|
|
12
|
+
* board.json board metadata {name, version:2, createdAt}
|
|
13
|
+
* agents/<name>.json {name, firstSeen, lastSeen, sessionId?, lastDir?}
|
|
14
|
+
* dm/<recipient>/<id>.json {id, from, to, body, at}
|
|
15
|
+
* delivered/<recipient>/<id>.json push markers written by the opencode
|
|
16
|
+
* plugin after injecting into a session
|
|
17
|
+
* (fire-once; CLI never writes these)
|
|
18
|
+
*
|
|
19
|
+
* No tasks, no claims, no holds, no verify gates, no cooldowns. Send whenever
|
|
20
|
+
* you want. Delivery is files; "inserted into context" is done by the opencode
|
|
21
|
+
* plugin (opencode/plugins/dm-watch.js) via client.session.promptAsync, or by
|
|
22
|
+
* polling `inbox` / blocking `listen` on other harnesses.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
|
|
30
|
+
const MAX_BODY_CHARS = 8000;
|
|
31
|
+
const BOARD_VERSION = 2;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function fail(msg, code = 1) {
|
|
38
|
+
process.stderr.write(`agentboard: ${msg}\n`);
|
|
39
|
+
process.exit(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function boardDir(args) {
|
|
43
|
+
const flagIdx = args.indexOf("--board");
|
|
44
|
+
if (flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith("--"))
|
|
45
|
+
return path.resolve(args[flagIdx + 1]);
|
|
46
|
+
if (args.includes("--global")) {
|
|
47
|
+
return path.join(os.homedir(), ".agentboard", "boards", "default");
|
|
48
|
+
}
|
|
49
|
+
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
50
|
+
return path.join(process.cwd(), ".agentboard");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dirs(root) {
|
|
54
|
+
return {
|
|
55
|
+
root,
|
|
56
|
+
agents: path.join(root, "agents"),
|
|
57
|
+
dm: path.join(root, "dm"),
|
|
58
|
+
delivered: path.join(root, "delivered"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureBoard(root) {
|
|
63
|
+
const d = dirs(root);
|
|
64
|
+
for (const p of [d.root, d.agents, d.dm, d.delivered]) {
|
|
65
|
+
fs.mkdirSync(p, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
const metaPath = path.join(d.root, "board.json");
|
|
68
|
+
if (!fs.existsSync(metaPath)) {
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
metaPath,
|
|
71
|
+
JSON.stringify({ name: "board", version: BOARD_VERSION, createdAt: new Date().toISOString() }, null, 2) + "\n"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return d;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJson(p) {
|
|
78
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeJson(p, obj) {
|
|
82
|
+
const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
83
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
84
|
+
fs.renameSync(tmp, p);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Atomic fire-once claim: create file only if it does not exist. */
|
|
88
|
+
function writeExclusiveJson(p, obj) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
91
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", { flag: "wx" });
|
|
92
|
+
return true;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e && (e.code === "EEXIST" || String(e.message).includes("EEXIST"))) return false;
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function newId(prefix) {
|
|
100
|
+
const t = new Date();
|
|
101
|
+
const stamp =
|
|
102
|
+
t.getUTCFullYear().toString().slice(2) +
|
|
103
|
+
String(t.getUTCMonth() + 1).padStart(2, "0") +
|
|
104
|
+
String(t.getUTCDate()).padStart(2, "0") +
|
|
105
|
+
"-" +
|
|
106
|
+
String(t.getUTCHours()).padStart(2, "0") +
|
|
107
|
+
String(t.getUTCMinutes()).padStart(2, "0") +
|
|
108
|
+
String(t.getUTCSeconds()).padStart(2, "0");
|
|
109
|
+
return `${prefix}-${stamp}-${crypto.randomBytes(3).toString("hex")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sanitizeName(name, what) {
|
|
113
|
+
if (!name) fail(`missing --${what === "recipient" ? "to" : "from"} <agent-name> (${what}); or set env AGENTBOARD_AGENT=<name>`);
|
|
114
|
+
const clean = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
|
|
115
|
+
if (!clean) fail(`invalid agent name`);
|
|
116
|
+
return clean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getFlag(args, flag) {
|
|
120
|
+
const i = args.indexOf(flag);
|
|
121
|
+
return i !== -1 && args[i + 1] !== undefined && !String(args[i + 1]).startsWith("--") ? args[i + 1] : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Positional args with flag values removed (so `send --from alice --to bob`
|
|
125
|
+
// with no body doesn't mistake "alice bob" for a message).
|
|
126
|
+
const VALUE_FLAGS = new Set(["--from", "--to", "--body", "--session", "--board", "--limit", "--after", "--timeout"]);
|
|
127
|
+
function restArgs(args) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (let i = 0; i < args.length; i++) {
|
|
130
|
+
if (String(args[i]).startsWith("--")) {
|
|
131
|
+
if (VALUE_FLAGS.has(args[i])) i++; // skip its value too
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
out.push(args[i]);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveAgent(args, what) {
|
|
140
|
+
return sanitizeName(getFlag(args, "--from") || process.env.AGENTBOARD_AGENT, what);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function optionalAgent(args) {
|
|
144
|
+
const raw = getFlag(args, "--from") || process.env.AGENTBOARD_AGENT;
|
|
145
|
+
return raw ? sanitizeName(raw, "agent") : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function listJson(dirPath) {
|
|
149
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
150
|
+
return fs
|
|
151
|
+
.readdirSync(dirPath)
|
|
152
|
+
.filter((f) => f.endsWith(".json"))
|
|
153
|
+
.sort()
|
|
154
|
+
.map((f) => {
|
|
155
|
+
const p = path.join(dirPath, f);
|
|
156
|
+
try {
|
|
157
|
+
return { file: f, path: p, data: readJson(p) };
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { file: f, path: p, data: { _error: String(e) } };
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function relTime(iso) {
|
|
165
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
166
|
+
const s = Math.floor(ms / 1000);
|
|
167
|
+
if (s < 60) return `${s}s ago`;
|
|
168
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
169
|
+
return `${Math.floor(Math.max(s, 0) / 3600)}h ago`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cliInvoke() {
|
|
173
|
+
const here = path.resolve(process.argv[1] || "").split(path.sep).join("/");
|
|
174
|
+
if (here.includes("node_modules/agentboard/bin/agentboard.js")) return "agentboard";
|
|
175
|
+
return `node "${here}"`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// opencode integration templates (embedded so `init` works when globally installed)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
const OPENCODE_TOOL_DM_SEND = `// .opencode/tools/dm-send.js — primitive DM tool for agent-board (DM-only v2).
|
|
183
|
+
// Filename becomes the tool name: dm-send.
|
|
184
|
+
// Loaded by opencode alongside built-in tools. Zero extra deps.
|
|
185
|
+
//
|
|
186
|
+
// Usage from the agent (just a tool call, whenever you want):
|
|
187
|
+
// dm-send({ from: "alice", to: "bob", body: "the parser accepts ISO dates only" })
|
|
188
|
+
//
|
|
189
|
+
// What it does:
|
|
190
|
+
// 1. resolves the board (AGENTBOARD_DIR env, else <worktree>/.agentboard)
|
|
191
|
+
// 2. writes .agentboard/dm/<to>/<msg-id>.json (atomic write-then-rename)
|
|
192
|
+
// 3. upserts .agentboard/agents/<from>.json with { lastSeen, sessionId }
|
|
193
|
+
// so the watcher plugin can route pushes back to the right session.
|
|
194
|
+
// 4. returns "sent <id> -> <to>" for the calling agent to see.
|
|
195
|
+
//
|
|
196
|
+
// Delivery ("inserted into context") is done by ../plugins/dm-watch.js, which
|
|
197
|
+
// polls dm/ and injects via client.session.promptAsync. This tool never blocks
|
|
198
|
+
// waiting for a reply — fire and forget, like Slack.
|
|
199
|
+
|
|
200
|
+
import { tool } from "@opencode-ai/plugin";
|
|
201
|
+
import fs from "node:fs";
|
|
202
|
+
import path from "node:path";
|
|
203
|
+
import crypto from "node:crypto";
|
|
204
|
+
|
|
205
|
+
function boardRoot(worktree) {
|
|
206
|
+
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
207
|
+
return path.join(worktree, ".agentboard");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clean(name, what) {
|
|
211
|
+
if (!name) throw new Error("missing " + what);
|
|
212
|
+
const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
|
|
213
|
+
if (!c) throw new Error("invalid " + what);
|
|
214
|
+
return c;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writeJsonAtomic(p, obj) {
|
|
218
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
219
|
+
const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
220
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\\n");
|
|
221
|
+
fs.renameSync(tmp, p);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export default tool({
|
|
225
|
+
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).",
|
|
227
|
+
args: {
|
|
228
|
+
from: tool.schema.string().describe("Your stable agent name, e.g. alice. Keep it constant for the session."),
|
|
229
|
+
to: tool.schema.string().describe("Recipient agent name, e.g. bob. They receive it on inbox/listen even before registering."),
|
|
230
|
+
body: tool.schema.string().describe("Message text, 1..8000 chars."),
|
|
231
|
+
},
|
|
232
|
+
async execute(args, context) {
|
|
233
|
+
const from = clean(args.from, "from");
|
|
234
|
+
const to = clean(args.to, "to");
|
|
235
|
+
const body = String(args.body || "").trim();
|
|
236
|
+
if (!body) return "error: empty body";
|
|
237
|
+
if (body.length > 8000) return "error: body too large (max 8000 chars)";
|
|
238
|
+
const root = boardRoot(context.worktree || context.directory || process.cwd());
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
const t = new Date();
|
|
241
|
+
const stamp =
|
|
242
|
+
String(t.getUTCFullYear()).slice(2) +
|
|
243
|
+
String(t.getUTCMonth() + 1).padStart(2, "0") +
|
|
244
|
+
String(t.getUTCDate()).padStart(2, "0") +
|
|
245
|
+
"-" +
|
|
246
|
+
String(t.getUTCHours()).padStart(2, "0") +
|
|
247
|
+
String(t.getUTCMinutes()).padStart(2, "0") +
|
|
248
|
+
String(t.getUTCSeconds()).padStart(2, "0");
|
|
249
|
+
const id = "msg-" + stamp + "-" + crypto.randomBytes(3).toString("hex");
|
|
250
|
+
writeJsonAtomic(path.join(root, "dm", to, id + ".json"), { id, from, to, body, at: now });
|
|
251
|
+
// upsert sender with live session routing for the watcher plugin
|
|
252
|
+
const ap = path.join(root, "agents", from + ".json");
|
|
253
|
+
let prev = null;
|
|
254
|
+
try {
|
|
255
|
+
prev = JSON.parse(fs.readFileSync(ap, "utf8"));
|
|
256
|
+
} catch {}
|
|
257
|
+
writeJsonAtomic(ap, {
|
|
258
|
+
name: from,
|
|
259
|
+
firstSeen: (prev && prev.firstSeen) || now,
|
|
260
|
+
lastSeen: now,
|
|
261
|
+
sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
|
|
262
|
+
lastDir: context.worktree || context.directory || undefined,
|
|
263
|
+
});
|
|
264
|
+
return "sent " + id + " -> " + to;
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
`;
|
|
268
|
+
|
|
269
|
+
const OPENCODE_PLUGIN_DM_WATCH = `// .opencode/plugins/dm-watch.js — inject DMs into context (agent-board DM-only v2).
|
|
270
|
+
// Watches <board>/dm/<agent>/*.json and delivers new messages to the live
|
|
271
|
+
// opencode session registered for <agent> via client.session.promptAsync.
|
|
272
|
+
//
|
|
273
|
+
// Routing: .agentboard/agents/<name>.json holds { sessionId }. The dm-send
|
|
274
|
+
// tool writes it on every send; register --session writes it from the CLI.
|
|
275
|
+
// Fire-once: in-memory Set + on-disk delivered/<agent>/<msgId>.json markers
|
|
276
|
+
// claimed with exclusive create ('wx'), pre-populated on startup (survives
|
|
277
|
+
// restarts, same idea as bgrun's .notify -> .notified rename). Markers are
|
|
278
|
+
// shared with agentboard-hook, and the hook's cursors/<agent>.json fast-
|
|
279
|
+
// forward pointer is honored (and advanced on our deliveries), so agents
|
|
280
|
+
// mixing harnesses never get a message twice.
|
|
281
|
+
// Polls every 1s; that poll is the source of truth (no fs.watch dependency).
|
|
282
|
+
//
|
|
283
|
+
// Agents with no known session are skipped — their mail waits in the inbox
|
|
284
|
+
// for pull (\`inbox --from <you>\`), so one idle session never steals another
|
|
285
|
+
// agent's mail.
|
|
286
|
+
|
|
287
|
+
import fs from "node:fs";
|
|
288
|
+
import path from "node:path";
|
|
289
|
+
|
|
290
|
+
const POLL_MS = 1000;
|
|
291
|
+
|
|
292
|
+
function boardRoot(directory) {
|
|
293
|
+
if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
|
|
294
|
+
return path.join(directory, ".agentboard");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readJsonSafe(p) {
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export const DmWatchPlugin = async ({ client, directory }) => {
|
|
306
|
+
const root = boardRoot(directory);
|
|
307
|
+
const dmDir = path.join(root, "dm");
|
|
308
|
+
const agentsDir = path.join(root, "agents");
|
|
309
|
+
const deliveredDir = path.join(root, "delivered");
|
|
310
|
+
|
|
311
|
+
const agentToSession = new Map(); // agent -> sessionID
|
|
312
|
+
const processed = new Set(); // "<agent>/<msgId>"
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
fs.mkdirSync(deliveredDir, { recursive: true });
|
|
316
|
+
} catch {}
|
|
317
|
+
|
|
318
|
+
// pre-populate from delivered markers so restarts don't replay
|
|
319
|
+
try {
|
|
320
|
+
for (const agent of fs.readdirSync(deliveredDir)) {
|
|
321
|
+
const ad = path.join(deliveredDir, agent);
|
|
322
|
+
try {
|
|
323
|
+
if (!fs.statSync(ad).isDirectory()) continue;
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const f of fs.readdirSync(ad)) {
|
|
328
|
+
if (f.endsWith(".json")) processed.add(agent + "/" + f.replace(/\\.json$/, ""));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
|
|
333
|
+
function refreshAgentMap() {
|
|
334
|
+
let files = [];
|
|
335
|
+
try {
|
|
336
|
+
files = fs.readdirSync(agentsDir);
|
|
337
|
+
} catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
for (const f of files) {
|
|
341
|
+
if (!f.endsWith(".json")) continue;
|
|
342
|
+
const doc = readJsonSafe(path.join(agentsDir, f));
|
|
343
|
+
if (doc && doc.name && doc.sessionId) agentToSession.set(doc.name, doc.sessionId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function deliveredMarker(agent, id) {
|
|
348
|
+
return path.join(deliveredDir, agent, id + ".json");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function claim(agent, id, sessionID) {
|
|
352
|
+
const key = agent + "/" + id;
|
|
353
|
+
if (processed.has(key)) return false;
|
|
354
|
+
processed.add(key);
|
|
355
|
+
try {
|
|
356
|
+
fs.mkdirSync(path.dirname(deliveredMarker(agent, id)), { recursive: true });
|
|
357
|
+
fs.writeFileSync(deliveredMarker(agent, id), JSON.stringify({ sessionID, at: new Date().toISOString() }) + "\\n", {
|
|
358
|
+
flag: "wx",
|
|
359
|
+
});
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false; // already delivered by another instance
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Cursor file shared with agentboard-hook: hook delivery moves it, and we
|
|
367
|
+
// honor it (plus our markers) so mixed-harness agents never get doubles.
|
|
368
|
+
// We also advance it on our own deliveries.
|
|
369
|
+
function readCursor(agent) {
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(fs.readFileSync(path.join(root, "cursors", agent + ".json"), "utf8"));
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function orderIds(agent) {
|
|
378
|
+
try {
|
|
379
|
+
return fs.readdirSync(path.join(dmDir, agent)).filter((f) => f.endsWith(".json")).sort().map((f) => f.replace(/\\.json$/, ""));
|
|
380
|
+
} catch {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function advanceCursor(agent, id) {
|
|
386
|
+
try {
|
|
387
|
+
const order = orderIds(agent);
|
|
388
|
+
const cur = readCursor(agent);
|
|
389
|
+
const curIdx = cur && cur.lastId ? order.indexOf(cur.lastId) : -1;
|
|
390
|
+
if (order.indexOf(id) > curIdx) {
|
|
391
|
+
fs.mkdirSync(path.join(root, "cursors"), { recursive: true });
|
|
392
|
+
fs.writeFileSync(
|
|
393
|
+
path.join(root, "cursors", agent + ".json"),
|
|
394
|
+
JSON.stringify({ lastId: id, at: new Date().toISOString() }) + "\\n"
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function coveredByCursor(agent, id) {
|
|
401
|
+
const order = orderIds(agent);
|
|
402
|
+
const cur = readCursor(agent);
|
|
403
|
+
if (!cur || !cur.lastId) return false;
|
|
404
|
+
const curIdx = order.indexOf(cur.lastId);
|
|
405
|
+
const idx = order.indexOf(id);
|
|
406
|
+
return curIdx !== -1 && idx !== -1 && idx <= curIdx;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function deliver(agent, sessionID, msg) {
|
|
410
|
+
if (!claim(agent, msg.id, sessionID)) return;
|
|
411
|
+
advanceCursor(agent, msg.id);
|
|
412
|
+
const text =
|
|
413
|
+
"[DM from " +
|
|
414
|
+
msg.from +
|
|
415
|
+
" @ " +
|
|
416
|
+
msg.at +
|
|
417
|
+
"]\\n" +
|
|
418
|
+
msg.body +
|
|
419
|
+
"\\n\\n(Reply with dm-send if needed, or continue current work if unrelated.)";
|
|
420
|
+
try {
|
|
421
|
+
if (client.session && typeof client.session.promptAsync === "function") {
|
|
422
|
+
await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
|
|
423
|
+
} else if (client.session && typeof client.session.prompt === "function") {
|
|
424
|
+
await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
|
|
425
|
+
}
|
|
426
|
+
} catch (e) {
|
|
427
|
+
try {
|
|
428
|
+
await client.app.log({
|
|
429
|
+
body: {
|
|
430
|
+
service: "dm-watch",
|
|
431
|
+
level: "warn",
|
|
432
|
+
message: "DM wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
} catch {}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function poll() {
|
|
440
|
+
refreshAgentMap();
|
|
441
|
+
let agents = [];
|
|
442
|
+
try {
|
|
443
|
+
agents = fs.readdirSync(dmDir).filter((e) => {
|
|
444
|
+
try {
|
|
445
|
+
return fs.statSync(path.join(dmDir, e)).isDirectory();
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
} catch {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
for (const agent of agents) {
|
|
454
|
+
const sessionID = agentToSession.get(agent);
|
|
455
|
+
if (!sessionID) continue; // nobody live for this name — mail waits for pull
|
|
456
|
+
let files = [];
|
|
457
|
+
try {
|
|
458
|
+
files = fs.readdirSync(path.join(dmDir, agent)).filter((f) => f.endsWith(".json")).sort();
|
|
459
|
+
} catch {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
for (const f of files) {
|
|
463
|
+
const id = f.replace(/\\.json$/, "");
|
|
464
|
+
const key = agent + "/" + id;
|
|
465
|
+
if (processed.has(key)) continue;
|
|
466
|
+
if (fs.existsSync(deliveredMarker(agent, id))) {
|
|
467
|
+
processed.add(key);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (coveredByCursor(agent, id)) {
|
|
471
|
+
processed.add(key);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const msg = readJsonSafe(path.join(dmDir, agent, f));
|
|
475
|
+
if (!msg || !msg.id || !msg.from || !msg.body) {
|
|
476
|
+
processed.add(key);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
await deliver(agent, agentToSession.get(agent) || sessionID, {
|
|
480
|
+
id: msg.id,
|
|
481
|
+
from: msg.from,
|
|
482
|
+
body: String(msg.body),
|
|
483
|
+
at: msg.at || "",
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const timer = setInterval(() => {
|
|
490
|
+
poll().catch(() => {});
|
|
491
|
+
}, POLL_MS);
|
|
492
|
+
poll().catch(() => {});
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
dispose: async () => {
|
|
496
|
+
clearInterval(timer);
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
export default DmWatchPlugin;
|
|
502
|
+
`;
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// AGENTS.md snippet (minimal)
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
const AGENTS_MD_SNIPPET = `<!-- agentboard:start -->
|
|
509
|
+
## Agent board (DM-only)
|
|
510
|
+
|
|
511
|
+
You coordinate with other AI agents by messaging them directly — like Slack, minimal structure, figure it out yourselves.
|
|
512
|
+
|
|
513
|
+
Binary: \`{CLI}\` (board lives in \`./.agentboard\`, or \`$env:AGENTBOARD_DIR\`)
|
|
514
|
+
|
|
515
|
+
{CLI} register --from <you> [--session <opencode-session-id>]
|
|
516
|
+
{CLI} agents
|
|
517
|
+
{CLI} send --from <you> --to <peer> --body "..." # just a tool call, whenever you want
|
|
518
|
+
{CLI} inbox --from <you> [--limit 20] [--after <msg-id>] [--json]
|
|
519
|
+
{CLI} listen --from <you> [--timeout 60000] # block and print new DMs as they arrive
|
|
520
|
+
|
|
521
|
+
On opencode the \`dm-send\` tool does the same as \`send\` (and registers your session for push).
|
|
522
|
+
Incoming DMs are inserted into your context automatically by the watcher plugin — otherwise poll \`inbox\` often.
|
|
523
|
+
|
|
524
|
+
Rules: pick a stable \`--from\` name and keep it. Discover peers via \`agents\`. Send DMs anytime. No tasks, no claims, no holds — emergent coordination.
|
|
525
|
+
<!-- agentboard:end -->
|
|
526
|
+
`;
|
|
527
|
+
|
|
528
|
+
function upsertAgentsMd(cwd, snippet) {
|
|
529
|
+
const agentsMd = path.join(cwd, "AGENTS.md");
|
|
530
|
+
if (!fs.existsSync(agentsMd)) {
|
|
531
|
+
fs.writeFileSync(agentsMd, `# AGENTS.md\n\n` + snippet);
|
|
532
|
+
console.log("Created AGENTS.md with agent-board DM instructions");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
let cur = fs.readFileSync(agentsMd, "utf8");
|
|
536
|
+
const start = "<!-- agentboard:start -->";
|
|
537
|
+
const end = "<!-- agentboard:end -->";
|
|
538
|
+
if (cur.includes(start) && cur.includes(end)) {
|
|
539
|
+
const re = new RegExp("<!-- agentboard:start -->[\\s\\S]*?<!-- agentboard:end -->", "m");
|
|
540
|
+
cur = cur.replace(re, snippet.trim());
|
|
541
|
+
fs.writeFileSync(agentsMd, cur.endsWith("\n") ? cur : cur + "\n");
|
|
542
|
+
console.log("Updated agent-board section in AGENTS.md");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// Remove legacy v1 block if present (it starts with "## Agent board" and mentions the old workflow)
|
|
546
|
+
if (cur.includes("COORDINATOR PRIME DIRECTIVE") || cur.includes("tasks ready")) {
|
|
547
|
+
const lines = cur.split("\n");
|
|
548
|
+
const out = [];
|
|
549
|
+
let skipping = false;
|
|
550
|
+
for (const ln of lines) {
|
|
551
|
+
if (/^## Agent board/.test(ln)) { skipping = true; continue; }
|
|
552
|
+
if (skipping && /^## /.test(ln)) { skipping = false; }
|
|
553
|
+
if (!skipping) out.push(ln);
|
|
554
|
+
}
|
|
555
|
+
cur = out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
556
|
+
fs.writeFileSync(agentsMd, cur);
|
|
557
|
+
console.log("Removed legacy agent-board v1 section from AGENTS.md");
|
|
558
|
+
cur = fs.readFileSync(agentsMd, "utf8");
|
|
559
|
+
}
|
|
560
|
+
if (!cur.includes("agentboard") && !cur.includes("agent-board DM")) {
|
|
561
|
+
fs.appendFileSync(agentsMd, "\n" + snippet);
|
|
562
|
+
console.log("Appended agent-board DM section to AGENTS.md");
|
|
563
|
+
} else if (!cur.includes(start)) {
|
|
564
|
+
fs.appendFileSync(agentsMd, "\n" + snippet);
|
|
565
|
+
console.log("Appended agent-board DM section to AGENTS.md");
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function installOpencodeFiles(cwd, force) {
|
|
570
|
+
const toolsDir = path.join(cwd, ".opencode", "tools");
|
|
571
|
+
const pluginsDir = path.join(cwd, ".opencode", "plugins");
|
|
572
|
+
fs.mkdirSync(toolsDir, { recursive: true });
|
|
573
|
+
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
574
|
+
const toolPath = path.join(toolsDir, "dm-send.js");
|
|
575
|
+
const pluginPath = path.join(pluginsDir, "dm-watch.js");
|
|
576
|
+
// Prefer repo-shipped templates when running from a checkout (keeps CLI embed in sync).
|
|
577
|
+
let toolSrc = OPENCODE_TOOL_DM_SEND;
|
|
578
|
+
let pluginSrc = OPENCODE_PLUGIN_DM_WATCH;
|
|
579
|
+
try {
|
|
580
|
+
const here = path.dirname(path.resolve(process.argv[1] || ""));
|
|
581
|
+
const repoTool = path.join(here, "..", "opencode", "tools", "dm-send.js");
|
|
582
|
+
const repoPlugin = path.join(here, "..", "opencode", "plugins", "dm-watch.js");
|
|
583
|
+
if (fs.existsSync(repoTool)) toolSrc = fs.readFileSync(repoTool, "utf8");
|
|
584
|
+
if (fs.existsSync(repoPlugin)) pluginSrc = fs.readFileSync(repoPlugin, "utf8");
|
|
585
|
+
} catch {}
|
|
586
|
+
let wrote = 0;
|
|
587
|
+
if (!fs.existsSync(toolPath) || force) {
|
|
588
|
+
fs.writeFileSync(toolPath, toolSrc);
|
|
589
|
+
console.log(`Installed opencode tool: ${toolPath}`);
|
|
590
|
+
wrote++;
|
|
591
|
+
} else {
|
|
592
|
+
console.log(`opencode tool exists, skipping (use --force to overwrite): ${toolPath}`);
|
|
593
|
+
}
|
|
594
|
+
if (!fs.existsSync(pluginPath) || force) {
|
|
595
|
+
fs.writeFileSync(pluginPath, pluginSrc);
|
|
596
|
+
console.log(`Installed opencode plugin: ${pluginPath}`);
|
|
597
|
+
wrote++;
|
|
598
|
+
} else {
|
|
599
|
+
console.log(`opencode plugin exists, skipping (use --force to overwrite): ${pluginPath}`);
|
|
600
|
+
}
|
|
601
|
+
return wrote;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// harness adapters (init --harness)
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// The dm/ file protocol is identical on every harness. What differs is the
|
|
608
|
+
// wiring init installs: hooks files, MCP config, and AGENTS.md wording.
|
|
609
|
+
// Explicit --harness wins; otherwise init applies the union of detected
|
|
610
|
+
// marker dirs (.opencode/.claude/.codex/.agents/.grok). No markers at all
|
|
611
|
+
// keeps the legacy default (opencode) so existing checkouts don't change.
|
|
612
|
+
|
|
613
|
+
const HARNESSES = ["opencode", "claude", "codex", "antigravity", "grok", "generic"];
|
|
614
|
+
const HARNESS_MARKERS = {
|
|
615
|
+
opencode: ".opencode",
|
|
616
|
+
claude: ".claude",
|
|
617
|
+
codex: ".codex",
|
|
618
|
+
antigravity: ".agents",
|
|
619
|
+
grok: ".grok",
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
function parseHarnessFlag(args) {
|
|
623
|
+
const out = [];
|
|
624
|
+
for (let i = 0; i < args.length; i++) {
|
|
625
|
+
if (args[i] === "--harness" && args[i + 1] && !String(args[i + 1]).startsWith("--")) {
|
|
626
|
+
for (const part of String(args[i + 1]).split(",")) {
|
|
627
|
+
const h = part.trim().toLowerCase();
|
|
628
|
+
if (!h) continue;
|
|
629
|
+
if (!HARNESSES.includes(h)) fail(`unknown --harness "${part}" (want one of ${HARNESSES.join("|")})`);
|
|
630
|
+
if (!out.includes(h)) out.push(h);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function detectHarnesses(cwd) {
|
|
638
|
+
const found = [];
|
|
639
|
+
for (const [h, marker] of Object.entries(HARNESS_MARKERS)) {
|
|
640
|
+
try {
|
|
641
|
+
if (fs.statSync(path.join(cwd, marker)).isDirectory() && !found.includes(h)) found.push(h);
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
644
|
+
return found;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function binAbs(name) {
|
|
648
|
+
let here = "";
|
|
649
|
+
try {
|
|
650
|
+
here = path.dirname(fs.realpathSync(path.resolve(process.argv[1] || "")));
|
|
651
|
+
} catch {
|
|
652
|
+
here = path.dirname(path.resolve(process.argv[1] || ""));
|
|
653
|
+
}
|
|
654
|
+
return path.join(here, name).split(path.sep).join("/");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function readJsonFile(p, fallback) {
|
|
658
|
+
try {
|
|
659
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
660
|
+
} catch {
|
|
661
|
+
return fallback;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function hookCommand(hookAbs, sub, extra) {
|
|
666
|
+
return `node "${hookAbs}" ${sub}${extra ? ` ${extra}` : ""}`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Merge Claude/Codex style {hooks:{Event:[groups]}}: append our group per
|
|
670
|
+
// event unless a group already references agentboard-hook. Returns changed?
|
|
671
|
+
function mergeHookGroups(file, hookAbs, boardExtra, styles) {
|
|
672
|
+
const obj = readJsonFile(file, {});
|
|
673
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
674
|
+
fail(`cannot merge hooks: ${file} is not a JSON object (edit it by hand)`);
|
|
675
|
+
}
|
|
676
|
+
obj.hooks = obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
|
|
677
|
+
let changed = false;
|
|
678
|
+
for (const [event, style] of Object.entries(styles)) {
|
|
679
|
+
const groups = Array.isArray(obj.hooks[event]) ? obj.hooks[event] : [];
|
|
680
|
+
const hasOurs = groups.some((g) =>
|
|
681
|
+
(g && g.hooks && g.hooks.some((h) => String((h && h.command) || "").includes("agentboard-hook")))
|
|
682
|
+
);
|
|
683
|
+
if (!hasOurs) {
|
|
684
|
+
const sub = event === "SessionStart" ? "session-start" : `poll --style ${style}`;
|
|
685
|
+
groups.push({ hooks: [{ type: "command", command: hookCommand(hookAbs, sub, boardExtra) }] });
|
|
686
|
+
changed = true;
|
|
687
|
+
}
|
|
688
|
+
obj.hooks[event] = groups;
|
|
689
|
+
}
|
|
690
|
+
if (changed) {
|
|
691
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
692
|
+
writeJson(file, obj);
|
|
693
|
+
}
|
|
694
|
+
return changed;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Merge Antigravity style {name: {Event: [...]}} under our own key. Returns changed?
|
|
698
|
+
function mergeAntigravityHooks(file, hookAbs, boardExtra) {
|
|
699
|
+
const obj = readJsonFile(file, {});
|
|
700
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
701
|
+
fail(`cannot merge hooks: ${file} is not a JSON object (edit it by hand)`);
|
|
702
|
+
}
|
|
703
|
+
const stop = hookCommand(hookAbs, "poll --style antigravity-stop", boardExtra);
|
|
704
|
+
const pre = hookCommand(hookAbs, "poll --style antigravity-pre --idle-after 30", boardExtra);
|
|
705
|
+
const want = {
|
|
706
|
+
Stop: [{ hooks: [{ type: "command", command: stop, timeout: 30 }] }],
|
|
707
|
+
PreInvocation: [{ type: "command", command: pre, timeout: 30 }],
|
|
708
|
+
};
|
|
709
|
+
if (JSON.stringify(obj["agentboard-dm"]) !== JSON.stringify(want)) {
|
|
710
|
+
obj["agentboard-dm"] = want;
|
|
711
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
712
|
+
writeJson(file, obj);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Merge {mcpServers:{agentboard: entry}} (Claude .mcp.json, Antigravity mcp_config.json). Returns changed?
|
|
719
|
+
function mergeMcpServers(file, entry) {
|
|
720
|
+
const obj = readJsonFile(file, {});
|
|
721
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
722
|
+
fail(`cannot merge MCP config: ${file} is not a JSON object (edit it by hand)`);
|
|
723
|
+
}
|
|
724
|
+
obj.mcpServers = obj.mcpServers && typeof obj.mcpServers === "object" ? obj.mcpServers : {};
|
|
725
|
+
if (JSON.stringify(obj.mcpServers.agentboard) !== JSON.stringify(entry)) {
|
|
726
|
+
obj.mcpServers.agentboard = entry;
|
|
727
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
728
|
+
writeJson(file, obj);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const HARNESS_SECTIONS = {
|
|
735
|
+
opencode: (cli) =>
|
|
736
|
+
`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.`,
|
|
738
|
+
claude: (cli) =>
|
|
739
|
+
`On Claude Code use the \`agentboard\` MCP tools (\`dm_send\` / \`dm_inbox\` / \`dm_agents\` / \`dm_register\`) — approve \`.mcp.json\` when prompted.\n` +
|
|
740
|
+
`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.`,
|
|
741
|
+
codex: (cli) =>
|
|
742
|
+
`On Codex run \`codex mcp add agentboard -- node "<abs path to>/bin/agentboard-mcp.js"\` for the \`dm_send\`/\`dm_inbox\` tools,\n` +
|
|
743
|
+
`then open \`/hooks\` and trust the project hooks. A Stop hook (\`.codex/hooks.json\`) injects waiting DMs at turn end. Set \`AGENTBOARD_AGENT=<you>\` once per terminal.`,
|
|
744
|
+
antigravity: (cli) =>
|
|
745
|
+
`On Antigravity the \`agentboard\` MCP server (\`.agents/mcp_config.json\`) gives you DM tools; Stop/PreInvocation hooks (\`.agents/hooks.json\`) inject waiting DMs.\n` +
|
|
746
|
+
`Set \`AGENTBOARD_AGENT=<you>\` once per terminal so hooks know who you are.`,
|
|
747
|
+
grok: (cli) =>
|
|
748
|
+
`On grok-build run \`grok mcp add --scope project agentboard -- node "<abs path to>/bin/agentboard-mcp.js"\` for the DM tools,\n` +
|
|
749
|
+
`then grant folder trust (\`/hooks-trust\`) so the project hooks in \`.grok/hooks/\` run. A Stop hook injects waiting DMs at turn end (Claude-compatible envelope).\n` +
|
|
750
|
+
`\`AGENTS.md\` is auto-loaded (needs the same folder trust). Set \`AGENTBOARD_AGENT=<you>\` once per terminal.`,
|
|
751
|
+
generic: (cli) =>
|
|
752
|
+
`On any other harness: send with \`${cli} send\`, read with \`inbox\`, or block with \`listen\`. Poll \`inbox\` at session start and after each task.`,
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
function upsertHarnessSections(cwd, cli, ids) {
|
|
756
|
+
const agentsMd = path.join(cwd, "AGENTS.md");
|
|
757
|
+
if (!fs.existsSync(agentsMd)) return;
|
|
758
|
+
let cur = fs.readFileSync(agentsMd, "utf8");
|
|
759
|
+
cur = cur.replace(/<!-- agentboard:harness:.*?-->[\s\S]*?<!-- agentboard:harness:.*?end -->\n?/g, "");
|
|
760
|
+
const blocks = ids
|
|
761
|
+
.filter((h) => HARNESS_SECTIONS[h])
|
|
762
|
+
.map((h) => `<!-- agentboard:harness:${h} -->\n${HARNESS_SECTIONS[h](cli)}\n<!-- agentboard:harness:${h}:end -->`);
|
|
763
|
+
if (blocks.length > 0) {
|
|
764
|
+
cur = cur.endsWith("\n") ? cur : cur + "\n";
|
|
765
|
+
cur += "\n" + blocks.join("\n\n") + "\n";
|
|
766
|
+
}
|
|
767
|
+
fs.writeFileSync(agentsMd, cur);
|
|
768
|
+
console.log(`AGENTS.md harness notes: ${ids.join(", ") || "none"}`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function recordHarnesses(root, ids) {
|
|
772
|
+
const p = path.join(root, "board.json");
|
|
773
|
+
const meta = readJsonFile(p, {});
|
|
774
|
+
meta.harnesses = ids;
|
|
775
|
+
try {
|
|
776
|
+
writeJson(p, meta);
|
|
777
|
+
} catch {}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function mcpEntry(ctx, mcpAbs) {
|
|
781
|
+
// --portable writes PATH-based entries (needs npm i -g . first); default
|
|
782
|
+
// writes absolute paths that work from a checkout with zero setup.
|
|
783
|
+
if (ctx.portable) {
|
|
784
|
+
const entry = { command: "agentboard-mcp", args: [] };
|
|
785
|
+
if (ctx.mcpEnv) entry.env = ctx.mcpEnv;
|
|
786
|
+
return entry;
|
|
787
|
+
}
|
|
788
|
+
const entry = { command: "node", args: [mcpAbs] };
|
|
789
|
+
if (ctx.mcpEnv) entry.env = ctx.mcpEnv;
|
|
790
|
+
return entry;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function mcpRunCmd(ctx, mcpAbs, tool) {
|
|
794
|
+
// one-liner the user runs for harnesses whose MCP lives outside the repo
|
|
795
|
+
// (codex/grok keep MCP in user config, so init prints instead of writing)
|
|
796
|
+
const run = ctx.portable ? "agentboard-mcp" : `node "${mcpAbs}"`;
|
|
797
|
+
if (tool === "grok") return `grok mcp add --scope project agentboard -- ${run}`;
|
|
798
|
+
return `${tool} mcp add agentboard -- ${run}`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function applyHarness(cwd, h, ctx) {
|
|
802
|
+
const { force, hookAbs, mcpAbs, boardExtra, mcpEnv } = ctx;
|
|
803
|
+
switch (h) {
|
|
804
|
+
case "opencode":
|
|
805
|
+
installOpencodeFiles(cwd, force);
|
|
806
|
+
return [];
|
|
807
|
+
case "claude": {
|
|
808
|
+
const changedHooks = mergeHookGroups(path.join(cwd, ".claude", "settings.json"), hookAbs, boardExtra, {
|
|
809
|
+
SessionStart: "session-start",
|
|
810
|
+
Stop: "claude",
|
|
811
|
+
});
|
|
812
|
+
console.log(changedHooks ? "Wired Claude hooks: .claude/settings.json (SessionStart + Stop)" : "Claude hooks already wired: .claude/settings.json");
|
|
813
|
+
const entry = mcpEntry(ctx, mcpAbs);
|
|
814
|
+
const changedMcp = mergeMcpServers(path.join(cwd, ".mcp.json"), entry);
|
|
815
|
+
console.log(changedMcp ? "Wired Claude MCP: .mcp.json (agentboard stdio)" : "Claude MCP already wired: .mcp.json");
|
|
816
|
+
return ["approve .mcp.json when Claude prompts (project MCP servers need approval)", "set AGENTBOARD_AGENT=<you> once per terminal for the hooks"];
|
|
817
|
+
}
|
|
818
|
+
case "codex": {
|
|
819
|
+
const changedHooks = mergeHookGroups(path.join(cwd, ".codex", "hooks.json"), hookAbs, boardExtra, {
|
|
820
|
+
SessionStart: "session-start",
|
|
821
|
+
Stop: "codex",
|
|
822
|
+
});
|
|
823
|
+
console.log(changedHooks ? "Wired Codex hooks: .codex/hooks.json (SessionStart + Stop)" : "Codex hooks already wired: .codex/hooks.json");
|
|
824
|
+
return [
|
|
825
|
+
`run: ${mcpRunCmd(ctx, mcpAbs, "codex")} (for dm_send/dm_inbox tools)`,
|
|
826
|
+
"open /hooks and trust the project hooks before they run",
|
|
827
|
+
"set AGENTBOARD_AGENT=<you> once per terminal for the hooks",
|
|
828
|
+
];
|
|
829
|
+
}
|
|
830
|
+
case "antigravity": {
|
|
831
|
+
const changedHooks = mergeAntigravityHooks(path.join(cwd, ".agents", "hooks.json"), hookAbs, boardExtra);
|
|
832
|
+
console.log(changedHooks ? "Wired Antigravity hooks: .agents/hooks.json (Stop + PreInvocation)" : "Antigravity hooks already wired: .agents/hooks.json");
|
|
833
|
+
const entry = mcpEntry(ctx, mcpAbs);
|
|
834
|
+
const changedMcp = mergeMcpServers(path.join(cwd, ".agents", "mcp_config.json"), entry);
|
|
835
|
+
console.log(changedMcp ? "Wired Antigravity MCP: .agents/mcp_config.json (agentboard stdio)" : "Antigravity MCP already wired: .agents/mcp_config.json");
|
|
836
|
+
return ["set AGENTBOARD_AGENT=<you> once per terminal for the hooks"];
|
|
837
|
+
}
|
|
838
|
+
case "grok": {
|
|
839
|
+
const changedHooks = mergeHookGroups(path.join(cwd, ".grok", "hooks", "agentboard.json"), hookAbs, boardExtra, {
|
|
840
|
+
SessionStart: "session-start",
|
|
841
|
+
Stop: "grok",
|
|
842
|
+
});
|
|
843
|
+
console.log(changedHooks ? "Wired grok hooks: .grok/hooks/agentboard.json (SessionStart + Stop)" : "grok hooks already wired: .grok/hooks/agentboard.json");
|
|
844
|
+
return [
|
|
845
|
+
`run: ${mcpRunCmd(ctx, mcpAbs, "grok")} (for DM tools)`,
|
|
846
|
+
"grant folder trust (/hooks-trust or --trust) so project hooks + AGENTS.md load",
|
|
847
|
+
"set AGENTBOARD_AGENT=<you> once per terminal for the hooks",
|
|
848
|
+
];
|
|
849
|
+
}
|
|
850
|
+
default:
|
|
851
|
+
return [];
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function cmdInit(args) {
|
|
856
|
+
const root = boardDir(args);
|
|
857
|
+
ensureBoard(root);
|
|
858
|
+
const force = args.includes("--force");
|
|
859
|
+
const noOpencode = args.includes("--no-opencode");
|
|
860
|
+
if (root === path.join(process.cwd(), ".agentboard")) {
|
|
861
|
+
if (fs.existsSync(path.join(process.cwd(), ".git"))) {
|
|
862
|
+
const gitIgnore = path.join(process.cwd(), ".gitignore");
|
|
863
|
+
let gi = "";
|
|
864
|
+
try {
|
|
865
|
+
gi = fs.readFileSync(gitIgnore, "utf8");
|
|
866
|
+
} catch {}
|
|
867
|
+
if (!/^\.agentboard\/?\s*$/m.test(gi)) {
|
|
868
|
+
const addition = (gi && !gi.endsWith("\n") ? "\n" : "") + "# agent-board state\n.agentboard/\n";
|
|
869
|
+
fs.appendFileSync(gitIgnore, addition);
|
|
870
|
+
console.log("Added .agentboard/ to .gitignore");
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const snippet = AGENTS_MD_SNIPPET.replaceAll("{CLI}", cliInvoke());
|
|
874
|
+
upsertAgentsMd(process.cwd(), snippet);
|
|
875
|
+
// harness wiring: explicit --harness wins, else union of detected markers,
|
|
876
|
+
// else legacy default (opencode) so old checkouts keep working.
|
|
877
|
+
let ids = parseHarnessFlag(args);
|
|
878
|
+
if (ids.length === 0) {
|
|
879
|
+
ids = detectHarnesses(process.cwd());
|
|
880
|
+
if (ids.length === 0) {
|
|
881
|
+
ids = ["opencode"];
|
|
882
|
+
console.log("No harness markers detected (.opencode/.claude/.codex/.agents/.grok) — defaulting to opencode (use --harness to choose)");
|
|
883
|
+
} else {
|
|
884
|
+
console.log(`Detected harness markers: ${ids.join(", ")} (use --harness to override)`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (noOpencode) ids = ids.filter((h) => h !== "opencode");
|
|
888
|
+
if (ids.includes("generic") && ids.length > 1) ids = ids.filter((h) => h !== "generic");
|
|
889
|
+
const nonLocal = root !== path.join(process.cwd(), ".agentboard");
|
|
890
|
+
const boardExtra = nonLocal ? `--board "${root.split(path.sep).join("/")}"` : "";
|
|
891
|
+
const mcpEnv = nonLocal ? { AGENTBOARD_DIR: root } : null;
|
|
892
|
+
const portable = args.includes("--portable");
|
|
893
|
+
if (portable) console.log("Portable mode: MCP entries use the agentboard-mcp binary (needs npm i -g . first)");
|
|
894
|
+
const ctx = { force, hookAbs: binAbs("agentboard-hook.js"), mcpAbs: binAbs("agentboard-mcp.js"), boardExtra, mcpEnv, portable };
|
|
895
|
+
const followUps = [];
|
|
896
|
+
for (const h of ids) {
|
|
897
|
+
if (h === "generic") continue;
|
|
898
|
+
for (const f of applyHarness(process.cwd(), h, ctx)) {
|
|
899
|
+
if (!followUps.includes(f)) followUps.push(f);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (!nonLocal) upsertHarnessSections(process.cwd(), cliInvoke(), ids);
|
|
903
|
+
recordHarnesses(root, ids);
|
|
904
|
+
for (const f of followUps) console.log(`Follow-up: ${f}`);
|
|
905
|
+
} else {
|
|
906
|
+
console.log(`(non-local board: skipping AGENTS.md/harness install for ${root})`);
|
|
907
|
+
}
|
|
908
|
+
console.log(`Board ready at ${root}`);
|
|
909
|
+
console.log(`Point other agents here with: set AGENTBOARD_DIR=${root}`);
|
|
910
|
+
console.log(`Tip: set AGENTBOARD_AGENT=<your-name> to skip --from on every command`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function touchAgent(d, name, extra) {
|
|
914
|
+
const p = path.join(d.agents, `${name}.json`);
|
|
915
|
+
const now = new Date().toISOString();
|
|
916
|
+
let prev = null;
|
|
917
|
+
try {
|
|
918
|
+
prev = readJson(p);
|
|
919
|
+
} catch {}
|
|
920
|
+
const doc = {
|
|
921
|
+
name,
|
|
922
|
+
firstSeen: (prev && prev.firstSeen) || now,
|
|
923
|
+
lastSeen: now,
|
|
924
|
+
sessionId: (extra && extra.sessionId) || (prev && prev.sessionId) || undefined,
|
|
925
|
+
lastDir: (extra && extra.lastDir) || (prev && prev.lastDir) || undefined,
|
|
926
|
+
};
|
|
927
|
+
writeJson(p, doc);
|
|
928
|
+
return doc;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function cmdRegister(args) {
|
|
932
|
+
const d = ensureBoard(boardDir(args));
|
|
933
|
+
const agent = resolveAgent(args, "agent");
|
|
934
|
+
const session = getFlag(args, "--session");
|
|
935
|
+
const doc = touchAgent(d, agent, { sessionId: session || undefined, lastDir: process.cwd() });
|
|
936
|
+
console.log(`registered ${agent}${doc.sessionId ? ` (session ${doc.sessionId})` : ""}`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function cmdAgents(args) {
|
|
940
|
+
const d = ensureBoard(boardDir(args));
|
|
941
|
+
const items = listJson(d.agents)
|
|
942
|
+
.map((e) => e.data)
|
|
943
|
+
.filter((x) => x && x.name)
|
|
944
|
+
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
945
|
+
if (args.includes("--json")) {
|
|
946
|
+
console.log(JSON.stringify(items, null, 2));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (items.length === 0) {
|
|
950
|
+
console.log("no agents registered (register --from <you>)");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
for (const a of items) {
|
|
954
|
+
console.log(`${a.name} (last seen ${relTime(a.lastSeen)}${a.sessionId ? `, session ${a.sessionId}` : ""})`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function cmdSend(args) {
|
|
959
|
+
const d = ensureBoard(boardDir(args));
|
|
960
|
+
const from = resolveAgent(args, "sender");
|
|
961
|
+
const toRaw = getFlag(args, "--to");
|
|
962
|
+
const to = sanitizeName(toRaw, "recipient");
|
|
963
|
+
const body = getFlag(args, "--body") || restArgs(args).join(" ");
|
|
964
|
+
if (!body || !body.trim()) fail('missing message body (--body "...")');
|
|
965
|
+
if (body.length > MAX_BODY_CHARS) fail(`message body too large (max ${MAX_BODY_CHARS} chars)`);
|
|
966
|
+
const session = getFlag(args, "--session");
|
|
967
|
+
touchAgent(d, from, { sessionId: session || undefined, lastDir: process.cwd() });
|
|
968
|
+
const id = newId("msg");
|
|
969
|
+
const msg = { id, from, to, body: body.trim(), at: new Date().toISOString() };
|
|
970
|
+
const dir = path.join(d.dm, to);
|
|
971
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
972
|
+
writeJson(path.join(dir, `${id}.json`), msg);
|
|
973
|
+
console.log(`sent ${id} -> ${to}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function readDMs(d, recipient) {
|
|
977
|
+
return listJson(path.join(d.dm, recipient))
|
|
978
|
+
.map((e) => e.data)
|
|
979
|
+
.filter((x) => x && x.id && x.from)
|
|
980
|
+
.sort((a, b) => (String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id))));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function printMsg(m, showTo, json) {
|
|
984
|
+
if (json) {
|
|
985
|
+
console.log(JSON.stringify(m));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
console.log(`${m.id} (from ${m.from}${showTo ? ` -> ${m.to}` : ""}, ${relTime(m.at)})`);
|
|
989
|
+
console.log(` ${m.body}`);
|
|
990
|
+
console.log("");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function cmdInbox(args) {
|
|
994
|
+
const d = ensureBoard(boardDir(args));
|
|
995
|
+
const showAll = args.includes("--all");
|
|
996
|
+
const limit = Number(getFlag(args, "--limit") || 20);
|
|
997
|
+
const after = getFlag(args, "--after");
|
|
998
|
+
const json = args.includes("--json");
|
|
999
|
+
let items;
|
|
1000
|
+
let showTo = false;
|
|
1001
|
+
if (showAll) {
|
|
1002
|
+
items = [];
|
|
1003
|
+
let subs = [];
|
|
1004
|
+
try {
|
|
1005
|
+
subs = fs.readdirSync(d.dm);
|
|
1006
|
+
} catch {
|
|
1007
|
+
subs = [];
|
|
1008
|
+
}
|
|
1009
|
+
for (const sub of subs) {
|
|
1010
|
+
let st = null;
|
|
1011
|
+
try {
|
|
1012
|
+
st = fs.statSync(path.join(d.dm, sub));
|
|
1013
|
+
} catch {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (!st.isDirectory()) continue;
|
|
1017
|
+
for (const m of readDMs(d, sub)) items.push(m);
|
|
1018
|
+
}
|
|
1019
|
+
items.sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id)));
|
|
1020
|
+
showTo = true;
|
|
1021
|
+
} else {
|
|
1022
|
+
const agent = resolveAgent(args, "reader");
|
|
1023
|
+
items = readDMs(d, agent);
|
|
1024
|
+
}
|
|
1025
|
+
if (after) {
|
|
1026
|
+
const idx = items.findIndex((m) => m.id === after);
|
|
1027
|
+
if (idx !== -1) items = items.slice(idx + 1);
|
|
1028
|
+
}
|
|
1029
|
+
items = items.slice(-Math.max(limit, 0));
|
|
1030
|
+
if (json) {
|
|
1031
|
+
console.log(JSON.stringify(items, null, 2));
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (items.length === 0) {
|
|
1035
|
+
console.log(showAll ? "no messages" : `no messages for ${resolveAgent(args, "reader")}`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
for (const m of items) printMsg(m, showTo, false);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function cmdListen(args) {
|
|
1042
|
+
const d = ensureBoard(boardDir(args));
|
|
1043
|
+
const agent = resolveAgent(args, "listener");
|
|
1044
|
+
const timeoutMs = Number(getFlag(args, "--timeout") || 0);
|
|
1045
|
+
const json = args.includes("--json");
|
|
1046
|
+
if (!(timeoutMs >= 0)) fail("--timeout must be a non-negative number of ms");
|
|
1047
|
+
const dir = path.join(d.dm, agent);
|
|
1048
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1049
|
+
const seen = new Set(readDMs(d, agent).map((m) => m.id));
|
|
1050
|
+
// print backlog first so a fresh listener doesn't miss history
|
|
1051
|
+
for (const m of readDMs(d, agent)) printMsg(m, false, json);
|
|
1052
|
+
let done = false;
|
|
1053
|
+
let timer = null;
|
|
1054
|
+
const finish = () => {
|
|
1055
|
+
if (!done) {
|
|
1056
|
+
done = true;
|
|
1057
|
+
if (timer) clearTimeout(timer);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
process.on("SIGINT", () => {
|
|
1061
|
+
finish();
|
|
1062
|
+
process.exit(0);
|
|
1063
|
+
});
|
|
1064
|
+
const scan = () => {
|
|
1065
|
+
for (const m of readDMs(d, agent)) {
|
|
1066
|
+
if (!seen.has(m.id)) {
|
|
1067
|
+
seen.add(m.id);
|
|
1068
|
+
printMsg(m, false, json);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
let watcher = null;
|
|
1073
|
+
try {
|
|
1074
|
+
watcher = fs.watch(dir, () => scan());
|
|
1075
|
+
} catch {}
|
|
1076
|
+
const poll = setInterval(() => {
|
|
1077
|
+
if (done) {
|
|
1078
|
+
clearInterval(poll);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
scan();
|
|
1082
|
+
}, 500);
|
|
1083
|
+
if (timeoutMs > 0) {
|
|
1084
|
+
await new Promise((res) => {
|
|
1085
|
+
timer = setTimeout(res, timeoutMs);
|
|
1086
|
+
});
|
|
1087
|
+
} else {
|
|
1088
|
+
await new Promise(() => {});
|
|
1089
|
+
}
|
|
1090
|
+
clearInterval(poll);
|
|
1091
|
+
try {
|
|
1092
|
+
if (watcher) watcher.close();
|
|
1093
|
+
} catch {}
|
|
1094
|
+
finish();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ---------------------------------------------------------------------------
|
|
1098
|
+
// doctor: validate board + harness wiring
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
|
|
1101
|
+
function cmdDoctor(args) {
|
|
1102
|
+
const root = boardDir(args);
|
|
1103
|
+
const cwd = process.cwd();
|
|
1104
|
+
const local = root === path.join(cwd, ".agentboard");
|
|
1105
|
+
let bad = 0;
|
|
1106
|
+
const ok = (label) => console.log(`ok ${label}`);
|
|
1107
|
+
const no = (label, hint) => {
|
|
1108
|
+
bad++;
|
|
1109
|
+
console.log(`FAIL ${label}${hint ? ` — ${hint}` : ""}`);
|
|
1110
|
+
};
|
|
1111
|
+
const info = (label) => console.log(`info ${label}`);
|
|
1112
|
+
|
|
1113
|
+
const nodeMajor = Number(String(process.version).replace(/^v/, "").split(".")[0]);
|
|
1114
|
+
if (nodeMajor >= 18) ok(`node ${process.version} (>= 18)`);
|
|
1115
|
+
else no(`node ${process.version} (>= 18 required)`);
|
|
1116
|
+
|
|
1117
|
+
const meta = readJsonFile(path.join(root, "board.json"), null);
|
|
1118
|
+
if (meta && meta.version === 2) ok(`board at ${root} (v2)`);
|
|
1119
|
+
else no(`board at ${root}`, "run: agentboard init");
|
|
1120
|
+
|
|
1121
|
+
let ids = parseHarnessFlag(args);
|
|
1122
|
+
if (ids.length === 0) {
|
|
1123
|
+
if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
|
|
1124
|
+
else {
|
|
1125
|
+
ids = detectHarnesses(cwd);
|
|
1126
|
+
if (ids.length === 0) ids = ["generic"];
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (!local) {
|
|
1130
|
+
info(`non-local board: skipping project-file checks for ${root}`);
|
|
1131
|
+
console.log(bad === 0 ? "doctor: healthy" : `doctor: ${bad} problem(s)`);
|
|
1132
|
+
if (bad > 0) process.exitCode = 1;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const md = (() => {
|
|
1137
|
+
try {
|
|
1138
|
+
return fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
|
|
1139
|
+
} catch {
|
|
1140
|
+
return "";
|
|
1141
|
+
}
|
|
1142
|
+
})();
|
|
1143
|
+
if (md.includes("agentboard:start")) ok("AGENTS.md core block");
|
|
1144
|
+
else no("AGENTS.md core block", "run: agentboard init");
|
|
1145
|
+
|
|
1146
|
+
const hasHookRef = (file, events) => {
|
|
1147
|
+
const obj = readJsonFile(file, null);
|
|
1148
|
+
if (!obj || typeof obj.hooks !== "object") return false;
|
|
1149
|
+
return events.every(
|
|
1150
|
+
(ev) =>
|
|
1151
|
+
Array.isArray(obj.hooks[ev]) &&
|
|
1152
|
+
obj.hooks[ev].some((g) => g && g.hooks && g.hooks.some((h) => String((h && h.command) || "").includes("agentboard-hook")))
|
|
1153
|
+
);
|
|
1154
|
+
};
|
|
1155
|
+
const hasMcpServer = (file) => {
|
|
1156
|
+
const obj = readJsonFile(file, null);
|
|
1157
|
+
return !!(obj && obj.mcpServers && obj.mcpServers.agentboard && obj.mcpServers.agentboard.command);
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
for (const h of ids) {
|
|
1161
|
+
switch (h) {
|
|
1162
|
+
case "opencode":
|
|
1163
|
+
if (fs.existsSync(path.join(cwd, ".opencode", "tools", "dm-send.js"))) ok("opencode tool .opencode/tools/dm-send.js");
|
|
1164
|
+
else no("opencode tool .opencode/tools/dm-send.js", "run: agentboard init --harness opencode (then restart opencode)");
|
|
1165
|
+
if (fs.existsSync(path.join(cwd, ".opencode", "plugins", "dm-watch.js"))) ok("opencode plugin .opencode/plugins/dm-watch.js");
|
|
1166
|
+
else no("opencode plugin .opencode/plugins/dm-watch.js", "run: agentboard init --harness opencode (then restart opencode)");
|
|
1167
|
+
break;
|
|
1168
|
+
case "claude":
|
|
1169
|
+
if (hasHookRef(path.join(cwd, ".claude", "settings.json"), ["SessionStart", "Stop"])) ok("claude hooks .claude/settings.json");
|
|
1170
|
+
else no("claude hooks .claude/settings.json", "run: agentboard init --harness claude");
|
|
1171
|
+
if (hasMcpServer(path.join(cwd, ".mcp.json"))) ok("claude MCP .mcp.json");
|
|
1172
|
+
else no("claude MCP .mcp.json", "run: agentboard init --harness claude (then approve it in Claude)");
|
|
1173
|
+
break;
|
|
1174
|
+
case "codex":
|
|
1175
|
+
if (hasHookRef(path.join(cwd, ".codex", "hooks.json"), ["SessionStart", "Stop"])) ok("codex hooks .codex/hooks.json");
|
|
1176
|
+
else no("codex hooks .codex/hooks.json", "run: agentboard init --harness codex (then trust them in /hooks)");
|
|
1177
|
+
info("codex MCP is a CLI step: codex mcp add agentboard -- node <board-checkout>/bin/agentboard-mcp.js");
|
|
1178
|
+
break;
|
|
1179
|
+
case "antigravity": {
|
|
1180
|
+
const obj = readJsonFile(path.join(cwd, ".agents", "hooks.json"), null);
|
|
1181
|
+
if (obj && obj["agentboard-dm"] && obj["agentboard-dm"].Stop) ok("antigravity hooks .agents/hooks.json");
|
|
1182
|
+
else no("antigravity hooks .agents/hooks.json", "run: agentboard init --harness antigravity");
|
|
1183
|
+
if (hasMcpServer(path.join(cwd, ".agents", "mcp_config.json"))) ok("antigravity MCP .agents/mcp_config.json");
|
|
1184
|
+
else no("antigravity MCP .agents/mcp_config.json", "run: agentboard init --harness antigravity");
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
case "grok":
|
|
1188
|
+
if (hasHookRef(path.join(cwd, ".grok", "hooks", "agentboard.json"), ["SessionStart", "Stop"])) ok("grok hooks .grok/hooks/agentboard.json");
|
|
1189
|
+
else no("grok hooks .grok/hooks/agentboard.json", "run: agentboard init --harness grok (then /hooks-trust)");
|
|
1190
|
+
info("grok MCP is a CLI step: grok mcp add --scope project agentboard -- node <board-checkout>/bin/agentboard-mcp.js");
|
|
1191
|
+
break;
|
|
1192
|
+
default:
|
|
1193
|
+
info(`generic harness: CLI pull only (inbox/listen), nothing to validate`);
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (!process.env.AGENTBOARD_AGENT) info("AGENTBOARD_AGENT is unset — hooks need it to know who you are");
|
|
1198
|
+
console.log(bad === 0 ? "doctor: healthy" : `doctor: ${bad} problem(s)`);
|
|
1199
|
+
if (bad > 0) process.exitCode = 1;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ---------------------------------------------------------------------------
|
|
1203
|
+
// dispatch
|
|
1204
|
+
// ---------------------------------------------------------------------------
|
|
1205
|
+
|
|
1206
|
+
const REMOVED = new Set([
|
|
1207
|
+
"task", "tasks", "show", "claim", "progress", "verify", "done", "abandon",
|
|
1208
|
+
"reap", "edit", "assign", "split", "hold", "release", "veto", "digest",
|
|
1209
|
+
"say", "messages", "sweep", "watch", "stats",
|
|
1210
|
+
]);
|
|
1211
|
+
|
|
1212
|
+
const USAGE = `agentboard — DM-only minimal bus for AI agent coordination (v2)
|
|
1213
|
+
|
|
1214
|
+
Setup:
|
|
1215
|
+
agentboard init [--global] [--board <path>] [--force] [--no-opencode] [--portable]
|
|
1216
|
+
[--harness opencode,claude,codex,antigravity,grok,generic]
|
|
1217
|
+
create board in ./.agentboard, write AGENTS.md block, install harness
|
|
1218
|
+
wiring (hooks + MCP config + notes). Without --harness, init applies the
|
|
1219
|
+
union of detected markers (.opencode/.claude/.codex/.agents/.grok).
|
|
1220
|
+
|
|
1221
|
+
Identity:
|
|
1222
|
+
agentboard register --from <you> [--session <opencode-session-id>]
|
|
1223
|
+
agentboard agents [--json]
|
|
1224
|
+
|
|
1225
|
+
Messaging (primitive — just a tool call, whenever you want):
|
|
1226
|
+
agentboard send --from <you> --to <peer> --body "..." [--session <id>]
|
|
1227
|
+
agentboard inbox --from <you> [--limit 20] [--after <msg-id>] [--all] [--json]
|
|
1228
|
+
agentboard listen --from <you> [--timeout <ms>] [--json]
|
|
1229
|
+
(prints backlog, then blocks and prints new DMs as they arrive;
|
|
1230
|
+
opencode plugin injects into context automatically instead of polling)
|
|
1231
|
+
|
|
1232
|
+
agentboard doctor [--harness <list>] [--board <path>]
|
|
1233
|
+
(validate board + harness wiring; exit 1 with FAIL lines when broken)
|
|
1234
|
+
|
|
1235
|
+
Tips:
|
|
1236
|
+
set AGENTBOARD_AGENT=<name> to skip --from on every command
|
|
1237
|
+
set AGENTBOARD_DIR=<path> (or --board <path>) to pick the board`;
|
|
1238
|
+
|
|
1239
|
+
async function main() {
|
|
1240
|
+
const [, , cmd, ...rest] = process.argv;
|
|
1241
|
+
if (REMOVED.has(cmd)) {
|
|
1242
|
+
fail(`"${cmd}" was removed in v2 DM-only — use "send --from A --to B --body ..." (+ inbox/listen). See --help.`);
|
|
1243
|
+
}
|
|
1244
|
+
switch (cmd) {
|
|
1245
|
+
case "init": return cmdInit(rest);
|
|
1246
|
+
case "register": return cmdRegister(rest);
|
|
1247
|
+
case "agents": return cmdAgents(rest);
|
|
1248
|
+
case "send": return cmdSend(rest);
|
|
1249
|
+
case "inbox": return cmdInbox(rest);
|
|
1250
|
+
case "listen": return await cmdListen(rest);
|
|
1251
|
+
case "doctor": return cmdDoctor(rest);
|
|
1252
|
+
case undefined:
|
|
1253
|
+
case "-h":
|
|
1254
|
+
case "--help":
|
|
1255
|
+
case "help":
|
|
1256
|
+
console.log(USAGE);
|
|
1257
|
+
return;
|
|
1258
|
+
default:
|
|
1259
|
+
fail(`unknown command "${cmd}"\n\n${USAGE}`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
main().catch((e) => {
|
|
1264
|
+
fail(e && e.stack ? e.stack : String(e));
|
|
1265
|
+
});
|