agent-coord-mcp 0.3.5 → 0.3.8

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.8",
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,29 +53,97 @@ 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();
59
82
 
83
+ // Visual separator above the prompt — delimits "typing area starts here."
84
+ // Embedded in the prompt string itself so readline owns the redraw; no
85
+ // scroll-region gymnastics needed (those don't survive tmux cleanly).
86
+ const TTY = !!process.stdout.isTTY;
87
+ let COLS = process.stdout.columns || 80;
88
+ const sepLine = () => A.dim("─".repeat(Math.max(10, COLS)));
89
+
90
+ // inputAreaReady flips to true after the banner + initial drain finish and
91
+ // we lay down the first separator. From that point say() treats the line
92
+ // above the prompt as a separator slot it owns.
93
+ let inputAreaReady = false;
94
+
95
+ if (TTY) {
96
+ process.stdout.on("resize", () => {
97
+ COLS = process.stdout.columns || 80;
98
+ if (typeof rl !== "undefined") {
99
+ rl.setPrompt(makePrompt());
100
+ rl.prompt(true);
101
+ }
102
+ });
103
+ }
104
+
105
+ const SLASH_COMMANDS = [
106
+ "/dm", "/list", "/who", "/whoami", "/last", "/clear", "/cls",
107
+ "/me", "/help", "/?", "/quit", "/exit",
108
+ ];
109
+
110
+ function completer(line) {
111
+ // Tab-complete slash commands and DM targets.
112
+ if (line.startsWith("/dm ")) {
113
+ const partial = line.slice(4);
114
+ const reg = readJsonSafe(AGENTS_FILE, {});
115
+ const ids = Object.keys(reg).filter((id) => id !== ID && id.startsWith(partial));
116
+ const hits = ids.map((id) => `/dm ${id} `);
117
+ return [hits, line];
118
+ }
119
+ if (line.startsWith("/")) {
120
+ const hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
121
+ return [hits, line];
122
+ }
123
+ return [[], line];
124
+ }
125
+
60
126
  const rl = readline.createInterface({
61
127
  input: process.stdin,
62
128
  output: process.stdout,
63
- prompt: `${ID}> `,
129
+ prompt: makePrompt(),
130
+ completer,
64
131
  });
65
132
 
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
-
133
+ // Banner printed once on launch. Keep it tight; this is a CLI, not a poster.
134
+ printBanner();
74
135
  await drainAndPrint();
75
136
 
137
+ // Lay down the first separator, then activate input-area mode so subsequent
138
+ // say() calls maintain a sep line directly above the prompt.
139
+ process.stdout.write(sepLine() + "\n");
140
+ inputAreaReady = true;
141
+
76
142
  try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
77
143
  try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
144
+ try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
78
145
  setInterval(() => void drainAndPrint(), 1000);
146
+ setInterval(refreshPrompt, 5000);
79
147
 
80
148
  rl.prompt();
81
149
 
@@ -85,34 +153,56 @@ rl.on("line", async (line) => {
85
153
  try {
86
154
  if (text === "/quit" || text === "/exit") {
87
155
  await unregister();
88
- say("bye.");
156
+ teardownFooter();
157
+ process.stdout.write(A.dim("bye.\n"));
89
158
  process.exit(0);
90
- } else if (text === "/help") {
91
- say("commands: <text>=room /dm <id> <text> /list /help /quit");
92
- } else if (text === "/list") {
159
+ } else if (text === "/help" || text === "/?") {
160
+ printHelp();
161
+ } else if (text === "/list" || text === "/who") {
93
162
  await printAgents();
163
+ } else if (text === "/whoami") {
164
+ await printWhoami();
165
+ } else if (text === "/clear" || text === "/cls") {
166
+ process.stdout.write("\x1b[2J\x1b[H");
167
+ printBanner();
168
+ } else if (text.startsWith("/last")) {
169
+ const m = text.match(/^\/last(?:\s+(\d+))?$/);
170
+ const n = m && m[1] ? parseInt(m[1], 10) : 20;
171
+ await printRecent(n);
172
+ } else if (text.startsWith("/me ")) {
173
+ const action = text.slice(4).trim();
174
+ if (!action) say(A.red("usage: /me <action>"));
175
+ else await sendRoom(`* ${ID} ${action}`);
94
176
  } else if (text.startsWith("/dm ")) {
95
177
  const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
96
- if (!m) say(red("usage: /dm <agentId> <text>"));
178
+ if (!m) say(A.red("usage: /dm <agentId> <text>"));
97
179
  else await sendDm(m[1], m[2]);
98
180
  } else if (text.startsWith("/")) {
99
- say(red(`unknown command: ${text.split(" ")[0]}`));
181
+ say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
100
182
  } else {
101
183
  await sendRoom(text);
102
184
  }
103
185
  } catch (e) {
104
- say(red(`error: ${e?.message ?? e}`));
186
+ say(A.red(`error: ${e?.message ?? e}`));
105
187
  }
188
+ // After Enter, terminal advanced to a new line. Print a fresh separator
189
+ // there so the next prompt sits below a sep line, maintaining the layout.
190
+ process.stdout.write(sepLine() + "\n");
106
191
  rl.prompt();
107
192
  });
108
193
 
109
194
  process.on("SIGINT", async () => {
110
195
  try { await unregister(); } catch {}
111
- process.stdout.write("\n");
112
- say("bye.");
196
+ teardownFooter();
197
+ process.stdout.write("\n" + A.dim("bye.\n"));
113
198
  process.exit(0);
114
199
  });
115
200
 
201
+ process.on("exit", () => {
202
+ // Final safety net — restore terminal state if we exit via any path.
203
+ teardownFooter();
204
+ });
205
+
116
206
  // ---------- helpers ----------
117
207
 
118
208
  function parseArgs(argv) {
@@ -134,14 +224,97 @@ function sanitize(s) {
134
224
  return s.replace(/[^a-zA-Z0-9._-]/g, "_");
135
225
  }
136
226
 
137
- // Write output without clobbering whatever the user is typing.
138
227
  function say(line) {
139
- readline.clearLine(process.stdout, 0);
140
- readline.cursorTo(process.stdout, 0);
228
+ if (!inputAreaReady) {
229
+ process.stdout.write(line + "\n");
230
+ return;
231
+ }
232
+ // We're on the prompt line. The line above is the current separator.
233
+ // Move up to it, clear it, drop our message there, then a fresh separator,
234
+ // then re-render the prompt on the next line.
235
+ process.stdout.write("\x1b[1A\r\x1b[2K");
141
236
  process.stdout.write(line + "\n");
237
+ process.stdout.write(sepLine() + "\n");
142
238
  if (typeof rl !== "undefined") rl.prompt(true);
143
239
  }
144
240
 
241
+ function makePrompt() {
242
+ const reg = readJsonSafe(AGENTS_FILE, {});
243
+ const now = Date.now();
244
+ const STALE = 5 * 60 * 1000;
245
+ let online = 0;
246
+ for (const id of Object.keys(reg)) {
247
+ if (id === ID) continue;
248
+ const a = reg[id];
249
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
250
+ const live = marker && marker.pid && pidAlive(marker.pid);
251
+ if (live || now - a.lastHeartbeat < STALE) online++;
252
+ }
253
+ const peers = online === 1 ? "1 peer" : `${online} peers`;
254
+ return `${agentColor(ID)(ID)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
255
+ }
256
+
257
+ // No-op stubs kept so the exit paths don't reference deleted functions.
258
+ function teardownFooter() {}
259
+
260
+ function refreshPrompt() {
261
+ if (typeof rl === "undefined") return;
262
+ rl.setPrompt(makePrompt());
263
+ rl.prompt(true);
264
+ }
265
+
266
+ function printBanner() {
267
+ // Compact banner — three lines plus a separator. ASCII so it renders
268
+ // anywhere; no UTF-8 box-drawing surprises.
269
+ const lines = [
270
+ A.bold(A.cyan(" agent-coord ")) + A.dim("— shared chat for agents and humans"),
271
+ A.dim(` agentId=${A.reset}${agentColor(ID)(ID)}${A.dim(" dir=" + ROOT)}`),
272
+ A.dim(" type /help for commands · /quit to leave"),
273
+ ];
274
+ for (const l of lines) say(l);
275
+ say(A.dim(" " + "─".repeat(60)));
276
+ }
277
+
278
+ function printHelp() {
279
+ const rows = [
280
+ ["<text>", "post to the shared room"],
281
+ ["/dm <agent> <text>", "send a direct message"],
282
+ ["/me <action>", "post an IRC-style action (* you wave)"],
283
+ ["/list, /who", "show registered agents + transports"],
284
+ ["/whoami", "show your registration + transport"],
285
+ ["/last [n]", "show last n messages (default 20)"],
286
+ ["/clear", "clear the screen"],
287
+ ["/help, /?", "this list"],
288
+ ["/quit, /exit", "unregister and leave"],
289
+ ];
290
+ say(A.bold("commands:"));
291
+ for (const [cmd, desc] of rows) {
292
+ say(` ${A.cyan(cmd.padEnd(22))} ${A.dim(desc)}`);
293
+ }
294
+ }
295
+
296
+ async function printWhoami() {
297
+ const reg = readJsonSafe(AGENTS_FILE, {});
298
+ const a = reg[ID];
299
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(ID)}.json`), null);
300
+ const live = marker && marker.pid && pidAlive(marker.pid);
301
+ say(A.bold("you:"));
302
+ say(` ${A.cyan("id")} ${agentColor(ID)(ID)}`);
303
+ say(` ${A.cyan("role")} ${a?.role ?? "-"}`);
304
+ say(` ${A.cyan("dir")} ${A.dim(ROOT)}`);
305
+ say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
306
+ say(` ${A.cyan("registered")} ${a ? A.green("yes") : A.red("no")}`);
307
+ }
308
+
309
+ async function printRecent(n) {
310
+ const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
311
+ const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
312
+ const all = [...inbox, ...room].sort((a, b) => a.ts - b.ts).slice(-n);
313
+ if (!all.length) return say(A.dim("(no history)"));
314
+ say(A.bold(`last ${all.length} message(s):`));
315
+ for (const m of all) printMsg(m._kind, m, { history: true });
316
+ }
317
+
145
318
  async function withLock(file, fn) {
146
319
  await ensureFile(file);
147
320
  const release = await lockfile.lock(file, {
@@ -184,7 +357,7 @@ async function unregister() {
184
357
  async function sendDm(to, text) {
185
358
  const target = path.join(INBOX_DIR, `${sanitize(to)}.jsonl`);
186
359
  await appendMessage(target, { from: ID, to, text });
187
- say(dim(`→ DM sent to ${to}`));
360
+ say(A.dim(`→ DM sent to ${to}`));
188
361
  }
189
362
 
190
363
  async function sendRoom(text) {
@@ -226,10 +399,21 @@ async function drainAndPrint() {
226
399
  if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
227
400
  }
228
401
 
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 ?? ""}`);
402
+ function printMsg(kind, m, opts = {}) {
403
+ const d = new Date(m.ts);
404
+ const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
405
+ const who = m.from ?? "?";
406
+ const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
407
+ const meta = `${A.dim(t)} ${tag} ${agentColor(who)(who)}`;
408
+ // Multi-line bodies: first line on the meta row, subsequent lines indented
409
+ // to the body column for readability.
410
+ const body = (m.text ?? "").split("\n");
411
+ const indent = " ";
412
+ const first = body[0] ?? "";
413
+ const rest = body.slice(1).map((l) => indent + A.dim("│ ") + l);
414
+ const prefix = opts.history ? A.dim(" ") : "";
415
+ say(`${prefix}${meta} ${first}`);
416
+ for (const line of rest) say(`${prefix}${line}`);
233
417
  }
234
418
 
235
419
  async function printAgents() {
@@ -237,14 +421,28 @@ async function printAgents() {
237
421
  const now = Date.now();
238
422
  const STALE = 5 * 60 * 1000;
239
423
  const ids = Object.keys(reg).sort();
240
- if (!ids.length) return say(dim("(no agents)"));
424
+ if (!ids.length) return say(A.dim("(no agents)"));
425
+ // Compute column widths from data so things line up.
426
+ const idW = Math.max(8, ...ids.map((i) => i.length));
427
+ const roleW = Math.max(4, ...ids.map((i) => (reg[i].role ?? "-").length));
428
+ say(A.bold(`agents (${ids.length}):`));
429
+ say(
430
+ " " +
431
+ A.dim(
432
+ `${"id".padEnd(idW)} ${"status".padEnd(7)} ${"role".padEnd(roleW)} transport`,
433
+ ),
434
+ );
241
435
  for (const id of ids) {
242
436
  const a = reg[id];
243
437
  const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
244
438
  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 ?? "-"}`);
439
+ const onlineNow = live || now - a.lastHeartbeat < STALE;
440
+ const dot = onlineNow ? A.green("●") : A.dim("");
441
+ const status = onlineNow ? "online " : "offline";
442
+ const role = (a.role ?? "-").padEnd(roleW);
443
+ const trans = live ? A.green(marker.transport) : A.dim("none");
444
+ const me = id === ID ? A.dim(" (you)") : "";
445
+ say(` ${dot} ${agentColor(id)(id.padEnd(idW))} ${A.dim(status)} ${role} ${trans}${me}`);
248
446
  }
249
447
  }
250
448