agent-coord-mcp 0.14.0 → 0.16.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/hooks/tier.mjs +23 -3
- package/hooks/tmux-pusher.mjs +50 -6
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +1 -1
- package/scripts/coord-node.sh +98 -0
- package/scripts/coord-pusher.mjs +37 -4
- 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
package/src/server.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
quitTool,
|
|
63
63
|
waitForMessageSchema,
|
|
64
64
|
waitForMessageTool,
|
|
65
|
-
} from "./tools.js";
|
|
65
|
+
} from "./tools/index.js";
|
|
66
66
|
|
|
67
67
|
function jsonResult(data: unknown) {
|
|
68
68
|
return {
|
|
@@ -186,7 +186,7 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
186
186
|
|
|
187
187
|
server.tool(
|
|
188
188
|
"send_message",
|
|
189
|
-
"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. The 'from' field is enforced against the session's bound identity when binding is configured.",
|
|
189
|
+
"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. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. The 'from' field is enforced against the session's bound identity when binding is configured.",
|
|
190
190
|
sendMessageSchema,
|
|
191
191
|
gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
192
192
|
);
|
|
@@ -221,7 +221,7 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
221
221
|
|
|
222
222
|
server.tool(
|
|
223
223
|
"prune",
|
|
224
|
-
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
|
|
224
|
+
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted. Pass `room` to prune a single channel, or `targets` (rooms|status|inbox|receipts|members) to narrow the sweep. Sweeps room members that are unregistered or haven't heartbeated since the cutoff, and archives+removes non-default rooms left empty and inactive (disable via archiveEmptyRooms=false). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
|
|
225
225
|
pruneSchema,
|
|
226
226
|
gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
227
227
|
);
|
|
@@ -422,12 +422,41 @@ async function startHttp(port: number): Promise<void> {
|
|
|
422
422
|
const bindAddr = process.env.AGENT_COORD_BIND ?? "127.0.0.1";
|
|
423
423
|
const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
|
|
424
424
|
|
|
425
|
+
// Fail-closed network gate. A non-loopback bind is a real network listener, so
|
|
426
|
+
// it must have (a) enforced per-agent identity and (b) a secured transport. We
|
|
427
|
+
// refuse rather than warn: a shared/advisory token lets any node impersonate
|
|
428
|
+
// any agent, and plaintext leaks bearer tokens to anyone on the path.
|
|
429
|
+
const isLoopbackBind =
|
|
430
|
+
bindAddr === "127.0.0.1" || bindAddr === "localhost" || bindAddr === "::1";
|
|
431
|
+
if (!isLoopbackBind) {
|
|
432
|
+
if (!bound) {
|
|
433
|
+
console.error(
|
|
434
|
+
`[agent-coord-mcp] refusing to bind ${bindAddr} without per-agent tokens: a ` +
|
|
435
|
+
`shared/advisory token lets any node impersonate any agent. Create ` +
|
|
436
|
+
`~/agent-coord/tokens.json (per-agent, enforced identity) for network binds.`,
|
|
437
|
+
);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
if (process.env.AGENT_COORD_INSECURE !== "1") {
|
|
441
|
+
console.error(
|
|
442
|
+
`[agent-coord-mcp] refusing plaintext bind to ${bindAddr}: bearer tokens would ` +
|
|
443
|
+
`travel in cleartext. Put the bus behind TLS or a private overlay ` +
|
|
444
|
+
`(Tailscale/WireGuard), then set AGENT_COORD_INSECURE=1 to acknowledge the ` +
|
|
445
|
+
`transport is secured out-of-band.`,
|
|
446
|
+
);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
425
451
|
// One transport+server pair per client session. The SDK exposes session
|
|
426
452
|
// affinity via the `mcp-session-id` header: a new request without it is
|
|
427
453
|
// an init (create new pair); follow-ups carry the id (look up the pair).
|
|
428
454
|
// We cannot share one stateful transport across clients (it errors with
|
|
429
455
|
// "Server already initialized"), and stateless mode rejects reuse.
|
|
430
456
|
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
|
457
|
+
// Which bound agent each session id belongs to, so a session can't be driven
|
|
458
|
+
// by a *different* bearer that merely presents its id (session hijack).
|
|
459
|
+
const sessionAgents = new Map<string, string | undefined>();
|
|
431
460
|
|
|
432
461
|
async function makeSessionTransport(boundAgent?: string): Promise<StreamableHTTPServerTransport> {
|
|
433
462
|
// `let` + explicit type lets the SDK callbacks close over the binding
|
|
@@ -435,10 +464,16 @@ async function startHttp(port: number): Promise<void> {
|
|
|
435
464
|
let transport: StreamableHTTPServerTransport;
|
|
436
465
|
transport = new StreamableHTTPServerTransport({
|
|
437
466
|
sessionIdGenerator: () => randomUUID(),
|
|
438
|
-
onsessioninitialized: (id: string) => {
|
|
467
|
+
onsessioninitialized: (id: string) => {
|
|
468
|
+
sessions.set(id, transport);
|
|
469
|
+
sessionAgents.set(id, boundAgent);
|
|
470
|
+
},
|
|
439
471
|
});
|
|
440
472
|
transport.onclose = () => {
|
|
441
|
-
if (transport.sessionId)
|
|
473
|
+
if (transport.sessionId) {
|
|
474
|
+
sessions.delete(transport.sessionId);
|
|
475
|
+
sessionAgents.delete(transport.sessionId);
|
|
476
|
+
}
|
|
442
477
|
};
|
|
443
478
|
const server = buildServer(boundAgent);
|
|
444
479
|
await server.connect(transport);
|
|
@@ -487,6 +522,20 @@ async function startHttp(port: number): Promise<void> {
|
|
|
487
522
|
// bearer's agent; anything else is a protocol error.
|
|
488
523
|
const sid = req.headers["mcp-session-id"];
|
|
489
524
|
let transport = typeof sid === "string" ? sessions.get(sid) : undefined;
|
|
525
|
+
// Re-bind check: a session is pinned to the agent whose bearer opened it.
|
|
526
|
+
// In bound mode, reject a request whose bearer resolves to a *different*
|
|
527
|
+
// agent than the session was created for — otherwise any valid token plus
|
|
528
|
+
// a leaked session id could drive that session's identity (session hijack).
|
|
529
|
+
if (
|
|
530
|
+
transport &&
|
|
531
|
+
tokenMap &&
|
|
532
|
+
typeof sid === "string" &&
|
|
533
|
+
sessionAgents.get(sid) !== resolved.agent
|
|
534
|
+
) {
|
|
535
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
536
|
+
res.end("session/identity mismatch\n");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
490
539
|
if (!transport) {
|
|
491
540
|
if (req.method !== "POST") {
|
|
492
541
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
package/src/store.ts
CHANGED
|
@@ -44,6 +44,17 @@ export const ROOMS_DIR = path.join(ROOT, "rooms");
|
|
|
44
44
|
export const ROOMS_FILE = path.join(ROOT, "rooms.json");
|
|
45
45
|
export const DEFAULT_ROOM = "general";
|
|
46
46
|
|
|
47
|
+
// Cold storage (v0.15.0). Nothing on the bus is ever deleted by prune or
|
|
48
|
+
// compaction — aged-out entries are APPENDED here first, one file per room
|
|
49
|
+
// (general included, despite its live file being the legacy room.jsonl), plus
|
|
50
|
+
// status.jsonl and inbox/<agent>.jsonl. The server never reads these back;
|
|
51
|
+
// they exist for offline analysis of what happened in a room. Receipts are
|
|
52
|
+
// the one exception: delivery proofs carry no analysis value and are deleted.
|
|
53
|
+
export const ARCHIVE_DIR = path.join(ROOT, "archive");
|
|
54
|
+
export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms");
|
|
55
|
+
export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox");
|
|
56
|
+
export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
|
|
57
|
+
|
|
47
58
|
// Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
|
|
48
59
|
// { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
|
|
49
60
|
// HTTP transport reverse-looks-up the bearer to bind the session to an
|
|
@@ -339,6 +350,57 @@ export async function rewriteJsonl<T>(
|
|
|
339
350
|
});
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
// Archive destinations for each live JSONL stream.
|
|
354
|
+
export function archiveRoomFile(chan: string): string {
|
|
355
|
+
return path.join(ARCHIVE_ROOMS_DIR, `${sanitize(normalizeRoom(chan))}.jsonl`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function archiveInboxFile(agentId: string): string {
|
|
359
|
+
return path.join(ARCHIVE_INBOX_DIR, `${sanitize(agentId)}.jsonl`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Like rewriteJsonl, but entries failing the filter are appended to
|
|
363
|
+
// archiveFile instead of discarded. The archive append happens under the
|
|
364
|
+
// source-file lock and BEFORE the rewrite, so a crash mid-operation can at
|
|
365
|
+
// worst duplicate entries into the archive — never lose them. Malformed
|
|
366
|
+
// lines are archived verbatim rather than parsed.
|
|
367
|
+
export async function archiveJsonl<T>(
|
|
368
|
+
file: string,
|
|
369
|
+
archiveFile: string,
|
|
370
|
+
filter: (entry: T) => boolean
|
|
371
|
+
): Promise<{ kept: number; removed: number; archived: number }> {
|
|
372
|
+
if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0 };
|
|
373
|
+
return withLock(file, async () => {
|
|
374
|
+
const raw = await fs.readFile(file, "utf8");
|
|
375
|
+
let kept = 0;
|
|
376
|
+
let removed = 0;
|
|
377
|
+
const keep: string[] = [];
|
|
378
|
+
const archive: string[] = [];
|
|
379
|
+
for (const line of raw.split("\n")) {
|
|
380
|
+
if (!line.trim()) continue;
|
|
381
|
+
try {
|
|
382
|
+
const entry = JSON.parse(line) as T;
|
|
383
|
+
if (filter(entry)) {
|
|
384
|
+
keep.push(line);
|
|
385
|
+
kept++;
|
|
386
|
+
} else {
|
|
387
|
+
archive.push(line);
|
|
388
|
+
removed++;
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
archive.push(line);
|
|
392
|
+
removed++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (archive.length) {
|
|
396
|
+
await fs.mkdir(path.dirname(archiveFile), { recursive: true });
|
|
397
|
+
await fs.appendFile(archiveFile, archive.join("\n") + "\n", "utf8");
|
|
398
|
+
}
|
|
399
|
+
await fs.writeFile(file, keep.length ? keep.join("\n") + "\n" : "", "utf8");
|
|
400
|
+
return { kept, removed, archived: archive.length };
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
342
404
|
export async function deleteFile(file: string): Promise<boolean> {
|
|
343
405
|
if (!existsSync(file)) return false;
|
|
344
406
|
await fs.unlink(file);
|
|
@@ -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
|
+
}
|