mcp-coordinator 0.3.0 → 0.5.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 (44) hide show
  1. package/README.md +14 -0
  2. package/dashboard/public/index.html +23 -0
  3. package/dist/cli/server/backup.d.ts +7 -0
  4. package/dist/cli/server/backup.js +162 -0
  5. package/dist/cli/server/index.js +5 -0
  6. package/dist/cli/server/restore.d.ts +2 -0
  7. package/dist/cli/server/restore.js +117 -0
  8. package/dist/cli/server/start.js +33 -0
  9. package/dist/src/announce-workflow.d.ts +1 -0
  10. package/dist/src/announce-workflow.js +28 -0
  11. package/dist/src/consultation.d.ts +8 -0
  12. package/dist/src/consultation.js +8 -0
  13. package/dist/src/database.js +65 -0
  14. package/dist/src/db-adapter.d.ts +30 -0
  15. package/dist/src/db-adapter.js +32 -1
  16. package/dist/src/dependency-map.js +2 -2
  17. package/dist/src/file-tracker.d.ts +12 -0
  18. package/dist/src/file-tracker.js +35 -2
  19. package/dist/src/git-cochange-builder.d.ts +32 -0
  20. package/dist/src/git-cochange-builder.js +238 -0
  21. package/dist/src/http/handle-health.d.ts +23 -0
  22. package/dist/src/http/handle-health.js +112 -0
  23. package/dist/src/http/handle-rest.js +83 -2
  24. package/dist/src/http/utils.d.ts +0 -4
  25. package/dist/src/http/utils.js +16 -2
  26. package/dist/src/impact-scorer.d.ts +5 -1
  27. package/dist/src/impact-scorer.js +182 -55
  28. package/dist/src/metrics.d.ts +88 -0
  29. package/dist/src/metrics.js +195 -0
  30. package/dist/src/mqtt-bridge.d.ts +19 -0
  31. package/dist/src/mqtt-bridge.js +53 -5
  32. package/dist/src/path-normalize.d.ts +17 -0
  33. package/dist/src/path-normalize.js +38 -0
  34. package/dist/src/serve-http.js +76 -3
  35. package/dist/src/server-setup.d.ts +8 -0
  36. package/dist/src/server-setup.js +31 -3
  37. package/dist/src/sse-emitter.d.ts +6 -0
  38. package/dist/src/sse-emitter.js +50 -2
  39. package/dist/src/tools/consultation-tools.js +4 -2
  40. package/dist/src/tree-sitter-extractor.d.ts +36 -0
  41. package/dist/src/tree-sitter-extractor.js +354 -0
  42. package/dist/src/working-files-tracker.d.ts +42 -0
  43. package/dist/src/working-files-tracker.js +111 -0
  44. package/package.json +20 -1
@@ -6,6 +6,14 @@ export class MqttBridge {
6
6
  onOfflineHandler = null;
7
7
  listeners = new Map();
8
8
  log;
9
+ agentId = "coordinator-internal";
10
+ /**
11
+ * P1: track the last threadId we retained on `coordinator/consultations/new`.
12
+ * The topic is fixed (not per-thread), so retain holds only the LAST event.
13
+ * `clearRetainedConsultation(threadId)` only clears when it matches, so a
14
+ * later consultation isn't accidentally wiped by a stale resolve callback.
15
+ */
16
+ lastRetainedConsultationThreadId = null;
9
17
  constructor(logger) {
10
18
  this.log = logger || silentLogger;
11
19
  }
@@ -14,11 +22,24 @@ export class MqttBridge {
14
22
  const timeout = setTimeout(() => {
15
23
  reject(new Error("MQTT connection timeout"));
16
24
  }, 5000);
25
+ // P1 fix: LWT requires a stable agent identifier. Default to
26
+ // "coordinator-internal" which matches the auth identity used by
27
+ // serve-http for the embedded broker bridge.
28
+ this.agentId = config.agentId || "coordinator-internal";
17
29
  this.client = mqtt.connect(config.url, {
18
- clientId: `coordinator-${Date.now()}`,
30
+ clientId: `${this.agentId}-${Date.now()}`,
19
31
  clean: true,
20
32
  username: config.username,
21
33
  password: config.password,
34
+ // P1 fix: register Last Will & Testament so a crashed/disconnected
35
+ // bridge automatically broadcasts offline status. Without this the
36
+ // agent appears online indefinitely after an unexpected disconnect.
37
+ will: {
38
+ topic: `coordinator/agents/${this.agentId}/status`,
39
+ payload: Buffer.from(JSON.stringify({ status: "offline", reason: "lwt_unexpected" })),
40
+ qos: 1,
41
+ retain: false,
42
+ },
22
43
  });
23
44
  this.client.on("connect", () => {
24
45
  clearTimeout(timeout);
@@ -80,17 +101,40 @@ export class MqttBridge {
80
101
  publishConsultation(threadId, agentId, subject, targetModules) {
81
102
  if (!this.client || !this.connected)
82
103
  return;
83
- this.client.publish("coordinator/consultations/new", JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }));
104
+ // P1 fix: QoS 1 (at-least-once) so consultation events survive transient
105
+ // disconnects. retain=true so a coordinator/subscriber restart can rebuild
106
+ // the active state without an event-history replay.
107
+ this.lastRetainedConsultationThreadId = threadId;
108
+ this.client.publish("coordinator/consultations/new", JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }), { qos: 1, retain: true });
109
+ }
110
+ /**
111
+ * P1 fix: clear the retained `coordinator/consultations/new` event when the
112
+ * matching thread resolves. The topic is fixed (not per-thread), so retain
113
+ * holds only the LAST consultation — clearing here means a coordinator
114
+ * restart after resolution doesn't re-broadcast a stale "new" event.
115
+ *
116
+ * No-op when the supplied threadId doesn't match the currently retained one
117
+ * (a newer consultation has already overwritten it).
118
+ */
119
+ clearRetainedConsultation(threadId) {
120
+ if (!this.client || !this.connected)
121
+ return;
122
+ if (this.lastRetainedConsultationThreadId !== threadId)
123
+ return;
124
+ this.client.publish("coordinator/consultations/new", "", { qos: 1, retain: true });
125
+ this.lastRetainedConsultationThreadId = null;
84
126
  }
85
127
  publishMessage(threadId, agentId, type, content) {
86
128
  if (!this.client || !this.connected)
87
129
  return;
130
+ // QoS 0: high-frequency chat-style traffic, lossy-OK.
88
131
  this.client.publish(`coordinator/consultations/${threadId}/messages`, JSON.stringify({ agent_id: agentId, type, content }));
89
132
  }
90
133
  publishResolution(threadId, status, summary) {
91
134
  if (!this.client || !this.connected)
92
135
  return;
93
- this.client.publish(`coordinator/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { retain: true });
136
+ // P1 fix: QoS 1 (at-least-once) resolution is a state-change event.
137
+ this.client.publish(`coordinator/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { qos: 1, retain: true });
94
138
  }
95
139
  publishBroadcast(agentId, message) {
96
140
  if (!this.client || !this.connected)
@@ -105,12 +149,15 @@ export class MqttBridge {
105
149
  publishTaskClaimed(threadId, claimedBy) {
106
150
  if (!this.client || !this.connected)
107
151
  return;
108
- this.client.publish(`coordinator/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }));
152
+ // P1 fix: QoS 1 claim is a coordination state-change. Loss would mean
153
+ // multiple agents think a task is unclaimed.
154
+ this.client.publish(`coordinator/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }), { qos: 1 });
109
155
  }
110
156
  publishTaskCompleted(threadId, completedBy, summary) {
111
157
  if (!this.client || !this.connected)
112
158
  return;
113
- this.client.publish(`coordinator/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }));
159
+ // P1 fix: QoS 1 completion is a coordination state-change.
160
+ this.client.publish(`coordinator/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }), { qos: 1 });
114
161
  }
115
162
  /**
116
163
  * Fanout a refreshed QuotaInfo to live subscribers (dashboard widget,
@@ -120,6 +167,7 @@ export class MqttBridge {
120
167
  publishQuotaUpdate(info) {
121
168
  if (!this.client || !this.connected)
122
169
  return;
170
+ // QoS 0: high-frequency telemetry, lossy-OK (the next refresh overwrites).
123
171
  this.client.publish("coordinator/quota/update", JSON.stringify(info));
124
172
  }
125
173
  // ── Agent listener methods (for integrated MCP tools) ──
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Normalize a file path for matching/correctness — NOT security.
3
+ *
4
+ * Returns POSIX (forward slash), repo-relative when repoRoot is provided,
5
+ * lower-cased when the path is Windows-style (drive letter prefix in repoRoot
6
+ * or input, or backslash in input). Collapses ./ and .. segments via
7
+ * path.posix.normalize.
8
+ *
9
+ * The lowercase pass is anchored to path SHAPE rather than `process.platform`
10
+ * so a Linux coordinator processing paths from a Windows agent (or a CI run
11
+ * exercising Windows-shaped fixtures) still produces consistent canonical
12
+ * forms.
13
+ *
14
+ * Throws when an absolute path falls outside repoRoot. Security path
15
+ * traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
16
+ */
17
+ export declare function normalizePath(repoRoot: string | null, input: string): string;
@@ -0,0 +1,38 @@
1
+ import path from "path";
2
+ /**
3
+ * Normalize a file path for matching/correctness — NOT security.
4
+ *
5
+ * Returns POSIX (forward slash), repo-relative when repoRoot is provided,
6
+ * lower-cased when the path is Windows-style (drive letter prefix in repoRoot
7
+ * or input, or backslash in input). Collapses ./ and .. segments via
8
+ * path.posix.normalize.
9
+ *
10
+ * The lowercase pass is anchored to path SHAPE rather than `process.platform`
11
+ * so a Linux coordinator processing paths from a Windows agent (or a CI run
12
+ * exercising Windows-shaped fixtures) still produces consistent canonical
13
+ * forms.
14
+ *
15
+ * Throws when an absolute path falls outside repoRoot. Security path
16
+ * traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
17
+ */
18
+ export function normalizePath(repoRoot, input) {
19
+ const isWindowsStyle = (repoRoot != null && (/^[a-zA-Z]:/.test(repoRoot) || repoRoot.includes("\\"))) ||
20
+ /^[a-zA-Z]:/.test(input) ||
21
+ input.includes("\\");
22
+ let p = input.replace(/\\/g, "/");
23
+ if (repoRoot) {
24
+ const root = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
25
+ if (path.isAbsolute(input) || /^[a-zA-Z]:/.test(input)) {
26
+ const lowerP = isWindowsStyle ? p.toLowerCase() : p;
27
+ const lowerRoot = isWindowsStyle ? root.toLowerCase() : root;
28
+ if (!lowerP.startsWith(lowerRoot + "/") && lowerP !== lowerRoot) {
29
+ throw new Error(`path is outside repoRoot: ${input}`);
30
+ }
31
+ p = p.slice(root.length).replace(/^\/+/, "");
32
+ }
33
+ }
34
+ p = path.posix.normalize(p).replace(/^\.\//, "");
35
+ if (isWindowsStyle)
36
+ p = p.toLowerCase();
37
+ return p;
38
+ }
@@ -17,6 +17,8 @@ import { createLogger } from "./logger.js";
17
17
  import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
18
18
  import { safeJoinUnderRoot } from "./path-guard.js";
19
19
  import { handleRest as handleRestExt } from "./http/handle-rest.js";
20
+ import { handleLivez, handleReadyz, handleHealth } from "./http/handle-health.js";
21
+ import { serveMetrics } from "./metrics.js";
20
22
  import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
21
23
  import { getVersion } from "../cli/version.js";
22
24
  const VERSION = getVersion();
@@ -83,7 +85,15 @@ async function handleRest(req, res) {
83
85
  }
84
86
  async function handleAuth(req, res) {
85
87
  const url = req.url || "";
86
- const body = await parseBody(req);
88
+ let body;
89
+ try {
90
+ body = await parseBody(req);
91
+ }
92
+ catch (err) {
93
+ const e = err;
94
+ json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
95
+ return;
96
+ }
87
97
  if (url === "/api/auth/register" && req.method === "POST") {
88
98
  const { agent_name, registration_secret } = body;
89
99
  if (!agent_name || !registration_secret) {
@@ -167,6 +177,16 @@ function writeSseEvent(res, event) {
167
177
  const data = injectTimestamp(event.payload, event.created_at ?? new Date().toISOString());
168
178
  res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${data}\n\n`);
169
179
  }
180
+ // P3: heartbeat interval in ms. Default 30s — well under nginx/Cloudflare's
181
+ // typical 60s idle SSE timeout, but infrequent enough to add negligible
182
+ // bandwidth (one ":keep-alive\n\n" comment is ~16 bytes).
183
+ const SSE_HEARTBEAT_MS = (() => {
184
+ const raw = process.env.COORDINATOR_SSE_HEARTBEAT_MS;
185
+ if (!raw)
186
+ return 30_000;
187
+ const n = parseInt(raw, 10);
188
+ return Number.isFinite(n) && n > 0 ? n : 30_000;
189
+ })();
170
190
  function handleSse(req, res) {
171
191
  res.writeHead(200, {
172
192
  "Content-Type": "text/event-stream",
@@ -174,6 +194,8 @@ function handleSse(req, res) {
174
194
  Connection: "keep-alive",
175
195
  "Access-Control-Allow-Origin": "*",
176
196
  });
197
+ services.metrics.incSseClients();
198
+ services.metrics.recordHttpRequest("/api/events", 200);
177
199
  // Use Last-Event-ID for resumption, otherwise send last 50
178
200
  const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
179
201
  const events = lastEventId > 0
@@ -186,7 +208,29 @@ function handleSse(req, res) {
186
208
  const unsubscribe = services.sseEmitter.addListener((event) => {
187
209
  writeSseEvent(res, event);
188
210
  });
189
- req.on("close", () => unsubscribe());
211
+ // P3: heartbeat. Browsers ignore the `:` comment line per the SSE spec,
212
+ // but it counts as activity for intermediate proxies that would otherwise
213
+ // kill an idle connection after ~60s. Wrapped in try/catch because once
214
+ // the socket is half-closed res.write throws synchronously.
215
+ const heartbeat = setInterval(() => {
216
+ try {
217
+ res.write(":keep-alive\n\n");
218
+ }
219
+ catch {
220
+ // Connection already torn down — req.on("close") will clean up shortly.
221
+ }
222
+ }, SSE_HEARTBEAT_MS);
223
+ // Don't keep the event loop alive solely for heartbeats; without unref()
224
+ // a still-open SSE connection at process shutdown delays exit.
225
+ if (typeof heartbeat.unref === "function")
226
+ heartbeat.unref();
227
+ req.on("close", () => {
228
+ // P3: clear the interval BEFORE unsubscribing so a heartbeat tick that
229
+ // fires between close and unsubscribe can't write to a dead socket.
230
+ clearInterval(heartbeat);
231
+ unsubscribe();
232
+ services.metrics.decSseClients();
233
+ });
190
234
  }
191
235
  export async function startServer(opts) {
192
236
  const port = opts?.port ?? PORT;
@@ -272,8 +316,21 @@ export async function startServer(opts) {
272
316
  }
273
317
  return;
274
318
  }
319
+ else if (url === "/livez") {
320
+ handleLivez(req, res);
321
+ services.metrics.recordHttpRequest("/livez", 200);
322
+ }
323
+ else if (url === "/readyz") {
324
+ handleReadyz(req, res, services);
325
+ services.metrics.recordHttpRequest("/readyz", res.statusCode || 0);
326
+ }
275
327
  else if (url === "/health") {
276
- json(res, { status: "ok", version: VERSION });
328
+ handleHealth(req, res);
329
+ services.metrics.recordHttpRequest("/health", 200);
330
+ }
331
+ else if (url === "/metrics" && req.method === "GET") {
332
+ await serveMetrics(req, res, services, services.metrics);
333
+ services.metrics.recordHttpRequest("/metrics", 200);
277
334
  }
278
335
  else if (url === "/api/events" && req.method === "GET") {
279
336
  handleSse(req, res);
@@ -330,15 +387,18 @@ export async function startServer(opts) {
330
387
  const authResult = await authenticateRequest(req);
331
388
  if (!authResult.ok) {
332
389
  authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
390
+ services.metrics.recordAuthRejected();
333
391
  json(res, { error: authResult.error }, authResult.status);
334
392
  return;
335
393
  }
336
394
  }
337
395
  if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
338
396
  await handleRest(req, res);
397
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
339
398
  }
340
399
  else {
341
400
  json(res, { error: "not found" }, 404);
401
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), 404);
342
402
  }
343
403
  }
344
404
  }
@@ -380,10 +440,17 @@ export async function startServer(opts) {
380
440
  url: `mqtt://127.0.0.1:${mqttTcpPort}`,
381
441
  username: AUTH_ENABLED ? "coordinator-internal" : undefined,
382
442
  password: internalToken,
443
+ // P1 fix: stable agent identity for LWT topic
444
+ // (`coordinator/agents/coordinator-internal/status`).
445
+ agentId: "coordinator-internal",
383
446
  });
384
447
  services.mqttBridge.onOffline((agentId) => {
385
448
  services.registry.setOffline(agentId);
386
449
  services.consultation.handleAgentDeparture(agentId);
450
+ // Clear in-flight working_files AFTER consultation cleanup so any future
451
+ // consultation logic that might inspect working_files state for this agent
452
+ // sees the pre-cleanup view.
453
+ services.workingFiles.clearForAgent(agentId);
387
454
  services.sseEmitter.emit("agent_offline", { agent_id: agentId });
388
455
  });
389
456
  // Wait for the HTTP server to be actually listening before resolving the
@@ -449,6 +516,12 @@ export async function startServer(opts) {
449
516
  catch (err) {
450
517
  log.warn({ err }, "Error stopping timeout sweeper");
451
518
  }
519
+ try {
520
+ services.workingFiles.stopSweeper();
521
+ }
522
+ catch (err) {
523
+ log.warn({ err }, "Error stopping working-files sweeper");
524
+ }
452
525
  try {
453
526
  const { closeDb } = await import("./database.js");
454
527
  closeDb?.();
@@ -11,6 +11,10 @@ import { SseEmitter } from "./sse-emitter.js";
11
11
  import { MqttBridge } from "./mqtt-bridge.js";
12
12
  import { AgentActivityTracker } from "./agent-activity.js";
13
13
  import { QuotaCache } from "./quota/quota-cache.js";
14
+ import { WorkingFilesTracker } from "./working-files-tracker.js";
15
+ import { Metrics } from "./metrics.js";
16
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
17
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
14
18
  import type { CoordinatorConfig } from "./types.js";
15
19
  import { type Logger } from "./logger.js";
16
20
  export interface CoordinatorServices {
@@ -22,11 +26,15 @@ export interface CoordinatorServices {
22
26
  depMap: DependencyMapper;
23
27
  fileTracker: FileTracker;
24
28
  impactScorer: ImpactScorer;
29
+ workingFiles: WorkingFilesTracker;
25
30
  introspection: IntrospectionManager;
26
31
  contextProvider: SummaryContextProvider;
27
32
  sseEmitter: SseEmitter;
28
33
  mqttBridge: MqttBridge;
29
34
  quotaCache: QuotaCache;
35
+ metrics: Metrics;
36
+ treeSitter: TreeSitterExtractor;
37
+ gitCochange: GitCochangeBuilder | null;
30
38
  }
31
39
  /** Create shared services (once at startup). */
32
40
  export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
@@ -18,6 +18,10 @@ import { SseEmitter } from "./sse-emitter.js";
18
18
  import { MqttBridge } from "./mqtt-bridge.js";
19
19
  import { AgentActivityTracker } from "./agent-activity.js";
20
20
  import { QuotaCache } from "./quota/quota-cache.js";
21
+ import { WorkingFilesTracker } from "./working-files-tracker.js";
22
+ import { Metrics } from "./metrics.js";
23
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
24
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
21
25
  import { createLogger } from "./logger.js";
22
26
  import { getVersion } from "../cli/version.js";
23
27
  const VERSION = getVersion();
@@ -25,17 +29,35 @@ const VERSION = getVersion();
25
29
  export function createServices(config) {
26
30
  initDatabase(config.dataDir);
27
31
  const logger = createLogger();
32
+ const metrics = new Metrics();
28
33
  const registry = new AgentRegistry();
29
34
  const activityTracker = new AgentActivityTracker(registry);
30
35
  const consultation = new Consultation(logger.child({ component: "consultation" }));
31
36
  const depMap = new DependencyMapper();
32
37
  const fileTracker = new FileTracker();
33
- const impactScorer = new ImpactScorer(registry, fileTracker, consultation);
38
+ const workingFiles = new WorkingFilesTracker(logger.child({ component: "working-files" }), metrics);
39
+ workingFiles.startSweeper(parseInt(process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS || "60000", 10));
40
+ const impactScorer = new ImpactScorer(registry, fileTracker, consultation, workingFiles);
34
41
  const introspection = new IntrospectionManager();
35
42
  const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
36
43
  const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
37
44
  const sseEmitter = new SseEmitter();
38
45
  const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
46
+ const treeSitter = new TreeSitterExtractor(metrics);
47
+ treeSitter.load().catch(() => { });
48
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT;
49
+ const gitCochange = repoRoot
50
+ ? new GitCochangeBuilder({
51
+ repoRoot,
52
+ logger: logger.child({ component: "gitcc" }),
53
+ metrics,
54
+ sinceDays: parseInt(process.env.COORDINATOR_LAYER4_SINCE_DAYS || "7", 10),
55
+ maxCount: parseInt(process.env.COORDINATOR_LAYER4_MAX_COMMITS || "2000", 10),
56
+ refreshMs: parseInt(process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS || "1800000", 10),
57
+ retryMs: parseInt(process.env.COORDINATOR_LAYER4_RETRY_MS || "300000", 10),
58
+ })
59
+ : null;
60
+ gitCochange?.startScheduler();
39
61
  // Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
40
62
  // /api/quota handler so raids keep running without a quota guardrail there.
41
63
  // onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
@@ -62,8 +84,9 @@ export function createServices(config) {
62
84
  else if (event.type === "agent_offline")
63
85
  quotaCache.onAgentInactive();
64
86
  });
65
- // Centralized resolution → SSE + MQTT
87
+ // Centralized resolution → SSE + MQTT + metrics
66
88
  consultation.onResolve((event) => {
89
+ metrics.recordThreadResolved(event.resolution_type);
67
90
  sseEmitter.emit("thread_resolved", {
68
91
  thread_id: event.thread_id,
69
92
  resolution_type: event.resolution_type,
@@ -77,10 +100,15 @@ export function createServices(config) {
77
100
  if (event.resolution_type !== "auto_resolved") {
78
101
  mqttBridge.publishResolution(event.thread_id, "resolved", event.resolution_summary || "");
79
102
  }
103
+ // P1 fix: clear the retained `coordinator/consultations/new` event so a
104
+ // coordinator restart doesn't re-broadcast a consultation that's already
105
+ // been resolved. No-op when the retained slot holds a different (newer)
106
+ // thread.
107
+ mqttBridge.clearRetainedConsultation(event.thread_id);
80
108
  });
81
109
  return {
82
110
  logger, registry, activityTracker, consultation, conflictDetector,
83
- depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache,
111
+ depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
84
112
  };
85
113
  }
86
114
  /** Create a new McpServer bound to the shared services (one per MCP session). */
@@ -1,10 +1,16 @@
1
1
  import type { CoordinatorEvent, EventType } from "./types.js";
2
2
  type EventListener = (event: CoordinatorEvent) => void;
3
+ export declare const MAX_SSE_CLIENTS: number;
3
4
  export declare class SseEmitter {
4
5
  private listeners;
6
+ private rejectedCount;
5
7
  emit(type: EventType, payload: Record<string, unknown>): void;
6
8
  getEventsSince(lastId: number): CoordinatorEvent[];
7
9
  addListener(listener: EventListener): () => void;
8
10
  removeAllListeners(): void;
11
+ /** P3: introspection for tests + ops dashboards. */
12
+ listenerCount(): number;
13
+ /** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
14
+ getRejectedCount(): number;
9
15
  }
10
16
  export {};
@@ -1,6 +1,25 @@
1
1
  import { getDb } from "./database.js";
2
+ /**
3
+ * P3: bound the listener array so a runaway client (or DoS attempt) can't
4
+ * grow it without limit. Default 100 covers a small-to-mid swarm — enough
5
+ * headroom for a dashboard + every agent + a handful of CLI tailers, but
6
+ * not so large that a leak would silently exhaust memory. Override via
7
+ * COORDINATOR_MAX_SSE_CLIENTS for larger deployments.
8
+ */
9
+ const DEFAULT_MAX_SSE_CLIENTS = 100;
10
+ export const MAX_SSE_CLIENTS = (() => {
11
+ const raw = process.env.COORDINATOR_MAX_SSE_CLIENTS;
12
+ if (!raw)
13
+ return DEFAULT_MAX_SSE_CLIENTS;
14
+ const n = parseInt(raw, 10);
15
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SSE_CLIENTS;
16
+ })();
17
+ const NOOP = () => { };
2
18
  export class SseEmitter {
3
19
  listeners = [];
20
+ // P3: track refusals so operators can see when the cap is being hit.
21
+ // Also lets tests assert "we refused without throwing" without scraping logs.
22
+ rejectedCount = 0;
4
23
  emit(type, payload) {
5
24
  const db = getDb();
6
25
  const payloadStr = JSON.stringify(payload);
@@ -13,8 +32,22 @@ export class SseEmitter {
13
32
  payload: payloadStr,
14
33
  created_at: new Date().toISOString(),
15
34
  };
16
- for (const listener of this.listeners) {
17
- listener(event);
35
+ // P3: async fan-out via setImmediate so a slow listener (e.g. a stalled
36
+ // SSE client whose socket buffer is full) cannot block siblings or the
37
+ // emit() caller. Snapshot the array first so a listener that unsubscribes
38
+ // mid-loop doesn't shift indices under us.
39
+ const snapshot = this.listeners.slice();
40
+ for (const listener of snapshot) {
41
+ setImmediate(() => {
42
+ try {
43
+ listener(event);
44
+ }
45
+ catch {
46
+ // Listener errors must not crash the emitter or affect siblings.
47
+ // Drop silently — the SSE response writers swallow their own
48
+ // socket errors via the unsubscribe path on req.on("close").
49
+ }
50
+ });
18
51
  }
19
52
  }
20
53
  getEventsSince(lastId) {
@@ -24,6 +57,13 @@ export class SseEmitter {
24
57
  .all(lastId);
25
58
  }
26
59
  addListener(listener) {
60
+ // P3: refuse-with-no-op when the cap is reached. Returning a no-op
61
+ // keeps the caller's unsubscribe contract intact (no special-casing
62
+ // upstream) while preventing the array from growing past MAX_SSE_CLIENTS.
63
+ if (this.listeners.length >= MAX_SSE_CLIENTS) {
64
+ this.rejectedCount++;
65
+ return NOOP;
66
+ }
27
67
  this.listeners.push(listener);
28
68
  return () => {
29
69
  this.listeners = this.listeners.filter((l) => l !== listener);
@@ -32,4 +72,12 @@ export class SseEmitter {
32
72
  removeAllListeners() {
33
73
  this.listeners = [];
34
74
  }
75
+ /** P3: introspection for tests + ops dashboards. */
76
+ listenerCount() {
77
+ return this.listeners.length;
78
+ }
79
+ /** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
80
+ getRejectedCount() {
81
+ return this.rejectedCount;
82
+ }
35
83
  }
@@ -30,7 +30,9 @@ export function registerConsultationTools(server, services, mcpLog) {
30
30
  exports_affected: z.array(z.string()).optional(),
31
31
  keep_open: z.boolean().optional().describe("Keep thread open even if no agents are concerned (for manual coordination like games or debates)"),
32
32
  assigned_to: z.string().optional().describe("Directed-dispatch: only this agent_id will be allowed to claim the thread. Use for lead→worker handoffs in maitre/chaine/relais presets. Implies keep_open=true."),
33
- }, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to }) => {
33
+ target_symbols: z.array(z.string().max(256)).max(200).optional()
34
+ .describe("Qualified symbol names you intend to touch (e.g. 'UserService.getById'). Used by Layer 0.5 to annotate same-file overlaps."),
35
+ }, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols }) => {
34
36
  mcpLog.info({ tool: "announce_work", agent_id, subject, target_modules, target_files, assigned_to }, "Tool called");
35
37
  const conflicts = conflictDetector.detect({ agent_id, target_modules, target_files });
36
38
  const thread = consultation.announceWork({
@@ -41,7 +43,7 @@ export function registerConsultationTools(server, services, mcpLog) {
41
43
  .run(JSON.stringify(conflicts), thread.id);
42
44
  }
43
45
  const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
44
- agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
46
+ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, target_symbols,
45
47
  });
46
48
  sseEmitter.emit("thread_opened", {
47
49
  thread_id: thread.id, initiator: agent_id, subject, target_modules, conflicts,
@@ -0,0 +1,36 @@
1
+ import type { Metrics } from "./metrics.js";
2
+ /**
3
+ * Tree-sitter symbol extractor.
4
+ *
5
+ * Loads grammars asynchronously at boot. extract() runs synchronously per call
6
+ * so it slots into the existing synchronous file_activity ingest path.
7
+ *
8
+ * Naming table per language documented in the v0.6 spec:
9
+ * - top-level fn / arrow assigned to const → `name`
10
+ * - class member → `Class.method`
11
+ * - anonymous default export → `<file_basename>:default`
12
+ * - re-exports, anonymous IIFE → not emitted
13
+ */
14
+ export declare class TreeSitterExtractor {
15
+ private grammars;
16
+ private ready;
17
+ private grammarsLoaded;
18
+ private totalGrammars;
19
+ private metrics?;
20
+ constructor(metrics?: Metrics);
21
+ load(): Promise<void>;
22
+ status(): {
23
+ ok: boolean;
24
+ grammars_loaded: number;
25
+ total_grammars: number;
26
+ optional: true;
27
+ };
28
+ /**
29
+ * Extract qualified symbol names from `content`. Returns null on parse
30
+ * failure, unsupported extension, or grammar not loaded.
31
+ * Caps output at 200 entries (per spec).
32
+ */
33
+ extract(filePath: string, content: string, _changedRanges: Array<[number, number]> | null): string[] | null;
34
+ private extToKey;
35
+ private walk;
36
+ }