agent-coord-mcp 0.3.5 → 0.3.7
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/hooks/peek-coord.mjs +44 -6
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +156 -25
package/hooks/peek-coord.mjs
CHANGED
|
@@ -10,21 +10,31 @@
|
|
|
10
10
|
// Optional env: AGENT_COORD_DIR (default ~/agent-coord)
|
|
11
11
|
// Optional env: AGENT_COORD_INCLUDE_ROOM=1 to also drain the shared room
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
|
|
17
17
|
const MODE = (process.argv.find((a) => a.startsWith("--mode="))?.slice(7)) ?? "user-prompt";
|
|
18
|
-
const AGENT_ID = process.env.AGENT_COORD_ID;
|
|
19
|
-
if (!AGENT_ID) {
|
|
20
|
-
// No agent id configured — silently no-op so the hook never blocks the user.
|
|
21
|
-
process.exit(0);
|
|
22
|
-
}
|
|
23
18
|
|
|
24
19
|
const ROOT =
|
|
25
20
|
process.env.AGENT_COORD_DIR ??
|
|
26
21
|
process.env.CLAUDE_COORD_DIR ??
|
|
27
22
|
path.join(homedir(), "agent-coord");
|
|
23
|
+
|
|
24
|
+
// Prefer the agentId that is actually attached to this tmux pane (via the
|
|
25
|
+
// transports/ registry) over the env-provided one. This is what lets the
|
|
26
|
+
// generic settings.json hook command — which derives AGENT_COORD_ID from the
|
|
27
|
+
// cwd basename — still resolve to the agent that registered with a custom id
|
|
28
|
+
// (e.g. "claude-david" rather than "linkaroo.io"). Without this, peek reads
|
|
29
|
+
// the wrong cursor + inbox files and the m.from === AGENT_ID self-filter
|
|
30
|
+
// below never matches the agent's own room posts.
|
|
31
|
+
const AGENT_ID = resolveAgentId();
|
|
32
|
+
if (!AGENT_ID) {
|
|
33
|
+
// No agent id configured and no tmux transport match — silently no-op so
|
|
34
|
+
// the hook never blocks the user.
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
const INBOX = path.join(ROOT, "inbox", `${sanitize(AGENT_ID)}.jsonl`);
|
|
29
39
|
const ROOM = path.join(ROOT, "room.jsonl");
|
|
30
40
|
const CURSOR = path.join(ROOT, "cursors", `${sanitize(AGENT_ID)}.json`);
|
|
@@ -107,3 +117,31 @@ function writeJsonAtomic(file, data) {
|
|
|
107
117
|
function sanitize(id) {
|
|
108
118
|
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
109
119
|
}
|
|
120
|
+
|
|
121
|
+
function resolveAgentId() {
|
|
122
|
+
const pane = process.env.TMUX_PANE;
|
|
123
|
+
if (pane) {
|
|
124
|
+
const fromTransport = lookupAgentByPane(pane);
|
|
125
|
+
if (fromTransport) return fromTransport;
|
|
126
|
+
}
|
|
127
|
+
return process.env.AGENT_COORD_ID || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function lookupAgentByPane(pane) {
|
|
131
|
+
const dir = path.join(ROOT, "transports");
|
|
132
|
+
if (!existsSync(dir)) return null;
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = readdirSync(dir);
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
for (const name of entries) {
|
|
140
|
+
if (!name.endsWith(".json")) continue;
|
|
141
|
+
const data = readJson(path.join(dir, name), null);
|
|
142
|
+
if (data && data.tmuxTarget === pane && typeof data.agentId === "string") {
|
|
143
|
+
return data.agentId;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
package/package.json
CHANGED
package/scripts/coord-chat.mjs
CHANGED
|
@@ -53,6 +53,29 @@ const CURSOR_FILE = path.join(CURSOR_DIR, `${sanitize(ID)}.json`);
|
|
|
53
53
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
54
54
|
mkdirSync(CURSOR_DIR, { recursive: true });
|
|
55
55
|
|
|
56
|
+
// ---------- ANSI helpers ----------
|
|
57
|
+
|
|
58
|
+
const A = {
|
|
59
|
+
reset: "\x1b[0m",
|
|
60
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
61
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
62
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
63
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
64
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
65
|
+
blue: (s) => `\x1b[34m${s}\x1b[0m`,
|
|
66
|
+
magenta: (s) => `\x1b[35m${s}\x1b[0m`,
|
|
67
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Stable per-agent color from agentId hash. Skip red (reserved for errors)
|
|
71
|
+
// and white/black; cycle the remaining 5 bright colors.
|
|
72
|
+
const AGENT_COLORS = [A.green, A.yellow, A.blue, A.magenta, A.cyan];
|
|
73
|
+
function agentColor(id) {
|
|
74
|
+
let h = 0;
|
|
75
|
+
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
|
76
|
+
return AGENT_COLORS[h % AGENT_COLORS.length];
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
// ---------- register and start UI ----------
|
|
57
80
|
|
|
58
81
|
await register();
|
|
@@ -60,22 +83,18 @@ await register();
|
|
|
60
83
|
const rl = readline.createInterface({
|
|
61
84
|
input: process.stdin,
|
|
62
85
|
output: process.stdout,
|
|
63
|
-
prompt:
|
|
86
|
+
prompt: makePrompt(),
|
|
64
87
|
});
|
|
65
88
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
69
|
-
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
70
|
-
|
|
71
|
-
say(dim(`coord-chat — agentId=${ID} dir=${ROOT}`));
|
|
72
|
-
say(dim("commands: <text>=room /dm <id> <text> /list /help /quit"));
|
|
73
|
-
|
|
89
|
+
// Banner — printed once on launch. Keep it tight; this is a CLI, not a poster.
|
|
90
|
+
printBanner();
|
|
74
91
|
await drainAndPrint();
|
|
75
92
|
|
|
76
93
|
try { watch(INBOX_FILE, () => void drainAndPrint()); } catch {}
|
|
77
94
|
try { watch(ROOM_FILE, () => void drainAndPrint()); } catch {}
|
|
95
|
+
try { watch(AGENTS_FILE, () => refreshPrompt()); } catch {}
|
|
78
96
|
setInterval(() => void drainAndPrint(), 1000);
|
|
97
|
+
setInterval(refreshPrompt, 5000);
|
|
79
98
|
|
|
80
99
|
rl.prompt();
|
|
81
100
|
|
|
@@ -85,23 +104,36 @@ rl.on("line", async (line) => {
|
|
|
85
104
|
try {
|
|
86
105
|
if (text === "/quit" || text === "/exit") {
|
|
87
106
|
await unregister();
|
|
88
|
-
say("bye.");
|
|
107
|
+
say(A.dim("bye."));
|
|
89
108
|
process.exit(0);
|
|
90
|
-
} else if (text === "/help") {
|
|
91
|
-
|
|
92
|
-
} else if (text === "/list") {
|
|
109
|
+
} else if (text === "/help" || text === "/?") {
|
|
110
|
+
printHelp();
|
|
111
|
+
} else if (text === "/list" || text === "/who") {
|
|
93
112
|
await printAgents();
|
|
113
|
+
} else if (text === "/whoami") {
|
|
114
|
+
await printWhoami();
|
|
115
|
+
} else if (text === "/clear" || text === "/cls") {
|
|
116
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
117
|
+
printBanner();
|
|
118
|
+
} else if (text.startsWith("/last")) {
|
|
119
|
+
const m = text.match(/^\/last(?:\s+(\d+))?$/);
|
|
120
|
+
const n = m && m[1] ? parseInt(m[1], 10) : 20;
|
|
121
|
+
await printRecent(n);
|
|
122
|
+
} else if (text.startsWith("/me ")) {
|
|
123
|
+
const action = text.slice(4).trim();
|
|
124
|
+
if (!action) say(A.red("usage: /me <action>"));
|
|
125
|
+
else await sendRoom(`* ${ID} ${action}`);
|
|
94
126
|
} else if (text.startsWith("/dm ")) {
|
|
95
127
|
const m = text.match(/^\/dm\s+(\S+)\s+([\s\S]+)$/);
|
|
96
|
-
if (!m) say(red("usage: /dm <agentId> <text>"));
|
|
128
|
+
if (!m) say(A.red("usage: /dm <agentId> <text>"));
|
|
97
129
|
else await sendDm(m[1], m[2]);
|
|
98
130
|
} else if (text.startsWith("/")) {
|
|
99
|
-
say(red(`unknown command: ${text.split(" ")[0]}`));
|
|
131
|
+
say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
|
|
100
132
|
} else {
|
|
101
133
|
await sendRoom(text);
|
|
102
134
|
}
|
|
103
135
|
} catch (e) {
|
|
104
|
-
say(red(`error: ${e?.message ?? e}`));
|
|
136
|
+
say(A.red(`error: ${e?.message ?? e}`));
|
|
105
137
|
}
|
|
106
138
|
rl.prompt();
|
|
107
139
|
});
|
|
@@ -142,6 +174,80 @@ function say(line) {
|
|
|
142
174
|
if (typeof rl !== "undefined") rl.prompt(true);
|
|
143
175
|
}
|
|
144
176
|
|
|
177
|
+
function makePrompt() {
|
|
178
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const STALE = 5 * 60 * 1000;
|
|
181
|
+
let online = 0;
|
|
182
|
+
for (const id of Object.keys(reg)) {
|
|
183
|
+
if (id === ID) continue;
|
|
184
|
+
const a = reg[id];
|
|
185
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
|
|
186
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
187
|
+
if (live || now - a.lastHeartbeat < STALE) online++;
|
|
188
|
+
}
|
|
189
|
+
const peers = online === 1 ? "1 peer" : `${online} peers`;
|
|
190
|
+
return `${agentColor(ID)(ID)} ${A.dim(`(${peers})`)}${A.dim(">")} `;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function refreshPrompt() {
|
|
194
|
+
if (typeof rl === "undefined") return;
|
|
195
|
+
rl.setPrompt(makePrompt());
|
|
196
|
+
rl.prompt(true);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function printBanner() {
|
|
200
|
+
// Compact banner — three lines plus a separator. ASCII so it renders
|
|
201
|
+
// anywhere; no UTF-8 box-drawing surprises.
|
|
202
|
+
const lines = [
|
|
203
|
+
A.bold(A.cyan(" agent-coord ")) + A.dim("— shared chat for agents and humans"),
|
|
204
|
+
A.dim(` agentId=${A.reset}${agentColor(ID)(ID)}${A.dim(" dir=" + ROOT)}`),
|
|
205
|
+
A.dim(" type /help for commands · /quit to leave"),
|
|
206
|
+
];
|
|
207
|
+
for (const l of lines) say(l);
|
|
208
|
+
say(A.dim(" " + "─".repeat(60)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printHelp() {
|
|
212
|
+
const rows = [
|
|
213
|
+
["<text>", "post to the shared room"],
|
|
214
|
+
["/dm <agent> <text>", "send a direct message"],
|
|
215
|
+
["/me <action>", "post an IRC-style action (* you wave)"],
|
|
216
|
+
["/list, /who", "show registered agents + transports"],
|
|
217
|
+
["/whoami", "show your registration + transport"],
|
|
218
|
+
["/last [n]", "show last n messages (default 20)"],
|
|
219
|
+
["/clear", "clear the screen"],
|
|
220
|
+
["/help, /?", "this list"],
|
|
221
|
+
["/quit, /exit", "unregister and leave"],
|
|
222
|
+
];
|
|
223
|
+
say(A.bold("commands:"));
|
|
224
|
+
for (const [cmd, desc] of rows) {
|
|
225
|
+
say(` ${A.cyan(cmd.padEnd(22))} ${A.dim(desc)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function printWhoami() {
|
|
230
|
+
const reg = readJsonSafe(AGENTS_FILE, {});
|
|
231
|
+
const a = reg[ID];
|
|
232
|
+
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(ID)}.json`), null);
|
|
233
|
+
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
234
|
+
say(A.bold("you:"));
|
|
235
|
+
say(` ${A.cyan("id")} ${agentColor(ID)(ID)}`);
|
|
236
|
+
say(` ${A.cyan("role")} ${a?.role ?? "-"}`);
|
|
237
|
+
say(` ${A.cyan("dir")} ${A.dim(ROOT)}`);
|
|
238
|
+
say(` ${A.cyan("transport")} ${live ? A.green(marker.transport) : A.dim("none")}`);
|
|
239
|
+
say(` ${A.cyan("registered")} ${a ? A.green("yes") : A.red("no")}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function printRecent(n) {
|
|
243
|
+
const inbox = readJsonl(INBOX_FILE).slice(-n).map((m) => ({ ...m, _kind: "DM" }));
|
|
244
|
+
const room = readJsonl(ROOM_FILE).slice(-n).map((m) => ({ ...m, _kind: "room" }));
|
|
245
|
+
const all = [...inbox, ...room].sort((a, b) => a.ts - b.ts).slice(-n);
|
|
246
|
+
if (!all.length) return say(A.dim("(no history)"));
|
|
247
|
+
say(A.bold(`last ${all.length} message(s):`));
|
|
248
|
+
for (const m of all) printMsg(m._kind, m, { history: true });
|
|
249
|
+
}
|
|
250
|
+
|
|
145
251
|
async function withLock(file, fn) {
|
|
146
252
|
await ensureFile(file);
|
|
147
253
|
const release = await lockfile.lock(file, {
|
|
@@ -184,7 +290,7 @@ async function unregister() {
|
|
|
184
290
|
async function sendDm(to, text) {
|
|
185
291
|
const target = path.join(INBOX_DIR, `${sanitize(to)}.jsonl`);
|
|
186
292
|
await appendMessage(target, { from: ID, to, text });
|
|
187
|
-
say(dim(`→ DM sent to ${to}`));
|
|
293
|
+
say(A.dim(`→ DM sent to ${to}`));
|
|
188
294
|
}
|
|
189
295
|
|
|
190
296
|
async function sendRoom(text) {
|
|
@@ -226,10 +332,21 @@ async function drainAndPrint() {
|
|
|
226
332
|
if (changed) writeJsonAtomic(CURSOR_FILE, cursor);
|
|
227
333
|
}
|
|
228
334
|
|
|
229
|
-
function printMsg(kind, m) {
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
335
|
+
function printMsg(kind, m, opts = {}) {
|
|
336
|
+
const d = new Date(m.ts);
|
|
337
|
+
const t = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
338
|
+
const who = m.from ?? "?";
|
|
339
|
+
const tag = kind === "DM" ? A.bold(A.cyan("DM")) : A.dim("room");
|
|
340
|
+
const meta = `${A.dim(t)} ${tag} ${agentColor(who)(who)}`;
|
|
341
|
+
// Multi-line bodies: first line on the meta row, subsequent lines indented
|
|
342
|
+
// to the body column for readability.
|
|
343
|
+
const body = (m.text ?? "").split("\n");
|
|
344
|
+
const indent = " ";
|
|
345
|
+
const first = body[0] ?? "";
|
|
346
|
+
const rest = body.slice(1).map((l) => indent + A.dim("│ ") + l);
|
|
347
|
+
const prefix = opts.history ? A.dim(" ") : "";
|
|
348
|
+
say(`${prefix}${meta} ${first}`);
|
|
349
|
+
for (const line of rest) say(`${prefix}${line}`);
|
|
233
350
|
}
|
|
234
351
|
|
|
235
352
|
async function printAgents() {
|
|
@@ -237,14 +354,28 @@ async function printAgents() {
|
|
|
237
354
|
const now = Date.now();
|
|
238
355
|
const STALE = 5 * 60 * 1000;
|
|
239
356
|
const ids = Object.keys(reg).sort();
|
|
240
|
-
if (!ids.length) return say(dim("(no agents)"));
|
|
357
|
+
if (!ids.length) return say(A.dim("(no agents)"));
|
|
358
|
+
// Compute column widths from data so things line up.
|
|
359
|
+
const idW = Math.max(8, ...ids.map((i) => i.length));
|
|
360
|
+
const roleW = Math.max(4, ...ids.map((i) => (reg[i].role ?? "-").length));
|
|
361
|
+
say(A.bold(`agents (${ids.length}):`));
|
|
362
|
+
say(
|
|
363
|
+
" " +
|
|
364
|
+
A.dim(
|
|
365
|
+
`${"id".padEnd(idW)} ${"status".padEnd(7)} ${"role".padEnd(roleW)} transport`,
|
|
366
|
+
),
|
|
367
|
+
);
|
|
241
368
|
for (const id of ids) {
|
|
242
369
|
const a = reg[id];
|
|
243
370
|
const marker = readJsonSafe(path.join(TRANSPORT_DIR, `${sanitize(id)}.json`), null);
|
|
244
371
|
const live = marker && marker.pid && pidAlive(marker.pid);
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
372
|
+
const onlineNow = live || now - a.lastHeartbeat < STALE;
|
|
373
|
+
const dot = onlineNow ? A.green("●") : A.dim("○");
|
|
374
|
+
const status = onlineNow ? "online " : "offline";
|
|
375
|
+
const role = (a.role ?? "-").padEnd(roleW);
|
|
376
|
+
const trans = live ? A.green(marker.transport) : A.dim("none");
|
|
377
|
+
const me = id === ID ? A.dim(" (you)") : "";
|
|
378
|
+
say(` ${dot} ${agentColor(id)(id.padEnd(idW))} ${A.dim(status)} ${role} ${trans}${me}`);
|
|
248
379
|
}
|
|
249
380
|
}
|
|
250
381
|
|