agent-coord-mcp 0.5.2 → 0.7.1
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 +95 -7
- package/dist/server.js +235 -25
- package/dist/server.js.map +1 -1
- package/dist/store.js +50 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +135 -11
- package/dist/tools.js.map +1 -1
- package/hooks/peek-coord.mjs +21 -19
- package/package.json +10 -4
- package/scripts/coord-chat.mjs +17 -5
- package/scripts/coord-pusher.mjs +288 -0
- package/src/server.ts +270 -26
- package/src/store.ts +56 -1
- package/src/tools.ts +149 -10
package/src/tools.ts
CHANGED
|
@@ -32,8 +32,10 @@ import {
|
|
|
32
32
|
removeMember,
|
|
33
33
|
rewriteJsonl,
|
|
34
34
|
roomFile,
|
|
35
|
+
rotateAgentToken,
|
|
35
36
|
setRoomMeta,
|
|
36
37
|
transportFile,
|
|
38
|
+
TRANSPORT_DIR,
|
|
37
39
|
updateJson,
|
|
38
40
|
type RoomRegistry,
|
|
39
41
|
} from "./store.js";
|
|
@@ -53,6 +55,9 @@ type TransportMarker = {
|
|
|
53
55
|
pid: number;
|
|
54
56
|
tmuxTarget?: string;
|
|
55
57
|
since: number;
|
|
58
|
+
// Remote pushers run on a different machine; the local pid is meaningless,
|
|
59
|
+
// so we tag the host and use heartbeat-based liveness instead of pidAlive.
|
|
60
|
+
host?: string;
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
type AgentRegistry = Record<string, AgentEntry>;
|
|
@@ -136,6 +141,7 @@ export async function registerTool(args: { agentId: string; project?: string; ro
|
|
|
136
141
|
role: args.role ?? existing?.role,
|
|
137
142
|
registeredAt: existing?.registeredAt ?? now,
|
|
138
143
|
lastHeartbeat: now,
|
|
144
|
+
capabilities: existing?.capabilities,
|
|
139
145
|
};
|
|
140
146
|
return current;
|
|
141
147
|
});
|
|
@@ -159,7 +165,20 @@ export async function unregisterTool(args: { agentId: string }) {
|
|
|
159
165
|
}
|
|
160
166
|
return current;
|
|
161
167
|
});
|
|
162
|
-
|
|
168
|
+
|
|
169
|
+
// Drop the agent from every channel's membership so it doesn't linger as a
|
|
170
|
+
// ghost in list_rooms / joinedRooms() after it's gone from the registry.
|
|
171
|
+
const leftRooms: string[] = [];
|
|
172
|
+
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
173
|
+
for (const [chan, e] of Object.entries(current)) {
|
|
174
|
+
if (e.members?.includes(args.agentId)) {
|
|
175
|
+
e.members = e.members.filter((m) => m !== args.agentId);
|
|
176
|
+
leftRooms.push(chan);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return current;
|
|
180
|
+
});
|
|
181
|
+
return { ok: true, removed: existed, detach, leftRooms };
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
// ---------- heartbeat ----------
|
|
@@ -226,14 +245,26 @@ export async function listAgentsTool() {
|
|
|
226
245
|
|
|
227
246
|
async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
|
|
228
247
|
const out = new Map<string, TransportMarker>();
|
|
248
|
+
// For remote markers we can't pid-check the foreign process — instead we trust
|
|
249
|
+
// the registry's lastHeartbeat, which the remote pusher refreshes every minute.
|
|
250
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
251
|
+
const now = Date.now();
|
|
229
252
|
for (const fname of await listTransportFiles()) {
|
|
230
|
-
const file = path.join(
|
|
253
|
+
const file = path.join(TRANSPORT_DIR, fname);
|
|
231
254
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
232
255
|
if (!marker) {
|
|
233
256
|
await deleteFile(file);
|
|
234
257
|
continue;
|
|
235
258
|
}
|
|
236
|
-
|
|
259
|
+
const isRemote = marker.transport === "tmux-push-remote";
|
|
260
|
+
if (isRemote) {
|
|
261
|
+
const entry = reg[marker.agentId];
|
|
262
|
+
const fresh = !!entry && now - entry.lastHeartbeat < STALE_MS;
|
|
263
|
+
if (!fresh) {
|
|
264
|
+
await deleteFile(file);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
} else if (!isPidAlive(marker.pid)) {
|
|
237
268
|
await deleteFile(file);
|
|
238
269
|
continue;
|
|
239
270
|
}
|
|
@@ -280,7 +311,14 @@ export async function sendMessageTool(args: {
|
|
|
280
311
|
};
|
|
281
312
|
const target = inboxFile(args.to);
|
|
282
313
|
await appendJsonl(target, msg);
|
|
283
|
-
|
|
314
|
+
// Offline delivery is intentional (the inbox is created on demand), but a
|
|
315
|
+
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
316
|
+
// target isn't a known agent so the caller can catch the mistake.
|
|
317
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
318
|
+
const warning = reg[args.to]
|
|
319
|
+
? undefined
|
|
320
|
+
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
321
|
+
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
284
322
|
}
|
|
285
323
|
|
|
286
324
|
const chan = normalizeRoom(args.room);
|
|
@@ -350,6 +388,7 @@ export async function readMessagesTool(args: {
|
|
|
350
388
|
: limited;
|
|
351
389
|
|
|
352
390
|
return {
|
|
391
|
+
ok: true,
|
|
353
392
|
messages: visible,
|
|
354
393
|
totalNew,
|
|
355
394
|
returned: visible.length,
|
|
@@ -487,6 +526,11 @@ export async function pruneTool(args: {
|
|
|
487
526
|
const entries = await readJsonl<Message>(path.join(INBOX_DIR, fname));
|
|
488
527
|
inboxRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
489
528
|
}
|
|
529
|
+
const rooms = await getRooms();
|
|
530
|
+
const orphanMembers = new Set<string>();
|
|
531
|
+
for (const e of Object.values(rooms)) {
|
|
532
|
+
for (const m of e.members ?? []) if (!knownAgents.has(m)) orphanMembers.add(m);
|
|
533
|
+
}
|
|
490
534
|
return {
|
|
491
535
|
dryRun: true,
|
|
492
536
|
cutoff,
|
|
@@ -496,6 +540,7 @@ export async function pruneTool(args: {
|
|
|
496
540
|
statusEntries: status.filter((e) => e.ts <= cutoff).length,
|
|
497
541
|
inboxMessages: inboxRemoved,
|
|
498
542
|
orphanInboxes: orphans,
|
|
543
|
+
orphanMembers: [...orphanMembers],
|
|
499
544
|
},
|
|
500
545
|
};
|
|
501
546
|
}
|
|
@@ -569,6 +614,24 @@ export async function pruneTool(args: {
|
|
|
569
614
|
if (touched) cursorsAdjusted.push(id);
|
|
570
615
|
}
|
|
571
616
|
|
|
617
|
+
// Compact channel memberships: drop any member no longer in the registry so
|
|
618
|
+
// list_rooms / joinedRooms() stop surfacing ghosts (mirrors orphan-inbox
|
|
619
|
+
// cleanup). Empty non-default channels are left in place — their history may
|
|
620
|
+
// still matter and `general` must always persist.
|
|
621
|
+
const orphanMembers = new Set<string>();
|
|
622
|
+
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
623
|
+
for (const e of Object.values(current)) {
|
|
624
|
+
const before = e.members?.length ?? 0;
|
|
625
|
+
if (before === 0) continue;
|
|
626
|
+
e.members = (e.members ?? []).filter((m) => {
|
|
627
|
+
const keep = knownAgents.has(m);
|
|
628
|
+
if (!keep) orphanMembers.add(m);
|
|
629
|
+
return keep;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return current;
|
|
633
|
+
});
|
|
634
|
+
|
|
572
635
|
return {
|
|
573
636
|
dryRun: false,
|
|
574
637
|
cutoff,
|
|
@@ -578,6 +641,7 @@ export async function pruneTool(args: {
|
|
|
578
641
|
statusEntries: statusResult.removed,
|
|
579
642
|
inboxMessages: inboxRemoved,
|
|
580
643
|
orphanInboxes: deletedOrphans,
|
|
644
|
+
orphanMembers: [...orphanMembers],
|
|
581
645
|
},
|
|
582
646
|
cursorsAdjusted,
|
|
583
647
|
};
|
|
@@ -641,15 +705,18 @@ export async function attachAgentTool(args: {
|
|
|
641
705
|
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
642
706
|
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
643
707
|
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
644
|
-
const
|
|
645
|
-
const err = openSync(log, "a");
|
|
708
|
+
const logFd = openSync(log, "a");
|
|
646
709
|
// Default: deliver room broadcasts too. The bus is chat-first — silence on
|
|
647
710
|
// a room post is a worse failure mode than a slightly noisier pane. Callers
|
|
648
711
|
// who want DM-only can pass includeRoom:false explicitly.
|
|
649
712
|
const includeRoom = args.includeRoom !== false;
|
|
650
|
-
|
|
713
|
+
// Use the exact node binary running this server, not bare "node" — the MCP
|
|
714
|
+
// server is often launched via an absolute path (nvm/Homebrew/bundled
|
|
715
|
+
// runtime) that isn't on the spawned child's PATH, which would silently fail
|
|
716
|
+
// the pusher launch ("attached but nothing arrives").
|
|
717
|
+
const child = spawn(process.execPath, [pusher], {
|
|
651
718
|
detached: true,
|
|
652
|
-
stdio: ["ignore",
|
|
719
|
+
stdio: ["ignore", logFd, logFd],
|
|
653
720
|
env: {
|
|
654
721
|
...process.env,
|
|
655
722
|
AGENT_COORD_ID: args.agentId,
|
|
@@ -952,6 +1019,18 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
952
1019
|
|
|
953
1020
|
const joined = await memberRooms(oldId);
|
|
954
1021
|
|
|
1022
|
+
// A running pusher has the OLD agentId (and its file paths) baked into its
|
|
1023
|
+
// env, so after we migrate the inbox/cursor below it would keep tailing the
|
|
1024
|
+
// now-empty old inbox while new DMs land in the new one — silently breaking
|
|
1025
|
+
// delivery and orphaning the moved marker. Take it down first; the caller
|
|
1026
|
+
// must re-attach under the new id (join/attach_agent) to restore push.
|
|
1027
|
+
const liveTransport = await readJson<TransportMarker | null>(transportFile(oldId), null);
|
|
1028
|
+
let detachedTransport = false;
|
|
1029
|
+
if (liveTransport && isPidAlive(liveTransport.pid)) {
|
|
1030
|
+
await detachAgentTool({ agentId: oldId });
|
|
1031
|
+
detachedTransport = true;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
955
1034
|
// Registry: move the entry under the new key.
|
|
956
1035
|
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
957
1036
|
if (current[oldId]) {
|
|
@@ -969,17 +1048,77 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
969
1048
|
return current;
|
|
970
1049
|
});
|
|
971
1050
|
|
|
972
|
-
// Per-agent files: inbox, cursor
|
|
1051
|
+
// Per-agent files: inbox, cursor. (The transport marker was already removed
|
|
1052
|
+
// above if a pusher was live; nothing to move otherwise.)
|
|
973
1053
|
await moveFile(inboxFile(oldId), inboxFile(newId));
|
|
974
1054
|
await moveFile(cursorFile(oldId), cursorFile(newId));
|
|
975
1055
|
await moveFile(transportFile(oldId), transportFile(newId));
|
|
976
1056
|
|
|
1057
|
+
// Identity-binding token rotation: if tokens.json exists and had the old
|
|
1058
|
+
// id, move its token to the new id atomically. Lets the same bearer keep
|
|
1059
|
+
// authenticating after rename — no-op if binding isn't configured.
|
|
1060
|
+
await rotateAgentToken(oldId, newId);
|
|
1061
|
+
|
|
977
1062
|
// Broadcast a NICK notice to every channel the agent was in.
|
|
978
1063
|
for (const chan of joined) {
|
|
979
1064
|
await appendJsonl(roomFile(chan), sysMsg(newId, chan, `is now known as ${newId} (was ${oldId})`));
|
|
980
1065
|
}
|
|
981
1066
|
|
|
982
|
-
return {
|
|
1067
|
+
return {
|
|
1068
|
+
ok: true,
|
|
1069
|
+
from: oldId,
|
|
1070
|
+
to: newId,
|
|
1071
|
+
rooms: joined,
|
|
1072
|
+
detachedTransport,
|
|
1073
|
+
...(detachedTransport
|
|
1074
|
+
? { warning: `the live tmux-push transport was detached during rename — re-attach as '${newId}' (e.g. join/attach_agent) to restore real-time delivery` }
|
|
1075
|
+
: {}),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---------- transport markers (for remote pushers) ----------
|
|
1080
|
+
|
|
1081
|
+
export const reportTransportSchema = {
|
|
1082
|
+
agentId: z.string().min(1),
|
|
1083
|
+
transport: z.string().min(1),
|
|
1084
|
+
tmuxTarget: z.string().optional(),
|
|
1085
|
+
host: z.string().optional(),
|
|
1086
|
+
since: z.number().optional(),
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Called by an external push daemon (typically scripts/coord-pusher.mjs on a
|
|
1090
|
+
// remote machine) to publish a transport marker so list_agents reflects the
|
|
1091
|
+
// attachment. The local tmux-push path writes the marker directly inside
|
|
1092
|
+
// attach_agent; this is the wire-callable equivalent for remote pushers.
|
|
1093
|
+
export async function reportTransportTool(args: {
|
|
1094
|
+
agentId: string;
|
|
1095
|
+
transport: string;
|
|
1096
|
+
tmuxTarget?: string;
|
|
1097
|
+
host?: string;
|
|
1098
|
+
since?: number;
|
|
1099
|
+
}) {
|
|
1100
|
+
const marker: TransportMarker = {
|
|
1101
|
+
agentId: args.agentId,
|
|
1102
|
+
transport: args.transport,
|
|
1103
|
+
pid: 0, // not meaningful for remote; liveness comes from heartbeat
|
|
1104
|
+
tmuxTarget: args.tmuxTarget,
|
|
1105
|
+
host: args.host,
|
|
1106
|
+
since: args.since ?? Date.now(),
|
|
1107
|
+
};
|
|
1108
|
+
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
1109
|
+
return { ok: true, marker };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
export const clearTransportSchema = {
|
|
1113
|
+
agentId: z.string().min(1),
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// Idempotent remote-counterpart to detach_agent: just deletes the marker. Used
|
|
1117
|
+
// by the remote pusher on graceful shutdown so list_agents stops showing it
|
|
1118
|
+
// attached. (Does NOT try to kill any process — there's nothing local to kill.)
|
|
1119
|
+
export async function clearTransportTool(args: { agentId: string }) {
|
|
1120
|
+
const removed = await deleteFile(transportFile(args.agentId));
|
|
1121
|
+
return { ok: true, removed };
|
|
983
1122
|
}
|
|
984
1123
|
|
|
985
1124
|
// ---------- helpers ----------
|