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
@@ -45,7 +45,10 @@ export class Consultation {
45
45
  }
46
46
  emitResolution(threadId, type, approvedBy, approvedByName) {
47
47
  const db = getDb();
48
- const thread = this.getThread(threadId);
48
+ // emitResolution is called internally after we already know the thread belongs
49
+ // to the right org. We look it up cross-org here intentionally so that
50
+ // handleAgentDeparture (which is cross-org) can still emit resolution events.
51
+ const thread = this.getThreadCrossOrg(threadId);
49
52
  if (!thread)
50
53
  return;
51
54
  const messageCount = db.prepare("SELECT COUNT(*) as count FROM thread_messages WHERE thread_id = ?").get(threadId).count;
@@ -59,6 +62,7 @@ export class Consultation {
59
62
  if (this.onResolveCallback) {
60
63
  this.onResolveCallback({
61
64
  thread_id: threadId,
65
+ org_id: thread.org_id,
62
66
  resolution_type: type,
63
67
  resolution_summary: thread.resolution_summary,
64
68
  created_at: thread.created_at,
@@ -69,7 +73,7 @@ export class Consultation {
69
73
  });
70
74
  }
71
75
  }
72
- announceWork(params) {
76
+ announceWork(orgId, params) {
73
77
  const db = getDb();
74
78
  const id = randomUUID();
75
79
  // B1 fix: SELECT respondents + INSERT thread must be atomic w.r.t. agent
@@ -79,8 +83,8 @@ export class Consultation {
79
83
  // thread then stays open forever waiting for an absent voter.
80
84
  const tx = db.transaction(() => {
81
85
  const onlineAgents = db
82
- .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
83
- .all(params.agent_id);
86
+ .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ? AND org_id = ?")
87
+ .all(params.agent_id, orgId);
84
88
  const respondents = onlineAgents.filter((agent) => {
85
89
  const agentModules = JSON.parse(agent.modules);
86
90
  return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
@@ -92,13 +96,14 @@ export class Consultation {
92
96
  const assignedTo = params.assigned_to ?? null;
93
97
  const keepOpen = params.keep_open || assignedTo !== null;
94
98
  const autoResolve = respondentIds.length === 0 && !keepOpen;
95
- db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
96
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
99
+ db.prepare(`INSERT INTO threads (id, org_id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
97
101
  return { autoResolve, respondentIds, assignedTo };
98
102
  });
99
103
  const { autoResolve, respondentIds, assignedTo } = tx();
100
104
  this.log.info({
101
105
  thread_id: id,
106
+ org_id: orgId,
102
107
  agent_id: params.agent_id,
103
108
  subject: params.subject,
104
109
  target_modules: params.target_modules,
@@ -106,11 +111,11 @@ export class Consultation {
106
111
  respondent_count: respondentIds.length,
107
112
  assigned_to: assignedTo,
108
113
  }, "Thread opened");
109
- return this.getThread(id);
114
+ return this.getThread(orgId, id);
110
115
  }
111
- postToThread(params) {
116
+ postToThread(orgId, params) {
112
117
  const db = getDb();
113
- const thread = this.getThread(params.thread_id);
118
+ const thread = this.getThread(orgId, params.thread_id);
114
119
  if (!thread)
115
120
  throw new Error(`Thread ${params.thread_id} not found`);
116
121
  // Cancelled threads are explicit aborts — reject posts.
@@ -122,8 +127,8 @@ export class Consultation {
122
127
  const id = randomUUID();
123
128
  // Simple token estimate: ~4 chars per token for English/French
124
129
  const tokenEstimate = Math.ceil(params.content.length / 4);
125
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
126
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
130
+ db.prepare(`INSERT INTO thread_messages (id, org_id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
131
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
127
132
  this.log.debug({
128
133
  thread_id: params.thread_id,
129
134
  agent_id: params.agent_id,
@@ -132,20 +137,20 @@ export class Consultation {
132
137
  }, "Message posted to thread");
133
138
  return db.prepare("SELECT * FROM thread_messages WHERE id = ?").get(id);
134
139
  }
135
- proposeResolution(threadId, agentId, summary) {
140
+ proposeResolution(orgId, threadId, agentId, summary) {
136
141
  const db = getDb();
137
- const thread = this.getThread(threadId);
142
+ const thread = this.getThread(orgId, threadId);
138
143
  if (!thread)
139
144
  throw new Error(`Thread ${threadId} not found`);
140
145
  if (thread.initiator_id !== agentId && thread.claimed_by !== agentId)
141
146
  throw new Error("Only the initiator or the claimant can propose a resolution");
142
- db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ?").run(summary, threadId);
147
+ db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ? AND org_id = ?").run(summary, threadId, orgId);
143
148
  // Post resolution message
144
- this.postResolutionMessage(threadId, agentId, "resolution", summary);
149
+ this.postResolutionMessage(orgId, threadId, agentId, "resolution", summary);
145
150
  }
146
- approveResolution(threadId, agentId, agentName) {
151
+ approveResolution(orgId, threadId, agentId, agentName) {
147
152
  const db = getDb();
148
- const thread = this.getThread(threadId);
153
+ const thread = this.getThread(orgId, threadId);
149
154
  if (!thread)
150
155
  throw new Error(`Thread ${threadId} not found`);
151
156
  if (thread.status !== "resolving")
@@ -156,12 +161,12 @@ export class Consultation {
156
161
  // the first transaction wins the consensus race; the loser's UPDATE
157
162
  // affects 0 rows and emit is suppressed.
158
163
  const tx = db.transaction(() => {
159
- this.postResolutionMessage(threadId, agentId, "approve", "Approved");
160
- if (!this.allRespondentsApproved(threadId))
164
+ this.postResolutionMessage(orgId, threadId, agentId, "approve", "Approved");
165
+ if (!this.allRespondentsApproved(orgId, threadId))
161
166
  return false;
162
167
  const res = db
163
- .prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'resolving'")
164
- .run(new Date().toISOString(), threadId);
168
+ .prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ? AND status = 'resolving'")
169
+ .run(new Date().toISOString(), threadId, orgId);
165
170
  return res.changes > 0;
166
171
  });
167
172
  const wonRace = tx();
@@ -170,42 +175,42 @@ export class Consultation {
170
175
  this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
171
176
  }
172
177
  }
173
- contestResolution(threadId, agentId, reason) {
178
+ contestResolution(orgId, threadId, agentId, reason) {
174
179
  const db = getDb();
175
- const thread = this.getThread(threadId);
180
+ const thread = this.getThread(orgId, threadId);
176
181
  if (!thread)
177
182
  throw new Error(`Thread ${threadId} not found`);
178
183
  if (thread.status !== "resolving")
179
184
  throw new Error(`Thread is ${thread.status}, not resolving`);
180
185
  // Post contest message
181
- this.postResolutionMessage(threadId, agentId, "contest", reason);
186
+ this.postResolutionMessage(orgId, threadId, agentId, "contest", reason);
182
187
  this.log.debug({ thread_id: threadId, agent_id: agentId, reason }, "Resolution contested");
183
188
  // Return to open with next round
184
189
  const nextRound = thread.round + 1;
185
190
  if (nextRound > thread.max_rounds) {
186
191
  // Max rounds reached — force resolve
187
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
192
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), threadId, orgId);
188
193
  this.emitResolution(threadId, "max_rounds");
189
194
  }
190
195
  else {
191
- db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ?").run(nextRound, threadId);
196
+ db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ? AND org_id = ?").run(nextRound, threadId, orgId);
192
197
  }
193
198
  }
194
- cancelThread(threadId, agentId, reason) {
199
+ cancelThread(orgId, threadId, agentId, reason) {
195
200
  const db = getDb();
196
- const thread = this.getThread(threadId);
201
+ const thread = this.getThread(orgId, threadId);
197
202
  if (!thread)
198
203
  throw new Error(`Thread ${threadId} not found`);
199
204
  if (thread.initiator_id !== agentId)
200
205
  throw new Error("Only the initiator can cancel");
201
- db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
206
+ db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), threadId, orgId);
202
207
  if (reason) {
203
- this.postResolutionMessage(threadId, agentId, "context", `Cancelled: ${reason}`);
208
+ this.postResolutionMessage(orgId, threadId, agentId, "context", `Cancelled: ${reason}`);
204
209
  }
205
210
  }
206
- closeThread(threadId, agentId, summary) {
211
+ closeThread(orgId, threadId, agentId, summary) {
207
212
  const db = getDb();
208
- const thread = this.getThread(threadId);
213
+ const thread = this.getThread(orgId, threadId);
209
214
  if (!thread) {
210
215
  throw new Error(`Thread ${threadId} not found`);
211
216
  }
@@ -215,35 +220,54 @@ export class Consultation {
215
220
  if (thread.status !== "open" && thread.status !== "resolving") {
216
221
  throw new Error(`Cannot close thread ${threadId} in status '${thread.status}'`);
217
222
  }
218
- db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ?").run(summary, new Date().toISOString(), threadId);
223
+ db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ? AND org_id = ?").run(summary, new Date().toISOString(), threadId, orgId);
219
224
  this.emitResolution(threadId, "closed");
220
225
  }
226
+ /**
227
+ * Cross-org maintenance sweep — stays at v0.6 signature per Phase 1 plan.
228
+ * handleAgentDeparture iterates ALL orgs (internal maintenance only).
229
+ */
221
230
  handleAgentDeparture(agentId) {
222
231
  const db = getDb();
223
- // Unclaim any tasks claimed by the departing agent
232
+ // Unclaim any tasks claimed by the departing agent.
233
+ //
234
+ // INTENTIONALLY cross-org: this method is invoked from the MQTT-bridge
235
+ // disconnect handler, which has no org context (Phase 1: MQTT topics carry
236
+ // no org_id yet — see Task 22 follow-up). If two orgs ever happen to
237
+ // register agents under the same agent_id string, an MQTT disconnect for
238
+ // that string will release claims in BOTH orgs. Phase 2 multi-org rollout
239
+ // must thread org from the MQTT topic before this becomes load-bearing.
224
240
  db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ? AND status = 'open'")
225
241
  .run(agentId);
226
- // Remove departed agent from expected_respondents of all open/resolving threads
242
+ // Remove departed agent from expected_respondents of all open/resolving threads.
243
+ // We iterate cross-org (no WHERE org_id filter on the SELECT) for the same
244
+ // reason — agentId is the only key we have from the MQTT bridge — but every
245
+ // point-update below is scoped by the row's own org_id (read from the SELECT)
246
+ // as defense in depth against bare id collisions.
227
247
  const threads = db
228
- .prepare("SELECT id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
248
+ .prepare("SELECT id, org_id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
229
249
  .all();
230
250
  for (const thread of threads) {
231
251
  const respondents = JSON.parse(thread.expected_respondents || "[]");
232
252
  const updated = respondents.filter((r) => r !== agentId);
233
- db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?").run(JSON.stringify(updated), thread.id);
253
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ? AND org_id = ?").run(JSON.stringify(updated), thread.id, thread.org_id);
234
254
  // If resolving and all remaining approved, resolve
235
- const t = this.getThread(thread.id);
236
- if (t.status === "resolving" && this.allRespondentsApproved(thread.id)) {
237
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
255
+ const t = this.getThreadCrossOrg(thread.id);
256
+ if (t.status === "resolving" && this.allRespondentsApproved(thread.org_id, thread.id)) {
257
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), thread.id, thread.org_id);
238
258
  this.emitResolution(thread.id, "agent_departure");
239
259
  }
240
260
  // If open and no respondents left, auto-resolve
241
261
  if (t.status === "open" && updated.length === 0) {
242
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
262
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), thread.id, thread.org_id);
243
263
  this.emitResolution(thread.id, "agent_departure");
244
264
  }
245
265
  }
246
266
  }
267
+ /**
268
+ * Cross-org sweeper — stays at v0.6 signature per Phase 1 plan.
269
+ * checkTimeouts scans ALL orgs (internal maintenance only).
270
+ */
247
271
  checkTimeouts() {
248
272
  const db = getDb();
249
273
  // B2 fix: SELECT-then-UPDATE wrapped in a transaction so two concurrent
@@ -279,28 +303,28 @@ export class Consultation {
279
303
  this.emitResolution(t.id, "timeout");
280
304
  }
281
305
  }
282
- getThread(threadId) {
306
+ getThread(orgId, threadId) {
283
307
  // B2 fix: timeout sweeping moved to startTimeoutSweeper() background timer.
284
308
  // Reads no longer mutate state. Tests that need synchronous timeout
285
309
  // resolution should call checkTimeouts() explicitly.
286
310
  const db = getDb();
287
- return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
311
+ return (db.prepare("SELECT * FROM threads WHERE id = ? AND org_id = ?").get(threadId, orgId) || null);
288
312
  }
289
- getThreadWithMessages(threadId) {
290
- const thread = this.getThread(threadId);
313
+ getThreadWithMessages(orgId, threadId) {
314
+ const thread = this.getThread(orgId, threadId);
291
315
  if (!thread)
292
316
  return null;
293
317
  const db = getDb();
294
318
  const messages = db
295
- .prepare("SELECT * FROM thread_messages WHERE thread_id = ? ORDER BY created_at")
296
- .all(threadId);
319
+ .prepare("SELECT tm.* FROM thread_messages tm JOIN threads t ON tm.thread_id = t.id WHERE tm.thread_id = ? AND t.org_id = ? ORDER BY tm.created_at")
320
+ .all(threadId, orgId);
297
321
  return { thread, messages };
298
322
  }
299
- listThreads(filters) {
323
+ listThreads(orgId, filters) {
300
324
  // B2 fix: removed checkTimeouts() side-effect; sweeper handles it.
301
325
  const db = getDb();
302
- let sql = "SELECT * FROM threads WHERE 1=1";
303
- const params = [];
326
+ let sql = "SELECT * FROM threads WHERE org_id = ?";
327
+ const params = [orgId];
304
328
  if (filters.status) {
305
329
  sql += " AND status = ?";
306
330
  params.push(filters.status);
@@ -331,13 +355,14 @@ export class Consultation {
331
355
  sql += " ORDER BY created_at DESC";
332
356
  return db.prepare(sql).all(...params);
333
357
  }
334
- getThreadUpdates(agentId, since) {
358
+ getThreadUpdates(orgId, agentId, since) {
335
359
  const db = getDb();
336
360
  let sql = `SELECT tm.* FROM thread_messages tm
337
361
  JOIN threads t ON tm.thread_id = t.id
338
- WHERE t.status IN ('open', 'resolving')
362
+ WHERE t.org_id = ?
363
+ AND t.status IN ('open', 'resolving')
339
364
  AND tm.agent_id != ?`;
340
- const params = [agentId];
365
+ const params = [orgId, agentId];
341
366
  if (since) {
342
367
  sql += " AND tm.created_at >= ?";
343
368
  // Normalize ANY parseable ISO/date string (including timezone offsets
@@ -354,17 +379,17 @@ export class Consultation {
354
379
  sql += " ORDER BY tm.created_at";
355
380
  return db.prepare(sql).all(...params);
356
381
  }
357
- logActionSummary(params) {
382
+ logActionSummary(orgId, params) {
358
383
  const db = getDb();
359
384
  const id = randomUUID();
360
- db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
361
- VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
385
+ db.prepare(`INSERT INTO action_summaries (id, org_id, session_id, agent_id, file_path, summary)
386
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, orgId, params.session_id, params.agent_id, params.file_path || null, params.summary);
362
387
  return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
363
388
  }
364
- getActionSummaries(agentId, since) {
389
+ getActionSummaries(orgId, agentId, since) {
365
390
  const db = getDb();
366
- let sql = "SELECT * FROM action_summaries WHERE agent_id = ?";
367
- const params = [agentId];
391
+ let sql = "SELECT * FROM action_summaries WHERE org_id = ? AND agent_id = ?";
392
+ const params = [orgId, agentId];
368
393
  if (since) {
369
394
  sql += " AND created_at > ?";
370
395
  params.push(since);
@@ -372,23 +397,31 @@ export class Consultation {
372
397
  sql += " ORDER BY created_at DESC";
373
398
  return db.prepare(sql).all(...params);
374
399
  }
375
- getActionSummariesBySession(sessionId) {
400
+ getActionSummariesBySession(orgId, sessionId) {
376
401
  const db = getDb();
377
402
  return db
378
- .prepare("SELECT * FROM action_summaries WHERE session_id = ? ORDER BY created_at")
379
- .all(sessionId);
403
+ .prepare("SELECT * FROM action_summaries WHERE org_id = ? AND session_id = ? ORDER BY created_at")
404
+ .all(orgId, sessionId);
380
405
  }
381
406
  // ── Private helpers ──
382
- postResolutionMessage(threadId, agentId, type, content) {
407
+ /**
408
+ * Cross-org thread lookup for internal sweepers/departure handlers.
409
+ * Do NOT call from public methods — use getThread(orgId, id) instead.
410
+ */
411
+ getThreadCrossOrg(threadId) {
412
+ const db = getDb();
413
+ return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
414
+ }
415
+ postResolutionMessage(orgId, threadId, agentId, type, content) {
383
416
  const db = getDb();
384
- const thread = this.getThread(threadId);
417
+ const thread = this.getThread(orgId, threadId);
385
418
  const id = randomUUID();
386
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
387
- VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
419
+ db.prepare(`INSERT INTO thread_messages (id, org_id, thread_id, agent_id, type, content, round)
420
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, threadId, agentId, type, content, thread.round);
388
421
  }
389
- allRespondentsApproved(threadId) {
422
+ allRespondentsApproved(orgId, threadId) {
390
423
  const db = getDb();
391
- const thread = this.getThread(threadId);
424
+ const thread = this.getThread(orgId, threadId);
392
425
  const expected = JSON.parse(thread.expected_respondents || "[]");
393
426
  if (expected.length === 0)
394
427
  return true;
@@ -3,12 +3,12 @@ import type { AgentRegistry } from "./agent-registry.js";
3
3
  import type { Consultation } from "./consultation.js";
4
4
  import type { FileTracker } from "./file-tracker.js";
5
5
  export interface ContextProvider {
6
- getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
6
+ getRelevantContext(orgId: string, agentId: string, query: ConsultationAnnounce): AgentContext;
7
7
  }
8
8
  export declare class SummaryContextProvider implements ContextProvider {
9
9
  private registry;
10
10
  private consultation;
11
11
  private fileTracker;
12
12
  constructor(registry: AgentRegistry, consultation: Consultation, fileTracker: FileTracker);
13
- getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
13
+ getRelevantContext(orgId: string, agentId: string, query: ConsultationAnnounce): AgentContext;
14
14
  }
@@ -7,8 +7,8 @@ export class SummaryContextProvider {
7
7
  this.consultation = consultation;
8
8
  this.fileTracker = fileTracker;
9
9
  }
10
- getRelevantContext(agentId, query) {
11
- const agent = this.registry.get(agentId);
10
+ getRelevantContext(orgId, agentId, query) {
11
+ const agent = this.registry.get(orgId, agentId);
12
12
  if (!agent) {
13
13
  return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
14
14
  }
@@ -18,8 +18,7 @@ export class SummaryContextProvider {
18
18
  if (overlapping.length === 0) {
19
19
  return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
20
20
  }
21
- // Get action summaries for this agent
22
- const summaries = this.consultation.getActionSummaries(agentId);
21
+ const summaries = this.consultation.getActionSummaries(orgId, agentId);
23
22
  // Get recent files from action summaries (agent writes these via MCP tool)
24
23
  const recentFiles = summaries
25
24
  .filter((s) => s.file_path)
@@ -1,9 +1,9 @@
1
1
  import path from "path";
2
- import { mkdirSync } from "fs";
2
+ import { mkdirSync, chmodSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  const require = createRequire(import.meta.url);
5
5
  let db;
6
- const CURRENT_USER_VERSION = 6;
6
+ const CURRENT_USER_VERSION = 7;
7
7
  const SCHEMA = `
8
8
  CREATE TABLE IF NOT EXISTS agents (
9
9
  id TEXT PRIMARY KEY,
@@ -169,6 +169,71 @@ const SCHEMA = `
169
169
  );
170
170
  CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
171
171
  CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
172
+
173
+ CREATE TABLE IF NOT EXISTS orgs (
174
+ id TEXT PRIMARY KEY,
175
+ name TEXT NOT NULL,
176
+ idp_provider TEXT,
177
+ idp_org_id TEXT,
178
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
179
+ );
180
+
181
+ CREATE TABLE IF NOT EXISTS users (
182
+ id TEXT PRIMARY KEY,
183
+ org_id TEXT NOT NULL REFERENCES orgs(id),
184
+ email TEXT NOT NULL,
185
+ name TEXT,
186
+ idp_provider TEXT NOT NULL,
187
+ idp_user_id TEXT NOT NULL,
188
+ role TEXT NOT NULL DEFAULT 'member',
189
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
190
+ last_login_at TEXT,
191
+ UNIQUE(idp_provider, idp_user_id)
192
+ );
193
+ CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id);
194
+
195
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
196
+ id TEXT PRIMARY KEY,
197
+ org_id TEXT NOT NULL REFERENCES orgs(id),
198
+ user_id TEXT NOT NULL REFERENCES users(id),
199
+ jti TEXT NOT NULL UNIQUE,
200
+ device_label TEXT,
201
+ expires_at TEXT NOT NULL,
202
+ revoked_at TEXT,
203
+ last_used_at TEXT,
204
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id, revoked_at);
207
+ CREATE INDEX IF NOT EXISTS idx_refresh_org_user ON refresh_tokens(org_id, user_id, revoked_at);
208
+
209
+ CREATE TABLE IF NOT EXISTS device_auth_requests (
210
+ device_code TEXT PRIMARY KEY,
211
+ user_code TEXT NOT NULL UNIQUE,
212
+ nonce TEXT NOT NULL UNIQUE,
213
+ approved_user_id TEXT REFERENCES users(id),
214
+ org_id TEXT,
215
+ expires_at TEXT NOT NULL,
216
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
217
+ );
218
+ CREATE INDEX IF NOT EXISTS idx_device_user_code ON device_auth_requests(user_code);
219
+ CREATE INDEX IF NOT EXISTS idx_device_nonce ON device_auth_requests(nonce);
220
+
221
+ CREATE TABLE IF NOT EXISTS audit_log (
222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
223
+ user_id TEXT,
224
+ org_id TEXT,
225
+ action TEXT NOT NULL,
226
+ target TEXT,
227
+ ip TEXT,
228
+ user_agent TEXT,
229
+ metadata TEXT,
230
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
231
+ );
232
+ CREATE INDEX IF NOT EXISTS idx_audit_org_time ON audit_log(org_id, created_at);
233
+ CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
234
+ CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
235
+
236
+ INSERT OR IGNORE INTO orgs (id, name) VALUES ('default', 'Default Organization');
172
237
  `;
173
238
  function createBetterSqlite3(dataDir) {
174
239
  mkdirSync(dataDir, { recursive: true });
@@ -212,6 +277,13 @@ export function initDatabase(dataDir) {
212
277
  throw new Error(`Database schema is from a newer version (${foundVersion}) than this binary supports (${CURRENT_USER_VERSION}). Downgrade not supported.`);
213
278
  }
214
279
  db.exec(SCHEMA);
280
+ // v0.7 security baseline: only owner can read/write the DB file.
281
+ // Idempotent re-chmod on every boot so existing v0.6 DBs are tightened too.
282
+ // POSIX-only: chmod is a no-op on Windows (NTFS permissions don't map).
283
+ try {
284
+ chmodSync(path.join(dataDir, "coordinator.db"), 0o600);
285
+ }
286
+ catch { /* non-POSIX or permission error — log-only would be too noisy */ }
215
287
  // Migrations for existing databases — columns may already exist
216
288
  try {
217
289
  db.exec("ALTER TABLE threads ADD COLUMN claimed_by TEXT");
@@ -245,8 +317,135 @@ export function initDatabase(dataDir) {
245
317
  db.exec("ALTER TABLE file_activity ADD COLUMN content_hash TEXT");
246
318
  }
247
319
  catch { /* already exists */ }
248
- // v0.6: schema version marker. Used by cli/server/restore.ts to refuse downgrades.
249
- db.exec("PRAGMA user_version = 6");
320
+ // v0.7: multi-tenant org_id column on every table that participates in scoping.
321
+ // SQLite lacks online DDL — each ALTER briefly blocks writes. Migration is
322
+ // idempotent (already-exists is caught silently).
323
+ // SECURITY: `t` is from a compile-time constant array only — never user input.
324
+ const TABLES_NEEDING_ORG = [
325
+ "agents", "threads", "thread_messages", "action_summaries",
326
+ "file_activity", "events", "dependency_map", "introspections",
327
+ "agent_activity_status", "revoked_agents", "working_files",
328
+ "git_cochange", "git_cochange_meta", "layer_firings",
329
+ ];
330
+ for (const t of TABLES_NEEDING_ORG) {
331
+ try {
332
+ db.exec(`ALTER TABLE ${t} ADD COLUMN org_id TEXT NOT NULL DEFAULT 'default'`);
333
+ }
334
+ catch { /* already exists */ }
335
+ }
336
+ // v0.7: scan index for events table — getEventsSince queries by (org_id, id)
337
+ try {
338
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_org_id ON events(org_id, id)");
339
+ }
340
+ catch { /* already exists */ }
341
+ // v0.7: SQLite lacks ALTER PRIMARY KEY. Pattern per table:
342
+ // 1. Create new table with composite PK.
343
+ // 2. Copy all rows from old to new.
344
+ // 3. Drop old.
345
+ // 4. Rename new to old.
346
+ // 5. Recreate indexes.
347
+ // Idempotent: skip if the table's PK already includes org_id.
348
+ function migrateToCompositePK(targetDb, tableName, newCreateSql, columnList, indexCreateSqls) {
349
+ // Check if migration already happened by inspecting PK columns
350
+ const cols = targetDb.prepare(`PRAGMA table_info(${tableName})`).all();
351
+ const pkCols = cols.filter((c) => c.pk > 0).map((c) => c.name);
352
+ if (pkCols.includes("org_id"))
353
+ return; // already migrated
354
+ // SQLite: FK enforcement blocks DROP TABLE when other tables hold FK refs.
355
+ // PRAGMA foreign_keys must be toggled OUTSIDE the transaction (it's a no-op
356
+ // inside an open transaction). The finally block guarantees FKs are re-enabled
357
+ // even on error to avoid permanent corruption.
358
+ targetDb.exec("PRAGMA foreign_keys = OFF");
359
+ try {
360
+ targetDb.exec("BEGIN");
361
+ try {
362
+ targetDb.exec(newCreateSql.replace(tableName, `${tableName}_new`));
363
+ targetDb.exec(`INSERT INTO ${tableName}_new (${columnList}) SELECT ${columnList} FROM ${tableName}`);
364
+ targetDb.exec(`DROP TABLE ${tableName}`);
365
+ targetDb.exec(`ALTER TABLE ${tableName}_new RENAME TO ${tableName}`);
366
+ for (const idxSql of indexCreateSqls)
367
+ targetDb.exec(idxSql);
368
+ targetDb.exec("COMMIT");
369
+ }
370
+ catch (e) {
371
+ targetDb.exec("ROLLBACK");
372
+ throw e;
373
+ }
374
+ }
375
+ finally {
376
+ targetDb.exec("PRAGMA foreign_keys = ON");
377
+ }
378
+ }
379
+ migrateToCompositePK(db, "agents", `CREATE TABLE agents (
380
+ id TEXT NOT NULL,
381
+ org_id TEXT NOT NULL DEFAULT 'default',
382
+ name TEXT NOT NULL,
383
+ modules TEXT DEFAULT '[]',
384
+ status TEXT DEFAULT 'offline',
385
+ registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
386
+ last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
387
+ PRIMARY KEY (org_id, id)
388
+ )`, "id, org_id, name, modules, status, registered_at, last_seen_at", ["CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id ON agents(id)"]);
389
+ migrateToCompositePK(db, "agent_activity_status", `CREATE TABLE agent_activity_status (
390
+ agent_id TEXT NOT NULL,
391
+ org_id TEXT NOT NULL DEFAULT 'default',
392
+ activity_status TEXT DEFAULT 'idle',
393
+ current_file TEXT,
394
+ current_thread TEXT,
395
+ last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
396
+ FOREIGN KEY (agent_id) REFERENCES agents(id),
397
+ PRIMARY KEY (org_id, agent_id)
398
+ )`, "agent_id, org_id, activity_status, current_file, current_thread, last_activity_at", []);
399
+ migrateToCompositePK(db, "revoked_agents", `CREATE TABLE revoked_agents (
400
+ agent_id TEXT NOT NULL,
401
+ org_id TEXT NOT NULL DEFAULT 'default',
402
+ revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
403
+ revoked_by TEXT NOT NULL,
404
+ PRIMARY KEY (org_id, agent_id)
405
+ )`, "agent_id, org_id, revoked_at, revoked_by", []);
406
+ migrateToCompositePK(db, "working_files", `CREATE TABLE working_files (
407
+ agent_id TEXT NOT NULL,
408
+ file_path TEXT NOT NULL,
409
+ org_id TEXT NOT NULL DEFAULT 'default',
410
+ started_at TEXT NOT NULL,
411
+ last_activity_at TEXT NOT NULL,
412
+ claim_until TEXT NOT NULL,
413
+ PRIMARY KEY (org_id, agent_id, file_path)
414
+ )`, "agent_id, file_path, org_id, started_at, last_activity_at, claim_until", [
415
+ "CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path)",
416
+ "CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until)",
417
+ ]);
418
+ migrateToCompositePK(db, "dependency_map", `CREATE TABLE dependency_map (
419
+ module_id TEXT NOT NULL,
420
+ org_id TEXT NOT NULL DEFAULT 'default',
421
+ depends_on TEXT DEFAULT '[]',
422
+ exports TEXT DEFAULT '[]',
423
+ owners TEXT DEFAULT '[]',
424
+ PRIMARY KEY (org_id, module_id)
425
+ )`, "module_id, org_id, depends_on, exports, owners", []);
426
+ migrateToCompositePK(db, "git_cochange", `CREATE TABLE git_cochange (
427
+ file_a TEXT NOT NULL,
428
+ file_b TEXT NOT NULL,
429
+ org_id TEXT NOT NULL DEFAULT 'default',
430
+ count INTEGER NOT NULL,
431
+ total_commits INTEGER NOT NULL,
432
+ computed_at TEXT NOT NULL,
433
+ PRIMARY KEY (org_id, file_a, file_b),
434
+ CHECK (file_a < file_b)
435
+ )`, "file_a, file_b, org_id, count, total_commits, computed_at", [
436
+ "CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a)",
437
+ "CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b)",
438
+ ]);
439
+ migrateToCompositePK(db, "git_cochange_meta", `CREATE TABLE git_cochange_meta (
440
+ k TEXT NOT NULL,
441
+ org_id TEXT NOT NULL DEFAULT 'default',
442
+ v TEXT,
443
+ PRIMARY KEY (org_id, k)
444
+ )`, "k, org_id, v", []);
445
+ // v0.7: bump version marker LAST — after every CREATE TABLE and ALTER above succeeded.
446
+ // A crash before this line leaves user_version=6 and the next boot retries the migration
447
+ // (idempotent). Bumping earlier would make a partial migration look complete.
448
+ db.exec("PRAGMA user_version = 7");
250
449
  }
251
450
  export function getDb() {
252
451
  if (!db)