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.
@@ -1,3 +1,6 @@
1
+ import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
2
+ import { registerTool } from "./registry.js";
3
+ import { sendMessageTool, readMessagesTool } from "./messaging.js";
1
4
  import { randomUUID } from "node:crypto";
2
5
  import { existsSync, openSync, watch } from "node:fs";
3
6
  import { promises as fsp } from "node:fs";
@@ -46,278 +49,24 @@ import {
46
49
  TRANSPORT_DIR,
47
50
  updateJson,
48
51
  type RoomRegistry,
49
- } from "./store.js";
50
-
51
- type AgentEntry = {
52
- agentId: string;
53
- project?: string;
54
- role?: string;
55
- registeredAt: number;
56
- lastHeartbeat: number;
57
- capabilities?: string[];
58
- };
59
-
60
- type TransportMarker = {
61
- agentId: string;
62
- transport: string;
63
- pid: number;
64
- tmuxTarget?: string;
65
- since: number;
66
- // Remote pushers run on a different machine; the local pid is meaningless,
67
- // so we tag the host and use heartbeat-based liveness instead of pidAlive.
68
- host?: string;
69
- // mtime of the pusher script the daemon loaded into memory at spawn time
70
- // (epoch ms). When the on-disk script is upgraded but the daemon isn't
71
- // restarted, doctor() compares this to the current mtime to flag a stale
72
- // pusher — the class of bug that silently dropped /clear /compact in v0.8.1.
73
- // Absent on markers written by older versions (treated as "unknown, skip").
74
- scriptMtime?: number;
75
- };
76
-
77
- type AgentRegistry = Record<string, AgentEntry>;
78
-
79
- type Message = {
80
- id: string;
81
- ts: number;
82
- from: string;
83
- to?: string;
84
- room?: string;
85
- text: string;
86
- // System notices (join/part/topic/nick) — rendered distinctly by clients.
87
- system?: boolean;
88
- // Control commands (`/clear`, `/compact`) addressed at the agent's CLI, not
89
- // its operator. The tmux pushers inject these RAW (no banner/prefix) so the
90
- // TUI runs them as real slash commands; every other consumer ignores them.
91
- control?: boolean;
92
- // Server-generated push-now override (e.g. the post-/clear identity
93
- // reminder). Only server code can set this — send_message constructs the
94
- // Message from fixed fields, so peers cannot smuggle it in.
95
- urgent?: boolean;
96
- };
97
-
98
- type StatusEntry = {
99
- id: string;
100
- ts: number;
101
- agentId: string;
102
- status: string;
103
- detail?: string;
104
- };
105
-
106
- type Cursor = {
107
- inboxOffset?: number;
108
- roomOffset?: number; // the default channel (`general` → room.jsonl)
109
- statusOffset?: number;
110
- // Per-channel read offsets for every non-default channel.
111
- roomOffsets?: Record<string, number>;
112
- };
113
-
114
- type Source = "inbox" | "room" | "status";
115
-
116
- // Resolve the physical file for a (source, agent, channel) tuple.
117
- function sourceFile(source: Source, agentId: string, room?: string): string {
118
- if (source === "inbox") return inboxFile(agentId);
119
- if (source === "status") return STATUS_FILE;
120
- return roomFile(room ?? DEFAULT_ROOM);
121
- }
122
-
123
- // Read/write the cursor offset for a (source, channel). `general` keeps using
124
- // the flat roomOffset key (hook compat); other channels use roomOffsets[chan].
125
- function getOffset(cursor: Cursor, source: Source, room?: string): number {
126
- if (source === "inbox") return cursor.inboxOffset ?? 0;
127
- if (source === "status") return cursor.statusOffset ?? 0;
128
- const chan = normalizeRoom(room);
129
- return chan === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[chan] ?? 0;
130
- }
131
-
132
- function setOffset(cursor: Cursor, source: Source, room: string | undefined, n: number): void {
133
- if (source === "inbox") { cursor.inboxOffset = n; return; }
134
- if (source === "status") { cursor.statusOffset = n; return; }
135
- const chan = normalizeRoom(room);
136
- if (chan === DEFAULT_ROOM) cursor.roomOffset = n;
137
- else (cursor.roomOffsets ??= {})[chan] = n;
138
- }
139
-
140
- function sysMsg(from: string, room: string, text: string): Message {
141
- return { id: randomUUID(), ts: Date.now(), from, room, text, system: true };
142
- }
143
-
144
- const STALE_MS = 5 * 60 * 1000;
145
- const EVICT_MS = 24 * 60 * 60 * 1000;
146
- const MAX_WAIT_MS = 60_000;
147
-
148
- // ---------- register ----------
149
-
150
- export const registerSchema = {
151
- agentId: z.string().min(1),
152
- project: z.string().optional(),
153
- role: z.string().optional(),
154
- };
155
-
156
- export async function registerTool(args: { agentId: string; project?: string; role?: string }) {
157
- const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
158
- const now = Date.now();
159
- const existing = current[args.agentId];
160
- current[args.agentId] = {
161
- agentId: args.agentId,
162
- project: args.project ?? existing?.project,
163
- role: args.role ?? existing?.role,
164
- registeredAt: existing?.registeredAt ?? now,
165
- lastHeartbeat: now,
166
- capabilities: existing?.capabilities,
167
- };
168
- return current;
169
- });
170
- return { ok: true, agent: reg[args.agentId] };
171
- }
172
-
173
- // ---------- unregister ----------
174
-
175
- export const unregisterSchema = { agentId: z.string().min(1) };
176
-
177
- export async function unregisterTool(args: { agentId: string }) {
178
- // If a transport is attached, take it down first so the pusher doesn't keep
179
- // re-publishing the marker after we drop the registry entry.
180
- const detach = await detachAgentTool(args);
181
-
182
- let existed = false;
183
- await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
184
- if (current[args.agentId]) {
185
- existed = true;
186
- delete current[args.agentId];
187
- }
188
- return current;
189
- });
190
-
191
- // Drop the agent from every channel's membership so it doesn't linger as a
192
- // ghost in list_rooms / joinedRooms() after it's gone from the registry.
193
- const leftRooms: string[] = [];
194
- await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
195
- for (const [chan, e] of Object.entries(current)) {
196
- if (e.members?.includes(args.agentId)) {
197
- e.members = e.members.filter((m) => m !== args.agentId);
198
- leftRooms.push(chan);
199
- }
200
- }
201
- return current;
202
- });
203
- return { ok: true, removed: existed, detach, leftRooms };
204
- }
205
-
206
- // ---------- quit ----------
207
- // Clean shutdown: unregister (detach transport + leave rooms + remove registry
208
- // entry) then exit the MCP process. Only the bound identity can call this —
209
- // prevents one agent from killing another agent's session.
210
-
211
- export const quitSchema = { agentId: z.string().min(1) };
212
-
213
- export async function quitTool(args: { agentId: string }): Promise<never> {
214
- await unregisterTool(args);
215
- // Give stdio a moment to flush the JSON response before exiting
216
- setTimeout(() => process.exit(0), 150);
217
- // Return a result so the MCP framework sends the response before the
218
- // setTimeout fires — the caller will see this before the process dies.
219
- return { ok: true, message: `'${args.agentId}' unregistered — MCP process exiting` } as never;
220
- }
221
-
222
- // ---------- heartbeat ----------
223
-
224
- export const heartbeatSchema = { agentId: z.string().min(1) };
225
-
226
- export async function heartbeatTool(args: { agentId: string }) {
227
- let missing = false;
228
- await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
229
- if (!current[args.agentId]) {
230
- missing = true;
231
- return current;
232
- }
233
- current[args.agentId].lastHeartbeat = Date.now();
234
- return current;
235
- });
236
- if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` };
237
- return { ok: true };
238
- }
239
-
240
- // ---------- list_agents ----------
241
-
242
- export const listAgentsSchema = {} as const;
243
-
244
- export async function listAgentsTool() {
245
- const now = Date.now();
246
- const evicted: string[] = [];
247
-
248
- // Load live transport markers first so we can refresh heartbeats for agents
249
- // whose pusher (or other transport daemon) is alive — the live process IS
250
- // the heartbeat, no separate ping needed.
251
- const liveTransports = await loadLiveTransports();
252
-
253
- const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
254
- for (const [id, entry] of Object.entries(current)) {
255
- if (liveTransports.has(id)) {
256
- entry.lastHeartbeat = now;
257
- continue;
258
- }
259
- if (now - entry.lastHeartbeat > EVICT_MS) {
260
- evicted.push(id);
261
- delete current[id];
262
- }
263
- }
264
- return current;
265
- });
266
-
267
- const agents = Object.values(reg).map((a) => {
268
- const transport = liveTransports.get(a.agentId);
269
- const merged = [...(a.capabilities ?? [])];
270
- if (transport && !merged.includes(transport.transport)) merged.push(transport.transport);
271
- return {
272
- ...a,
273
- online: transport ? true : now - a.lastHeartbeat < STALE_MS,
274
- secondsSinceHeartbeat: Math.floor((now - a.lastHeartbeat) / 1000),
275
- capabilities: merged.length > 0 ? merged : undefined,
276
- transport: transport
277
- ? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid }
278
- : undefined,
279
- };
280
- });
281
- return { agents, evicted };
282
- }
283
-
284
- async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
285
- const out = new Map<string, TransportMarker>();
286
- const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
287
- const now = Date.now();
288
- for (const fname of await listTransportFiles()) {
289
- const file = path.join(TRANSPORT_DIR, fname);
290
- const marker = await readJson<TransportMarker | null>(file, null);
291
- if (!marker || !isMarkerLive(marker, reg, now)) {
292
- await deleteFile(file);
293
- continue;
294
- }
295
- out.set(marker.agentId, marker);
296
- }
297
- return out;
298
- }
299
-
300
- // Liveness for a transport marker. Local markers carry a real pid we can probe;
301
- // remote markers (tmux-push-remote, pid 0 on a foreign host) can't be — so we
302
- // trust the registry heartbeat the remote pusher refreshes (within STALE_MS).
303
- function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: number): boolean {
304
- if (marker.transport === "tmux-push-remote") {
305
- const entry = reg[marker.agentId];
306
- return !!entry && now - entry.lastHeartbeat < STALE_MS;
307
- }
308
- return isPidAlive(marker.pid);
309
- }
310
-
311
- function isPidAlive(pid: number): boolean {
312
- if (!pid || pid <= 0) return false;
313
- try {
314
- process.kill(pid, 0);
315
- return true;
316
- } catch (err) {
317
- const code = (err as NodeJS.ErrnoException).code;
318
- return code === "EPERM"; // EPERM = exists but not ours; ESRCH = gone
319
- }
320
- }
52
+ } from "../store.js";
53
+ import {
54
+ type AgentEntry,
55
+ type AgentRegistry,
56
+ type Message,
57
+ type StatusEntry,
58
+ type Cursor,
59
+ type Source,
60
+ type TransportMarker,
61
+ sourceFile,
62
+ getOffset,
63
+ setOffset,
64
+ sysMsg,
65
+ moveFile,
66
+ STALE_MS,
67
+ EVICT_MS,
68
+ MAX_WAIT_MS,
69
+ } from "./shared.js";
321
70
 
322
71
  // ---------- ping ----------
323
72
 
@@ -392,57 +141,6 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
392
141
  };
393
142
  }
394
143
 
395
- // ---------- send_message ----------
396
-
397
- export const sendMessageSchema = {
398
- from: z.string().min(1),
399
- to: z.string().optional(),
400
- room: z.string().optional(),
401
- text: z.string().min(1),
402
- };
403
-
404
- export async function sendMessageTool(args: {
405
- from: string;
406
- to?: string;
407
- room?: string;
408
- text: string;
409
- }) {
410
- // DM → inbox. Otherwise resolve the channel (default `general`), make sure it
411
- // exists in the registry, and tag the message with its channel.
412
- if (args.to) {
413
- const msg: Message = {
414
- id: randomUUID(),
415
- ts: Date.now(),
416
- from: args.from,
417
- to: args.to,
418
- text: args.text,
419
- };
420
- const target = inboxFile(args.to);
421
- await appendJsonl(target, msg);
422
- // Offline delivery is intentional (the inbox is created on demand), but a
423
- // typo'd recipient shouldn't vanish silently — surface a warning when the
424
- // target isn't a known agent so the caller can catch the mistake.
425
- const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
426
- const warning = reg[args.to]
427
- ? undefined
428
- : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
429
- return { ok: true, id: msg.id, target, room: undefined, warning };
430
- }
431
-
432
- const chan = normalizeRoom(args.room);
433
- if (chan !== DEFAULT_ROOM) await ensureRoom(chan, args.from);
434
- const msg: Message = {
435
- id: randomUUID(),
436
- ts: Date.now(),
437
- from: args.from,
438
- room: chan,
439
- text: args.text,
440
- };
441
- const target = roomFile(chan);
442
- await appendJsonl(target, msg);
443
- return { ok: true, id: msg.id, target, room: chan };
444
- }
445
-
446
144
  // ---------- send_command (context-management control commands) ----------
447
145
 
448
146
  // The only slash commands a lead may inject into a sub-agent's CLI. Locked on
@@ -704,439 +402,6 @@ export async function sendCommandTool(args: {
704
402
  };
705
403
  }
706
404
 
707
- // ---------- read_messages ----------
708
-
709
- export const readMessagesSchema = {
710
- agentId: z.string().min(1),
711
- source: z.enum(["inbox", "room", "status"]),
712
- room: z.string().optional(),
713
- limit: z.number().int().positive().max(500).optional(),
714
- peek: z.boolean().optional(),
715
- sinceTs: z.number().optional(),
716
- };
717
-
718
- export async function readMessagesTool(args: {
719
- agentId: string;
720
- source: Source;
721
- room?: string;
722
- limit?: number;
723
- peek?: boolean;
724
- sinceTs?: number;
725
- }) {
726
- const file = sourceFile(args.source, args.agentId, args.room);
727
- const all = await readJsonl<Message | StatusEntry>(file);
728
-
729
- let entries: (Message | StatusEntry)[] = [];
730
- let totalNew = 0;
731
-
732
- // Room and status reads default to 50 entries to prevent agents flooding
733
- // themselves with full history on join — the status stream grows unbounded
734
- // across the fleet. Inbox drains fully since it is targeted by nature.
735
- const effectiveLimit = args.limit ?? (args.source === "inbox" ? undefined : 50);
736
-
737
- if (args.peek) {
738
- const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
739
- const startOffset = getOffset(cursor, args.source, args.room);
740
- entries = all.slice(startOffset);
741
- if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
742
- totalNew = entries.length;
743
- } else {
744
- await updateJson<Cursor>(cursorFile(args.agentId), {}, (current) => {
745
- const startOffset = getOffset(current, args.source, args.room);
746
- let e = all.slice(startOffset);
747
- if (args.sinceTs !== undefined) e = e.filter((x) => x.ts > args.sinceTs!);
748
- totalNew = e.length;
749
- entries = e;
750
- // Advance past EVERYTHING we account for here (recent window + any
751
- // overflow we stash below). The overflow is recoverable via the history
752
- // hash, so it must not requeue for the next read — that would re-flood.
753
- if (e.length > 0) setOffset(current, args.source, args.room, startOffset + e.length);
754
- return current;
755
- });
756
- }
757
-
758
- // CCR overflow handling (room and status sources). When the backlog exceeds
759
- // the window, return the RECENT slice raw and replace the older overflow with
760
- // a compact digest carrying a retrieval hash. The agent expands it on demand
761
- // via retrieve_room_history. Peek is side-effect-free, so it never stashes —
762
- // it reports the count and tells the agent to do a real read to get a hash.
763
- let recent = entries;
764
- let history: { digest: string; hash?: string; older: number } | undefined;
765
- if (args.source !== "inbox" && effectiveLimit && entries.length > effectiveLimit) {
766
- const overflow = entries.slice(0, entries.length - effectiveLimit);
767
- recent = entries.slice(entries.length - effectiveLimit);
768
- const stashKey = args.source === "room" ? normalizeRoom(args.room) : "status";
769
- if (args.peek) {
770
- history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
771
- } else {
772
- const hash = await stashHistory(stashKey, args.agentId, overflow);
773
- history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
774
- }
775
- } else if (effectiveLimit && entries.length > effectiveLimit) {
776
- // Inbox keeps the legacy oldest-first chunking (no stash) — targeted
777
- // messages must never be skipped over.
778
- recent = entries.slice(0, effectiveLimit);
779
- }
780
-
781
- // Drop the agent's own posts on shared channels — reading your own broadcast
782
- // back is never useful and confuses turn-based agents into self-replies.
783
- // Cursor has already advanced past them, so they won't reappear.
784
- const visible =
785
- args.source === "room" || args.source === "status"
786
- ? recent.filter((e) => entryAuthor(e) !== args.agentId)
787
- : recent;
788
-
789
- return {
790
- ok: true,
791
- messages: visible,
792
- totalNew,
793
- returned: visible.length,
794
- room: args.source === "room" ? normalizeRoom(args.room) : undefined,
795
- ...(history ? { history } : {}),
796
- };
797
- }
798
-
799
- // Lossless summary of a stashed backlog slice: surfaces error/failure posts
800
- // verbatim (the lines that usually matter most in a flood) and collapses the
801
- // rest to counts. Mirrors headroom's content-aware digest, kept deliberately
802
- // simple — the full originals are one retrieve_room_history call away.
803
- function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefined): string {
804
- const authors = new Set(over.map(entryAuthor).filter(Boolean));
805
- const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
806
- const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
807
- const first = over[0]?.ts;
808
- const last = over[over.length - 1]?.ts;
809
- const span =
810
- first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
811
- const parts = [
812
- `[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
813
- `${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
814
- ];
815
- if (errors.length) parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
816
- const head = parts.join(", ");
817
- const tail = hash
818
- ? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
819
- : `] — read without peek to get an expandable hash`;
820
- return head + tail;
821
- }
822
-
823
- // ---------- retrieve_room_history ----------
824
-
825
- export const retrieveRoomHistorySchema = {
826
- agentId: z.string().min(1),
827
- hash: z.string().min(1),
828
- query: z.string().optional(),
829
- };
830
-
831
- export async function retrieveRoomHistoryTool(args: {
832
- agentId: string;
833
- hash: string;
834
- query?: string;
835
- }) {
836
- const res = await retrieveHistory<Message | StatusEntry>(args.hash, args.agentId, args.query);
837
- if (!res.ok) {
838
- const reason =
839
- res.reason === "expired"
840
- ? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
841
- : res.reason === "forbidden"
842
- ? "That history hash was produced for a different agent and cannot be retrieved by you."
843
- : "No history entry for that hash. It may have expired or never existed.";
844
- return { ok: false, reason: res.reason, message: reason };
845
- }
846
- return {
847
- ok: true,
848
- room: res.room,
849
- hash: args.hash,
850
- total: res.total,
851
- returned: res.messages.length,
852
- messages: res.messages,
853
- };
854
- }
855
-
856
- function entryAuthor(e: Message | StatusEntry): string | undefined {
857
- return "from" in e ? e.from : e.agentId;
858
- }
859
-
860
- // ---------- post_status ----------
861
-
862
- export const postStatusSchema = {
863
- agentId: z.string().min(1),
864
- status: z.string().min(1),
865
- detail: z.string().optional(),
866
- };
867
-
868
- export async function postStatusTool(args: { agentId: string; status: string; detail?: string }) {
869
- const entry: StatusEntry = {
870
- id: randomUUID(),
871
- ts: Date.now(),
872
- agentId: args.agentId,
873
- status: args.status,
874
- detail: args.detail,
875
- };
876
- await appendJsonl(STATUS_FILE, entry);
877
- return { ok: true, id: entry.id };
878
- }
879
-
880
- // ---------- wait_for_message ----------
881
-
882
- export const waitForMessageSchema = {
883
- agentId: z.string().min(1),
884
- source: z.enum(["inbox", "room", "status"]),
885
- room: z.string().optional(),
886
- timeoutMs: z.number().int().positive().max(MAX_WAIT_MS).optional(),
887
- };
888
-
889
- export async function waitForMessageTool(args: {
890
- agentId: string;
891
- source: Source;
892
- room?: string;
893
- timeoutMs?: number;
894
- }) {
895
- const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
896
- const file = sourceFile(args.source, args.agentId, args.room);
897
- const deadline = Date.now() + totalTimeout;
898
-
899
- // Loop so that file growth caused only by the agent's own self-posts (which
900
- // readMessagesTool now filters out for room/status) doesn't return an empty
901
- // result — keep waiting until we have something to deliver or time out.
902
- while (Date.now() < deadline) {
903
- const startSize = await fileSize(file);
904
- const remaining = deadline - Date.now();
905
- if (remaining <= 0) break;
906
-
907
- const changed = await new Promise<boolean>((resolve) => {
908
- let settled = false;
909
- const finish = (v: boolean) => {
910
- if (settled) return;
911
- settled = true;
912
- clearInterval(poll);
913
- try {
914
- watcher?.close();
915
- } catch {
916
- // ignore
917
- }
918
- clearTimeout(t);
919
- resolve(v);
920
- };
921
-
922
- const check = async () => {
923
- const sz = await fileSize(file);
924
- if (sz > startSize) finish(true);
925
- };
926
-
927
- let watcher: ReturnType<typeof watch> | undefined;
928
- try {
929
- watcher = watch(file, () => {
930
- void check();
931
- });
932
- } catch {
933
- // file may not exist; polling will handle
934
- }
935
- const poll = setInterval(() => void check(), 500);
936
- const t = setTimeout(() => finish(false), remaining);
937
- });
938
-
939
- if (!changed) break;
940
- const result = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
941
- if (result.returned > 0) return result;
942
- // otherwise, only self-posts arrived; keep waiting on the remaining budget
943
- }
944
-
945
- return { ok: false, timedOut: true };
946
- }
947
-
948
- // ---------- prune ----------
949
-
950
- export const pruneSchema = {
951
- olderThanDays: z.number().positive().max(365).optional(),
952
- removeOrphanInboxes: z.boolean().optional(),
953
- dryRun: z.boolean().optional(),
954
- };
955
-
956
- export async function pruneTool(args: {
957
- olderThanDays?: number;
958
- removeOrphanInboxes?: boolean;
959
- dryRun?: boolean;
960
- }) {
961
- const days = args.olderThanDays ?? 7;
962
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
963
- const dryRun = args.dryRun ?? false;
964
-
965
- const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
966
- const knownAgents = new Set(Object.keys(reg));
967
-
968
- const channels = Object.keys(await getRooms());
969
-
970
- if (dryRun) {
971
- let roomMessages = 0;
972
- for (const chan of channels) {
973
- const msgs = await readJsonl<Message>(roomFile(chan));
974
- roomMessages += msgs.filter((e) => e.ts <= cutoff).length;
975
- }
976
- const status = await readJsonl<StatusEntry>(STATUS_FILE);
977
- const inboxFiles = await listInboxFiles();
978
- let inboxRemoved = 0;
979
- const orphans: string[] = [];
980
- for (const fname of inboxFiles) {
981
- const id = fname.replace(/\.jsonl$/, "");
982
- if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) orphans.push(fname);
983
- const entries = await readJsonl<Message>(path.join(INBOX_DIR, fname));
984
- inboxRemoved += entries.filter((e) => e.ts <= cutoff).length;
985
- }
986
- const rooms = await getRooms();
987
- const orphanMembers = new Set<string>();
988
- for (const e of Object.values(rooms)) {
989
- for (const m of e.members ?? []) if (!knownAgents.has(m)) orphanMembers.add(m);
990
- }
991
- let receiptsRemoved = 0;
992
- const orphanReceipts: string[] = [];
993
- for (const filePath of await listReceiptFiles()) {
994
- const id = path.basename(filePath).replace(/\.jsonl$/, "");
995
- const entries = await readJsonl<{ ts: number }>(filePath);
996
- if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) orphanReceipts.push(id);
997
- receiptsRemoved += entries.filter((e) => e.ts <= cutoff).length;
998
- }
999
- return {
1000
- dryRun: true,
1001
- cutoff,
1002
- olderThanDays: days,
1003
- wouldRemove: {
1004
- roomMessages,
1005
- statusEntries: status.filter((e) => e.ts <= cutoff).length,
1006
- inboxMessages: inboxRemoved,
1007
- orphanInboxes: orphans,
1008
- orphanMembers: [...orphanMembers],
1009
- receipts: receiptsRemoved,
1010
- orphanReceipts,
1011
- },
1012
- };
1013
- }
1014
-
1015
- // Per-channel removed counts so we can shift the matching offset in each
1016
- // cursor (default channel → roomOffset, others → roomOffsets[chan]).
1017
- const roomRemovedByChan: Record<string, number> = {};
1018
- let roomRemovedTotal = 0;
1019
- for (const chan of channels) {
1020
- const r = await rewriteJsonl<Message>(roomFile(chan), (e) => e.ts > cutoff);
1021
- if (r.removed > 0) {
1022
- roomRemovedByChan[chan] = r.removed;
1023
- roomRemovedTotal += r.removed;
1024
- }
1025
- }
1026
- const statusResult = await rewriteJsonl<StatusEntry>(STATUS_FILE, (e) => e.ts > cutoff);
1027
-
1028
- const inboxFiles = await listInboxFiles();
1029
- let inboxRemoved = 0;
1030
- const deletedOrphans: string[] = [];
1031
- // perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
1032
- // inbox, so we can shift only that agent's inboxOffset cursor below.
1033
- const perAgentInboxRemoved: Record<string, number> = {};
1034
- for (const fname of inboxFiles) {
1035
- const id = fname.replace(/\.jsonl$/, "");
1036
- const filePath = path.join(INBOX_DIR, fname);
1037
- if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
1038
- const entries = await readJsonl<Message>(filePath);
1039
- inboxRemoved += entries.length;
1040
- await deleteFile(filePath);
1041
- deletedOrphans.push(id);
1042
- continue;
1043
- }
1044
- const r = await rewriteJsonl<Message>(filePath, (e) => e.ts > cutoff);
1045
- inboxRemoved += r.removed;
1046
- perAgentInboxRemoved[id] = r.removed;
1047
- }
1048
-
1049
- // Cursor adjustment: appendJsonl is time-ordered and rewriteJsonl removes
1050
- // the oldest entries (ts <= cutoff). Any cursor offset shifts down by the
1051
- // removed count, clamped at 0. Without this, an offset past the new file
1052
- // length would make read_messages return [] forever.
1053
- const cursorsAdjusted: string[] = [];
1054
- for (const cname of await listCursorFiles()) {
1055
- const id = cname.replace(/\.json$/, "");
1056
- const cursorPath = path.join(CURSOR_DIR, cname);
1057
- let touched = false;
1058
- await updateJson<Cursor>(cursorPath, {}, (current) => {
1059
- for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
1060
- if (chan === DEFAULT_ROOM) {
1061
- if (current.roomOffset !== undefined) {
1062
- current.roomOffset = Math.max(0, current.roomOffset - removed);
1063
- touched = true;
1064
- }
1065
- } else if (current.roomOffsets?.[chan] !== undefined) {
1066
- current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
1067
- touched = true;
1068
- }
1069
- }
1070
- if (current.statusOffset !== undefined && statusResult.removed > 0) {
1071
- current.statusOffset = Math.max(0, current.statusOffset - statusResult.removed);
1072
- touched = true;
1073
- }
1074
- const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
1075
- if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
1076
- current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
1077
- touched = true;
1078
- }
1079
- return current;
1080
- });
1081
- if (touched) cursorsAdjusted.push(id);
1082
- }
1083
-
1084
- // Compact channel memberships: drop any member no longer in the registry so
1085
- // list_rooms / joinedRooms() stop surfacing ghosts (mirrors orphan-inbox
1086
- // cleanup). Empty non-default channels are left in place — their history may
1087
- // still matter and `general` must always persist.
1088
- const orphanMembers = new Set<string>();
1089
- await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
1090
- for (const e of Object.values(current)) {
1091
- const before = e.members?.length ?? 0;
1092
- if (before === 0) continue;
1093
- e.members = (e.members ?? []).filter((m) => {
1094
- const keep = knownAgents.has(m);
1095
- if (!keep) orphanMembers.add(m);
1096
- return keep;
1097
- });
1098
- }
1099
- return current;
1100
- });
1101
-
1102
- // Receipts are out-of-band proof logs with no cursor — trim old entries by ts
1103
- // and delete logs for agents no longer registered. No offset adjustment needed.
1104
- let receiptsRemoved = 0;
1105
- const deletedOrphanReceipts: string[] = [];
1106
- for (const filePath of await listReceiptFiles()) {
1107
- const id = path.basename(filePath).replace(/\.jsonl$/, "");
1108
- if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
1109
- const entries = await readJsonl<{ ts: number }>(filePath);
1110
- receiptsRemoved += entries.length;
1111
- await deleteFile(filePath);
1112
- deletedOrphanReceipts.push(id);
1113
- continue;
1114
- }
1115
- const r = await rewriteJsonl<{ ts: number }>(filePath, (e) => e.ts > cutoff);
1116
- receiptsRemoved += r.removed;
1117
- }
1118
-
1119
- // Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
1120
- // every read, so this just catches entries on a server with few reads).
1121
- await pruneHistory();
1122
-
1123
- return {
1124
- dryRun: false,
1125
- cutoff,
1126
- olderThanDays: days,
1127
- removed: {
1128
- roomMessages: roomRemovedTotal,
1129
- statusEntries: statusResult.removed,
1130
- inboxMessages: inboxRemoved,
1131
- orphanInboxes: deletedOrphans,
1132
- orphanMembers: [...orphanMembers],
1133
- receipts: receiptsRemoved,
1134
- orphanReceipts: deletedOrphanReceipts,
1135
- },
1136
- cursorsAdjusted,
1137
- };
1138
- }
1139
-
1140
405
  // ---------- attach_agent / detach_agent (tmux push transport) ----------
1141
406
 
1142
407
  export const attachAgentSchema = {
@@ -1307,9 +572,9 @@ export async function detachAgentTool(args: { agentId: string }) {
1307
572
  }
1308
573
 
1309
574
  function resolvePusherPath(): string {
1310
- // tools.js (compiled) lives in dist/; pusher lives in hooks/ at repo root.
575
+ // transport.js (compiled) lives in dist/tools/; pusher lives in hooks/ at repo root.
1311
576
  const here = path.dirname(fileURLToPath(import.meta.url));
1312
- return path.resolve(here, "..", "hooks", "tmux-pusher.mjs");
577
+ return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
1313
578
  }
1314
579
 
1315
580
  // ---------- status / whoami ----------
@@ -1407,172 +672,6 @@ export async function joinTool(args: {
1407
672
  };
1408
673
  }
1409
674
 
1410
- // ---------- room / channel tools ----------
1411
-
1412
- export const listRoomsSchema = {} as const;
1413
-
1414
- export async function listRoomsTool() {
1415
- const reg = await getRooms();
1416
- const rooms = [];
1417
- for (const [room, e] of Object.entries(reg)) {
1418
- const msgs = await readJsonl<Message>(roomFile(room));
1419
- rooms.push({
1420
- room,
1421
- topic: e.topic,
1422
- motd: e.motd,
1423
- members: e.members ?? [],
1424
- memberCount: (e.members ?? []).length,
1425
- messageCount: msgs.length,
1426
- lastTs: msgs.length ? msgs[msgs.length - 1].ts : undefined,
1427
- createdAt: e.createdAt,
1428
- createdBy: e.createdBy,
1429
- });
1430
- }
1431
- return { rooms };
1432
- }
1433
-
1434
- export const joinRoomSchema = {
1435
- agentId: z.string().min(1),
1436
- room: z.string().min(1),
1437
- };
1438
-
1439
- export async function joinRoomTool(args: { agentId: string; room: string }) {
1440
- const chan = normalizeRoom(args.room);
1441
- await ensureRoom(chan, args.agentId);
1442
- await addMember(chan, args.agentId);
1443
- await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `${args.agentId} has joined`));
1444
- const reg = await getRooms();
1445
- const e = reg[chan];
1446
- const all = await readJsonl<Message>(roomFile(chan));
1447
- const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
1448
- const unread = Math.max(0, all.length - getOffset(cursor, "room", chan));
1449
- return {
1450
- ok: true,
1451
- room: chan,
1452
- topic: e?.topic,
1453
- motd: e?.motd,
1454
- members: e?.members ?? [],
1455
- unread,
1456
- };
1457
- }
1458
-
1459
- export const leaveRoomSchema = {
1460
- agentId: z.string().min(1),
1461
- room: z.string().min(1),
1462
- };
1463
-
1464
- export async function leaveRoomTool(args: { agentId: string; room: string }) {
1465
- const chan = normalizeRoom(args.room);
1466
- if (chan === DEFAULT_ROOM) {
1467
- return { ok: false, error: "cannot leave the default channel" };
1468
- }
1469
- await removeMember(chan, args.agentId);
1470
- return { ok: true, room: chan };
1471
- }
1472
-
1473
- export const setRoomTopicSchema = {
1474
- agentId: z.string().min(1),
1475
- room: z.string().min(1),
1476
- topic: z.string(),
1477
- };
1478
-
1479
- export async function setRoomTopicTool(args: { agentId: string; room: string; topic: string }) {
1480
- const chan = normalizeRoom(args.room);
1481
- await ensureRoom(chan, args.agentId);
1482
- await setRoomMeta(chan, { topic: args.topic }, args.agentId);
1483
- await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `changed topic to: ${args.topic}`));
1484
- return { ok: true, room: chan, topic: args.topic };
1485
- }
1486
-
1487
- export const setRoomMotdSchema = {
1488
- agentId: z.string().min(1),
1489
- room: z.string().min(1),
1490
- motd: z.string(),
1491
- };
1492
-
1493
- export async function setRoomMotdTool(args: { agentId: string; room: string; motd: string }) {
1494
- const chan = normalizeRoom(args.room);
1495
- await ensureRoom(chan, args.agentId);
1496
- await setRoomMeta(chan, { motd: args.motd }, args.agentId);
1497
- await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `updated the room rules (MOTD)`));
1498
- return { ok: true, room: chan, motd: args.motd };
1499
- }
1500
-
1501
- // ---------- rename_agent (NICK) ----------
1502
-
1503
- export const renameAgentSchema = {
1504
- agentId: z.string().min(1),
1505
- newAgentId: z.string().min(1),
1506
- };
1507
-
1508
- export async function renameAgentTool(args: { agentId: string; newAgentId: string }) {
1509
- const oldId = args.agentId;
1510
- const newId = args.newAgentId;
1511
- if (oldId === newId) return { ok: false, error: "new id is identical to the current id" };
1512
-
1513
- const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
1514
- if (!reg[oldId]) return { ok: false, error: `agent '${oldId}' not registered` };
1515
- if (reg[newId]) return { ok: false, error: `agent '${newId}' already exists` };
1516
-
1517
- const joined = await memberRooms(oldId);
1518
-
1519
- // A running pusher has the OLD agentId (and its file paths) baked into its
1520
- // env, so after we migrate the inbox/cursor below it would keep tailing the
1521
- // now-empty old inbox while new DMs land in the new one — silently breaking
1522
- // delivery and orphaning the moved marker. Take it down first; the caller
1523
- // must re-attach under the new id (join/attach_agent) to restore push.
1524
- const liveTransport = await readJson<TransportMarker | null>(transportFile(oldId), null);
1525
- let detachedTransport = false;
1526
- if (liveTransport && isPidAlive(liveTransport.pid)) {
1527
- await detachAgentTool({ agentId: oldId });
1528
- detachedTransport = true;
1529
- }
1530
-
1531
- // Registry: move the entry under the new key.
1532
- await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
1533
- if (current[oldId]) {
1534
- current[newId] = { ...current[oldId], agentId: newId };
1535
- delete current[oldId];
1536
- }
1537
- return current;
1538
- });
1539
-
1540
- // Channel memberships: rename in place.
1541
- await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
1542
- for (const e of Object.values(current)) {
1543
- if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m));
1544
- }
1545
- return current;
1546
- });
1547
-
1548
- // Per-agent files: inbox, cursor. (The transport marker was already removed
1549
- // above if a pusher was live; nothing to move otherwise.)
1550
- await moveFile(inboxFile(oldId), inboxFile(newId));
1551
- await moveFile(cursorFile(oldId), cursorFile(newId));
1552
- await moveFile(transportFile(oldId), transportFile(newId));
1553
-
1554
- // Identity-binding token rotation: if tokens.json exists and had the old
1555
- // id, move its token to the new id atomically. Lets the same bearer keep
1556
- // authenticating after rename — no-op if binding isn't configured.
1557
- await rotateAgentToken(oldId, newId);
1558
-
1559
- // Broadcast a NICK notice to every channel the agent was in.
1560
- for (const chan of joined) {
1561
- await appendJsonl(roomFile(chan), sysMsg(newId, chan, `is now known as ${newId} (was ${oldId})`));
1562
- }
1563
-
1564
- return {
1565
- ok: true,
1566
- from: oldId,
1567
- to: newId,
1568
- rooms: joined,
1569
- detachedTransport,
1570
- ...(detachedTransport
1571
- ? { warning: `the live tmux-push transport was detached during rename — re-attach as '${newId}' (e.g. join/attach_agent) to restore real-time delivery` }
1572
- : {}),
1573
- };
1574
- }
1575
-
1576
675
  // ---------- transport markers (for remote pushers) ----------
1577
676
 
1578
677
  export const reportTransportSchema = {
@@ -2035,87 +1134,3 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
2035
1134
  };
2036
1135
  }
2037
1136
 
2038
- // ---------- delete_room ----------
2039
-
2040
- export const deleteRoomSchema = {
2041
- agentId: z.string().min(1),
2042
- room: z.string().min(1),
2043
- // When true, kick remaining members instead of refusing.
2044
- force: z.boolean().optional(),
2045
- };
2046
-
2047
- export async function deleteRoomTool(args: { agentId: string; room: string; force?: boolean }) {
2048
- const chan = normalizeRoom(args.room);
2049
- if (chan === DEFAULT_ROOM) return { ok: false, error: "cannot delete the default channel" };
2050
-
2051
- const rooms = await getRooms();
2052
- const entry = rooms[chan];
2053
- if (!entry) return { ok: false, error: `room '${chan}' does not exist` };
2054
-
2055
- const members = entry.members ?? [];
2056
- if (members.length > 0 && !args.force) {
2057
- return {
2058
- ok: false,
2059
- error: `room '${chan}' still has ${members.length} member(s): ${members.join(", ")}. Pass force=true to delete anyway.`,
2060
- members,
2061
- };
2062
- }
2063
-
2064
- // Remove from rooms registry.
2065
- await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
2066
- delete current[chan];
2067
- return current;
2068
- });
2069
-
2070
- // Delete the backing JSONL file.
2071
- const file = roomFile(chan);
2072
- const fileDeleted = await deleteFile(file);
2073
-
2074
- // Drop the channel offset from every agent's cursor so no offset points into
2075
- // a now-deleted file.
2076
- const cursorsAdjusted: string[] = [];
2077
- for (const fname of await listCursorFiles()) {
2078
- const id = fname.replace(/\.json$/, "");
2079
- const cursorPath = path.join(CURSOR_DIR, fname);
2080
- let touched = false;
2081
- await updateJson<Cursor>(cursorPath, {}, (current) => {
2082
- if (current.roomOffsets?.[chan] !== undefined) {
2083
- delete current.roomOffsets[chan];
2084
- touched = true;
2085
- }
2086
- return current;
2087
- });
2088
- if (touched) cursorsAdjusted.push(id);
2089
- }
2090
-
2091
- await appendJsonl(ROOM_FILE, sysMsg(args.agentId, DEFAULT_ROOM, `deleted channel #${chan}`));
2092
- return { ok: true, room: chan, fileDeleted, members, cursorsAdjusted };
2093
- }
2094
-
2095
- // ---------- force_unregister ----------
2096
-
2097
- export const forceUnregisterSchema = {
2098
- targetAgentId: z.string().min(1),
2099
- };
2100
-
2101
- // Admin eviction — same logic as unregister but bypasses the identity gate
2102
- // so the caller does not need to be the target agent.
2103
- export async function forceUnregisterTool(args: { targetAgentId: string }) {
2104
- return unregisterTool({ agentId: args.targetAgentId });
2105
- }
2106
-
2107
- // ---------- helpers ----------
2108
-
2109
- async function moveFile(from: string, to: string): Promise<boolean> {
2110
- if (!existsSync(from)) return false;
2111
- await fsp.mkdir(path.dirname(to), { recursive: true });
2112
- try {
2113
- await fsp.rename(from, to);
2114
- } catch {
2115
- // Cross-device or other rename failure — fall back to copy + unlink.
2116
- const data = await fsp.readFile(from);
2117
- await fsp.writeFile(to, data);
2118
- await fsp.unlink(from);
2119
- }
2120
- return true;
2121
- }