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.
@@ -41,7 +41,7 @@ import lockfile from "proper-lockfile";
41
41
  // ---------- args ----------
42
42
 
43
43
  const args = parseArgs(process.argv.slice(2));
44
- const ID = args.id ?? process.env.USER ?? "human";
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
- const SELF_MENTION_RE = new RegExp(
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 INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
85
- const CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
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", "/list", "/who", "/whoami", "/last", "/find", "/clear", "/cls",
150
- "/me", "/status", "/prune", "/kick", "/wipe-room",
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.startsWith("/")) {
179
- hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
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
- try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
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
- rl.on("line", async (line) => {
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
- return `${agentColor(ID)(ID)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
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 shared room"],
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 + room history"],
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 shared room (destructive)"],
606
+ ["/wipe-room", "truncate the current channel (destructive)"],
468
607
  [A.dim("---"), ""],
469
608
  ["/help, /?", "this list"],
470
- ["/quit, /exit", "unregister and leave"],
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.roomOffset = readJsonl(ROOM_FILE).length;
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
- const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
505
- const all = [...inbox, ...room].sort((a, b) => a.ts - b.ts).slice(-n);
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-file removal counts so we can shift the corresponding cursor
567
- // offsets — without this, every other agent's roomOffset/statusOffset/
568
- // inboxOffset would point past the now-shorter file and they'd silently
569
- // miss messages until enough new ones piled up to overtake the stale offset.
570
- let roomRemoved = 0;
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
- { path: ROOM_FILE, kind: "room" },
576
- { path: STATUS_FILE_PATH, kind: "status" },
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") roomRemoved += removed;
595
- else if (f.kind === "status") statusRemoved += removed;
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 (roomRemoved || statusRemoved || Object.keys(inboxRemoved).length) {
600
- shiftAllCursors({ roomRemoved, statusRemoved, inboxRemoved });
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
- await ensureFile(ROOM_FILE);
607
- writeFileSync(ROOM_FILE, "");
608
- // Reset every agent's roomOffset to 0 so they start reading the (now empty)
609
- // file from the beginning. Otherwise their stale offsets point past EOF.
610
- resetAllRoomOffsets();
611
- say(A.dim("→ room wiped (all room cursors reset)"));
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({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {} }) {
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
- if (cur.roomOffset !== undefined && roomRemoved > 0) {
625
- cur.roomOffset = Math.max(0, cur.roomOffset - roomRemoved);
626
- touched = true;
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 resetAllRoomOffsets() {
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.roomOffset !== undefined && cur.roomOffset !== 0) {
648
- cur.roomOffset = 0;
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
- const room = readJsonl(ROOM_FILE).map((m) => ({ ...m, _kind: "room" }));
690
- const matches = [...inbox, ...room]
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
- async function sendRoom(text) {
699
- await appendMessage(ROOM_FILE, { from: ID, text });
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
- const roomAll = readJsonl(ROOM_FILE);
724
- const roomOff = cursor.roomOffset ?? 0;
725
- for (let i = roomOff; i < roomAll.length; i++) {
726
- const m = roomAll[i];
727
- if (m && m.from !== ID) printMsg("room", m);
728
- }
729
- if (roomAll.length > roomOff) {
730
- cursor.roomOffset = roomAll.length;
731
- changed = true;
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
  }