agent-coord-mcp 0.3.9 → 0.4.0

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.3.9",
3
+ "version": "0.4.0",
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": {
@@ -22,11 +22,13 @@
22
22
 
23
23
  import {
24
24
  existsSync,
25
+ readdirSync,
25
26
  readFileSync,
26
27
  writeFileSync,
27
28
  appendFileSync,
28
29
  renameSync,
29
30
  mkdirSync,
31
+ unlinkSync,
30
32
  watch,
31
33
  } from "node:fs";
32
34
  import { promises as fsp } from "node:fs";
@@ -87,10 +89,13 @@ const TTY = !!process.stdout.isTTY;
87
89
  let COLS = process.stdout.columns || 80;
88
90
  const sepLine = () => A.dim("─".repeat(Math.max(10, COLS)));
89
91
 
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;
92
+ // `lastLineWasSep` tracks whether the line directly above the cursor is a
93
+ // separator that we own. When true, say() is delivering an async message —
94
+ // it should move up, overwrite the sep with the message, drop a fresh sep,
95
+ // re-prompt with preserved input. When false (e.g. just after the user
96
+ // pressed Enter), say() is printing synchronous command output — it should
97
+ // write naturally and let the post-Enter logic re-establish the input area.
98
+ let lastLineWasSep = false;
94
99
 
95
100
  if (TTY) {
96
101
  process.stdout.on("resize", () => {
@@ -103,10 +108,13 @@ if (TTY) {
103
108
  }
104
109
 
105
110
  const SLASH_COMMANDS = [
106
- "/dm", "/list", "/who", "/whoami", "/last", "/clear", "/cls",
107
- "/me", "/help", "/?", "/quit", "/exit",
111
+ "/dm", "/list", "/who", "/whoami", "/last", "/find", "/clear", "/cls",
112
+ "/me", "/status", "/prune", "/kick", "/wipe-room",
113
+ "/help", "/?", "/quit", "/exit",
108
114
  ];
109
115
 
116
+ const STATUS_FILE_PATH = path.join(ROOT, "status.jsonl");
117
+
110
118
  function completer(line) {
111
119
  // Tab-complete slash commands and DM targets. On multi-match with no
112
120
  // common-prefix advancement, surface the options on the first Tab via
@@ -148,10 +156,11 @@ const rl = readline.createInterface({
148
156
  printBanner();
149
157
  await drainAndPrint();
150
158
 
151
- // Lay down the first separator, then activate input-area mode so subsequent
152
- // say() calls maintain a sep line directly above the prompt.
159
+ // Lay down the first separator. From this point, async incoming messages
160
+ // (via the watcher drainAndPrint say) know they can use the cursor
161
+ // games to slot themselves above the prompt.
153
162
  process.stdout.write(sepLine() + "\n");
154
- inputAreaReady = true;
163
+ lastLineWasSep = true;
155
164
 
156
165
  try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
157
166
  try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
@@ -164,6 +173,9 @@ rl.prompt();
164
173
  rl.on("line", async (line) => {
165
174
  const text = line.trim();
166
175
  if (!text) return rl.prompt();
176
+ // The user's typed-and-submitted line is now in scrollback; it is NOT a
177
+ // separator slot we own. Sync output from commands should write naturally.
178
+ lastLineWasSep = false;
167
179
  try {
168
180
  if (text === "/quit" || text === "/exit") {
169
181
  await unregister();
@@ -178,6 +190,8 @@ rl.on("line", async (line) => {
178
190
  await printWhoami();
179
191
  } else if (text === "/clear" || text === "/cls") {
180
192
  process.stdout.write("\x1b[2J\x1b[H");
193
+ // The post-Enter sep write below re-establishes the input area.
194
+ lastLineWasSep = false;
181
195
  printBanner();
182
196
  } else if (text.startsWith("/last")) {
183
197
  const m = text.match(/^\/last(?:\s+(\d+))?$/);
@@ -191,6 +205,24 @@ rl.on("line", async (line) => {
191
205
  const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
192
206
  if (!m) say(A.red("usage: /dm <agentId> <text>"));
193
207
  else await sendDm(m[1], m[2]);
208
+ } else if (text.startsWith("/status")) {
209
+ const status = text.slice(7).trim();
210
+ if (!status) say(A.red("usage: /status <text>"));
211
+ else await postStatus(status);
212
+ } else if (text.startsWith("/prune")) {
213
+ const m = text.match(/^\/prune(?:\s+(\d+))?$/);
214
+ const days = m && m[1] ? parseInt(m[1], 10) : 7;
215
+ await pruneOld(days);
216
+ } else if (text.startsWith("/kick ")) {
217
+ const target = text.slice(6).trim();
218
+ if (!target) say(A.red("usage: /kick <agentId>"));
219
+ else await kickAgent(target);
220
+ } else if (text === "/wipe-room") {
221
+ await wipeRoom();
222
+ } else if (text.startsWith("/find ")) {
223
+ const term = text.slice(6).trim();
224
+ if (!term) say(A.red("usage: /find <text>"));
225
+ else await findInHistory(term);
194
226
  } else if (text.startsWith("/")) {
195
227
  say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
196
228
  } else {
@@ -199,9 +231,11 @@ rl.on("line", async (line) => {
199
231
  } catch (e) {
200
232
  say(A.red(`error: ${e?.message ?? e}`));
201
233
  }
202
- // After Enter, terminal advanced to a new line. Print a fresh separator
203
- // there so the next prompt sits below a sep line, maintaining the layout.
234
+ // Re-establish the input area: separator above, prompt below. Sets
235
+ // lastLineWasSep so any async incoming messages from here on can use the
236
+ // cursor-game path to slot in above the prompt.
204
237
  process.stdout.write(sepLine() + "\n");
238
+ lastLineWasSep = true;
205
239
  rl.prompt();
206
240
  });
207
241
 
@@ -239,17 +273,22 @@ function sanitize(s) {
239
273
  }
240
274
 
241
275
  function say(line) {
242
- if (!inputAreaReady) {
276
+ if (lastLineWasSep) {
277
+ // Async path: a separator we own sits directly above the prompt; we own
278
+ // that line. Replace it with the incoming message, drop a new sep, and
279
+ // re-render the prompt so user input is preserved.
280
+ process.stdout.write("\x1b[1A\r\x1b[2K");
281
+ process.stdout.write(line + "\n");
282
+ process.stdout.write(sepLine() + "\n");
283
+ if (typeof rl !== "undefined") rl.prompt(true);
284
+ // lastLineWasSep stays true — there's still a sep above the prompt.
285
+ } else {
286
+ // Sync path: no separator above us (startup banner, post-Enter command
287
+ // output). Just write the line at the current cursor.
288
+ readline.clearLine(process.stdout, 0);
289
+ readline.cursorTo(process.stdout, 0);
243
290
  process.stdout.write(line + "\n");
244
- return;
245
291
  }
246
- // We're on the prompt line. The line above is the current separator.
247
- // Move up to it, clear it, drop our message there, then a fresh separator,
248
- // then re-render the prompt on the next line.
249
- process.stdout.write("\x1b[1A\r\x1b[2K");
250
- process.stdout.write(line + "\n");
251
- process.stdout.write(sepLine() + "\n");
252
- if (typeof rl !== "undefined") rl.prompt(true);
253
292
  }
254
293
 
255
294
  function makePrompt() {
@@ -278,14 +317,14 @@ function refreshPrompt() {
278
317
  }
279
318
 
280
319
  function printBanner() {
281
- // Compact bannerthree lines plus a separator matching the input-area
282
- // separator width so they look like the same UI element, not two.
320
+ // Banner lines are static no need to go through say() and its
321
+ // sep-overwrite logic, which corrupts layout when called after /clear.
283
322
  const lines = [
284
323
  A.bold(A.cyan(" agent-coord ")) + A.dim("— shared chat for agents and humans"),
285
324
  A.dim(` agentId=${A.reset}${agentColor(ID)(ID)}${A.dim(" dir=" + ROOT)}`),
286
325
  A.dim(" type /help for commands · /quit to leave"),
287
326
  ];
288
- for (const l of lines) say(l);
327
+ for (const l of lines) process.stdout.write(l + "\n");
289
328
  }
290
329
 
291
330
  function printHelp() {
@@ -293,10 +332,17 @@ function printHelp() {
293
332
  ["<text>", "post to the shared room"],
294
333
  ["/dm <agent> <text>", "send a direct message"],
295
334
  ["/me <action>", "post an IRC-style action (* you wave)"],
335
+ ["/status <text>", "post to the status broadcast channel"],
296
336
  ["/list, /who", "show registered agents + transports"],
297
337
  ["/whoami", "show your registration + transport"],
298
338
  ["/last [n]", "show last n messages (default 20)"],
339
+ ["/find <text>", "search recent inbox + room history"],
299
340
  ["/clear", "clear the screen"],
341
+ [A.dim("--- admin ---"), ""],
342
+ ["/prune [days]", "drop messages older than N days (default 7)"],
343
+ ["/kick <agent>", "unregister an agent + kill their pusher"],
344
+ ["/wipe-room", "truncate the shared room (destructive)"],
345
+ [A.dim("---"), ""],
300
346
  ["/help, /?", "this list"],
301
347
  ["/quit, /exit", "unregister and leave"],
302
348
  ];
@@ -373,6 +419,85 @@ async function sendDm(to, text) {
373
419
  say(A.dim(`→ DM sent to ${to}`));
374
420
  }
375
421
 
422
+ async function postStatus(status) {
423
+ await ensureFile(STATUS_FILE_PATH);
424
+ const entry = { id: randomUUID(), ts: Date.now(), agentId: ID, status };
425
+ appendFileSync(STATUS_FILE_PATH, JSON.stringify(entry) + "\n");
426
+ say(A.dim(`→ status posted: ${status}`));
427
+ }
428
+
429
+ async function pruneOld(days) {
430
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
431
+ let total = 0;
432
+ const files = [ROOM_FILE, STATUS_FILE_PATH];
433
+ if (existsSync(INBOX_DIR)) {
434
+ for (const n of readdirSync(INBOX_DIR)) {
435
+ if (n.endsWith(".jsonl")) files.push(path.join(INBOX_DIR, n));
436
+ }
437
+ }
438
+ for (const file of files) {
439
+ if (!existsSync(file)) continue;
440
+ const all = readJsonl(file);
441
+ const kept = all.filter((e) => e && e.ts > cutoff);
442
+ if (kept.length < all.length) {
443
+ const body = kept.length ? kept.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
444
+ writeFileSync(file, body);
445
+ total += all.length - kept.length;
446
+ }
447
+ }
448
+ say(A.dim(`→ pruned ${total} entries older than ${days}d`));
449
+ }
450
+
451
+ async function kickAgent(target) {
452
+ let existed = false;
453
+ await withLock(AGENTS_FILE, async () => {
454
+ const reg = readJsonSafe(AGENTS_FILE, {});
455
+ if (reg[target]) {
456
+ existed = true;
457
+ delete reg[target];
458
+ writeJsonAtomic(AGENTS_FILE, reg);
459
+ }
460
+ });
461
+ if (!existed) {
462
+ say(A.red(`agent '${target}' not registered`));
463
+ return;
464
+ }
465
+ // Best-effort: kill their pusher and remove the transport marker so they
466
+ // disappear immediately from list_agents instead of hanging around with a
467
+ // live transport pointer.
468
+ const markerPath = path.join(TRANSPORT_DIR, `${sanitize(target)}.json`);
469
+ const marker = readJsonSafe(markerPath, null);
470
+ if (marker?.pid) {
471
+ try { process.kill(marker.pid, "SIGTERM"); } catch {}
472
+ }
473
+ try { if (existsSync(markerPath)) unlinkSync(markerPath); } catch {}
474
+ say(A.dim(`→ kicked ${target} (registry + transport cleared)`));
475
+ }
476
+
477
+ async function wipeRoom() {
478
+ await ensureFile(ROOM_FILE);
479
+ writeFileSync(ROOM_FILE, "");
480
+ // Reset cursors so prior offsets don't point past the now-shorter file.
481
+ const cur = readJsonSafe(CURSOR_FILE, {});
482
+ if (cur.roomOffset !== undefined) {
483
+ delete cur.roomOffset;
484
+ writeJsonAtomic(CURSOR_FILE, cur);
485
+ }
486
+ say(A.dim("→ room wiped"));
487
+ }
488
+
489
+ async function findInHistory(term) {
490
+ const t = term.toLowerCase();
491
+ const inbox = readJsonl(INBOX_FILE).map((m) => ({ ...m, _kind: "DM" }));
492
+ const room = readJsonl(ROOM_FILE).map((m) => ({ ...m, _kind: "room" }));
493
+ const matches = [...inbox, ...room]
494
+ .filter((m) => (m.text ?? "").toLowerCase().includes(t))
495
+ .sort((a, b) => a.ts - b.ts);
496
+ if (!matches.length) return say(A.dim(`(no matches for "${term}")`));
497
+ say(A.bold(`${matches.length} match(es) for "${term}":`));
498
+ for (const m of matches.slice(-20)) printMsg(m._kind, m, { history: true });
499
+ }
500
+
376
501
  async function sendRoom(text) {
377
502
  await appendMessage(ROOM_FILE, { from: ID, text });
378
503
  }