mcp-coordinator 0.2.1 → 0.4.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 (60) hide show
  1. package/README.md +846 -846
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1178 -1178
  4. package/dist/cli/dashboard.js +9 -5
  5. package/dist/cli/server/backup.d.ts +7 -0
  6. package/dist/cli/server/backup.js +162 -0
  7. package/dist/cli/server/index.js +5 -0
  8. package/dist/cli/server/restore.d.ts +2 -0
  9. package/dist/cli/server/restore.js +117 -0
  10. package/dist/cli/server/start.js +24 -1
  11. package/dist/cli/server/status.js +16 -23
  12. package/dist/src/agent-activity.js +6 -6
  13. package/dist/src/agent-registry.js +6 -6
  14. package/dist/src/announce-workflow.d.ts +52 -0
  15. package/dist/src/announce-workflow.js +91 -0
  16. package/dist/src/consultation.d.ts +22 -0
  17. package/dist/src/consultation.js +118 -45
  18. package/dist/src/database.js +126 -126
  19. package/dist/src/db-adapter.d.ts +30 -0
  20. package/dist/src/db-adapter.js +32 -1
  21. package/dist/src/dependency-map.js +5 -5
  22. package/dist/src/file-tracker.d.ts +10 -0
  23. package/dist/src/file-tracker.js +40 -8
  24. package/dist/src/http/handle-health.d.ts +23 -0
  25. package/dist/src/http/handle-health.js +86 -0
  26. package/dist/src/http/handle-rest.d.ts +23 -0
  27. package/dist/src/http/handle-rest.js +374 -0
  28. package/dist/src/http/utils.d.ts +15 -0
  29. package/dist/src/http/utils.js +39 -0
  30. package/dist/src/impact-scorer.js +87 -50
  31. package/dist/src/introspection.js +1 -1
  32. package/dist/src/metrics.d.ts +83 -0
  33. package/dist/src/metrics.js +162 -0
  34. package/dist/src/mqtt-bridge.d.ts +21 -0
  35. package/dist/src/mqtt-bridge.js +55 -5
  36. package/dist/src/mqtt-broker.d.ts +16 -0
  37. package/dist/src/mqtt-broker.js +16 -1
  38. package/dist/src/path-guard.d.ts +14 -0
  39. package/dist/src/path-guard.js +44 -0
  40. package/dist/src/reset-guard.d.ts +16 -0
  41. package/dist/src/reset-guard.js +24 -0
  42. package/dist/src/serve-http.d.ts +31 -1
  43. package/dist/src/serve-http.js +189 -446
  44. package/dist/src/server-setup.d.ts +2 -0
  45. package/dist/src/server-setup.js +25 -366
  46. package/dist/src/sse-emitter.d.ts +6 -0
  47. package/dist/src/sse-emitter.js +50 -2
  48. package/dist/src/tools/agents-tools.d.ts +8 -0
  49. package/dist/src/tools/agents-tools.js +46 -0
  50. package/dist/src/tools/consultation-tools.d.ts +21 -0
  51. package/dist/src/tools/consultation-tools.js +170 -0
  52. package/dist/src/tools/dependencies-tools.d.ts +8 -0
  53. package/dist/src/tools/dependencies-tools.js +27 -0
  54. package/dist/src/tools/files-tools.d.ts +8 -0
  55. package/dist/src/tools/files-tools.js +28 -0
  56. package/dist/src/tools/mqtt-tools.d.ts +9 -0
  57. package/dist/src/tools/mqtt-tools.js +33 -0
  58. package/dist/src/tools/status-tools.d.ts +8 -0
  59. package/dist/src/tools/status-tools.js +63 -0
  60. package/package.json +83 -80
@@ -3,7 +3,7 @@ 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 (session_id, agent_id, agent_name, tool_name, file_path, module)
6
+ db.prepare(`INSERT INTO file_activity (session_id, agent_id, agent_name, tool_name, file_path, module)
7
7
  VALUES (?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module);
8
8
  }
9
9
  getBySession(sessionId) {
@@ -12,11 +12,11 @@ export class FileTracker {
12
12
  }
13
13
  getHotFiles(sinceMinutes = 30) {
14
14
  const db = getDb();
15
- const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
16
- FROM file_activity
17
- WHERE created_at > datetime('now', '-' || ? || ' minutes')
18
- GROUP BY file_path
19
- HAVING COUNT(DISTINCT agent_id) > 1
15
+ const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
16
+ FROM file_activity
17
+ WHERE created_at > datetime('now', '-' || ? || ' minutes')
18
+ GROUP BY file_path
19
+ HAVING COUNT(DISTINCT agent_id) > 1
20
20
  ORDER BY agent_count DESC`).all(sinceMinutes);
21
21
  return rows.map((r) => ({
22
22
  file_path: r.file_path,
@@ -26,11 +26,43 @@ export class FileTracker {
26
26
  }
27
27
  checkFileConflict(filePath, agentId, withinMinutes = 30) {
28
28
  const db = getDb();
29
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
30
- WHERE file_path = ? AND agent_id != ?
29
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
30
+ WHERE file_path = ? AND agent_id != ?
31
31
  AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
32
32
  return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
33
33
  }
34
+ /**
35
+ * P2 perf: batch lookup of recent file→agents activity. Replaces N
36
+ * `checkFileConflict` calls (one per file) with a single SQL query, then
37
+ * builds an in-memory reverse index. The impact scorer uses this so its
38
+ * per-file inner loop is O(1) Map.get() rather than O(F) SQL round-trips.
39
+ *
40
+ * Excludes the calling agent (so the scorer doesn't flag the announcer
41
+ * against themselves). Returns Map<file_path, Set<agent_id>>.
42
+ */
43
+ getFileToAgentsIndex(filePaths, excludeAgentId, withinMinutes = 30) {
44
+ const index = new Map();
45
+ if (filePaths.length === 0)
46
+ return index;
47
+ const db = getDb();
48
+ // Dynamic IN-list — better-sqlite3 binds each ? positionally. Cheap because
49
+ // the impact scorer only passes target_files + depends_on_files (typically
50
+ // a handful of files per announce_work call).
51
+ const placeholders = filePaths.map(() => "?").join(",");
52
+ const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
53
+ WHERE file_path IN (${placeholders})
54
+ AND agent_id != ?
55
+ AND created_at > datetime('now', '-' || ? || ' minutes')`).all(...filePaths, excludeAgentId, withinMinutes);
56
+ for (const r of rows) {
57
+ let set = index.get(r.file_path);
58
+ if (!set) {
59
+ set = new Set();
60
+ index.set(r.file_path, set);
61
+ }
62
+ set.add(r.agent_id);
63
+ }
64
+ return index;
65
+ }
34
66
  fileToModule(filePath) {
35
67
  // Strip leading / so "/server/src/x.ts" and "server/src/x.ts" produce the
36
68
  // same module name. Without this, split("/") on an absolute path yields
@@ -0,0 +1,23 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { CoordinatorServices } from "../server-setup.js";
3
+ /**
4
+ * Liveness probe — process is alive. Always returns 200 with no dep checks
5
+ * so orchestrators don't restart the pod over transient downstream failures.
6
+ */
7
+ export declare function handleLivez(_req: IncomingMessage, res: ServerResponse): void;
8
+ /**
9
+ * Readiness probe — downstream deps must all be green for the LB to route
10
+ * traffic here. 503 when any check fails so the pod is drained until ready.
11
+ *
12
+ * Each check is wrapped in try/catch so a thrown DB/MQTT error becomes a
13
+ * structured `{ok:false,error:"…"}` instead of a 500. The response shape is
14
+ * identical between 200 and 503 so consumers can parse uniformly.
15
+ */
16
+ export declare function handleReadyz(_req: IncomingMessage, res: ServerResponse, services: Pick<CoordinatorServices, "mqttBridge">): void;
17
+ /**
18
+ * Backwards-compatible alias. The original /health route returned a fixed
19
+ * {status:"ok",version} payload with no dep checks; semantically that is a
20
+ * liveness probe, so we delegate. Anything that polled /health for "is the
21
+ * process up" continues to work without changes.
22
+ */
23
+ export declare function handleHealth(req: IncomingMessage, res: ServerResponse): void;
@@ -0,0 +1,86 @@
1
+ import { getDb } from "../database.js";
2
+ import { json } from "./utils.js";
3
+ import { getVersion } from "../../cli/version.js";
4
+ /**
5
+ * v0.4 Operability: Kubernetes-style health probes.
6
+ *
7
+ * - /livez → is the process alive? Used by an orchestrator (k8s, systemd,
8
+ * docker swarm) to decide whether to restart the pod. MUST NOT
9
+ * check downstream deps; an unreachable DB does not mean the
10
+ * coordinator process should be killed and restarted.
11
+ *
12
+ * - /readyz → are downstream deps ready? Used by a load balancer / service
13
+ * mesh to decide whether to add this pod to rotation. Returns 503
14
+ * when the DB or MQTT broker is not reachable so the LB drains
15
+ * traffic until the coordinator can actually serve it.
16
+ *
17
+ * - /health → backwards-compat alias for /livez. The original stub returned
18
+ * {status:"ok",version} unconditionally; preserving alive-only
19
+ * semantics keeps existing dashboards and uptime probes green
20
+ * without forcing them to migrate.
21
+ */
22
+ const STARTED_AT_MS = Date.now();
23
+ const VERSION = getVersion();
24
+ function uptimeSeconds() {
25
+ return Math.floor((Date.now() - STARTED_AT_MS) / 1000);
26
+ }
27
+ /**
28
+ * Liveness probe — process is alive. Always returns 200 with no dep checks
29
+ * so orchestrators don't restart the pod over transient downstream failures.
30
+ */
31
+ export function handleLivez(_req, res) {
32
+ json(res, {
33
+ status: "alive",
34
+ uptime_seconds: uptimeSeconds(),
35
+ version: VERSION,
36
+ });
37
+ }
38
+ /**
39
+ * Readiness probe — downstream deps must all be green for the LB to route
40
+ * traffic here. 503 when any check fails so the pod is drained until ready.
41
+ *
42
+ * Each check is wrapped in try/catch so a thrown DB/MQTT error becomes a
43
+ * structured `{ok:false,error:"…"}` instead of a 500. The response shape is
44
+ * identical between 200 and 503 so consumers can parse uniformly.
45
+ */
46
+ export function handleReadyz(_req, res, services) {
47
+ const checks = {
48
+ db: { ok: false },
49
+ mqtt: { ok: false },
50
+ };
51
+ try {
52
+ // Cheapest possible round-trip that exercises the connection without
53
+ // touching application tables. Throws if the handle is closed or the
54
+ // file is locked beyond busy_timeout.
55
+ getDb().prepare("SELECT 1").get();
56
+ checks.db.ok = true;
57
+ }
58
+ catch (err) {
59
+ checks.db.error = err.message;
60
+ }
61
+ try {
62
+ if (services.mqttBridge.isConnected()) {
63
+ checks.mqtt.ok = true;
64
+ }
65
+ else {
66
+ checks.mqtt.error = "not connected";
67
+ }
68
+ }
69
+ catch (err) {
70
+ checks.mqtt.error = err.message;
71
+ }
72
+ const allOk = checks.db.ok && checks.mqtt.ok;
73
+ json(res, {
74
+ status: allOk ? "ready" : "not_ready",
75
+ checks,
76
+ }, allOk ? 200 : 503);
77
+ }
78
+ /**
79
+ * Backwards-compatible alias. The original /health route returned a fixed
80
+ * {status:"ok",version} payload with no dep checks; semantically that is a
81
+ * liveness probe, so we delegate. Anything that polled /health for "is the
82
+ * process up" continues to work without changes.
83
+ */
84
+ export function handleHealth(req, res) {
85
+ return handleLivez(req, res);
86
+ }
@@ -0,0 +1,23 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { CoordinatorServices } from "../server-setup.js";
3
+ import type { Logger } from "../logger.js";
4
+ /**
5
+ * S1: REST router extracted from serve-http.ts. Was a 382-line `handleRest`
6
+ * function inside startServer's closure with module-scope captures for
7
+ * services, httpLog, currentRunConfig, AUTH_ENABLED, etc.
8
+ *
9
+ * The body is unchanged — only ambient closure references became explicit
10
+ * fields on RestContext. parseBody/json moved to ./utils.js to share between
11
+ * REST + SSE + auth handlers.
12
+ *
13
+ * The currentRunConfig mutable state stays in serve-http.ts (single source
14
+ * of truth) and is exposed via getRunConfig/setRunConfig on the context.
15
+ */
16
+ export interface RestContext {
17
+ services: CoordinatorServices;
18
+ httpLog: Logger;
19
+ authEnabled: boolean;
20
+ getRunConfig: () => Record<string, unknown> | null;
21
+ setRunConfig: (cfg: Record<string, unknown> | null) => void;
22
+ }
23
+ export declare function handleRest(req: IncomingMessage, res: ServerResponse, ctx: RestContext): Promise<void>;
@@ -0,0 +1,374 @@
1
+ import { getDb } from "../database.js";
2
+ import { runCommonAnnounceFlow } from "../announce-workflow.js";
3
+ import { canResetDb } from "../reset-guard.js";
4
+ import { parseBody, json } from "./utils.js";
5
+ export async function handleRest(req, res, ctx) {
6
+ const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
7
+ const url = req.url || "";
8
+ const body = await parseBody(req);
9
+ const agentId = body.agent_id;
10
+ // Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
11
+ // to keep the info log focused on coordination events (announce, claim, resolve, etc).
12
+ const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
13
+ // Note: /api/quota/refresh is NOT in the poll list — it's a manual user
14
+ // action and deserves an info-level log for auditability.
15
+ if (isPoll) {
16
+ httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
17
+ }
18
+ else {
19
+ httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
20
+ }
21
+ const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
22
+ if (url === "/api/register") {
23
+ const { agent_id, name, modules } = body;
24
+ const agent = registry.register(agent_id, name, modules || []);
25
+ sseEmitter.emit("agent_online", { agent_id, name, modules });
26
+ json(res, agent);
27
+ }
28
+ else if (url === "/api/session-start") {
29
+ const online = registry.listOnline();
30
+ const openThreads = consultation.listThreads({ status: "open" });
31
+ const hotFiles = fileTracker.getHotFiles(30);
32
+ const briefing = [
33
+ `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
34
+ `Consultations ouvertes: ${openThreads.length}`,
35
+ `Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
36
+ ].join("\n");
37
+ json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
38
+ }
39
+ else if (url === "/api/session-stop") {
40
+ const { agent_id } = body;
41
+ registry.setOffline(agent_id);
42
+ activityTracker.reportOffline(agent_id);
43
+ consultation.handleAgentDeparture(agent_id);
44
+ sseEmitter.emit("agent_offline", { agent_id });
45
+ json(res, { ok: true });
46
+ }
47
+ else if (url === "/api/check-conflict") {
48
+ const { file, agent_id } = body;
49
+ const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
50
+ const warnings = [];
51
+ if (conflict.conflict) {
52
+ warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
53
+ }
54
+ json(res, { conflict: conflict.conflict, warnings });
55
+ }
56
+ else if (url === "/api/log-file") {
57
+ const { session_id, agent_id, agent_name, tool_name, file } = body;
58
+ fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
59
+ activityTracker.reportFileActivity(agent_id, file);
60
+ sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
61
+ json(res, { ok: true });
62
+ }
63
+ else if (url === "/api/announce") {
64
+ const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
65
+ const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
66
+ const agentInfo = registry.get(agent_id);
67
+ // S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
68
+ // impact_scored + introspection SSE, plan-quality downgrade event). Same
69
+ // function used by the MCP announce_work tool path.
70
+ const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
71
+ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
72
+ });
73
+ // REST-specific thread_opened SSE shape (different field set than MCP — kept
74
+ // divergent because consumers may depend on this exact contract).
75
+ sseEmitter.emit("thread_opened", {
76
+ thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
77
+ target_modules, target_files, expected_respondents: respondents,
78
+ conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
79
+ created_at: updated.created_at,
80
+ mode: planQuality.mode,
81
+ plan: plan || null,
82
+ plan_quality: planQuality,
83
+ });
84
+ json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
85
+ }
86
+ else if (url === "/api/post-to-thread") {
87
+ const { thread_id, agent_id, agent_name, type, content } = body;
88
+ // Pre-check the thread so we can return actionable status codes instead
89
+ // of always-500 on any error. The client uses the status to decide
90
+ // whether to warn (unexpected) or silently skip (normal race).
91
+ const targetThread = consultation.getThread(thread_id);
92
+ if (!targetThread) {
93
+ json(res, { error: "thread_not_found", thread_id }, 404);
94
+ return;
95
+ }
96
+ if (targetThread.status === "cancelled") {
97
+ json(res, { error: "thread_cancelled", thread_id }, 410);
98
+ return;
99
+ }
100
+ const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
101
+ const thread = consultation.getThread(thread_id);
102
+ sseEmitter.emit("message_posted", {
103
+ thread_id, agent_id, agent_name: agent_name || agent_id,
104
+ type, content, round: thread?.round || 1,
105
+ token_estimate: msg.token_estimate || 0,
106
+ });
107
+ json(res, msg);
108
+ }
109
+ else if (url === "/api/token-usage") {
110
+ // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
111
+ // and reports can pinpoint where tokens are being burned.
112
+ const payload = body;
113
+ sseEmitter.emit("token_usage", payload);
114
+ json(res, { ok: true });
115
+ }
116
+ else if (url === "/api/unclaim-task") {
117
+ const { thread_id, agent_id } = body;
118
+ if (!thread_id || !agent_id) {
119
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
120
+ return;
121
+ }
122
+ const db = getDb();
123
+ // F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
124
+ // to "poisoned" so no agent claims it again — prevents the tight
125
+ // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
126
+ // Only the claiming agent can unclaim to prevent cross-agent interference.
127
+ const POISON_THRESHOLD = 2;
128
+ 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);
129
+ let poisoned = false;
130
+ if (result.changes === 1) {
131
+ const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
132
+ if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
133
+ db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
134
+ poisoned = true;
135
+ httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
136
+ }
137
+ }
138
+ json(res, { success: result.changes === 1, poisoned });
139
+ }
140
+ else if (url === "/api/claim-task") {
141
+ const { thread_id, agent_id } = body;
142
+ if (!thread_id || !agent_id) {
143
+ json(res, { success: false, error: "thread_id and agent_id required" }, 400);
144
+ return;
145
+ }
146
+ const db = getDb();
147
+ // Only claim threads with status='open' — poisoned threads are filtered out
148
+ // automatically because the status filter excludes them.
149
+ // Directed-dispatch constraint: if assigned_to is set, only that specific
150
+ // agent can claim; NULL keeps the original open-pool semantics.
151
+ 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);
152
+ if (result.changes === 1) {
153
+ mqttBridge.publishTaskClaimed(thread_id, agent_id);
154
+ sseEmitter.emit("task_claimed", { thread_id, agent_id });
155
+ json(res, { success: true });
156
+ }
157
+ else {
158
+ const thread = consultation.getThread(thread_id);
159
+ // Surface the assigned_to in the 'why not' response so clients can
160
+ // distinguish "already claimed by X" from "reserved for Y".
161
+ json(res, {
162
+ success: false,
163
+ claimed_by: thread?.claimed_by || null,
164
+ assigned_to: thread?.assigned_to || null,
165
+ status: thread?.status,
166
+ });
167
+ }
168
+ }
169
+ else if (url === "/api/propose-resolution") {
170
+ const { thread_id, agent_id, summary } = body;
171
+ const agentInfo = registry.get(agent_id);
172
+ consultation.proposeResolution(thread_id, agent_id, summary);
173
+ sseEmitter.emit("resolution_proposed", {
174
+ thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
175
+ });
176
+ json(res, consultation.getThread(thread_id));
177
+ mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
178
+ }
179
+ else if (url === "/api/approve-resolution") {
180
+ const { thread_id, agent_id } = body;
181
+ const agentInfo = registry.get(agent_id);
182
+ consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
183
+ const t = consultation.getThread(thread_id);
184
+ json(res, t);
185
+ }
186
+ else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
187
+ const threadId = url.split("/")[3];
188
+ const thread = consultation.getThreadWithMessages(threadId);
189
+ if (!thread) {
190
+ json(res, { error: "not found" }, 404);
191
+ }
192
+ else {
193
+ json(res, {
194
+ status: thread.thread.status,
195
+ messages: thread.messages,
196
+ resolution_summary: thread.thread.resolution_summary,
197
+ expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
198
+ });
199
+ }
200
+ }
201
+ else if (url === "/api/threads-active") {
202
+ const open = consultation.listThreads({ status: "open" });
203
+ const resolving = consultation.listThreads({ status: "resolving" });
204
+ json(res, [...open, ...resolving]);
205
+ }
206
+ else if (url === "/api/hot-files") {
207
+ const { since_minutes } = body;
208
+ json(res, fileTracker.getHotFiles(since_minutes || 30));
209
+ }
210
+ else if (url === "/api/quota") {
211
+ // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
212
+ // Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
213
+ // 503 as "quota unknown = proceed" (fail-open) per the project decision.
214
+ const info = await quotaCache.get();
215
+ if (!info) {
216
+ const status = quotaCache.snapshot();
217
+ json(res, {
218
+ error: "quota unavailable",
219
+ reason: status.lastError,
220
+ cooldown_until: status.cooldownUntil,
221
+ }, 503);
222
+ }
223
+ else {
224
+ json(res, {
225
+ five_hour: info.fiveHour,
226
+ seven_day: info.sevenDay,
227
+ seven_day_sonnet: info.sevenDaySonnet,
228
+ fetched_at: info.fetchedAt,
229
+ });
230
+ }
231
+ }
232
+ else if (url === "/api/quota/refresh") {
233
+ // Force-refresh the cache, bypassing the TTL. Used by the dashboard's
234
+ // manual refresh button. The underlying quotaCache.refresh() is single-
235
+ // flight-deduped, so mashing the button doesn't stack parallel fetches.
236
+ // The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
237
+ // dashboard receives the update through the normal channel too — this
238
+ // endpoint only exists for "give me the answer now" semantics.
239
+ const info = await quotaCache.refresh();
240
+ if (!info) {
241
+ const status = quotaCache.snapshot();
242
+ json(res, {
243
+ error: "quota unavailable",
244
+ reason: status.lastError,
245
+ cooldown_until: status.cooldownUntil,
246
+ }, 503);
247
+ }
248
+ else {
249
+ json(res, {
250
+ five_hour: info.fiveHour,
251
+ seven_day: info.sevenDay,
252
+ seven_day_sonnet: info.sevenDaySonnet,
253
+ fetched_at: info.fetchedAt,
254
+ });
255
+ }
256
+ }
257
+ else if (url === "/api/introspection-response") {
258
+ const { introspection_id, concerned, reason } = body;
259
+ const intro = introspection.respond(introspection_id, concerned, reason);
260
+ // If concerned, add to thread's expected_respondents
261
+ if (concerned && intro) {
262
+ const db = getDb();
263
+ const thread = consultation.getThread(intro.thread_id);
264
+ if (thread && (thread.status === "open" || thread.status === "resolving")) {
265
+ const respondents = JSON.parse(thread.expected_respondents || "[]");
266
+ if (!respondents.includes(intro.agent_id)) {
267
+ respondents.push(intro.agent_id);
268
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
269
+ .run(JSON.stringify(respondents), thread.id);
270
+ }
271
+ }
272
+ }
273
+ const agentInfo = registry.get(intro?.agent_id || "");
274
+ sseEmitter.emit("introspection_completed", {
275
+ introspection_id, thread_id: intro?.thread_id,
276
+ agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
277
+ concerned, reason,
278
+ });
279
+ json(res, intro);
280
+ }
281
+ else if (url?.startsWith("/api/pending-introspections")) {
282
+ const urlObj = new URL(url, "http://localhost");
283
+ const agent_id = urlObj.searchParams.get("agent_id") || "";
284
+ const pending = introspection.getPending(agent_id);
285
+ json(res, pending);
286
+ }
287
+ else if (url === "/api/run-config") {
288
+ if (req.method === "POST") {
289
+ setRunConfig(body);
290
+ sseEmitter.emit("run_config", getRunConfig());
291
+ json(res, { ok: true });
292
+ }
293
+ else {
294
+ json(res, getRunConfig() || { active: false });
295
+ }
296
+ }
297
+ else if (url === "/api/reset") {
298
+ // B4 fix: gate destructive reset when AUTH is disabled.
299
+ // When AUTH_ENABLED=true, ADMIN_ONLY_ROUTES already enforced upstream
300
+ // by authenticateRequest (see auth.ts). This guard covers the AUTH off case.
301
+ if (!canResetDb(process.env, authEnabled)) {
302
+ json(res, {
303
+ error: "Forbidden: /api/reset requires NODE_ENV=test, COORDINATOR_ALLOW_RESET=true, or COORDINATOR_AUTH_ENABLED with admin token",
304
+ }, 403);
305
+ return;
306
+ }
307
+ // Reset all tables for clean test run (disable FK checks to avoid ordering issues)
308
+ const db = getDb();
309
+ db.exec("PRAGMA foreign_keys = OFF");
310
+ db.exec("DELETE FROM introspections");
311
+ db.exec("DELETE FROM events");
312
+ db.exec("DELETE FROM thread_messages");
313
+ db.exec("DELETE FROM threads");
314
+ db.exec("DELETE FROM action_summaries");
315
+ db.exec("DELETE FROM file_activity");
316
+ db.exec("DELETE FROM agent_activity_status");
317
+ db.exec("DELETE FROM dependency_map");
318
+ db.exec("DELETE FROM agents");
319
+ db.exec("DELETE FROM revoked_agents");
320
+ db.exec("PRAGMA foreign_keys = ON");
321
+ setRunConfig(null);
322
+ json(res, { ok: true });
323
+ }
324
+ else if (url === "/api/check-interrupt") {
325
+ const { agent_id } = body;
326
+ // Check for threads where this agent is an expected respondent and hasn't posted yet.
327
+ // Covers both open threads (waiting for initial response) and resolving threads
328
+ // (waiting for approval/contest of a proposed resolution).
329
+ const pendingThreads = [
330
+ ...consultation.listThreads({ status: "open" }),
331
+ ...consultation.listThreads({ status: "resolving" }),
332
+ ].filter((t) => {
333
+ const respondents = JSON.parse(t.expected_respondents || "[]");
334
+ return respondents.includes(agent_id);
335
+ });
336
+ if (pendingThreads.length > 0) {
337
+ const details = pendingThreads.map((t) => ({
338
+ thread_id: t.id,
339
+ subject: t.subject,
340
+ initiator_id: t.initiator_id,
341
+ status: t.status,
342
+ target_files: JSON.parse(t.target_files || "[]"),
343
+ }));
344
+ json(res, { interrupt: true, threads: details });
345
+ }
346
+ else {
347
+ json(res, { interrupt: false });
348
+ }
349
+ }
350
+ else if (url?.startsWith("/api/agent-status/")) {
351
+ const aid = url.split("/")[3];
352
+ const agent = registry.get(aid);
353
+ if (!agent) {
354
+ json(res, { registered: false, status: "unknown" });
355
+ }
356
+ else {
357
+ const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
358
+ json(res, { registered: true, status: agent.status, activity: activity.activity_status });
359
+ }
360
+ }
361
+ else if (url === "/api/status") {
362
+ const online = registry.listOnline();
363
+ const openThreads = consultation.listThreads({ status: "open" });
364
+ json(res, {
365
+ online: online.length,
366
+ open_threads: openThreads.length,
367
+ hot_files: fileTracker.getHotFiles(30).length,
368
+ mqtt: services.mqttBridge.isConnected(),
369
+ });
370
+ }
371
+ else {
372
+ json(res, { error: "not found" }, 404);
373
+ }
374
+ }
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ /**
3
+ * S1: shared HTTP helpers extracted from serve-http.ts.
4
+ * parseBody, json, decodeJwtPayload, safeEqual.
5
+ */
6
+ export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
7
+ export declare function json(res: ServerResponse, data: unknown, status?: number): void;
8
+ /**
9
+ * Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
10
+ * ourselves (to read the `exp` claim before returning it to the client). Real
11
+ * verification of inbound tokens happens in `authenticateRequest` via
12
+ * jose.jwtVerify().
13
+ */
14
+ export declare function decodeJwtPayload(token: string): Record<string, unknown>;
15
+ export declare function safeEqual(a: string, b: string): boolean;
@@ -0,0 +1,39 @@
1
+ import { timingSafeEqual } from "crypto";
2
+ /**
3
+ * S1: shared HTTP helpers extracted from serve-http.ts.
4
+ * parseBody, json, decodeJwtPayload, safeEqual.
5
+ */
6
+ export function parseBody(req) {
7
+ return new Promise((resolve, reject) => {
8
+ let body = "";
9
+ req.on("data", (chunk) => (body += chunk.toString()));
10
+ req.on("end", () => {
11
+ try {
12
+ resolve(body ? JSON.parse(body) : {});
13
+ }
14
+ catch {
15
+ reject(new Error("Invalid JSON"));
16
+ }
17
+ });
18
+ req.on("error", reject);
19
+ });
20
+ }
21
+ export function json(res, data, status = 200) {
22
+ res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
23
+ res.end(JSON.stringify(data));
24
+ }
25
+ /**
26
+ * Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
27
+ * ourselves (to read the `exp` claim before returning it to the client). Real
28
+ * verification of inbound tokens happens in `authenticateRequest` via
29
+ * jose.jwtVerify().
30
+ */
31
+ export function decodeJwtPayload(token) {
32
+ const base64url = token.split(".")[1];
33
+ return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
34
+ }
35
+ export function safeEqual(a, b) {
36
+ if (a.length !== b.length)
37
+ return false;
38
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
39
+ }