agent-coord-mcp 0.17.0 → 0.18.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 +34 -5
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/roles.js +92 -0
- package/dist/roles.js.map +1 -0
- package/dist/server.js +30 -14
- package/dist/server.js.map +1 -1
- package/dist/store.js +54 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +5 -3
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +187 -8
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +54 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +84 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +367 -33
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +209 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/work.js +260 -0
- package/dist/work.js.map +1 -0
- package/hooks/marker.mjs +18 -0
- package/hooks/roles.mjs +79 -0
- package/hooks/submit.mjs +271 -0
- package/hooks/tier.mjs +104 -11
- package/hooks/tmux-pusher.mjs +137 -51
- package/package.json +5 -4
- package/scripts/check-self-dependency.mjs +42 -0
- package/scripts/check-test-count.mjs +83 -0
- package/scripts/coord-pusher.mjs +150 -33
- package/src/build.ts +111 -0
- package/src/roles.ts +111 -0
- package/src/server.ts +79 -14
- package/src/store.ts +60 -1
- package/src/tools/admin.ts +10 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +206 -7
- package/src/tools/registry.ts +63 -4
- package/src/tools/render.ts +80 -0
- package/src/tools/scopes.ts +177 -0
- package/src/tools/shared.ts +120 -0
- package/src/tools/transport.ts +386 -34
- package/src/tools/work.ts +265 -0
- package/src/work.ts +329 -0
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
-
import { ensureDirs,
|
|
7
|
+
import { ensureDirs, getTokenMap, reloadTokenMapSync } from "./store.js";
|
|
8
8
|
import {
|
|
9
9
|
attachAgentSchema,
|
|
10
10
|
attachAgentTool,
|
|
@@ -38,12 +38,16 @@ import {
|
|
|
38
38
|
pruneTool,
|
|
39
39
|
readMessagesSchema,
|
|
40
40
|
readMessagesTool,
|
|
41
|
+
retrieveMessageSchema,
|
|
42
|
+
retrieveMessageTool,
|
|
41
43
|
retrieveRoomHistorySchema,
|
|
42
44
|
retrieveRoomHistoryTool,
|
|
43
45
|
registerSchema,
|
|
44
46
|
registerTool,
|
|
45
47
|
renameAgentSchema,
|
|
46
48
|
renameAgentTool,
|
|
49
|
+
reportReceiptSchema,
|
|
50
|
+
reportReceiptTool,
|
|
47
51
|
reportTransportSchema,
|
|
48
52
|
reportTransportTool,
|
|
49
53
|
sendCommandSchema,
|
|
@@ -62,6 +66,14 @@ import {
|
|
|
62
66
|
quitTool,
|
|
63
67
|
waitForMessageSchema,
|
|
64
68
|
waitForMessageTool,
|
|
69
|
+
listScopesSchema,
|
|
70
|
+
listScopesTool,
|
|
71
|
+
importWorkSchema,
|
|
72
|
+
importWorkTool,
|
|
73
|
+
listWorkSchema,
|
|
74
|
+
listWorkTool,
|
|
75
|
+
exportWorkSchema,
|
|
76
|
+
exportWorkTool,
|
|
65
77
|
} from "./tools/index.js";
|
|
66
78
|
|
|
67
79
|
function jsonResult(data: unknown) {
|
|
@@ -88,16 +100,25 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
88
100
|
|
|
89
101
|
// Gate every tool that takes a caller identity. `field: null` (list_agents,
|
|
90
102
|
// list_rooms, prune) bypasses the check entirely.
|
|
103
|
+
//
|
|
104
|
+
// `bindOnClaim: false` (status, ping) means the tool still enforces a
|
|
105
|
+
// mismatch against an *existing* binding, but a fresh (unbound) session
|
|
106
|
+
// never claims one just by naming an agentId — a diagnostic status/ping
|
|
107
|
+
// call must not be able to silently TOFU-bind a session to some other,
|
|
108
|
+
// already-live agent's identity.
|
|
91
109
|
function gate(
|
|
92
110
|
field: "agentId" | "from" | null,
|
|
93
111
|
handler: (args: Record<string, unknown>) => Promise<unknown>,
|
|
112
|
+
{ bindOnClaim = true }: { bindOnClaim?: boolean } = {},
|
|
94
113
|
) {
|
|
95
114
|
return async (args: Record<string, unknown>) => {
|
|
96
115
|
if (field) {
|
|
97
116
|
const claimed = args[field];
|
|
98
117
|
if (typeof claimed === "string") {
|
|
99
118
|
if (bound === undefined) {
|
|
100
|
-
|
|
119
|
+
if (bindOnClaim) {
|
|
120
|
+
bound = claimed; // TOFU: first claim wins, then sticky.
|
|
121
|
+
}
|
|
101
122
|
} else if (bound !== claimed) {
|
|
102
123
|
throw new Error(
|
|
103
124
|
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
|
|
@@ -158,9 +179,9 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
158
179
|
|
|
159
180
|
server.tool(
|
|
160
181
|
"status",
|
|
161
|
-
"Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'.",
|
|
182
|
+
"Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'. Read-only — naming an agentId here never binds this session's identity.",
|
|
162
183
|
statusSchema,
|
|
163
|
-
gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown
|
|
184
|
+
gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
164
185
|
);
|
|
165
186
|
|
|
166
187
|
server.tool(
|
|
@@ -172,9 +193,9 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
172
193
|
|
|
173
194
|
server.tool(
|
|
174
195
|
"ping",
|
|
175
|
-
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity.",
|
|
196
|
+
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity, but read-only — naming 'from' here never binds this session's identity.",
|
|
176
197
|
pingSchema,
|
|
177
|
-
gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown
|
|
198
|
+
gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
178
199
|
);
|
|
179
200
|
|
|
180
201
|
server.tool(
|
|
@@ -212,6 +233,13 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
212
233
|
gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
213
234
|
);
|
|
214
235
|
|
|
236
|
+
server.tool(
|
|
237
|
+
"retrieve_message",
|
|
238
|
+
"Expand a `retrieve_message id=<uuid>` handle from a pane digest into the full message and its typed `record`. A record whose text rendering spans multiple lines (a DAVID_DECISION packet) is delivered to a pane as ONE attributed line plus this handle; call it to get the structured record back. Reads the message by id from the channels you can read (your inbox and rooms you belong to), falling through to the append-only archive if compaction moved it — so unlike retrieve_room_history there is no TTL and nothing to expire. A handle for a message never delivered to you is simply not found.",
|
|
239
|
+
retrieveMessageSchema,
|
|
240
|
+
gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
241
|
+
);
|
|
242
|
+
|
|
215
243
|
server.tool(
|
|
216
244
|
"post_status",
|
|
217
245
|
"Append a status broadcast to the shared status stream.",
|
|
@@ -320,6 +348,13 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
320
348
|
gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
321
349
|
);
|
|
322
350
|
|
|
351
|
+
server.tool(
|
|
352
|
+
"report_receipt",
|
|
353
|
+
"Append a delivery receipt for a message this agent's pusher just typed into its pane — the wire-callable counterpart to the local pusher's receipts/<id>.jsonl stamp, for remote pushers (scripts/coord-pusher.mjs) that cannot write this host's filesystem. This is what lets send_command to a tmux-push-remote agent return delivery:'confirmed'. For control commands pass exactly what submit verification observed (submitted/verified/reason); omitting 'submitted' means 'typed but unverified' and is reported as delivery:'pending', never 'confirmed'. 'agentId' (the receiving agent) is enforced against the session's bound identity, so a pusher can only stamp its own agent's receipt file.",
|
|
354
|
+
reportReceiptSchema,
|
|
355
|
+
gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
356
|
+
);
|
|
357
|
+
|
|
323
358
|
server.tool(
|
|
324
359
|
"doctor",
|
|
325
360
|
"Bus-wide health check: inspects the whole state dir and reports drift, leaks, and corruption (orphan transport markers / memberships / inboxes, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, environment). Read-only by default; pass fix=true to apply the safe, reversible repairs (malformed-line rewrites are backed up to .bak first). A clean report (healthy=true) means the bus is internally consistent.",
|
|
@@ -327,6 +362,34 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
327
362
|
gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
328
363
|
);
|
|
329
364
|
|
|
365
|
+
server.tool(
|
|
366
|
+
"list_scopes",
|
|
367
|
+
"Read the declared write scopes for managed documents (~/agent-coord/scopes.json). Call it with 'path' (and your 'agentId') to ask \"may I write this?\" BEFORE editing a shared doc like docs/QUEUE.md; call it bare to list every declared document and its owning role. ADVISORY ONLY: the bus does not mediate file writes, so this answers who owns a document, it does not stop anyone — enforcement arrives when work state moves into the store. Absent scopes.json means nothing is owned and nothing warns (opt-in).",
|
|
368
|
+
listScopesSchema,
|
|
369
|
+
gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
server.tool(
|
|
373
|
+
"import_work",
|
|
374
|
+
"Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date} and board rows. The markdown stays authoritative — this store is a derived index, and export_work renders it back byte-identically.",
|
|
375
|
+
importWorkSchema,
|
|
376
|
+
gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
server.tool(
|
|
380
|
+
"list_work",
|
|
381
|
+
"Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, and the board's lane rows. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
|
|
382
|
+
listWorkSchema,
|
|
383
|
+
gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
server.tool(
|
|
387
|
+
"export_work",
|
|
388
|
+
"Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
|
|
389
|
+
exportWorkSchema,
|
|
390
|
+
gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
391
|
+
);
|
|
392
|
+
|
|
330
393
|
server.tool(
|
|
331
394
|
"delete_room",
|
|
332
395
|
"Permanently delete a channel: removes it from the registry, deletes its JSONL file, and clears all agent cursor offsets for that channel. Refuses if agents are still joined unless force=true. Cannot delete the default 'general' channel. Posts a system notice to #general on success.",
|
|
@@ -344,12 +407,13 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
344
407
|
return server;
|
|
345
408
|
}
|
|
346
409
|
|
|
347
|
-
//
|
|
348
|
-
// operators can rotate / add agents
|
|
349
|
-
|
|
410
|
+
// Token map for HTTP identity binding, held in-process via store.ts's
|
|
411
|
+
// shared cache. Hot-reloaded on SIGHUP so operators can rotate / add agents
|
|
412
|
+
// without a server restart; also refreshed automatically by rename_agent
|
|
413
|
+
// (see rotateAgentToken) so a live rename doesn't need one.
|
|
350
414
|
function loadTokenMap(initial: boolean): void {
|
|
351
415
|
try {
|
|
352
|
-
|
|
416
|
+
reloadTokenMapSync();
|
|
353
417
|
} catch (e) {
|
|
354
418
|
// On initial load a bad file is fatal — refuse to start in a known-bad
|
|
355
419
|
// auth state. On SIGHUP, log and keep the previous (valid) map.
|
|
@@ -361,7 +425,7 @@ function loadTokenMap(initial: boolean): void {
|
|
|
361
425
|
return;
|
|
362
426
|
}
|
|
363
427
|
if (!initial) {
|
|
364
|
-
console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${
|
|
428
|
+
console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${getTokenMap()?.size ?? 0} agents)`);
|
|
365
429
|
}
|
|
366
430
|
}
|
|
367
431
|
|
|
@@ -396,7 +460,7 @@ async function main() {
|
|
|
396
460
|
|
|
397
461
|
async function startHttp(port: number): Promise<void> {
|
|
398
462
|
const sharedToken = process.env.AGENT_COORD_TOKEN;
|
|
399
|
-
const bound =
|
|
463
|
+
const bound = getTokenMap() !== null;
|
|
400
464
|
if (!bound && !sharedToken) {
|
|
401
465
|
console.error(
|
|
402
466
|
"[agent-coord-mcp] HTTP mode needs auth: either set AGENT_COORD_TOKEN (legacy " +
|
|
@@ -486,6 +550,7 @@ async function startHttp(port: number): Promise<void> {
|
|
|
486
550
|
function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
|
|
487
551
|
if (!authHeader || !authHeader.startsWith("Bearer ")) return { ok: false };
|
|
488
552
|
const bearer = authHeader.slice("Bearer ".length);
|
|
553
|
+
const tokenMap = getTokenMap();
|
|
489
554
|
if (tokenMap) {
|
|
490
555
|
const agent = tokenMap.get(bearer);
|
|
491
556
|
return agent ? { ok: true, agent } : { ok: false };
|
|
@@ -528,7 +593,7 @@ async function startHttp(port: number): Promise<void> {
|
|
|
528
593
|
// a leaked session id could drive that session's identity (session hijack).
|
|
529
594
|
if (
|
|
530
595
|
transport &&
|
|
531
|
-
|
|
596
|
+
getTokenMap() &&
|
|
532
597
|
typeof sid === "string" &&
|
|
533
598
|
sessionAgents.get(sid) !== resolved.agent
|
|
534
599
|
) {
|
|
@@ -555,7 +620,7 @@ async function startHttp(port: number): Promise<void> {
|
|
|
555
620
|
});
|
|
556
621
|
|
|
557
622
|
http.listen(port, bindAddr, () => {
|
|
558
|
-
const mode = bound ? `pre-bound (${
|
|
623
|
+
const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
|
|
559
624
|
console.error(`[agent-coord-mcp] http listening on ${bindAddr}:${port} — identity ${mode}`);
|
|
560
625
|
if (bindAddr !== "127.0.0.1" && bindAddr !== "localhost") {
|
|
561
626
|
console.error(
|
package/src/store.ts
CHANGED
|
@@ -63,8 +63,26 @@ export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
|
|
|
63
63
|
// startup warning). Should be mode 600; operator-managed.
|
|
64
64
|
export const TOKENS_FILE = path.join(ROOT, "tokens.json");
|
|
65
65
|
|
|
66
|
+
// Declared write scopes for managed documents (v0.18.0, Phase 8 Task 4).
|
|
67
|
+
// Opt-in and operator-managed: absent → nothing is owned and nothing warns.
|
|
68
|
+
// ADVISORY. The bus does not mediate writes to docs/QUEUE.md & friends —
|
|
69
|
+
// agents edit them with ordinary file tools, so there is no interception
|
|
70
|
+
// point. This file lets an agent ASK who owns a document (list_scopes) and
|
|
71
|
+
// lets `doctor` DETECT drift after the fact. Pre-emptive enforcement waits
|
|
72
|
+
// for Phase 8 Task 5, when work state moves into the store.
|
|
73
|
+
export const SCOPES_FILE = path.join(ROOT, "scopes.json");
|
|
74
|
+
|
|
75
|
+
// Work state as data (v0.18.0, Phase 8 Task 5). One file per project holding
|
|
76
|
+
// the parsed QUEUE/DONE/board documents. DERIVED, not authoritative: the
|
|
77
|
+
// markdown in the repo remains the source of truth, and deleting this
|
|
78
|
+
// directory costs an `import_work`, never data (see src/work.ts).
|
|
79
|
+
export const WORK_DIR = path.join(ROOT, "work");
|
|
80
|
+
export function workFile(project: string): string {
|
|
81
|
+
return path.join(WORK_DIR, `${sanitize(project)}.json`);
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
export function ensureDirs(): void {
|
|
67
|
-
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR]) {
|
|
85
|
+
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR]) {
|
|
68
86
|
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
69
87
|
}
|
|
70
88
|
for (const f of [ROOM_FILE, STATUS_FILE]) {
|
|
@@ -105,18 +123,59 @@ export function readTokenMapSync(): Map<string, string> | null {
|
|
|
105
123
|
return out;
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
// In-process cache of the token map, shared by the HTTP identity-binding
|
|
127
|
+
// layer (server.ts) and rotateAgentToken below. A module-level cache (rather
|
|
128
|
+
// than each caller re-reading the file) is what lets a rename refresh the
|
|
129
|
+
// live server's view without an operator SIGHUP.
|
|
130
|
+
let tokenMapCache: Map<string, string> | null = null;
|
|
131
|
+
let tokenMapInitialized = false;
|
|
132
|
+
|
|
133
|
+
// Current in-process token map (bearer -> agentId), or null if unbound (no
|
|
134
|
+
// tokens.json). Lazily loads from disk on first call.
|
|
135
|
+
export function getTokenMap(): Map<string, string> | null {
|
|
136
|
+
if (!tokenMapInitialized) {
|
|
137
|
+
tokenMapCache = readTokenMapSync();
|
|
138
|
+
tokenMapInitialized = true;
|
|
139
|
+
}
|
|
140
|
+
return tokenMapCache;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Re-read tokens.json from disk and replace the in-process cache. Throws on
|
|
144
|
+
// a malformed file — callers decide whether that's fatal (startup) or
|
|
145
|
+
// recoverable (SIGHUP, post-rotate refresh).
|
|
146
|
+
export function reloadTokenMapSync(): Map<string, string> | null {
|
|
147
|
+
tokenMapCache = readTokenMapSync();
|
|
148
|
+
tokenMapInitialized = true;
|
|
149
|
+
return tokenMapCache;
|
|
150
|
+
}
|
|
151
|
+
|
|
108
152
|
// Atomically rotate the token entry for an agent rename (used by
|
|
109
153
|
// rename_agent so the same bearer continues to authenticate the renamed
|
|
110
154
|
// identity). No-op if the file is absent or the old id isn't in the map.
|
|
111
155
|
export async function rotateAgentToken(oldAgentId: string, newAgentId: string): Promise<void> {
|
|
112
156
|
if (!existsSync(TOKENS_FILE)) return;
|
|
157
|
+
let rotated = false;
|
|
113
158
|
await updateJson<Record<string, string>>(TOKENS_FILE, {}, (current) => {
|
|
114
159
|
if (current[oldAgentId] !== undefined) {
|
|
115
160
|
current[newAgentId] = current[oldAgentId];
|
|
116
161
|
delete current[oldAgentId];
|
|
162
|
+
rotated = true;
|
|
117
163
|
}
|
|
118
164
|
return current;
|
|
119
165
|
});
|
|
166
|
+
if (rotated) {
|
|
167
|
+
// Refresh the in-process cache immediately: without this, the renamed
|
|
168
|
+
// agent's bearer keeps resolving to the OLD id in this process until an
|
|
169
|
+
// operator sends SIGHUP, silently misattributing its calls.
|
|
170
|
+
try {
|
|
171
|
+
reloadTokenMapSync();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error(
|
|
174
|
+
`[agent-coord-mcp] rename_agent: token map reload after rotate failed: ${(e as Error).message} ` +
|
|
175
|
+
`(keeping previous in-process map; send SIGHUP to retry)`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
120
179
|
}
|
|
121
180
|
|
|
122
181
|
export type RoomEntry = {
|
package/src/tools/admin.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type Cursor,
|
|
31
31
|
type Message,
|
|
32
32
|
type StatusEntry,
|
|
33
|
+
isDecision,
|
|
33
34
|
} from "./shared.js";
|
|
34
35
|
|
|
35
36
|
// ---------- prune ----------
|
|
@@ -47,10 +48,15 @@ export const pruneSchema = {
|
|
|
47
48
|
dryRun: z.boolean().optional(),
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
// Retention
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Retention: decisions live by the (longer) decision cutoff; everything else
|
|
52
|
+
// by the standard cutoff. What counts as a decision is `isDecision` — the
|
|
53
|
+
// shared predicate, not a fourth copy of the comparison.
|
|
54
|
+
function keepEntry(
|
|
55
|
+
e: { ts: number; kind?: string; record?: { type?: string } },
|
|
56
|
+
cutoff: number,
|
|
57
|
+
decisionCutoff: number,
|
|
58
|
+
): boolean {
|
|
59
|
+
return isDecision(e) ? e.ts > decisionCutoff : e.ts > cutoff;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
// Shift every agent's cursor offsets down by the number of entries removed
|
package/src/tools/index.ts
CHANGED
package/src/tools/messaging.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { adjustCursors } from "./admin.js";
|
|
2
|
-
import {
|
|
2
|
+
import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
|
|
3
|
+
import { ARCHIVE_STATUS_FILE, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { existsSync, openSync, watch } from "node:fs";
|
|
5
6
|
import { promises as fsp } from "node:fs";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { z } from "zod";
|
|
10
|
+
import { renderRecord } from "./render.js";
|
|
9
11
|
import path from "node:path";
|
|
10
12
|
import {
|
|
11
13
|
AGENTS_FILE,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
type AgentEntry,
|
|
54
56
|
type AgentRegistry,
|
|
55
57
|
type Message,
|
|
58
|
+
type MessageRecord,
|
|
56
59
|
type StatusEntry,
|
|
57
60
|
type Cursor,
|
|
58
61
|
type Source,
|
|
@@ -65,25 +68,149 @@ import {
|
|
|
65
68
|
STALE_MS,
|
|
66
69
|
EVICT_MS,
|
|
67
70
|
MAX_WAIT_MS,
|
|
71
|
+
isDecision,
|
|
68
72
|
} from "./shared.js";
|
|
69
73
|
|
|
70
74
|
// ---------- send_message ----------
|
|
71
75
|
|
|
76
|
+
// Typed protocol record (Phase 8). Additive: omitting it reproduces v1
|
|
77
|
+
// behavior exactly.
|
|
78
|
+
const citationSchema = z.object({
|
|
79
|
+
kind: z.enum(["pr", "file", "commit", "url"]),
|
|
80
|
+
ref: z.string().min(1),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Payloads are LOOSE objects: a v3 sender's extra keys ride through to disk
|
|
84
|
+
// untouched rather than being silently stripped. That is safe precisely
|
|
85
|
+
// because payload is nested — it is caller data, and nothing in it is ever
|
|
86
|
+
// read as a top-level Message field (which is what keeps a forged `tag` or
|
|
87
|
+
// `urgent` out; see the source-level lock in test/tier.test.mjs).
|
|
88
|
+
const summaryPayload = z.looseObject({ summary: z.string().min(1) });
|
|
89
|
+
|
|
90
|
+
// All five fields, or none. A `decision` carrying three of them is
|
|
91
|
+
// structurally wrong for the type it claims — and would render as a truncated
|
|
92
|
+
// packet, which is worse than no packet.
|
|
93
|
+
const decisionPayload = z.looseObject({
|
|
94
|
+
title: z.string().min(1),
|
|
95
|
+
context: z.string().min(1),
|
|
96
|
+
options: z.array(z.string().min(1)).min(1),
|
|
97
|
+
recommendation: z.string().min(1),
|
|
98
|
+
ifNoAction: z.string().min(1),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const verdictPayload = z.looseObject({
|
|
102
|
+
result: z.enum(["pass", "fail"]),
|
|
103
|
+
headRefOid: z.string().min(1),
|
|
104
|
+
notes: z.string().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Discriminated on `type`, so an unknown type is rejected outright while a
|
|
108
|
+
// known type is checked only against its own shape. `payload` is optional on
|
|
109
|
+
// every arm: Phase 8 is additive and may not put a new required field on the
|
|
110
|
+
// wire. `cites` is optional here too — `done` needs a PR citation, but that is
|
|
111
|
+
// enforced in sendMessageTool as a plain {ok:false,error}, mirroring the
|
|
112
|
+
// identity-binding rejection, rather than as a schema throw.
|
|
113
|
+
export const messageRecordSchema = z.discriminatedUnion("type", [
|
|
114
|
+
z.object({ type: z.literal("decision"), payload: decisionPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
115
|
+
z.object({ type: z.literal("verdict"), payload: verdictPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
116
|
+
z.object({ type: z.literal("done"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
117
|
+
z.object({ type: z.literal("blocker"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
118
|
+
z.object({ type: z.literal("risk"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
119
|
+
z.object({ type: z.literal("fyi"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
120
|
+
z.object({ type: z.literal("action"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
121
|
+
z.object({ type: z.literal("go"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
122
|
+
z.object({ type: z.literal("scope"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
// ---------- record authority (Phase 8 Task 4) ----------
|
|
126
|
+
|
|
127
|
+
// The table (RECORD_AUTHORITY, ../roles.ts) is a floor on the three types
|
|
128
|
+
// other agents ACT on; everything else is unrestricted.
|
|
129
|
+
//
|
|
130
|
+
// NOT A TRUST BOUNDARY. A role is self-declared at register/join (there is no
|
|
131
|
+
// authority issuing them), so this is a CONSISTENCY check: it stops a worker
|
|
132
|
+
// from accidentally emitting a `verdict` or countersigning its own `scope`,
|
|
133
|
+
// the same way a linter stops a typo. Anything that must actually be
|
|
134
|
+
// authenticated has to resolve identity-bound tokens (see tokens.json), never
|
|
135
|
+
// this table.
|
|
136
|
+
// Rejection shape mirrors the identity-binding rejection in server.ts: the
|
|
137
|
+
// caller gets a plain `{ok: false, error}`, and nothing is written.
|
|
138
|
+
export async function checkRecordAuthority(
|
|
139
|
+
from: string,
|
|
140
|
+
record: MessageRecord | undefined,
|
|
141
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
142
|
+
if (!record) return { ok: true };
|
|
143
|
+
const rule = RECORD_AUTHORITY[record.type];
|
|
144
|
+
if (!rule) return { ok: true };
|
|
145
|
+
|
|
146
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
147
|
+
const entry = reg[from];
|
|
148
|
+
if (roleMatches(entry, rule.roles)) return { ok: true };
|
|
149
|
+
|
|
150
|
+
const held = resolveRole(entry);
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error:
|
|
154
|
+
`record.type '${record.type}' is restricted to ${rule.label} roles ` +
|
|
155
|
+
`(${[...rule.roles].join(", ")}); sender '${from}' holds ` +
|
|
156
|
+
`${held ? `role '${held.roleId}'` : entry ? "no role" : "no registry entry"}. ` +
|
|
157
|
+
`Send it as text, or register with the role that owns this record type.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
72
161
|
export const sendMessageSchema = {
|
|
73
162
|
from: z.string().min(1),
|
|
74
163
|
to: z.string().optional(),
|
|
75
164
|
room: z.string().optional(),
|
|
76
|
-
|
|
165
|
+
// Optional ONLY so a record can fill it (Task 3.3). This relaxes a
|
|
166
|
+
// constraint rather than adding one, so v1 senders are unaffected; a call
|
|
167
|
+
// with neither `text` nor a renderable `record` is rejected in the tool.
|
|
168
|
+
text: z.string().min(1).optional(),
|
|
77
169
|
kind: z.enum(["decision", "status", "chatter"]).optional(),
|
|
170
|
+
record: messageRecordSchema.optional(),
|
|
78
171
|
};
|
|
79
172
|
|
|
80
173
|
export async function sendMessageTool(args: {
|
|
81
174
|
from: string;
|
|
82
175
|
to?: string;
|
|
83
176
|
room?: string;
|
|
84
|
-
text
|
|
177
|
+
text?: string;
|
|
85
178
|
kind?: "decision" | "status" | "chatter";
|
|
179
|
+
record?: MessageRecord;
|
|
86
180
|
}) {
|
|
181
|
+
// Record authority first — a sender who may not emit this type is refused
|
|
182
|
+
// before any other check runs, so a rejected record writes nothing anywhere.
|
|
183
|
+
const authority = await checkRecordAuthority(args.from, args.record);
|
|
184
|
+
if (!authority.ok) return { ok: false as const, error: authority.error };
|
|
185
|
+
|
|
186
|
+
// A `done` must cite the work it claims. Presence and shape only — resolving
|
|
187
|
+
// the ref against gh/git is a consumer's job, and the send path makes no
|
|
188
|
+
// network calls. Rejected as a value, not a throw, mirroring the
|
|
189
|
+
// identity-binding rejection in src/server.ts.
|
|
190
|
+
if (args.record?.type === "done") {
|
|
191
|
+
const hasPr = (args.record.cites ?? []).some((c) => c.kind === "pr" && c.ref.trim().length > 0);
|
|
192
|
+
if (!hasPr) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false as const,
|
|
195
|
+
error:
|
|
196
|
+
"a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `text` is what every consumer reads, so it must exist. The author's
|
|
202
|
+
// wording ALWAYS wins: a record renders only to fill an absent text, never
|
|
203
|
+
// to overwrite one.
|
|
204
|
+
const text = args.text ?? (args.record ? renderRecord(args.record) : null);
|
|
205
|
+
if (!text) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false as const,
|
|
208
|
+
error: args.record
|
|
209
|
+
? `record type '${args.record.type}' has no payload to render — supply 'text', or a payload the type's layout can render`
|
|
210
|
+
: "'text' is required when no record is supplied",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
87
214
|
// DM → inbox. Otherwise resolve the channel (default `general`), make sure it
|
|
88
215
|
// exists in the registry, and tag the message with its channel.
|
|
89
216
|
if (args.to) {
|
|
@@ -92,7 +219,8 @@ export async function sendMessageTool(args: {
|
|
|
92
219
|
ts: Date.now(),
|
|
93
220
|
from: args.from,
|
|
94
221
|
to: args.to,
|
|
95
|
-
text
|
|
222
|
+
text,
|
|
223
|
+
...(args.record ? { record: args.record } : {}),
|
|
96
224
|
};
|
|
97
225
|
const target = inboxFile(args.to);
|
|
98
226
|
await appendJsonl(target, msg);
|
|
@@ -113,8 +241,9 @@ export async function sendMessageTool(args: {
|
|
|
113
241
|
ts: Date.now(),
|
|
114
242
|
from: args.from,
|
|
115
243
|
room: chan,
|
|
116
|
-
text
|
|
244
|
+
text,
|
|
117
245
|
...(args.kind ? { kind: args.kind } : {}),
|
|
246
|
+
...(args.record ? { record: args.record } : {}),
|
|
118
247
|
};
|
|
119
248
|
const target = roomFile(chan);
|
|
120
249
|
await appendJsonl(target, msg);
|
|
@@ -154,7 +283,7 @@ async function maybeCompactRoom(chan: string): Promise<void> {
|
|
|
154
283
|
const r = await archiveJsonl<Message>(
|
|
155
284
|
file,
|
|
156
285
|
archiveRoomFile(chan),
|
|
157
|
-
(e) => e.ts >= boundaryTs || (e
|
|
286
|
+
(e) => e.ts >= boundaryTs || (isDecision(e) && e.ts > decisionCutoff)
|
|
158
287
|
);
|
|
159
288
|
if (r.removed > 0) await adjustCursors({ roomRemovedByChan: { [chan]: r.removed } });
|
|
160
289
|
}
|
|
@@ -287,7 +416,7 @@ function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefine
|
|
|
287
416
|
: `] — read without peek to get an expandable hash`;
|
|
288
417
|
// Decisions are the one thing a digest must not bury — quote them verbatim
|
|
289
418
|
// (capped) below the summary line.
|
|
290
|
-
const decisions = over.filter((m): m is Message => (m as Message)
|
|
419
|
+
const decisions = over.filter((m): m is Message => isDecision(m as Message));
|
|
291
420
|
const quoted = decisions
|
|
292
421
|
.slice(-5)
|
|
293
422
|
.map((d) => ` [decision] ${d.from}: ${d.text.length > 200 ? d.text.slice(0, 200) + "…" : d.text}`);
|
|
@@ -297,6 +426,76 @@ function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefine
|
|
|
297
426
|
return head + tail + decisionBlock;
|
|
298
427
|
}
|
|
299
428
|
|
|
429
|
+
// ---------- retrieve_message (Phase 8 Task 6) ----------
|
|
430
|
+
|
|
431
|
+
export const retrieveMessageSchema = {
|
|
432
|
+
agentId: z.string().min(1),
|
|
433
|
+
id: z.string().min(1),
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Expand a digest handle back into the full typed record.
|
|
437
|
+
//
|
|
438
|
+
// NOT a cache. The handle is the message's own `id`, and this reads the source
|
|
439
|
+
// of truth — rooms/<chan>.jsonl or inbox/<agent>.jsonl. That matters for two
|
|
440
|
+
// reasons the CCR history cache could not offer: nothing is duplicated, and
|
|
441
|
+
// nothing expires. A stashed copy would have carried HISTORY_TTL_MS and become
|
|
442
|
+
// permanently unrecoverable after 30 minutes, which is exactly when a handle
|
|
443
|
+
// that outlived a /clear would be expanded.
|
|
444
|
+
//
|
|
445
|
+
// It also sidesteps the cursor: the pusher SHARES the cursor file with
|
|
446
|
+
// read_messages, so anything already delivered to a pane is behind the cursor
|
|
447
|
+
// and re-reading the channel would not return it. A by-id lookup never
|
|
448
|
+
// consults a cursor.
|
|
449
|
+
//
|
|
450
|
+
// AUTHORITY BY CONSTRUCTION: we only ever open files this agent is entitled to
|
|
451
|
+
// read — its own inbox, and the rooms it is a member of. There is no separate
|
|
452
|
+
// permission check that could disagree with the search, and a handle for a
|
|
453
|
+
// message delivered somewhere else simply is not found. That is the same
|
|
454
|
+
// property the history cache spent `forAgent` scoping to get.
|
|
455
|
+
export async function retrieveMessageTool(args: { agentId: string; id: string }) {
|
|
456
|
+
const rooms = await memberRooms(args.agentId);
|
|
457
|
+
const scopes = [
|
|
458
|
+
{
|
|
459
|
+
source: "inbox" as const,
|
|
460
|
+
room: undefined as string | undefined,
|
|
461
|
+
live: inboxFile(args.agentId),
|
|
462
|
+
archived: archiveInboxFile(args.agentId),
|
|
463
|
+
},
|
|
464
|
+
...rooms.map((r) => ({
|
|
465
|
+
source: "room" as const,
|
|
466
|
+
room: r as string | undefined,
|
|
467
|
+
live: roomFile(r),
|
|
468
|
+
archived: archiveRoomFile(r),
|
|
469
|
+
})),
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
// Live files first. The archive is opened ONLY on a miss, so no normal
|
|
473
|
+
// retrieval touches it and compaction/prune semantics are unchanged — but a
|
|
474
|
+
// record that compaction moved is still recoverable, because archive/ is
|
|
475
|
+
// append-only and complete.
|
|
476
|
+
for (const pass of ["live", "archived"] as const) {
|
|
477
|
+
for (const s of scopes) {
|
|
478
|
+
const entries = await readJsonl<Message>(s[pass]);
|
|
479
|
+
const msg = entries.find((m) => m.id === args.id);
|
|
480
|
+
if (msg) {
|
|
481
|
+
return {
|
|
482
|
+
ok: true as const,
|
|
483
|
+
id: msg.id,
|
|
484
|
+
source: s.source,
|
|
485
|
+
room: s.room,
|
|
486
|
+
archived: pass === "archived",
|
|
487
|
+
message: msg,
|
|
488
|
+
record: msg.record,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
ok: false as const,
|
|
495
|
+
error: `no message '${args.id}' in any channel you can read — it may never have been delivered to you`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
300
499
|
// ---------- retrieve_room_history ----------
|
|
301
500
|
|
|
302
501
|
export const retrieveRoomHistorySchema = {
|