mcp-coordinator 0.6.1 → 0.7.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 (66) hide show
  1. package/README.md +24 -0
  2. package/dist/src/agent-activity.d.ts +13 -9
  3. package/dist/src/agent-activity.js +45 -24
  4. package/dist/src/agent-registry.d.ts +7 -7
  5. package/dist/src/agent-registry.js +19 -18
  6. package/dist/src/announce-workflow.d.ts +1 -0
  7. package/dist/src/announce-workflow.js +13 -12
  8. package/dist/src/auth/providers/registry.d.ts +4 -0
  9. package/dist/src/auth/providers/registry.js +7 -0
  10. package/dist/src/auth/providers/types.d.ts +11 -0
  11. package/dist/src/auth/providers/types.js +1 -0
  12. package/dist/src/auth.d.ts +24 -5
  13. package/dist/src/auth.js +172 -23
  14. package/dist/src/conflict-detector.d.ts +1 -0
  15. package/dist/src/conflict-detector.js +4 -4
  16. package/dist/src/consultation.d.ts +28 -14
  17. package/dist/src/consultation.js +101 -68
  18. package/dist/src/context-provider.d.ts +2 -2
  19. package/dist/src/context-provider.js +3 -4
  20. package/dist/src/database.js +203 -4
  21. package/dist/src/dependency-map.d.ts +25 -4
  22. package/dist/src/dependency-map.js +49 -11
  23. package/dist/src/file-tracker.d.ts +5 -4
  24. package/dist/src/file-tracker.js +16 -14
  25. package/dist/src/git-cochange-builder.d.ts +11 -2
  26. package/dist/src/git-cochange-builder.js +15 -7
  27. package/dist/src/http/handle-health.d.ts +9 -5
  28. package/dist/src/http/handle-health.js +22 -8
  29. package/dist/src/http/handle-rest.d.ts +3 -0
  30. package/dist/src/http/handle-rest.js +56 -55
  31. package/dist/src/http/utils.d.ts +4 -0
  32. package/dist/src/http/utils.js +7 -1
  33. package/dist/src/impact-scorer.d.ts +3 -0
  34. package/dist/src/impact-scorer.js +65 -51
  35. package/dist/src/introspection.d.ts +13 -7
  36. package/dist/src/introspection.js +34 -11
  37. package/dist/src/metrics.js +2 -1
  38. package/dist/src/mqtt-bridge.d.ts +3 -2
  39. package/dist/src/mqtt-bridge.js +33 -23
  40. package/dist/src/mqtt-broker.d.ts +16 -7
  41. package/dist/src/mqtt-broker.js +57 -15
  42. package/dist/src/security/audit.d.ts +11 -0
  43. package/dist/src/security/audit.js +7 -0
  44. package/dist/src/security/encryption.d.ts +17 -0
  45. package/dist/src/security/encryption.js +5 -0
  46. package/dist/src/serve-http.js +136 -57
  47. package/dist/src/server-setup.d.ts +12 -2
  48. package/dist/src/server-setup.js +33 -15
  49. package/dist/src/sse-emitter.d.ts +7 -4
  50. package/dist/src/sse-emitter.js +27 -21
  51. package/dist/src/tools/agents-tools.d.ts +2 -1
  52. package/dist/src/tools/agents-tools.js +36 -12
  53. package/dist/src/tools/consultation-tools.d.ts +2 -1
  54. package/dist/src/tools/consultation-tools.js +102 -36
  55. package/dist/src/tools/dependencies-tools.d.ts +2 -1
  56. package/dist/src/tools/dependencies-tools.js +25 -7
  57. package/dist/src/tools/files-tools.d.ts +2 -1
  58. package/dist/src/tools/files-tools.js +25 -7
  59. package/dist/src/tools/mqtt-tools.d.ts +7 -1
  60. package/dist/src/tools/mqtt-tools.js +27 -4
  61. package/dist/src/tools/status-tools.d.ts +7 -1
  62. package/dist/src/tools/status-tools.js +26 -9
  63. package/dist/src/types.d.ts +2 -0
  64. package/dist/src/working-files-tracker.d.ts +21 -11
  65. package/dist/src/working-files-tracker.js +32 -21
  66. package/package.json +1 -1
@@ -31,14 +31,14 @@ export async function handleRest(req, res, ctx) {
31
31
  const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
32
32
  if (url === "/api/register") {
33
33
  const { agent_id, name, modules } = body;
34
- const agent = registry.register(agent_id, name, modules || []);
35
- sseEmitter.emit("agent_online", { agent_id, name, modules });
34
+ const agent = registry.register(ctx.claims.org, agent_id, name, modules || []);
35
+ sseEmitter.emit("agent_online", { agent_id, name, modules }, { org_id: ctx.claims.org });
36
36
  json(res, agent);
37
37
  }
38
38
  else if (url === "/api/session-start") {
39
- const online = registry.listOnline();
40
- const openThreads = consultation.listThreads({ status: "open" });
41
- const hotFiles = fileTracker.getHotFiles(30);
39
+ const online = registry.listOnline(ctx.claims.org);
40
+ const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
41
+ const hotFiles = fileTracker.getHotFiles(ctx.claims.org, 30);
42
42
  const briefing = [
43
43
  `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
44
44
  `Consultations ouvertes: ${openThreads.length}`,
@@ -48,15 +48,15 @@ export async function handleRest(req, res, ctx) {
48
48
  }
49
49
  else if (url === "/api/session-stop") {
50
50
  const { agent_id } = body;
51
- registry.setOffline(agent_id);
52
- activityTracker.reportOffline(agent_id);
51
+ registry.setOffline(ctx.claims.org, agent_id);
52
+ activityTracker.reportOffline(ctx.claims.org, agent_id);
53
53
  consultation.handleAgentDeparture(agent_id);
54
- sseEmitter.emit("agent_offline", { agent_id });
54
+ sseEmitter.emit("agent_offline", { agent_id }, { org_id: ctx.claims.org });
55
55
  json(res, { ok: true });
56
56
  }
57
57
  else if (url === "/api/check-conflict") {
58
58
  const { file, agent_id } = body;
59
- const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
59
+ const conflict = fileTracker.checkFileConflict(ctx.claims.org, file, agent_id, 30);
60
60
  const warnings = [];
61
61
  if (conflict.conflict) {
62
62
  warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
@@ -65,20 +65,20 @@ export async function handleRest(req, res, ctx) {
65
65
  }
66
66
  else if (url === "/api/log-file") {
67
67
  const { session_id, agent_id, agent_name, tool_name, file } = body;
68
- fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
69
- activityTracker.reportFileActivity(agent_id, file);
70
- sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
68
+ fileTracker.log({ org_id: ctx.claims.org, session_id, agent_id, agent_name, tool_name, file_path: file });
69
+ activityTracker.reportFileActivity(ctx.claims.org, agent_id, file);
70
+ sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name }, { org_id: ctx.claims.org });
71
71
  json(res, { ok: true });
72
72
  }
73
73
  else if (url === "/api/announce") {
74
74
  const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
75
- const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
76
- const agentInfo = registry.get(agent_id);
75
+ const thread = consultation.announceWork(ctx.claims.org, { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
76
+ const agentInfo = registry.get(ctx.claims.org, agent_id);
77
77
  // S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
78
78
  // impact_scored + introspection SSE, plan-quality downgrade event). Same
79
79
  // function used by the MCP announce_work tool path.
80
80
  const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
81
- agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
81
+ org_id: ctx.claims.org, agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
82
82
  target_symbols,
83
83
  });
84
84
  // REST-specific thread_opened SSE shape (different field set than MCP — kept
@@ -91,7 +91,7 @@ export async function handleRest(req, res, ctx) {
91
91
  mode: planQuality.mode,
92
92
  plan: plan || null,
93
93
  plan_quality: planQuality,
94
- });
94
+ }, { org_id: ctx.claims.org });
95
95
  json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
96
96
  }
97
97
  else if (url === "/api/post-to-thread") {
@@ -99,7 +99,7 @@ export async function handleRest(req, res, ctx) {
99
99
  // Pre-check the thread so we can return actionable status codes instead
100
100
  // of always-500 on any error. The client uses the status to decide
101
101
  // whether to warn (unexpected) or silently skip (normal race).
102
- const targetThread = consultation.getThread(thread_id);
102
+ const targetThread = consultation.getThread(ctx.claims.org, thread_id);
103
103
  if (!targetThread) {
104
104
  json(res, { error: "thread_not_found", thread_id }, 404);
105
105
  return;
@@ -108,20 +108,20 @@ export async function handleRest(req, res, ctx) {
108
108
  json(res, { error: "thread_cancelled", thread_id }, 410);
109
109
  return;
110
110
  }
111
- const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
112
- const thread = consultation.getThread(thread_id);
111
+ const msg = consultation.postToThread(ctx.claims.org, { thread_id, agent_id, agent_name, type, content });
112
+ const thread = consultation.getThread(ctx.claims.org, thread_id);
113
113
  sseEmitter.emit("message_posted", {
114
114
  thread_id, agent_id, agent_name: agent_name || agent_id,
115
115
  type, content, round: thread?.round || 1,
116
116
  token_estimate: msg.token_estimate || 0,
117
- });
117
+ }, { org_id: ctx.claims.org });
118
118
  json(res, msg);
119
119
  }
120
120
  else if (url === "/api/token-usage") {
121
121
  // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
122
122
  // and reports can pinpoint where tokens are being burned.
123
123
  const payload = body;
124
- sseEmitter.emit("token_usage", payload);
124
+ sseEmitter.emit("token_usage", payload, { org_id: ctx.claims.org });
125
125
  json(res, { ok: true });
126
126
  }
127
127
  else if (url === "/api/unclaim-task") {
@@ -136,12 +136,12 @@ export async function handleRest(req, res, ctx) {
136
136
  // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
137
137
  // Only the claiming agent can unclaim to prevent cross-agent interference.
138
138
  const POISON_THRESHOLD = 2;
139
- 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);
139
+ const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND org_id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, ctx.claims.org, agent_id);
140
140
  let poisoned = false;
141
141
  if (result.changes === 1) {
142
- const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
142
+ const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ? AND org_id = ?").get(thread_id, ctx.claims.org);
143
143
  if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
144
- db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
144
+ db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND org_id = ? AND status = 'open'").run(thread_id, ctx.claims.org);
145
145
  poisoned = true;
146
146
  httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
147
147
  }
@@ -159,14 +159,14 @@ export async function handleRest(req, res, ctx) {
159
159
  // automatically because the status filter excludes them.
160
160
  // Directed-dispatch constraint: if assigned_to is set, only that specific
161
161
  // agent can claim; NULL keeps the original open-pool semantics.
162
- 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);
162
+ const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND org_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, ctx.claims.org, agent_id);
163
163
  if (result.changes === 1) {
164
164
  mqttBridge.publishTaskClaimed(thread_id, agent_id);
165
- sseEmitter.emit("task_claimed", { thread_id, agent_id });
165
+ sseEmitter.emit("task_claimed", { thread_id, agent_id }, { org_id: ctx.claims.org });
166
166
  json(res, { success: true });
167
167
  }
168
168
  else {
169
- const thread = consultation.getThread(thread_id);
169
+ const thread = consultation.getThread(ctx.claims.org, thread_id);
170
170
  // Surface the assigned_to in the 'why not' response so clients can
171
171
  // distinguish "already claimed by X" from "reserved for Y".
172
172
  json(res, {
@@ -179,24 +179,24 @@ export async function handleRest(req, res, ctx) {
179
179
  }
180
180
  else if (url === "/api/propose-resolution") {
181
181
  const { thread_id, agent_id, summary } = body;
182
- const agentInfo = registry.get(agent_id);
183
- consultation.proposeResolution(thread_id, agent_id, summary);
182
+ const agentInfo = registry.get(ctx.claims.org, agent_id);
183
+ consultation.proposeResolution(ctx.claims.org, thread_id, agent_id, summary);
184
184
  sseEmitter.emit("resolution_proposed", {
185
185
  thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
186
- });
187
- json(res, consultation.getThread(thread_id));
186
+ }, { org_id: ctx.claims.org });
187
+ json(res, consultation.getThread(ctx.claims.org, thread_id));
188
188
  mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
189
189
  }
190
190
  else if (url === "/api/approve-resolution") {
191
191
  const { thread_id, agent_id } = body;
192
- const agentInfo = registry.get(agent_id);
193
- consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
194
- const t = consultation.getThread(thread_id);
192
+ const agentInfo = registry.get(ctx.claims.org, agent_id);
193
+ consultation.approveResolution(ctx.claims.org, thread_id, agent_id, agentInfo?.name ?? undefined);
194
+ const t = consultation.getThread(ctx.claims.org, thread_id);
195
195
  json(res, t);
196
196
  }
197
197
  else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
198
198
  const threadId = url.split("/")[3];
199
- const thread = consultation.getThreadWithMessages(threadId);
199
+ const thread = consultation.getThreadWithMessages(ctx.claims.org, threadId);
200
200
  if (!thread) {
201
201
  json(res, { error: "not found" }, 404);
202
202
  }
@@ -210,13 +210,13 @@ export async function handleRest(req, res, ctx) {
210
210
  }
211
211
  }
212
212
  else if (url === "/api/threads-active") {
213
- const open = consultation.listThreads({ status: "open" });
214
- const resolving = consultation.listThreads({ status: "resolving" });
213
+ const open = consultation.listThreads(ctx.claims.org, { status: "open" });
214
+ const resolving = consultation.listThreads(ctx.claims.org, { status: "resolving" });
215
215
  json(res, [...open, ...resolving]);
216
216
  }
217
217
  else if (url === "/api/hot-files") {
218
218
  const { since_minutes } = body;
219
- json(res, fileTracker.getHotFiles(since_minutes || 30));
219
+ json(res, fileTracker.getHotFiles(ctx.claims.org, since_minutes || 30));
220
220
  }
221
221
  else if (url === "/api/quota") {
222
222
  // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
@@ -267,38 +267,38 @@ export async function handleRest(req, res, ctx) {
267
267
  }
268
268
  else if (url === "/api/introspection-response") {
269
269
  const { introspection_id, concerned, reason } = body;
270
- const intro = introspection.respond(introspection_id, concerned, reason);
270
+ const intro = introspection.respond(ctx.claims.org, introspection_id, reason);
271
271
  // If concerned, add to thread's expected_respondents
272
272
  if (concerned && intro) {
273
273
  const db = getDb();
274
- const thread = consultation.getThread(intro.thread_id);
274
+ const thread = consultation.getThread(ctx.claims.org, intro.thread_id);
275
275
  if (thread && (thread.status === "open" || thread.status === "resolving")) {
276
276
  const respondents = JSON.parse(thread.expected_respondents || "[]");
277
277
  if (!respondents.includes(intro.agent_id)) {
278
278
  respondents.push(intro.agent_id);
279
- db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
280
- .run(JSON.stringify(respondents), thread.id);
279
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ? AND org_id = ?")
280
+ .run(JSON.stringify(respondents), thread.id, ctx.claims.org);
281
281
  }
282
282
  }
283
283
  }
284
- const agentInfo = registry.get(intro?.agent_id || "");
284
+ const agentInfo = registry.get(ctx.claims.org, intro?.agent_id || "");
285
285
  sseEmitter.emit("introspection_completed", {
286
286
  introspection_id, thread_id: intro?.thread_id,
287
287
  agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
288
288
  concerned, reason,
289
- });
289
+ }, { org_id: ctx.claims.org });
290
290
  json(res, intro);
291
291
  }
292
292
  else if (url?.startsWith("/api/pending-introspections")) {
293
293
  const urlObj = new URL(url, "http://localhost");
294
294
  const agent_id = urlObj.searchParams.get("agent_id") || "";
295
- const pending = introspection.getPending(agent_id);
295
+ const pending = introspection.getPending(ctx.claims.org, agent_id);
296
296
  json(res, pending);
297
297
  }
298
298
  else if (url === "/api/run-config") {
299
299
  if (req.method === "POST") {
300
300
  setRunConfig(body);
301
- sseEmitter.emit("run_config", getRunConfig());
301
+ sseEmitter.emit("run_config", getRunConfig(), { org_id: ctx.claims.org });
302
302
  json(res, { ok: true });
303
303
  }
304
304
  else {
@@ -338,8 +338,8 @@ export async function handleRest(req, res, ctx) {
338
338
  // Covers both open threads (waiting for initial response) and resolving threads
339
339
  // (waiting for approval/contest of a proposed resolution).
340
340
  const pendingThreads = [
341
- ...consultation.listThreads({ status: "open" }),
342
- ...consultation.listThreads({ status: "resolving" }),
341
+ ...consultation.listThreads(ctx.claims.org, { status: "open" }),
342
+ ...consultation.listThreads(ctx.claims.org, { status: "resolving" }),
343
343
  ].filter((t) => {
344
344
  const respondents = JSON.parse(t.expected_respondents || "[]");
345
345
  return respondents.includes(agent_id);
@@ -360,12 +360,12 @@ export async function handleRest(req, res, ctx) {
360
360
  }
361
361
  else if (url?.startsWith("/api/agent-status/")) {
362
362
  const aid = url.split("/")[3];
363
- const agent = registry.get(aid);
363
+ const agent = registry.get(ctx.claims.org, aid);
364
364
  if (!agent) {
365
365
  json(res, { registered: false, status: "unknown" });
366
366
  }
367
367
  else {
368
- const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
368
+ const activity = activityTracker.getActivity(ctx.claims.org, aid, { idleAfterMinutes: 5 });
369
369
  json(res, { registered: true, status: agent.status, activity: activity.activity_status });
370
370
  }
371
371
  }
@@ -400,6 +400,7 @@ export async function handleRest(req, res, ctx) {
400
400
  symbols = ctx.services.treeSitter.extract(filePath, body.content, null);
401
401
  }
402
402
  ctx.services.fileTracker.log({
403
+ org_id: ctx.claims.org,
403
404
  session_id: body.session_id,
404
405
  agent_id: body.agent_id,
405
406
  agent_name: body.agent_name,
@@ -425,7 +426,7 @@ export async function handleRest(req, res, ctx) {
425
426
  return;
426
427
  }
427
428
  const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
428
- services.workingFiles.start(body.agent_id, filePath, ttl);
429
+ services.workingFiles.start(ctx.claims.org, body.agent_id, filePath, ttl);
429
430
  json(res, { ok: true });
430
431
  }
431
432
  else if (url === "/api/working-files/stop" && req.method === "POST") {
@@ -442,7 +443,7 @@ export async function handleRest(req, res, ctx) {
442
443
  json(res, { error: `invalid file_path: ${err.message}` }, 400);
443
444
  return;
444
445
  }
445
- services.workingFiles.stop(body.agent_id, filePath);
446
+ services.workingFiles.stop(ctx.claims.org, body.agent_id, filePath);
446
447
  json(res, { ok: true });
447
448
  }
448
449
  else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
@@ -483,12 +484,12 @@ export async function handleRest(req, res, ctx) {
483
484
  });
484
485
  }
485
486
  else if (url === "/api/status") {
486
- const online = registry.listOnline();
487
- const openThreads = consultation.listThreads({ status: "open" });
487
+ const online = registry.listOnline(ctx.claims.org);
488
+ const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
488
489
  json(res, {
489
490
  online: online.length,
490
491
  open_threads: openThreads.length,
491
- hot_files: fileTracker.getHotFiles(30).length,
492
+ hot_files: fileTracker.getHotFiles(ctx.claims.org, 30).length,
492
493
  mqtt: services.mqttBridge.isConnected(),
493
494
  });
494
495
  }
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { AuthResult } from "../auth.js";
2
3
  export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
3
4
  export declare function json(res: ServerResponse, data: unknown, status?: number): void;
4
5
  /**
@@ -9,3 +10,6 @@ export declare function json(res: ServerResponse, data: unknown, status?: number
9
10
  */
10
11
  export declare function decodeJwtPayload(token: string): Record<string, unknown>;
11
12
  export declare function safeEqual(a: string, b: string): boolean;
13
+ export declare function jsonAuthError(res: ServerResponse, authResult: Exclude<AuthResult, {
14
+ ok: true;
15
+ }>): void;
@@ -1,7 +1,7 @@
1
1
  import { timingSafeEqual } from "crypto";
2
2
  /**
3
3
  * S1: shared HTTP helpers extracted from serve-http.ts.
4
- * parseBody, json, decodeJwtPayload, safeEqual.
4
+ * parseBody, json, decodeJwtPayload, safeEqual, jsonAuthError.
5
5
  */
6
6
  const MAX_BODY_BYTES = parseInt(process.env.COORDINATOR_MAX_BODY_BYTES || "1048576", 10);
7
7
  export function parseBody(req) {
@@ -51,3 +51,9 @@ export function safeEqual(a, b) {
51
51
  return false;
52
52
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
53
53
  }
54
+ export function jsonAuthError(res, authResult) {
55
+ if (authResult.wwwAuthenticate) {
56
+ res.setHeader("WWW-Authenticate", authResult.wwwAuthenticate);
57
+ }
58
+ json(res, { error: authResult.error }, authResult.status);
59
+ }
@@ -15,6 +15,7 @@ export interface CategorizedImpact {
15
15
  pass: ImpactScore[];
16
16
  }
17
17
  interface AnnounceParams {
18
+ org_id: string;
18
19
  agent_id: string;
19
20
  target_modules: string[];
20
21
  target_files: string[];
@@ -31,5 +32,7 @@ export declare class ImpactScorer {
31
32
  score(params: AnnounceParams): ImpactScore[];
32
33
  categorize(params: AnnounceParams): CategorizedImpact;
33
34
  private getRecentSymbolsForFile;
35
+ private _collectSymbolsTouched;
36
+ private _layer4Score;
34
37
  }
35
38
  export {};
@@ -21,7 +21,7 @@ export class ImpactScorer {
21
21
  }
22
22
  score(params) {
23
23
  const onlineAgents = this.registry
24
- .listOnline()
24
+ .listOnline(params.org_id)
25
25
  .filter((a) => a.id !== params.agent_id);
26
26
  if (onlineAgents.length === 0)
27
27
  return [];
@@ -43,35 +43,16 @@ export class ImpactScorer {
43
43
  ...(params.depends_on_files || []),
44
44
  ];
45
45
  const fileToAgents = filesToIndex.length > 0
46
- ? this.fileTracker.getFileToAgentsIndex(filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
46
+ ? this.fileTracker.getFileToAgentsIndex(params.org_id, filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
47
47
  : new Map();
48
48
  const inFlightToAgents = this.workingFiles
49
- ? this.workingFiles.getIndex(filesToIndex, params.agent_id)
49
+ ? this.workingFiles.getIndex(params.org_id, filesToIndex, params.agent_id)
50
50
  : new Map();
51
51
  // Pre-load symbols_touched for the target_files × online_agents matrix once,
52
52
  // keyed by (file_path, agent_id). Avoids N*M DB roundtrips inside the score loop.
53
53
  let symbolsByFileAgent = null;
54
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
- }
55
+ symbolsByFileAgent = this._collectSymbolsTouched(params.org_id, params.target_files);
75
56
  }
76
57
  // O2: bound the resolved-thread query to a recency window. Without this,
77
58
  // listThreads({status:'resolved'}) returns ALL historical resolved threads
@@ -81,9 +62,9 @@ export class ImpactScorer {
81
62
  let activeThreadsByAgent = null;
82
63
  if (this.consultation) {
83
64
  const allActive = [
84
- ...this.consultation.listThreads({ status: "open" }),
85
- ...this.consultation.listThreads({ status: "resolving" }),
86
- ...this.consultation.listThreads({ status: "resolved", since_minutes: LAYER_0_WINDOW_MINUTES }),
65
+ ...this.consultation.listThreads(params.org_id, { status: "open" }),
66
+ ...this.consultation.listThreads(params.org_id, { status: "resolving" }),
67
+ ...this.consultation.listThreads(params.org_id, { status: "resolved", since_minutes: LAYER_0_WINDOW_MINUTES }),
87
68
  ];
88
69
  // Group by initiator_id so the per-agent loop is O(threads-for-this-agent)
89
70
  // rather than O(all-active-threads). Avoids an outer-product scan over
@@ -180,29 +161,11 @@ export class ImpactScorer {
180
161
  // Layer 4: git co-change. For each target_file F, find rows in git_cochange where
181
162
  // (LEAST(F,partner), GREATEST(F,partner)) match. If the OTHER agent recently
182
163
  // touched the partner file, apply the co-change score.
183
- const db = getDb();
184
164
  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
- }
165
+ const layer4Results = this._layer4Score(params.org_id, targetFile, agent.id);
166
+ for (const result of layer4Results) {
167
+ maxScore = Math.max(maxScore, result.score);
168
+ reasons.push(result.reason);
206
169
  }
207
170
  }
208
171
  return {
@@ -222,11 +185,11 @@ export class ImpactScorer {
222
185
  pass: scores.filter((s) => s.score < 30),
223
186
  };
224
187
  }
225
- getRecentSymbolsForFile(filePath, agentId) {
188
+ getRecentSymbolsForFile(orgId, filePath, agentId) {
226
189
  const db = getDb();
227
190
  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);
191
+ WHERE org_id = ? AND agent_id = ? AND file_path = ? AND symbols_touched IS NOT NULL
192
+ ORDER BY id DESC LIMIT 1`).get(orgId, agentId, filePath);
230
193
  if (!row || !row.symbols_touched)
231
194
  return null;
232
195
  try {
@@ -236,4 +199,55 @@ export class ImpactScorer {
236
199
  return null;
237
200
  }
238
201
  }
202
+ _collectSymbolsTouched(orgId, files) {
203
+ const db = getDb();
204
+ const placeholders = files.map(() => "?").join(",");
205
+ const rows = db.prepare(`SELECT agent_id, file_path, symbols_touched
206
+ FROM file_activity
207
+ WHERE org_id = ?
208
+ AND file_path IN (${placeholders})
209
+ AND symbols_touched IS NOT NULL
210
+ AND id IN (
211
+ SELECT MAX(id) FROM file_activity
212
+ WHERE org_id = ?
213
+ AND file_path IN (${placeholders})
214
+ AND symbols_touched IS NOT NULL
215
+ GROUP BY agent_id, file_path
216
+ )`).all(orgId, ...files, orgId, ...files);
217
+ const result = new Map();
218
+ for (const r of rows) {
219
+ try {
220
+ const arr = JSON.parse(r.symbols_touched);
221
+ result.set(`${r.file_path}|${r.agent_id}`, arr);
222
+ }
223
+ catch { /* malformed JSON: ignore */ }
224
+ }
225
+ return result;
226
+ }
227
+ _layer4Score(orgId, targetFile, agentId) {
228
+ const db = getDb();
229
+ const rows = db.prepare(`SELECT file_a, file_b, count, total_commits FROM git_cochange
230
+ WHERE org_id = ? AND (file_a = ? OR file_b = ?)`).all(orgId, targetFile, targetFile);
231
+ const results = [];
232
+ for (const r of rows) {
233
+ const partner = r.file_a === targetFile ? r.file_b : r.file_a;
234
+ const ratio = r.count / Math.max(r.total_commits, 1);
235
+ let layer4Score = 0;
236
+ if (ratio > 0.5)
237
+ layer4Score = 60;
238
+ else if (ratio > 0.2)
239
+ layer4Score = 40;
240
+ if (layer4Score === 0)
241
+ continue;
242
+ // Did the OTHER agent touch the partner file recently?
243
+ const partnerActivity = db.prepare(`SELECT 1 FROM file_activity
244
+ WHERE org_id = ? AND file_path = ? AND agent_id = ?
245
+ AND created_at > datetime('now', '-60 minutes')
246
+ LIMIT 1`).get(orgId, partner, agentId);
247
+ if (partnerActivity) {
248
+ results.push({ score: layer4Score, reason: `co-change: ${targetFile} ↔ ${partner} (ratio ${ratio.toFixed(2)})` });
249
+ }
250
+ }
251
+ return results;
252
+ }
239
253
  }
@@ -1,24 +1,30 @@
1
1
  export interface IntrospectionRecord {
2
2
  id: string;
3
+ org_id: string;
3
4
  thread_id: string;
4
5
  agent_id: string;
5
6
  score: number;
6
7
  reasons: string | null;
7
- status: "pending" | "concerned" | "not_concerned";
8
+ status: "pending" | "concerned" | "not_concerned" | "responded";
8
9
  response: string | null;
9
10
  concerned: number;
10
11
  created_at: string;
11
12
  responded_at: string | null;
12
13
  }
13
14
  export declare class IntrospectionManager {
14
- create(params: {
15
+ create(orgId: string, params: {
15
16
  thread_id: string;
16
17
  agent_id: string;
17
18
  score: number;
18
- reasons: string[];
19
+ reasons?: string | string[];
19
20
  }): IntrospectionRecord;
20
- respond(id: string, concerned: boolean, response: string): IntrospectionRecord;
21
- get(id: string): IntrospectionRecord | null;
22
- getPending(agentId: string): IntrospectionRecord[];
23
- getByThread(threadId: string): IntrospectionRecord[];
21
+ respond(orgId: string, id: string, response: string): IntrospectionRecord | null;
22
+ /** Retrieve a single record by id (unscoped internal helper only). */
23
+ private get;
24
+ /** Retrieve a single record scoped to an org. */
25
+ private getScoped;
26
+ getPending(orgId: string, agentId: string): IntrospectionRecord[];
27
+ list(orgId: string, threadId: string): IntrospectionRecord[];
28
+ /** @deprecated Use list(orgId, threadId) instead. Kept for backward compat. */
29
+ getByThread(orgId: string, threadId: string): IntrospectionRecord[];
24
30
  }
@@ -1,28 +1,51 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { getDb } from "./database.js";
3
3
  export class IntrospectionManager {
4
- create(params) {
4
+ create(orgId, params) {
5
5
  const db = getDb();
6
6
  const id = randomUUID();
7
- db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
8
- VALUES (?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.score, JSON.stringify(params.reasons));
7
+ const reasons = params.reasons == null
8
+ ? null
9
+ : Array.isArray(params.reasons)
10
+ ? JSON.stringify(params.reasons)
11
+ : params.reasons;
12
+ db.prepare(`INSERT INTO introspections (id, org_id, thread_id, agent_id, score, reasons)
13
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, orgId, params.thread_id, params.agent_id, params.score, reasons);
9
14
  return this.get(id);
10
15
  }
11
- respond(id, concerned, response) {
16
+ respond(orgId, id, response) {
12
17
  const db = getDb();
13
- db.prepare(`UPDATE introspections SET status = ?, concerned = ?, response = ?, responded_at = ? WHERE id = ?`).run(concerned ? "concerned" : "not_concerned", concerned ? 1 : 0, response, new Date().toISOString(), id);
14
- return this.get(id);
18
+ db.prepare(`UPDATE introspections SET response = ?, status = 'responded', responded_at = ? WHERE org_id = ? AND id = ?`).run(response, new Date().toISOString(), orgId, id);
19
+ return this.getScoped(orgId, id);
15
20
  }
21
+ /** Retrieve a single record by id (unscoped — internal helper only). */
16
22
  get(id) {
17
23
  const db = getDb();
18
- return db.prepare("SELECT * FROM introspections WHERE id = ?").get(id) || null;
24
+ return (db
25
+ .prepare("SELECT * FROM introspections WHERE id = ?")
26
+ .get(id) || null);
27
+ }
28
+ /** Retrieve a single record scoped to an org. */
29
+ getScoped(orgId, id) {
30
+ const db = getDb();
31
+ return (db
32
+ .prepare("SELECT * FROM introspections WHERE org_id = ? AND id = ?")
33
+ .get(orgId, id) || null);
19
34
  }
20
- getPending(agentId) {
35
+ getPending(orgId, agentId) {
21
36
  const db = getDb();
22
- return db.prepare("SELECT * FROM introspections WHERE agent_id = ? AND status = 'pending' ORDER BY created_at").all(agentId);
37
+ return db
38
+ .prepare("SELECT * FROM introspections WHERE org_id = ? AND agent_id = ? AND status = 'pending' ORDER BY created_at")
39
+ .all(orgId, agentId);
23
40
  }
24
- getByThread(threadId) {
41
+ list(orgId, threadId) {
25
42
  const db = getDb();
26
- return db.prepare("SELECT * FROM introspections WHERE thread_id = ? ORDER BY created_at").all(threadId);
43
+ return db
44
+ .prepare("SELECT * FROM introspections WHERE org_id = ? AND thread_id = ? ORDER BY created_at")
45
+ .all(orgId, threadId);
46
+ }
47
+ /** @deprecated Use list(orgId, threadId) instead. Kept for backward compat. */
48
+ getByThread(orgId, threadId) {
49
+ return this.list(orgId, threadId);
27
50
  }
28
51
  }
@@ -145,7 +145,8 @@ export class Metrics {
145
145
  */
146
146
  gaugeSnapshot(services) {
147
147
  try {
148
- this.agentsOnline.set(services.registry.listOnline().length);
148
+ // TODO(Task 23.5): thread real org_id from MCP session claims; for now MCP uses 'default' (cross-org leak window — single-tenant only)
149
+ this.agentsOnline.set(services.registry.listOnline("default").length);
149
150
  }
150
151
  catch {
151
152
  // Registry not initialised yet (test bootstrap race) — leave gauge at 0.