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.
- package/README.md +32 -13
- package/dist/server.js +10 -4
- package/dist/server.js.map +1 -1
- package/dist/store.js +79 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +236 -33
- package/dist/tools.js.map +1 -1
- package/hooks/peek-coord.mjs +66 -8
- package/hooks/tmux-pusher.mjs +81 -7
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +491 -66
- package/src/server.ts +57 -3
- package/src/store.ts +90 -1
- package/src/tools.ts +263 -32
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
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
room: args.room,
|
|
292
|
+
room: chan,
|
|
233
293
|
text: args.text,
|
|
234
294
|
};
|
|
235
|
-
const target =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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:
|
|
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
|
-
// ----------
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
}
|