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/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
- return { ok: true, removed: existed, detach };
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(path.dirname(transportFile("x")), fname);
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
- if (!isPidAlive(marker.pid)) {
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
- return { ok: true, id: msg.id, target, room: undefined };
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 out = openSync(log, "a");
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
- const child = spawn("node", [pusher], {
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", out, err],
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, transport marker.
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 { ok: true, from: oldId, to: newId, rooms: joined };
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 ----------