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 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.2",
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
+ }