mcp-coordinator 0.6.0 → 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 +86 -57
  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 +106 -40
  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 +26 -8
  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 +4 -1
@@ -3,6 +3,7 @@ import { getDb } from "../database.js";
3
3
  import { runCommonAnnounceFlow } from "../announce-workflow.js";
4
4
  import { canResetDb } from "../reset-guard.js";
5
5
  import { parseBody, json } from "./utils.js";
6
+ import { normalizePath } from "../path-normalize.js";
6
7
  export async function handleRest(req, res, ctx) {
7
8
  const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
8
9
  const url = req.url || "";
@@ -30,14 +31,14 @@ export async function handleRest(req, res, ctx) {
30
31
  const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
31
32
  if (url === "/api/register") {
32
33
  const { agent_id, name, modules } = body;
33
- const agent = registry.register(agent_id, name, modules || []);
34
- 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 });
35
36
  json(res, agent);
36
37
  }
37
38
  else if (url === "/api/session-start") {
38
- const online = registry.listOnline();
39
- const openThreads = consultation.listThreads({ status: "open" });
40
- 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);
41
42
  const briefing = [
42
43
  `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
43
44
  `Consultations ouvertes: ${openThreads.length}`,
@@ -47,15 +48,15 @@ export async function handleRest(req, res, ctx) {
47
48
  }
48
49
  else if (url === "/api/session-stop") {
49
50
  const { agent_id } = body;
50
- registry.setOffline(agent_id);
51
- activityTracker.reportOffline(agent_id);
51
+ registry.setOffline(ctx.claims.org, agent_id);
52
+ activityTracker.reportOffline(ctx.claims.org, agent_id);
52
53
  consultation.handleAgentDeparture(agent_id);
53
- sseEmitter.emit("agent_offline", { agent_id });
54
+ sseEmitter.emit("agent_offline", { agent_id }, { org_id: ctx.claims.org });
54
55
  json(res, { ok: true });
55
56
  }
56
57
  else if (url === "/api/check-conflict") {
57
58
  const { file, agent_id } = body;
58
- const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
59
+ const conflict = fileTracker.checkFileConflict(ctx.claims.org, file, agent_id, 30);
59
60
  const warnings = [];
60
61
  if (conflict.conflict) {
61
62
  warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
@@ -64,20 +65,20 @@ export async function handleRest(req, res, ctx) {
64
65
  }
65
66
  else if (url === "/api/log-file") {
66
67
  const { session_id, agent_id, agent_name, tool_name, file } = body;
67
- fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
68
- activityTracker.reportFileActivity(agent_id, file);
69
- 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 });
70
71
  json(res, { ok: true });
71
72
  }
72
73
  else if (url === "/api/announce") {
73
74
  const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
74
- const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
75
- 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);
76
77
  // S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
77
78
  // impact_scored + introspection SSE, plan-quality downgrade event). Same
78
79
  // function used by the MCP announce_work tool path.
79
80
  const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
80
- 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,
81
82
  target_symbols,
82
83
  });
83
84
  // REST-specific thread_opened SSE shape (different field set than MCP — kept
@@ -90,7 +91,7 @@ export async function handleRest(req, res, ctx) {
90
91
  mode: planQuality.mode,
91
92
  plan: plan || null,
92
93
  plan_quality: planQuality,
93
- });
94
+ }, { org_id: ctx.claims.org });
94
95
  json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
95
96
  }
96
97
  else if (url === "/api/post-to-thread") {
@@ -98,7 +99,7 @@ export async function handleRest(req, res, ctx) {
98
99
  // Pre-check the thread so we can return actionable status codes instead
99
100
  // of always-500 on any error. The client uses the status to decide
100
101
  // whether to warn (unexpected) or silently skip (normal race).
101
- const targetThread = consultation.getThread(thread_id);
102
+ const targetThread = consultation.getThread(ctx.claims.org, thread_id);
102
103
  if (!targetThread) {
103
104
  json(res, { error: "thread_not_found", thread_id }, 404);
104
105
  return;
@@ -107,20 +108,20 @@ export async function handleRest(req, res, ctx) {
107
108
  json(res, { error: "thread_cancelled", thread_id }, 410);
108
109
  return;
109
110
  }
110
- const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
111
- 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);
112
113
  sseEmitter.emit("message_posted", {
113
114
  thread_id, agent_id, agent_name: agent_name || agent_id,
114
115
  type, content, round: thread?.round || 1,
115
116
  token_estimate: msg.token_estimate || 0,
116
- });
117
+ }, { org_id: ctx.claims.org });
117
118
  json(res, msg);
118
119
  }
119
120
  else if (url === "/api/token-usage") {
120
121
  // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
121
122
  // and reports can pinpoint where tokens are being burned.
122
123
  const payload = body;
123
- sseEmitter.emit("token_usage", payload);
124
+ sseEmitter.emit("token_usage", payload, { org_id: ctx.claims.org });
124
125
  json(res, { ok: true });
125
126
  }
126
127
  else if (url === "/api/unclaim-task") {
@@ -135,12 +136,12 @@ export async function handleRest(req, res, ctx) {
135
136
  // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
136
137
  // Only the claiming agent can unclaim to prevent cross-agent interference.
137
138
  const POISON_THRESHOLD = 2;
138
- 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);
139
140
  let poisoned = false;
140
141
  if (result.changes === 1) {
141
- 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);
142
143
  if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
143
- 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);
144
145
  poisoned = true;
145
146
  httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
146
147
  }
@@ -158,14 +159,14 @@ export async function handleRest(req, res, ctx) {
158
159
  // automatically because the status filter excludes them.
159
160
  // Directed-dispatch constraint: if assigned_to is set, only that specific
160
161
  // agent can claim; NULL keeps the original open-pool semantics.
161
- 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);
162
163
  if (result.changes === 1) {
163
164
  mqttBridge.publishTaskClaimed(thread_id, agent_id);
164
- sseEmitter.emit("task_claimed", { thread_id, agent_id });
165
+ sseEmitter.emit("task_claimed", { thread_id, agent_id }, { org_id: ctx.claims.org });
165
166
  json(res, { success: true });
166
167
  }
167
168
  else {
168
- const thread = consultation.getThread(thread_id);
169
+ const thread = consultation.getThread(ctx.claims.org, thread_id);
169
170
  // Surface the assigned_to in the 'why not' response so clients can
170
171
  // distinguish "already claimed by X" from "reserved for Y".
171
172
  json(res, {
@@ -178,24 +179,24 @@ export async function handleRest(req, res, ctx) {
178
179
  }
179
180
  else if (url === "/api/propose-resolution") {
180
181
  const { thread_id, agent_id, summary } = body;
181
- const agentInfo = registry.get(agent_id);
182
- 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);
183
184
  sseEmitter.emit("resolution_proposed", {
184
185
  thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
185
- });
186
- json(res, consultation.getThread(thread_id));
186
+ }, { org_id: ctx.claims.org });
187
+ json(res, consultation.getThread(ctx.claims.org, thread_id));
187
188
  mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
188
189
  }
189
190
  else if (url === "/api/approve-resolution") {
190
191
  const { thread_id, agent_id } = body;
191
- const agentInfo = registry.get(agent_id);
192
- consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
193
- 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);
194
195
  json(res, t);
195
196
  }
196
197
  else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
197
198
  const threadId = url.split("/")[3];
198
- const thread = consultation.getThreadWithMessages(threadId);
199
+ const thread = consultation.getThreadWithMessages(ctx.claims.org, threadId);
199
200
  if (!thread) {
200
201
  json(res, { error: "not found" }, 404);
201
202
  }
@@ -209,13 +210,13 @@ export async function handleRest(req, res, ctx) {
209
210
  }
210
211
  }
211
212
  else if (url === "/api/threads-active") {
212
- const open = consultation.listThreads({ status: "open" });
213
- 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" });
214
215
  json(res, [...open, ...resolving]);
215
216
  }
216
217
  else if (url === "/api/hot-files") {
217
218
  const { since_minutes } = body;
218
- json(res, fileTracker.getHotFiles(since_minutes || 30));
219
+ json(res, fileTracker.getHotFiles(ctx.claims.org, since_minutes || 30));
219
220
  }
220
221
  else if (url === "/api/quota") {
221
222
  // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
@@ -266,38 +267,38 @@ export async function handleRest(req, res, ctx) {
266
267
  }
267
268
  else if (url === "/api/introspection-response") {
268
269
  const { introspection_id, concerned, reason } = body;
269
- const intro = introspection.respond(introspection_id, concerned, reason);
270
+ const intro = introspection.respond(ctx.claims.org, introspection_id, reason);
270
271
  // If concerned, add to thread's expected_respondents
271
272
  if (concerned && intro) {
272
273
  const db = getDb();
273
- const thread = consultation.getThread(intro.thread_id);
274
+ const thread = consultation.getThread(ctx.claims.org, intro.thread_id);
274
275
  if (thread && (thread.status === "open" || thread.status === "resolving")) {
275
276
  const respondents = JSON.parse(thread.expected_respondents || "[]");
276
277
  if (!respondents.includes(intro.agent_id)) {
277
278
  respondents.push(intro.agent_id);
278
- db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
279
- .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);
280
281
  }
281
282
  }
282
283
  }
283
- const agentInfo = registry.get(intro?.agent_id || "");
284
+ const agentInfo = registry.get(ctx.claims.org, intro?.agent_id || "");
284
285
  sseEmitter.emit("introspection_completed", {
285
286
  introspection_id, thread_id: intro?.thread_id,
286
287
  agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
287
288
  concerned, reason,
288
- });
289
+ }, { org_id: ctx.claims.org });
289
290
  json(res, intro);
290
291
  }
291
292
  else if (url?.startsWith("/api/pending-introspections")) {
292
293
  const urlObj = new URL(url, "http://localhost");
293
294
  const agent_id = urlObj.searchParams.get("agent_id") || "";
294
- const pending = introspection.getPending(agent_id);
295
+ const pending = introspection.getPending(ctx.claims.org, agent_id);
295
296
  json(res, pending);
296
297
  }
297
298
  else if (url === "/api/run-config") {
298
299
  if (req.method === "POST") {
299
300
  setRunConfig(body);
300
- sseEmitter.emit("run_config", getRunConfig());
301
+ sseEmitter.emit("run_config", getRunConfig(), { org_id: ctx.claims.org });
301
302
  json(res, { ok: true });
302
303
  }
303
304
  else {
@@ -337,8 +338,8 @@ export async function handleRest(req, res, ctx) {
337
338
  // Covers both open threads (waiting for initial response) and resolving threads
338
339
  // (waiting for approval/contest of a proposed resolution).
339
340
  const pendingThreads = [
340
- ...consultation.listThreads({ status: "open" }),
341
- ...consultation.listThreads({ status: "resolving" }),
341
+ ...consultation.listThreads(ctx.claims.org, { status: "open" }),
342
+ ...consultation.listThreads(ctx.claims.org, { status: "resolving" }),
342
343
  ].filter((t) => {
343
344
  const respondents = JSON.parse(t.expected_respondents || "[]");
344
345
  return respondents.includes(agent_id);
@@ -359,12 +360,12 @@ export async function handleRest(req, res, ctx) {
359
360
  }
360
361
  else if (url?.startsWith("/api/agent-status/")) {
361
362
  const aid = url.split("/")[3];
362
- const agent = registry.get(aid);
363
+ const agent = registry.get(ctx.claims.org, aid);
363
364
  if (!agent) {
364
365
  json(res, { registered: false, status: "unknown" });
365
366
  }
366
367
  else {
367
- const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
368
+ const activity = activityTracker.getActivity(ctx.claims.org, aid, { idleAfterMinutes: 5 });
368
369
  json(res, { registered: true, status: agent.status, activity: activity.activity_status });
369
370
  }
370
371
  }
@@ -378,6 +379,15 @@ export async function handleRest(req, res, ctx) {
378
379
  json(res, { error: "agent_name must be string when present" }, 400);
379
380
  return;
380
381
  }
382
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
383
+ let filePath;
384
+ try {
385
+ filePath = normalizePath(repoRoot, body.file_path);
386
+ }
387
+ catch (err) {
388
+ json(res, { error: `invalid file_path: ${err.message}` }, 400);
389
+ return;
390
+ }
381
391
  const MAX_CONTENT = 262144;
382
392
  let symbols = null;
383
393
  let contentHash = null;
@@ -387,14 +397,15 @@ export async function handleRest(req, res, ctx) {
387
397
  return;
388
398
  }
389
399
  contentHash = createHash("sha256").update(body.content).digest("hex");
390
- symbols = ctx.services.treeSitter.extract(body.file_path, body.content, null);
400
+ symbols = ctx.services.treeSitter.extract(filePath, body.content, null);
391
401
  }
392
402
  ctx.services.fileTracker.log({
403
+ org_id: ctx.claims.org,
393
404
  session_id: body.session_id,
394
405
  agent_id: body.agent_id,
395
406
  agent_name: body.agent_name,
396
407
  tool_name: body.tool_name,
397
- file_path: body.file_path,
408
+ file_path: filePath,
398
409
  content_hash: contentHash,
399
410
  symbols_touched: symbols,
400
411
  });
@@ -405,8 +416,17 @@ export async function handleRest(req, res, ctx) {
405
416
  json(res, { error: "agent_id and file_path required" }, 400);
406
417
  return;
407
418
  }
419
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
420
+ let filePath;
421
+ try {
422
+ filePath = normalizePath(repoRoot, body.file_path);
423
+ }
424
+ catch (err) {
425
+ json(res, { error: `invalid file_path: ${err.message}` }, 400);
426
+ return;
427
+ }
408
428
  const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
409
- services.workingFiles.start(body.agent_id, body.file_path, ttl);
429
+ services.workingFiles.start(ctx.claims.org, body.agent_id, filePath, ttl);
410
430
  json(res, { ok: true });
411
431
  }
412
432
  else if (url === "/api/working-files/stop" && req.method === "POST") {
@@ -414,7 +434,16 @@ export async function handleRest(req, res, ctx) {
414
434
  json(res, { error: "agent_id and file_path required" }, 400);
415
435
  return;
416
436
  }
417
- services.workingFiles.stop(body.agent_id, body.file_path);
437
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
438
+ let filePath;
439
+ try {
440
+ filePath = normalizePath(repoRoot, body.file_path);
441
+ }
442
+ catch (err) {
443
+ json(res, { error: `invalid file_path: ${err.message}` }, 400);
444
+ return;
445
+ }
446
+ services.workingFiles.stop(ctx.claims.org, body.agent_id, filePath);
418
447
  json(res, { ok: true });
419
448
  }
420
449
  else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
@@ -455,12 +484,12 @@ export async function handleRest(req, res, ctx) {
455
484
  });
456
485
  }
457
486
  else if (url === "/api/status") {
458
- const online = registry.listOnline();
459
- const openThreads = consultation.listThreads({ status: "open" });
487
+ const online = registry.listOnline(ctx.claims.org);
488
+ const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
460
489
  json(res, {
461
490
  online: online.length,
462
491
  open_threads: openThreads.length,
463
- hot_files: fileTracker.getHotFiles(30).length,
492
+ hot_files: fileTracker.getHotFiles(ctx.claims.org, 30).length,
464
493
  mqtt: services.mqttBridge.isConnected(),
465
494
  });
466
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
  }