agent-coord-mcp 0.4.6 → 0.4.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.
package/dist/server.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.4.6",
3
+ "version": "0.4.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": {
@@ -44,6 +44,33 @@ const args = parseArgs(process.argv.slice(2));
44
44
  const ID = args.id ?? process.env.USER ?? "human";
45
45
  const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
46
46
 
47
+ // Message-rendering state + helpers. Declared up here (above the top-level
48
+ // printRecent() call) so they're initialized before first use — const/let
49
+ // don't hoist the way function declarations do.
50
+
51
+ // Consecutive messages from the same sender within this window are visually
52
+ // grouped: the second one drops its header/blank line and just continues the
53
+ // gutter, Slack-style.
54
+ const GROUP_WINDOW = 2 * 60 * 1000;
55
+ let lastBlock = { who: null, ts: 0, kind: null };
56
+
57
+ // Matches "@<this agent>" not followed by a name char, so we can flag messages
58
+ // that ping the current user. ID may contain regex metachars — escape it.
59
+ const SELF_MENTION_RE = new RegExp(
60
+ "@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
61
+ );
62
+ const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
63
+
64
+ // Recency at a glance: "now" / "5m" for fresh messages, falling back to a wall
65
+ // clock for anything over an hour (a stale "63m" reads worse than "08:34").
66
+ function relTime(ts) {
67
+ const mins = Math.floor((Date.now() - ts) / 60000);
68
+ if (mins < 1) return "now";
69
+ if (mins < 60) return `${mins}m`;
70
+ const d = new Date(ts);
71
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
72
+ }
73
+
47
74
  const INBOX_DIR = path.join(ROOT, "inbox");
48
75
  const CURSOR_DIR = path.join(ROOT, "cursors");
49
76
  const TRANSPORT_DIR = path.join(ROOT, "transports");
@@ -136,14 +163,12 @@ function completer(line) {
136
163
  const mentionMatch = line.match(/@([A-Za-z0-9._-]*)$/);
137
164
  if (mentionMatch) {
138
165
  const partial = mentionMatch[1];
139
- const reg = readJsonSafe(AGENTS_FILE, {});
140
- const ids = Object.keys(reg).filter((id) => id.startsWith(partial));
166
+ const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
141
167
  hits = ids.map((id) => `@${id} `);
142
168
  substr = mentionMatch[0]; // tell readline to replace just the @partial part
143
169
  } else if (line.startsWith("/dm ")) {
144
170
  const partial = line.slice(4);
145
- const reg = readJsonSafe(AGENTS_FILE, {});
146
- const ids = Object.keys(reg).filter((id) => id !== ID && id.startsWith(partial));
171
+ const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
147
172
  hits = ids.map((id) => `/dm ${id} `);
148
173
  } else if (line.startsWith("/")) {
149
174
  hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
@@ -171,6 +196,17 @@ const rl = readline.createInterface({
171
196
  completer,
172
197
  });
173
198
 
199
+ // Auto-offer the logged-in agents the instant "@" is typed (editor-style),
200
+ // so you don't have to press Tab to discover who's reachable. We only observe
201
+ // keypresses — readline still owns input. setImmediate lets readline insert
202
+ // the "@" into its line buffer before we inspect it.
203
+ if (process.stdin.isTTY) {
204
+ readline.emitKeypressEvents(process.stdin);
205
+ process.stdin.on("keypress", (str) => {
206
+ if (str === "@") setImmediate(showMentionPicker);
207
+ });
208
+ }
209
+
174
210
  // Banner — printed once on launch. Keep it tight; this is a CLI, not a poster.
175
211
  printBanner();
176
212
  // Show recent context (last 3 messages from inbox + room) then fast-forward
@@ -662,30 +698,40 @@ async function drainAndPrint() {
662
698
  }
663
699
 
664
700
  function printMsg(kind, m, opts = {}) {
665
- const d = new Date(m.ts);
666
- const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
667
701
  const who = m.from ?? "?";
668
702
  const color = agentColor(who);
669
703
  const gutter = color("▎");
670
- const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
671
- const nick = A.bold(color(who));
672
- const meta = `${A.dim(t)} ${tag} ${nick}`;
673
704
  const prefix = opts.history ? A.dim(" ") : "";
674
705
 
675
- // Visible widths: prefix + "▎ " + meta + " " on the first line;
676
- // prefix + "▎ " on continuation lines. We wrap manually so the
677
- // colored gutter prepends every wrapped line — terminal auto-wrap
678
- // would lose it.
706
+ // Body wraps manually under a continuous gutter terminal auto-wrap would
707
+ // lose the colored gutter on continuation lines.
679
708
  const prefixW = visibleLength(prefix);
680
- const firstChrome = prefixW + visibleLength(`▎ ${meta} `);
681
- const restChrome = prefixW + visibleLength(`▎ `);
682
- const firstWidth = Math.max(20, COLS - firstChrome);
683
- const restWidth = Math.max(20, COLS - restChrome);
684
-
709
+ const bodyWidth = Math.max(20, COLS - prefixW - visibleLength(`▎ `));
685
710
  const text = (m.text ?? "").split("\n").map(formatBody).join("\n");
686
- const lines = wrapText(text, firstWidth, restWidth);
687
- say(`${prefix}${gutter} ${meta} ${lines[0] ?? ""}`);
688
- for (const line of lines.slice(1)) say(`${prefix}${gutter} ${line}`);
711
+ const lines = wrapBody(text, bodyWidth);
712
+
713
+ // Group onto the previous block when it's the same live sender within the
714
+ // window — skip the blank line + header, just keep the gutter going.
715
+ const grouped = !opts.history
716
+ && lastBlock.who === who && lastBlock.kind === kind
717
+ && (m.ts - lastBlock.ts) < GROUP_WINDOW;
718
+
719
+ if (!grouped) {
720
+ // Header on its own line so the sender is a scannable anchor and the body
721
+ // always starts at a fixed column. "room" is the default and stays
722
+ // implied; only DMs get a badge. A ping to the current user brightens the
723
+ // gutter and adds a ► marker so it pops out of the firehose.
724
+ const pinged = mentionsSelf(m.text);
725
+ const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
726
+ const marker = pinged ? A.bold(A.yellow("► ")) : "";
727
+ const headGutter = pinged ? A.bold(color("▌")) : gutter;
728
+ const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
729
+ say("");
730
+ say(`${prefix}${headGutter} ${header}`);
731
+ }
732
+ for (const line of lines) say(`${prefix}${gutter} ${line}`);
733
+
734
+ if (!opts.history) lastBlock = { who, ts: m.ts, kind };
689
735
  }
690
736
 
691
737
  function visibleLength(s) {
@@ -693,24 +739,31 @@ function visibleLength(s) {
693
739
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
694
740
  }
695
741
 
696
- function wrapText(text, firstWidth, restWidth) {
697
- if (firstWidth <= 0 || restWidth <= 0) return [text];
742
+ // Wrap one message's text, preserving list/indent structure: a leading bullet
743
+ // ("- ", "* ", "1. ", "2) ") or whitespace indent is detected so wrapped
744
+ // continuation lines hang-indent under the text rather than re-flowing as flat
745
+ // prose.
746
+ function wrapBody(text, width) {
747
+ if (width <= 0) return [text];
698
748
  const out = [];
699
- for (const para of text.split("\n")) {
700
- const words = para.split(" ");
701
- let line = "";
702
- let currentWidth = out.length === 0 ? firstWidth : restWidth;
703
- for (const word of words) {
704
- const proposed = line ? line + " " + word : word;
705
- if (visibleLength(proposed) > currentWidth && line) {
749
+ for (const raw of text.split("\n")) {
750
+ const mk = raw.match(/^(\s*(?:[-*•]\s+|\d+[.)]\s+)?)([\s\S]*)$/);
751
+ const lead = mk ? mk[1] : "";
752
+ const body = mk ? mk[2] : raw;
753
+ const indent = " ".repeat(visibleLength(lead));
754
+ const words = body.length ? body.split(/\s+/) : [];
755
+ if (!words.length) { out.push(lead.trimEnd()); continue; }
756
+ let line = lead + words[0];
757
+ for (let i = 1; i < words.length; i++) {
758
+ const proposed = line + " " + words[i];
759
+ if (visibleLength(proposed) > width) {
706
760
  out.push(line);
707
- line = word;
708
- currentWidth = restWidth;
761
+ line = indent + words[i];
709
762
  } else {
710
763
  line = proposed;
711
764
  }
712
765
  }
713
- if (line || words.length === 0) out.push(line);
766
+ out.push(line);
714
767
  }
715
768
  return out;
716
769
  }
@@ -736,16 +789,31 @@ function formatBody(text) {
736
789
  // non-asterisk neighbors)
737
790
  s = s.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
738
791
  s = s.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
739
- // [text](url) — show text underlined with a dim trailing (url)
792
+ // [text](url) — show text underlined with a dim, shortened trailing (url)
740
793
  s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, t, u) =>
741
- `\x1b[4m${t}\x1b[0m${A.dim(` (${u})`)}`,
794
+ `\x1b[4m${t}\x1b[0m${A.dim(` (${shortenUrl(u)})`)}`,
742
795
  );
743
- // Bare URLs — underline only the URL itself
744
- s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${u}\x1b[0m`);
796
+ // Bare URLs — underline only the URL itself, shortened if long
797
+ s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${shortenUrl(u)}\x1b[0m`);
745
798
  return s;
746
799
  }).join("");
747
800
  }
748
801
 
802
+ // Long URLs eat a whole wrapped line. Collapse to "host/…/last-segment" so the
803
+ // link stays recognizable without dominating the message. Short URLs are left
804
+ // intact (and remain copy-pasteable).
805
+ function shortenUrl(u) {
806
+ if (u.length <= 48) return u;
807
+ try {
808
+ const { host, pathname } = new URL(u);
809
+ const tail = pathname.split("/").filter(Boolean).pop() ?? "";
810
+ const short = tail ? `${host}/…/${tail}` : host;
811
+ return short.length < u.length ? short : u.slice(0, 45) + "…";
812
+ } catch {
813
+ return u.slice(0, 45) + "…";
814
+ }
815
+ }
816
+
749
817
  async function printAgents() {
750
818
  const reg = readJsonSafe(AGENTS_FILE, {});
751
819
  const now = Date.now();
@@ -781,6 +849,38 @@ function pidAlive(pid) {
781
849
  catch (e) { return e?.code === "EPERM"; }
782
850
  }
783
851
 
852
+ // Agents considered "logged in": a live transport process, or a heartbeat
853
+ // within the stale window. Shared by the @mention picker and completer so we
854
+ // only ever offer reachable agents.
855
+ function onlineAgentIds() {
856
+ const reg = readJsonSafe(AGENTS_FILE, {});
857
+ const now = Date.now();
858
+ const STALE = 5 * 60 * 1000;
859
+ return Object.keys(reg)
860
+ .filter((id) => {
861
+ const a = reg[id];
862
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
863
+ const live = marker && marker.pid && pidAlive(marker.pid);
864
+ return live || now - (a?.lastHeartbeat ?? 0) < STALE;
865
+ })
866
+ .sort();
867
+ }
868
+
869
+ // Pop the list of logged-in agents the moment "@" starts a mention token, so
870
+ // you can see who's reachable without hunting through /list. The list is
871
+ // dim/cosmetic and re-renders above the preserved input line.
872
+ function showMentionPicker() {
873
+ if (typeof rl === "undefined") return;
874
+ const before = (rl.line ?? "").slice(0, rl.cursor ?? (rl.line ?? "").length);
875
+ // Only when the just-typed "@" opens a fresh token (start of line or after
876
+ // whitespace) — avoids firing inside emails or mid-word.
877
+ if (!/(^|\s)@$/.test(before)) return;
878
+ const ids = onlineAgentIds().filter((id) => id !== ID);
879
+ if (!ids.length) return;
880
+ const list = ids.map((id) => A.green("●") + agentColor(id)(`@${id}`)).join(" ");
881
+ say(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
882
+ }
883
+
784
884
  function readJsonl(file) {
785
885
  if (!existsSync(file)) return [];
786
886
  return readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {