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.
@@ -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
@@ -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
- try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
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
- rl.on("line", async (line) => {
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
- return `${agentColor(ID)(ID)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
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 shared room"],
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 + room history"],
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 shared room (destructive)"],
614
+ ["/wipe-room", "truncate the current channel (destructive)"],
468
615
  [A.dim("---"), ""],
469
616
  ["/help, /?", "this list"],
470
- ["/quit, /exit", "unregister and leave"],
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.roomOffset = readJsonl(ROOM_FILE).length;
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
- 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);
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-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;
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
- { path: ROOM_FILE, kind: "room" },
576
- { path: STATUS_FILE_PATH, kind: "status" },
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") roomRemoved += removed;
595
- else if (f.kind === "status") statusRemoved += removed;
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 (roomRemoved || statusRemoved || Object.keys(inboxRemoved).length) {
600
- shiftAllCursors({ roomRemoved, statusRemoved, inboxRemoved });
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
- 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)"));
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({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {} }) {
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
- if (cur.roomOffset !== undefined && roomRemoved > 0) {
625
- cur.roomOffset = Math.max(0, cur.roomOffset - roomRemoved);
626
- touched = true;
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 resetAllRoomOffsets() {
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.roomOffset !== undefined && cur.roomOffset !== 0) {
648
- cur.roomOffset = 0;
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
- const room = readJsonl(ROOM_FILE).map((m) => ({ ...m, _kind: "room" }));
690
- const matches = [...inbox, ...room]
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
- async function sendRoom(text) {
699
- await appendMessage(ROOM_FILE, { from: ID, text });
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
- 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;
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
  }