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
@@ -1,14 +1,18 @@
1
1
  import { Command } from "commander";
2
- import { exec } from "child_process";
2
+ import { spawn } from "child_process";
3
3
  export function createDashboardCommand() {
4
4
  return new Command("dashboard")
5
5
  .description("Open the real-time dashboard")
6
6
  .action(() => {
7
7
  const url = "http://localhost:3100/dashboard";
8
8
  console.log(`Dashboard: ${url}`);
9
- const cmd = process.platform === "darwin"
10
- ? `open "${url}"`
11
- : `xdg-open "${url}" 2>/dev/null`;
12
- exec(cmd, () => { });
9
+ // Use spawn with an argv array (no shell) so the URL is never
10
+ // interpolated into a shell command — eliminates command-injection risk.
11
+ const opener = process.platform === "darwin" ? "open"
12
+ : process.platform === "win32" ? "explorer.exe"
13
+ : "xdg-open";
14
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
15
+ child.on("error", () => { });
16
+ child.unref();
13
17
  });
14
18
  }
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Check whether the coordinator daemon appears to be running.
4
+ * Returns the pid if alive, or null otherwise.
5
+ */
6
+ export declare function getRunningCoordinatorPid(configDir: string): number | null;
7
+ export declare function createServerBackupCommand(): Command;
@@ -0,0 +1,162 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { join, resolve, basename } from "path";
4
+ import { create as tarCreate } from "tar";
5
+ import { getConfigDir, loadConfig } from "../config.js";
6
+ /**
7
+ * Format a timestamp suitable for filenames: YYYY-MM-DD-HHMMSS (UTC).
8
+ */
9
+ function timestampSlug(date = new Date()) {
10
+ const pad = (n) => String(n).padStart(2, "0");
11
+ return (`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
12
+ `-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`);
13
+ }
14
+ /**
15
+ * Returns true if a process with the given pid is currently alive.
16
+ * On both POSIX and Windows, sending signal 0 acts as a liveness probe
17
+ * (it raises ESRCH if the process is dead, EPERM if alive but not ours).
18
+ */
19
+ function isProcessAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ }
24
+ catch (err) {
25
+ // EPERM means the process exists but we lack permission — still "alive".
26
+ if (err.code === "EPERM")
27
+ return true;
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * Check whether the coordinator daemon appears to be running.
33
+ * Returns the pid if alive, or null otherwise.
34
+ */
35
+ export function getRunningCoordinatorPid(configDir) {
36
+ const pidPath = join(configDir, "server.pid");
37
+ if (!existsSync(pidPath))
38
+ return null;
39
+ const raw = readFileSync(pidPath, "utf-8").trim();
40
+ const pid = parseInt(raw, 10);
41
+ if (Number.isNaN(pid))
42
+ return null;
43
+ return isProcessAlive(pid) ? pid : null;
44
+ }
45
+ /**
46
+ * Recursively walk a directory and yield relative file paths.
47
+ * Used purely for reporting (file count) — tar handles the actual packing.
48
+ */
49
+ async function countFiles(root) {
50
+ const { readdir, stat } = await import("fs/promises");
51
+ let count = 0;
52
+ const walk = async (dir) => {
53
+ const entries = await readdir(dir, { withFileTypes: true });
54
+ for (const entry of entries) {
55
+ const full = join(dir, entry.name);
56
+ if (entry.isDirectory()) {
57
+ await walk(full);
58
+ }
59
+ else if (entry.isFile()) {
60
+ count += 1;
61
+ }
62
+ }
63
+ };
64
+ if (existsSync(root))
65
+ await walk(root);
66
+ return count;
67
+ }
68
+ /**
69
+ * Build the list of entries (relative to configDir) we want to include in the
70
+ * tarball. We deliberately keep the layout flat — the same paths used at
71
+ * runtime — so a `tar -xzf` into `~/.mcp-coordinator/` is a valid restore.
72
+ *
73
+ * NOTE on live backups: this command refuses to run while the coordinator is
74
+ * up because better-sqlite3's WAL journal may have uncommitted writes that
75
+ * file-copy will miss. For online backups, switch to SQLite's Online Backup
76
+ * API (`db.backup(path)` from better-sqlite3) and snapshot config.json
77
+ * separately — see docs/superpowers/working/v04/backup-integration.md.
78
+ */
79
+ function buildEntries(configDir, dataDirAbsolute) {
80
+ const entries = [];
81
+ if (existsSync(join(configDir, "config.json")))
82
+ entries.push("config.json");
83
+ // The data dir might live outside ~/.mcp-coordinator (custom --data-dir).
84
+ // tar's `cwd` option can only point at one directory, so when the data dir
85
+ // is non-default we pack it under its absolute path inside the archive
86
+ // (preserving structure for round-trip restore via --data-dir).
87
+ const defaultDataDir = join(configDir, "data");
88
+ if (resolve(dataDirAbsolute) === resolve(defaultDataDir) && existsSync(defaultDataDir)) {
89
+ entries.push("data");
90
+ }
91
+ return entries;
92
+ }
93
+ export function createServerBackupCommand() {
94
+ return new Command("backup")
95
+ .description("Snapshot the coordinator config + SQLite database to a tar.gz archive")
96
+ .option("--output <path>", "Output tarball path (default ./mcp-coordinator-backup-<ts>.tar.gz)")
97
+ .option("--data-dir <path>", "Data directory to back up (overrides config.server.data_dir)")
98
+ .option("--force", "Skip the running-coordinator safety check")
99
+ .action(async (opts) => {
100
+ const configDir = getConfigDir();
101
+ const config = loadConfig(configDir);
102
+ const dataDir = resolve(opts.dataDir ?? process.env.COORDINATOR_DATA_DIR ?? config.server.data_dir);
103
+ // Safety: refuse when the daemon is up. WAL writes might be in flight.
104
+ const runningPid = getRunningCoordinatorPid(configDir);
105
+ if (runningPid !== null && !opts.force) {
106
+ console.error(`Coordinator is running (PID ${runningPid}).`);
107
+ console.error("Refusing to back up: live SQLite WAL writes may be in flight.");
108
+ console.error("Either stop it first ('mcp-coordinator server stop') or pass --force.");
109
+ process.exit(1);
110
+ }
111
+ if (!existsSync(configDir)) {
112
+ console.error(`No coordinator config directory at ${configDir} — nothing to back up.`);
113
+ process.exit(1);
114
+ }
115
+ const ts = timestampSlug();
116
+ const outputPath = resolve(opts.output ?? join(process.cwd(), `mcp-coordinator-backup-${ts}.tar.gz`));
117
+ const defaultDataDir = join(configDir, "data");
118
+ const dataIsCustom = resolve(dataDir) !== resolve(defaultDataDir);
119
+ // Pack ~/.mcp-coordinator entries from configDir as cwd.
120
+ const entries = buildEntries(configDir, dataDir);
121
+ // For custom data dirs, pack them under their absolute path so restore
122
+ // can reproduce the original location (or be redirected with --data-dir).
123
+ const customDataEntries = [];
124
+ if (dataIsCustom && existsSync(dataDir)) {
125
+ customDataEntries.push({ cwd: resolve(dataDir, ".."), entry: basename(dataDir) });
126
+ }
127
+ if (entries.length === 0 && customDataEntries.length === 0) {
128
+ console.error("Nothing to back up: no config.json and no data directory found.");
129
+ process.exit(1);
130
+ }
131
+ // First archive pass: config + default data (if any).
132
+ if (entries.length > 0) {
133
+ await tarCreate({ gzip: true, file: outputPath, cwd: configDir, portable: true }, entries);
134
+ }
135
+ // Second pass for a custom data dir — append into the same archive.
136
+ // tar's gzip mode doesn't support append, so we re-create instead by
137
+ // extracting the previous entries and re-packing. Simpler path: emit
138
+ // a sibling .data.tar.gz when custom dir is in use, and document it.
139
+ if (customDataEntries.length > 0) {
140
+ const dataArchive = outputPath.replace(/\.tar\.gz$/, ".data.tar.gz");
141
+ for (const { cwd, entry } of customDataEntries) {
142
+ await tarCreate({ gzip: true, file: dataArchive, cwd, portable: true }, [entry]);
143
+ }
144
+ console.log(`Custom data dir packed separately: ${dataArchive}`);
145
+ }
146
+ // outputPath only exists if entries.length > 0; report on whichever
147
+ // archive(s) we actually produced.
148
+ const reportPath = entries.length > 0
149
+ ? outputPath
150
+ : outputPath.replace(/\.tar\.gz$/, ".data.tar.gz");
151
+ const sizeBytes = existsSync(reportPath) ? statSync(reportPath).size : 0;
152
+ const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
153
+ const fileCount = (existsSync(join(configDir, "config.json")) ? 1 : 0) +
154
+ (await countFiles(dataIsCustom ? dataDir : defaultDataDir));
155
+ console.log("Backup complete.");
156
+ console.log(` Archive: ${reportPath}`);
157
+ console.log(` Size: ${sizeMB} MB (${sizeBytes} bytes)`);
158
+ console.log(` Files: ${fileCount}`);
159
+ console.log(` ConfigDir: ${configDir}`);
160
+ console.log(` DataDir: ${dataDir}${dataIsCustom ? " (custom)" : ""}`);
161
+ });
162
+ }
@@ -3,11 +3,16 @@ import { createServerStartCommand } from "./start.js";
3
3
  import { createServerStopCommand } from "./stop.js";
4
4
  import { createServerStatusCommand } from "./status.js";
5
5
  import { createServerLogsCommand } from "./logs.js";
6
+ import { createServerBackupCommand } from "./backup.js";
7
+ import { createServerRestoreCommand } from "./restore.js";
6
8
  export function createServerProgram() {
7
9
  const server = new Command("server").description("Manage the coordination server");
8
10
  server.addCommand(createServerStartCommand());
9
11
  server.addCommand(createServerStopCommand());
10
12
  server.addCommand(createServerStatusCommand());
11
13
  server.addCommand(createServerLogsCommand());
14
+ // v0.4 Operability
15
+ server.addCommand(createServerBackupCommand());
16
+ server.addCommand(createServerRestoreCommand());
12
17
  return server;
13
18
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createServerRestoreCommand(): Command;
@@ -0,0 +1,117 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, mkdirSync, renameSync, statSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { extract as tarExtract, list as tarList } from "tar";
5
+ import { getConfigDir } from "../config.js";
6
+ import { getRunningCoordinatorPid } from "./backup.js";
7
+ function timestampSlug(date = new Date()) {
8
+ const pad = (n) => String(n).padStart(2, "0");
9
+ return (`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
10
+ `-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`);
11
+ }
12
+ /**
13
+ * Inspect the tarball without extracting and return the list of top-level
14
+ * entries (file or dir names). Used to validate structure before touching
15
+ * the user's existing config dir.
16
+ */
17
+ async function listTarballEntries(tarPath) {
18
+ const entries = [];
19
+ await tarList({
20
+ file: tarPath,
21
+ onReadEntry: (entry) => {
22
+ // Trailing slash on dirs — strip it, take the first path segment only.
23
+ const head = entry.path.replace(/\\/g, "/").split("/")[0];
24
+ if (head && !entries.includes(head))
25
+ entries.push(head);
26
+ },
27
+ });
28
+ return entries;
29
+ }
30
+ export function createServerRestoreCommand() {
31
+ return new Command("restore")
32
+ .description("Restore a coordinator config + database snapshot from a tar.gz archive")
33
+ .argument("<tarball>", "Path to the backup .tar.gz produced by 'mcp-coordinator server backup'")
34
+ .option("--force", "Skip the running-coordinator safety check")
35
+ .option("--no-backup", "Do not snapshot the existing config dir before overwriting")
36
+ .option("--data-dir <path>", "Override data directory (rarely needed)")
37
+ .action(async (tarballArg, opts) => {
38
+ const tarPath = resolve(tarballArg);
39
+ if (!existsSync(tarPath)) {
40
+ console.error(`Tarball not found: ${tarPath}`);
41
+ process.exit(1);
42
+ }
43
+ const tarStat = statSync(tarPath);
44
+ if (!tarStat.isFile()) {
45
+ console.error(`Not a regular file: ${tarPath}`);
46
+ process.exit(1);
47
+ }
48
+ const configDir = getConfigDir();
49
+ // Safety: refuse when the daemon is running so we don't clobber an
50
+ // open SQLite handle (would corrupt the WAL on the daemon side).
51
+ const runningPid = getRunningCoordinatorPid(configDir);
52
+ if (runningPid !== null && !opts.force) {
53
+ console.error(`Coordinator is running (PID ${runningPid}).`);
54
+ console.error("Refusing to restore: stop the coordinator first or pass --force.");
55
+ process.exit(1);
56
+ }
57
+ // Validate tarball contents BEFORE moving anything aside.
58
+ let entries;
59
+ try {
60
+ entries = await listTarballEntries(tarPath);
61
+ }
62
+ catch (err) {
63
+ console.error(`Failed to read tarball: ${err.message}`);
64
+ process.exit(1);
65
+ }
66
+ const hasConfig = entries.includes("config.json");
67
+ const hasData = entries.includes("data");
68
+ if (!hasConfig && !hasData) {
69
+ console.error("Tarball does not contain expected entries (config.json or data/).");
70
+ console.error(`Top-level entries found: ${entries.join(", ") || "(none)"}`);
71
+ process.exit(1);
72
+ }
73
+ // Snapshot the existing config dir before overwriting.
74
+ // commander auto-flips `--no-backup` -> opts.backup = false. The default
75
+ // value is `true` (the option is registered with .option("--no-backup")
76
+ // below, which makes commander seed `opts.backup = true`).
77
+ const shouldSnapshot = opts.backup !== false;
78
+ let snapshotPath = null;
79
+ if (shouldSnapshot && existsSync(configDir)) {
80
+ snapshotPath = `${configDir}.bak-${timestampSlug()}`;
81
+ renameSync(configDir, snapshotPath);
82
+ console.log(`Existing config moved aside: ${snapshotPath}`);
83
+ }
84
+ // Recreate the target dir and extract.
85
+ mkdirSync(configDir, { recursive: true });
86
+ try {
87
+ await tarExtract({ file: tarPath, cwd: configDir });
88
+ }
89
+ catch (err) {
90
+ console.error(`Extraction failed: ${err.message}`);
91
+ // Try to roll back the snapshot if we made one.
92
+ if (snapshotPath !== null && existsSync(snapshotPath)) {
93
+ // Best-effort: only roll back if extraction created an empty dir.
94
+ try {
95
+ renameSync(configDir, `${configDir}.failed-${timestampSlug()}`);
96
+ renameSync(snapshotPath, configDir);
97
+ console.error("Rolled back to previous config dir.");
98
+ }
99
+ catch {
100
+ console.error(`Manual recovery required — snapshot at: ${snapshotPath}`);
101
+ }
102
+ }
103
+ process.exit(1);
104
+ }
105
+ console.log("Restore complete.");
106
+ console.log(` Source: ${tarPath}`);
107
+ console.log(` ConfigDir: ${configDir}`);
108
+ console.log(` Restored: ${entries.filter((e) => e === "config.json" || e === "data").join(", ")}`);
109
+ if (snapshotPath !== null) {
110
+ console.log(` Previous: ${snapshotPath} (delete once verified)`);
111
+ }
112
+ if (opts.dataDir !== undefined) {
113
+ console.log(` Note: --data-dir was provided but restore extracts to default location.\n` +
114
+ ` Update config.json or COORDINATOR_DATA_DIR if you need a non-default path.`);
115
+ }
116
+ });
117
+ }
@@ -24,10 +24,33 @@ export function createServerStartCommand() {
24
24
  const isBun = typeof globalThis.Bun !== "undefined";
25
25
  const cmd = isBun ? process.execPath : process.execPath;
26
26
  const args = isBun ? ["server", "start"] : [process.argv[1], "server", "start"];
27
+ // Only forward the env vars the daemon actually needs.
28
+ // Each var is read explicitly (no Object.keys / dynamic indexing into
29
+ // process.env) so the daemon can't inherit unrelated parent-process
30
+ // secrets such as AWS_*, GITHUB_TOKEN, OPENAI_API_KEY, etc.
31
+ const childEnv = {
32
+ PATH: process.env.PATH ?? "",
33
+ HOME: process.env.HOME ?? process.env.USERPROFILE ?? "",
34
+ PORT: String(port),
35
+ COORDINATOR_DATA_DIR: dataDir,
36
+ };
37
+ const fwd = (key, value) => {
38
+ if (value !== undefined)
39
+ childEnv[key] = value;
40
+ };
41
+ fwd("NODE_ENV", process.env.NODE_ENV);
42
+ fwd("LOG_LEVEL", process.env.LOG_LEVEL);
43
+ fwd("COORDINATOR_AUTH_ENABLED", process.env.COORDINATOR_AUTH_ENABLED);
44
+ fwd("COORDINATOR_JWT_SECRET", process.env.COORDINATOR_JWT_SECRET);
45
+ fwd("COORDINATOR_JWT_EXPIRY", process.env.COORDINATOR_JWT_EXPIRY);
46
+ fwd("COORDINATOR_REGISTRATION_SECRET", process.env.COORDINATOR_REGISTRATION_SECRET);
47
+ fwd("COORDINATOR_ADMIN_SECRET", process.env.COORDINATOR_ADMIN_SECRET);
48
+ fwd("COORDINATOR_MQTT_TCP_PORT", process.env.COORDINATOR_MQTT_TCP_PORT);
49
+ fwd("COORDINATOR_MQTT_WS_PATH", process.env.COORDINATOR_MQTT_WS_PATH);
27
50
  const child = spawn(cmd, args, {
28
51
  detached: true,
29
52
  stdio: ["ignore", logFd, logFd],
30
- env: { ...process.env, PORT: String(port), COORDINATOR_DATA_DIR: dataDir },
53
+ env: childEnv,
31
54
  });
32
55
  // Write PID file
33
56
  writeFileSync(join(configDir, "server.pid"), String(child.pid));
@@ -1,12 +1,22 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
- import { execSync } from "child_process";
5
4
  import { getConfigDir, loadConfig } from "../config.js";
5
+ async function fetchJson(url, init) {
6
+ try {
7
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(3000) });
8
+ if (!response.ok)
9
+ return null;
10
+ return (await response.json());
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
6
16
  export function createServerStatusCommand() {
7
17
  return new Command("status")
8
18
  .description("Show coordinator status")
9
- .action(() => {
19
+ .action(async () => {
10
20
  const configDir = getConfigDir();
11
21
  const pidPath = join(configDir, "server.pid");
12
22
  const config = loadConfig();
@@ -26,28 +36,11 @@ export function createServerStatusCommand() {
26
36
  console.log("Coordinator: stopped (stale PID file)");
27
37
  return;
28
38
  }
29
- // Health check
30
- let health = {};
31
- try {
32
- const raw = execSync(`curl -s --max-time 3 http://localhost:${port}/health`, {
33
- encoding: "utf-8",
34
- stdio: ["pipe", "pipe", "pipe"],
35
- });
36
- health = JSON.parse(raw);
37
- }
38
- catch { }
39
- if (health.status === "ok") {
40
- let status = {};
41
- try {
42
- const raw = execSync(`curl -s --max-time 3 -X POST http://localhost:${port}/api/status`, {
43
- encoding: "utf-8",
44
- stdio: ["pipe", "pipe", "pipe"],
45
- });
46
- status = JSON.parse(raw);
47
- }
48
- catch { }
39
+ const health = await fetchJson(`http://localhost:${port}/health`);
40
+ if (health?.status === "ok") {
41
+ const status = await fetchJson(`http://localhost:${port}/api/status`, { method: "POST" });
49
42
  console.log(`Coordinator: running (PID ${pid}, port ${port})`);
50
- if (status.online !== undefined) {
43
+ if (status?.online !== undefined) {
51
44
  console.log(`Agents: ${status.online} online`);
52
45
  console.log(`Threads: ${status.open_threads} open`);
53
46
  }
@@ -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
  }
@@ -0,0 +1,52 @@
1
+ import type { Thread } from "./types.js";
2
+ import type { CoordinatorServices } from "./server-setup.js";
3
+ import type { CategorizedImpact } from "./impact-scorer.js";
4
+ import { type PlanQualityResult } from "./plan-quality.js";
5
+ /**
6
+ * S2 fix: shared `announce_work` orchestration.
7
+ *
8
+ * The MCP tool handler (`server-setup.ts`) and the REST endpoint
9
+ * (`serve-http.ts`) historically duplicated ~95 lines of code that
10
+ * scored impact, overrode respondents, auto-resolved when alone,
11
+ * and emitted impact/introspection/plan-quality SSE events.
12
+ *
13
+ * This module extracts that common orchestration. Both transports
14
+ * call `runCommonAnnounceFlow()` after creating the thread; each
15
+ * transport then adds its own pre-step (MCP: conflict detection)
16
+ * and post-step (MCP: MQTT publish + context gathering, REST: JSON
17
+ * response) so existing SSE/response contracts are preserved.
18
+ *
19
+ * Why not unify the response/SSE shapes too? The MCP and REST
20
+ * `thread_opened` event payloads have DIFFERENT field sets today
21
+ * (e.g. MCP uses `initiator`, REST uses `agent_id` + `agent_name`).
22
+ * essaim (and other consumers) may depend on those exact shapes.
23
+ * Behavioral unification is a separate, riskier change deferred to
24
+ * a later major.
25
+ */
26
+ export interface CommonFlowResult {
27
+ /** The same thread you passed in, refreshed after the override+auto-resolve. */
28
+ updated: Thread;
29
+ /** Impact scoring across all online agents. */
30
+ categorized: CategorizedImpact;
31
+ /** Concerned agent IDs (== updated.expected_respondents). */
32
+ respondents: string[];
33
+ /** Plan quality assessment (callers may emit downgrade event). */
34
+ planQuality: PlanQualityResult;
35
+ }
36
+ export interface CommonFlowParams {
37
+ agent_id: string;
38
+ subject: string;
39
+ plan?: string;
40
+ target_modules: string[];
41
+ target_files: string[];
42
+ depends_on_files?: string[];
43
+ exports_affected?: string[];
44
+ keep_open?: boolean;
45
+ }
46
+ /**
47
+ * Run the common post-`announceWork` orchestration. Mutates the thread row
48
+ * (overrides `expected_respondents`, may transition to `resolved`) and emits
49
+ * SSE events for impact scoring + introspection. Pure side-effects on the
50
+ * services + DB; returns metadata callers use to build their own responses.
51
+ */
52
+ export declare function runCommonAnnounceFlow(services: CoordinatorServices, threadId: string, params: CommonFlowParams): CommonFlowResult;
@@ -0,0 +1,91 @@
1
+ import { getDb } from "./database.js";
2
+ import { assessPlanQuality } from "./plan-quality.js";
3
+ /**
4
+ * Run the common post-`announceWork` orchestration. Mutates the thread row
5
+ * (overrides `expected_respondents`, may transition to `resolved`) and emits
6
+ * SSE events for impact scoring + introspection. Pure side-effects on the
7
+ * services + DB; returns metadata callers use to build their own responses.
8
+ */
9
+ export function runCommonAnnounceFlow(services, threadId, params) {
10
+ const { registry, consultation, impactScorer, introspection, sseEmitter } = services;
11
+ // 1. Score impact: categorize all online agents into concerned / gray_zone / pass.
12
+ const categorized = impactScorer.categorize({
13
+ agent_id: params.agent_id,
14
+ target_modules: params.target_modules,
15
+ target_files: params.target_files,
16
+ depends_on_files: params.depends_on_files,
17
+ exports_affected: params.exports_affected,
18
+ });
19
+ // 2. Override expected_respondents on the thread with the scored set.
20
+ // Auto-resolve only when truly alone — if peers are online but not concerned
21
+ // (e.g., they haven't announced yet), keep the thread open so a subsequent
22
+ // announce can match via Layer 0. Thread will timeout naturally if no one joins.
23
+ const db = getDb();
24
+ const concernedIds = categorized.concerned.map((s) => s.agent_id);
25
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
26
+ .run(JSON.stringify(concernedIds), threadId);
27
+ const otherOnlineCount = registry.listOnline().filter((a) => a.id !== params.agent_id).length;
28
+ const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
29
+ const currentThread = consultation.getThread(threadId);
30
+ if (shouldAutoResolve && currentThread.status === "open" && !params.keep_open) {
31
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
32
+ .run(new Date().toISOString(), threadId);
33
+ consultation.emitResolution(threadId, "auto_resolved");
34
+ }
35
+ // 3. Emit impact_scored SSE events for every scored agent.
36
+ for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
37
+ sseEmitter.emit("impact_scored", {
38
+ thread_id: threadId,
39
+ agent_id: s.agent_id,
40
+ agent_name: s.agent_name,
41
+ score: s.score,
42
+ reasons: s.reasons,
43
+ category: scoredCategory(s),
44
+ });
45
+ }
46
+ // 4. Create introspection records and emit introspection_requested for gray_zone agents.
47
+ for (const s of categorized.gray_zone) {
48
+ introspection.create({ thread_id: threadId, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
49
+ sseEmitter.emit("introspection_requested", {
50
+ thread_id: threadId,
51
+ agent_id: s.agent_id,
52
+ agent_name: s.agent_name,
53
+ score: s.score,
54
+ reasons: s.reasons,
55
+ });
56
+ }
57
+ // 5. Plan quality downgrade event — both transports emit this when a plan
58
+ // was provided but quality was insufficient. Callers can re-emit if their
59
+ // payload shape differs (MCP vs REST agent_name source).
60
+ const planQuality = assessPlanQuality(params.plan);
61
+ if (params.plan && planQuality.mode === "discovery") {
62
+ sseEmitter.emit("impact_scored", {
63
+ thread_id: threadId,
64
+ agent_id: params.agent_id,
65
+ agent_name: registry.get(params.agent_id)?.name || params.agent_id,
66
+ score: planQuality.score,
67
+ reasons: [planDowngradeReason(planQuality)],
68
+ category: "plan_quality",
69
+ });
70
+ }
71
+ const updated = consultation.getThread(threadId);
72
+ const respondents = JSON.parse(updated.expected_respondents || "[]");
73
+ return { updated, categorized, respondents, planQuality };
74
+ }
75
+ function scoredCategory(s) {
76
+ if (s.score >= 90)
77
+ return "concerned";
78
+ if (s.score >= 30)
79
+ return "gray_zone";
80
+ return "pass";
81
+ }
82
+ function planDowngradeReason(pq) {
83
+ const flags = [];
84
+ if (!pq.checks.mentions_files)
85
+ flags.push("no files");
86
+ if (!pq.checks.concrete_approach)
87
+ flags.push("vague approach");
88
+ if (!pq.checks.sufficient_detail)
89
+ flags.push("too short");
90
+ return `plan downgraded: score ${pq.score}/3 — ${flags.join(" ")}`.trim();
91
+ }
@@ -13,8 +13,22 @@ export interface ResolutionEvent {
13
13
  export declare class Consultation {
14
14
  private onResolveCallback;
15
15
  private log;
16
+ private timeoutSweeperHandle;
16
17
  constructor(logger?: Logger);
17
18
  onResolve(callback: (event: ResolutionEvent) => void): void;
19
+ /**
20
+ * B2 fix: replace the side-effect-on-read timeout check with an explicit
21
+ * background sweeper. Each tick atomically claims and resolves any thread
22
+ * past its deadline, then emits resolution events outside the transaction.
23
+ *
24
+ * Default tick interval: 30 seconds. Tests can pass a shorter interval to
25
+ * exercise the sweeper, or call checkTimeouts() explicitly.
26
+ *
27
+ * Safe to call multiple times — second call is a no-op until the previous
28
+ * sweeper is stopped.
29
+ */
30
+ startTimeoutSweeper(intervalMs?: number): void;
31
+ stopTimeoutSweeper(): void;
18
32
  emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
19
33
  announceWork(params: {
20
34
  agent_id: string;
@@ -60,6 +74,14 @@ export declare class Consultation {
60
74
  * parsing the thread list themselves.
61
75
  */
62
76
  assigned_to_me?: string;
77
+ /**
78
+ * P2 perf: bound resolved-thread queries to a recency window. Without
79
+ * this, the impact scorer would scan all-time resolved threads on every
80
+ * announce_work call (O(historical-threads) per scoring pass). The window
81
+ * applies to resolved_at when status='resolved', otherwise to created_at,
82
+ * so the filter is meaningful for both states.
83
+ */
84
+ since_minutes?: number;
63
85
  }): Thread[];
64
86
  getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
65
87
  logActionSummary(params: {