agent-coord-mcp 0.4.8 → 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
@@ -54,9 +54,14 @@ const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "ag
54
54
  const GROUP_WINDOW = 2 * 60 * 1000;
55
55
  let lastBlock = { who: null, ts: 0, kind: null };
56
56
 
57
+ // Whether the ephemeral suggestion hint is currently occupying the slot above
58
+ // the prompt (see drawHint/clearHint). Declared early so say(), called during
59
+ // startup replay, can reference it without tripping the const/let TDZ.
60
+ let hintActive = false;
61
+
57
62
  // Matches "@<this agent>" not followed by a name char, so we can flag messages
58
63
  // that ping the current user. ID may contain regex metachars — escape it.
59
- const SELF_MENTION_RE = new RegExp(
64
+ let SELF_MENTION_RE = new RegExp(
60
65
  "@" + ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9._-])",
61
66
  );
62
67
  const mentionsSelf = (text) => SELF_MENTION_RE.test(text ?? "");
@@ -76,11 +81,81 @@ const CURSOR_DIR = path.join(ROOT, "cursors");
76
81
  const TRANSPORT_DIR = path.join(ROOT, "transports");
77
82
  const AGENTS_FILE = path.join(ROOT, "agents.json");
78
83
  const ROOM_FILE = path.join(ROOT, "room.jsonl");
79
- const INBOX_FILE = path.join(INBOX_DIR, `${sanitize(ID)}.jsonl`);
80
- 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`);
81
88
 
82
89
  mkdirSync(INBOX_DIR, { recursive: true });
83
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
+ }
84
159
 
85
160
  // ---------- ANSI helpers ----------
86
161
 
@@ -141,8 +216,10 @@ if (TTY) {
141
216
  }
142
217
 
143
218
  const SLASH_COMMANDS = [
144
- "/dm", "/list", "/who", "/whoami", "/last", "/find", "/clear", "/cls",
145
- "/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",
146
223
  "/help", "/?", "/quit", "/exit",
147
224
  ];
148
225
 
@@ -170,12 +247,29 @@ function completer(line) {
170
247
  const partial = line.slice(4);
171
248
  const ids = onlineAgentIds().filter((id) => id !== ID && id.startsWith(partial));
172
249
  hits = ids.map((id) => `/dm ${id} `);
173
- } else if (line.startsWith("/")) {
174
- 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
+ }
175
264
  }
176
- if (hits.length > 1 && commonPrefix(hits).length <= substr.length) {
265
+ if (hits.length > 1) {
266
+ // Show the options as an ephemeral hint and hand readline only the common
267
+ // prefix — a single-element completion means its native multi-column dump
268
+ // never lands in scrollback. Tab still advances to the shared prefix.
177
269
  const display = hits.map((h) => h.trim()).join(" ");
178
- say(A.dim(" ┄ " + display));
270
+ drawHint(A.dim(" ┄ " + display));
271
+ const cp = commonPrefix(hits);
272
+ return [[cp.length >= substr.length ? cp : substr], substr];
179
273
  }
180
274
  return [hits, substr];
181
275
  }
@@ -202,8 +296,14 @@ const rl = readline.createInterface({
202
296
  // the "@" into its line buffer before we inspect it.
203
297
  if (process.stdin.isTTY) {
204
298
  readline.emitKeypressEvents(process.stdin);
205
- process.stdin.on("keypress", (str) => {
206
- if (str === "@") setImmediate(showMentionPicker);
299
+ process.stdin.on("keypress", (str, key) => {
300
+ // setImmediate lets readline mutate its line buffer first, then we inspect
301
+ // it / redraw the slot above the prompt.
302
+ if (str === "@") { setImmediate(showMentionPicker); return; }
303
+ // Tab is the completer's — it draws/keeps the hint itself. Every other key
304
+ // dismisses a showing hint so the view snaps back to a clean separator.
305
+ if (key && key.name === "tab") return;
306
+ if (hintActive) setImmediate(clearHint);
207
307
  });
208
308
  }
209
309
 
@@ -221,21 +321,32 @@ process.stdout.write(sepLine() + "\n");
221
321
  lastLineWasSep = true;
222
322
 
223
323
  try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
224
- try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
324
+ for (const chan of joinedRooms()) watchRoom(chan);
225
325
  try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
226
326
  setInterval(() => void drainAndPrint(), 1000);
227
327
  setInterval(refreshPrompt, 5000);
228
328
 
229
329
  rl.prompt();
230
330
 
231
- 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) {
232
341
  const text = line.trim();
233
342
  if (!text) return rl.prompt();
234
343
  // The user's typed-and-submitted line is now in scrollback; it is NOT a
235
344
  // separator slot we own. Sync output from commands should write naturally.
236
345
  lastLineWasSep = false;
237
346
  try {
238
- 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");
239
350
  await unregister();
240
351
  teardownFooter();
241
352
  process.stdout.write(A.dim("bye.\n"));
@@ -244,6 +355,36 @@ rl.on("line", async (line) => {
244
355
  printHelp();
245
356
  } else if (text === "/list" || text === "/who") {
246
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());
247
388
  } else if (text === "/whoami") {
248
389
  await printWhoami();
249
390
  } else if (text === "/clear" || text === "/cls") {
@@ -295,7 +436,7 @@ rl.on("line", async (line) => {
295
436
  process.stdout.write(sepLine() + "\n");
296
437
  lastLineWasSep = true;
297
438
  rl.prompt();
298
- });
439
+ }
299
440
 
300
441
  process.on("SIGINT", async () => {
301
442
  try { await unregister(); } catch {}
@@ -358,7 +499,28 @@ function agentColor(id) {
358
499
  return AGENT_COLORS[idx];
359
500
  }
360
501
 
502
+ // Ephemeral suggestion line (the @mention / completion picker). It borrows the
503
+ // separator slot directly above the prompt: drawn there, then wiped back to a
504
+ // real separator on the next keystroke — so it never piles up in scrollback.
505
+ function drawHint(content) {
506
+ if (typeof rl === "undefined" || !lastLineWasSep) return;
507
+ process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the slot, clear it
508
+ process.stdout.write(content + "\n"); // draw the hint in place of the sep
509
+ rl.prompt(true); // redraw prompt + preserved input
510
+ hintActive = true;
511
+ }
512
+
513
+ function clearHint() {
514
+ if (typeof rl === "undefined" || !lastLineWasSep) { hintActive = false; return; }
515
+ if (!hintActive) return;
516
+ process.stdout.write("\x1b[1A\r\x1b[2K"); // up to the hint slot, clear it
517
+ process.stdout.write(sepLine() + "\n"); // restore the separator
518
+ rl.prompt(true);
519
+ hintActive = false;
520
+ }
521
+
361
522
  function say(line) {
523
+ hintActive = false; // any real output reclaims the slot the hint borrowed
362
524
  if (lastLineWasSep) {
363
525
  // Async path: a separator we own sits directly above the prompt; we own
364
526
  // that line. Replace it with the incoming message, drop a new sep, and
@@ -390,7 +552,8 @@ function makePrompt() {
390
552
  if (live || now - a.lastHeartbeat < STALE) online++;
391
553
  }
392
554
  const peers = online === 1 ? "1 peer" : `${online} peers`;
393
- 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(">")} `;
394
557
  }
395
558
 
396
559
  // No-op stubs kept so the exit paths don't reference deleted functions.
@@ -415,22 +578,35 @@ function printBanner() {
415
578
 
416
579
  function printHelp() {
417
580
  const rows = [
418
- ["<text>", "post to the shared room"],
581
+ ["<text>", "post to the current channel"],
419
582
  ["/dm <agent> <text>", "send a direct message"],
583
+ ["/msg <#chan> <text>", "post to a channel without switching to it"],
420
584
  ["/me <action>", "post an IRC-style action (* you wave)"],
421
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 ---"), ""],
422
593
  ["/list, /who", "show registered agents + transports"],
594
+ ["/whois <agent>", "show an agent's detail (role, channels, status)"],
423
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 ---"), ""],
424
600
  ["/last [n]", "show last n messages (default 20)"],
425
- ["/find <text>", "search recent inbox + room history"],
601
+ ["/find <text>", "search recent inbox + channel history"],
426
602
  ["/clear", "clear the screen"],
427
603
  [A.dim("--- admin ---"), ""],
428
604
  ["/prune [days]", "drop messages older than N days (default 7)"],
429
605
  ["/kick <agent>", "unregister an agent + kill their pusher"],
430
- ["/wipe-room", "truncate the shared room (destructive)"],
606
+ ["/wipe-room", "truncate the current channel (destructive)"],
431
607
  [A.dim("---"), ""],
432
608
  ["/help, /?", "this list"],
433
- ["/quit, /exit", "unregister and leave"],
609
+ ["/quit [msg], /exit", "unregister and leave"],
434
610
  ];
435
611
  say(A.bold("commands:"));
436
612
  for (const [cmd, desc] of rows) {
@@ -458,14 +634,19 @@ function fastForwardCursors() {
458
634
  // new messages going forward.
459
635
  const cur = readJsonSafe(CURSOR_FILE, {});
460
636
  cur.inboxOffset = readJsonl(INBOX_FILE).length;
461
- cur.roomOffset = readJsonl(ROOM_FILE).length;
637
+ for (const chan of joinedRooms()) setRoomOffset(cur, chan, readJsonl(roomFile(chan)).length);
462
638
  writeJsonAtomic(CURSOR_FILE, cur);
463
639
  }
464
640
 
465
641
  async function printRecent(n) {
466
642
  const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
467
- const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
468
- 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);
469
650
  if (!all.length) return say(A.dim("(no history)"));
470
651
  say(A.bold(`last ${all.length} message(s):`));
471
652
  for (const m of all) printMsg(m._kind, m, { history: true });
@@ -497,9 +678,15 @@ async function register() {
497
678
  role: existing?.role ?? "human",
498
679
  registeredAt: existing?.registeredAt ?? now,
499
680
  lastHeartbeat: now,
681
+ away: existing?.away,
500
682
  };
501
683
  writeJsonAtomic(AGENTS_FILE, reg);
502
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
+ });
503
690
  }
504
691
 
505
692
  async function unregister() {
@@ -508,6 +695,11 @@ async function unregister() {
508
695
  delete reg[ID];
509
696
  writeJsonAtomic(AGENTS_FILE, reg);
510
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
+ });
511
703
  }
512
704
 
513
705
  async function sendDm(to, text) {
@@ -526,18 +718,17 @@ async function postStatus(status) {
526
718
  async function pruneOld(days) {
527
719
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
528
720
  let total = 0;
529
- // Per-file removal counts so we can shift the corresponding cursor
530
- // offsets — without this, every other agent's roomOffset/statusOffset/
531
- // inboxOffset would point past the now-shorter file and they'd silently
532
- // miss messages until enough new ones piled up to overtake the stale offset.
533
- 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 = {};
534
726
  let statusRemoved = 0;
535
727
  const inboxRemoved = {}; // agentId → count
536
728
 
537
- const files = [
538
- { path: ROOM_FILE, kind: "room" },
539
- { path: STATUS_FILE_PATH, kind: "status" },
540
- ];
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" });
541
732
  if (existsSync(INBOX_DIR)) {
542
733
  for (const n of readdirSync(INBOX_DIR)) {
543
734
  if (n.endsWith(".jsonl")) {
@@ -554,29 +745,32 @@ async function pruneOld(days) {
554
745
  const body = kept.length ? kept.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
555
746
  writeFileSync(f.path, body);
556
747
  total += removed;
557
- if (f.kind === "room") roomRemoved += removed;
558
- 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;
559
752
  else inboxRemoved[f.agentId] = (inboxRemoved[f.agentId] ?? 0) + removed;
560
753
  }
561
754
  }
562
- if (roomRemoved || statusRemoved || Object.keys(inboxRemoved).length) {
563
- shiftAllCursors({ roomRemoved, statusRemoved, inboxRemoved });
755
+ if (Object.keys(roomRemovedByChan).length || statusRemoved || Object.keys(inboxRemoved).length) {
756
+ shiftAllCursors({ roomRemovedByChan, statusRemoved, inboxRemoved });
564
757
  }
565
758
  say(A.dim(`→ pruned ${total} entries older than ${days}d (cursors adjusted)`));
566
759
  }
567
760
 
568
761
  async function wipeRoom() {
569
- await ensureFile(ROOM_FILE);
570
- writeFileSync(ROOM_FILE, "");
571
- // Reset every agent's roomOffset to 0 so they start reading the (now empty)
572
- // file from the beginning. Otherwise their stale offsets point past EOF.
573
- resetAllRoomOffsets();
574
- 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)`));
575
769
  }
576
770
 
577
771
  // Walk every cursor file and shift offsets down by the per-channel removed
578
772
  // counts. Mirrors what the MCP prune tool does server-side.
579
- function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {} }) {
773
+ function shiftAllCursors({ roomRemovedByChan = {}, statusRemoved = 0, inboxRemoved = {} }) {
580
774
  if (!existsSync(CURSOR_DIR)) return;
581
775
  for (const name of readdirSync(CURSOR_DIR)) {
582
776
  if (!name.endsWith(".json")) continue;
@@ -584,9 +778,13 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
584
778
  const cur = readJsonSafe(cursorPath, {});
585
779
  const id = name.replace(/\.json$/, "");
586
780
  let touched = false;
587
- if (cur.roomOffset !== undefined && roomRemoved > 0) {
588
- cur.roomOffset = Math.max(0, cur.roomOffset - roomRemoved);
589
- 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
+ }
590
788
  }
591
789
  if (cur.statusOffset !== undefined && statusRemoved > 0) {
592
790
  cur.statusOffset = Math.max(0, cur.statusOffset - statusRemoved);
@@ -601,14 +799,15 @@ function shiftAllCursors({ roomRemoved = 0, statusRemoved = 0, inboxRemoved = {}
601
799
  }
602
800
  }
603
801
 
604
- function resetAllRoomOffsets() {
802
+ function resetRoomOffsets(chan) {
605
803
  if (!existsSync(CURSOR_DIR)) return;
804
+ const c = normalizeRoom(chan);
606
805
  for (const name of readdirSync(CURSOR_DIR)) {
607
806
  if (!name.endsWith(".json")) continue;
608
807
  const cursorPath = path.join(CURSOR_DIR, name);
609
808
  const cur = readJsonSafe(cursorPath, {});
610
- if (cur.roomOffset !== undefined && cur.roomOffset !== 0) {
611
- cur.roomOffset = 0;
809
+ if (getRoomOffset(cur, c) !== 0) {
810
+ setRoomOffset(cur, c, 0);
612
811
  writeJsonAtomic(cursorPath, cur);
613
812
  }
614
813
  }
@@ -649,8 +848,11 @@ async function kickAgent(target) {
649
848
  async function findInHistory(term) {
650
849
  const t = term.toLowerCase();
651
850
  const inbox = readJsonl(INBOX_FILE).map((m) => ({ ...m, _kind: "DM" }));
652
- const room = readJsonl(ROOM_FILE).map((m) => ({ ...m, _kind: "room" }));
653
- 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]
654
856
  .filter((m) => (m.text ?? "").toLowerCase().includes(t))
655
857
  .sort((a, b) => a.ts - b.ts);
656
858
  if (!matches.length) return say(A.dim(`(no matches for "${term}")`));
@@ -658,8 +860,214 @@ async function findInHistory(term) {
658
860
  for (const m of matches.slice(-20)) printMsg(m._kind, m, { history: true });
659
861
  }
660
862
 
661
- async function sendRoom(text) {
662
- 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 });
663
1071
  }
664
1072
 
665
1073
  async function appendMessage(file, partial) {
@@ -676,22 +1084,25 @@ async function drainAndPrint() {
676
1084
  const inboxOff = cursor.inboxOffset ?? 0;
677
1085
  for (let i = inboxOff; i < inboxAll.length; i++) {
678
1086
  const m = inboxAll[i];
679
- if (m && m.from !== ID) printMsg("DM", m);
1087
+ if (m && m.from !== ID && !ignored.has(m.from)) printMsg("DM", m);
680
1088
  }
681
1089
  if (inboxAll.length > inboxOff) {
682
1090
  cursor.inboxOffset = inboxAll.length;
683
1091
  changed = true;
684
1092
  }
685
1093
 
686
- const roomAll = readJsonl(ROOM_FILE);
687
- const roomOff = cursor.roomOffset ?? 0;
688
- for (let i = roomOff; i < roomAll.length; i++) {
689
- const m = roomAll[i];
690
- if (m && m.from !== ID) printMsg("room", m);
691
- }
692
- if (roomAll.length > roomOff) {
693
- cursor.roomOffset = roomAll.length;
694
- 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
+ }
695
1106
  }
696
1107
 
697
1108
  if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
@@ -703,6 +1114,20 @@ function printMsg(kind, m, opts = {}) {
703
1114
  const gutter = color("▎");
704
1115
  const prefix = opts.history ? A.dim(" ") : "";
705
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
+
706
1131
  // Body wraps manually under a continuous gutter — terminal auto-wrap would
707
1132
  // lose the colored gutter on continuation lines.
708
1133
  const prefixW = visibleLength(prefix);
@@ -725,7 +1150,7 @@ function printMsg(kind, m, opts = {}) {
725
1150
  const badge = kind === "DM" ? A.bold(A.cyan("DM ")) : "";
726
1151
  const marker = pinged ? A.bold(A.yellow("► ")) : "";
727
1152
  const headGutter = pinged ? A.bold(color("▌")) : gutter;
728
- 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)}`)}`;
729
1154
  say("");
730
1155
  say(`${prefix}${headGutter} ${header}`);
731
1156
  }
@@ -878,7 +1303,7 @@ function showMentionPicker() {
878
1303
  const ids = onlineAgentIds().filter((id) => id !== ID);
879
1304
  if (!ids.length) return;
880
1305
  const list = ids.map((id) => A.green("●") + agentColor(id)(`@${id}`)).join(" ");
881
- say(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
1306
+ drawHint(A.dim(" ┄ ") + list + A.dim(" · Tab to complete"));
882
1307
  }
883
1308
 
884
1309
  function readJsonl(file) {