agent-coord-mcp 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -9,10 +9,16 @@ import {
9
9
  detachAgentTool,
10
10
  heartbeatSchema,
11
11
  heartbeatTool,
12
+ joinRoomSchema,
13
+ joinRoomTool,
12
14
  joinSchema,
13
15
  joinTool,
16
+ leaveRoomSchema,
17
+ leaveRoomTool,
14
18
  listAgentsSchema,
15
19
  listAgentsTool,
20
+ listRoomsSchema,
21
+ listRoomsTool,
16
22
  postStatusSchema,
17
23
  postStatusTool,
18
24
  pruneSchema,
@@ -21,8 +27,14 @@ import {
21
27
  readMessagesTool,
22
28
  registerSchema,
23
29
  registerTool,
30
+ renameAgentSchema,
31
+ renameAgentTool,
24
32
  sendMessageSchema,
25
33
  sendMessageTool,
34
+ setRoomMotdSchema,
35
+ setRoomMotdTool,
36
+ setRoomTopicSchema,
37
+ setRoomTopicTool,
26
38
  statusSchema,
27
39
  statusTool,
28
40
  unregisterSchema,
@@ -89,14 +101,14 @@ async function main() {
89
101
 
90
102
  server.tool(
91
103
  "send_message",
92
- "Send a message. If 'to' is set, goes to that agent's inbox; otherwise to the shared room.",
104
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel.",
93
105
  sendMessageSchema,
94
106
  async (args) => jsonResult(await sendMessageTool(args))
95
107
  );
96
108
 
97
109
  server.tool(
98
110
  "read_messages",
99
- "Read new messages from inbox|room|status. Advances the cursor unless peek=true.",
111
+ "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Advances the per-channel cursor unless peek=true.",
100
112
  readMessagesSchema,
101
113
  async (args) => jsonResult(await readMessagesTool(args))
102
114
  );
@@ -117,11 +129,53 @@ async function main() {
117
129
 
118
130
  server.tool(
119
131
  "wait_for_message",
120
- "Block (max 60s) until a new message appears on the given source, then return it.",
132
+ "Block (max 60s) until a new message appears on the given source, then return it. For source='room', pass 'room' to wait on a specific channel (default 'general').",
121
133
  waitForMessageSchema,
122
134
  async (args) => jsonResult(await waitForMessageTool(args))
123
135
  );
124
136
 
137
+ server.tool(
138
+ "list_rooms",
139
+ "List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
140
+ listRoomsSchema,
141
+ async () => jsonResult(await listRoomsTool())
142
+ );
143
+
144
+ server.tool(
145
+ "join_room",
146
+ "Join a channel (creating it if new). Adds this agent to the channel's membership so the notification hooks push its messages, and returns the channel's topic, MOTD, members, and unread count.",
147
+ joinRoomSchema,
148
+ async (args) => jsonResult(await joinRoomTool(args))
149
+ );
150
+
151
+ server.tool(
152
+ "leave_room",
153
+ "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
154
+ leaveRoomSchema,
155
+ async (args) => jsonResult(await leaveRoomTool(args))
156
+ );
157
+
158
+ server.tool(
159
+ "set_room_topic",
160
+ "Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
161
+ setRoomTopicSchema,
162
+ async (args) => jsonResult(await setRoomTopicTool(args))
163
+ );
164
+
165
+ server.tool(
166
+ "set_room_motd",
167
+ "Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
168
+ setRoomMotdSchema,
169
+ async (args) => jsonResult(await setRoomMotdTool(args))
170
+ );
171
+
172
+ server.tool(
173
+ "rename_agent",
174
+ "Rename an agent (NICK): migrates its registry entry, inbox, cursor, transport marker, and channel memberships to the new id, then broadcasts a rename notice to its channels. Note: a running attached pusher keeps its old id until restarted.",
175
+ renameAgentSchema,
176
+ async (args) => jsonResult(await renameAgentTool(args))
177
+ );
178
+
125
179
  server.tool(
126
180
  "attach_agent",
127
181
  "Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
package/src/store.ts CHANGED
@@ -16,8 +16,16 @@ export const TRANSPORT_DIR = path.join(ROOT, "transports");
16
16
  export const PID_DIR = path.join(ROOT, "pids");
17
17
  export const LOG_DIR = path.join(ROOT, "logs");
18
18
 
19
+ // Channels. The default channel `general` keeps using the legacy single-room
20
+ // file (room.jsonl) + the flat `roomOffset` cursor key, so existing agents and
21
+ // the notification hooks keep working with zero migration. Every other channel
22
+ // lives in rooms/<chan>.jsonl with its offset under cursor.roomOffsets[chan].
23
+ export const ROOMS_DIR = path.join(ROOT, "rooms");
24
+ export const ROOMS_FILE = path.join(ROOT, "rooms.json");
25
+ export const DEFAULT_ROOM = "general";
26
+
19
27
  export function ensureDirs(): void {
20
- for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR]) {
28
+ for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR]) {
21
29
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
22
30
  }
23
31
  for (const f of [ROOM_FILE, STATUS_FILE]) {
@@ -25,6 +33,87 @@ export function ensureDirs(): void {
25
33
  }
26
34
  }
27
35
 
36
+ export type RoomEntry = {
37
+ topic?: string;
38
+ motd?: string;
39
+ createdAt: number;
40
+ createdBy: string;
41
+ members: string[];
42
+ };
43
+ export type RoomRegistry = Record<string, RoomEntry>;
44
+
45
+ // Normalize a channel name: strip leading '#', lowercase, restrict to a safe
46
+ // charset, empty → the default channel. Display layers re-add the '#'.
47
+ export function normalizeRoom(name?: string): string {
48
+ if (!name) return DEFAULT_ROOM;
49
+ const n = name.trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, "");
50
+ return n || DEFAULT_ROOM;
51
+ }
52
+
53
+ // Resolve a channel to its physical JSONL file. `general` maps to the legacy
54
+ // room.jsonl for backward compatibility; everything else to rooms/<chan>.jsonl.
55
+ export function roomFile(chan: string): string {
56
+ const c = normalizeRoom(chan);
57
+ return c === DEFAULT_ROOM ? ROOM_FILE : path.join(ROOMS_DIR, `${sanitize(c)}.jsonl`);
58
+ }
59
+
60
+ function blankRoom(createdBy: string): RoomEntry {
61
+ return { createdAt: Date.now(), createdBy, members: [] };
62
+ }
63
+
64
+ // Read the channel registry, always surfacing `general` even before it has been
65
+ // explicitly persisted (it exists implicitly via room.jsonl).
66
+ export async function getRooms(): Promise<RoomRegistry> {
67
+ const reg = await readJson<RoomRegistry>(ROOMS_FILE, {});
68
+ if (!reg[DEFAULT_ROOM]) reg[DEFAULT_ROOM] = { createdAt: 0, createdBy: "system", members: [] };
69
+ return reg;
70
+ }
71
+
72
+ export async function ensureRoom(chan: string, createdBy: string): Promise<void> {
73
+ const c = normalizeRoom(chan);
74
+ await updateJson<RoomRegistry>(ROOMS_FILE, {}, (cur) => {
75
+ if (!cur[c]) cur[c] = blankRoom(createdBy);
76
+ return cur;
77
+ });
78
+ }
79
+
80
+ export async function setRoomMeta(chan: string, meta: { topic?: string; motd?: string }, by = "system"): Promise<void> {
81
+ const c = normalizeRoom(chan);
82
+ await updateJson<RoomRegistry>(ROOMS_FILE, {}, (cur) => {
83
+ const e = (cur[c] ??= blankRoom(by));
84
+ if (meta.topic !== undefined) e.topic = meta.topic;
85
+ if (meta.motd !== undefined) e.motd = meta.motd;
86
+ return cur;
87
+ });
88
+ }
89
+
90
+ export async function addMember(chan: string, agentId: string): Promise<void> {
91
+ const c = normalizeRoom(chan);
92
+ await updateJson<RoomRegistry>(ROOMS_FILE, {}, (cur) => {
93
+ const e = (cur[c] ??= blankRoom(agentId));
94
+ if (!e.members.includes(agentId)) e.members.push(agentId);
95
+ return cur;
96
+ });
97
+ }
98
+
99
+ export async function removeMember(chan: string, agentId: string): Promise<void> {
100
+ const c = normalizeRoom(chan);
101
+ await updateJson<RoomRegistry>(ROOMS_FILE, {}, (cur) => {
102
+ if (cur[c]) cur[c].members = cur[c].members.filter((m) => m !== agentId);
103
+ return cur;
104
+ });
105
+ }
106
+
107
+ // Channels this agent has joined (always includes the default channel).
108
+ export async function memberRooms(agentId: string): Promise<string[]> {
109
+ const reg = await getRooms();
110
+ const out = new Set<string>([DEFAULT_ROOM]);
111
+ for (const [chan, e] of Object.entries(reg)) {
112
+ if (e.members?.includes(agentId)) out.add(chan);
113
+ }
114
+ return [...out];
115
+ }
116
+
28
117
  export function transportFile(agentId: string): string {
29
118
  return path.join(TRANSPORT_DIR, `${sanitize(agentId)}.json`);
30
119
  }
package/src/tools.ts CHANGED
@@ -8,24 +8,34 @@ import path from "node:path";
8
8
  import {
9
9
  AGENTS_FILE,
10
10
  CURSOR_DIR,
11
+ DEFAULT_ROOM,
11
12
  INBOX_DIR,
12
- ROOM_FILE,
13
+ ROOMS_FILE,
13
14
  STATUS_FILE,
15
+ addMember,
14
16
  appendJsonl,
15
17
  cursorFile,
16
18
  deleteFile,
19
+ ensureRoom,
17
20
  fileSize,
21
+ getRooms,
18
22
  inboxFile,
19
23
  listCursorFiles,
20
24
  listInboxFiles,
21
25
  listTransportFiles,
22
26
  logFile,
27
+ memberRooms,
28
+ normalizeRoom,
23
29
  pidFile,
24
30
  readJson,
25
31
  readJsonl,
32
+ removeMember,
26
33
  rewriteJsonl,
34
+ roomFile,
35
+ setRoomMeta,
27
36
  transportFile,
28
37
  updateJson,
38
+ type RoomRegistry,
29
39
  } from "./store.js";
30
40
 
31
41
  type AgentEntry = {
@@ -54,6 +64,8 @@ type Message = {
54
64
  to?: string;
55
65
  room?: string;
56
66
  text: string;
67
+ // System notices (join/part/topic/nick) — rendered distinctly by clients.
68
+ system?: boolean;
57
69
  };
58
70
 
59
71
  type StatusEntry = {
@@ -66,10 +78,42 @@ type StatusEntry = {
66
78
 
67
79
  type Cursor = {
68
80
  inboxOffset?: number;
69
- roomOffset?: number;
81
+ roomOffset?: number; // the default channel (`general` → room.jsonl)
70
82
  statusOffset?: number;
83
+ // Per-channel read offsets for every non-default channel.
84
+ roomOffsets?: Record<string, number>;
71
85
  };
72
86
 
87
+ type Source = "inbox" | "room" | "status";
88
+
89
+ // Resolve the physical file for a (source, agent, channel) tuple.
90
+ function sourceFile(source: Source, agentId: string, room?: string): string {
91
+ if (source === "inbox") return inboxFile(agentId);
92
+ if (source === "status") return STATUS_FILE;
93
+ return roomFile(room ?? DEFAULT_ROOM);
94
+ }
95
+
96
+ // Read/write the cursor offset for a (source, channel). `general` keeps using
97
+ // the flat roomOffset key (hook compat); other channels use roomOffsets[chan].
98
+ function getOffset(cursor: Cursor, source: Source, room?: string): number {
99
+ if (source === "inbox") return cursor.inboxOffset ?? 0;
100
+ if (source === "status") return cursor.statusOffset ?? 0;
101
+ const chan = normalizeRoom(room);
102
+ return chan === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[chan] ?? 0;
103
+ }
104
+
105
+ function setOffset(cursor: Cursor, source: Source, room: string | undefined, n: number): void {
106
+ if (source === "inbox") { cursor.inboxOffset = n; return; }
107
+ if (source === "status") { cursor.statusOffset = n; return; }
108
+ const chan = normalizeRoom(room);
109
+ if (chan === DEFAULT_ROOM) cursor.roomOffset = n;
110
+ else (cursor.roomOffsets ??= {})[chan] = n;
111
+ }
112
+
113
+ function sysMsg(from: string, room: string, text: string): Message {
114
+ return { id: randomUUID(), ts: Date.now(), from, room, text, system: true };
115
+ }
116
+
73
117
  const STALE_MS = 5 * 60 * 1000;
74
118
  const EVICT_MS = 24 * 60 * 60 * 1000;
75
119
  const MAX_WAIT_MS = 60_000;
@@ -224,17 +268,33 @@ export async function sendMessageTool(args: {
224
268
  room?: string;
225
269
  text: string;
226
270
  }) {
271
+ // DM → inbox. Otherwise resolve the channel (default `general`), make sure it
272
+ // exists in the registry, and tag the message with its channel.
273
+ if (args.to) {
274
+ const msg: Message = {
275
+ id: randomUUID(),
276
+ ts: Date.now(),
277
+ from: args.from,
278
+ to: args.to,
279
+ text: args.text,
280
+ };
281
+ const target = inboxFile(args.to);
282
+ await appendJsonl(target, msg);
283
+ return { ok: true, id: msg.id, target, room: undefined };
284
+ }
285
+
286
+ const chan = normalizeRoom(args.room);
287
+ if (chan !== DEFAULT_ROOM) await ensureRoom(chan, args.from);
227
288
  const msg: Message = {
228
289
  id: randomUUID(),
229
290
  ts: Date.now(),
230
291
  from: args.from,
231
- to: args.to,
232
- room: args.room,
292
+ room: chan,
233
293
  text: args.text,
234
294
  };
235
- const target = args.to ? inboxFile(args.to) : ROOM_FILE;
295
+ const target = roomFile(chan);
236
296
  await appendJsonl(target, msg);
237
- return { ok: true, id: msg.id, target };
297
+ return { ok: true, id: msg.id, target, room: chan };
238
298
  }
239
299
 
240
300
  // ---------- read_messages ----------
@@ -242,6 +302,7 @@ export async function sendMessageTool(args: {
242
302
  export const readMessagesSchema = {
243
303
  agentId: z.string().min(1),
244
304
  source: z.enum(["inbox", "room", "status"]),
305
+ room: z.string().optional(),
245
306
  limit: z.number().int().positive().max(500).optional(),
246
307
  peek: z.boolean().optional(),
247
308
  sinceTs: z.number().optional(),
@@ -249,13 +310,13 @@ export const readMessagesSchema = {
249
310
 
250
311
  export async function readMessagesTool(args: {
251
312
  agentId: string;
252
- source: "inbox" | "room" | "status";
313
+ source: Source;
314
+ room?: string;
253
315
  limit?: number;
254
316
  peek?: boolean;
255
317
  sinceTs?: number;
256
318
  }) {
257
- const file = sourceFile(args.source, args.agentId);
258
- const offsetKey = offsetKeyFor(args.source);
319
+ const file = sourceFile(args.source, args.agentId, args.room);
259
320
  const all = await readJsonl<Message | StatusEntry>(file);
260
321
 
261
322
  let limited: (Message | StatusEntry)[] = [];
@@ -263,19 +324,19 @@ export async function readMessagesTool(args: {
263
324
 
264
325
  if (args.peek) {
265
326
  const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
266
- const startOffset = cursor[offsetKey] ?? 0;
327
+ const startOffset = getOffset(cursor, args.source, args.room);
267
328
  let entries = all.slice(startOffset);
268
329
  if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
269
330
  totalNew = entries.length;
270
331
  limited = args.limit ? entries.slice(0, args.limit) : entries;
271
332
  } else {
272
333
  await updateJson<Cursor>(cursorFile(args.agentId), {}, (current) => {
273
- const startOffset = current[offsetKey] ?? 0;
334
+ const startOffset = getOffset(current, args.source, args.room);
274
335
  let entries = all.slice(startOffset);
275
336
  if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
276
337
  totalNew = entries.length;
277
338
  limited = args.limit ? entries.slice(0, args.limit) : entries;
278
- if (limited.length > 0) current[offsetKey] = startOffset + limited.length;
339
+ if (limited.length > 0) setOffset(current, args.source, args.room, startOffset + limited.length);
279
340
  return current;
280
341
  });
281
342
  }
@@ -288,7 +349,12 @@ export async function readMessagesTool(args: {
288
349
  ? limited.filter((e) => entryAuthor(e) !== args.agentId)
289
350
  : limited;
290
351
 
291
- return { messages: visible, totalNew, returned: visible.length };
352
+ return {
353
+ messages: visible,
354
+ totalNew,
355
+ returned: visible.length,
356
+ room: args.source === "room" ? normalizeRoom(args.room) : undefined,
357
+ };
292
358
  }
293
359
 
294
360
  function entryAuthor(e: Message | StatusEntry): string | undefined {
@@ -320,16 +386,18 @@ export async function postStatusTool(args: { agentId: string; status: string; de
320
386
  export const waitForMessageSchema = {
321
387
  agentId: z.string().min(1),
322
388
  source: z.enum(["inbox", "room", "status"]),
389
+ room: z.string().optional(),
323
390
  timeoutMs: z.number().int().positive().max(MAX_WAIT_MS).optional(),
324
391
  };
325
392
 
326
393
  export async function waitForMessageTool(args: {
327
394
  agentId: string;
328
- source: "inbox" | "room" | "status";
395
+ source: Source;
396
+ room?: string;
329
397
  timeoutMs?: number;
330
398
  }) {
331
399
  const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
332
- const file = sourceFile(args.source, args.agentId);
400
+ const file = sourceFile(args.source, args.agentId, args.room);
333
401
  const deadline = Date.now() + totalTimeout;
334
402
 
335
403
  // Loop so that file growth caused only by the agent's own self-posts (which
@@ -373,7 +441,7 @@ export async function waitForMessageTool(args: {
373
441
  });
374
442
 
375
443
  if (!changed) break;
376
- const result = await readMessagesTool({ agentId: args.agentId, source: args.source });
444
+ const result = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
377
445
  if (result.returned > 0) return result;
378
446
  // otherwise, only self-posts arrived; keep waiting on the remaining budget
379
447
  }
@@ -401,8 +469,14 @@ export async function pruneTool(args: {
401
469
  const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
402
470
  const knownAgents = new Set(Object.keys(reg));
403
471
 
472
+ const channels = Object.keys(await getRooms());
473
+
404
474
  if (dryRun) {
405
- const room = await readJsonl<Message>(ROOM_FILE);
475
+ let roomMessages = 0;
476
+ for (const chan of channels) {
477
+ const msgs = await readJsonl<Message>(roomFile(chan));
478
+ roomMessages += msgs.filter((e) => e.ts <= cutoff).length;
479
+ }
406
480
  const status = await readJsonl<StatusEntry>(STATUS_FILE);
407
481
  const inboxFiles = await listInboxFiles();
408
482
  let inboxRemoved = 0;
@@ -418,7 +492,7 @@ export async function pruneTool(args: {
418
492
  cutoff,
419
493
  olderThanDays: days,
420
494
  wouldRemove: {
421
- roomMessages: room.filter((e) => e.ts <= cutoff).length,
495
+ roomMessages,
422
496
  statusEntries: status.filter((e) => e.ts <= cutoff).length,
423
497
  inboxMessages: inboxRemoved,
424
498
  orphanInboxes: orphans,
@@ -426,7 +500,17 @@ export async function pruneTool(args: {
426
500
  };
427
501
  }
428
502
 
429
- const roomResult = await rewriteJsonl<Message>(ROOM_FILE, (e) => e.ts > cutoff);
503
+ // Per-channel removed counts so we can shift the matching offset in each
504
+ // cursor (default channel → roomOffset, others → roomOffsets[chan]).
505
+ const roomRemovedByChan: Record<string, number> = {};
506
+ let roomRemovedTotal = 0;
507
+ for (const chan of channels) {
508
+ const r = await rewriteJsonl<Message>(roomFile(chan), (e) => e.ts > cutoff);
509
+ if (r.removed > 0) {
510
+ roomRemovedByChan[chan] = r.removed;
511
+ roomRemovedTotal += r.removed;
512
+ }
513
+ }
430
514
  const statusResult = await rewriteJsonl<StatusEntry>(STATUS_FILE, (e) => e.ts > cutoff);
431
515
 
432
516
  const inboxFiles = await listInboxFiles();
@@ -460,9 +544,16 @@ export async function pruneTool(args: {
460
544
  const cursorPath = path.join(CURSOR_DIR, cname);
461
545
  let touched = false;
462
546
  await updateJson<Cursor>(cursorPath, {}, (current) => {
463
- if (current.roomOffset !== undefined && roomResult.removed > 0) {
464
- current.roomOffset = Math.max(0, current.roomOffset - roomResult.removed);
465
- touched = true;
547
+ for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
548
+ if (chan === DEFAULT_ROOM) {
549
+ if (current.roomOffset !== undefined) {
550
+ current.roomOffset = Math.max(0, current.roomOffset - removed);
551
+ touched = true;
552
+ }
553
+ } else if (current.roomOffsets?.[chan] !== undefined) {
554
+ current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
555
+ touched = true;
556
+ }
466
557
  }
467
558
  if (current.statusOffset !== undefined && statusResult.removed > 0) {
468
559
  current.statusOffset = Math.max(0, current.statusOffset - statusResult.removed);
@@ -483,7 +574,7 @@ export async function pruneTool(args: {
483
574
  cutoff,
484
575
  olderThanDays: days,
485
576
  removed: {
486
- roomMessages: roomResult.removed,
577
+ roomMessages: roomRemovedTotal,
487
578
  statusEntries: statusResult.removed,
488
579
  inboxMessages: inboxRemoved,
489
580
  orphanInboxes: deletedOrphans,
@@ -747,16 +838,156 @@ export async function joinTool(args: {
747
838
  };
748
839
  }
749
840
 
750
- // ---------- helpers ----------
841
+ // ---------- room / channel tools ----------
842
+
843
+ export const listRoomsSchema = {} as const;
844
+
845
+ export async function listRoomsTool() {
846
+ const reg = await getRooms();
847
+ const rooms = [];
848
+ for (const [room, e] of Object.entries(reg)) {
849
+ const msgs = await readJsonl<Message>(roomFile(room));
850
+ rooms.push({
851
+ room,
852
+ topic: e.topic,
853
+ motd: e.motd,
854
+ members: e.members ?? [],
855
+ memberCount: (e.members ?? []).length,
856
+ messageCount: msgs.length,
857
+ lastTs: msgs.length ? msgs[msgs.length - 1].ts : undefined,
858
+ createdAt: e.createdAt,
859
+ createdBy: e.createdBy,
860
+ });
861
+ }
862
+ return { rooms };
863
+ }
751
864
 
752
- function sourceFile(source: "inbox" | "room" | "status", agentId: string): string {
753
- if (source === "inbox") return inboxFile(agentId);
754
- if (source === "room") return ROOM_FILE;
755
- return STATUS_FILE;
865
+ export const joinRoomSchema = {
866
+ agentId: z.string().min(1),
867
+ room: z.string().min(1),
868
+ };
869
+
870
+ export async function joinRoomTool(args: { agentId: string; room: string }) {
871
+ const chan = normalizeRoom(args.room);
872
+ await ensureRoom(chan, args.agentId);
873
+ await addMember(chan, args.agentId);
874
+ const reg = await getRooms();
875
+ const e = reg[chan];
876
+ const all = await readJsonl<Message>(roomFile(chan));
877
+ const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
878
+ const unread = Math.max(0, all.length - getOffset(cursor, "room", chan));
879
+ return {
880
+ ok: true,
881
+ room: chan,
882
+ topic: e?.topic,
883
+ motd: e?.motd,
884
+ members: e?.members ?? [],
885
+ unread,
886
+ };
756
887
  }
757
888
 
758
- function offsetKeyFor(source: "inbox" | "room" | "status"): keyof Cursor {
759
- if (source === "inbox") return "inboxOffset";
760
- if (source === "room") return "roomOffset";
761
- return "statusOffset";
889
+ export const leaveRoomSchema = {
890
+ agentId: z.string().min(1),
891
+ room: z.string().min(1),
892
+ };
893
+
894
+ export async function leaveRoomTool(args: { agentId: string; room: string }) {
895
+ const chan = normalizeRoom(args.room);
896
+ if (chan === DEFAULT_ROOM) {
897
+ return { ok: false, error: "cannot leave the default channel" };
898
+ }
899
+ await removeMember(chan, args.agentId);
900
+ return { ok: true, room: chan };
901
+ }
902
+
903
+ export const setRoomTopicSchema = {
904
+ agentId: z.string().min(1),
905
+ room: z.string().min(1),
906
+ topic: z.string(),
907
+ };
908
+
909
+ export async function setRoomTopicTool(args: { agentId: string; room: string; topic: string }) {
910
+ const chan = normalizeRoom(args.room);
911
+ await ensureRoom(chan, args.agentId);
912
+ await setRoomMeta(chan, { topic: args.topic }, args.agentId);
913
+ await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `changed topic to: ${args.topic}`));
914
+ return { ok: true, room: chan, topic: args.topic };
915
+ }
916
+
917
+ export const setRoomMotdSchema = {
918
+ agentId: z.string().min(1),
919
+ room: z.string().min(1),
920
+ motd: z.string(),
921
+ };
922
+
923
+ export async function setRoomMotdTool(args: { agentId: string; room: string; motd: string }) {
924
+ const chan = normalizeRoom(args.room);
925
+ await ensureRoom(chan, args.agentId);
926
+ await setRoomMeta(chan, { motd: args.motd }, args.agentId);
927
+ await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `updated the room rules (MOTD)`));
928
+ return { ok: true, room: chan, motd: args.motd };
929
+ }
930
+
931
+ // ---------- rename_agent (NICK) ----------
932
+
933
+ export const renameAgentSchema = {
934
+ agentId: z.string().min(1),
935
+ newAgentId: z.string().min(1),
936
+ };
937
+
938
+ export async function renameAgentTool(args: { agentId: string; newAgentId: string }) {
939
+ const oldId = args.agentId;
940
+ const newId = args.newAgentId;
941
+ if (oldId === newId) return { ok: false, error: "new id is identical to the current id" };
942
+
943
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
944
+ if (!reg[oldId]) return { ok: false, error: `agent '${oldId}' not registered` };
945
+ if (reg[newId]) return { ok: false, error: `agent '${newId}' already exists` };
946
+
947
+ const joined = await memberRooms(oldId);
948
+
949
+ // Registry: move the entry under the new key.
950
+ await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
951
+ if (current[oldId]) {
952
+ current[newId] = { ...current[oldId], agentId: newId };
953
+ delete current[oldId];
954
+ }
955
+ return current;
956
+ });
957
+
958
+ // Channel memberships: rename in place.
959
+ await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
960
+ for (const e of Object.values(current)) {
961
+ if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m));
962
+ }
963
+ return current;
964
+ });
965
+
966
+ // Per-agent files: inbox, cursor, transport marker.
967
+ await moveFile(inboxFile(oldId), inboxFile(newId));
968
+ await moveFile(cursorFile(oldId), cursorFile(newId));
969
+ await moveFile(transportFile(oldId), transportFile(newId));
970
+
971
+ // Broadcast a NICK notice to every channel the agent was in.
972
+ for (const chan of joined) {
973
+ await appendJsonl(roomFile(chan), sysMsg(newId, chan, `is now known as ${newId} (was ${oldId})`));
974
+ }
975
+
976
+ return { ok: true, from: oldId, to: newId, rooms: joined };
977
+ }
978
+
979
+ // ---------- helpers ----------
980
+
981
+ async function moveFile(from: string, to: string): Promise<boolean> {
982
+ if (!existsSync(from)) return false;
983
+ await fsp.mkdir(path.dirname(to), { recursive: true });
984
+ try {
985
+ await fsp.rename(from, to);
986
+ } catch {
987
+ // Cross-device or other rename failure — fall back to copy + unlink.
988
+ const data = await fsp.readFile(from);
989
+ await fsp.writeFile(to, data);
990
+ await fsp.unlink(from);
991
+ }
992
+ return true;
762
993
  }