agent-coord-mcp 0.1.0 → 0.2.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 +51 -1
- package/dist/server.js +0 -0
- package/hooks/peek-coord.mjs +109 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -111,7 +111,57 @@ Set `AGENT_COORD_DIR=/some/other/path` in the MCP server's env to relocate state
|
|
|
111
111
|
|
|
112
112
|
`wait_for_message` is the cheap path: one tool call, server-side `fs.watch` + 500ms poll, capped at 60s. The model only pays for one round-trip per wait.
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
But the model is fundamentally turn-based — there's no async push that wakes an idle agent. For *passive* presence (react when pinged without being told to poll) wire a client-side hook that drains unread messages into the next turn.
|
|
115
|
+
|
|
116
|
+
### Claude Code hook
|
|
117
|
+
|
|
118
|
+
A reference hook ships in [`hooks/peek-coord.mjs`](./hooks/peek-coord.mjs). It reads `~/agent-coord/inbox/<id>.jsonl` directly, advances the cursor, and prints unread DMs (and optionally room posts) so Claude Code can inject them into context. No MCP roundtrip, no extra deps.
|
|
119
|
+
|
|
120
|
+
Two places to wire it:
|
|
121
|
+
|
|
122
|
+
- **`UserPromptSubmit`** — fires before the agent sees the user's next message. Stdout is appended to context. Good for *"new DMs since last turn"*.
|
|
123
|
+
- **`Stop`** — fires when the agent finishes its turn. If there are unread messages, the hook returns `{"decision":"block","reason":"..."}` which keeps the session going and feeds the messages in. Good for *"peer pinged me 2 seconds after I stopped"*.
|
|
124
|
+
|
|
125
|
+
Add to your project or user `settings.json`:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"hooks": {
|
|
130
|
+
"UserPromptSubmit": [
|
|
131
|
+
{
|
|
132
|
+
"hooks": [
|
|
133
|
+
{
|
|
134
|
+
"type": "command",
|
|
135
|
+
"command": "AGENT_COORD_ID=frontend node /absolute/path/to/agent-coord-mcp/hooks/peek-coord.mjs --mode=user-prompt"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
"Stop": [
|
|
141
|
+
{
|
|
142
|
+
"hooks": [
|
|
143
|
+
{
|
|
144
|
+
"type": "command",
|
|
145
|
+
"command": "AGENT_COORD_ID=frontend node /absolute/path/to/agent-coord-mcp/hooks/peek-coord.mjs --mode=stop"
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Set `AGENT_COORD_ID` to whatever you passed to `register({agentId})`. Set `AGENT_COORD_INCLUDE_ROOM=1` to also drain the shared room. Set `AGENT_COORD_DIR` if you've relocated the state directory.
|
|
155
|
+
|
|
156
|
+
Caveat: the hook writes the cursor file directly (atomic tmp+rename) without taking the MCP server's lockfile, so if the agent calls `read_messages` at the exact instant the hook runs, one of them may double-deliver a message. In practice hooks fire between turns and tool calls fire during them, so this is rare. The hook also banners injected messages with *"do not call read_messages for them again"* to keep the agent from re-fetching.
|
|
157
|
+
|
|
158
|
+
### Other clients
|
|
159
|
+
|
|
160
|
+
The script itself is plain Node — no Claude-specific deps — so it ports anywhere you can run a shell command around the agent loop. The MCP protocol doesn't standardize client-side hooks, so the wiring varies:
|
|
161
|
+
|
|
162
|
+
- **Cursor / Cline / Continue / Zed** — no first-class lifecycle hooks today. Closest workaround is to put *"run `peek-coord.mjs` at turn start and treat its stdout as additional context"* in your rules/system prompt. Less reliable (model can skip it) but functional.
|
|
163
|
+
- **Custom SDK agents (`@anthropic-ai/sdk`, `openai`, etc.)** — easiest fit. Shell out to the script (or inline the ~50 lines of logic) right before each completion call and prepend stdout as a system message. Fully deterministic.
|
|
164
|
+
- **Client-agnostic fallback** — a `launchd`/`systemd`/cron watcher that tails `inbox/<id>.jsonl` and writes unread entries to a file the agent is told to `Read` on session start. Crude, works everywhere.
|
|
115
165
|
|
|
116
166
|
## License
|
|
117
167
|
|
package/dist/server.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code hook: drains unread agent-coord messages into the next turn
|
|
3
|
+
// without making the agent poll. Reads ~/agent-coord/ directly (no MCP roundtrip).
|
|
4
|
+
//
|
|
5
|
+
// Usage (in settings.json):
|
|
6
|
+
// UserPromptSubmit -> node /path/to/peek-coord.mjs --mode=user-prompt
|
|
7
|
+
// Stop -> node /path/to/peek-coord.mjs --mode=stop
|
|
8
|
+
//
|
|
9
|
+
// Required env: AGENT_COORD_ID (the agent's id, matches register({agentId}))
|
|
10
|
+
// Optional env: AGENT_COORD_DIR (default ~/agent-coord)
|
|
11
|
+
// Optional env: AGENT_COORD_INCLUDE_ROOM=1 to also drain the shared room
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
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
|
+
|
|
24
|
+
const ROOT =
|
|
25
|
+
process.env.AGENT_COORD_DIR ??
|
|
26
|
+
process.env.CLAUDE_COORD_DIR ??
|
|
27
|
+
path.join(homedir(), "agent-coord");
|
|
28
|
+
const INBOX = path.join(ROOT, "inbox", `${sanitize(AGENT_ID)}.jsonl`);
|
|
29
|
+
const ROOM = path.join(ROOT, "room.jsonl");
|
|
30
|
+
const CURSOR = path.join(ROOT, "cursors", `${sanitize(AGENT_ID)}.json`);
|
|
31
|
+
const INCLUDE_ROOM = process.env.AGENT_COORD_INCLUDE_ROOM === "1";
|
|
32
|
+
|
|
33
|
+
const cursor = readJson(CURSOR, {});
|
|
34
|
+
const out = [];
|
|
35
|
+
|
|
36
|
+
const inbox = drain(INBOX, cursor, "inboxOffset");
|
|
37
|
+
for (const m of inbox) out.push(fmt("inbox", m));
|
|
38
|
+
|
|
39
|
+
if (INCLUDE_ROOM) {
|
|
40
|
+
const room = drain(ROOM, cursor, "roomOffset");
|
|
41
|
+
for (const m of room) {
|
|
42
|
+
if (m.from === AGENT_ID) continue; // don't echo our own room posts back
|
|
43
|
+
out.push(fmt("room", m));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (out.length === 0) process.exit(0);
|
|
48
|
+
|
|
49
|
+
writeJsonAtomic(CURSOR, cursor);
|
|
50
|
+
|
|
51
|
+
const banner =
|
|
52
|
+
`[agent-coord] ${out.length} unread message(s) for "${AGENT_ID}". ` +
|
|
53
|
+
`These were delivered via hook; do not call read_messages for them again.\n`;
|
|
54
|
+
const body = banner + out.join("\n");
|
|
55
|
+
|
|
56
|
+
if (MODE === "stop") {
|
|
57
|
+
// Stop hook: emit JSON to keep the session going with the new context.
|
|
58
|
+
process.stdout.write(
|
|
59
|
+
JSON.stringify({ decision: "block", reason: body }) + "\n"
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
// UserPromptSubmit (and any other mode): plain stdout is appended to context.
|
|
63
|
+
process.stdout.write(body + "\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------- helpers ----------
|
|
67
|
+
|
|
68
|
+
function drain(file, cursor, key) {
|
|
69
|
+
if (!existsSync(file)) return [];
|
|
70
|
+
const raw = readFileSync(file, "utf8");
|
|
71
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
72
|
+
const start = cursor[key] ?? 0;
|
|
73
|
+
const slice = lines.slice(start);
|
|
74
|
+
const parsed = [];
|
|
75
|
+
for (const line of slice) {
|
|
76
|
+
try { parsed.push(JSON.parse(line)); } catch { /* skip */ }
|
|
77
|
+
}
|
|
78
|
+
if (slice.length > 0) cursor[key] = start + slice.length;
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fmt(source, m) {
|
|
83
|
+
const ts = new Date(m.ts ?? Date.now()).toISOString();
|
|
84
|
+
const who = m.from ?? "?";
|
|
85
|
+
const tag = source === "room" ? "room" : "dm";
|
|
86
|
+
return ` [${tag} ${ts} from=${who}] ${m.text ?? ""}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJson(file, fallback) {
|
|
90
|
+
if (!existsSync(file)) return fallback;
|
|
91
|
+
try {
|
|
92
|
+
const raw = readFileSync(file, "utf8");
|
|
93
|
+
if (!raw.trim()) return fallback;
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeJsonAtomic(file, data) {
|
|
101
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
103
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
104
|
+
renameSync(tmp, file);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitize(id) {
|
|
108
|
+
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.) on the same machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
17
|
"src",
|
|
18
|
+
"hooks",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|