mcp-coordinator 0.5.0 → 0.6.1

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.
@@ -59,12 +59,12 @@ export class AgentActivityTracker {
59
59
  // ── Private ──
60
60
  upsert(agentId, status, file, thread) {
61
61
  const db = getDb();
62
- db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
- ON CONFLICT(agent_id) DO UPDATE SET
65
- activity_status = excluded.activity_status,
66
- current_file = excluded.current_file,
67
- current_thread = excluded.current_thread,
62
+ db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
+ ON CONFLICT(agent_id) DO UPDATE SET
65
+ activity_status = excluded.activity_status,
66
+ current_file = excluded.current_file,
67
+ current_thread = excluded.current_thread,
68
68
  last_activity_at = CURRENT_TIMESTAMP`).run(agentId, status, file, thread);
69
69
  }
70
70
  }
@@ -2,12 +2,12 @@ import { getDb } from "./database.js";
2
2
  export class AgentRegistry {
3
3
  register(agentId, name, modules) {
4
4
  const db = getDb();
5
- db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
- VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
- ON CONFLICT(id) DO UPDATE SET
8
- name = excluded.name,
9
- modules = excluded.modules,
10
- status = 'online',
5
+ db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
+ VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
+ ON CONFLICT(id) DO UPDATE SET
8
+ name = excluded.name,
9
+ modules = excluded.modules,
10
+ status = 'online',
11
11
  last_seen_at = CURRENT_TIMESTAMP`).run(agentId, name, JSON.stringify(modules));
12
12
  return this.get(agentId);
13
13
  }
@@ -92,7 +92,7 @@ export class Consultation {
92
92
  const assignedTo = params.assigned_to ?? null;
93
93
  const keepOpen = params.keep_open || assignedTo !== null;
94
94
  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)
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
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);
97
97
  return { autoResolve, respondentIds, assignedTo };
98
98
  });
@@ -122,7 +122,7 @@ export class Consultation {
122
122
  const id = randomUUID();
123
123
  // Simple token estimate: ~4 chars per token for English/French
124
124
  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)
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
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);
127
127
  this.log.debug({
128
128
  thread_id: params.thread_id,
@@ -252,21 +252,21 @@ export class Consultation {
252
252
  // thread IDs to emit for. The transaction makes both observe the same
253
253
  // snapshot.
254
254
  const tx = db.transaction(() => {
255
- const timedOut = db.prepare(`
256
- SELECT id FROM threads
257
- WHERE status IN ('open', 'resolving')
258
- AND timeout_seconds > 0
259
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
255
+ const timedOut = db.prepare(`
256
+ SELECT id FROM threads
257
+ WHERE status IN ('open', 'resolving')
258
+ AND timeout_seconds > 0
259
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
260
260
  `).all();
261
261
  if (timedOut.length === 0)
262
262
  return [];
263
- db.prepare(`
264
- UPDATE threads SET status = 'resolved',
265
- resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
- resolved_at = CURRENT_TIMESTAMP
267
- WHERE status IN ('open', 'resolving')
268
- AND timeout_seconds > 0
269
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
263
+ db.prepare(`
264
+ UPDATE threads SET status = 'resolved',
265
+ resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
+ resolved_at = CURRENT_TIMESTAMP
267
+ WHERE status IN ('open', 'resolving')
268
+ AND timeout_seconds > 0
269
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
270
270
  `).run();
271
271
  return timedOut;
272
272
  });
@@ -333,9 +333,9 @@ export class Consultation {
333
333
  }
334
334
  getThreadUpdates(agentId, since) {
335
335
  const db = getDb();
336
- let sql = `SELECT tm.* FROM thread_messages tm
337
- JOIN threads t ON tm.thread_id = t.id
338
- WHERE t.status IN ('open', 'resolving')
336
+ let sql = `SELECT tm.* FROM thread_messages tm
337
+ JOIN threads t ON tm.thread_id = t.id
338
+ WHERE t.status IN ('open', 'resolving')
339
339
  AND tm.agent_id != ?`;
340
340
  const params = [agentId];
341
341
  if (since) {
@@ -357,7 +357,7 @@ export class Consultation {
357
357
  logActionSummary(params) {
358
358
  const db = getDb();
359
359
  const id = randomUUID();
360
- db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
360
+ db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
361
361
  VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
362
362
  return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
363
363
  }
@@ -383,7 +383,7 @@ export class Consultation {
383
383
  const db = getDb();
384
384
  const thread = this.getThread(threadId);
385
385
  const id = randomUUID();
386
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
386
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
387
387
  VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
388
388
  }
389
389
  allRespondentsApproved(threadId) {
@@ -396,7 +396,7 @@ export class Consultation {
396
396
  // increments the round, and prior-round approvals must be re-collected
397
397
  // for the new proposal.
398
398
  const approvals = db
399
- .prepare(`SELECT DISTINCT agent_id FROM thread_messages
399
+ .prepare(`SELECT DISTINCT agent_id FROM thread_messages
400
400
  WHERE thread_id = ? AND type = 'approve' AND round = ?`)
401
401
  .all(threadId, thread.round);
402
402
  const approvedIds = new Set(approvals.map((a) => a.agent_id));
@@ -4,171 +4,171 @@ import { createRequire } from "module";
4
4
  const require = createRequire(import.meta.url);
5
5
  let db;
6
6
  const CURRENT_USER_VERSION = 6;
7
- const SCHEMA = `
8
- CREATE TABLE IF NOT EXISTS agents (
9
- id TEXT PRIMARY KEY,
10
- name TEXT NOT NULL,
11
- modules TEXT DEFAULT '[]',
12
- status TEXT DEFAULT 'offline',
13
- registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
14
- last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
15
- );
16
-
17
- CREATE TABLE IF NOT EXISTS threads (
18
- id TEXT PRIMARY KEY,
19
- initiator_id TEXT NOT NULL,
20
- subject TEXT NOT NULL,
21
- plan TEXT,
22
- target_modules TEXT DEFAULT '[]',
23
- target_files TEXT DEFAULT '[]',
24
- status TEXT DEFAULT 'open',
25
- resolution_summary TEXT,
26
- conflicts TEXT,
27
- round INTEGER DEFAULT 1,
28
- max_rounds INTEGER DEFAULT 4,
29
- timeout_seconds INTEGER DEFAULT 600,
30
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
31
- resolved_at TEXT,
32
- expected_respondents TEXT,
33
- depends_on_files TEXT,
34
- exports_affected TEXT,
35
- claimed_by TEXT,
36
- claimed_at TEXT,
37
- FOREIGN KEY (initiator_id) REFERENCES agents(id)
38
- );
39
-
40
- CREATE TABLE IF NOT EXISTS thread_messages (
41
- id TEXT PRIMARY KEY,
42
- thread_id TEXT NOT NULL,
43
- agent_id TEXT NOT NULL,
44
- agent_name TEXT,
45
- type TEXT NOT NULL,
46
- content TEXT NOT NULL,
47
- context_snapshot TEXT,
48
- in_reply_to TEXT,
49
- round INTEGER NOT NULL,
50
- token_estimate INTEGER DEFAULT 0,
51
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
52
- FOREIGN KEY (thread_id) REFERENCES threads(id),
53
- FOREIGN KEY (agent_id) REFERENCES agents(id)
54
- );
55
-
56
- CREATE TABLE IF NOT EXISTS action_summaries (
57
- id TEXT PRIMARY KEY,
58
- session_id TEXT NOT NULL,
59
- agent_id TEXT NOT NULL,
60
- file_path TEXT,
61
- summary TEXT NOT NULL,
62
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
63
- FOREIGN KEY (agent_id) REFERENCES agents(id)
64
- );
65
-
66
- CREATE TABLE IF NOT EXISTS events (
67
- id INTEGER PRIMARY KEY AUTOINCREMENT,
68
- type TEXT NOT NULL,
69
- payload TEXT NOT NULL,
70
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
71
- );
72
-
73
- CREATE TABLE IF NOT EXISTS dependency_map (
74
- module_id TEXT PRIMARY KEY,
75
- depends_on TEXT DEFAULT '[]',
76
- exports TEXT DEFAULT '[]',
77
- owners TEXT DEFAULT '[]'
78
- );
79
-
80
- CREATE TABLE IF NOT EXISTS file_activity (
81
- id INTEGER PRIMARY KEY AUTOINCREMENT,
82
- session_id TEXT NOT NULL,
83
- agent_id TEXT NOT NULL,
84
- agent_name TEXT,
85
- tool_name TEXT NOT NULL,
86
- file_path TEXT NOT NULL,
87
- module TEXT,
88
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
89
- );
90
-
91
- CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
92
- CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
93
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
94
- CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
95
- CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
96
- CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
97
- CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
98
- CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
99
- CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
100
-
101
- CREATE TABLE IF NOT EXISTS introspections (
102
- id TEXT PRIMARY KEY,
103
- thread_id TEXT NOT NULL,
104
- agent_id TEXT NOT NULL,
105
- score INTEGER NOT NULL,
106
- reasons TEXT,
107
- status TEXT DEFAULT 'pending',
108
- response TEXT,
109
- concerned INTEGER DEFAULT 0,
110
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
111
- responded_at TEXT,
112
- FOREIGN KEY (thread_id) REFERENCES threads(id),
113
- FOREIGN KEY (agent_id) REFERENCES agents(id)
114
- );
115
-
116
- CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
117
- CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
118
-
119
- CREATE TABLE IF NOT EXISTS agent_activity_status (
120
- agent_id TEXT PRIMARY KEY,
121
- activity_status TEXT DEFAULT 'idle',
122
- current_file TEXT,
123
- current_thread TEXT,
124
- last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
125
- FOREIGN KEY (agent_id) REFERENCES agents(id)
126
- );
127
-
128
- CREATE TABLE IF NOT EXISTS revoked_agents (
129
- agent_id TEXT PRIMARY KEY,
130
- revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
131
- revoked_by TEXT NOT NULL
132
- );
133
-
134
- CREATE TABLE IF NOT EXISTS working_files (
135
- agent_id TEXT NOT NULL,
136
- file_path TEXT NOT NULL,
137
- started_at TEXT NOT NULL,
138
- last_activity_at TEXT NOT NULL,
139
- claim_until TEXT NOT NULL,
140
- PRIMARY KEY (agent_id, file_path)
141
- );
142
- CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path);
143
- CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until);
144
-
145
- CREATE TABLE IF NOT EXISTS git_cochange (
146
- file_a TEXT NOT NULL,
147
- file_b TEXT NOT NULL,
148
- count INTEGER NOT NULL,
149
- total_commits INTEGER NOT NULL,
150
- computed_at TEXT NOT NULL,
151
- PRIMARY KEY (file_a, file_b),
152
- CHECK (file_a < file_b)
153
- );
154
- CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a);
155
- CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b);
156
-
157
- CREATE TABLE IF NOT EXISTS git_cochange_meta (
158
- k TEXT PRIMARY KEY,
159
- v TEXT
160
- );
161
-
162
- CREATE TABLE IF NOT EXISTS layer_firings (
163
- id INTEGER PRIMARY KEY AUTOINCREMENT,
164
- thread_id TEXT,
165
- layer TEXT NOT NULL,
166
- score INTEGER NOT NULL,
167
- agent_id TEXT,
168
- fired_at TEXT DEFAULT CURRENT_TIMESTAMP
169
- );
170
- CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
171
- CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
7
+ const SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS agents (
9
+ id TEXT PRIMARY KEY,
10
+ name TEXT NOT NULL,
11
+ modules TEXT DEFAULT '[]',
12
+ status TEXT DEFAULT 'offline',
13
+ registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
14
+ last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS threads (
18
+ id TEXT PRIMARY KEY,
19
+ initiator_id TEXT NOT NULL,
20
+ subject TEXT NOT NULL,
21
+ plan TEXT,
22
+ target_modules TEXT DEFAULT '[]',
23
+ target_files TEXT DEFAULT '[]',
24
+ status TEXT DEFAULT 'open',
25
+ resolution_summary TEXT,
26
+ conflicts TEXT,
27
+ round INTEGER DEFAULT 1,
28
+ max_rounds INTEGER DEFAULT 4,
29
+ timeout_seconds INTEGER DEFAULT 600,
30
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
31
+ resolved_at TEXT,
32
+ expected_respondents TEXT,
33
+ depends_on_files TEXT,
34
+ exports_affected TEXT,
35
+ claimed_by TEXT,
36
+ claimed_at TEXT,
37
+ FOREIGN KEY (initiator_id) REFERENCES agents(id)
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS thread_messages (
41
+ id TEXT PRIMARY KEY,
42
+ thread_id TEXT NOT NULL,
43
+ agent_id TEXT NOT NULL,
44
+ agent_name TEXT,
45
+ type TEXT NOT NULL,
46
+ content TEXT NOT NULL,
47
+ context_snapshot TEXT,
48
+ in_reply_to TEXT,
49
+ round INTEGER NOT NULL,
50
+ token_estimate INTEGER DEFAULT 0,
51
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
52
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
53
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS action_summaries (
57
+ id TEXT PRIMARY KEY,
58
+ session_id TEXT NOT NULL,
59
+ agent_id TEXT NOT NULL,
60
+ file_path TEXT,
61
+ summary TEXT NOT NULL,
62
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
63
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS events (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ type TEXT NOT NULL,
69
+ payload TEXT NOT NULL,
70
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS dependency_map (
74
+ module_id TEXT PRIMARY KEY,
75
+ depends_on TEXT DEFAULT '[]',
76
+ exports TEXT DEFAULT '[]',
77
+ owners TEXT DEFAULT '[]'
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS file_activity (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ session_id TEXT NOT NULL,
83
+ agent_id TEXT NOT NULL,
84
+ agent_name TEXT,
85
+ tool_name TEXT NOT NULL,
86
+ file_path TEXT NOT NULL,
87
+ module TEXT,
88
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
92
+ CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
93
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
94
+ CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
95
+ CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
96
+ CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
97
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
98
+ CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
99
+ CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
100
+
101
+ CREATE TABLE IF NOT EXISTS introspections (
102
+ id TEXT PRIMARY KEY,
103
+ thread_id TEXT NOT NULL,
104
+ agent_id TEXT NOT NULL,
105
+ score INTEGER NOT NULL,
106
+ reasons TEXT,
107
+ status TEXT DEFAULT 'pending',
108
+ response TEXT,
109
+ concerned INTEGER DEFAULT 0,
110
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
111
+ responded_at TEXT,
112
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
113
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
114
+ );
115
+
116
+ CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
117
+ CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
118
+
119
+ CREATE TABLE IF NOT EXISTS agent_activity_status (
120
+ agent_id TEXT PRIMARY KEY,
121
+ activity_status TEXT DEFAULT 'idle',
122
+ current_file TEXT,
123
+ current_thread TEXT,
124
+ last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
125
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS revoked_agents (
129
+ agent_id TEXT PRIMARY KEY,
130
+ revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
131
+ revoked_by TEXT NOT NULL
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS working_files (
135
+ agent_id TEXT NOT NULL,
136
+ file_path TEXT NOT NULL,
137
+ started_at TEXT NOT NULL,
138
+ last_activity_at TEXT NOT NULL,
139
+ claim_until TEXT NOT NULL,
140
+ PRIMARY KEY (agent_id, file_path)
141
+ );
142
+ CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path);
143
+ CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until);
144
+
145
+ CREATE TABLE IF NOT EXISTS git_cochange (
146
+ file_a TEXT NOT NULL,
147
+ file_b TEXT NOT NULL,
148
+ count INTEGER NOT NULL,
149
+ total_commits INTEGER NOT NULL,
150
+ computed_at TEXT NOT NULL,
151
+ PRIMARY KEY (file_a, file_b),
152
+ CHECK (file_a < file_b)
153
+ );
154
+ CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a);
155
+ CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b);
156
+
157
+ CREATE TABLE IF NOT EXISTS git_cochange_meta (
158
+ k TEXT PRIMARY KEY,
159
+ v TEXT
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS layer_firings (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ thread_id TEXT,
165
+ layer TEXT NOT NULL,
166
+ score INTEGER NOT NULL,
167
+ agent_id TEXT,
168
+ fired_at TEXT DEFAULT CURRENT_TIMESTAMP
169
+ );
170
+ CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
171
+ CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
172
172
  `;
173
173
  function createBetterSqlite3(dataDir) {
174
174
  mkdirSync(dataDir, { recursive: true });
@@ -17,9 +17,9 @@ export class DependencyMapper {
17
17
  }
18
18
  setMap(map) {
19
19
  const db = getDb();
20
- const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
- VALUES (?, ?, ?, ?)
22
- ON CONFLICT(module_id) DO UPDATE SET
20
+ const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
+ VALUES (?, ?, ?, ?)
22
+ ON CONFLICT(module_id) DO UPDATE SET
23
23
  depends_on = excluded.depends_on, exports = excluded.exports, owners = excluded.owners`);
24
24
  withTransaction(db, () => {
25
25
  for (const [id, info] of Object.entries(map)) {
@@ -3,8 +3,8 @@ export class FileTracker {
3
3
  log(params) {
4
4
  const db = getDb();
5
5
  const module = this.fileToModule(params.file_path);
6
- db.prepare(`INSERT INTO file_activity
7
- (session_id, agent_id, agent_name, tool_name, file_path, module, content_hash, symbols_touched)
6
+ db.prepare(`INSERT INTO file_activity
7
+ (session_id, agent_id, agent_name, tool_name, file_path, module, content_hash, symbols_touched)
8
8
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module, params.content_hash || null, params.symbols_touched ? JSON.stringify(params.symbols_touched) : null);
9
9
  }
10
10
  getBySession(sessionId) {
@@ -13,11 +13,11 @@ export class FileTracker {
13
13
  }
14
14
  getHotFiles(sinceMinutes = 30) {
15
15
  const db = getDb();
16
- const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
17
- FROM file_activity
18
- WHERE created_at > datetime('now', '-' || ? || ' minutes')
19
- GROUP BY file_path
20
- HAVING COUNT(DISTINCT agent_id) > 1
16
+ const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
17
+ FROM file_activity
18
+ WHERE created_at > datetime('now', '-' || ? || ' minutes')
19
+ GROUP BY file_path
20
+ HAVING COUNT(DISTINCT agent_id) > 1
21
21
  ORDER BY agent_count DESC`).all(sinceMinutes);
22
22
  return rows.map((r) => ({
23
23
  file_path: r.file_path,
@@ -27,8 +27,8 @@ export class FileTracker {
27
27
  }
28
28
  checkFileConflict(filePath, agentId, withinMinutes = 30) {
29
29
  const db = getDb();
30
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
31
- WHERE file_path = ? AND agent_id != ?
30
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
31
+ WHERE file_path = ? AND agent_id != ?
32
32
  AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
33
33
  return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
34
34
  }
@@ -50,9 +50,9 @@ export class FileTracker {
50
50
  // the impact scorer only passes target_files + depends_on_files (typically
51
51
  // a handful of files per announce_work call).
52
52
  const placeholders = filePaths.map(() => "?").join(",");
53
- const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
54
- WHERE file_path IN (${placeholders})
55
- AND agent_id != ?
53
+ const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
54
+ WHERE file_path IN (${placeholders})
55
+ AND agent_id != ?
56
56
  AND created_at > datetime('now', '-' || ? || ' minutes')`).all(...filePaths, excludeAgentId, withinMinutes);
57
57
  for (const r of rows) {
58
58
  let set = index.get(r.file_path);
@@ -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 || "";
@@ -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,14 @@ 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({
393
403
  session_id: body.session_id,
394
404
  agent_id: body.agent_id,
395
405
  agent_name: body.agent_name,
396
406
  tool_name: body.tool_name,
397
- file_path: body.file_path,
407
+ file_path: filePath,
398
408
  content_hash: contentHash,
399
409
  symbols_touched: symbols,
400
410
  });
@@ -405,8 +415,17 @@ export async function handleRest(req, res, ctx) {
405
415
  json(res, { error: "agent_id and file_path required" }, 400);
406
416
  return;
407
417
  }
418
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
419
+ let filePath;
420
+ try {
421
+ filePath = normalizePath(repoRoot, body.file_path);
422
+ }
423
+ catch (err) {
424
+ json(res, { error: `invalid file_path: ${err.message}` }, 400);
425
+ return;
426
+ }
408
427
  const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
409
- services.workingFiles.start(body.agent_id, body.file_path, ttl);
428
+ services.workingFiles.start(body.agent_id, filePath, ttl);
410
429
  json(res, { ok: true });
411
430
  }
412
431
  else if (url === "/api/working-files/stop" && req.method === "POST") {
@@ -414,7 +433,16 @@ export async function handleRest(req, res, ctx) {
414
433
  json(res, { error: "agent_id and file_path required" }, 400);
415
434
  return;
416
435
  }
417
- services.workingFiles.stop(body.agent_id, body.file_path);
436
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
437
+ let filePath;
438
+ try {
439
+ filePath = normalizePath(repoRoot, body.file_path);
440
+ }
441
+ catch (err) {
442
+ json(res, { error: `invalid file_path: ${err.message}` }, 400);
443
+ return;
444
+ }
445
+ services.workingFiles.stop(body.agent_id, filePath);
418
446
  json(res, { ok: true });
419
447
  }
420
448
  else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
@@ -424,18 +452,33 @@ export async function handleRest(req, res, ctx) {
424
452
  : sinceParam.endsWith("d") ? parseInt(sinceParam) * 60 * 24
425
453
  : 60 * 24;
426
454
  const db = getDb();
427
- const layers = db.prepare(`SELECT layer, COUNT(*) AS fire_count, AVG(score) AS avg_score
428
- FROM layer_firings
429
- WHERE fired_at > datetime('now', '-' || ? || ' minutes')
430
- GROUP BY layer
455
+ const rows = db.prepare(`SELECT
456
+ lf.layer,
457
+ COUNT(*) AS fire_count,
458
+ AVG(lf.score) AS avg_score,
459
+ SUM(CASE WHEN json_extract(e.payload, '$.resolution_type') = 'auto_resolved' THEN 1 ELSE 0 END) AS auto_resolved,
460
+ SUM(CASE WHEN json_extract(e.payload, '$.resolution_type') = 'consensus' THEN 1 ELSE 0 END) AS consensus,
461
+ SUM(CASE WHEN json_extract(e.payload, '$.resolution_type') = 'timeout' THEN 1 ELSE 0 END) AS timeout_count,
462
+ SUM(CASE WHEN json_extract(e.payload, '$.resolution_type') IN ('agent_departure','closed') THEN 1 ELSE 0 END) AS cancelled
463
+ FROM layer_firings lf
464
+ LEFT JOIN events e
465
+ ON e.type = 'thread_resolved'
466
+ AND json_extract(e.payload, '$.thread_id') = lf.thread_id
467
+ WHERE lf.fired_at > datetime('now', '-' || ? || ' minutes')
468
+ GROUP BY lf.layer
431
469
  ORDER BY fire_count DESC`).all(sinceMin);
432
470
  json(res, {
433
471
  window: { since: sinceParam, now: new Date().toISOString() },
434
- layers: layers.map(l => ({
435
- layer: l.layer,
436
- fire_count: l.fire_count,
437
- avg_score: l.avg_score,
438
- outcomes: { auto_resolved: 0, consensus: 0, timeout: 0, cancelled: 0 },
472
+ layers: rows.map(r => ({
473
+ layer: r.layer,
474
+ fire_count: r.fire_count,
475
+ avg_score: r.avg_score,
476
+ outcomes: {
477
+ auto_resolved: r.auto_resolved,
478
+ consensus: r.consensus,
479
+ timeout: r.timeout_count,
480
+ cancelled: r.cancelled,
481
+ },
439
482
  })),
440
483
  });
441
484
  }