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 +1 -1
- package/scripts/coord-chat.mjs +148 -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,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
|
-
//
|
|
203
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
282
|
-
//
|
|
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)
|
|
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
|
}
|