agent-coord-mcp 0.4.9 → 0.5.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/README.md +32 -13
- package/dist/server.js +10 -4
- package/dist/server.js.map +1 -1
- package/dist/store.js +79 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +236 -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 +449 -61
- package/src/server.ts +57 -3
- package/src/store.ts +90 -1
- package/src/tools.ts +263 -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
|
|
@@ -237,21 +321,32 @@ process.stdout.write(sepLine() + "\n");
|
|
|
237
321
|
lastLineWasSep = true;
|
|
238
322
|
|
|
239
323
|
try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
|
|
240
|
-
|
|
324
|
+
for (const chan of joinedRooms()) watchRoom(chan);
|
|
241
325
|
try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
|
|
242
326
|
setInterval(() => void drainAndPrint(), 1000);
|
|
243
327
|
setInterval(refreshPrompt, 5000);
|
|
244
328
|
|
|
245
329
|
rl.prompt();
|
|
246
330
|
|
|
247
|
-
|
|
331
|
+
// Serialize line handling: readline fires 'line' events back-to-back for
|
|
332
|
+
// pasted/piped input, and our handlers are async (channel switches, file RMW).
|
|
333
|
+
// Chaining them guarantees e.g. "/join #x" fully completes — currentRoom set —
|
|
334
|
+
// before the next line posts, so a message can't leak into the old channel.
|
|
335
|
+
let lineChain = Promise.resolve();
|
|
336
|
+
rl.on("line", (line) => {
|
|
337
|
+
lineChain = lineChain.then(() => handleLine(line)).catch(() => {});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
async function handleLine(line) {
|
|
248
341
|
const text = line.trim();
|
|
249
342
|
if (!text) return rl.prompt();
|
|
250
343
|
// The user's typed-and-submitted line is now in scrollback; it is NOT a
|
|
251
344
|
// separator slot we own. Sync output from commands should write naturally.
|
|
252
345
|
lastLineWasSep = false;
|
|
253
346
|
try {
|
|
254
|
-
if (text === "/quit" || text === "/exit") {
|
|
347
|
+
if (text === "/quit" || text === "/exit" || text.startsWith("/quit ") || text.startsWith("/exit ")) {
|
|
348
|
+
const msg = text.replace(/^\/(quit|exit)\s*/, "").trim();
|
|
349
|
+
for (const chan of joinedRooms()) await sendSystem(chan, msg ? `has quit (${msg})` : "has quit");
|
|
255
350
|
await unregister();
|
|
256
351
|
teardownFooter();
|
|
257
352
|
process.stdout.write(A.dim("bye.\n"));
|
|
@@ -260,6 +355,36 @@ rl.on("line", async (line) => {
|
|
|
260
355
|
printHelp();
|
|
261
356
|
} else if (text === "/list" || text === "/who") {
|
|
262
357
|
await printAgents();
|
|
358
|
+
} else if (text === "/rooms" || text === "/channels") {
|
|
359
|
+
listRooms();
|
|
360
|
+
} else if (text.startsWith("/join ") || text === "/join") {
|
|
361
|
+
const chan = text.slice(5).trim();
|
|
362
|
+
if (!chan) say(A.red("usage: /join <#channel>"));
|
|
363
|
+
else await joinRoom(chan);
|
|
364
|
+
} else if (text === "/part" || text.startsWith("/part ") || text === "/leave" || text.startsWith("/leave ")) {
|
|
365
|
+
await partRoom(text.replace(/^\/(part|leave)\s*/, "").trim());
|
|
366
|
+
} else if (text === "/topic" || text.startsWith("/topic ")) {
|
|
367
|
+
await setTopic(text.slice(6).trim());
|
|
368
|
+
} else if (text === "/motd" || text.startsWith("/motd ") || text === "/rules" || text.startsWith("/rules ")) {
|
|
369
|
+
await setMotd(text.replace(/^\/(motd|rules)\s*/, "").trim());
|
|
370
|
+
} else if (text.startsWith("/msg ")) {
|
|
371
|
+
const m = text.match(/^\/msg\s+(\S+)\s+([\s\S]+)$/);
|
|
372
|
+
if (!m) say(A.red("usage: /msg <#channel> <text>"));
|
|
373
|
+
else { await sendRoom(m[2], m[1]); say(A.dim(`→ sent to #${normalizeRoom(m[1])}`)); }
|
|
374
|
+
} else if (text.startsWith("/whois ")) {
|
|
375
|
+
const target = text.slice(7).trim();
|
|
376
|
+
if (!target) say(A.red("usage: /whois <agent>"));
|
|
377
|
+
else whois(target);
|
|
378
|
+
} else if (text === "/away" || text.startsWith("/away ")) {
|
|
379
|
+
await setAway(text.slice(5).trim());
|
|
380
|
+
} else if (text === "/back") {
|
|
381
|
+
await setBack();
|
|
382
|
+
} else if (text.startsWith("/ignore")) {
|
|
383
|
+
ignoreAgent(text.slice(7).trim());
|
|
384
|
+
} else if (text.startsWith("/unignore")) {
|
|
385
|
+
unignoreAgent(text.slice(9).trim());
|
|
386
|
+
} else if (text === "/nick" || text.startsWith("/nick ")) {
|
|
387
|
+
await nick(text.slice(5).trim());
|
|
263
388
|
} else if (text === "/whoami") {
|
|
264
389
|
await printWhoami();
|
|
265
390
|
} else if (text === "/clear" || text === "/cls") {
|
|
@@ -311,7 +436,7 @@ rl.on("line", async (line) => {
|
|
|
311
436
|
process.stdout.write(sepLine() + "\n");
|
|
312
437
|
lastLineWasSep = true;
|
|
313
438
|
rl.prompt();
|
|
314
|
-
}
|
|
439
|
+
}
|
|
315
440
|
|
|
316
441
|
process.on("SIGINT", async () => {
|
|
317
442
|
try { await unregister(); } catch {}
|
|
@@ -427,7 +552,8 @@ function makePrompt() {
|
|
|
427
552
|
if (live || now - a.lastHeartbeat < STALE) online++;
|
|
428
553
|
}
|
|
429
554
|
const peers = online === 1 ? "1 peer" : `${online} peers`;
|
|
430
|
-
|
|
555
|
+
const chan = A.dim("#") + normalizeRoom(currentRoom);
|
|
556
|
+
return `${agentColor(ID)(ID)} ${agentColor(currentRoom)(chan)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
|
|
431
557
|
}
|
|
432
558
|
|
|
433
559
|
// No-op stubs kept so the exit paths don't reference deleted functions.
|
|
@@ -452,22 +578,35 @@ function printBanner() {
|
|
|
452
578
|
|
|
453
579
|
function printHelp() {
|
|
454
580
|
const rows = [
|
|
455
|
-
["<text>", "post to the
|
|
581
|
+
["<text>", "post to the current channel"],
|
|
456
582
|
["/dm <agent> <text>", "send a direct message"],
|
|
583
|
+
["/msg <#chan> <text>", "post to a channel without switching to it"],
|
|
457
584
|
["/me <action>", "post an IRC-style action (* you wave)"],
|
|
458
585
|
["/status <text>", "post to the status broadcast channel"],
|
|
586
|
+
[A.dim("--- channels ---"), ""],
|
|
587
|
+
["/join <#chan>", "join (and switch to) a channel, creating it if new"],
|
|
588
|
+
["/part [#chan]", "leave the current (or named) channel"],
|
|
589
|
+
["/rooms", "list all channels (topic + members)"],
|
|
590
|
+
["/topic [text]", "show or set the current channel's topic"],
|
|
591
|
+
["/motd [text]", "show or set the channel rules (MOTD)"],
|
|
592
|
+
[A.dim("--- people ---"), ""],
|
|
459
593
|
["/list, /who", "show registered agents + transports"],
|
|
594
|
+
["/whois <agent>", "show an agent's detail (role, channels, status)"],
|
|
460
595
|
["/whoami", "show your registration + transport"],
|
|
596
|
+
["/nick <name>", "rename yourself (migrates inbox/history)"],
|
|
597
|
+
["/away [msg], /back", "set or clear your away status"],
|
|
598
|
+
["/ignore <agent>", "mute an agent for this session (/unignore to undo)"],
|
|
599
|
+
[A.dim("--- history ---"), ""],
|
|
461
600
|
["/last [n]", "show last n messages (default 20)"],
|
|
462
|
-
["/find <text>", "search recent inbox +
|
|
601
|
+
["/find <text>", "search recent inbox + channel history"],
|
|
463
602
|
["/clear", "clear the screen"],
|
|
464
603
|
[A.dim("--- admin ---"), ""],
|
|
465
604
|
["/prune [days]", "drop messages older than N days (default 7)"],
|
|
466
605
|
["/kick <agent>", "unregister an agent + kill their pusher"],
|
|
467
|
-
["/wipe-room", "truncate the
|
|
606
|
+
["/wipe-room", "truncate the current channel (destructive)"],
|
|
468
607
|
[A.dim("---"), ""],
|
|
469
608
|
["/help, /?", "this list"],
|
|
470
|
-
["/quit, /exit",
|
|
609
|
+
["/quit [msg], /exit", "unregister and leave"],
|
|
471
610
|
];
|
|
472
611
|
say(A.bold("commands:"));
|
|
473
612
|
for (const [cmd, desc] of rows) {
|
|
@@ -495,14 +634,19 @@ function fastForwardCursors() {
|
|
|
495
634
|
// new messages going forward.
|
|
496
635
|
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
497
636
|
cur.inboxOffset = readJsonl(INBOX_FILE).length;
|
|
498
|
-
cur
|
|
637
|
+
for (const chan of joinedRooms()) setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
499
638
|
writeJsonAtomic(CURSOR_FILE, cur);
|
|
500
639
|
}
|
|
501
640
|
|
|
502
641
|
async function printRecent(n) {
|
|
503
642
|
const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
|
|
504
|
-
|
|
505
|
-
const
|
|
643
|
+
let rooms = [];
|
|
644
|
+
for (const chan of joinedRooms()) {
|
|
645
|
+
rooms = rooms.concat(
|
|
646
|
+
readJsonl(roomFile(chan)).slice(-n).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const all = [...inbox, ...rooms].sort((a, b) => a.ts - b.ts).slice(-n);
|
|
506
650
|
if (!all.length) return say(A.dim("(no history)"));
|
|
507
651
|
say(A.bold(`last ${all.length} message(s):`));
|
|
508
652
|
for (const m of all) printMsg(m._kind, m, { history: true });
|
|
@@ -534,9 +678,15 @@ async function register() {
|
|
|
534
678
|
role: existing?.role ?? "human",
|
|
535
679
|
registeredAt: existing?.registeredAt ?? now,
|
|
536
680
|
lastHeartbeat: now,
|
|
681
|
+
away: existing?.away,
|
|
537
682
|
};
|
|
538
683
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
539
684
|
});
|
|
685
|
+
// Record default-channel membership so /rooms + the hooks see us there.
|
|
686
|
+
await updateRooms((reg) => {
|
|
687
|
+
const e = (reg[DEFAULT_ROOM] ??= { createdAt: 0, createdBy: "system", members: [] });
|
|
688
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
689
|
+
});
|
|
540
690
|
}
|
|
541
691
|
|
|
542
692
|
async function unregister() {
|
|
@@ -545,6 +695,11 @@ async function unregister() {
|
|
|
545
695
|
delete reg[ID];
|
|
546
696
|
writeJsonAtomic(AGENTS_FILE, reg);
|
|
547
697
|
});
|
|
698
|
+
await updateRooms((reg) => {
|
|
699
|
+
for (const e of Object.values(reg)) {
|
|
700
|
+
if (e.members?.includes(ID)) e.members = e.members.filter((m) => m !== ID);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
548
703
|
}
|
|
549
704
|
|
|
550
705
|
async function sendDm(to, text) {
|
|
@@ -563,18 +718,17 @@ async function postStatus(status) {
|
|
|
563
718
|
async function pruneOld(days) {
|
|
564
719
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
565
720
|
let total = 0;
|
|
566
|
-
// Per-
|
|
567
|
-
// offsets — without this, every other agent's
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
721
|
+
// Per-channel removal counts so we can shift the corresponding cursor
|
|
722
|
+
// offsets — without this, every other agent's offsets would point past the
|
|
723
|
+
// now-shorter file and they'd silently miss messages until enough new ones
|
|
724
|
+
// piled up to overtake the stale offset.
|
|
725
|
+
const roomRemovedByChan = {};
|
|
571
726
|
let statusRemoved = 0;
|
|
572
727
|
const inboxRemoved = {}; // agentId → count
|
|
573
728
|
|
|
574
|
-
const files = [
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
];
|
|
729
|
+
const files = [];
|
|
730
|
+
for (const chan of Object.keys(getRooms())) files.push({ path: roomFile(chan), kind: "room", chan });
|
|
731
|
+
files.push({ path: STATUS_FILE_PATH, kind: "status" });
|
|
578
732
|
if (existsSync(INBOX_DIR)) {
|
|
579
733
|
for (const n of readdirSync(INBOX_DIR)) {
|
|
580
734
|
if (n.endsWith(".jsonl")) {
|
|
@@ -591,29 +745,32 @@ async function pruneOld(days) {
|
|
|
591
745
|
const body = kept.length ? kept.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
592
746
|
writeFileSync(f.path, body);
|
|
593
747
|
total += removed;
|
|
594
|
-
if (f.kind === "room")
|
|
595
|
-
|
|
748
|
+
if (f.kind === "room") {
|
|
749
|
+
const c = normalizeRoom(f.chan);
|
|
750
|
+
roomRemovedByChan[c] = (roomRemovedByChan[c] ?? 0) + removed;
|
|
751
|
+
} else if (f.kind === "status") statusRemoved += removed;
|
|
596
752
|
else inboxRemoved[f.agentId] = (inboxRemoved[f.agentId] ?? 0) + removed;
|
|
597
753
|
}
|
|
598
754
|
}
|
|
599
|
-
if (
|
|
600
|
-
shiftAllCursors({
|
|
755
|
+
if (Object.keys(roomRemovedByChan).length || statusRemoved || Object.keys(inboxRemoved).length) {
|
|
756
|
+
shiftAllCursors({ roomRemovedByChan, statusRemoved, inboxRemoved });
|
|
601
757
|
}
|
|
602
758
|
say(A.dim(`→ pruned ${total} entries older than ${days}d (cursors adjusted)`));
|
|
603
759
|
}
|
|
604
760
|
|
|
605
761
|
async function wipeRoom() {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
762
|
+
const chan = normalizeRoom(currentRoom);
|
|
763
|
+
await ensureFile(roomFile(chan));
|
|
764
|
+
writeFileSync(roomFile(chan), "");
|
|
765
|
+
// Reset every agent's offset for this channel so they re-read from the start
|
|
766
|
+
// of the (now empty) file rather than pointing past EOF.
|
|
767
|
+
resetRoomOffsets(chan);
|
|
768
|
+
say(A.dim(`→ #${chan} wiped (channel cursors reset)`));
|
|
612
769
|
}
|
|
613
770
|
|
|
614
771
|
// Walk every cursor file and shift offsets down by the per-channel removed
|
|
615
772
|
// counts. Mirrors what the MCP prune tool does server-side.
|
|
616
|
-
function shiftAllCursors({
|
|
773
|
+
function shiftAllCursors({ roomRemovedByChan = {}, statusRemoved = 0, inboxRemoved = {} }) {
|
|
617
774
|
if (!existsSync(CURSOR_DIR)) return;
|
|
618
775
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
619
776
|
if (!name.endsWith(".json")) continue;
|
|
@@ -621,9 +778,13 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
621
778
|
const cur = readJsonSafe(cursorPath, {});
|
|
622
779
|
const id = name.replace(/\.json$/, "");
|
|
623
780
|
let touched = false;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
781
|
+
for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
|
|
782
|
+
if (removed <= 0) continue;
|
|
783
|
+
const has = chan === DEFAULT_ROOM ? cur.roomOffset !== undefined : cur.roomOffsets?.[chan] !== undefined;
|
|
784
|
+
if (has) {
|
|
785
|
+
setRoomOffset(cur, chan, Math.max(0, getRoomOffset(cur, chan) - removed));
|
|
786
|
+
touched = true;
|
|
787
|
+
}
|
|
627
788
|
}
|
|
628
789
|
if (cur.statusOffset !== undefined && statusRemoved > 0) {
|
|
629
790
|
cur.statusOffset = Math.max(0, cur.statusOffset - statusRemoved);
|
|
@@ -638,14 +799,15 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
|
|
|
638
799
|
}
|
|
639
800
|
}
|
|
640
801
|
|
|
641
|
-
function
|
|
802
|
+
function resetRoomOffsets(chan) {
|
|
642
803
|
if (!existsSync(CURSOR_DIR)) return;
|
|
804
|
+
const c = normalizeRoom(chan);
|
|
643
805
|
for (const name of readdirSync(CURSOR_DIR)) {
|
|
644
806
|
if (!name.endsWith(".json")) continue;
|
|
645
807
|
const cursorPath = path.join(CURSOR_DIR, name);
|
|
646
808
|
const cur = readJsonSafe(cursorPath, {});
|
|
647
|
-
if (cur
|
|
648
|
-
cur
|
|
809
|
+
if (getRoomOffset(cur, c) !== 0) {
|
|
810
|
+
setRoomOffset(cur, c, 0);
|
|
649
811
|
writeJsonAtomic(cursorPath, cur);
|
|
650
812
|
}
|
|
651
813
|
}
|
|
@@ -686,8 +848,11 @@ async function kickAgent(target) {
|
|
|
686
848
|
async function findInHistory(term) {
|
|
687
849
|
const t = term.toLowerCase();
|
|
688
850
|
const inbox = readJsonl(INBOX_FILE).map((m) => ({ ...m, _kind: "DM" }));
|
|
689
|
-
|
|
690
|
-
const
|
|
851
|
+
let rooms = [];
|
|
852
|
+
for (const chan of joinedRooms()) {
|
|
853
|
+
rooms = rooms.concat(readJsonl(roomFile(chan)).map((m) => ({ ...m, _kind: "room", room: m.room ?? chan })));
|
|
854
|
+
}
|
|
855
|
+
const matches = [...inbox, ...rooms]
|
|
691
856
|
.filter((m) => (m.text ?? "").toLowerCase().includes(t))
|
|
692
857
|
.sort((a, b) => a.ts - b.ts);
|
|
693
858
|
if (!matches.length) return say(A.dim(`(no matches for "${term}")`));
|
|
@@ -695,8 +860,214 @@ async function findInHistory(term) {
|
|
|
695
860
|
for (const m of matches.slice(-20)) printMsg(m._kind, m, { history: true });
|
|
696
861
|
}
|
|
697
862
|
|
|
698
|
-
|
|
699
|
-
|
|
863
|
+
// ---------- channel commands ----------
|
|
864
|
+
|
|
865
|
+
function showRoomBanner(chan) {
|
|
866
|
+
const c = normalizeRoom(chan);
|
|
867
|
+
const e = getRooms()[c];
|
|
868
|
+
say(A.bold(agentColor(c)(`#${c}`)) + (e?.topic ? A.dim(" — " + e.topic) : ""));
|
|
869
|
+
if (e?.motd) say(A.dim(" rules: ") + e.motd);
|
|
870
|
+
const members = e?.members ?? [];
|
|
871
|
+
if (members.length) say(A.dim(` members: ${members.join(", ")}`));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function joinRoom(arg) {
|
|
875
|
+
const chan = normalizeRoom(arg);
|
|
876
|
+
await updateRooms((reg) => {
|
|
877
|
+
const e = (reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] });
|
|
878
|
+
if (!e.members.includes(ID)) e.members.push(ID);
|
|
879
|
+
});
|
|
880
|
+
// Fast-forward this channel's offset so we don't replay its whole backlog.
|
|
881
|
+
const cur = readJsonSafe(CURSOR_FILE, {});
|
|
882
|
+
setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
|
|
883
|
+
writeJsonAtomic(CURSOR_FILE, cur);
|
|
884
|
+
await sendSystem(chan, `joined #${chan}`);
|
|
885
|
+
currentRoom = chan;
|
|
886
|
+
watchRoom(chan);
|
|
887
|
+
refreshPrompt();
|
|
888
|
+
say(A.dim("→ now in ") + A.bold(agentColor(chan)(`#${chan}`)));
|
|
889
|
+
showRoomBanner(chan);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function partRoom(arg) {
|
|
893
|
+
const chan = normalizeRoom(arg || currentRoom);
|
|
894
|
+
if (chan === DEFAULT_ROOM) return say(A.red("cannot leave #general"));
|
|
895
|
+
if (!joinedRooms().includes(chan)) return say(A.red(`not in #${chan}`));
|
|
896
|
+
await sendSystem(chan, `left #${chan}`);
|
|
897
|
+
await updateRooms((reg) => {
|
|
898
|
+
if (reg[chan]) reg[chan].members = (reg[chan].members ?? []).filter((m) => m !== ID);
|
|
899
|
+
});
|
|
900
|
+
if (normalizeRoom(currentRoom) === chan) {
|
|
901
|
+
currentRoom = DEFAULT_ROOM;
|
|
902
|
+
refreshPrompt();
|
|
903
|
+
}
|
|
904
|
+
say(A.dim("→ left ") + A.bold(`#${chan}`));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function listRooms() {
|
|
908
|
+
const reg = getRooms();
|
|
909
|
+
const joined = new Set(joinedRooms());
|
|
910
|
+
const names = Object.keys(reg).sort();
|
|
911
|
+
say(A.bold(`channels (${names.length}):`));
|
|
912
|
+
for (const c of names) {
|
|
913
|
+
const e = reg[c];
|
|
914
|
+
const here =
|
|
915
|
+
normalizeRoom(currentRoom) === c ? A.green("*") : joined.has(c) ? A.dim("·") : " ";
|
|
916
|
+
const count = readJsonl(roomFile(c)).length;
|
|
917
|
+
const topic = e.topic ? A.dim(" — " + e.topic) : "";
|
|
918
|
+
say(` ${here} ${agentColor(c)(("#" + c).padEnd(16))} ${A.dim(`${(e.members ?? []).length} member(s), ${count} msg`)}${topic}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function setTopic(arg) {
|
|
923
|
+
const chan = normalizeRoom(currentRoom);
|
|
924
|
+
if (!arg) {
|
|
925
|
+
const e = getRooms()[chan];
|
|
926
|
+
return say(e?.topic ? A.bold(`#${chan} topic: `) + e.topic : A.dim(`#${chan} has no topic`));
|
|
927
|
+
}
|
|
928
|
+
await updateRooms((reg) => {
|
|
929
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).topic = arg;
|
|
930
|
+
});
|
|
931
|
+
await sendSystem(chan, `changed topic to: ${arg}`);
|
|
932
|
+
say(A.dim(`→ topic set for #${chan}`));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function setMotd(arg) {
|
|
936
|
+
const chan = normalizeRoom(currentRoom);
|
|
937
|
+
if (!arg) {
|
|
938
|
+
const e = getRooms()[chan];
|
|
939
|
+
return say(e?.motd ? A.bold(`#${chan} rules: `) + e.motd : A.dim(`#${chan} has no rules (MOTD)`));
|
|
940
|
+
}
|
|
941
|
+
await updateRooms((reg) => {
|
|
942
|
+
(reg[chan] ??= { createdAt: Date.now(), createdBy: ID, members: [] }).motd = arg;
|
|
943
|
+
});
|
|
944
|
+
await sendSystem(chan, `updated the room rules (MOTD)`);
|
|
945
|
+
say(A.dim(`→ rules (MOTD) set for #${chan}`));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---------- phase-1 parity commands ----------
|
|
949
|
+
|
|
950
|
+
function whois(target) {
|
|
951
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
952
|
+
const a = reg[target];
|
|
953
|
+
if (!a) return say(A.red(`no such agent: ${target}`));
|
|
954
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(target)}.json`), null);
|
|
955
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
956
|
+
const online = live || Date.now() - a.lastHeartbeat < 5 * 60 * 1000;
|
|
957
|
+
const rooms = Object.entries(getRooms())
|
|
958
|
+
.filter(([, e]) => e.members?.includes(target))
|
|
959
|
+
.map(([c]) => `#${c}`);
|
|
960
|
+
say(A.bold("whois ") + agentColor(target)(target) + ":");
|
|
961
|
+
say(` ${A.cyan("status")} ${online ? A.green("online") : A.dim("offline")}${a.away ? A.yellow(` (away: ${a.away})`) : ""}`);
|
|
962
|
+
say(` ${A.cyan("role")} ${a.role ?? "-"}`);
|
|
963
|
+
say(` ${A.cyan("seen")} ${A.dim(relTime(a.lastHeartbeat))}`);
|
|
964
|
+
say(` ${A.cyan("channels")} ${rooms.length ? rooms.join(" ") : A.dim("(general)")}`);
|
|
965
|
+
say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function setAway(msg) {
|
|
969
|
+
await withLock(AGENTS_FILE, async () => {
|
|
970
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
971
|
+
if (reg[ID]) {
|
|
972
|
+
reg[ID].away = msg || "away";
|
|
973
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
say(A.dim(`→ marked away${msg ? ": " + msg : ""}`));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function setBack() {
|
|
980
|
+
await withLock(AGENTS_FILE, async () => {
|
|
981
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
982
|
+
if (reg[ID]) {
|
|
983
|
+
delete reg[ID].away;
|
|
984
|
+
writeJsonAtomic(AGENTS_FILE, reg);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
say(A.dim("→ welcome back (away cleared)"));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function ignoreAgent(target) {
|
|
991
|
+
if (!target) return say(A.red("usage: /ignore <agent>"));
|
|
992
|
+
ignored.add(target);
|
|
993
|
+
say(A.dim(`→ ignoring ${target} (this session)`));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function unignoreAgent(target) {
|
|
997
|
+
if (target) {
|
|
998
|
+
ignored.delete(target);
|
|
999
|
+
say(A.dim(`→ no longer ignoring ${target}`));
|
|
1000
|
+
} else {
|
|
1001
|
+
ignored.clear();
|
|
1002
|
+
say(A.dim("→ cleared ignore list"));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function moveFileSync(from, to) {
|
|
1007
|
+
if (!existsSync(from) || from === to) return;
|
|
1008
|
+
try {
|
|
1009
|
+
renameSync(from, to);
|
|
1010
|
+
} catch {
|
|
1011
|
+
try {
|
|
1012
|
+
writeFileSync(to, readFileSync(from));
|
|
1013
|
+
unlinkSync(from);
|
|
1014
|
+
} catch {
|
|
1015
|
+
/* best effort */
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Full rename (NICK): migrate registry, channel membership, inbox, cursor,
|
|
1021
|
+
// transport marker, and color, then rebind the in-session identity. Mirrors
|
|
1022
|
+
// the MCP rename_agent tool.
|
|
1023
|
+
async function nick(arg) {
|
|
1024
|
+
const oldId = ID;
|
|
1025
|
+
const newId = (arg || "").trim();
|
|
1026
|
+
if (!newId || newId === oldId) return say(A.red("usage: /nick <newname>"));
|
|
1027
|
+
if (readJsonSafe(AGENTS_FILE, {})[newId]) return say(A.red(`'${newId}' already exists`));
|
|
1028
|
+
const joined = joinedRooms();
|
|
1029
|
+
|
|
1030
|
+
await withLock(AGENTS_FILE, async () => {
|
|
1031
|
+
const r = readJsonSafe(AGENTS_FILE, {});
|
|
1032
|
+
if (r[oldId]) {
|
|
1033
|
+
r[newId] = { ...r[oldId], agentId: newId };
|
|
1034
|
+
delete r[oldId];
|
|
1035
|
+
}
|
|
1036
|
+
writeJsonAtomic(AGENTS_FILE, r);
|
|
1037
|
+
});
|
|
1038
|
+
await updateRooms((r) => {
|
|
1039
|
+
for (const e of Object.values(r)) {
|
|
1040
|
+
if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m));
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
moveFileSync(path.join(INBOX_DIR, `${sanitize(oldId)}.jsonl`), path.join(INBOX_DIR, `${sanitize(newId)}.jsonl`));
|
|
1044
|
+
moveFileSync(path.join(CURSOR_DIR, `${sanitize(oldId)}.json`), path.join(CURSOR_DIR, `${sanitize(newId)}.json`));
|
|
1045
|
+
moveFileSync(path.join(TRANSPORT_DIR, `${sanitize(oldId)}.json`), path.join(TRANSPORT_DIR, `${sanitize(newId)}.json`));
|
|
1046
|
+
const cmap = readJsonSafe(COLOR_MAP_FILE, {});
|
|
1047
|
+
if (cmap[oldId] !== undefined && cmap[newId] === undefined) {
|
|
1048
|
+
cmap[newId] = cmap[oldId];
|
|
1049
|
+
writeJsonAtomic(COLOR_MAP_FILE, cmap);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Rebind in-session identity, then broadcast under the new name.
|
|
1053
|
+
ID = newId;
|
|
1054
|
+
INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
|
|
1055
|
+
CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
1056
|
+
SELF_MENTION_RE = new RegExp("@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])");
|
|
1057
|
+
for (const chan of joined) await sendSystem(chan, `is now known as ${newId} (was ${oldId})`);
|
|
1058
|
+
refreshPrompt();
|
|
1059
|
+
say(A.dim("→ you are now ") + agentColor(ID)(ID));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function sendRoom(text, chan = currentRoom) {
|
|
1063
|
+
const c = normalizeRoom(chan);
|
|
1064
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Post a system notice (join/part/topic/nick) to a channel.
|
|
1068
|
+
async function sendSystem(chan, text) {
|
|
1069
|
+
const c = normalizeRoom(chan);
|
|
1070
|
+
await appendMessage(roomFile(c), { from: ID, room: c, text, system: true });
|
|
700
1071
|
}
|
|
701
1072
|
|
|
702
1073
|
async function appendMessage(file, partial) {
|
|
@@ -713,22 +1084,25 @@ async function drainAndPrint() {
|
|
|
713
1084
|
const inboxOff = cursor.inboxOffset ?? 0;
|
|
714
1085
|
for (let i = inboxOff; i < inboxAll.length; i++) {
|
|
715
1086
|
const m = inboxAll[i];
|
|
716
|
-
if (m && m.from !== ID) printMsg("DM", m);
|
|
1087
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("DM", m);
|
|
717
1088
|
}
|
|
718
1089
|
if (inboxAll.length > inboxOff) {
|
|
719
1090
|
cursor.inboxOffset = inboxAll.length;
|
|
720
1091
|
changed = true;
|
|
721
1092
|
}
|
|
722
1093
|
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1094
|
+
// Drain every joined channel against its own per-channel offset.
|
|
1095
|
+
for (const chan of joinedRooms()) {
|
|
1096
|
+
const all = readJsonl(roomFile(chan));
|
|
1097
|
+
const off = getRoomOffset(cursor, chan);
|
|
1098
|
+
for (let i = off; i < all.length; i++) {
|
|
1099
|
+
const m = all[i];
|
|
1100
|
+
if (m && m.from !== ID && !ignored.has(m.from)) printMsg("room", { ...m, room: m.room ?? chan });
|
|
1101
|
+
}
|
|
1102
|
+
if (all.length > off) {
|
|
1103
|
+
setRoomOffset(cursor, chan, all.length);
|
|
1104
|
+
changed = true;
|
|
1105
|
+
}
|
|
732
1106
|
}
|
|
733
1107
|
|
|
734
1108
|
if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
|
|
@@ -740,6 +1114,20 @@ function printMsg(kind, m, opts = {}) {
|
|
|
740
1114
|
const gutter = color("▎");
|
|
741
1115
|
const prefix = opts.history ? A.dim(" ") : "";
|
|
742
1116
|
|
|
1117
|
+
// A channel tag when the message isn't from the focused channel, so cross-
|
|
1118
|
+
// channel traffic stays legible without cluttering the common single-room case.
|
|
1119
|
+
const otherChan = kind === "room" && m.room && normalizeRoom(m.room) !== normalizeRoom(currentRoom);
|
|
1120
|
+
const chanTag = otherChan ? agentColor(m.room)(`#${normalizeRoom(m.room)}`) + " " : "";
|
|
1121
|
+
|
|
1122
|
+
// System notices (join/part/topic/nick) render as a dim italic one-liner.
|
|
1123
|
+
if (m.system) {
|
|
1124
|
+
const tag = chanTag ? `#${normalizeRoom(m.room)} ` : "";
|
|
1125
|
+
say("");
|
|
1126
|
+
say(`${prefix}\x1b[2;3m — ${tag}${who} ${m.text ?? ""} —\x1b[0m`);
|
|
1127
|
+
if (!opts.history) lastBlock = { who: null, ts: m.ts, kind: null };
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
743
1131
|
// Body wraps manually under a continuous gutter — terminal auto-wrap would
|
|
744
1132
|
// lose the colored gutter on continuation lines.
|
|
745
1133
|
const prefixW = visibleLength(prefix);
|
|
@@ -762,7 +1150,7 @@ function printMsg(kind, m, opts = {}) {
|
|
|
762
1150
|
const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
|
|
763
1151
|
const marker = pinged ? A.bold(A.yellow("► ")) : "";
|
|
764
1152
|
const headGutter = pinged ? A.bold(color("▌")) : gutter;
|
|
765
|
-
const header = `${marker}${badge}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
1153
|
+
const header = `${marker}${badge}${chanTag}${A.bold(color(who))} ${A.dim(`· ${relTime(m.ts)}`)}`;
|
|
766
1154
|
say("");
|
|
767
1155
|
say(`${prefix}${headGutter} ${header}`);
|
|
768
1156
|
}
|