agent-coord-mcp 0.3.10 → 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.10",
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,8 +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");
181
- // No sep above the prompt yet — the post-Enter path will write one.
182
- inputAreaReady = false;
193
+ // The post-Enter sep write below re-establishes the input area.
194
+ lastLineWasSep = false;
183
195
  printBanner();
184
196
  } else if (text.startsWith("/last")) {
185
197
  const m = text.match(/^\/last(?:\s+(\d+))?$/);
@@ -193,6 +205,24 @@ rl.on("line", async (line) => {
193
205
  const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
194
206
  if (!m) say(A.red("usage: /dm <agentId> <text>"));
195
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);
196
226
  } else if (text.startsWith("/")) {
197
227
  say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
198
228
  } else {
@@ -201,10 +231,11 @@ rl.on("line", async (line) => {
201
231
  } catch (e) {
202
232
  say(A.red(`error: ${e?.message ?? e}`));
203
233
  }
204
- // After Enter, terminal advanced to a new line. Print a fresh separator
205
- // 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.
206
237
  process.stdout.write(sepLine() + "\n");
207
- inputAreaReady = true;
238
+ lastLineWasSep = true;
208
239
  rl.prompt();
209
240
  });
210
241
 
@@ -242,17 +273,22 @@ function sanitize(s) {
242
273
  }
243
274
 
244
275
  function say(line) {
245
- 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);
246
290
  process.stdout.write(line + "\n");
247
- return;
248
291
  }
249
- // We're on the prompt line. The line above is the current separator.
250
- // Move up to it, clear it, drop our message there, then a fresh separator,
251
- // then re-render the prompt on the next line.
252
- process.stdout.write("\x1b[1A\r\x1b[2K");
253
- process.stdout.write(line + "\n");
254
- process.stdout.write(sepLine() + "\n");
255
- if (typeof rl !== "undefined") rl.prompt(true);
256
292
  }
257
293
 
258
294
  function makePrompt() {
@@ -296,10 +332,17 @@ function printHelp() {
296
332
  ["<text>", "post to the shared room"],
297
333
  ["/dm <agent> <text>", "send a direct message"],
298
334
  ["/me <action>", "post an IRC-style action (* you wave)"],
335
+ ["/status <text>", "post to the status broadcast channel"],
299
336
  ["/list, /who", "show registered agents + transports"],
300
337
  ["/whoami", "show your registration + transport"],
301
338
  ["/last [n]", "show last n messages (default 20)"],
339
+ ["/find <text>", "search recent inbox + room history"],
302
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("---"), ""],
303
346
  ["/help, /?", "this list"],
304
347
  ["/quit, /exit", "unregister and leave"],
305
348
  ];
@@ -376,6 +419,85 @@ async function sendDm(to, text) {
376
419
  say(A.dim(`→ DM sent to ${to}`));
377
420
  }
378
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
+
379
501
  async function sendRoom(text) {
380
502
  await appendMessage(ROOM_FILE, { from: ID, text });
381
503
  }