agent-coord-mcp 0.4.9 → 0.5.1
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/README.md +32 -13
- package/dist/server.js +11 -5
- package/dist/server.js.map +1 -1
- package/dist/store.js +79 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +241 -33
- package/dist/tools.js.map +1 -1
- package/hooks/peek-coord.mjs +66 -8
- package/hooks/tmux-pusher.mjs +81 -7
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +457 -61
- package/src/server.ts +58 -4
- package/src/store.ts +90 -1
- package/src/tools.ts +269 -32
package/scripts/coord-chat.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import lockfile from "proper-lockfile";
|
|
|
41
41
|
// ---------- args ----------
|
|
42
42
|
|
|
43
43
|
const args = parseArgs(process.argv.slice(2));
|
|
44
|
-
|
|
44
|
+
let ID = args.id ?? process.env.USER ?? "human"; // reassignable so /nick can rebind it
|
|
45
45
|
const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
|
|
46
46
|
|
|
47
47
|
// Message-rendering state + helpers. Declared up here (above the top-level
|
|
@@ -61,7 +61,7 @@ let hintActive = false;
|
|
|
61
61
|
|
|
62
62
|
// Matches "@<this agent>" not followed by a name char, so we can flag messages
|
|
63
63
|
// that ping the current user. ID may contain regex metachars — escape it.
|
|
64
|
-
|
|
64
|
+
let SELF_MENTION_RE = new RegExp(
|
|
65
65
|
"@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
|
|
66
66
|
);
|
|
67
67
|
const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
|
|
@@ -81,11 +81,81 @@ const CURSOR_DIR = path.join(ROOT, "cursors");
|
|
|
81
81
|
const TRANSPORT_DIR = path.join(ROOT, "transports");
|
|
82
82
|
const AGENTS_FILE = path.join(ROOT, "agents.json");
|
|
83
83
|
const ROOM_FILE = path.join(ROOT, "room.jsonl");
|
|
84
|
-
const
|
|
85
|
-
const
|
|
84
|
+
const ROOMS_DIR = path.join(ROOT, "rooms");
|
|
85
|
+
const ROOMS_FILE = path.join(ROOT, "rooms.json");
|
|
86
|
+
let INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
87
|
+
let CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
86
88
|
|
|
87
89
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
88
90
|
mkdirSync(CURSOR_DIR, { recursive: true });
|
|
91
|
+
mkdirSync(ROOMS_DIR, { recursive: true });
|
|
92
|
+
|
|
93
|
+
// ---------- channels ----------
|
|
94
|
+
// Mirrors src/store.ts: `general` keeps using room.jsonl + the flat roomOffset
|
|
95
|
+
// cursor key (hook/back-compat); other channels live in rooms/<chan>.jsonl with
|
|
96
|
+
// their offset under cursor.roomOffsets[chan]. currentRoom is the focused
|
|
97
|
+
// channel that plain text posts to; we tail every channel we've joined.
|
|
98
|
+
const DEFAULT_ROOM = "general";
|
|
99
|
+
let currentRoom = DEFAULT_ROOM;
|
|
100
|
+
const ignored = new Set(); // session-local /ignore — agentIds whose msgs we hide
|
|
101
|
+
|
|
102
|
+
function normalizeRoom(name) {
|
|
103
|
+
if (!name) return DEFAULT_ROOM;
|
|
104
|
+
const n = String(name).trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, "");
|
|
105
|
+
return n || DEFAULT_ROOM;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function roomFile(chan) {
|
|
109
|
+
const c = normalizeRoom(chan);
|
|
110
|
+
return c === DEFAULT_ROOM ? ROOM_FILE : path.join(ROOMS_DIR, `${sanitize(c)}.jsonl`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getRoomOffset(cursor, chan) {
|
|
114
|
+
const c = normalizeRoom(chan);
|
|
115
|
+
return c === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[c] ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function setRoomOffset(cursor, chan, n) {
|
|
119
|
+
const c = normalizeRoom(chan);
|
|
120
|
+
if (c === DEFAULT_ROOM) cursor.roomOffset = n;
|
|
121
|
+
else (cursor.roomOffsets ??= {})[c] = n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getRooms() {
|
|
125
|
+
const reg = readJsonSafe(ROOMS_FILE, {});
|
|
126
|
+
if (!reg[DEFAULT_ROOM]) reg[DEFAULT_ROOM] = { createdAt: 0, createdBy: "system", members: [] };
|
|
127
|
+
return reg;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Channels this agent has joined (always includes the default channel).
|
|
131
|
+
function joinedRooms() {
|
|
132
|
+
const reg = getRooms();
|
|
133
|
+
const out = new Set([DEFAULT_ROOM]);
|
|
134
|
+
for (const [chan, e] of Object.entries(reg)) {
|
|
135
|
+
if (e.members?.includes(ID)) out.add(chan);
|
|
136
|
+
}
|
|
137
|
+
return [...out];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function updateRooms(mutate) {
|
|
141
|
+
await withLock(ROOMS_FILE, async () => {
|
|
142
|
+
const reg = readJsonSafe(ROOMS_FILE, {});
|
|
143
|
+
mutate(reg);
|
|
144
|
+
writeJsonAtomic(ROOMS_FILE, reg);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const watchedRooms = new Set();
|
|
149
|
+
function watchRoom(chan) {
|
|
150
|
+
const f = roomFile(chan);
|
|
151
|
+
if (watchedRooms.has(f)) return;
|
|
152
|
+
try {
|
|
153
|
+
watch(f, () => void drainAndPrint());
|
|
154
|
+
watchedRooms.add(f);
|
|
155
|
+
} catch {
|
|
156
|
+
// file may not exist yet; the 1s interval drain covers it until it does
|
|
157
|
+
}
|
|
158
|
+
}
|
|
89
159
|
|
|
90
160
|
// ---------- ANSI helpers ----------
|
|
91
161
|
|
|
@@ -146,8 +216,10 @@ if (TTY) {
|
|
|
146
216
|
}
|
|
147
217
|
|
|
148
218
|
const SLASH_COMMANDS = [
|
|
149
|
-
"/dm", "/
|
|
150
|
-
"/me", "/status", "/
|
|
219
|
+
"/dm", "/msg", "/list", "/who", "/whoami", "/whois", "/last", "/find",
|
|
220
|
+
"/clear", "/cls", "/me", "/status", "/away", "/back", "/ignore", "/unignore",
|
|
221
|
+
"/nick", "/join", "/part", "/leave", "/rooms", "/channels", "/topic", "/motd",
|
|
222
|
+
"/rules", "/prune", "/kick", "/wipe-room",
|
|
151
223
|
"/help", "/?", "/quit", "/exit",
|
|
152
224
|
];
|
|
153
225
|
|
|
@@ -175,8 +247,20 @@ function completer(line) {
|
|
|
175
247
|
const partial = line.slice(4);
|
|
176
248
|
const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
|
|
177
249
|
hits = ids.map((id) => `/dm ${id} `);
|
|
178
|
-
} else if (line
|
|
179
|
-
|
|
250
|
+
} else if (/^\/whois\s/.test(line)) {
|
|
251
|
+
const partial = line.replace(/^\/whois\s+/, "");
|
|
252
|
+
hits = onlineAgentIds().filter((id) => id.startsWith(partial)).map((id) => `/whois ${id} `);
|
|
253
|
+
} else {
|
|
254
|
+
// Channel-name completion for the channel-taking commands.
|
|
255
|
+
const cm = line.match(/^\/(join|part|leave|msg)\s+#?(\S*)$/);
|
|
256
|
+
if (cm) {
|
|
257
|
+
const cmd = cm[1];
|
|
258
|
+
const partial = cm[2];
|
|
259
|
+
const chans = Object.keys(getRooms()).filter((c) => c.startsWith(partial));
|
|
260
|
+
hits = chans.map((c) => `/${cmd} #${c} `);
|
|
261
|
+
} else if (line.startsWith("/")) {
|
|
262
|
+
hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
|
|
263
|
+
}
|
|
180
264
|
}
|
|
181
265
|
if (hits.length > 1) {
|
|
182
266
|
// Show the options as an ephemeral hint and hand readline only the common
|
|
@@ -230,6 +314,14 @@ printBanner();
|
|
|
230
314
|
fastForwardCursors();
|
|
231
315
|
await printRecent(3);
|
|
232
316
|
|
|
317
|
+
// Surface the focused channel's topic + MOTD (room rules) on launch — same
|
|
318
|
+
// banner /join shows — so the rules are seen on connect, not just on switch.
|
|
319
|
+
// Skip the bare header when neither is set, to avoid noise.
|
|
320
|
+
{
|
|
321
|
+
const e = getRooms()[normalizeRoom(currentRoom)];
|
|
322
|
+
if (e?.topic || e?.motd) showRoomBanner(currentRoom);
|
|
323
|
+
}
|
|
324
|
+
|
|
233
325
|
// Lay down the first separator. From this point, async incoming messages
|
|
234
326
|
// (via the watcher → drainAndPrint → say) know they can use the cursor
|
|
235
327
|
// games to slot themselves above the prompt.
|
|
@@ -237,21 +329,32 @@ process.stdout.write(sepLine() + "\n");
|
|
|
237
329
|
lastLineWasSep = true;
|
|
238
330
|
|
|
239
331
|
try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
|
|
240
|
-
|
|
332
|
+
for (const chan of joinedRooms()) watchRoom(chan);
|
|
241
333
|
try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
|
|
242
334
|
setInterval(() => void drainAndPrint(), 1000);
|
|
243
335
|
setInterval(refreshPrompt, 5000);
|
|
244
336
|
|
|
245
337
|
rl.prompt();
|
|
246
338
|
|
|
247
|
-
|
|
339
|
+
// Serialize line handling: readline fires 'line' events back-to-back for
|
|
340
|
+
// pasted/piped input, and our handlers are async (channel switches, file RMW).
|
|
341
|
+
// Chaining them guarantees e.g. "/join #x" fully completes — currentRoom set —
|
|
342
|
+
// before the next line posts, so a message can't leak into the old channel.
|
|
343
|
+
let lineChain = Promise.resolve();
|
|
344
|
+
rl.on("line", (line) => {
|
|
345
|
+
lineChain = lineChain.then(() => handleLine(line)).catch(() => {});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
async function handleLine(line) {
|
|
248
349
|
const text = line.trim();
|
|
249
350
|
if (!text) return rl.prompt();
|
|
250
351
|
// The user's typed-and-submitted line is now in scrollback; it is NOT a
|
|
251
352
|
// separator slot we own. Sync output from commands should write naturally.
|
|
252
353
|
lastLineWasSep = false;
|
|
253
354
|
try {
|
|
254
|
-
if (text === "/quit" || text === "/exit") {
|
|
355
|
+
if (text === "/quit" || text === "/exit" || text.startsWith("/quit ") || text.startsWith("/exit ")) {
|
|
356
|
+
const msg = text.replace(/^\/(quit|exit)\s*/, "").trim();
|
|
357
|
+
for (const chan of joinedRooms()) await sendSystem(chan, msg ? `has quit (${msg})` : "has quit");
|
|
255
358
|
await unregister();
|
|
256
359
|
teardownFooter();
|
|
257
360
|
process.stdout.write(A.dim("bye.\n"));
|
|
@@ -260,6 +363,36 @@ rl.on("line", async (line) => {
|
|
|
260
363
|
printHelp();
|
|
261
364
|
} else if (text === "/list" || text === "/who") {
|
|
262
365
|
await printAgents();
|
|
366
|
+
} else if (text === "/rooms" || text === "/channels") {
|
|
367
|
+
listRooms();
|
|
368
|
+
} else if (text.startsWith("/join ") || text === "/join") {
|
|
369
|
+
const chan = text.slice(5).trim();
|
|
370
|
+
if (!chan) say(A.red("usage: /join <#channel>"));
|
|
371
|
+
else await joinRoom(chan);
|
|
372
|
+
} else if (text === "/part" || text.startsWith("/part ") || text === "/leave" || text.startsWith("/leave ")) {
|
|
373
|
+
await partRoom(text.replace(/^\/(part|leave)\s*/, "").trim());
|
|
374
|
+
} else if (text === "/topic" || text.startsWith("/topic ")) {
|
|
375
|
+
await setTopic(text.slice(6).trim());
|
|
376
|
+
} else if (text === "/motd" || text.startsWith("/motd ") || text === "/rules" || text.startsWith("/rules ")) {
|
|
377
|
+
await setMotd(text.replace(/^\/(motd|rules)\s*/, "").trim());
|
|
378
|
+
} else if (text.startsWith("/msg ")) {
|
|
379
|
+
const m = text.match(/^\/msg\s+(\S+)\s+([\s\S]+)$/);
|
|
380
|
+
if (!m) say(A.red("usage: /msg <#channel> <text>"));
|
|
381
|
+
else { await sendRoom(m[2], m[1]); say(A.dim(`→ sent to #${normalizeRoom(m[1])}`)); }
|
|
382
|
+
} else if (text.startsWith("/whois ")) {
|
|
383
|
+
const target = text.slice(7).trim();
|
|
384
|
+
if (!target) say(A.red("usage: /whois <agent>"));
|
|
385
|
+
else whois(target);
|
|
386
|
+
} else if (text === "/away" || text.startsWith("/away ")) {
|
|
387
|
+
await setAway(text.slice(5).trim());
|
|
388
|
+
} else if (text === "/back") {
|
|
389
|
+
await setBack();
|
|
390
|
+
} else if (text.startsWith("/ignore")) {
|
|
391
|
+
ignoreAgent(text.slice(7).trim());
|
|
392
|
+
} else if (text.startsWith("/unignore")) {
|
|
393
|
+
unignoreAgent(text.slice(9).trim());
|
|
394
|
+
} else if (text === "/nick" || text.startsWith("/nick ")) {
|
|
395
|
+
await nick(text.slice(5).trim());
|
|
263
396
|
} else if (text === "/whoami") {
|
|
264
397
|
await printWhoami();
|
|
265
398
|
} else if (text === "/clear" || text === "/cls") {
|
|
@@ -311,7 +444,7 @@ rl.on("line", async (line) => {
|
|
|
311
444
|
process.stdout.write(sepLine() + "\n");
|
|
312
445
|
lastLineWasSep = true;
|
|
313
446
|
rl.prompt();
|
|
314
|
-
}
|
|
447
|
+
}
|
|
315
448
|
|
|
316
449
|
process.on("SIGINT", async () => {
|
|
317
450
|
try { await unregister(); } catch {}
|
|
@@ -427,7 +560,8 @@ function makePrompt() {
|
|
|
427
560
|
if (live || now - a.lastHeartbeat < STALE) online++;
|
|
428
561
|
}
|
|
429
562
|
const peers = online === 1 ? "1 peer" : `${online} peers`;
|
|
430
|
-
|
|
563
|
+
const chan = A.dim("#") + normalizeRoom(currentRoom);
|
|
564
|
+
return `${agentColor(ID)(ID)} ${agentColor(currentRoom)(chan)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
|
|
431
565
|
}
|
|
432
566
|
|
|
433
567
|
// No-op stubs kept so the exit paths don't reference deleted functions.
|
|
@@ -452,22 +586,35 @@ function printBanner() {
|
|
|
452
586
|
|
|
453
587
|
function printHelp() {
|
|
454
588
|
const rows = [
|
|
455
|
-
["<text>", "post to the
|
|
589
|
+
["<text>", "post to the current channel"],
|
|
456
590
|
["/dm <agent> <text>", "send a direct message"],
|
|
591
|
+
["/msg <#chan> <text>", "post to a channel without switching to it"],
|
|
457
592
|
["/me <action>", "post an IRC-style action (* you wave)"],
|
|
458
593
|
["/status <text>", "post to the status broadcast channel"],
|
|
594
|
+
[A.dim("--- channels ---"), ""],
|
|
595
|
+
["/join <#chan>", "join (and switch to) a channel, creating it if new"],
|
|
596
|
+
["/part [#chan]", "leave the current (or named) channel"],
|
|
597
|
+
["/rooms", "list all channels (topic + members)"],
|
|
598
|
+
["/topic [text]", "show or set the current channel's topic"],
|
|
599
|
+
["/motd [text]", "show or set the channel rules (MOTD)"],
|
|
600
|
+
[A.dim("--- people ---"), ""],
|
|
459
601
|
["/list, /who", "show registered agents + transports"],
|
|
602
|
+
["/whois <agent>", "show an agent's detail (role, channels, status)"],
|
|
460
603
|
["/whoami", "show your registration + transport"],
|
|
604
|
+
["/nick <name>", "rename yourself (migrates inbox/history)"],
|
|
605
|
+
["/away [msg], /back", "set or clear your away status"],
|
|
606
|
+
["/ignore <agent>", "mute an agent for this session (/unignore to undo)"],
|
|
607
|
+
[A.dim("--- history ---"), ""],
|
|
461
608
|
["/last [n]", "show last n messages (default 20)"],
|
|
462
|
-
["/find <text>", "search recent inbox +
|
|
609
|
+
["/find <text>", "search recent inbox + channel history"],
|
|
463
610
|
["/clear", "clear the screen"],
|
|
464
611
|
[A.dim("--- admin ---"), ""],
|
|
465
612
|
["/prune [days]", "drop messages older than N days (default 7)"],
|
|
466
613
|
["/kick <agent>", "unregister an agent + kill their pusher"],
|
|
467
|
-
["/wipe-room", "truncate the
|
|
614
|
+
["/wipe-room", "truncate the current channel (destructive)"],
|
|
468
615
|
[A.dim("---"), ""],
|
|
469
616
|
["/help, /?", "this list"],
|
|
470
|
-
["/quit, /exit",
|
|
617
|
+
["/quit [msg], /exit", "unregister and leave"],
|
|
471
618
|
];
|
|
472
619
|
say(A.bold("commands:"));
|
|
473
620
|
for (const [cmd, desc] of rows) {
|
|
@@ -495,14 +642,19 @@ function fastForwardCursors() {
|
|
|
495
642
|
// new messages going forward.
|
|
496
643
|
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
497
644
|
cur.inboxOffset = readJsonl(INBOX_FILE).length;
|
|
498
|
-
cur
|
|
645
|
+
for (const chan of joinedRooms()) setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
499
646
|
writeJsonAtomic(CURSOR_FILE, cur);
|
|
500
647
|
}
|
|
501
648
|
|
|
502
649
|
async function printRecent(n) {
|
|
503
650
|
const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
|
|
504
|
-
|
|
505
|
-
const
|
|
651
|
+
let rooms = [];
|
|
652
|
+
for (const chan of joinedRooms()) {
|
|
653
|
+
rooms = rooms.concat(
|
|
654
|
+
readJsonl(roomFile(chan)).slice(-n).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const all = [...inbox, ...rooms].sort((a, b) => a.ts - b.ts).slice(-n);
|
|
506
658
|
if (!all.length) return say(A.dim("(no history)"));
|
|
507
659
|
say(A.bold(`last ${all.length} message(s):`));
|
|
508
660
|
for (const m of all) printMsg(m._kind, m, { history: true });
|
|
@@ -534,9 +686,15 @@ async function register() {
|
|
|
534
686
|
role: existing?.role ?? "human",
|
|
535
687
|
registeredAt: existing?.registeredAt ?? now,
|
|
536
688
|
lastHeartbeat: now,
|
|
689
|
+
away: existing?.away,
|
|
537
690
|
};
|
|
538
691
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
539
692
|
});
|
|
693
|
+
// Record default-channel membership so /rooms + the hooks see us there.
|
|
694
|
+
await updateRooms((reg) => {
|
|
695
|
+
const e = (reg[DEFAULT_ROOM] ??= { createdAt: 0, createdBy: "system", members: [] });
|
|
696
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
697
|
+
});
|
|
540
698
|
}
|
|
541
699
|
|
|
542
700
|
async function unregister() {
|
|
@@ -545,6 +703,11 @@ async function unregister() {
|
|
|
545
703
|
delete reg[ID];
|
|
546
704
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
547
705
|
});
|
|
706
|
+
await updateRooms((reg) => {
|
|
707
|
+
for (const e of Object.values(reg)) {
|
|
708
|
+
if (e.members?.includes(ID)) e.members = e.members.filter((m) => m !== ID);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
548
711
|
}
|
|
549
712
|
|
|
550
713
|
async function sendDm(to, text) {
|
|
@@ -563,18 +726,17 @@ async function postStatus(status) {
|
|
|
563
726
|
async function pruneOld(days) {
|
|
564
727
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
565
728
|
let total = 0;
|
|
566
|
-
// Per-
|
|
567
|
-
// offsets — without this, every other agent's
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
729
|
+
// Per-channel removal counts so we can shift the corresponding cursor
|
|
730
|
+
// offsets — without this, every other agent's offsets would point past the
|
|
731
|
+
// now-shorter file and they'd silently miss messages until enough new ones
|
|
732
|
+
// piled up to overtake the stale offset.
|
|
733
|
+
const roomRemovedByChan = {};
|
|
571
734
|
let statusRemoved = 0;
|
|
572
735
|
const inboxRemoved = {}; // agentId → count
|
|
573
736
|
|
|
574
|
-
const files = [
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
];
|
|
737
|
+
const files = [];
|
|
738
|
+
for (const chan of Object.keys(getRooms())) files.push({ path: roomFile(chan), kind: "room", chan });
|
|
739
|
+
files.push({ path: STATUS_FILE_PATH, kind: "status" });
|
|
578
740
|
if (existsSync(INBOX_DIR)) {
|
|
579
741
|
for (const n of readdirSync(INBOX_DIR)) {
|
|
580
742
|
if (n.endsWith(".jsonl")) {
|
|
@@ -591,29 +753,32 @@ async function pruneOld(days) {
|
|
|
591
753
|
const body = kept.length ? kept.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
592
754
|
writeFileSync(f.path, body);
|
|
593
755
|
total += removed;
|
|
594
|
-
if (f.kind === "room")
|
|
595
|
-
|
|
756
|
+
if (f.kind === "room") {
|
|
757
|
+
const c = normalizeRoom(f.chan);
|
|
758
|
+
roomRemovedByChan[c] = (roomRemovedByChan[c] ?? 0) + removed;
|
|
759
|
+
} else if (f.kind === "status") statusRemoved += removed;
|
|
596
760
|
else inboxRemoved[f.agentId] = (inboxRemoved[f.agentId] ?? 0) + removed;
|
|
597
761
|
}
|
|
598
762
|
}
|
|
599
|
-
if (
|
|
600
|
-
shiftAllCursors({
|
|
763
|
+
if (Object.keys(roomRemovedByChan).length || statusRemoved || Object.keys(inboxRemoved).length) {
|
|
764
|
+
shiftAllCursors({ roomRemovedByChan, statusRemoved, inboxRemoved });
|
|
601
765
|
}
|
|
602
766
|
say(A.dim(`→ pruned ${total} entries older than ${days}d (cursors adjusted)`));
|
|
603
767
|
}
|
|
604
768
|
|
|
605
769
|
async function wipeRoom() {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
770
|
+
const chan = normalizeRoom(currentRoom);
|
|
771
|
+
await ensureFile(roomFile(chan));
|
|
772
|
+
writeFileSync(roomFile(chan), "");
|
|
773
|
+
// Reset every agent's offset for this channel so they re-read from the start
|
|
774
|
+
// of the (now empty) file rather than pointing past EOF.
|
|
775
|
+
resetRoomOffsets(chan);
|
|
776
|
+
say(A.dim(`→ #${chan} wiped (channel cursors reset)`));
|
|
612
777
|
}
|
|
613
778
|
|
|
614
779
|
// Walk every cursor file and shift offsets down by the per-channel removed
|
|
615
780
|
// counts. Mirrors what the MCP prune tool does server-side.
|
|
616
|
-
function shiftAllCursors({
|
|
781
|
+
function shiftAllCursors({ roomRemovedByChan = {}, statusRemoved = 0, inboxRemoved = {} }) {
|
|
617
782
|
if (!existsSync(CURSOR_DIR)) return;
|
|
618
783
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
619
784
|
if (!name.endsWith(".json")) continue;
|
|
@@ -621,9 +786,13 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
621
786
|
const cur = readJsonSafe(cursorPath, {});
|
|
622
787
|
const id = name.replace(/\.json$/, "");
|
|
623
788
|
let touched = false;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
789
|
+
for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
|
|
790
|
+
if (removed <= 0) continue;
|
|
791
|
+
const has = chan === DEFAULT_ROOM ? cur.roomOffset !== undefined : cur.roomOffsets?.[chan] !== undefined;
|
|
792
|
+
if (has) {
|
|
793
|
+
setRoomOffset(cur, chan, Math.max(0, getRoomOffset(cur, chan) - removed));
|
|
794
|
+
touched = true;
|
|
795
|
+
}
|
|
627
796
|
}
|
|
628
797
|
if (cur.statusOffset !== undefined && statusRemoved > 0) {
|
|
629
798
|
cur.statusOffset = Math.max(0, cur.statusOffset - statusRemoved);
|
|
@@ -638,14 +807,15 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
638
807
|
}
|
|
639
808
|
}
|
|
640
809
|
|
|
641
|
-
function
|
|
810
|
+
function resetRoomOffsets(chan) {
|
|
642
811
|
if (!existsSync(CURSOR_DIR)) return;
|
|
812
|
+
const c = normalizeRoom(chan);
|
|
643
813
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
644
814
|
if (!name.endsWith(".json")) continue;
|
|
645
815
|
const cursorPath = path.join(CURSOR_DIR, name);
|
|
646
816
|
const cur = readJsonSafe(cursorPath, {});
|
|
647
|
-
if (cur
|
|
648
|
-
cur
|
|
817
|
+
if (getRoomOffset(cur, c) !== 0) {
|
|
818
|
+
setRoomOffset(cur, c, 0);
|
|
649
819
|
writeJsonAtomic(cursorPath, cur);
|
|
650
820
|
}
|
|
651
821
|
}
|
|
@@ -686,8 +856,11 @@ async function kickAgent(target) {
|
|
|
686
856
|
async function findInHistory(term) {
|
|
687
857
|
const t = term.toLowerCase();
|
|
688
858
|
const inbox = readJsonl(INBOX_FILE).map((m) => ({ ...m, _kind: "DM" }));
|
|
689
|
-
|
|
690
|
-
const
|
|
859
|
+
let rooms = [];
|
|
860
|
+
for (const chan of joinedRooms()) {
|
|
861
|
+
rooms = rooms.concat(readJsonl(roomFile(chan)).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })));
|
|
862
|
+
}
|
|
863
|
+
const matches = [...inbox, ...rooms]
|
|
691
864
|
.filter((m) => (m.text ?? "").toLowerCase().includes(t))
|
|
692
865
|
.sort((a, b) => a.ts - b.ts);
|
|
693
866
|
if (!matches.length) return say(A.dim(`(no matches for "${term}")`));
|
|
@@ -695,8 +868,214 @@ async function findInHistory(term) {
|
|
|
695
868
|
for (const m of matches.slice(-20)) printMsg(m._kind, m, { history: true });
|
|
696
869
|
}
|
|
697
870
|
|
|
698
|
-
|
|
699
|
-
|
|
871
|
+
// ---------- channel commands ----------
|
|
872
|
+
|
|
873
|
+
function showRoomBanner(chan) {
|
|
874
|
+
const c = normalizeRoom(chan);
|
|
875
|
+
const e = getRooms()[c];
|
|
876
|
+
say(A.bold(agentColor(c)(`#${c}`)) + (e?.topic ? A.dim(" — " + e.topic) : ""));
|
|
877
|
+
if (e?.motd) say(A.dim(" rules: ") + e.motd);
|
|
878
|
+
const members = e?.members ?? [];
|
|
879
|
+
if (members.length) say(A.dim(` members: ${members.join(", ")}`));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function joinRoom(arg) {
|
|
883
|
+
const chan = normalizeRoom(arg);
|
|
884
|
+
await updateRooms((reg) => {
|
|
885
|
+
const e = (reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] });
|
|
886
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
887
|
+
});
|
|
888
|
+
// Fast-forward this channel's offset so we don't replay its whole backlog.
|
|
889
|
+
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
890
|
+
setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
891
|
+
writeJsonAtomic(CURSOR_FILE, cur);
|
|
892
|
+
await sendSystem(chan, `joined #${chan}`);
|
|
893
|
+
currentRoom = chan;
|
|
894
|
+
watchRoom(chan);
|
|
895
|
+
refreshPrompt();
|
|
896
|
+
say(A.dim("→ now in ") + A.bold(agentColor(chan)(`#${chan}`)));
|
|
897
|
+
showRoomBanner(chan);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function partRoom(arg) {
|
|
901
|
+
const chan = normalizeRoom(arg || currentRoom);
|
|
902
|
+
if (chan === DEFAULT_ROOM) return say(A.red("cannot leave #general"));
|
|
903
|
+
if (!joinedRooms().includes(chan)) return say(A.red(`not in #${chan}`));
|
|
904
|
+
await sendSystem(chan, `left #${chan}`);
|
|
905
|
+
await updateRooms((reg) => {
|
|
906
|
+
if (reg[chan]) reg[chan].members = (reg[chan].members ?? []).filter((m) => m !== ID);
|
|
907
|
+
});
|
|
908
|
+
if (normalizeRoom(currentRoom) === chan) {
|
|
909
|
+
currentRoom = DEFAULT_ROOM;
|
|
910
|
+
refreshPrompt();
|
|
911
|
+
}
|
|
912
|
+
say(A.dim("→ left ") + A.bold(`#${chan}`));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function listRooms() {
|
|
916
|
+
const reg = getRooms();
|
|
917
|
+
const joined = new Set(joinedRooms());
|
|
918
|
+
const names = Object.keys(reg).sort();
|
|
919
|
+
say(A.bold(`channels (${names.length}):`));
|
|
920
|
+
for (const c of names) {
|
|
921
|
+
const e = reg[c];
|
|
922
|
+
const here =
|
|
923
|
+
normalizeRoom(currentRoom) === c ? A.green("*") : joined.has(c) ? A.dim("·") : " ";
|
|
924
|
+
const count = readJsonl(roomFile(c)).length;
|
|
925
|
+
const topic = e.topic ? A.dim(" — " + e.topic) : "";
|
|
926
|
+
say(` ${here} ${agentColor(c)(("#" + c).padEnd(16))} ${A.dim(`${(e.members ?? []).length} member(s), ${count} msg`)}${topic}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function setTopic(arg) {
|
|
931
|
+
const chan = normalizeRoom(currentRoom);
|
|
932
|
+
if (!arg) {
|
|
933
|
+
const e = getRooms()[chan];
|
|
934
|
+
return say(e?.topic ? A.bold(`#${chan} topic: `) + e.topic : A.dim(`#${chan} has no topic`));
|
|
935
|
+
}
|
|
936
|
+
await updateRooms((reg) => {
|
|
937
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).topic = arg;
|
|
938
|
+
});
|
|
939
|
+
await sendSystem(chan, `changed topic to: ${arg}`);
|
|
940
|
+
say(A.dim(`→ topic set for #${chan}`));
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function setMotd(arg) {
|
|
944
|
+
const chan = normalizeRoom(currentRoom);
|
|
945
|
+
if (!arg) {
|
|
946
|
+
const e = getRooms()[chan];
|
|
947
|
+
return say(e?.motd ? A.bold(`#${chan} rules: `) + e.motd : A.dim(`#${chan} has no rules (MOTD)`));
|
|
948
|
+
}
|
|
949
|
+
await updateRooms((reg) => {
|
|
950
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).motd = arg;
|
|
951
|
+
});
|
|
952
|
+
await sendSystem(chan, `updated the room rules (MOTD)`);
|
|
953
|
+
say(A.dim(`→ rules (MOTD) set for #${chan}`));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ---------- phase-1 parity commands ----------
|
|
957
|
+
|
|
958
|
+
function whois(target) {
|
|
959
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
960
|
+
const a = reg[target];
|
|
961
|
+
if (!a) return say(A.red(`no such agent: ${target}`));
|
|
962
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(target)}.json`), null);
|
|
963
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
964
|
+
const online = live || Date.now() - a.lastHeartbeat < 5 * 60 * 1000;
|
|
965
|
+
const rooms = Object.entries(getRooms())
|
|
966
|
+
.filter(([, e]) => e.members?.includes(target))
|
|
967
|
+
.map(([c]) => `#${c}`);
|
|
968
|
+
say(A.bold("whois ") + agentColor(target)(target) + ":");
|
|
969
|
+
say(` ${A.cyan("status")} ${online ? A.green("online") : A.dim("offline")}${a.away ? A.yellow(` (away: ${a.away})`) : ""}`);
|
|
970
|
+
say(` ${A.cyan("role")} ${a.role ?? "-"}`);
|
|
971
|
+
say(` ${A.cyan("seen")} ${A.dim(relTime(a.lastHeartbeat))}`);
|
|
972
|
+
say(` ${A.cyan("channels")} ${rooms.length ? rooms.join(" ") : A.dim("(general)")}`);
|
|
973
|
+
say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function setAway(msg) {
|
|
977
|
+
await withLock(AGENTS_FILE, async () => {
|
|
978
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
979
|
+
if (reg[ID]) {
|
|
980
|
+
reg[ID].away = msg || "away";
|
|
981
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
say(A.dim(`→ marked away${msg ? ": " + msg : ""}`));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function setBack() {
|
|
988
|
+
await withLock(AGENTS_FILE, async () => {
|
|
989
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
990
|
+
if (reg[ID]) {
|
|
991
|
+
delete reg[ID].away;
|
|
992
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
say(A.dim("→ welcome back (away cleared)"));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function ignoreAgent(target) {
|
|
999
|
+
if (!target) return say(A.red("usage: /ignore <agent>"));
|
|
1000
|
+
ignored.add(target);
|
|
1001
|
+
say(A.dim(`→ ignoring ${target} (this session)`));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function unignoreAgent(target) {
|
|
1005
|
+
if (target) {
|
|
1006
|
+
ignored.delete(target);
|
|
1007
|
+
say(A.dim(`→ no longer ignoring ${target}`));
|
|
1008
|
+
} else {
|
|
1009
|
+
ignored.clear();
|
|
1010
|
+
say(A.dim("→ cleared ignore list"));
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function moveFileSync(from, to) {
|
|
1015
|
+
if (!existsSync(from) || from === to) return;
|
|
1016
|
+
try {
|
|
1017
|
+
renameSync(from, to);
|
|
1018
|
+
} catch {
|
|
1019
|
+
try {
|
|
1020
|
+
writeFileSync(to, readFileSync(from));
|
|
1021
|
+
unlinkSync(from);
|
|
1022
|
+
} catch {
|
|
1023
|
+
/* best effort */
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Full rename (NICK): migrate registry, channel membership, inbox, cursor,
|
|
1029
|
+
// transport marker, and color, then rebind the in-session identity. Mirrors
|
|
1030
|
+
// the MCP rename_agent tool.
|
|
1031
|
+
async function nick(arg) {
|
|
1032
|
+
const oldId = ID;
|
|
1033
|
+
const newId = (arg || "").trim();
|
|
1034
|
+
if (!newId || newId === oldId) return say(A.red("usage: /nick <newname>"));
|
|
1035
|
+
if (readJsonSafe(AGENTS_FILE, {})[newId]) return say(A.red(`'${newId}' already exists`));
|
|
1036
|
+
const joined = joinedRooms();
|
|
1037
|
+
|
|
1038
|
+
await withLock(AGENTS_FILE, async () => {
|
|
1039
|
+
const r = readJsonSafe(AGENTS_FILE, {});
|
|
1040
|
+
if (r[oldId]) {
|
|
1041
|
+
r[newId] = { ...r[oldId], agentId: newId };
|
|
1042
|
+
delete r[oldId];
|
|
1043
|
+
}
|
|
1044
|
+
writeJsonAtomic(AGENTS_FILE, r);
|
|
1045
|
+
});
|
|
1046
|
+
await updateRooms((r) => {
|
|
1047
|
+
for (const e of Object.values(r)) {
|
|
1048
|
+
if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m));
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
moveFileSync(path.join(INBOX_DIR, `${sanitize(oldId)}.jsonl`), path.join(INBOX_DIR, `${sanitize(newId)}.jsonl`));
|
|
1052
|
+
moveFileSync(path.join(CURSOR_DIR, `${sanitize(oldId)}.json`), path.join(CURSOR_DIR, `${sanitize(newId)}.json`));
|
|
1053
|
+
moveFileSync(path.join(TRANSPORT_DIR, `${sanitize(oldId)}.json`), path.join(TRANSPORT_DIR, `${sanitize(newId)}.json`));
|
|
1054
|
+
const cmap = readJsonSafe(COLOR_MAP_FILE, {});
|
|
1055
|
+
if (cmap[oldId] !== undefined && cmap[newId] === undefined) {
|
|
1056
|
+
cmap[newId] = cmap[oldId];
|
|
1057
|
+
writeJsonAtomic(COLOR_MAP_FILE, cmap);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Rebind in-session identity, then broadcast under the new name.
|
|
1061
|
+
ID = newId;
|
|
1062
|
+
INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
1063
|
+
CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
1064
|
+
SELF_MENTION_RE = new RegExp("@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])");
|
|
1065
|
+
for (const chan of joined) await sendSystem(chan, `is now known as ${newId} (was ${oldId})`);
|
|
1066
|
+
refreshPrompt();
|
|
1067
|
+
say(A.dim("→ you are now ") + agentColor(ID)(ID));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function sendRoom(text, chan = currentRoom) {
|
|
1071
|
+
const c = normalizeRoom(chan);
|
|
1072
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text });
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Post a system notice (join/part/topic/nick) to a channel.
|
|
1076
|
+
async function sendSystem(chan, text) {
|
|
1077
|
+
const c = normalizeRoom(chan);
|
|
1078
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text, system: true });
|
|
700
1079
|
}
|
|
701
1080
|
|
|
702
1081
|
async function appendMessage(file, partial) {
|
|
@@ -713,22 +1092,25 @@ async function drainAndPrint() {
|
|
|
713
1092
|
const inboxOff = cursor.inboxOffset ?? 0;
|
|
714
1093
|
for (let i = inboxOff; i < inboxAll.length; i++) {
|
|
715
1094
|
const m = inboxAll[i];
|
|
716
|
-
if (m && m.from !== ID) printMsg("DM", m);
|
|
1095
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("DM", m);
|
|
717
1096
|
}
|
|
718
1097
|
if (inboxAll.length > inboxOff) {
|
|
719
1098
|
cursor.inboxOffset = inboxAll.length;
|
|
720
1099
|
changed = true;
|
|
721
1100
|
}
|
|
722
1101
|
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1102
|
+
// Drain every joined channel against its own per-channel offset.
|
|
1103
|
+
for (const chan of joinedRooms()) {
|
|
1104
|
+
const all = readJsonl(roomFile(chan));
|
|
1105
|
+
const off = getRoomOffset(cursor, chan);
|
|
1106
|
+
for (let i = off; i < all.length; i++) {
|
|
1107
|
+
const m = all[i];
|
|
1108
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("room", { ...m, room: m.room ?? chan });
|
|
1109
|
+
}
|
|
1110
|
+
if (all.length > off) {
|
|
1111
|
+
setRoomOffset(cursor, chan, all.length);
|
|
1112
|
+
changed = true;
|
|
1113
|
+
}
|
|
732
1114
|
}
|
|
733
1115
|
|
|
734
1116
|
if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
|
|
@@ -740,6 +1122,20 @@ function printMsg(kind, m, opts = {}) {
|
|
|
740
1122
|
const gutter = color("▎");
|
|
741
1123
|
const prefix = opts.history ? A.dim(" ") : "";
|
|
742
1124
|
|
|
1125
|
+
// A channel tag when the message isn't from the focused channel, so cross-
|
|
1126
|
+
// channel traffic stays legible without cluttering the common single-room case.
|
|
1127
|
+
const otherChan = kind === "room" && m.room && normalizeRoom(m.room) !== normalizeRoom(currentRoom);
|
|
1128
|
+
const chanTag = otherChan ? agentColor(m.room)(`#${normalizeRoom(m.room)}`) + " " : "";
|
|
1129
|
+
|
|
1130
|
+
// System notices (join/part/topic/nick) render as a dim italic one-liner.
|
|
1131
|
+
if (m.system) {
|
|
1132
|
+
const tag = chanTag ? `#${normalizeRoom(m.room)} ` : "";
|
|
1133
|
+
say("");
|
|
1134
|
+
say(`${prefix}\x1b[2;3m — ${tag}${who} ${m.text ?? ""} —\x1b[0m`);
|
|
1135
|
+
if (!opts.history) lastBlock = { who: null, ts: m.ts, kind: null };
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
743
1139
|
// Body wraps manually under a continuous gutter — terminal auto-wrap would
|
|
744
1140
|
// lose the colored gutter on continuation lines.
|
|
745
1141
|
const prefixW = visibleLength(prefix);
|
|
@@ -762,7 +1158,7 @@ function printMsg(kind, m, opts = {}) {
|
|
|
762
1158
|
const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
|
|
763
1159
|
const marker = pinged ? A.bold(A.yellow("► ")) : "";
|
|
764
1160
|
const headGutter = pinged ? A.bold(color("▌")) : gutter;
|
|
765
|
-
const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
1161
|
+
const header = `${marker}${badge}${chanTag}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
766
1162
|
say("");
|
|
767
1163
|
say(`${prefix}${headGutter} ${header}`);
|
|
768
1164
|
}
|