agent-coord-mcp 0.4.6 → 0.4.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.
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.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": {
@@ -661,31 +661,64 @@ async function drainAndPrint() {
661
661
  if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
662
662
  }
663
663
 
664
+ // Consecutive messages from the same sender within this window are visually
665
+ // grouped: the second one drops its header/blank line and just continues the
666
+ // gutter, Slack-style.
667
+ const GROUP_WINDOW = 2 * 60 * 1000;
668
+ let lastBlock = { who: null, ts: 0, kind: null };
669
+
670
+ // Matches "@<this agent>" not followed by a name char, so we can flag messages
671
+ // that ping the current user. ID may contain regex metachars — escape it.
672
+ const SELF_MENTION_RE = new RegExp(
673
+ "@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
674
+ );
675
+ const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
676
+
677
+ // Recency at a glance: "now" / "5m" for fresh messages, falling back to a wall
678
+ // clock for anything over an hour (a stale "63m" reads worse than "08:34").
679
+ function relTime(ts) {
680
+ const mins = Math.floor((Date.now() - ts) / 60000);
681
+ if (mins < 1) return "now";
682
+ if (mins < 60) return `${mins}m`;
683
+ const d = new Date(ts);
684
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
685
+ }
686
+
664
687
  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
688
  const who = m.from ?? "?";
668
689
  const color = agentColor(who);
669
690
  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
691
  const prefix = opts.history ? A.dim(" ") : "";
674
692
 
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.
693
+ // Body wraps manually under a continuous gutter terminal auto-wrap would
694
+ // lose the colored gutter on continuation lines.
679
695
  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
-
696
+ const bodyWidth = Math.max(20, COLS - prefixW - visibleLength(`▎ `));
685
697
  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}`);
698
+ const lines = wrapBody(text, bodyWidth);
699
+
700
+ // Group onto the previous block when it's the same live sender within the
701
+ // window — skip the blank line + header, just keep the gutter going.
702
+ const grouped = !opts.history
703
+ && lastBlock.who === who && lastBlock.kind === kind
704
+ && (m.ts - lastBlock.ts) < GROUP_WINDOW;
705
+
706
+ if (!grouped) {
707
+ // Header on its own line so the sender is a scannable anchor and the body
708
+ // always starts at a fixed column. "room" is the default and stays
709
+ // implied; only DMs get a badge. A ping to the current user brightens the
710
+ // gutter and adds a ► marker so it pops out of the firehose.
711
+ const pinged = mentionsSelf(m.text);
712
+ const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
713
+ const marker = pinged ? A.bold(A.yellow("► ")) : "";
714
+ const headGutter = pinged ? A.bold(color("▌")) : gutter;
715
+ const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
716
+ say("");
717
+ say(`${prefix}${headGutter} ${header}`);
718
+ }
719
+ for (const line of lines) say(`${prefix}${gutter} ${line}`);
720
+
721
+ if (!opts.history) lastBlock = { who, ts: m.ts, kind };
689
722
  }
690
723
 
691
724
  function visibleLength(s) {
@@ -693,24 +726,31 @@ function visibleLength(s) {
693
726
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
694
727
  }
695
728
 
696
- function wrapText(text, firstWidth, restWidth) {
697
- if (firstWidth <= 0 || restWidth <= 0) return [text];
729
+ // Wrap one message's text, preserving list/indent structure: a leading bullet
730
+ // ("- ", "* ", "1. ", "2) ") or whitespace indent is detected so wrapped
731
+ // continuation lines hang-indent under the text rather than re-flowing as flat
732
+ // prose.
733
+ function wrapBody(text, width) {
734
+ if (width <= 0) return [text];
698
735
  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) {
736
+ for (const raw of text.split("\n")) {
737
+ const mk = raw.match(/^(\s*(?:[-*•]\s+|\d+[.)]\s+)?)([\s\S]*)$/);
738
+ const lead = mk ? mk[1] : "";
739
+ const body = mk ? mk[2] : raw;
740
+ const indent = " ".repeat(visibleLength(lead));
741
+ const words = body.length ? body.split(/\s+/) : [];
742
+ if (!words.length) { out.push(lead.trimEnd()); continue; }
743
+ let line = lead + words[0];
744
+ for (let i = 1; i < words.length; i++) {
745
+ const proposed = line + " " + words[i];
746
+ if (visibleLength(proposed) > width) {
706
747
  out.push(line);
707
- line = word;
708
- currentWidth = restWidth;
748
+ line = indent + words[i];
709
749
  } else {
710
750
  line = proposed;
711
751
  }
712
752
  }
713
- if (line || words.length === 0) out.push(line);
753
+ out.push(line);
714
754
  }
715
755
  return out;
716
756
  }
@@ -736,16 +776,31 @@ function formatBody(text) {
736
776
  // non-asterisk neighbors)
737
777
  s = s.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
738
778
  s = s.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, (_, t) => `\x1b[3m${t}\x1b[0m`);
739
- // [text](url) — show text underlined with a dim trailing (url)
779
+ // [text](url) — show text underlined with a dim, shortened trailing (url)
740
780
  s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, t, u) =>
741
- `\x1b[4m${t}\x1b[0m${A.dim(` (${u})`)}`,
781
+ `\x1b[4m${t}\x1b[0m${A.dim(` (${shortenUrl(u)})`)}`,
742
782
  );
743
- // Bare URLs — underline only the URL itself
744
- s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${u}\x1b[0m`);
783
+ // Bare URLs — underline only the URL itself, shortened if long
784
+ s = s.replace(/\bhttps?:\/\/[^\s)]+/g, (u) => `\x1b[4m${shortenUrl(u)}\x1b[0m`);
745
785
  return s;
746
786
  }).join("");
747
787
  }
748
788
 
789
+ // Long URLs eat a whole wrapped line. Collapse to "host/…/last-segment" so the
790
+ // link stays recognizable without dominating the message. Short URLs are left
791
+ // intact (and remain copy-pasteable).
792
+ function shortenUrl(u) {
793
+ if (u.length <= 48) return u;
794
+ try {
795
+ const { host, pathname } = new URL(u);
796
+ const tail = pathname.split("/").filter(Boolean).pop() ?? "";
797
+ const short = tail ? `${host}/…/${tail}` : host;
798
+ return short.length < u.length ? short : u.slice(0, 45) + "…";
799
+ } catch {
800
+ return u.slice(0, 45) + "…";
801
+ }
802
+ }
803
+
749
804
  async function printAgents() {
750
805
  const reg = readJsonSafe(AGENTS_FILE, {});
751
806
  const now = Date.now();