mcp-coordinator 0.1.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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dashboard/Dockerfile +19 -0
- package/dashboard/public/index.html +1178 -0
- package/dist/cli/config.d.ts +14 -0
- package/dist/cli/config.js +58 -0
- package/dist/cli/dashboard.d.ts +2 -0
- package/dist/cli/dashboard.js +14 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/server/index.d.ts +2 -0
- package/dist/cli/server/index.js +11 -0
- package/dist/cli/server/start.d.ts +2 -0
- package/dist/cli/server/start.js +57 -0
- package/dist/cli/server/status.d.ts +2 -0
- package/dist/cli/server/status.js +60 -0
- package/dist/cli/server/stop.d.ts +2 -0
- package/dist/cli/server/stop.js +59 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/src/agent-activity.d.ts +27 -0
- package/dist/src/agent-activity.js +70 -0
- package/dist/src/agent-registry.d.ts +10 -0
- package/dist/src/agent-registry.js +38 -0
- package/dist/src/auth.d.ts +22 -0
- package/dist/src/auth.js +91 -0
- package/dist/src/conflict-detector.d.ts +17 -0
- package/dist/src/conflict-detector.js +114 -0
- package/dist/src/consultation.d.ts +75 -0
- package/dist/src/consultation.js +332 -0
- package/dist/src/context-provider.d.ts +14 -0
- package/dist/src/context-provider.js +34 -0
- package/dist/src/database.d.ts +4 -0
- package/dist/src/database.js +194 -0
- package/dist/src/db-adapter.d.ts +15 -0
- package/dist/src/db-adapter.js +1 -0
- package/dist/src/dependency-map.d.ts +7 -0
- package/dist/src/dependency-map.js +76 -0
- package/dist/src/file-tracker.d.ts +21 -0
- package/dist/src/file-tracker.js +44 -0
- package/dist/src/impact-scorer.d.ts +31 -0
- package/dist/src/impact-scorer.js +112 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +26 -0
- package/dist/src/introspection.d.ts +24 -0
- package/dist/src/introspection.js +28 -0
- package/dist/src/logger.d.ts +20 -0
- package/dist/src/logger.js +55 -0
- package/dist/src/mqtt-bridge.d.ts +40 -0
- package/dist/src/mqtt-bridge.js +173 -0
- package/dist/src/mqtt-broker.d.ts +23 -0
- package/dist/src/mqtt-broker.js +99 -0
- package/dist/src/plan-quality.d.ts +11 -0
- package/dist/src/plan-quality.js +30 -0
- package/dist/src/quota/credential-reader.d.ts +21 -0
- package/dist/src/quota/credential-reader.js +86 -0
- package/dist/src/quota/quota-cache.d.ts +93 -0
- package/dist/src/quota/quota-cache.js +177 -0
- package/dist/src/quota/quota.d.ts +47 -0
- package/dist/src/quota/quota.js +117 -0
- package/dist/src/serve-http.d.ts +5 -0
- package/dist/src/serve-http.js +775 -0
- package/dist/src/server-setup.d.ts +34 -0
- package/dist/src/server-setup.js +453 -0
- package/dist/src/sse-emitter.d.ts +10 -0
- package/dist/src/sse-emitter.js +35 -0
- package/dist/src/types.d.ts +121 -0
- package/dist/src/types.js +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CoordinatorConfig {
|
|
2
|
+
server: {
|
|
3
|
+
port: number;
|
|
4
|
+
data_dir: string;
|
|
5
|
+
};
|
|
6
|
+
defaults: {
|
|
7
|
+
coordinator_url: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function getConfigDir(): string;
|
|
11
|
+
export declare function ensureConfigDir(): string;
|
|
12
|
+
export declare function loadConfig(configDir?: string): CoordinatorConfig;
|
|
13
|
+
export declare function saveConfig(config: CoordinatorConfig, configDir?: string): void;
|
|
14
|
+
export declare function resolveValue(flag: unknown, envVar: string | undefined, configValue: unknown, defaultValue: unknown): unknown;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
server: {
|
|
6
|
+
port: 3100,
|
|
7
|
+
data_dir: join(homedir(), ".mcp-coordinator", "data"),
|
|
8
|
+
},
|
|
9
|
+
defaults: {
|
|
10
|
+
coordinator_url: "http://localhost:3100",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export function getConfigDir() {
|
|
14
|
+
return join(homedir(), ".mcp-coordinator");
|
|
15
|
+
}
|
|
16
|
+
export function ensureConfigDir() {
|
|
17
|
+
const dir = getConfigDir();
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
mkdirSync(join(dir, "data"), { recursive: true });
|
|
20
|
+
mkdirSync(join(dir, "logs"), { recursive: true });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(configDir) {
|
|
24
|
+
const dir = configDir ?? getConfigDir();
|
|
25
|
+
const configPath = join(dir, "config.json");
|
|
26
|
+
if (!existsSync(configPath)) {
|
|
27
|
+
return { ...DEFAULT_CONFIG };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
31
|
+
return {
|
|
32
|
+
server: {
|
|
33
|
+
port: raw.server?.port ?? DEFAULT_CONFIG.server.port,
|
|
34
|
+
data_dir: raw.server?.data_dir ?? DEFAULT_CONFIG.server.data_dir,
|
|
35
|
+
},
|
|
36
|
+
defaults: {
|
|
37
|
+
coordinator_url: raw.defaults?.coordinator_url ?? DEFAULT_CONFIG.defaults.coordinator_url,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { ...DEFAULT_CONFIG };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function saveConfig(config, configDir) {
|
|
46
|
+
const dir = configDir ?? getConfigDir();
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
|
49
|
+
}
|
|
50
|
+
export function resolveValue(flag, envVar, configValue, defaultValue) {
|
|
51
|
+
if (flag !== undefined)
|
|
52
|
+
return flag;
|
|
53
|
+
if (envVar !== undefined)
|
|
54
|
+
return envVar;
|
|
55
|
+
if (configValue !== undefined)
|
|
56
|
+
return configValue;
|
|
57
|
+
return defaultValue;
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
export function createDashboardCommand() {
|
|
4
|
+
return new Command("dashboard")
|
|
5
|
+
.description("Open the real-time dashboard")
|
|
6
|
+
.action(() => {
|
|
7
|
+
const url = "http://localhost:3100/dashboard";
|
|
8
|
+
console.log(`Dashboard: ${url}`);
|
|
9
|
+
const cmd = process.platform === "darwin"
|
|
10
|
+
? `open "${url}"`
|
|
11
|
+
: `xdg-open "${url}" 2>/dev/null`;
|
|
12
|
+
exec(cmd, () => { });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createServerProgram } from "./server/index.js";
|
|
4
|
+
import { createDashboardCommand } from "./dashboard.js";
|
|
5
|
+
import { getVersion } from "./version.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("mcp-coordinator")
|
|
9
|
+
.description("Embedded MQTT broker + MCP server for multi-agent coordination")
|
|
10
|
+
.version(getVersion());
|
|
11
|
+
program.addCommand(createServerProgram());
|
|
12
|
+
program.addCommand(createDashboardCommand());
|
|
13
|
+
program.parse();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createServerStartCommand } from "./start.js";
|
|
3
|
+
import { createServerStopCommand } from "./stop.js";
|
|
4
|
+
import { createServerStatusCommand } from "./status.js";
|
|
5
|
+
export function createServerProgram() {
|
|
6
|
+
const server = new Command("server").description("Manage the coordination server");
|
|
7
|
+
server.addCommand(createServerStartCommand());
|
|
8
|
+
server.addCommand(createServerStopCommand());
|
|
9
|
+
server.addCommand(createServerStatusCommand());
|
|
10
|
+
return server;
|
|
11
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { loadConfig, ensureConfigDir } from "../config.js";
|
|
5
|
+
export function createServerStartCommand() {
|
|
6
|
+
return new Command("start")
|
|
7
|
+
.description("Start the coordination server")
|
|
8
|
+
.option("--port <port>", "Server port")
|
|
9
|
+
.option("--data-dir <path>", "Data directory")
|
|
10
|
+
.option("--daemon", "Run as background daemon")
|
|
11
|
+
.action(async (opts) => {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const port = parseInt(opts.port ?? process.env.PORT ?? String(config.server.port), 10);
|
|
14
|
+
const dataDir = opts.dataDir ?? process.env.COORDINATOR_DATA_DIR ?? config.server.data_dir;
|
|
15
|
+
const configDir = ensureConfigDir();
|
|
16
|
+
if (opts.daemon) {
|
|
17
|
+
// Daemon mode: spawn self, redirect logs via shell
|
|
18
|
+
const { spawn } = await import("child_process");
|
|
19
|
+
const { openSync } = await import("fs");
|
|
20
|
+
const logPath = join(configDir, "logs", "server.log");
|
|
21
|
+
const logFd = openSync(logPath, "a");
|
|
22
|
+
// In compiled binary: process.execPath IS the binary, no argv[1] needed
|
|
23
|
+
// In dev (tsx): process.execPath is node, argv[1] is the script
|
|
24
|
+
const isBun = typeof globalThis.Bun !== "undefined";
|
|
25
|
+
const cmd = isBun ? process.execPath : process.execPath;
|
|
26
|
+
const args = isBun ? ["server", "start"] : [process.argv[1], "server", "start"];
|
|
27
|
+
const child = spawn(cmd, args, {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: ["ignore", logFd, logFd],
|
|
30
|
+
env: { ...process.env, PORT: String(port), COORDINATOR_DATA_DIR: dataDir },
|
|
31
|
+
});
|
|
32
|
+
// Write PID file
|
|
33
|
+
writeFileSync(join(configDir, "server.pid"), String(child.pid));
|
|
34
|
+
child.unref();
|
|
35
|
+
console.log(`Coordinator started in background (PID ${child.pid}, port ${port})`);
|
|
36
|
+
console.log(` Logs: ${logPath}`);
|
|
37
|
+
console.log(` Stop: mcp-coordinator server stop`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
// Foreground mode: start server in-process
|
|
41
|
+
// Write PID file for server stop support
|
|
42
|
+
writeFileSync(join(configDir, "server.pid"), String(process.pid));
|
|
43
|
+
// Graceful shutdown
|
|
44
|
+
const { unlinkSync } = await import("fs");
|
|
45
|
+
const cleanup = () => {
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(join(configDir, "server.pid"));
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
};
|
|
51
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
52
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
53
|
+
// Import and start server in-process
|
|
54
|
+
const { startServer } = await import("../../src/serve-http.js");
|
|
55
|
+
await startServer({ port, dataDir });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { getConfigDir, loadConfig } from "../config.js";
|
|
6
|
+
export function createServerStatusCommand() {
|
|
7
|
+
return new Command("status")
|
|
8
|
+
.description("Show coordinator status")
|
|
9
|
+
.action(() => {
|
|
10
|
+
const configDir = getConfigDir();
|
|
11
|
+
const pidPath = join(configDir, "server.pid");
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const port = config.server.port;
|
|
14
|
+
if (!existsSync(pidPath)) {
|
|
15
|
+
console.log("Coordinator: stopped");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
19
|
+
let processAlive = false;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
processAlive = true;
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
if (!processAlive) {
|
|
26
|
+
console.log("Coordinator: stopped (stale PID file)");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
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 { }
|
|
49
|
+
console.log(`Coordinator: running (PID ${pid}, port ${port})`);
|
|
50
|
+
if (status.online !== undefined) {
|
|
51
|
+
console.log(`Agents: ${status.online} online`);
|
|
52
|
+
console.log(`Threads: ${status.open_threads} open`);
|
|
53
|
+
}
|
|
54
|
+
console.log(`Dashboard: http://localhost:${port}/dashboard`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(`Coordinator: running (PID ${pid}) but health check failed`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFileSync, unlinkSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getConfigDir } from "../config.js";
|
|
5
|
+
export function createServerStopCommand() {
|
|
6
|
+
return new Command("stop")
|
|
7
|
+
.description("Stop the coordination server")
|
|
8
|
+
.action(() => {
|
|
9
|
+
const pidPath = join(getConfigDir(), "server.pid");
|
|
10
|
+
if (!existsSync(pidPath)) {
|
|
11
|
+
console.error("No server PID file found. Is the server running?");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
15
|
+
if (isNaN(pid)) {
|
|
16
|
+
console.error("Invalid PID file. Removing.");
|
|
17
|
+
unlinkSync(pidPath);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
// Check if process is alive
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
console.log(`Server (PID ${pid}) is not running. Cleaning up PID file.`);
|
|
26
|
+
unlinkSync(pidPath);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Send SIGTERM
|
|
30
|
+
console.log(`Stopping coordinator (PID ${pid})...`);
|
|
31
|
+
process.kill(pid, "SIGTERM");
|
|
32
|
+
// Wait up to 5 seconds
|
|
33
|
+
const deadline = Date.now() + 5000;
|
|
34
|
+
const check = () => {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
if (Date.now() < deadline) {
|
|
38
|
+
setTimeout(check, 200);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.log("Server did not stop gracefully. Sending SIGKILL.");
|
|
42
|
+
process.kill(pid, "SIGKILL");
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(pidPath);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
console.log("Coordinator stopped.");
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(pidPath);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
check();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getVersion(): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
export function getVersion() {
|
|
5
|
+
// dist/cli/version.js -> ../../package.json
|
|
6
|
+
// cli/version.ts (tsx) -> ../package.json
|
|
7
|
+
// Wrap fileURLToPath in the try as well — under Bun --compile, import.meta.url
|
|
8
|
+
// may be a synthetic non-file URL that throws TypeError on fileURLToPath.
|
|
9
|
+
try {
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
for (const candidate of [resolve(here, "..", "package.json"), resolve(here, "..", "..", "package.json")]) {
|
|
12
|
+
try {
|
|
13
|
+
const json = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
14
|
+
if (json.version)
|
|
15
|
+
return json.version;
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
return "0.0.0";
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AgentRegistry } from "./agent-registry.js";
|
|
2
|
+
import type { AgentActivity } from "./types.js";
|
|
3
|
+
interface HeartbeatPayload {
|
|
4
|
+
currentFile: string | null;
|
|
5
|
+
currentThread: string | null;
|
|
6
|
+
}
|
|
7
|
+
interface GetActivityOptions {
|
|
8
|
+
idleAfterMinutes?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class AgentActivityTracker {
|
|
11
|
+
private registry;
|
|
12
|
+
constructor(registry: AgentRegistry);
|
|
13
|
+
/** Report file edit activity → status becomes "working" */
|
|
14
|
+
reportFileActivity(agentId: string, filePath: string): void;
|
|
15
|
+
/** Report agent is waiting on a consultation thread */
|
|
16
|
+
reportWaiting(agentId: string, threadId: string): void;
|
|
17
|
+
/** Report agent went offline → clear all activity */
|
|
18
|
+
reportOffline(agentId: string): void;
|
|
19
|
+
/** Enriched heartbeat — derives status from current state */
|
|
20
|
+
heartbeat(agentId: string, payload: HeartbeatPayload): void;
|
|
21
|
+
/** Get activity for a single agent, with optional idle timeout */
|
|
22
|
+
getActivity(agentId: string, options?: GetActivityOptions): AgentActivity;
|
|
23
|
+
/** List activity for all online agents */
|
|
24
|
+
listAll(options?: GetActivityOptions): AgentActivity[];
|
|
25
|
+
private upsert;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
export class AgentActivityTracker {
|
|
3
|
+
registry;
|
|
4
|
+
constructor(registry) {
|
|
5
|
+
this.registry = registry;
|
|
6
|
+
}
|
|
7
|
+
/** Report file edit activity → status becomes "working" */
|
|
8
|
+
reportFileActivity(agentId, filePath) {
|
|
9
|
+
this.upsert(agentId, "working", filePath, null);
|
|
10
|
+
}
|
|
11
|
+
/** Report agent is waiting on a consultation thread */
|
|
12
|
+
reportWaiting(agentId, threadId) {
|
|
13
|
+
this.upsert(agentId, "waiting", null, threadId);
|
|
14
|
+
}
|
|
15
|
+
/** Report agent went offline → clear all activity */
|
|
16
|
+
reportOffline(agentId) {
|
|
17
|
+
this.upsert(agentId, "offline", null, null);
|
|
18
|
+
}
|
|
19
|
+
/** Enriched heartbeat — derives status from current state */
|
|
20
|
+
heartbeat(agentId, payload) {
|
|
21
|
+
let status;
|
|
22
|
+
if (payload.currentFile) {
|
|
23
|
+
status = "working";
|
|
24
|
+
}
|
|
25
|
+
else if (payload.currentThread) {
|
|
26
|
+
status = "waiting";
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
status = "idle";
|
|
30
|
+
}
|
|
31
|
+
this.upsert(agentId, status, payload.currentFile, payload.currentThread);
|
|
32
|
+
}
|
|
33
|
+
/** Get activity for a single agent, with optional idle timeout */
|
|
34
|
+
getActivity(agentId, options) {
|
|
35
|
+
const agent = this.registry.get(agentId);
|
|
36
|
+
if (!agent || agent.status === "offline") {
|
|
37
|
+
return { agent_id: agentId, activity_status: "offline", current_file: null, current_thread: null, last_activity_at: new Date().toISOString() };
|
|
38
|
+
}
|
|
39
|
+
const db = getDb();
|
|
40
|
+
const row = db.prepare("SELECT * FROM agent_activity_status WHERE agent_id = ?").get(agentId);
|
|
41
|
+
if (!row) {
|
|
42
|
+
return { agent_id: agentId, activity_status: "idle", current_file: null, current_thread: null, last_activity_at: new Date().toISOString() };
|
|
43
|
+
}
|
|
44
|
+
// Check idle timeout: if working but no activity for X minutes → idle
|
|
45
|
+
if (row.activity_status === "working" && options?.idleAfterMinutes) {
|
|
46
|
+
const lastActivity = new Date(row.last_activity_at.replace(" ", "T") + "Z").getTime();
|
|
47
|
+
const threshold = options.idleAfterMinutes * 60 * 1000;
|
|
48
|
+
if (Date.now() - lastActivity > threshold) {
|
|
49
|
+
return { ...row, activity_status: "idle" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return row;
|
|
53
|
+
}
|
|
54
|
+
/** List activity for all online agents */
|
|
55
|
+
listAll(options) {
|
|
56
|
+
const onlineAgents = this.registry.listOnline();
|
|
57
|
+
return onlineAgents.map((agent) => this.getActivity(agent.id, options));
|
|
58
|
+
}
|
|
59
|
+
// ── Private ──
|
|
60
|
+
upsert(agentId, status, file, thread) {
|
|
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,
|
|
68
|
+
last_activity_at = CURRENT_TIMESTAMP`).run(agentId, status, file, thread);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Agent } from "./types.js";
|
|
2
|
+
export declare class AgentRegistry {
|
|
3
|
+
register(agentId: string, name: string, modules: string[]): Agent;
|
|
4
|
+
get(agentId: string): Agent | undefined;
|
|
5
|
+
listOnline(): Agent[];
|
|
6
|
+
listAll(): Agent[];
|
|
7
|
+
setOnline(agentId: string): void;
|
|
8
|
+
setOffline(agentId: string): void;
|
|
9
|
+
heartbeat(agentId: string): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
export class AgentRegistry {
|
|
3
|
+
register(agentId, name, modules) {
|
|
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',
|
|
11
|
+
last_seen_at = CURRENT_TIMESTAMP`).run(agentId, name, JSON.stringify(modules));
|
|
12
|
+
return this.get(agentId);
|
|
13
|
+
}
|
|
14
|
+
get(agentId) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
return db.prepare("SELECT * FROM agents WHERE id = ?").get(agentId);
|
|
17
|
+
}
|
|
18
|
+
listOnline() {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
return db.prepare("SELECT * FROM agents WHERE status = 'online' ORDER BY name").all();
|
|
21
|
+
}
|
|
22
|
+
listAll() {
|
|
23
|
+
const db = getDb();
|
|
24
|
+
return db.prepare("SELECT * FROM agents ORDER BY last_seen_at DESC").all();
|
|
25
|
+
}
|
|
26
|
+
setOnline(agentId) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
db.prepare("UPDATE agents SET status = 'online', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(agentId);
|
|
29
|
+
}
|
|
30
|
+
setOffline(agentId) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
db.prepare("UPDATE agents SET status = 'offline', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(agentId);
|
|
33
|
+
}
|
|
34
|
+
heartbeat(agentId) {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
db.prepare("UPDATE agents SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(agentId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingMessage } from "http";
|
|
2
|
+
import { type Logger } from "./logger.js";
|
|
3
|
+
export declare function setAuthLogger(logger: Logger): void;
|
|
4
|
+
export interface AuthClaims {
|
|
5
|
+
sub: string;
|
|
6
|
+
role: "agent" | "admin";
|
|
7
|
+
}
|
|
8
|
+
export declare function initAuth(secret: string, expiry?: string): void;
|
|
9
|
+
export declare function createToken(agentId: string, role: "agent" | "admin", expiry?: string): Promise<string>;
|
|
10
|
+
export declare function verifyToken(token: string): Promise<AuthClaims>;
|
|
11
|
+
export declare function refreshToken(token: string, gracePeriod?: string): Promise<string>;
|
|
12
|
+
export declare function isRevoked(agentId: string): boolean;
|
|
13
|
+
export declare function revokeAgent(agentId: string, revokedBy: string): void;
|
|
14
|
+
export type AuthResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
claims: AuthClaims;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
status: 401 | 403;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function authenticateRequest(req: IncomingMessage): Promise<AuthResult>;
|
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, errors } from "jose";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { getDb } from "./database.js";
|
|
4
|
+
import { silentLogger } from "./logger.js";
|
|
5
|
+
let signingKey;
|
|
6
|
+
let defaultExpiry = "24h";
|
|
7
|
+
let log = silentLogger;
|
|
8
|
+
export function setAuthLogger(logger) {
|
|
9
|
+
log = logger;
|
|
10
|
+
}
|
|
11
|
+
export function initAuth(secret, expiry) {
|
|
12
|
+
signingKey = new TextEncoder().encode(secret);
|
|
13
|
+
if (expiry)
|
|
14
|
+
defaultExpiry = expiry;
|
|
15
|
+
}
|
|
16
|
+
export async function createToken(agentId, role, expiry) {
|
|
17
|
+
return new SignJWT({ role })
|
|
18
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
19
|
+
.setSubject(agentId)
|
|
20
|
+
.setJti(randomUUID())
|
|
21
|
+
.setIssuedAt()
|
|
22
|
+
.setExpirationTime(expiry || defaultExpiry)
|
|
23
|
+
.sign(signingKey);
|
|
24
|
+
}
|
|
25
|
+
export async function verifyToken(token) {
|
|
26
|
+
const { payload } = await jwtVerify(token, signingKey);
|
|
27
|
+
if (!payload.sub)
|
|
28
|
+
throw new Error("Missing sub claim in token");
|
|
29
|
+
const role = payload.role;
|
|
30
|
+
if (role !== "agent" && role !== "admin")
|
|
31
|
+
throw new Error("Invalid role in token");
|
|
32
|
+
return { sub: payload.sub, role };
|
|
33
|
+
}
|
|
34
|
+
export async function refreshToken(token, gracePeriod = "1h") {
|
|
35
|
+
let claims;
|
|
36
|
+
try {
|
|
37
|
+
claims = await verifyToken(token);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof errors.JWTExpired) {
|
|
41
|
+
const { payload } = await jwtVerify(token, signingKey, {
|
|
42
|
+
clockTolerance: gracePeriod,
|
|
43
|
+
});
|
|
44
|
+
if (!payload.sub)
|
|
45
|
+
throw new Error("Missing sub claim in token");
|
|
46
|
+
const role = payload.role;
|
|
47
|
+
if (role !== "agent" && role !== "admin")
|
|
48
|
+
throw new Error("Invalid role in token");
|
|
49
|
+
claims = { sub: payload.sub, role };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return createToken(claims.sub, claims.role);
|
|
56
|
+
}
|
|
57
|
+
export function isRevoked(agentId) {
|
|
58
|
+
const db = getDb();
|
|
59
|
+
const row = db.prepare("SELECT 1 FROM revoked_agents WHERE agent_id = ?").get(agentId);
|
|
60
|
+
return !!row;
|
|
61
|
+
}
|
|
62
|
+
export function revokeAgent(agentId, revokedBy) {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
db.prepare("INSERT OR IGNORE INTO revoked_agents (agent_id, revoked_by) VALUES (?, ?)").run(agentId, revokedBy);
|
|
65
|
+
}
|
|
66
|
+
const ADMIN_ONLY_ROUTES = ["/api/auth/revoke", "/api/reset"];
|
|
67
|
+
export async function authenticateRequest(req) {
|
|
68
|
+
const authHeader = req.headers.authorization;
|
|
69
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
70
|
+
return { ok: false, status: 401, error: "Missing or invalid Authorization header" };
|
|
71
|
+
}
|
|
72
|
+
const token = authHeader.slice(7);
|
|
73
|
+
let claims;
|
|
74
|
+
try {
|
|
75
|
+
claims = await verifyToken(token);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log.error({ err }, "JWT verification error");
|
|
79
|
+
return { ok: false, status: 401, error: "Invalid or expired token" };
|
|
80
|
+
}
|
|
81
|
+
if (isRevoked(claims.sub)) {
|
|
82
|
+
return { ok: false, status: 403, error: "Agent has been revoked" };
|
|
83
|
+
}
|
|
84
|
+
const url = req.url || "";
|
|
85
|
+
// Strip query string and hash before matching — "/api/reset?x=1" must hit the check
|
|
86
|
+
const pathOnly = url.split(/[?#]/)[0];
|
|
87
|
+
if (ADMIN_ONLY_ROUTES.some((r) => pathOnly === r) && claims.role !== "admin") {
|
|
88
|
+
return { ok: false, status: 403, error: "Admin access required" };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, claims };
|
|
91
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Logger } from "./logger.js";
|
|
2
|
+
import type { ConflictReport } from "./types.js";
|
|
3
|
+
import type { Consultation } from "./consultation.js";
|
|
4
|
+
import type { DependencyMapper } from "./dependency-map.js";
|
|
5
|
+
import type { FileTracker } from "./file-tracker.js";
|
|
6
|
+
export declare class ConflictDetector {
|
|
7
|
+
private consultation;
|
|
8
|
+
private depMap;
|
|
9
|
+
private fileTracker;
|
|
10
|
+
private log;
|
|
11
|
+
constructor(consultation: Consultation, depMap: DependencyMapper, fileTracker: FileTracker, logger?: Logger);
|
|
12
|
+
detect(params: {
|
|
13
|
+
agent_id: string;
|
|
14
|
+
target_modules: string[];
|
|
15
|
+
target_files: string[];
|
|
16
|
+
}): ConflictReport[];
|
|
17
|
+
}
|