agent-coord-mcp 0.3.2 → 0.3.4
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/README.md +36 -0
- package/package.json +21 -2
- package/scripts/coord-chat.mjs +277 -0
package/README.md
CHANGED
|
@@ -178,6 +178,42 @@ Set `AGENT_COORD_ID` to whatever you passed to `register({agentId})`. Set `AGENT
|
|
|
178
178
|
|
|
179
179
|
Caveat: the hook writes the cursor file directly (atomic tmp+rename) without taking the MCP server's lockfile, so if the agent calls `read_messages` at the exact instant the hook runs, one of them may double-deliver a message. In practice hooks fire between turns and tool calls fire during them, so this is rare. The hook also banners injected messages with *"do not call read_messages for them again"* to keep the agent from re-fetching.
|
|
180
180
|
|
|
181
|
+
## Human seat: `coord-chat`
|
|
182
|
+
|
|
183
|
+
If you want to participate as a human (read what the agents are saying, DM one, post in the room), the package ships an IRC-style TUI exposed as the `coord-chat` bin entry.
|
|
184
|
+
|
|
185
|
+
**Install + run, three ways:**
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
# 1. One-shot, no install (downloads + caches transparently)
|
|
189
|
+
npx -y agent-coord-mcp coord-chat
|
|
190
|
+
npx -y agent-coord-mcp coord-chat --id david
|
|
191
|
+
|
|
192
|
+
# 2. Global install (faster startup, just type `coord-chat`)
|
|
193
|
+
npm i -g agent-coord-mcp
|
|
194
|
+
coord-chat # registers as $USER
|
|
195
|
+
coord-chat --id david # custom id
|
|
196
|
+
coord-chat --dir /custom/coord/dir # override state dir
|
|
197
|
+
|
|
198
|
+
# 3. From a checkout of this repo
|
|
199
|
+
node scripts/coord-chat.mjs --id david
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Defaults: `--id $USER`, `--dir $AGENT_COORD_DIR || ~/agent-coord`.
|
|
203
|
+
|
|
204
|
+
At the prompt:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
<text> → post to shared room
|
|
208
|
+
/dm <agent> <text> → DM a specific agent
|
|
209
|
+
/list → who's registered + transports
|
|
210
|
+
/quit → unregister and exit
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Incoming messages appear above the prompt as you receive them, without clobbering whatever you're typing. Cyan = DM, yellow = room. The chat session registers itself in the same `agents.json` as the rest of the bus, so peers see you in `list_agents` and can DM you back.
|
|
214
|
+
|
|
215
|
+
No tmux dependency — coord-chat is a plain readline UI. You can run it in any terminal alongside your other agents.
|
|
216
|
+
|
|
181
217
|
## Active push via tmux (any CLI agent)
|
|
182
218
|
|
|
183
219
|
Hooks are reactive — they only fire when the agent is already taking a turn. If you need peer messages to *wake* an idle agent (no human typing, agent already stopped), the working option is to run the agent inside a tmux pane and have a tiny daemon type incoming messages into that pane.
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.) on the same machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"agent-coord-mcp": "dist/server.js"
|
|
7
|
+
"agent-coord-mcp": "dist/server.js",
|
|
8
|
+
"coord-chat": "scripts/coord-chat.mjs"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"build": "tsc",
|
|
@@ -24,6 +25,24 @@
|
|
|
24
25
|
"type": "git",
|
|
25
26
|
"url": "https://github.com/davidbalzan/agent-coord-mcp.git"
|
|
26
27
|
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"mcp",
|
|
30
|
+
"model-context-protocol",
|
|
31
|
+
"agent",
|
|
32
|
+
"agents",
|
|
33
|
+
"multi-agent",
|
|
34
|
+
"coordination",
|
|
35
|
+
"ai",
|
|
36
|
+
"llm",
|
|
37
|
+
"claude",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"cursor",
|
|
40
|
+
"cline",
|
|
41
|
+
"anthropic",
|
|
42
|
+
"ipc",
|
|
43
|
+
"chat",
|
|
44
|
+
"tmux"
|
|
45
|
+
],
|
|
27
46
|
"license": "MIT",
|
|
28
47
|
"dependencies": {
|
|
29
48
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* coord-chat — minimal IRC-style TUI so a human can join the agent-coord bus.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/coord-chat.mjs [--id <name>] [--dir <path>]
|
|
7
|
+
* coord-chat [--id <name>] [--dir <path>] # if installed via npm bin
|
|
8
|
+
*
|
|
9
|
+
* Defaults: --id $USER, --dir $AGENT_COORD_DIR || ~/agent-coord
|
|
10
|
+
*
|
|
11
|
+
* Commands at the prompt:
|
|
12
|
+
* <text> → post to shared room
|
|
13
|
+
* /dm <id> <text> → DM a specific agent
|
|
14
|
+
* /list → show registered agents + transports
|
|
15
|
+
* /help → show commands
|
|
16
|
+
* /quit → unregister and exit
|
|
17
|
+
*
|
|
18
|
+
* Dependency-light: only proper-lockfile (already a package dep) for the
|
|
19
|
+
* read-modify-write on agents.json. JSONL appends are single small writes
|
|
20
|
+
* (POSIX atomic under PIPE_BUF), no lock needed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
appendFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
watch,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { promises as fsp } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import readline from "node:readline";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import lockfile from "proper-lockfile";
|
|
38
|
+
|
|
39
|
+
// ---------- args ----------
|
|
40
|
+
|
|
41
|
+
const args = parseArgs(process.argv.slice(2));
|
|
42
|
+
const ID = args.id ?? process.env.USER ?? "human";
|
|
43
|
+
const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
|
|
44
|
+
|
|
45
|
+
const INBOX_DIR = path.join(ROOT, "inbox");
|
|
46
|
+
const CURSOR_DIR = path.join(ROOT, "cursors");
|
|
47
|
+
const TRANSPORT_DIR = path.join(ROOT, "transports");
|
|
48
|
+
const AGENTS_FILE = path.join(ROOT, "agents.json");
|
|
49
|
+
const ROOM_FILE = path.join(ROOT, "room.jsonl");
|
|
50
|
+
const INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
51
|
+
const CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
52
|
+
|
|
53
|
+
mkdirSync(INBOX_DIR, { recursive: true });
|
|
54
|
+
mkdirSync(CURSOR_DIR, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// ---------- register and start UI ----------
|
|
57
|
+
|
|
58
|
+
await register();
|
|
59
|
+
|
|
60
|
+
const rl = readline.createInterface({
|
|
61
|
+
input: process.stdin,
|
|
62
|
+
output: process.stdout,
|
|
63
|
+
prompt: `${ID}> `,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
67
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
68
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
69
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
70
|
+
|
|
71
|
+
say(dim(`coord-chat — agentId=${ID} dir=${ROOT}`));
|
|
72
|
+
say(dim("commands: <text>=room /dm <id> <text> /list /help /quit"));
|
|
73
|
+
|
|
74
|
+
await drainAndPrint();
|
|
75
|
+
|
|
76
|
+
try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
|
|
77
|
+
try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
|
|
78
|
+
setInterval(() => void drainAndPrint(), 1000);
|
|
79
|
+
|
|
80
|
+
rl.prompt();
|
|
81
|
+
|
|
82
|
+
rl.on("line", async (line) => {
|
|
83
|
+
const text = line.trim();
|
|
84
|
+
if (!text) return rl.prompt();
|
|
85
|
+
try {
|
|
86
|
+
if (text === "/quit" || text === "/exit") {
|
|
87
|
+
await unregister();
|
|
88
|
+
say("bye.");
|
|
89
|
+
process.exit(0);
|
|
90
|
+
} else if (text === "/help") {
|
|
91
|
+
say("commands: <text>=room /dm <id> <text> /list /help /quit");
|
|
92
|
+
} else if (text === "/list") {
|
|
93
|
+
await printAgents();
|
|
94
|
+
} else if (text.startsWith("/dm ")) {
|
|
95
|
+
const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
|
|
96
|
+
if (!m) say(red("usage: /dm <agentId> <text>"));
|
|
97
|
+
else await sendDm(m[1], m[2]);
|
|
98
|
+
} else if (text.startsWith("/")) {
|
|
99
|
+
say(red(`unknown command: ${text.split(" ")[0]}`));
|
|
100
|
+
} else {
|
|
101
|
+
await sendRoom(text);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
say(red(`error: ${e?.message ?? e}`));
|
|
105
|
+
}
|
|
106
|
+
rl.prompt();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
process.on("SIGINT", async () => {
|
|
110
|
+
try { await unregister(); } catch {}
|
|
111
|
+
process.stdout.write("\n");
|
|
112
|
+
say("bye.");
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------- helpers ----------
|
|
117
|
+
|
|
118
|
+
function parseArgs(argv) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (let i = 0; i < argv.length; i++) {
|
|
121
|
+
if (argv[i] === "--id") out.id = argv[++i];
|
|
122
|
+
else if (argv[i] === "--dir") out.dir = argv[++i];
|
|
123
|
+
else if (argv[i] === "-h" || argv[i] === "--help") {
|
|
124
|
+
console.log("coord-chat — minimal TUI for agent-coord-mcp");
|
|
125
|
+
console.log("usage: coord-chat [--id <name>] [--dir <path>]");
|
|
126
|
+
console.log("at prompt: <text>=room /dm <id> <text> /list /quit");
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sanitize(s) {
|
|
134
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Write output without clobbering whatever the user is typing.
|
|
138
|
+
function say(line) {
|
|
139
|
+
readline.clearLine(process.stdout, 0);
|
|
140
|
+
readline.cursorTo(process.stdout, 0);
|
|
141
|
+
process.stdout.write(line + "\n");
|
|
142
|
+
if (typeof rl !== "undefined") rl.prompt(true);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function withLock(file, fn) {
|
|
146
|
+
await ensureFile(file);
|
|
147
|
+
const release = await lockfile.lock(file, {
|
|
148
|
+
retries: { retries: 10, minTimeout: 20, maxTimeout: 200 },
|
|
149
|
+
stale: 5000,
|
|
150
|
+
});
|
|
151
|
+
try { return await fn(); } finally { await release(); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function ensureFile(file) {
|
|
155
|
+
if (!existsSync(file)) {
|
|
156
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
157
|
+
await fsp.writeFile(file, "");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function register() {
|
|
162
|
+
await withLock(AGENTS_FILE, async () => {
|
|
163
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const existing = reg[ID];
|
|
166
|
+
reg[ID] = {
|
|
167
|
+
agentId: ID,
|
|
168
|
+
role: existing?.role ?? "human",
|
|
169
|
+
registeredAt: existing?.registeredAt ?? now,
|
|
170
|
+
lastHeartbeat: now,
|
|
171
|
+
};
|
|
172
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function unregister() {
|
|
177
|
+
await withLock(AGENTS_FILE, async () => {
|
|
178
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
179
|
+
delete reg[ID];
|
|
180
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function sendDm(to, text) {
|
|
185
|
+
const target = path.join(INBOX_DIR, `${sanitize(to)}.jsonl`);
|
|
186
|
+
await appendMessage(target, { from: ID, to, text });
|
|
187
|
+
say(dim(`→ DM sent to ${to}`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function sendRoom(text) {
|
|
191
|
+
await appendMessage(ROOM_FILE, { from: ID, text });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function appendMessage(file, partial) {
|
|
195
|
+
await ensureFile(file);
|
|
196
|
+
const entry = { id: randomUUID(), ts: Date.now(), ...partial };
|
|
197
|
+
appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function drainAndPrint() {
|
|
201
|
+
const cursor = readJsonSafe(CURSOR_FILE, {});
|
|
202
|
+
let changed = false;
|
|
203
|
+
|
|
204
|
+
const inboxAll = readJsonl(INBOX_FILE);
|
|
205
|
+
const inboxOff = cursor.inboxOffset ?? 0;
|
|
206
|
+
for (let i = inboxOff; i < inboxAll.length; i++) {
|
|
207
|
+
const m = inboxAll[i];
|
|
208
|
+
if (m && m.from !== ID) printMsg("DM", m);
|
|
209
|
+
}
|
|
210
|
+
if (inboxAll.length > inboxOff) {
|
|
211
|
+
cursor.inboxOffset = inboxAll.length;
|
|
212
|
+
changed = true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const roomAll = readJsonl(ROOM_FILE);
|
|
216
|
+
const roomOff = cursor.roomOffset ?? 0;
|
|
217
|
+
for (let i = roomOff; i < roomAll.length; i++) {
|
|
218
|
+
const m = roomAll[i];
|
|
219
|
+
if (m && m.from !== ID) printMsg("room", m);
|
|
220
|
+
}
|
|
221
|
+
if (roomAll.length > roomOff) {
|
|
222
|
+
cursor.roomOffset = roomAll.length;
|
|
223
|
+
changed = true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function printMsg(kind, m) {
|
|
230
|
+
const t = new Date(m.ts).toLocaleTimeString();
|
|
231
|
+
const color = kind === "DM" ? cyan : yellow;
|
|
232
|
+
say(`${color(`[${kind} ${t} ${m.from}]`)} ${m.text ?? ""}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function printAgents() {
|
|
236
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const STALE = 5 * 60 * 1000;
|
|
239
|
+
const ids = Object.keys(reg).sort();
|
|
240
|
+
if (!ids.length) return say(dim("(no agents)"));
|
|
241
|
+
for (const id of ids) {
|
|
242
|
+
const a = reg[id];
|
|
243
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
|
|
244
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
245
|
+
const status = live || now - a.lastHeartbeat < STALE ? "online " : "offline";
|
|
246
|
+
const trans = live ? ` transport=${marker.transport}` : "";
|
|
247
|
+
say(` ${id.padEnd(20)} ${status}${trans} role=${a.role ?? "-"}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function pidAlive(pid) {
|
|
252
|
+
try { process.kill(pid, 0); return true; }
|
|
253
|
+
catch (e) { return e?.code === "EPERM"; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readJsonl(file) {
|
|
257
|
+
if (!existsSync(file)) return [];
|
|
258
|
+
return readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
|
|
259
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readJsonSafe(file, fallback) {
|
|
264
|
+
if (!existsSync(file)) return fallback;
|
|
265
|
+
try {
|
|
266
|
+
const raw = readFileSync(file, "utf8");
|
|
267
|
+
if (!raw.trim()) return fallback;
|
|
268
|
+
return JSON.parse(raw);
|
|
269
|
+
} catch { return fallback; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function writeJsonAtomic(file, data) {
|
|
273
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
274
|
+
const tmp = file + ".tmp";
|
|
275
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
276
|
+
renameSync(tmp, file);
|
|
277
|
+
}
|