agent-coord-mcp 0.3.5 → 0.3.7

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.
@@ -10,21 +10,31 @@
10
10
  // Optional env: AGENT_COORD_DIR (default ~/agent-coord)
11
11
  // Optional env: AGENT_COORD_INCLUDE_ROOM=1 to also drain the shared room
12
12
 
13
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
13
+ import { existsSync, readFileSync, readdirSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import path from "node:path";
16
16
 
17
17
  const MODE = (process.argv.find((a) => a.startsWith("--mode="))?.slice(7)) ?? "user-prompt";
18
- const AGENT_ID = process.env.AGENT_COORD_ID;
19
- if (!AGENT_ID) {
20
- // No agent id configured — silently no-op so the hook never blocks the user.
21
- process.exit(0);
22
- }
23
18
 
24
19
  const ROOT =
25
20
  process.env.AGENT_COORD_DIR ??
26
21
  process.env.CLAUDE_COORD_DIR ??
27
22
  path.join(homedir(), "agent-coord");
23
+
24
+ // Prefer the agentId that is actually attached to this tmux pane (via the
25
+ // transports/ registry) over the env-provided one. This is what lets the
26
+ // generic settings.json hook command — which derives AGENT_COORD_ID from the
27
+ // cwd basename — still resolve to the agent that registered with a custom id
28
+ // (e.g. "claude-david" rather than "linkaroo.io"). Without this, peek reads
29
+ // the wrong cursor + inbox files and the m.from === AGENT_ID self-filter
30
+ // below never matches the agent's own room posts.
31
+ const AGENT_ID = resolveAgentId();
32
+ if (!AGENT_ID) {
33
+ // No agent id configured and no tmux transport match — silently no-op so
34
+ // the hook never blocks the user.
35
+ process.exit(0);
36
+ }
37
+
28
38
  const INBOX = path.join(ROOT, "inbox", `${sanitize(AGENT_ID)}.jsonl`);
29
39
  const ROOM = path.join(ROOT, "room.jsonl");
30
40
  const CURSOR = path.join(ROOT, "cursors", `${sanitize(AGENT_ID)}.json`);
@@ -107,3 +117,31 @@ function writeJsonAtomic(file, data) {
107
117
  function sanitize(id) {
108
118
  return id.replace(/[^a-zA-Z0-9._-]/g, "_");
109
119
  }
120
+
121
+ function resolveAgentId() {
122
+ const pane = process.env.TMUX_PANE;
123
+ if (pane) {
124
+ const fromTransport = lookupAgentByPane(pane);
125
+ if (fromTransport) return fromTransport;
126
+ }
127
+ return process.env.AGENT_COORD_ID || null;
128
+ }
129
+
130
+ function lookupAgentByPane(pane) {
131
+ const dir = path.join(ROOT, "transports");
132
+ if (!existsSync(dir)) return null;
133
+ let entries;
134
+ try {
135
+ entries = readdirSync(dir);
136
+ } catch {
137
+ return null;
138
+ }
139
+ for (const name of entries) {
140
+ if (!name.endsWith(".json")) continue;
141
+ const data = readJson(path.join(dir, name), null);
142
+ if (data && data.tmuxTarget === pane && typeof data.agentId === "string") {
143
+ return data.agentId;
144
+ }
145
+ }
146
+ return null;
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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": {
@@ -53,6 +53,29 @@ const CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
53
53
  mkdirSync(INBOX_DIR, { recursive: true });
54
54
  mkdirSync(CURSOR_DIR, { recursive: true });
55
55
 
56
+ // ---------- ANSI helpers ----------
57
+
58
+ const A = {
59
+ reset: "\x1b[0m",
60
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
61
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
62
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
63
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
64
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
65
+ blue: (s) => `\x1b[34m${s}\x1b[0m`,
66
+ magenta: (s) => `\x1b[35m${s}\x1b[0m`,
67
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
68
+ };
69
+
70
+ // Stable per-agent color from agentId hash. Skip red (reserved for errors)
71
+ // and white/black; cycle the remaining 5 bright colors.
72
+ const AGENT_COLORS = [A.green, A.yellow, A.blue, A.magenta, A.cyan];
73
+ function agentColor(id) {
74
+ let h = 0;
75
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
76
+ return AGENT_COLORS[h % AGENT_COLORS.length];
77
+ }
78
+
56
79
  // ---------- register and start UI ----------
57
80
 
58
81
  await register();
@@ -60,22 +83,18 @@ await register();
60
83
  const rl = readline.createInterface({
61
84
  input: process.stdin,
62
85
  output: process.stdout,
63
- prompt: `${ID}> `,
86
+ prompt: makePrompt(),
64
87
  });
65
88
 
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
-
89
+ // Banner printed once on launch. Keep it tight; this is a CLI, not a poster.
90
+ printBanner();
74
91
  await drainAndPrint();
75
92
 
76
93
  try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
77
94
  try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
95
+ try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
78
96
  setInterval(() => void drainAndPrint(), 1000);
97
+ setInterval(refreshPrompt, 5000);
79
98
 
80
99
  rl.prompt();
81
100
 
@@ -85,23 +104,36 @@ rl.on("line", async (line) => {
85
104
  try {
86
105
  if (text === "/quit" || text === "/exit") {
87
106
  await unregister();
88
- say("bye.");
107
+ say(A.dim("bye."));
89
108
  process.exit(0);
90
- } else if (text === "/help") {
91
- say("commands: <text>=room /dm <id> <text> /list /help /quit");
92
- } else if (text === "/list") {
109
+ } else if (text === "/help" || text === "/?") {
110
+ printHelp();
111
+ } else if (text === "/list" || text === "/who") {
93
112
  await printAgents();
113
+ } else if (text === "/whoami") {
114
+ await printWhoami();
115
+ } else if (text === "/clear" || text === "/cls") {
116
+ process.stdout.write("\x1b[2J\x1b[H");
117
+ printBanner();
118
+ } else if (text.startsWith("/last")) {
119
+ const m = text.match(/^\/last(?:\s+(\d+))?$/);
120
+ const n = m && m[1] ? parseInt(m[1], 10) : 20;
121
+ await printRecent(n);
122
+ } else if (text.startsWith("/me ")) {
123
+ const action = text.slice(4).trim();
124
+ if (!action) say(A.red("usage: /me <action>"));
125
+ else await sendRoom(`* ${ID} ${action}`);
94
126
  } else if (text.startsWith("/dm ")) {
95
127
  const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
96
- if (!m) say(red("usage: /dm <agentId> <text>"));
128
+ if (!m) say(A.red("usage: /dm <agentId> <text>"));
97
129
  else await sendDm(m[1], m[2]);
98
130
  } else if (text.startsWith("/")) {
99
- say(red(`unknown command: ${text.split(" ")[0]}`));
131
+ say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
100
132
  } else {
101
133
  await sendRoom(text);
102
134
  }
103
135
  } catch (e) {
104
- say(red(`error: ${e?.message ?? e}`));
136
+ say(A.red(`error: ${e?.message ?? e}`));
105
137
  }
106
138
  rl.prompt();
107
139
  });
@@ -142,6 +174,80 @@ function say(line) {
142
174
  if (typeof rl !== "undefined") rl.prompt(true);
143
175
  }
144
176
 
177
+ function makePrompt() {
178
+ const reg = readJsonSafe(AGENTS_FILE, {});
179
+ const now = Date.now();
180
+ const STALE = 5 * 60 * 1000;
181
+ let online = 0;
182
+ for (const id of Object.keys(reg)) {
183
+ if (id === ID) continue;
184
+ const a = reg[id];
185
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
186
+ const live = marker && marker.pid && pidAlive(marker.pid);
187
+ if (live || now - a.lastHeartbeat < STALE) online++;
188
+ }
189
+ const peers = online === 1 ? "1 peer" : `${online} peers`;
190
+ return `${agentColor(ID)(ID)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
191
+ }
192
+
193
+ function refreshPrompt() {
194
+ if (typeof rl === "undefined") return;
195
+ rl.setPrompt(makePrompt());
196
+ rl.prompt(true);
197
+ }
198
+
199
+ function printBanner() {
200
+ // Compact banner — three lines plus a separator. ASCII so it renders
201
+ // anywhere; no UTF-8 box-drawing surprises.
202
+ const lines = [
203
+ A.bold(A.cyan(" agent-coord ")) + A.dim("— shared chat for agents and humans"),
204
+ A.dim(` agentId=${A.reset}${agentColor(ID)(ID)}${A.dim(" dir=" + ROOT)}`),
205
+ A.dim(" type /help for commands · /quit to leave"),
206
+ ];
207
+ for (const l of lines) say(l);
208
+ say(A.dim(" " + "─".repeat(60)));
209
+ }
210
+
211
+ function printHelp() {
212
+ const rows = [
213
+ ["<text>", "post to the shared room"],
214
+ ["/dm <agent> <text>", "send a direct message"],
215
+ ["/me <action>", "post an IRC-style action (* you wave)"],
216
+ ["/list, /who", "show registered agents + transports"],
217
+ ["/whoami", "show your registration + transport"],
218
+ ["/last [n]", "show last n messages (default 20)"],
219
+ ["/clear", "clear the screen"],
220
+ ["/help, /?", "this list"],
221
+ ["/quit, /exit", "unregister and leave"],
222
+ ];
223
+ say(A.bold("commands:"));
224
+ for (const [cmd, desc] of rows) {
225
+ say(` ${A.cyan(cmd.padEnd(22))} ${A.dim(desc)}`);
226
+ }
227
+ }
228
+
229
+ async function printWhoami() {
230
+ const reg = readJsonSafe(AGENTS_FILE, {});
231
+ const a = reg[ID];
232
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(ID)}.json`), null);
233
+ const live = marker && marker.pid && pidAlive(marker.pid);
234
+ say(A.bold("you:"));
235
+ say(` ${A.cyan("id")} ${agentColor(ID)(ID)}`);
236
+ say(` ${A.cyan("role")} ${a?.role ?? "-"}`);
237
+ say(` ${A.cyan("dir")} ${A.dim(ROOT)}`);
238
+ say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
239
+ say(` ${A.cyan("registered")} ${a ? A.green("yes") : A.red("no")}`);
240
+ }
241
+
242
+ async function printRecent(n) {
243
+ const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
244
+ const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
245
+ const all = [...inbox, ...room].sort((a, b) => a.ts - b.ts).slice(-n);
246
+ if (!all.length) return say(A.dim("(no history)"));
247
+ say(A.bold(`last ${all.length} message(s):`));
248
+ for (const m of all) printMsg(m._kind, m, { history: true });
249
+ }
250
+
145
251
  async function withLock(file, fn) {
146
252
  await ensureFile(file);
147
253
  const release = await lockfile.lock(file, {
@@ -184,7 +290,7 @@ async function unregister() {
184
290
  async function sendDm(to, text) {
185
291
  const target = path.join(INBOX_DIR, `${sanitize(to)}.jsonl`);
186
292
  await appendMessage(target, { from: ID, to, text });
187
- say(dim(`→ DM sent to ${to}`));
293
+ say(A.dim(`→ DM sent to ${to}`));
188
294
  }
189
295
 
190
296
  async function sendRoom(text) {
@@ -226,10 +332,21 @@ async function drainAndPrint() {
226
332
  if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
227
333
  }
228
334
 
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 ?? ""}`);
335
+ function printMsg(kind, m, opts = {}) {
336
+ const d = new Date(m.ts);
337
+ const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
338
+ const who = m.from ?? "?";
339
+ const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
340
+ const meta = `${A.dim(t)} ${tag} ${agentColor(who)(who)}`;
341
+ // Multi-line bodies: first line on the meta row, subsequent lines indented
342
+ // to the body column for readability.
343
+ const body = (m.text ?? "").split("\n");
344
+ const indent = " ";
345
+ const first = body[0] ?? "";
346
+ const rest = body.slice(1).map((l) => indent + A.dim("│ ") + l);
347
+ const prefix = opts.history ? A.dim(" ") : "";
348
+ say(`${prefix}${meta} ${first}`);
349
+ for (const line of rest) say(`${prefix}${line}`);
233
350
  }
234
351
 
235
352
  async function printAgents() {
@@ -237,14 +354,28 @@ async function printAgents() {
237
354
  const now = Date.now();
238
355
  const STALE = 5 * 60 * 1000;
239
356
  const ids = Object.keys(reg).sort();
240
- if (!ids.length) return say(dim("(no agents)"));
357
+ if (!ids.length) return say(A.dim("(no agents)"));
358
+ // Compute column widths from data so things line up.
359
+ const idW = Math.max(8, ...ids.map((i) => i.length));
360
+ const roleW = Math.max(4, ...ids.map((i) => (reg[i].role ?? "-").length));
361
+ say(A.bold(`agents (${ids.length}):`));
362
+ say(
363
+ " " +
364
+ A.dim(
365
+ `${"id".padEnd(idW)} ${"status".padEnd(7)} ${"role".padEnd(roleW)} transport`,
366
+ ),
367
+ );
241
368
  for (const id of ids) {
242
369
  const a = reg[id];
243
370
  const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
244
371
  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 ?? "-"}`);
372
+ const onlineNow = live || now - a.lastHeartbeat < STALE;
373
+ const dot = onlineNow ? A.green("●") : A.dim("");
374
+ const status = onlineNow ? "online " : "offline";
375
+ const role = (a.role ?? "-").padEnd(roleW);
376
+ const trans = live ? A.green(marker.transport) : A.dim("none");
377
+ const me = id === ID ? A.dim(" (you)") : "";
378
+ say(` ${dot} ${agentColor(id)(id.padEnd(idW))} ${A.dim(status)} ${role} ${trans}${me}`);
248
379
  }
249
380
  }
250
381