agent-coord-mcp 0.4.1 → 0.4.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.5",
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": {
@@ -67,16 +67,22 @@ const A = {
67
67
  blue: (s) => `\x1b[34m${s}\x1b[0m`,
68
68
  magenta: (s) => `\x1b[35m${s}\x1b[0m`,
69
69
  cyan: (s) => `\x1b[36m${s}\x1b[0m`,
70
+ brightGreen: (s) => `\x1b[92m${s}\x1b[0m`,
71
+ brightYellow: (s) => `\x1b[93m${s}\x1b[0m`,
72
+ brightBlue: (s) => `\x1b[94m${s}\x1b[0m`,
73
+ brightMagenta: (s) => `\x1b[95m${s}\x1b[0m`,
74
+ brightCyan: (s) => `\x1b[96m${s}\x1b[0m`,
70
75
  };
71
76
 
72
- // Stable per-agent color from agentId hash. Skip red (reserved for errors)
73
- // and white/black; cycle the remaining 5 bright colors.
74
- const AGENT_COLORS = [A.green, A.yellow, A.blue, A.magenta, A.cyan];
75
- function agentColor(id) {
76
- let h = 0;
77
- for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
78
- return AGENT_COLORS[h % AGENT_COLORS.length];
79
- }
77
+ // Stable per-agent color via a persistent registry shared by all coord-chat
78
+ // sessions. First time we see an agentId we pick the next unused palette
79
+ // slot guarantees no collisions until the palette is exhausted. After that
80
+ // we fall back to hashing so behavior stays deterministic.
81
+ const AGENT_COLORS = [
82
+ A.green, A.yellow, A.blue, A.magenta, A.cyan,
83
+ A.brightGreen, A.brightYellow, A.brightBlue, A.brightMagenta, A.brightCyan,
84
+ ];
85
+ // Will be initialized after ROOT is set, just below.
80
86
 
81
87
  // ---------- register and start UI ----------
82
88
 
@@ -114,14 +120,27 @@ const SLASH_COMMANDS = [
114
120
  ];
115
121
 
116
122
  const STATUS_FILE_PATH = path.join(ROOT, "status.jsonl");
123
+ const COLOR_MAP_FILE = path.join(ROOT, "chat-colors.json");
117
124
 
118
125
  function completer(line) {
119
- // Tab-complete slash commands and DM targets. On multi-match with no
120
- // common-prefix advancement, surface the options on the first Tab via
121
- // say() — default readline UX hides them until a second Tab, which most
122
- // users assume means "nothing happened."
126
+ // Tab-complete slash commands, DM targets, and @mentions mid-message.
127
+ // On multi-match with no common-prefix advancement, surface the options
128
+ // on the first Tab via say() — default readline UX hides them until a
129
+ // second Tab, which most users assume means "nothing happened."
123
130
  let hits = [];
124
- if (line.startsWith("/dm ")) {
131
+ let substr = line;
132
+
133
+ // @mention completion takes priority — checked first because it can
134
+ // appear inside a slash command argument (e.g. `/dm bob hey @ali`) or
135
+ // in a plain room message.
136
+ const mentionMatch = line.match(/@([A-Za-z0-9._-]*)$/);
137
+ if (mentionMatch) {
138
+ const partial = mentionMatch[1];
139
+ const reg = readJsonSafe(AGENTS_FILE, {});
140
+ const ids = Object.keys(reg).filter((id) => id.startsWith(partial));
141
+ hits = ids.map((id) => `@${id} `);
142
+ substr = mentionMatch[0]; // tell readline to replace just the @partial part
143
+ } else if (line.startsWith("/dm ")) {
125
144
  const partial = line.slice(4);
126
145
  const reg = readJsonSafe(AGENTS_FILE, {});
127
146
  const ids = Object.keys(reg).filter((id) => id !== ID && id.startsWith(partial));
@@ -129,11 +148,11 @@ function completer(line) {
129
148
  } else if (line.startsWith("/")) {
130
149
  hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
131
150
  }
132
- if (hits.length > 1 && commonPrefix(hits).length <= line.length) {
151
+ if (hits.length > 1 && commonPrefix(hits).length <= substr.length) {
133
152
  const display = hits.map((h) => h.trim()).join(" ");
134
153
  say(A.dim(" ┄ " + display));
135
154
  }
136
- return [hits, line];
155
+ return [hits, substr];
137
156
  }
138
157
 
139
158
  function commonPrefix(strs) {
@@ -154,7 +173,10 @@ const rl = readline.createInterface({
154
173
 
155
174
  // Banner — printed once on launch. Keep it tight; this is a CLI, not a poster.
156
175
  printBanner();
157
- await drainAndPrint();
176
+ // Show recent context (last 3 messages from inbox + room) then fast-forward
177
+ // the cursor so the same entries don't show up again via the watcher path.
178
+ fastForwardCursors();
179
+ await printRecent(3);
158
180
 
159
181
  // Lay down the first separator. From this point, async incoming messages
160
182
  // (via the watcher → drainAndPrint → say) know they can use the cursor
@@ -272,6 +294,34 @@ function sanitize(s) {
272
294
  return s.replace(/[^a-zA-Z0-9._-]/g, "_");
273
295
  }
274
296
 
297
+ function agentColor(id) {
298
+ const map = readJsonSafe(COLOR_MAP_FILE, {});
299
+ const existing = map[id];
300
+ if (typeof existing === "number" && existing >= 0 && existing < AGENT_COLORS.length) {
301
+ return AGENT_COLORS[existing];
302
+ }
303
+ // First sighting — pick the first unused palette slot.
304
+ const used = new Set(Object.values(map).filter((v) => typeof v === "number"));
305
+ let idx = -1;
306
+ for (let i = 0; i < AGENT_COLORS.length; i++) {
307
+ if (!used.has(i)) { idx = i; break; }
308
+ }
309
+ if (idx === -1) {
310
+ // Palette exhausted — deterministic hash fallback. No persist (don't
311
+ // pollute the map with hash assignments that could be wrong).
312
+ let h = 0;
313
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
314
+ return AGENT_COLORS[h % AGENT_COLORS.length];
315
+ }
316
+ // Persist. Re-read from disk first to merge any concurrent assignments
317
+ // by other coord-chat processes; last-writer-wins for a single agentId
318
+ // is fine since colors are cosmetic.
319
+ const onDisk = readJsonSafe(COLOR_MAP_FILE, {});
320
+ onDisk[id] = idx;
321
+ try { writeJsonAtomic(COLOR_MAP_FILE, onDisk); } catch { /* best effort */ }
322
+ return AGENT_COLORS[idx];
323
+ }
324
+
275
325
  function say(line) {
276
326
  if (lastLineWasSep) {
277
327
  // Async path: a separator we own sits directly above the prompt; we own
@@ -365,6 +415,17 @@ async function printWhoami() {
365
415
  say(` ${A.cyan("registered")} ${a ? A.green("yes") : A.red("no")}`);
366
416
  }
367
417
 
418
+ function fastForwardCursors() {
419
+ // Move our cursor offsets to end-of-file so anything that existed before
420
+ // launch is treated as already-seen. printRecent(N) then shows the last N
421
+ // as historical context, and the watcher path only fires for genuinely
422
+ // new messages going forward.
423
+ const cur = readJsonSafe(CURSOR_FILE, {});
424
+ cur.inboxOffset = readJsonl(INBOX_FILE).length;
425
+ cur.roomOffset = readJsonl(ROOM_FILE).length;
426
+ writeJsonAtomic(CURSOR_FILE, cur);
427
+ }
428
+
368
429
  async function printRecent(n) {
369
430
  const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
370
431
  const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
@@ -604,19 +665,51 @@ function printMsg(kind, m, opts = {}) {
604
665
  const d = new Date(m.ts);
605
666
  const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
606
667
  const who = m.from ?? "?";
668
+ const color = agentColor(who);
669
+ const gutter = color("▎");
607
670
  const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
608
- const meta = `${A.dim(t)} ${tag} ${agentColor(who)(who)}`;
609
- // Multi-line bodies: first line on the meta row, subsequent lines indented
610
- // to the body column for readability.
611
- const body = (m.text ?? "").split("\n");
612
- const indent = " ";
671
+ const nick = A.bold(color(who));
672
+ const meta = `${A.dim(t)} ${tag} ${nick}`;
673
+ const body = (m.text ?? "").split("\n").map(formatBody);
674
+ const indent = " ";
613
675
  const first = body[0] ?? "";
614
- const rest = body.slice(1).map((l) => indent + A.dim("│ ") + l);
676
+ const rest = body.slice(1).map((l) => `${gutter} ${indent}${A.dim("│ ")}${l}`);
615
677
  const prefix = opts.history ? A.dim(" ") : "";
616
- say(`${prefix}${meta} ${first}`);
678
+ say(`${prefix}${gutter} ${meta} ${first}`);
617
679
  for (const line of rest) say(`${prefix}${line}`);
618
680
  }
619
681
 
682
+ // Lightweight inline-only "chat markdown" formatter — no dep. Handles bold,
683
+ // italic, inline code, links, and @mentions. Order matters: pull out inline
684
+ // code spans first so we don't touch their contents, then run the rest.
685
+ function formatBody(text) {
686
+ return text.split(/(`[^`\n]+`)/).map((part) => {
687
+ if (part.startsWith("`") && part.endsWith("`") && part.length >= 2) {
688
+ return A.dim("`") + A.cyan(part.slice(1, -1)) + A.dim("`");
689
+ }
690
+ let s = part;
691
+ // @mentions first — colored in the mentioned agent's hash color, bold if
692
+ // it's the current user (so you can spot pings at a glance).
693
+ s = s.replace(/@([A-Za-z0-9._-]+)/g, (_, name) => {
694
+ const colored = agentColor(name)(`@${name}`);
695
+ return name === ID ? A.bold(colored) : colored;
696
+ });
697
+ // **bold**
698
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, (_, t) => A.bold(t));
699
+ // *italic* and _italic_ (avoid matching inside **bold** by requiring
700
+ // non-asterisk neighbors)
701
+ s = s.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
702
+ s = s.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
703
+ // [text](url) — show text underlined with a dim trailing (url)
704
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, t, u) =>
705
+ `\x1b[4m${t}\x1b[0m${A.dim(` (${u})`)}`,
706
+ );
707
+ // Bare URLs — underline only the URL itself
708
+ s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${u}\x1b[0m`);
709
+ return s;
710
+ }).join("");
711
+ }
712
+
620
713
  async function printAgents() {
621
714
  const reg = readJsonSafe(AGENTS_FILE, {});
622
715
  const now = Date.now();