agent-coord-mcp 0.13.0 → 0.15.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 +49 -5
- package/dist/server.js +45 -5
- package/dist/server.js.map +1 -1
- package/dist/store.js +58 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +296 -0
- package/dist/tools/admin.js.map +1 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/messaging.js +317 -0
- package/dist/tools/messaging.js.map +1 -0
- package/dist/tools/registry.js +237 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/rooms.js +136 -0
- package/dist/tools/rooms.js.map +1 -0
- package/dist/tools/shared.js +61 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/transport.js +1003 -0
- package/dist/tools/transport.js.map +1 -0
- package/dist/tools.js +11 -79
- package/dist/tools.js.map +1 -1
- package/hooks/tier.mjs +18 -10
- package/hooks/tmux-pusher.mjs +37 -3
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +1 -1
- package/scripts/coord-node.sh +98 -0
- package/scripts/coord-pusher.mjs +52 -9
- package/scripts/coord-token.mjs +126 -0
- package/src/server.ts +54 -5
- package/src/store.ts +62 -0
- package/src/tools/admin.ts +347 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/messaging.ts +425 -0
- package/src/tools/registry.ts +329 -0
- package/src/tools/rooms.ts +215 -0
- package/src/tools/shared.ts +167 -0
- package/src/{tools.ts → tools/transport.ts} +23 -1008
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
AGENTS_FILE,
|
|
5
|
+
ARCHIVE_STATUS_FILE,
|
|
6
|
+
CURSOR_DIR,
|
|
7
|
+
DEFAULT_ROOM,
|
|
8
|
+
INBOX_DIR,
|
|
9
|
+
ROOMS_FILE,
|
|
10
|
+
STATUS_FILE,
|
|
11
|
+
archiveInboxFile,
|
|
12
|
+
archiveJsonl,
|
|
13
|
+
archiveRoomFile,
|
|
14
|
+
deleteFile,
|
|
15
|
+
getRooms,
|
|
16
|
+
listCursorFiles,
|
|
17
|
+
listInboxFiles,
|
|
18
|
+
listReceiptFiles,
|
|
19
|
+
normalizeRoom,
|
|
20
|
+
pruneHistory,
|
|
21
|
+
readJson,
|
|
22
|
+
readJsonl,
|
|
23
|
+
rewriteJsonl,
|
|
24
|
+
roomFile,
|
|
25
|
+
updateJson,
|
|
26
|
+
type RoomRegistry,
|
|
27
|
+
} from "../store.js";
|
|
28
|
+
import {
|
|
29
|
+
type AgentRegistry,
|
|
30
|
+
type Cursor,
|
|
31
|
+
type Message,
|
|
32
|
+
type StatusEntry,
|
|
33
|
+
} from "./shared.js";
|
|
34
|
+
|
|
35
|
+
// ---------- prune ----------
|
|
36
|
+
|
|
37
|
+
export const PRUNE_TARGETS = ["rooms", "status", "inbox", "receipts", "members"] as const;
|
|
38
|
+
export type PruneTarget = (typeof PRUNE_TARGETS)[number];
|
|
39
|
+
|
|
40
|
+
export const pruneSchema = {
|
|
41
|
+
olderThanDays: z.number().positive().max(365).optional(),
|
|
42
|
+
decisionDays: z.number().positive().max(365).optional(),
|
|
43
|
+
room: z.string().optional(),
|
|
44
|
+
targets: z.array(z.enum(PRUNE_TARGETS)).optional(),
|
|
45
|
+
removeOrphanInboxes: z.boolean().optional(),
|
|
46
|
+
archiveEmptyRooms: z.boolean().optional(),
|
|
47
|
+
dryRun: z.boolean().optional(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Retention predicate: kind="decision" messages live by the (longer)
|
|
51
|
+
// decision cutoff; everything else by the standard cutoff.
|
|
52
|
+
function keepEntry(e: { ts: number; kind?: string }, cutoff: number, decisionCutoff: number): boolean {
|
|
53
|
+
return e.kind === "decision" ? e.ts > decisionCutoff : e.ts > cutoff;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Shift every agent's cursor offsets down by the number of entries removed
|
|
57
|
+
// ahead of them (append-only + oldest-first removal ⇒ plain subtraction,
|
|
58
|
+
// clamped at 0). Shared by prune and live compaction — without this, an
|
|
59
|
+
// offset past the new file length makes read_messages return [] forever.
|
|
60
|
+
export async function adjustCursors(removals: {
|
|
61
|
+
roomRemovedByChan?: Record<string, number>;
|
|
62
|
+
statusRemoved?: number;
|
|
63
|
+
perAgentInboxRemoved?: Record<string, number>;
|
|
64
|
+
}): Promise<string[]> {
|
|
65
|
+
const roomRemovedByChan = removals.roomRemovedByChan ?? {};
|
|
66
|
+
const statusRemoved = removals.statusRemoved ?? 0;
|
|
67
|
+
const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
|
|
68
|
+
const cursorsAdjusted: string[] = [];
|
|
69
|
+
for (const cname of await listCursorFiles()) {
|
|
70
|
+
const id = cname.replace(/\.json$/, "");
|
|
71
|
+
const cursorPath = path.join(CURSOR_DIR, cname);
|
|
72
|
+
let touched = false;
|
|
73
|
+
await updateJson<Cursor>(cursorPath, {}, (current) => {
|
|
74
|
+
for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
|
|
75
|
+
if (chan === DEFAULT_ROOM) {
|
|
76
|
+
if (current.roomOffset !== undefined) {
|
|
77
|
+
current.roomOffset = Math.max(0, current.roomOffset - removed);
|
|
78
|
+
touched = true;
|
|
79
|
+
}
|
|
80
|
+
} else if (current.roomOffsets?.[chan] !== undefined) {
|
|
81
|
+
current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
|
|
82
|
+
touched = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (current.statusOffset !== undefined && statusRemoved > 0) {
|
|
86
|
+
current.statusOffset = Math.max(0, current.statusOffset - statusRemoved);
|
|
87
|
+
touched = true;
|
|
88
|
+
}
|
|
89
|
+
const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
|
|
90
|
+
if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
|
|
91
|
+
current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
|
|
92
|
+
touched = true;
|
|
93
|
+
}
|
|
94
|
+
return current;
|
|
95
|
+
});
|
|
96
|
+
if (touched) cursorsAdjusted.push(id);
|
|
97
|
+
}
|
|
98
|
+
return cursorsAdjusted;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function pruneTool(args: {
|
|
102
|
+
olderThanDays?: number;
|
|
103
|
+
decisionDays?: number;
|
|
104
|
+
room?: string;
|
|
105
|
+
targets?: PruneTarget[];
|
|
106
|
+
removeOrphanInboxes?: boolean;
|
|
107
|
+
archiveEmptyRooms?: boolean;
|
|
108
|
+
dryRun?: boolean;
|
|
109
|
+
}) {
|
|
110
|
+
const days = args.olderThanDays ?? 7;
|
|
111
|
+
const decisionDays = args.decisionDays ?? 30;
|
|
112
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
113
|
+
const decisionCutoff = Date.now() - decisionDays * 24 * 60 * 60 * 1000;
|
|
114
|
+
const dryRun = args.dryRun ?? false;
|
|
115
|
+
// room-scoped prune touches only that channel's messages; targets narrows
|
|
116
|
+
// the sweep otherwise. Default remains "everything".
|
|
117
|
+
const scopedRoom = args.room ? normalizeRoom(args.room) : undefined;
|
|
118
|
+
const targets = new Set<PruneTarget>(
|
|
119
|
+
scopedRoom ? ["rooms"] : args.targets ?? [...PRUNE_TARGETS]
|
|
120
|
+
);
|
|
121
|
+
const keep = (e: { ts: number; kind?: string }) => keepEntry(e, cutoff, decisionCutoff);
|
|
122
|
+
|
|
123
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
124
|
+
const knownAgents = new Set(Object.keys(reg));
|
|
125
|
+
// A member is stale when its agent is gone from the registry (orphan) or
|
|
126
|
+
// hasn't heartbeated since the cutoff (dead session).
|
|
127
|
+
const staleAgent = (m: string) => !knownAgents.has(m) || (reg[m]?.lastHeartbeat ?? 0) <= cutoff;
|
|
128
|
+
|
|
129
|
+
const channels = scopedRoom ? [scopedRoom] : Object.keys(await getRooms());
|
|
130
|
+
|
|
131
|
+
if (dryRun) {
|
|
132
|
+
let roomMessages = 0;
|
|
133
|
+
if (targets.has("rooms")) {
|
|
134
|
+
for (const chan of channels) {
|
|
135
|
+
const msgs = await readJsonl<Message>(roomFile(chan));
|
|
136
|
+
roomMessages += msgs.filter((e) => !keep(e)).length;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const status = targets.has("status") ? await readJsonl<StatusEntry>(STATUS_FILE) : [];
|
|
140
|
+
let inboxRemoved = 0;
|
|
141
|
+
const orphans: string[] = [];
|
|
142
|
+
if (targets.has("inbox")) {
|
|
143
|
+
for (const fname of await listInboxFiles()) {
|
|
144
|
+
const id = fname.replace(/\.jsonl$/, "");
|
|
145
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) orphans.push(fname);
|
|
146
|
+
const entries = await readJsonl<Message>(path.join(INBOX_DIR, fname));
|
|
147
|
+
inboxRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const orphanMembers = new Set<string>();
|
|
151
|
+
const staleMembers = new Set<string>();
|
|
152
|
+
const emptyRooms: string[] = [];
|
|
153
|
+
if (targets.has("members")) {
|
|
154
|
+
const rooms = await getRooms();
|
|
155
|
+
for (const [chan, e] of Object.entries(rooms)) {
|
|
156
|
+
const remaining: string[] = [];
|
|
157
|
+
for (const m of e.members ?? []) {
|
|
158
|
+
if (!knownAgents.has(m)) orphanMembers.add(m);
|
|
159
|
+
else if (staleAgent(m)) staleMembers.add(m);
|
|
160
|
+
else remaining.push(m);
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
(args.archiveEmptyRooms ?? true) &&
|
|
164
|
+
chan !== DEFAULT_ROOM &&
|
|
165
|
+
remaining.length === 0
|
|
166
|
+
) {
|
|
167
|
+
const msgs = await readJsonl<Message>(roomFile(chan));
|
|
168
|
+
const lastTs = msgs[msgs.length - 1]?.ts ?? 0;
|
|
169
|
+
if (lastTs <= cutoff) emptyRooms.push(chan);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let receiptsRemoved = 0;
|
|
174
|
+
const orphanReceipts: string[] = [];
|
|
175
|
+
if (targets.has("receipts")) {
|
|
176
|
+
for (const filePath of await listReceiptFiles()) {
|
|
177
|
+
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
178
|
+
const entries = await readJsonl<{ ts: number }>(filePath);
|
|
179
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) orphanReceipts.push(id);
|
|
180
|
+
receiptsRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
dryRun: true,
|
|
185
|
+
cutoff,
|
|
186
|
+
olderThanDays: days,
|
|
187
|
+
decisionDays,
|
|
188
|
+
...(scopedRoom ? { room: scopedRoom } : {}),
|
|
189
|
+
wouldRemove: {
|
|
190
|
+
roomMessages,
|
|
191
|
+
statusEntries: status.filter((e) => e.ts <= cutoff).length,
|
|
192
|
+
inboxMessages: inboxRemoved,
|
|
193
|
+
orphanInboxes: orphans,
|
|
194
|
+
orphanMembers: [...orphanMembers],
|
|
195
|
+
staleMembers: [...staleMembers],
|
|
196
|
+
archivedRooms: emptyRooms,
|
|
197
|
+
receipts: receiptsRemoved,
|
|
198
|
+
orphanReceipts,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-channel removed counts so we can shift the matching offset in each
|
|
204
|
+
// cursor (default channel → roomOffset, others → roomOffsets[chan]).
|
|
205
|
+
// Aged-out entries are archived to archive/rooms/<chan>.jsonl, never deleted.
|
|
206
|
+
const roomRemovedByChan: Record<string, number> = {};
|
|
207
|
+
let roomRemovedTotal = 0;
|
|
208
|
+
if (targets.has("rooms")) {
|
|
209
|
+
for (const chan of channels) {
|
|
210
|
+
const r = await archiveJsonl<Message>(roomFile(chan), archiveRoomFile(chan), keep);
|
|
211
|
+
if (r.removed > 0) {
|
|
212
|
+
roomRemovedByChan[chan] = r.removed;
|
|
213
|
+
roomRemovedTotal += r.removed;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const statusResult = targets.has("status")
|
|
218
|
+
? await archiveJsonl<StatusEntry>(STATUS_FILE, ARCHIVE_STATUS_FILE, (e) => e.ts > cutoff)
|
|
219
|
+
: { kept: 0, removed: 0, archived: 0 };
|
|
220
|
+
|
|
221
|
+
let inboxRemoved = 0;
|
|
222
|
+
const deletedOrphans: string[] = [];
|
|
223
|
+
// perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
|
|
224
|
+
// inbox, so we can shift only that agent's inboxOffset cursor below.
|
|
225
|
+
const perAgentInboxRemoved: Record<string, number> = {};
|
|
226
|
+
if (targets.has("inbox")) {
|
|
227
|
+
for (const fname of await listInboxFiles()) {
|
|
228
|
+
const id = fname.replace(/\.jsonl$/, "");
|
|
229
|
+
const filePath = path.join(INBOX_DIR, fname);
|
|
230
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
|
|
231
|
+
// Orphan inbox: archive everything it held, then drop the live file.
|
|
232
|
+
const r = await archiveJsonl<Message>(filePath, archiveInboxFile(id), () => false);
|
|
233
|
+
inboxRemoved += r.removed;
|
|
234
|
+
await deleteFile(filePath);
|
|
235
|
+
deletedOrphans.push(id);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const r = await archiveJsonl<Message>(filePath, archiveInboxFile(id), (e) => e.ts > cutoff);
|
|
239
|
+
inboxRemoved += r.removed;
|
|
240
|
+
perAgentInboxRemoved[id] = r.removed;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cursorsAdjusted = await adjustCursors({
|
|
245
|
+
roomRemovedByChan,
|
|
246
|
+
statusRemoved: statusResult.removed,
|
|
247
|
+
perAgentInboxRemoved,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Membership sweep: drop orphans (not in registry) and stale members (no
|
|
251
|
+
// heartbeat since cutoff) so list_rooms / joinedRooms() stop surfacing
|
|
252
|
+
// ghosts. Then archive non-default rooms left with no members and no
|
|
253
|
+
// activity since the cutoff: remaining messages move to the archive, the
|
|
254
|
+
// room leaves the registry, and every cursor forgets its offset — the same
|
|
255
|
+
// cleanup delete_room performs, minus the data loss.
|
|
256
|
+
const orphanMembers = new Set<string>();
|
|
257
|
+
const staleMembers = new Set<string>();
|
|
258
|
+
const archivedRooms: string[] = [];
|
|
259
|
+
if (targets.has("members")) {
|
|
260
|
+
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
261
|
+
for (const e of Object.values(current)) {
|
|
262
|
+
if ((e.members?.length ?? 0) === 0) continue;
|
|
263
|
+
e.members = (e.members ?? []).filter((m) => {
|
|
264
|
+
if (!knownAgents.has(m)) {
|
|
265
|
+
orphanMembers.add(m);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (staleAgent(m)) {
|
|
269
|
+
staleMembers.add(m);
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return current;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (args.archiveEmptyRooms ?? true) {
|
|
279
|
+
const rooms = await getRooms();
|
|
280
|
+
for (const [chan, e] of Object.entries(rooms)) {
|
|
281
|
+
if (chan === DEFAULT_ROOM || (e.members?.length ?? 0) > 0) continue;
|
|
282
|
+
const file = roomFile(chan);
|
|
283
|
+
const msgs = await readJsonl<Message>(file);
|
|
284
|
+
const lastTs = msgs[msgs.length - 1]?.ts ?? 0;
|
|
285
|
+
if (lastTs > cutoff) continue;
|
|
286
|
+
await archiveJsonl<Message>(file, archiveRoomFile(chan), () => false);
|
|
287
|
+
await deleteFile(file);
|
|
288
|
+
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
289
|
+
delete current[chan];
|
|
290
|
+
return current;
|
|
291
|
+
});
|
|
292
|
+
for (const cname of await listCursorFiles()) {
|
|
293
|
+
await updateJson<Cursor>(path.join(CURSOR_DIR, cname), {}, (current) => {
|
|
294
|
+
if (current.roomOffsets?.[chan] !== undefined) delete current.roomOffsets[chan];
|
|
295
|
+
return current;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
archivedRooms.push(chan);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Receipts are out-of-band proof logs with no cursor and no analysis value —
|
|
304
|
+
// trim old entries by ts and delete logs for agents no longer registered.
|
|
305
|
+
// The one stream that is genuinely deleted, not archived.
|
|
306
|
+
let receiptsRemoved = 0;
|
|
307
|
+
const deletedOrphanReceipts: string[] = [];
|
|
308
|
+
if (targets.has("receipts")) {
|
|
309
|
+
for (const filePath of await listReceiptFiles()) {
|
|
310
|
+
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
311
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
|
|
312
|
+
const entries = await readJsonl<{ ts: number }>(filePath);
|
|
313
|
+
receiptsRemoved += entries.length;
|
|
314
|
+
await deleteFile(filePath);
|
|
315
|
+
deletedOrphanReceipts.push(id);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const r = await rewriteJsonl<{ ts: number }>(filePath, (e) => e.ts > cutoff);
|
|
319
|
+
receiptsRemoved += r.removed;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
|
|
324
|
+
// every read, so this just catches entries on a server with few reads).
|
|
325
|
+
await pruneHistory();
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
dryRun: false,
|
|
329
|
+
cutoff,
|
|
330
|
+
olderThanDays: days,
|
|
331
|
+
decisionDays,
|
|
332
|
+
...(scopedRoom ? { room: scopedRoom } : {}),
|
|
333
|
+
removed: {
|
|
334
|
+
roomMessages: roomRemovedTotal,
|
|
335
|
+
statusEntries: statusResult.removed,
|
|
336
|
+
inboxMessages: inboxRemoved,
|
|
337
|
+
orphanInboxes: deletedOrphans,
|
|
338
|
+
orphanMembers: [...orphanMembers],
|
|
339
|
+
staleMembers: [...staleMembers],
|
|
340
|
+
archivedRooms,
|
|
341
|
+
receipts: receiptsRemoved,
|
|
342
|
+
orphanReceipts: deletedOrphanReceipts,
|
|
343
|
+
},
|
|
344
|
+
archivedTo: "archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl; receipts deleted)",
|
|
345
|
+
cursorsAdjusted,
|
|
346
|
+
};
|
|
347
|
+
}
|