mcp-coordinator 0.4.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import { getDb } from "../database.js";
2
3
  import { runCommonAnnounceFlow } from "../announce-workflow.js";
3
4
  import { canResetDb } from "../reset-guard.js";
@@ -5,7 +6,15 @@ import { parseBody, json } from "./utils.js";
5
6
  export async function handleRest(req, res, ctx) {
6
7
  const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
7
8
  const url = req.url || "";
8
- const body = await parseBody(req);
9
+ let body;
10
+ try {
11
+ body = await parseBody(req);
12
+ }
13
+ catch (err) {
14
+ const e = err;
15
+ json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
16
+ return;
17
+ }
9
18
  const agentId = body.agent_id;
10
19
  // Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
11
20
  // to keep the info log focused on coordination events (announce, claim, resolve, etc).
@@ -61,7 +70,7 @@ export async function handleRest(req, res, ctx) {
61
70
  json(res, { ok: true });
62
71
  }
63
72
  else if (url === "/api/announce") {
64
- const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
73
+ const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
65
74
  const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
66
75
  const agentInfo = registry.get(agent_id);
67
76
  // S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
@@ -69,6 +78,7 @@ export async function handleRest(req, res, ctx) {
69
78
  // function used by the MCP announce_work tool path.
70
79
  const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
71
80
  agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
81
+ target_symbols,
72
82
  });
73
83
  // REST-specific thread_opened SSE shape (different field set than MCP — kept
74
84
  // divergent because consumers may depend on this exact contract).
@@ -358,6 +368,77 @@ export async function handleRest(req, res, ctx) {
358
368
  json(res, { registered: true, status: agent.status, activity: activity.activity_status });
359
369
  }
360
370
  }
371
+ else if (url === "/api/file-activity" && req.method === "POST") {
372
+ if (typeof body.session_id !== "string" || typeof body.agent_id !== "string"
373
+ || typeof body.tool_name !== "string" || typeof body.file_path !== "string") {
374
+ json(res, { error: "missing required fields" }, 400);
375
+ return;
376
+ }
377
+ if (body.agent_name !== undefined && typeof body.agent_name !== "string") {
378
+ json(res, { error: "agent_name must be string when present" }, 400);
379
+ return;
380
+ }
381
+ const MAX_CONTENT = 262144;
382
+ let symbols = null;
383
+ let contentHash = null;
384
+ if (typeof body.content === "string") {
385
+ if (body.content.length > MAX_CONTENT) {
386
+ json(res, { error: "content exceeds 256 KB" }, 400);
387
+ return;
388
+ }
389
+ contentHash = createHash("sha256").update(body.content).digest("hex");
390
+ symbols = ctx.services.treeSitter.extract(body.file_path, body.content, null);
391
+ }
392
+ ctx.services.fileTracker.log({
393
+ session_id: body.session_id,
394
+ agent_id: body.agent_id,
395
+ agent_name: body.agent_name,
396
+ tool_name: body.tool_name,
397
+ file_path: body.file_path,
398
+ content_hash: contentHash,
399
+ symbols_touched: symbols,
400
+ });
401
+ json(res, { ok: true });
402
+ }
403
+ else if (url === "/api/working-files/start" && req.method === "POST") {
404
+ if (typeof body.agent_id !== "string" || typeof body.file_path !== "string") {
405
+ json(res, { error: "agent_id and file_path required" }, 400);
406
+ return;
407
+ }
408
+ const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
409
+ services.workingFiles.start(body.agent_id, body.file_path, ttl);
410
+ json(res, { ok: true });
411
+ }
412
+ else if (url === "/api/working-files/stop" && req.method === "POST") {
413
+ if (typeof body.agent_id !== "string" || typeof body.file_path !== "string") {
414
+ json(res, { error: "agent_id and file_path required" }, 400);
415
+ return;
416
+ }
417
+ services.workingFiles.stop(body.agent_id, body.file_path);
418
+ json(res, { ok: true });
419
+ }
420
+ else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
421
+ const u = new URL(url, "http://localhost");
422
+ const sinceParam = u.searchParams.get("since") || "24h";
423
+ const sinceMin = sinceParam.endsWith("h") ? parseInt(sinceParam) * 60
424
+ : sinceParam.endsWith("d") ? parseInt(sinceParam) * 60 * 24
425
+ : 60 * 24;
426
+ const db = getDb();
427
+ const layers = db.prepare(`SELECT layer, COUNT(*) AS fire_count, AVG(score) AS avg_score
428
+ FROM layer_firings
429
+ WHERE fired_at > datetime('now', '-' || ? || ' minutes')
430
+ GROUP BY layer
431
+ ORDER BY fire_count DESC`).all(sinceMin);
432
+ json(res, {
433
+ window: { since: sinceParam, now: new Date().toISOString() },
434
+ layers: layers.map(l => ({
435
+ layer: l.layer,
436
+ fire_count: l.fire_count,
437
+ avg_score: l.avg_score,
438
+ outcomes: { auto_resolved: 0, consensus: 0, timeout: 0, cancelled: 0 },
439
+ })),
440
+ });
441
+ }
361
442
  else if (url === "/api/status") {
362
443
  const online = registry.listOnline();
363
444
  const openThreads = consultation.listThreads({ status: "open" });
@@ -1,8 +1,4 @@
1
1
  import type { IncomingMessage, ServerResponse } from "http";
2
- /**
3
- * S1: shared HTTP helpers extracted from serve-http.ts.
4
- * parseBody, json, decodeJwtPayload, safeEqual.
5
- */
6
2
  export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
7
3
  export declare function json(res: ServerResponse, data: unknown, status?: number): void;
8
4
  /**
@@ -3,11 +3,25 @@ import { timingSafeEqual } from "crypto";
3
3
  * S1: shared HTTP helpers extracted from serve-http.ts.
4
4
  * parseBody, json, decodeJwtPayload, safeEqual.
5
5
  */
6
+ const MAX_BODY_BYTES = parseInt(process.env.COORDINATOR_MAX_BODY_BYTES || "1048576", 10);
6
7
  export function parseBody(req) {
7
8
  return new Promise((resolve, reject) => {
8
- let body = "";
9
- req.on("data", (chunk) => (body += chunk.toString()));
9
+ let bytes = 0;
10
+ const chunks = [];
11
+ req.on("data", (chunk) => {
12
+ bytes += chunk.length;
13
+ if (bytes > MAX_BODY_BYTES) {
14
+ const err = new Error("Payload too large");
15
+ err.statusCode = 413;
16
+ // destroy() may not exist on every IncomingMessage-like input (test stub).
17
+ req.destroy?.(err);
18
+ reject(err);
19
+ return;
20
+ }
21
+ chunks.push(chunk);
22
+ });
10
23
  req.on("end", () => {
24
+ const body = Buffer.concat(chunks).toString("utf-8");
11
25
  try {
12
26
  resolve(body ? JSON.parse(body) : {});
13
27
  }
@@ -1,6 +1,7 @@
1
1
  import type { AgentRegistry } from "./agent-registry.js";
2
2
  import type { FileTracker } from "./file-tracker.js";
3
3
  import type { Consultation } from "./consultation.js";
4
+ import type { WorkingFilesTracker } from "./working-files-tracker.js";
4
5
  export interface ImpactScore {
5
6
  agent_id: string;
6
7
  agent_name: string;
@@ -19,13 +20,16 @@ interface AnnounceParams {
19
20
  target_files: string[];
20
21
  depends_on_files?: string[];
21
22
  exports_affected?: string[];
23
+ target_symbols?: string[];
22
24
  }
23
25
  export declare class ImpactScorer {
24
26
  private registry;
25
27
  private fileTracker;
26
28
  private consultation?;
27
- constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined);
29
+ private workingFiles?;
30
+ constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined, workingFiles?: WorkingFilesTracker | undefined);
28
31
  score(params: AnnounceParams): ImpactScore[];
29
32
  categorize(params: AnnounceParams): CategorizedImpact;
33
+ private getRecentSymbolsForFile;
30
34
  }
31
35
  export {};
@@ -1,3 +1,4 @@
1
+ import { getDb } from "./database.js";
1
2
  // Layer 0 (announced-intent) recency window. Resolved threads older than this
2
3
  // are excluded — yesterday's resolved work shouldn't trigger today's scoring.
3
4
  // Aligned with file-tracker's default conflict window per the audit guidance.
@@ -11,10 +12,12 @@ export class ImpactScorer {
11
12
  registry;
12
13
  fileTracker;
13
14
  consultation;
14
- constructor(registry, fileTracker, consultation) {
15
+ workingFiles;
16
+ constructor(registry, fileTracker, consultation, workingFiles) {
15
17
  this.registry = registry;
16
18
  this.fileTracker = fileTracker;
17
19
  this.consultation = consultation;
20
+ this.workingFiles = workingFiles;
18
21
  }
19
22
  score(params) {
20
23
  const onlineAgents = this.registry
@@ -42,6 +45,34 @@ export class ImpactScorer {
42
45
  const fileToAgents = filesToIndex.length > 0
43
46
  ? this.fileTracker.getFileToAgentsIndex(filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
44
47
  : new Map();
48
+ const inFlightToAgents = this.workingFiles
49
+ ? this.workingFiles.getIndex(filesToIndex, params.agent_id)
50
+ : new Map();
51
+ // Pre-load symbols_touched for the target_files × online_agents matrix once,
52
+ // keyed by (file_path, agent_id). Avoids N*M DB roundtrips inside the score loop.
53
+ let symbolsByFileAgent = null;
54
+ if (params.target_symbols && params.target_symbols.length > 0 && params.target_files.length > 0) {
55
+ const db = getDb();
56
+ const placeholders = params.target_files.map(() => "?").join(",");
57
+ const rows = db.prepare(`SELECT agent_id, file_path, symbols_touched
58
+ FROM file_activity
59
+ WHERE file_path IN (${placeholders})
60
+ AND symbols_touched IS NOT NULL
61
+ AND id IN (
62
+ SELECT MAX(id) FROM file_activity
63
+ WHERE file_path IN (${placeholders})
64
+ AND symbols_touched IS NOT NULL
65
+ GROUP BY agent_id, file_path
66
+ )`).all(...params.target_files, ...params.target_files);
67
+ symbolsByFileAgent = new Map();
68
+ for (const r of rows) {
69
+ try {
70
+ const arr = JSON.parse(r.symbols_touched);
71
+ symbolsByFileAgent.set(`${r.file_path}|${r.agent_id}`, arr);
72
+ }
73
+ catch { /* malformed JSON: ignore */ }
74
+ }
75
+ }
45
76
  // O2: bound the resolved-thread query to a recency window. Without this,
46
77
  // listThreads({status:'resolved'}) returns ALL historical resolved threads
47
78
  // (unbounded growth). The Layer 0 filter only keeps threads where the
@@ -102,12 +133,32 @@ export class ImpactScorer {
102
133
  }
103
134
  }
104
135
  }
105
- // Layer 1: Same file recently modified (score 100) uses pre-built index.
136
+ // Layer 1: Same file recently modified (file_activity) OR currently in flight (working_files).
106
137
  for (const targetFile of params.target_files) {
107
- const agentsForFile = fileToAgents.get(targetFile);
108
- if (agentsForFile && agentsForFile.has(agent.id)) {
138
+ const recentAgents = fileToAgents.get(targetFile);
139
+ const inFlightAgents = inFlightToAgents.get(targetFile);
140
+ if (recentAgents && recentAgents.has(agent.id)) {
109
141
  maxScore = Math.max(maxScore, 100);
110
- reasons.push(`same file: ${targetFile}`);
142
+ let annotated = false;
143
+ if (params.target_symbols && params.target_symbols.length > 0) {
144
+ const theirSymbols = symbolsByFileAgent?.get(`${targetFile}|${agent.id}`) || null;
145
+ if (theirSymbols && theirSymbols.length > 0) {
146
+ const mine = new Set(params.target_symbols);
147
+ const theirs = new Set(theirSymbols);
148
+ const overlap = [...mine].some(s => theirs.has(s));
149
+ if (!overlap) {
150
+ reasons.push(`same file: ${targetFile}; disjoint symbols: you=[${[...mine].join(",")}], them=[${[...theirs].join(",")}] — verify shared module state`);
151
+ annotated = true;
152
+ }
153
+ }
154
+ }
155
+ if (!annotated) {
156
+ reasons.push(`same file (recent): ${targetFile}`);
157
+ }
158
+ }
159
+ if (inFlightAgents && inFlightAgents.has(agent.id)) {
160
+ maxScore = Math.max(maxScore, 100);
161
+ reasons.push(`same file (in flight): ${targetFile}`);
111
162
  }
112
163
  }
113
164
  // Layer 2: Depends-on file recently modified (score 80)
@@ -126,9 +177,34 @@ export class ImpactScorer {
126
177
  maxScore = Math.max(maxScore, 30);
127
178
  reasons.push(`module overlap: ${overlapping.join(", ")}`);
128
179
  }
129
- // Layer 4 (future): Git co-change analysis
130
- // Score 60 for >50% co-change ratio, 40 for >20%
131
- // Requires git history analysis not implemented in v3 prototype
180
+ // Layer 4: git co-change. For each target_file F, find rows in git_cochange where
181
+ // (LEAST(F,partner), GREATEST(F,partner)) match. If the OTHER agent recently
182
+ // touched the partner file, apply the co-change score.
183
+ const db = getDb();
184
+ for (const targetFile of params.target_files) {
185
+ const rows = db.prepare(`SELECT file_a, file_b, count, total_commits FROM git_cochange
186
+ WHERE file_a = ? OR file_b = ?`).all(targetFile, targetFile);
187
+ for (const r of rows) {
188
+ const partner = r.file_a === targetFile ? r.file_b : r.file_a;
189
+ const ratio = r.count / Math.max(r.total_commits, 1);
190
+ let layer4Score = 0;
191
+ if (ratio > 0.5)
192
+ layer4Score = 60;
193
+ else if (ratio > 0.2)
194
+ layer4Score = 40;
195
+ if (layer4Score === 0)
196
+ continue;
197
+ // Did the OTHER agent touch the partner file recently?
198
+ const partnerActivity = db.prepare(`SELECT 1 FROM file_activity
199
+ WHERE file_path = ? AND agent_id = ?
200
+ AND created_at > datetime('now', '-60 minutes')
201
+ LIMIT 1`).get(partner, agent.id);
202
+ if (partnerActivity) {
203
+ maxScore = Math.max(maxScore, layer4Score);
204
+ reasons.push(`co-change: ${targetFile} ↔ ${partner} (ratio ${ratio.toFixed(2)})`);
205
+ }
206
+ }
207
+ }
132
208
  return {
133
209
  agent_id: agent.id,
134
210
  agent_name: agent.name,
@@ -146,4 +222,18 @@ export class ImpactScorer {
146
222
  pass: scores.filter((s) => s.score < 30),
147
223
  };
148
224
  }
225
+ getRecentSymbolsForFile(filePath, agentId) {
226
+ const db = getDb();
227
+ const row = db.prepare(`SELECT symbols_touched FROM file_activity
228
+ WHERE agent_id = ? AND file_path = ? AND symbols_touched IS NOT NULL
229
+ ORDER BY id DESC LIMIT 1`).get(agentId, filePath);
230
+ if (!row || !row.symbols_touched)
231
+ return null;
232
+ try {
233
+ return JSON.parse(row.symbols_touched);
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
149
239
  }
@@ -42,6 +42,11 @@ export declare class Metrics {
42
42
  readonly threadsResolving: Gauge<string>;
43
43
  readonly mqttListenersActive: Gauge<string>;
44
44
  readonly sseClientsActive: Gauge<string>;
45
+ readonly workingFilesActive: Gauge<string>;
46
+ readonly gitCochangePairs: Gauge<string>;
47
+ readonly workingFilesStarts: Counter<"result">;
48
+ readonly treeSitterParseFailures: Counter<string>;
49
+ readonly gitCochangeBuilds: Counter<"outcome">;
45
50
  constructor(opts?: MetricsOptions);
46
51
  recordAnnounce(result: AnnounceResult): void;
47
52
  recordThreadResolved(type: ResolutionType): void;
@@ -14,6 +14,12 @@ export class Metrics {
14
14
  threadsResolving;
15
15
  mqttListenersActive;
16
16
  sseClientsActive;
17
+ workingFilesActive;
18
+ gitCochangePairs;
19
+ // v0.6 counters
20
+ workingFilesStarts;
21
+ treeSitterParseFailures;
22
+ gitCochangeBuilds;
17
23
  constructor(opts = {}) {
18
24
  this.registry = new Registry();
19
25
  if (opts.collectDefault !== false) {
@@ -72,6 +78,33 @@ export class Metrics {
72
78
  help: "Current number of connected SSE clients",
73
79
  registers: [this.registry],
74
80
  });
81
+ this.workingFilesActive = new Gauge({
82
+ name: "mcp_coordinator_working_files_active",
83
+ help: "Current working_files row count",
84
+ registers: [this.registry],
85
+ });
86
+ this.workingFilesStarts = new Counter({
87
+ name: "mcp_coordinator_working_files_starts_total",
88
+ help: "Total working_files start calls",
89
+ labelNames: ["result"],
90
+ registers: [this.registry],
91
+ });
92
+ this.treeSitterParseFailures = new Counter({
93
+ name: "mcp_coordinator_tree_sitter_parse_failures_total",
94
+ help: "Tree-sitter parse failures",
95
+ registers: [this.registry],
96
+ });
97
+ this.gitCochangeBuilds = new Counter({
98
+ name: "mcp_coordinator_git_cochange_builds_total",
99
+ help: "git_cochange build attempts",
100
+ labelNames: ["outcome"],
101
+ registers: [this.registry],
102
+ });
103
+ this.gitCochangePairs = new Gauge({
104
+ name: "mcp_coordinator_git_cochange_pairs_total",
105
+ help: "Current git_cochange row count",
106
+ registers: [this.registry],
107
+ });
75
108
  }
76
109
  // ── Counter helpers (named methods make hook points obvious) ──
77
110
  recordAnnounce(result) {
@@ -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) {
@@ -184,6 +194,8 @@ function handleSse(req, res) {
184
194
  Connection: "keep-alive",
185
195
  "Access-Control-Allow-Origin": "*",
186
196
  });
197
+ services.metrics.incSseClients();
198
+ services.metrics.recordHttpRequest("/api/events", 200);
187
199
  // Use Last-Event-ID for resumption, otherwise send last 50
188
200
  const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
189
201
  const events = lastEventId > 0
@@ -217,6 +229,7 @@ function handleSse(req, res) {
217
229
  // fires between close and unsubscribe can't write to a dead socket.
218
230
  clearInterval(heartbeat);
219
231
  unsubscribe();
232
+ services.metrics.decSseClients();
220
233
  });
221
234
  }
222
235
  export async function startServer(opts) {
@@ -303,8 +316,21 @@ export async function startServer(opts) {
303
316
  }
304
317
  return;
305
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
+ }
306
327
  else if (url === "/health") {
307
- 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);
308
334
  }
309
335
  else if (url === "/api/events" && req.method === "GET") {
310
336
  handleSse(req, res);
@@ -361,15 +387,18 @@ export async function startServer(opts) {
361
387
  const authResult = await authenticateRequest(req);
362
388
  if (!authResult.ok) {
363
389
  authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
390
+ services.metrics.recordAuthRejected();
364
391
  json(res, { error: authResult.error }, authResult.status);
365
392
  return;
366
393
  }
367
394
  }
368
395
  if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
369
396
  await handleRest(req, res);
397
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
370
398
  }
371
399
  else {
372
400
  json(res, { error: "not found" }, 404);
401
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), 404);
373
402
  }
374
403
  }
375
404
  }
@@ -418,6 +447,10 @@ export async function startServer(opts) {
418
447
  services.mqttBridge.onOffline((agentId) => {
419
448
  services.registry.setOffline(agentId);
420
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);
421
454
  services.sseEmitter.emit("agent_offline", { agent_id: agentId });
422
455
  });
423
456
  // Wait for the HTTP server to be actually listening before resolving the
@@ -483,6 +516,12 @@ export async function startServer(opts) {
483
516
  catch (err) {
484
517
  log.warn({ err }, "Error stopping timeout sweeper");
485
518
  }
519
+ try {
520
+ services.workingFiles.stopSweeper();
521
+ }
522
+ catch (err) {
523
+ log.warn({ err }, "Error stopping working-files sweeper");
524
+ }
486
525
  try {
487
526
  const { closeDb } = await import("./database.js");
488
527
  closeDb?.();
@@ -11,7 +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";
14
15
  import { Metrics } from "./metrics.js";
16
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
17
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
15
18
  import type { CoordinatorConfig } from "./types.js";
16
19
  import { type Logger } from "./logger.js";
17
20
  export interface CoordinatorServices {
@@ -23,12 +26,15 @@ export interface CoordinatorServices {
23
26
  depMap: DependencyMapper;
24
27
  fileTracker: FileTracker;
25
28
  impactScorer: ImpactScorer;
29
+ workingFiles: WorkingFilesTracker;
26
30
  introspection: IntrospectionManager;
27
31
  contextProvider: SummaryContextProvider;
28
32
  sseEmitter: SseEmitter;
29
33
  mqttBridge: MqttBridge;
30
34
  quotaCache: QuotaCache;
31
35
  metrics: Metrics;
36
+ treeSitter: TreeSitterExtractor;
37
+ gitCochange: GitCochangeBuilder | null;
32
38
  }
33
39
  /** Create shared services (once at startup). */
34
40
  export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
@@ -18,7 +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";
21
22
  import { Metrics } from "./metrics.js";
23
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
24
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
22
25
  import { createLogger } from "./logger.js";
23
26
  import { getVersion } from "../cli/version.js";
24
27
  const VERSION = getVersion();
@@ -26,18 +29,35 @@ const VERSION = getVersion();
26
29
  export function createServices(config) {
27
30
  initDatabase(config.dataDir);
28
31
  const logger = createLogger();
32
+ const metrics = new Metrics();
29
33
  const registry = new AgentRegistry();
30
34
  const activityTracker = new AgentActivityTracker(registry);
31
35
  const consultation = new Consultation(logger.child({ component: "consultation" }));
32
36
  const depMap = new DependencyMapper();
33
37
  const fileTracker = new FileTracker();
34
- 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);
35
41
  const introspection = new IntrospectionManager();
36
42
  const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
37
43
  const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
38
44
  const sseEmitter = new SseEmitter();
39
45
  const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
40
- const metrics = new Metrics();
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();
41
61
  // Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
42
62
  // /api/quota handler so raids keep running without a quota guardrail there.
43
63
  // onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
@@ -88,7 +108,7 @@ export function createServices(config) {
88
108
  });
89
109
  return {
90
110
  logger, registry, activityTracker, consultation, conflictDetector,
91
- depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics,
111
+ depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
92
112
  };
93
113
  }
94
114
  /** Create a new McpServer bound to the shared services (one per MCP session). */
@@ -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,