mcp-coordinator 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/dashboard/Dockerfile +19 -0
  4. package/dashboard/public/index.html +1178 -0
  5. package/dist/cli/config.d.ts +14 -0
  6. package/dist/cli/config.js +58 -0
  7. package/dist/cli/dashboard.d.ts +2 -0
  8. package/dist/cli/dashboard.js +14 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +13 -0
  11. package/dist/cli/server/index.d.ts +2 -0
  12. package/dist/cli/server/index.js +11 -0
  13. package/dist/cli/server/start.d.ts +2 -0
  14. package/dist/cli/server/start.js +57 -0
  15. package/dist/cli/server/status.d.ts +2 -0
  16. package/dist/cli/server/status.js +60 -0
  17. package/dist/cli/server/stop.d.ts +2 -0
  18. package/dist/cli/server/stop.js +59 -0
  19. package/dist/cli/version.d.ts +1 -0
  20. package/dist/cli/version.js +22 -0
  21. package/dist/src/agent-activity.d.ts +27 -0
  22. package/dist/src/agent-activity.js +70 -0
  23. package/dist/src/agent-registry.d.ts +10 -0
  24. package/dist/src/agent-registry.js +38 -0
  25. package/dist/src/auth.d.ts +22 -0
  26. package/dist/src/auth.js +91 -0
  27. package/dist/src/conflict-detector.d.ts +17 -0
  28. package/dist/src/conflict-detector.js +114 -0
  29. package/dist/src/consultation.d.ts +75 -0
  30. package/dist/src/consultation.js +332 -0
  31. package/dist/src/context-provider.d.ts +14 -0
  32. package/dist/src/context-provider.js +34 -0
  33. package/dist/src/database.d.ts +4 -0
  34. package/dist/src/database.js +194 -0
  35. package/dist/src/db-adapter.d.ts +15 -0
  36. package/dist/src/db-adapter.js +1 -0
  37. package/dist/src/dependency-map.d.ts +7 -0
  38. package/dist/src/dependency-map.js +76 -0
  39. package/dist/src/file-tracker.d.ts +21 -0
  40. package/dist/src/file-tracker.js +44 -0
  41. package/dist/src/impact-scorer.d.ts +31 -0
  42. package/dist/src/impact-scorer.js +112 -0
  43. package/dist/src/index.d.ts +2 -0
  44. package/dist/src/index.js +26 -0
  45. package/dist/src/introspection.d.ts +24 -0
  46. package/dist/src/introspection.js +28 -0
  47. package/dist/src/logger.d.ts +20 -0
  48. package/dist/src/logger.js +55 -0
  49. package/dist/src/mqtt-bridge.d.ts +40 -0
  50. package/dist/src/mqtt-bridge.js +173 -0
  51. package/dist/src/mqtt-broker.d.ts +23 -0
  52. package/dist/src/mqtt-broker.js +99 -0
  53. package/dist/src/plan-quality.d.ts +11 -0
  54. package/dist/src/plan-quality.js +30 -0
  55. package/dist/src/quota/credential-reader.d.ts +21 -0
  56. package/dist/src/quota/credential-reader.js +86 -0
  57. package/dist/src/quota/quota-cache.d.ts +93 -0
  58. package/dist/src/quota/quota-cache.js +177 -0
  59. package/dist/src/quota/quota.d.ts +47 -0
  60. package/dist/src/quota/quota.js +117 -0
  61. package/dist/src/serve-http.d.ts +5 -0
  62. package/dist/src/serve-http.js +775 -0
  63. package/dist/src/server-setup.d.ts +34 -0
  64. package/dist/src/server-setup.js +453 -0
  65. package/dist/src/sse-emitter.d.ts +10 -0
  66. package/dist/src/sse-emitter.js +35 -0
  67. package/dist/src/types.d.ts +121 -0
  68. package/dist/src/types.js +1 -0
  69. package/package.json +80 -0
@@ -0,0 +1,775 @@
1
+ import { createServer } from "http";
2
+ import { randomUUID, timingSafeEqual } from "crypto";
3
+ import path from "path";
4
+ import { readFileSync, existsSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ let __filename;
7
+ try {
8
+ __filename = fileURLToPath(import.meta.url);
9
+ }
10
+ catch {
11
+ __filename = process.cwd();
12
+ }
13
+ const __dirname = path.dirname(__filename);
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { createServices, createMcpServer } from "./server-setup.js";
16
+ import { createLogger } from "./logger.js";
17
+ import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger } from "./auth.js";
18
+ import { assessPlanQuality } from "./plan-quality.js";
19
+ import { getVersion } from "../cli/version.js";
20
+ const VERSION = getVersion();
21
+ import { startEmbeddedMqttBroker } from "./mqtt-broker.js";
22
+ const SERVER_FILE_DIR = path.dirname(__filename);
23
+ async function getDashboardDir() {
24
+ // src/serve-http.ts (tsx) → dashboard/public is at ../dashboard/public
25
+ // dist/src/serve-http.js → dashboard/public is at ../../dashboard/public
26
+ // Walk up until we find a directory containing dashboard/public/index.html.
27
+ let dir = SERVER_FILE_DIR;
28
+ while (dir !== path.dirname(dir)) {
29
+ const candidate = path.resolve(dir, "dashboard", "public", "index.html");
30
+ if (existsSync(candidate))
31
+ return path.resolve(dir, "dashboard", "public");
32
+ dir = path.dirname(dir);
33
+ }
34
+ throw new Error(`mcp-coordinator: could not locate dashboard/public/ from ${SERVER_FILE_DIR}`);
35
+ }
36
+ const PORT = parseInt(process.env.PORT || "3100");
37
+ const DATA_DIR = process.env.COORDINATOR_DATA_DIR || "./data";
38
+ // MQTT is always embedded; ports/paths are configurable for multi-instance setups
39
+ const MQTT_TCP_PORT = parseInt(process.env.COORDINATOR_MQTT_TCP_PORT || "1883");
40
+ const MQTT_WS_PATH = process.env.COORDINATOR_MQTT_WS_PATH || "/mqtt";
41
+ const AUTH_ENABLED = process.env.COORDINATOR_AUTH_ENABLED === "true";
42
+ const JWT_SECRET = process.env.COORDINATOR_JWT_SECRET || "";
43
+ const JWT_EXPIRY = process.env.COORDINATOR_JWT_EXPIRY || "24h";
44
+ const REGISTRATION_SECRET = process.env.COORDINATOR_REGISTRATION_SECRET || "";
45
+ const ADMIN_SECRET = process.env.COORDINATOR_ADMIN_SECRET || "";
46
+ let services;
47
+ let httpLog;
48
+ let mcpLog;
49
+ let authLog;
50
+ let currentRunConfig = null;
51
+ function parseBody(req) {
52
+ return new Promise((resolve, reject) => {
53
+ let body = "";
54
+ req.on("data", (chunk) => (body += chunk.toString()));
55
+ req.on("end", () => {
56
+ try {
57
+ resolve(body ? JSON.parse(body) : {});
58
+ }
59
+ catch {
60
+ reject(new Error("Invalid JSON"));
61
+ }
62
+ });
63
+ req.on("error", reject);
64
+ });
65
+ }
66
+ function json(res, data, status = 200) {
67
+ res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
68
+ res.end(JSON.stringify(data));
69
+ }
70
+ function decodeJwtPayload(token) {
71
+ const base64url = token.split(".")[1];
72
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
73
+ return JSON.parse(atob(base64));
74
+ }
75
+ function safeEqual(a, b) {
76
+ if (a.length !== b.length)
77
+ return false;
78
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
79
+ }
80
+ async function handleRest(req, res) {
81
+ const url = req.url || "";
82
+ const body = await parseBody(req);
83
+ const agentId = body.agent_id;
84
+ // Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
85
+ // to keep the info log focused on coordination events (announce, claim, resolve, etc).
86
+ const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
87
+ // Note: /api/quota/refresh is NOT in the poll list — it's a manual user
88
+ // action and deserves an info-level log for auditability.
89
+ if (isPoll) {
90
+ httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
91
+ }
92
+ else {
93
+ httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
94
+ }
95
+ const { registry, activityTracker, consultation, fileTracker, impactScorer, introspection, sseEmitter, mqttBridge, quotaCache } = services;
96
+ if (url === "/api/register") {
97
+ const { agent_id, name, modules } = body;
98
+ const agent = registry.register(agent_id, name, modules || []);
99
+ sseEmitter.emit("agent_online", { agent_id, name, modules });
100
+ json(res, agent);
101
+ }
102
+ else if (url === "/api/session-start") {
103
+ const { agent_id, agent_name } = body;
104
+ const online = registry.listOnline();
105
+ const openThreads = consultation.listThreads({ status: "open" });
106
+ const hotFiles = fileTracker.getHotFiles(30);
107
+ const briefing = [
108
+ `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
109
+ `Consultations ouvertes: ${openThreads.length}`,
110
+ `Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
111
+ ].join("\n");
112
+ json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
113
+ }
114
+ else if (url === "/api/session-stop") {
115
+ const { agent_id } = body;
116
+ registry.setOffline(agent_id);
117
+ activityTracker.reportOffline(agent_id);
118
+ consultation.handleAgentDeparture(agent_id);
119
+ sseEmitter.emit("agent_offline", { agent_id });
120
+ json(res, { ok: true });
121
+ }
122
+ else if (url === "/api/check-conflict") {
123
+ const { file, agent_id } = body;
124
+ const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
125
+ const warnings = [];
126
+ if (conflict.conflict) {
127
+ warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
128
+ }
129
+ json(res, { conflict: conflict.conflict, warnings });
130
+ }
131
+ else if (url === "/api/log-file") {
132
+ const { session_id, agent_id, agent_name, tool_name, file } = body;
133
+ fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
134
+ activityTracker.reportFileActivity(agent_id, file);
135
+ sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
136
+ json(res, { ok: true });
137
+ }
138
+ else if (url === "/api/announce") {
139
+ const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
140
+ // Quality gate on plan
141
+ const planQuality = assessPlanQuality(plan);
142
+ const effectiveMode = planQuality.mode;
143
+ const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
144
+ const agentInfo = registry.get(agent_id);
145
+ // Impact scoring: categorize all online agents
146
+ const categorized = impactScorer.categorize({
147
+ agent_id, target_modules, target_files, depends_on_files, exports_affected,
148
+ });
149
+ // Override expected_respondents with concerned agents from scorer
150
+ {
151
+ const db = (await import("./database.js")).getDb();
152
+ const concernedIds = categorized.concerned.map(s => s.agent_id);
153
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
154
+ .run(JSON.stringify(concernedIds), thread.id);
155
+ // Only auto-resolve when truly alone — no other online agents.
156
+ // If peers are online but not yet concerned (e.g. they haven't announced
157
+ // yet), keep the thread open so a subsequent announce can still match
158
+ // this work via Layer 0. Thread will timeout naturally if no one joins.
159
+ const otherOnlineCount = registry.listOnline().filter((a) => a.id !== agent_id).length;
160
+ const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
161
+ if (shouldAutoResolve && thread.status === "open" && !keep_open) {
162
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
163
+ .run(new Date().toISOString(), thread.id);
164
+ consultation.emitResolution(thread.id, "auto_resolved");
165
+ }
166
+ }
167
+ // Emit impact_scored SSE events for all agents
168
+ for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
169
+ sseEmitter.emit("impact_scored", {
170
+ thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name,
171
+ score: s.score, reasons: s.reasons, category: s.score >= 90 ? "concerned" : s.score >= 30 ? "gray_zone" : "pass",
172
+ });
173
+ }
174
+ // Create introspection records and emit introspection_requested for gray_zone agents
175
+ for (const s of categorized.gray_zone) {
176
+ introspection.create({ thread_id: thread.id, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
177
+ sseEmitter.emit("introspection_requested", {
178
+ thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name, score: s.score, reasons: s.reasons,
179
+ });
180
+ }
181
+ const updated = consultation.getThread(thread.id);
182
+ const respondents = JSON.parse(updated.expected_respondents || "[]");
183
+ // Emit downgrade event when plan is provided but quality is insufficient
184
+ if (plan && effectiveMode === "discovery") {
185
+ sseEmitter.emit("impact_scored", {
186
+ thread_id: thread.id,
187
+ agent_id: agent_id,
188
+ agent_name: agentInfo?.name || agent_id,
189
+ score: planQuality.score,
190
+ reasons: [`plan downgraded: score ${planQuality.score}/3 — ${!planQuality.checks.mentions_files ? 'no files' : ''} ${!planQuality.checks.concrete_approach ? 'vague approach' : ''} ${!planQuality.checks.sufficient_detail ? 'too short' : ''}`.trim()],
191
+ category: "plan_quality",
192
+ });
193
+ }
194
+ sseEmitter.emit("thread_opened", {
195
+ thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
196
+ target_modules, target_files, expected_respondents: respondents,
197
+ conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
198
+ created_at: updated.created_at,
199
+ mode: effectiveMode,
200
+ plan: plan || null,
201
+ plan_quality: planQuality,
202
+ });
203
+ json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
204
+ }
205
+ else if (url === "/api/post-to-thread") {
206
+ const { thread_id, agent_id, agent_name, type, content } = body;
207
+ // Pre-check the thread so we can return actionable status codes instead
208
+ // of always-500 on any error. The client uses the status to decide
209
+ // whether to warn (unexpected) or silently skip (normal race).
210
+ const targetThread = consultation.getThread(thread_id);
211
+ if (!targetThread) {
212
+ json(res, { error: "thread_not_found", thread_id }, 404);
213
+ return;
214
+ }
215
+ if (targetThread.status === "cancelled") {
216
+ json(res, { error: "thread_cancelled", thread_id }, 410);
217
+ return;
218
+ }
219
+ const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
220
+ const thread = consultation.getThread(thread_id);
221
+ sseEmitter.emit("message_posted", {
222
+ thread_id, agent_id, agent_name: agent_name || agent_id,
223
+ type, content, round: thread?.round || 1,
224
+ token_estimate: msg.token_estimate || 0,
225
+ });
226
+ json(res, msg);
227
+ }
228
+ else if (url === "/api/token-usage") {
229
+ // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
230
+ // and reports can pinpoint where tokens are being burned.
231
+ const payload = body;
232
+ sseEmitter.emit("token_usage", payload);
233
+ json(res, { ok: true });
234
+ }
235
+ else if (url === "/api/unclaim-task") {
236
+ const { thread_id, agent_id } = body;
237
+ if (!thread_id || !agent_id) {
238
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
239
+ return;
240
+ }
241
+ const db = (await import("./database.js")).getDb();
242
+ // F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
243
+ // to "poisoned" so no agent claims it again — prevents the tight
244
+ // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
245
+ // Only the claiming agent can unclaim to prevent cross-agent interference.
246
+ const POISON_THRESHOLD = 2;
247
+ const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, agent_id);
248
+ let poisoned = false;
249
+ if (result.changes === 1) {
250
+ const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
251
+ if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
252
+ db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
253
+ poisoned = true;
254
+ httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
255
+ }
256
+ }
257
+ json(res, { success: result.changes === 1, poisoned });
258
+ }
259
+ else if (url === "/api/claim-task") {
260
+ const { thread_id, agent_id } = body;
261
+ if (!thread_id || !agent_id) {
262
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
263
+ return;
264
+ }
265
+ const db = (await import("./database.js")).getDb();
266
+ // Only claim threads with status='open' — poisoned threads are filtered out
267
+ // automatically because the status filter excludes them.
268
+ // Directed-dispatch constraint: if assigned_to is set, only that specific
269
+ // agent can claim; NULL keeps the original open-pool semantics.
270
+ const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL AND status = 'open' AND (assigned_to IS NULL OR assigned_to = ?)").run(agent_id, new Date().toISOString(), thread_id, agent_id);
271
+ if (result.changes === 1) {
272
+ mqttBridge.publishTaskClaimed(thread_id, agent_id);
273
+ sseEmitter.emit("task_claimed", { thread_id, agent_id });
274
+ json(res, { success: true });
275
+ }
276
+ else {
277
+ const thread = consultation.getThread(thread_id);
278
+ // Surface the assigned_to in the 'why not' response so clients can
279
+ // distinguish "already claimed by X" from "reserved for Y".
280
+ json(res, {
281
+ success: false,
282
+ claimed_by: thread?.claimed_by || null,
283
+ assigned_to: thread?.assigned_to || null,
284
+ status: thread?.status,
285
+ });
286
+ }
287
+ }
288
+ else if (url === "/api/propose-resolution") {
289
+ const { thread_id, agent_id, summary } = body;
290
+ const agentInfo = registry.get(agent_id);
291
+ consultation.proposeResolution(thread_id, agent_id, summary);
292
+ sseEmitter.emit("resolution_proposed", {
293
+ thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
294
+ });
295
+ json(res, consultation.getThread(thread_id));
296
+ mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
297
+ }
298
+ else if (url === "/api/approve-resolution") {
299
+ const { thread_id, agent_id } = body;
300
+ const agentInfo = registry.get(agent_id);
301
+ consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
302
+ const t = consultation.getThread(thread_id);
303
+ json(res, t);
304
+ }
305
+ else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
306
+ const threadId = url.split("/")[3];
307
+ const thread = consultation.getThreadWithMessages(threadId);
308
+ if (!thread) {
309
+ json(res, { error: "not found" }, 404);
310
+ }
311
+ else {
312
+ json(res, {
313
+ status: thread.thread.status,
314
+ messages: thread.messages,
315
+ resolution_summary: thread.thread.resolution_summary,
316
+ expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
317
+ });
318
+ }
319
+ }
320
+ else if (url === "/api/threads-active") {
321
+ const open = consultation.listThreads({ status: "open" });
322
+ const resolving = consultation.listThreads({ status: "resolving" });
323
+ json(res, [...open, ...resolving]);
324
+ }
325
+ else if (url === "/api/hot-files") {
326
+ const { since_minutes } = body;
327
+ json(res, fileTracker.getHotFiles(since_minutes || 30));
328
+ }
329
+ else if (url === "/api/quota") {
330
+ // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
331
+ // Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
332
+ // 503 as "quota unknown = proceed" (fail-open) per the project decision.
333
+ const info = await quotaCache.get();
334
+ if (!info) {
335
+ const status = quotaCache.snapshot();
336
+ json(res, {
337
+ error: "quota unavailable",
338
+ reason: status.lastError,
339
+ cooldown_until: status.cooldownUntil,
340
+ }, 503);
341
+ }
342
+ else {
343
+ json(res, {
344
+ five_hour: info.fiveHour,
345
+ seven_day: info.sevenDay,
346
+ seven_day_sonnet: info.sevenDaySonnet,
347
+ fetched_at: info.fetchedAt,
348
+ });
349
+ }
350
+ }
351
+ else if (url === "/api/quota/refresh") {
352
+ // Force-refresh the cache, bypassing the TTL. Used by the dashboard's
353
+ // manual refresh button. The underlying quotaCache.refresh() is single-
354
+ // flight-deduped, so mashing the button doesn't stack parallel fetches.
355
+ // The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
356
+ // dashboard receives the update through the normal channel too — this
357
+ // endpoint only exists for "give me the answer now" semantics.
358
+ const info = await quotaCache.refresh();
359
+ if (!info) {
360
+ const status = quotaCache.snapshot();
361
+ json(res, {
362
+ error: "quota unavailable",
363
+ reason: status.lastError,
364
+ cooldown_until: status.cooldownUntil,
365
+ }, 503);
366
+ }
367
+ else {
368
+ json(res, {
369
+ five_hour: info.fiveHour,
370
+ seven_day: info.sevenDay,
371
+ seven_day_sonnet: info.sevenDaySonnet,
372
+ fetched_at: info.fetchedAt,
373
+ });
374
+ }
375
+ }
376
+ else if (url === "/api/introspection-response") {
377
+ const { introspection_id, concerned, reason } = body;
378
+ const intro = introspection.respond(introspection_id, concerned, reason);
379
+ // If concerned, add to thread's expected_respondents
380
+ if (concerned && intro) {
381
+ const db = (await import("./database.js")).getDb();
382
+ const thread = consultation.getThread(intro.thread_id);
383
+ if (thread && (thread.status === "open" || thread.status === "resolving")) {
384
+ const respondents = JSON.parse(thread.expected_respondents || "[]");
385
+ if (!respondents.includes(intro.agent_id)) {
386
+ respondents.push(intro.agent_id);
387
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
388
+ .run(JSON.stringify(respondents), thread.id);
389
+ }
390
+ }
391
+ }
392
+ const agentInfo = registry.get(intro?.agent_id || "");
393
+ sseEmitter.emit("introspection_completed", {
394
+ introspection_id, thread_id: intro?.thread_id,
395
+ agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
396
+ concerned, reason,
397
+ });
398
+ json(res, intro);
399
+ }
400
+ else if (url?.startsWith("/api/pending-introspections")) {
401
+ const urlObj = new URL(url, "http://localhost");
402
+ const agent_id = urlObj.searchParams.get("agent_id") || "";
403
+ const pending = introspection.getPending(agent_id);
404
+ json(res, pending);
405
+ }
406
+ else if (url === "/api/run-config") {
407
+ if (req.method === "POST") {
408
+ currentRunConfig = body;
409
+ sseEmitter.emit("run_config", currentRunConfig);
410
+ json(res, { ok: true });
411
+ }
412
+ else {
413
+ json(res, currentRunConfig || { active: false });
414
+ }
415
+ }
416
+ else if (url === "/api/reset") {
417
+ // Reset all tables for clean test run (disable FK checks to avoid ordering issues)
418
+ const db = (await import("./database.js")).getDb();
419
+ db.exec("PRAGMA foreign_keys = OFF");
420
+ db.exec("DELETE FROM introspections");
421
+ db.exec("DELETE FROM events");
422
+ db.exec("DELETE FROM thread_messages");
423
+ db.exec("DELETE FROM threads");
424
+ db.exec("DELETE FROM action_summaries");
425
+ db.exec("DELETE FROM file_activity");
426
+ db.exec("DELETE FROM agent_activity_status");
427
+ db.exec("DELETE FROM dependency_map");
428
+ db.exec("DELETE FROM agents");
429
+ db.exec("DELETE FROM revoked_agents");
430
+ db.exec("PRAGMA foreign_keys = ON");
431
+ currentRunConfig = null;
432
+ json(res, { ok: true });
433
+ }
434
+ else if (url === "/api/check-interrupt") {
435
+ const { agent_id } = body;
436
+ // Check for threads where this agent is an expected respondent and hasn't posted yet.
437
+ // Covers both open threads (waiting for initial response) and resolving threads
438
+ // (waiting for approval/contest of a proposed resolution).
439
+ const pendingThreads = [
440
+ ...consultation.listThreads({ status: "open" }),
441
+ ...consultation.listThreads({ status: "resolving" }),
442
+ ].filter((t) => {
443
+ const respondents = JSON.parse(t.expected_respondents || "[]");
444
+ return respondents.includes(agent_id);
445
+ });
446
+ if (pendingThreads.length > 0) {
447
+ const details = pendingThreads.map((t) => ({
448
+ thread_id: t.id,
449
+ subject: t.subject,
450
+ initiator_id: t.initiator_id,
451
+ status: t.status,
452
+ target_files: JSON.parse(t.target_files || "[]"),
453
+ }));
454
+ json(res, { interrupt: true, threads: details });
455
+ }
456
+ else {
457
+ json(res, { interrupt: false });
458
+ }
459
+ }
460
+ else if (url?.startsWith("/api/agent-status/")) {
461
+ const agentId = url.split("/")[3];
462
+ const agent = registry.get(agentId);
463
+ if (!agent) {
464
+ json(res, { registered: false, status: "unknown" });
465
+ }
466
+ else {
467
+ const activity = activityTracker.getActivity(agentId, { idleAfterMinutes: 5 });
468
+ json(res, { registered: true, status: agent.status, activity: activity.activity_status });
469
+ }
470
+ }
471
+ else if (url === "/api/status") {
472
+ const online = registry.listOnline();
473
+ const openThreads = consultation.listThreads({ status: "open" });
474
+ json(res, {
475
+ online: online.length,
476
+ open_threads: openThreads.length,
477
+ hot_files: fileTracker.getHotFiles(30).length,
478
+ mqtt: services.mqttBridge.isConnected(),
479
+ });
480
+ }
481
+ else {
482
+ json(res, { error: "not found" }, 404);
483
+ }
484
+ }
485
+ async function handleAuth(req, res) {
486
+ const url = req.url || "";
487
+ const body = await parseBody(req);
488
+ if (url === "/api/auth/register" && req.method === "POST") {
489
+ const { agent_name, registration_secret } = body;
490
+ if (!agent_name || !registration_secret) {
491
+ json(res, { error: "agent_name and registration_secret are required" }, 400);
492
+ return;
493
+ }
494
+ let role = "agent";
495
+ if (safeEqual(registration_secret, ADMIN_SECRET)) {
496
+ role = "admin";
497
+ }
498
+ else if (!safeEqual(registration_secret, REGISTRATION_SECRET)) {
499
+ authLog.warn({ agent_name, ip: req.socket.remoteAddress }, "Invalid registration secret");
500
+ json(res, { error: "Invalid registration secret" }, 401);
501
+ return;
502
+ }
503
+ const agentId = randomUUID();
504
+ const token = await createToken(agentId, role);
505
+ const payload = decodeJwtPayload(token);
506
+ const expiresAt = new Date(payload.exp * 1000).toISOString();
507
+ authLog.info({ agent_id: agentId, agent_name, role, method: "auto-register" }, "Agent registered via auto-register");
508
+ json(res, { agent_id: agentId, token, expires_at: expiresAt, role });
509
+ }
510
+ else if (url === "/api/auth/refresh" && req.method === "POST") {
511
+ const authHeader = req.headers.authorization;
512
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
513
+ json(res, { error: "Bearer token required" }, 401);
514
+ return;
515
+ }
516
+ try {
517
+ const newToken = await refreshToken(authHeader.slice(7));
518
+ const payload = decodeJwtPayload(newToken);
519
+ const expiresAt = new Date(payload.exp * 1000).toISOString();
520
+ authLog.info({ agent_id: payload.sub }, "Token refreshed");
521
+ json(res, { token: newToken, expires_at: expiresAt });
522
+ }
523
+ catch {
524
+ json(res, { error: "Invalid or expired token (beyond grace period)" }, 401);
525
+ }
526
+ }
527
+ else if (url === "/api/auth/revoke" && req.method === "POST") {
528
+ const authResult = await authenticateRequest(req);
529
+ if (!authResult.ok) {
530
+ json(res, { error: authResult.error }, authResult.status);
531
+ return;
532
+ }
533
+ const { agent_id } = body;
534
+ if (!agent_id) {
535
+ json(res, { error: "agent_id is required" }, 400);
536
+ return;
537
+ }
538
+ revokeAgent(agent_id, authResult.claims.sub);
539
+ authLog.info({ agent_id, revoked_by: authResult.claims.sub }, "Agent revoked");
540
+ json(res, { ok: true, agent_id, revoked_by: authResult.claims.sub });
541
+ }
542
+ else {
543
+ json(res, { error: "not found" }, 404);
544
+ }
545
+ }
546
+ /**
547
+ * Splice `_ts` (the event's created_at, set by the server when the event was
548
+ * first emitted) into the payload JSON. Done as a string prepend rather than
549
+ * JSON.parse+stringify to avoid the round-trip on every SSE message — the
550
+ * payload is always a JSON object literal by contract. The client reads `_ts`
551
+ * to render the original event time on page reload / replay instead of
552
+ * falling back to Date.now() which painted every historical event with the
553
+ * current wall clock.
554
+ */
555
+ function injectTimestamp(payloadJson, createdAt) {
556
+ if (!payloadJson.startsWith("{"))
557
+ return payloadJson;
558
+ const body = payloadJson.slice(1);
559
+ // Empty object `{}` → `{"_ts":"..."}` with no stray comma.
560
+ if (body === "}")
561
+ return `{"_ts":${JSON.stringify(createdAt)}}`;
562
+ return `{"_ts":${JSON.stringify(createdAt)},${body}`;
563
+ }
564
+ function writeSseEvent(res, event) {
565
+ // created_at is optional in the DB row type but always set at emit time by
566
+ // the SseEmitter. Fall back to "now" for the rare case a row predates the
567
+ // field — the client uses Date.now() when _ts is missing anyway.
568
+ const data = injectTimestamp(event.payload, event.created_at ?? new Date().toISOString());
569
+ res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${data}\n\n`);
570
+ }
571
+ function handleSse(req, res) {
572
+ res.writeHead(200, {
573
+ "Content-Type": "text/event-stream",
574
+ "Cache-Control": "no-cache",
575
+ Connection: "keep-alive",
576
+ "Access-Control-Allow-Origin": "*",
577
+ });
578
+ // Use Last-Event-ID for resumption, otherwise send last 50
579
+ const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
580
+ const events = lastEventId > 0
581
+ ? services.sseEmitter.getEventsSince(lastEventId)
582
+ : services.sseEmitter.getEventsSince(0).slice(-50);
583
+ for (const event of events) {
584
+ writeSseEvent(res, event);
585
+ }
586
+ // Listen for new events
587
+ const unsubscribe = services.sseEmitter.addListener((event) => {
588
+ writeSseEvent(res, event);
589
+ });
590
+ req.on("close", () => unsubscribe());
591
+ }
592
+ export async function startServer(opts) {
593
+ const port = opts?.port ?? PORT;
594
+ const dataDir = opts?.dataDir ?? DATA_DIR;
595
+ services = createServices({ dataDir });
596
+ const log = services.logger;
597
+ httpLog = log.child({ component: "http" });
598
+ mcpLog = log.child({ component: "mcp" });
599
+ authLog = log.child({ component: "auth" });
600
+ setAuthLogger(authLog);
601
+ if (AUTH_ENABLED) {
602
+ if (!JWT_SECRET || JWT_SECRET.length < 32) {
603
+ log.fatal("COORDINATOR_JWT_SECRET is required (min 32 chars) when auth is enabled");
604
+ process.exit(1);
605
+ }
606
+ if (!REGISTRATION_SECRET) {
607
+ log.fatal("COORDINATOR_REGISTRATION_SECRET is required when auth is enabled");
608
+ process.exit(1);
609
+ }
610
+ if (!ADMIN_SECRET) {
611
+ log.fatal("COORDINATOR_ADMIN_SECRET is required when auth is enabled");
612
+ process.exit(1);
613
+ }
614
+ initAuth(JWT_SECRET, JWT_EXPIRY);
615
+ log.info("Auth enabled (JWT HS256)");
616
+ }
617
+ // Multi-session: one transport+server per MCP client session
618
+ const sessions = new Map();
619
+ const httpServer = createServer(async (req, res) => {
620
+ const url = req.url || "";
621
+ // CORS preflight
622
+ if (req.method === "OPTIONS") {
623
+ res.writeHead(204, {
624
+ "Access-Control-Allow-Origin": "*",
625
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
626
+ "Access-Control-Allow-Headers": "Content-Type, mcp-session-id, Authorization",
627
+ });
628
+ res.end();
629
+ return;
630
+ }
631
+ try {
632
+ if (url === "/dashboard" || url.startsWith("/dashboard/")) {
633
+ const dashboardDir = await getDashboardDir().catch((err) => {
634
+ httpLog.warn({ err }, "Dashboard not found");
635
+ return null;
636
+ });
637
+ if (!dashboardDir) {
638
+ json(res, { error: "dashboard not available" }, 404);
639
+ return;
640
+ }
641
+ const filePath = url === "/dashboard" || url === "/dashboard/"
642
+ ? path.join(dashboardDir, "index.html")
643
+ : path.join(dashboardDir, url.replace("/dashboard/", ""));
644
+ if (existsSync(filePath)) {
645
+ const ext = path.extname(filePath);
646
+ const contentTypes = {
647
+ ".html": "text/html",
648
+ ".js": "application/javascript",
649
+ ".css": "text/css",
650
+ ".json": "application/json",
651
+ };
652
+ const content = readFileSync(filePath, "utf-8");
653
+ res.writeHead(200, {
654
+ "Content-Type": contentTypes[ext] || "text/plain",
655
+ "Access-Control-Allow-Origin": "*",
656
+ });
657
+ res.end(content);
658
+ }
659
+ else {
660
+ json(res, { error: "not found" }, 404);
661
+ }
662
+ return;
663
+ }
664
+ else if (url === "/health") {
665
+ json(res, { status: "ok", version: VERSION });
666
+ }
667
+ else if (url === "/api/events" && req.method === "GET") {
668
+ handleSse(req, res);
669
+ }
670
+ else if (url.startsWith("/api/auth/")) {
671
+ if (!AUTH_ENABLED) {
672
+ json(res, { error: "Authentication is not enabled on this coordinator" }, 501);
673
+ }
674
+ else {
675
+ await handleAuth(req, res);
676
+ }
677
+ }
678
+ else if (url === "/mcp") {
679
+ const sessionId = req.headers["mcp-session-id"];
680
+ if (sessionId && sessions.has(sessionId)) {
681
+ // Existing session — already authenticated, route directly
682
+ await sessions.get(sessionId).handleRequest(req, res);
683
+ }
684
+ else if (req.method === "POST" && !sessionId) {
685
+ // New session — auth guard required
686
+ let authenticatedAgent;
687
+ if (AUTH_ENABLED) {
688
+ const authResult = await authenticateRequest(req);
689
+ if (!authResult.ok) {
690
+ authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
691
+ json(res, { error: authResult.error }, authResult.status);
692
+ return;
693
+ }
694
+ authenticatedAgent = authResult.claims.sub;
695
+ }
696
+ // Create transport + server
697
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
698
+ const mcpServer = createMcpServer(services);
699
+ await mcpServer.connect(transport);
700
+ transport.onclose = () => {
701
+ const sid = transport.sessionId;
702
+ if (sid)
703
+ sessions.delete(sid);
704
+ mcpLog.info({ session_id: sid, remaining: sessions.size }, "MCP session closed");
705
+ };
706
+ await transport.handleRequest(req, res);
707
+ if (transport.sessionId) {
708
+ sessions.set(transport.sessionId, transport);
709
+ mcpLog.info({ session_id: transport.sessionId, total: sessions.size, agent_id: authenticatedAgent }, "MCP session opened");
710
+ }
711
+ }
712
+ else {
713
+ json(res, { error: "Session not found. Send a request without mcp-session-id to start a new session." }, 404);
714
+ }
715
+ }
716
+ else {
717
+ // Auth guard for protected routes
718
+ if (AUTH_ENABLED) {
719
+ const authResult = await authenticateRequest(req);
720
+ if (!authResult.ok) {
721
+ authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
722
+ json(res, { error: authResult.error }, authResult.status);
723
+ return;
724
+ }
725
+ }
726
+ if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
727
+ await handleRest(req, res);
728
+ }
729
+ else {
730
+ json(res, { error: "not found" }, 404);
731
+ }
732
+ }
733
+ }
734
+ catch (err) {
735
+ httpLog.error({ err }, "HTTP request error");
736
+ json(res, { error: err.message }, 500);
737
+ }
738
+ });
739
+ // Start the embedded MQTT broker (TCP + WebSocket on HTTP upgrade).
740
+ // Awaiting ensures the TCP listener is fully bound before we connect our
741
+ // own client or tell users the coordinator is ready.
742
+ await startEmbeddedMqttBroker({
743
+ tcpPort: MQTT_TCP_PORT,
744
+ httpServer,
745
+ wsPath: MQTT_WS_PATH,
746
+ logger: log.child({ component: "mqtt-broker" }),
747
+ });
748
+ // Connect the coordinator's own MQTT client to the embedded broker BEFORE
749
+ // the HTTP server accepts requests — agents shouldn't see a half-ready coordinator.
750
+ await services.mqttBridge.connect({ url: `mqtt://127.0.0.1:${MQTT_TCP_PORT}` });
751
+ services.mqttBridge.onOffline((agentId) => {
752
+ services.registry.setOffline(agentId);
753
+ services.consultation.handleAgentDeparture(agentId);
754
+ services.sseEmitter.emit("agent_offline", { agent_id: agentId });
755
+ });
756
+ httpServer.listen(port, () => {
757
+ log.info({
758
+ port,
759
+ mcp: `POST http://localhost:${port}/mcp`,
760
+ rest: `POST http://localhost:${port}/api/*`,
761
+ sse: `GET http://localhost:${port}/api/events`,
762
+ mqtt_tcp: `mqtt://127.0.0.1:${MQTT_TCP_PORT}`,
763
+ mqtt_ws: `ws://localhost:${port}${MQTT_WS_PATH}`,
764
+ }, "Coordinator v3 started");
765
+ });
766
+ }
767
+ // Auto-start when run directly (not imported)
768
+ const isMainModule = process.argv[1]?.endsWith("serve-http.ts") || process.argv[1]?.endsWith("serve-http.js");
769
+ if (isMainModule) {
770
+ startServer().catch((err) => {
771
+ const log = createLogger();
772
+ log.fatal({ err }, "Fatal startup error");
773
+ process.exit(1);
774
+ });
775
+ }