agent-coord-mcp 0.4.7 → 0.4.9

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.7",
3
+ "version": "0.4.9",
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,38 @@ 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
+ // Whether the ephemeral suggestion hint is currently occupying the slot above
58
+ // the prompt (see drawHint/clearHint). Declared early so say(), called during
59
+ // startup replay, can reference it without tripping the const/let TDZ.
60
+ let hintActive = false;
61
+
62
+ // Matches "@<this agent>" not followed by a name char, so we can flag messages
63
+ // that ping the current user. ID may contain regex metachars — escape it.
64
+ const SELF_MENTION_RE = new RegExp(
65
+ "@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
66
+ );
67
+ const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
68
+
69
+ // Recency at a glance: "now" / "5m" for fresh messages, falling back to a wall
70
+ // clock for anything over an hour (a stale "63m" reads worse than "08:34").
71
+ function relTime(ts) {
72
+ const mins = Math.floor((Date.now() - ts) / 60000);
73
+ if (mins < 1) return "now";
74
+ if (mins < 60) return `${mins}m`;
75
+ const d = new Date(ts);
76
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
77
+ }
78
+
47
79
  const INBOX_DIR = path.join(ROOT, "inbox");
48
80
  const CURSOR_DIR = path.join(ROOT, "cursors");
49
81
  const TRANSPORT_DIR = path.join(ROOT, "transports");
@@ -136,21 +168,24 @@ function completer(line) {
136
168
  const mentionMatch = line.match(/@([A-Za-z0-9._-]*)$/);
137
169
  if (mentionMatch) {
138
170
  const partial = mentionMatch[1];
139
- const reg = readJsonSafe(AGENTS_FILE, {});
140
- const ids = Object.keys(reg).filter((id) => id.startsWith(partial));
171
+ const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
141
172
  hits = ids.map((id) => `@${id} `);
142
173
  substr = mentionMatch[0]; // tell readline to replace just the @partial part
143
174
  } else if (line.startsWith("/dm ")) {
144
175
  const partial = line.slice(4);
145
- const reg = readJsonSafe(AGENTS_FILE, {});
146
- const ids = Object.keys(reg).filter((id) => id !== ID && id.startsWith(partial));
176
+ const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
147
177
  hits = ids.map((id) => `/dm ${id} `);
148
178
  } else if (line.startsWith("/")) {
149
179
  hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
150
180
  }
151
- if (hits.length > 1 && commonPrefix(hits).length <= substr.length) {
181
+ if (hits.length > 1) {
182
+ // Show the options as an ephemeral hint and hand readline only the common
183
+ // prefix — a single-element completion means its native multi-column dump
184
+ // never lands in scrollback. Tab still advances to the shared prefix.
152
185
  const display = hits.map((h) => h.trim()).join(" ");
153
- say(A.dim(" ┄ " + display));
186
+ drawHint(A.dim(" ┄ " + display));
187
+ const cp = commonPrefix(hits);
188
+ return [[cp.length >= substr.length ? cp : substr], substr];
154
189
  }
155
190
  return [hits, substr];
156
191
  }
@@ -171,6 +206,23 @@ const rl = readline.createInterface({
171
206
  completer,
172
207
  });
173
208
 
209
+ // Auto-offer the logged-in agents the instant "@" is typed (editor-style),
210
+ // so you don't have to press Tab to discover who's reachable. We only observe
211
+ // keypresses — readline still owns input. setImmediate lets readline insert
212
+ // the "@" into its line buffer before we inspect it.
213
+ if (process.stdin.isTTY) {
214
+ readline.emitKeypressEvents(process.stdin);
215
+ process.stdin.on("keypress", (str, key) => {
216
+ // setImmediate lets readline mutate its line buffer first, then we inspect
217
+ // it / redraw the slot above the prompt.
218
+ if (str === "@") { setImmediate(showMentionPicker); return; }
219
+ // Tab is the completer's — it draws/keeps the hint itself. Every other key
220
+ // dismisses a showing hint so the view snaps back to a clean separator.
221
+ if (key && key.name === "tab") return;
222
+ if (hintActive) setImmediate(clearHint);
223
+ });
224
+ }
225
+
174
226
  // Banner — printed once on launch. Keep it tight; this is a CLI, not a poster.
175
227
  printBanner();
176
228
  // Show recent context (last 3 messages from inbox + room) then fast-forward
@@ -322,7 +374,28 @@ function agentColor(id) {
322
374
  return AGENT_COLORS[idx];
323
375
  }
324
376
 
377
+ // Ephemeral suggestion line (the @mention / completion picker). It borrows the
378
+ // separator slot directly above the prompt: drawn there, then wiped back to a
379
+ // real separator on the next keystroke — so it never piles up in scrollback.
380
+ function drawHint(content) {
381
+ if (typeof rl === "undefined" || !lastLineWasSep) return;
382
+ process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the slot, clear it
383
+ process.stdout.write(content + "\n"); // draw the hint in place of the sep
384
+ rl.prompt(true); // redraw prompt + preserved input
385
+ hintActive = true;
386
+ }
387
+
388
+ function clearHint() {
389
+ if (typeof rl === "undefined" || !lastLineWasSep) { hintActive = false; return; }
390
+ if (!hintActive) return;
391
+ process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the hint slot, clear it
392
+ process.stdout.write(sepLine() + "\n"); // restore the separator
393
+ rl.prompt(true);
394
+ hintActive = false;
395
+ }
396
+
325
397
  function say(line) {
398
+ hintActive = false; // any real output reclaims the slot the hint borrowed
326
399
  if (lastLineWasSep) {
327
400
  // Async path: a separator we own sits directly above the prompt; we own
328
401
  // that line. Replace it with the incoming message, drop a new sep, and
@@ -661,29 +734,6 @@ async function drainAndPrint() {
661
734
  if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
662
735
  }
663
736
 
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
-
687
737
  function printMsg(kind, m, opts = {}) {
688
738
  const who = m.from ?? "?";
689
739
  const color = agentColor(who);
@@ -836,6 +886,38 @@ function pidAlive(pid) {
836
886
  catch (e) { return e?.code === "EPERM"; }
837
887
  }
838
888
 
889
+ // Agents considered "logged in": a live transport process, or a heartbeat
890
+ // within the stale window. Shared by the @mention picker and completer so we
891
+ // only ever offer reachable agents.
892
+ function onlineAgentIds() {
893
+ const reg = readJsonSafe(AGENTS_FILE, {});
894
+ const now = Date.now();
895
+ const STALE = 5 * 60 * 1000;
896
+ return Object.keys(reg)
897
+ .filter((id) => {
898
+ const a = reg[id];
899
+ const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
900
+ const live = marker && marker.pid && pidAlive(marker.pid);
901
+ return live || now - (a?.lastHeartbeat ?? 0) < STALE;
902
+ })
903
+ .sort();
904
+ }
905
+
906
+ // Pop the list of logged-in agents the moment "@" starts a mention token, so
907
+ // you can see who's reachable without hunting through /list. The list is
908
+ // dim/cosmetic and re-renders above the preserved input line.
909
+ function showMentionPicker() {
910
+ if (typeof rl === "undefined") return;
911
+ const before = (rl.line ?? "").slice(0, rl.cursor ?? (rl.line ?? "").length);
912
+ // Only when the just-typed "@" opens a fresh token (start of line or after
913
+ // whitespace) — avoids firing inside emails or mid-word.
914
+ if (!/(^|\s)@$/.test(before)) return;
915
+ const ids = onlineAgentIds().filter((id) => id !== ID);
916
+ if (!ids.length) return;
917
+ const list = ids.map((id) => A.green("●") + agentColor(id)(`@${id}`)).join(" ");
918
+ drawHint(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
919
+ }
920
+
839
921
  function readJsonl(file) {
840
922
  if (!existsSync(file)) return [];
841
923
  return readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {