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 +1 -1
- package/scripts/coord-chat.mjs +145 -23
package/package.json
CHANGED
package/scripts/coord-chat.mjs
CHANGED
|
@@ -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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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", "/
|
|
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
|
|
152
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
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
|
-
//
|
|
205
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|