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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/dashboard/Dockerfile +19 -0
  4. package/dashboard/public/index.html +1178 -0
  5. package/dist/cli/config.d.ts +14 -0
  6. package/dist/cli/config.js +58 -0
  7. package/dist/cli/dashboard.d.ts +2 -0
  8. package/dist/cli/dashboard.js +14 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +13 -0
  11. package/dist/cli/server/index.d.ts +2 -0
  12. package/dist/cli/server/index.js +11 -0
  13. package/dist/cli/server/start.d.ts +2 -0
  14. package/dist/cli/server/start.js +57 -0
  15. package/dist/cli/server/status.d.ts +2 -0
  16. package/dist/cli/server/status.js +60 -0
  17. package/dist/cli/server/stop.d.ts +2 -0
  18. package/dist/cli/server/stop.js +59 -0
  19. package/dist/cli/version.d.ts +1 -0
  20. package/dist/cli/version.js +22 -0
  21. package/dist/src/agent-activity.d.ts +27 -0
  22. package/dist/src/agent-activity.js +70 -0
  23. package/dist/src/agent-registry.d.ts +10 -0
  24. package/dist/src/agent-registry.js +38 -0
  25. package/dist/src/auth.d.ts +22 -0
  26. package/dist/src/auth.js +91 -0
  27. package/dist/src/conflict-detector.d.ts +17 -0
  28. package/dist/src/conflict-detector.js +114 -0
  29. package/dist/src/consultation.d.ts +75 -0
  30. package/dist/src/consultation.js +332 -0
  31. package/dist/src/context-provider.d.ts +14 -0
  32. package/dist/src/context-provider.js +34 -0
  33. package/dist/src/database.d.ts +4 -0
  34. package/dist/src/database.js +194 -0
  35. package/dist/src/db-adapter.d.ts +15 -0
  36. package/dist/src/db-adapter.js +1 -0
  37. package/dist/src/dependency-map.d.ts +7 -0
  38. package/dist/src/dependency-map.js +76 -0
  39. package/dist/src/file-tracker.d.ts +21 -0
  40. package/dist/src/file-tracker.js +44 -0
  41. package/dist/src/impact-scorer.d.ts +31 -0
  42. package/dist/src/impact-scorer.js +112 -0
  43. package/dist/src/index.d.ts +2 -0
  44. package/dist/src/index.js +26 -0
  45. package/dist/src/introspection.d.ts +24 -0
  46. package/dist/src/introspection.js +28 -0
  47. package/dist/src/logger.d.ts +20 -0
  48. package/dist/src/logger.js +55 -0
  49. package/dist/src/mqtt-bridge.d.ts +40 -0
  50. package/dist/src/mqtt-bridge.js +173 -0
  51. package/dist/src/mqtt-broker.d.ts +23 -0
  52. package/dist/src/mqtt-broker.js +99 -0
  53. package/dist/src/plan-quality.d.ts +11 -0
  54. package/dist/src/plan-quality.js +30 -0
  55. package/dist/src/quota/credential-reader.d.ts +21 -0
  56. package/dist/src/quota/credential-reader.js +86 -0
  57. package/dist/src/quota/quota-cache.d.ts +93 -0
  58. package/dist/src/quota/quota-cache.js +177 -0
  59. package/dist/src/quota/quota.d.ts +47 -0
  60. package/dist/src/quota/quota.js +117 -0
  61. package/dist/src/serve-http.d.ts +5 -0
  62. package/dist/src/serve-http.js +775 -0
  63. package/dist/src/server-setup.d.ts +34 -0
  64. package/dist/src/server-setup.js +453 -0
  65. package/dist/src/sse-emitter.d.ts +10 -0
  66. package/dist/src/sse-emitter.js +35 -0
  67. package/dist/src/types.d.ts +121 -0
  68. package/dist/src/types.js +1 -0
  69. package/package.json +80 -0
@@ -0,0 +1,40 @@
1
+ import { type Logger } from "./logger.js";
2
+ interface QueuedMessage {
3
+ topic: string;
4
+ payload: Record<string, unknown>;
5
+ timestamp: number;
6
+ }
7
+ export declare class MqttBridge {
8
+ private client;
9
+ private connected;
10
+ private onOfflineHandler;
11
+ private listeners;
12
+ private log;
13
+ constructor(logger?: Logger);
14
+ connect(config: {
15
+ url: string;
16
+ }): Promise<void>;
17
+ isConnected(): boolean;
18
+ onOffline(handler: (agentId: string) => void): void;
19
+ registerAgent(agentId: string, name: string): void;
20
+ publishConsultation(threadId: string, agentId: string, subject: string, targetModules: string[]): void;
21
+ publishMessage(threadId: string, agentId: string, type: string, content: string): void;
22
+ publishResolution(threadId: string, status: string, summary: string): void;
23
+ publishBroadcast(agentId: string, message: string): void;
24
+ publishAgentOffline(agentId: string): void;
25
+ publishTaskClaimed(threadId: string, claimedBy: string): void;
26
+ publishTaskCompleted(threadId: string, completedBy: string, summary: string): void;
27
+ /**
28
+ * Fanout a refreshed QuotaInfo to live subscribers (dashboard widget,
29
+ * scheduled-agent runners, anything wanting realtime quota pressure).
30
+ * Typed via `unknown` to avoid an import cycle with the quota module.
31
+ */
32
+ publishQuotaUpdate(info: unknown): void;
33
+ registerListener(agentId: string): void;
34
+ removeListener(agentId: string): void;
35
+ waitForMessage(agentId: string, timeoutMs: number): Promise<QueuedMessage | null>;
36
+ getQueuedMessages(agentId: string): QueuedMessage[];
37
+ mqttPublish(topic: string, payload: string): void;
38
+ disconnect(): Promise<void>;
39
+ }
40
+ export {};
@@ -0,0 +1,173 @@
1
+ import mqtt from "mqtt";
2
+ import { silentLogger } from "./logger.js";
3
+ export class MqttBridge {
4
+ client = null;
5
+ connected = false;
6
+ onOfflineHandler = null;
7
+ listeners = new Map();
8
+ log;
9
+ constructor(logger) {
10
+ this.log = logger || silentLogger;
11
+ }
12
+ async connect(config) {
13
+ return new Promise((resolve, reject) => {
14
+ const timeout = setTimeout(() => {
15
+ reject(new Error("MQTT connection timeout"));
16
+ }, 5000);
17
+ this.client = mqtt.connect(config.url, {
18
+ clientId: `coordinator-${Date.now()}`,
19
+ clean: true,
20
+ });
21
+ this.client.on("connect", () => {
22
+ clearTimeout(timeout);
23
+ this.connected = true;
24
+ this.log.info({ url: config.url }, "MQTT connected");
25
+ // Subscribe to agent status for LWT detection
26
+ this.client.subscribe("coordinator/agents/+/status");
27
+ resolve();
28
+ });
29
+ // Subscribe to consultation topics for agent listeners
30
+ this.client.subscribe("coordinator/consultations/#");
31
+ this.client.subscribe("coordinator/broadcast");
32
+ this.client.on("message", (topic, message) => {
33
+ const parts = topic.split("/");
34
+ if (parts[1] === "agents" && parts[3] === "status") {
35
+ const agentId = parts[2];
36
+ const status = message.toString();
37
+ if (status === "offline" && this.onOfflineHandler) {
38
+ this.onOfflineHandler(agentId);
39
+ }
40
+ }
41
+ // Route consultation messages to agent listeners
42
+ if (parts[1] === "consultations" || parts[1] === "broadcast") {
43
+ try {
44
+ const payload = JSON.parse(message.toString());
45
+ const msg = { topic, payload, timestamp: Date.now() };
46
+ for (const listener of this.listeners.values()) {
47
+ if (listener.waitResolve) {
48
+ const resolve = listener.waitResolve;
49
+ listener.waitResolve = null;
50
+ resolve(msg);
51
+ }
52
+ else {
53
+ listener.queue.push(msg);
54
+ }
55
+ }
56
+ }
57
+ catch { /* ignore malformed */ }
58
+ }
59
+ });
60
+ this.client.on("error", (err) => {
61
+ clearTimeout(timeout);
62
+ this.log.error({ err }, "MQTT error");
63
+ reject(err);
64
+ });
65
+ });
66
+ }
67
+ isConnected() {
68
+ return this.connected;
69
+ }
70
+ onOffline(handler) {
71
+ this.onOfflineHandler = handler;
72
+ }
73
+ registerAgent(agentId, name) {
74
+ if (!this.client || !this.connected)
75
+ return;
76
+ this.client.publish(`coordinator/agents/${agentId}/status`, JSON.stringify({ status: "online", name }), { retain: true });
77
+ }
78
+ publishConsultation(threadId, agentId, subject, targetModules) {
79
+ if (!this.client || !this.connected)
80
+ return;
81
+ this.client.publish("coordinator/consultations/new", JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }));
82
+ }
83
+ publishMessage(threadId, agentId, type, content) {
84
+ if (!this.client || !this.connected)
85
+ return;
86
+ this.client.publish(`coordinator/consultations/${threadId}/messages`, JSON.stringify({ agent_id: agentId, type, content }));
87
+ }
88
+ publishResolution(threadId, status, summary) {
89
+ if (!this.client || !this.connected)
90
+ return;
91
+ this.client.publish(`coordinator/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { retain: true });
92
+ }
93
+ publishBroadcast(agentId, message) {
94
+ if (!this.client || !this.connected)
95
+ return;
96
+ this.client.publish("coordinator/broadcast", JSON.stringify({ agent_id: agentId, message }));
97
+ }
98
+ publishAgentOffline(agentId) {
99
+ if (!this.client || !this.connected)
100
+ return;
101
+ this.client.publish(`coordinator/agents/${agentId}/status`, JSON.stringify({ status: "offline" }), { retain: true });
102
+ }
103
+ publishTaskClaimed(threadId, claimedBy) {
104
+ if (!this.client || !this.connected)
105
+ return;
106
+ this.client.publish(`coordinator/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }));
107
+ }
108
+ publishTaskCompleted(threadId, completedBy, summary) {
109
+ if (!this.client || !this.connected)
110
+ return;
111
+ this.client.publish(`coordinator/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }));
112
+ }
113
+ /**
114
+ * Fanout a refreshed QuotaInfo to live subscribers (dashboard widget,
115
+ * scheduled-agent runners, anything wanting realtime quota pressure).
116
+ * Typed via `unknown` to avoid an import cycle with the quota module.
117
+ */
118
+ publishQuotaUpdate(info) {
119
+ if (!this.client || !this.connected)
120
+ return;
121
+ this.client.publish("coordinator/quota/update", JSON.stringify(info));
122
+ }
123
+ // ── Agent listener methods (for integrated MCP tools) ──
124
+ registerListener(agentId) {
125
+ if (!this.listeners.has(agentId)) {
126
+ this.listeners.set(agentId, { queue: [], waitResolve: null });
127
+ }
128
+ }
129
+ removeListener(agentId) {
130
+ const listener = this.listeners.get(agentId);
131
+ if (listener?.waitResolve) {
132
+ listener.waitResolve(null); // unblock any waiting call
133
+ }
134
+ this.listeners.delete(agentId);
135
+ }
136
+ async waitForMessage(agentId, timeoutMs) {
137
+ this.registerListener(agentId);
138
+ const listener = this.listeners.get(agentId);
139
+ // Check queue first
140
+ if (listener.queue.length > 0) {
141
+ return listener.queue.shift();
142
+ }
143
+ // Block until message or timeout
144
+ return new Promise((resolve) => {
145
+ listener.waitResolve = resolve;
146
+ setTimeout(() => {
147
+ if (listener.waitResolve === resolve) {
148
+ listener.waitResolve = null;
149
+ resolve(null);
150
+ }
151
+ }, timeoutMs);
152
+ });
153
+ }
154
+ getQueuedMessages(agentId) {
155
+ this.registerListener(agentId);
156
+ const listener = this.listeners.get(agentId);
157
+ const messages = [...listener.queue];
158
+ listener.queue.length = 0;
159
+ return messages;
160
+ }
161
+ mqttPublish(topic, payload) {
162
+ if (this.client && this.connected) {
163
+ this.client.publish(topic, payload);
164
+ }
165
+ }
166
+ async disconnect() {
167
+ if (this.client) {
168
+ this.log.info("MQTT disconnected");
169
+ this.connected = false;
170
+ await this.client.endAsync();
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,23 @@
1
+ import type { Server as HttpServer } from "http";
2
+ import type { Logger } from "./logger.js";
3
+ export interface EmbeddedMqttBroker {
4
+ tcpPort: number | null;
5
+ wsPath: string | null;
6
+ close: () => Promise<void>;
7
+ }
8
+ export interface EmbeddedMqttOptions {
9
+ tcpPort?: number;
10
+ httpServer?: HttpServer;
11
+ wsPath?: string;
12
+ logger: Logger;
13
+ }
14
+ /**
15
+ * Start an embedded MQTT broker (aedes) exposed via TCP, WebSocket, or both.
16
+ * TCP and WS share the same aedes instance, so a client on ws:// can receive
17
+ * messages from a client on mqtt:// and vice versa.
18
+ *
19
+ * Uses Aedes.createBroker() to wait for the broker's async initialization
20
+ * before accepting connections — new Aedes() returns before the broker is
21
+ * fully ready, which causes client connect timeouts in compiled binaries.
22
+ */
23
+ export declare function startEmbeddedMqttBroker(opts: EmbeddedMqttOptions): Promise<EmbeddedMqttBroker>;
@@ -0,0 +1,99 @@
1
+ import { Aedes } from "aedes";
2
+ import { createServer as createTcpServer } from "net";
3
+ import { Duplex } from "stream";
4
+ import { WebSocketServer } from "ws";
5
+ /**
6
+ * Bridge a WebSocket to a Duplex stream for aedes.
7
+ * Replaces createWebSocketStream which is not supported in Bun.
8
+ */
9
+ function wsToDuplex(ws) {
10
+ const duplex = new Duplex({
11
+ read() { },
12
+ write(chunk, _encoding, callback) {
13
+ try {
14
+ ws.send(chunk);
15
+ callback();
16
+ }
17
+ catch (err) {
18
+ callback(err);
19
+ }
20
+ },
21
+ final(callback) {
22
+ ws.close();
23
+ callback();
24
+ },
25
+ });
26
+ ws.on("message", (data) => duplex.push(data));
27
+ ws.on("close", () => { duplex.push(null); duplex.destroy(); });
28
+ ws.on("error", (err) => duplex.destroy(err));
29
+ return duplex;
30
+ }
31
+ /**
32
+ * Start an embedded MQTT broker (aedes) exposed via TCP, WebSocket, or both.
33
+ * TCP and WS share the same aedes instance, so a client on ws:// can receive
34
+ * messages from a client on mqtt:// and vice versa.
35
+ *
36
+ * Uses Aedes.createBroker() to wait for the broker's async initialization
37
+ * before accepting connections — new Aedes() returns before the broker is
38
+ * fully ready, which causes client connect timeouts in compiled binaries.
39
+ */
40
+ export async function startEmbeddedMqttBroker(opts) {
41
+ const { tcpPort, httpServer, wsPath = "/mqtt", logger } = opts;
42
+ const broker = await Aedes.createBroker();
43
+ broker.on("client", (client) => {
44
+ logger.debug({ client_id: client?.id }, "MQTT client connected");
45
+ });
46
+ broker.on("clientDisconnect", (client) => {
47
+ logger.debug({ client_id: client?.id }, "MQTT client disconnected");
48
+ });
49
+ broker.on("clientError", (client, err) => {
50
+ logger.warn({ client_id: client?.id, err: err.message }, "MQTT client error");
51
+ });
52
+ let tcpServerClose = null;
53
+ let wsServerClose = null;
54
+ if (tcpPort && tcpPort > 0) {
55
+ const tcpServer = createTcpServer((socket) => {
56
+ broker.handle(socket);
57
+ });
58
+ // Bind to 127.0.0.1 explicitly — default binding to IPv6 (::) can cause
59
+ // the mqtt client (which resolves localhost → 127.0.0.1) to hang.
60
+ await new Promise((resolve, reject) => {
61
+ tcpServer.once("error", reject);
62
+ tcpServer.listen(tcpPort, "127.0.0.1", () => {
63
+ tcpServer.off("error", reject);
64
+ logger.info({ port: tcpPort, transport: "tcp" }, "Embedded MQTT broker listening");
65
+ resolve();
66
+ });
67
+ });
68
+ tcpServer.on("error", (err) => {
69
+ logger.error({ err, port: tcpPort }, "Embedded MQTT TCP server error");
70
+ });
71
+ tcpServerClose = () => new Promise((resolve) => tcpServer.close(() => resolve()));
72
+ }
73
+ if (httpServer) {
74
+ const wss = new WebSocketServer({ noServer: true });
75
+ wss.on("connection", (ws) => {
76
+ const duplex = wsToDuplex(ws);
77
+ broker.handle(duplex);
78
+ });
79
+ httpServer.on("upgrade", (req, socket, head) => {
80
+ const url = req.url || "";
81
+ if (url === wsPath || url.startsWith(`${wsPath}?`)) {
82
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
83
+ }
84
+ });
85
+ logger.info({ path: wsPath, transport: "ws" }, "Embedded MQTT broker listening on HTTP upgrade");
86
+ wsServerClose = () => new Promise((resolve) => wss.close(() => resolve()));
87
+ }
88
+ return {
89
+ tcpPort: tcpPort && tcpPort > 0 ? tcpPort : null,
90
+ wsPath: httpServer ? wsPath : null,
91
+ close: async () => {
92
+ if (tcpServerClose)
93
+ await tcpServerClose();
94
+ if (wsServerClose)
95
+ await wsServerClose();
96
+ await new Promise((resolve) => broker.close(() => resolve()));
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,11 @@
1
+ export interface PlanQualityResult {
2
+ mode: "with_plan" | "discovery";
3
+ score: number;
4
+ checks: {
5
+ mentions_files: boolean;
6
+ concrete_approach: boolean;
7
+ sufficient_detail: boolean;
8
+ };
9
+ original_plan: string | null;
10
+ }
11
+ export declare function assessPlanQuality(plan: string | null | undefined): PlanQualityResult;
@@ -0,0 +1,30 @@
1
+ export function assessPlanQuality(plan) {
2
+ if (!plan || plan.trim().length === 0) {
3
+ return {
4
+ mode: "discovery",
5
+ score: 0,
6
+ checks: { mentions_files: false, concrete_approach: false, sufficient_detail: false },
7
+ original_plan: null,
8
+ };
9
+ }
10
+ const trimmed = plan.trim();
11
+ // Check 1: Mentions specific files (paths with extensions or slashes)
12
+ const mentions_files = /\w+\/\w+|\.\w{2,4}\b/.test(trimmed);
13
+ // Check 2: Concrete approach — the plan contains concrete action verbs
14
+ // anywhere. Match word stems (no trailing \b) so inflected forms like
15
+ // "adding", "creating", "replaces", "implementing" are recognized.
16
+ // A leading vague word ("Fix", "Update") does NOT disqualify a plan that
17
+ // otherwise describes concrete actions.
18
+ const concretePatterns = /\b(ajout|cré|creat|add|split|extract|replac|remplac|implémen|implement|supprim|delet|remov|migr|wrap)/i;
19
+ const concrete_approach = concretePatterns.test(trimmed);
20
+ // Check 3: Sufficient detail (more than 20 words)
21
+ const wordCount = trimmed.split(/\s+/).length;
22
+ const sufficient_detail = wordCount > 20;
23
+ const score = [mentions_files, concrete_approach, sufficient_detail].filter(Boolean).length;
24
+ return {
25
+ mode: score >= 2 ? "with_plan" : "discovery",
26
+ score,
27
+ checks: { mentions_files, concrete_approach, sufficient_detail },
28
+ original_plan: trimmed,
29
+ };
30
+ }
@@ -0,0 +1,21 @@
1
+ export declare class NotImplementedError extends Error {
2
+ constructor(platform: NodeJS.Platform);
3
+ }
4
+ export interface CredentialReader {
5
+ readClaudeOAuthToken(): Promise<string>;
6
+ }
7
+ /**
8
+ * macOS implementation: shells out to `security find-generic-password` against
9
+ * the Claude Code-credentials entry in the user's Keychain, then parses the
10
+ * JSON blob the CLI stored.
11
+ */
12
+ export declare class MacOSCredentialReader implements CredentialReader {
13
+ readClaudeOAuthToken(): Promise<string>;
14
+ }
15
+ export declare class LinuxCredentialReader implements CredentialReader {
16
+ readClaudeOAuthToken(): Promise<string>;
17
+ }
18
+ export declare class WindowsCredentialReader implements CredentialReader {
19
+ readClaudeOAuthToken(): Promise<string>;
20
+ }
21
+ export declare function createCredentialReader(platform?: NodeJS.Platform): CredentialReader;
@@ -0,0 +1,86 @@
1
+ // OAuth credential access for Claude Code — reads the "Claude Code-credentials"
2
+ // secret stored by the Claude Code CLI and extracts the accessToken.
3
+ //
4
+ // macOS is the only platform with a real implementation; Linux and Windows
5
+ // stubs throw NotImplementedError so the coordinator can start on those
6
+ // platforms (the quota endpoint simply returns 503 — see quota-cache.ts /
7
+ // serve-http.ts for the fallback path that keeps raids running without a
8
+ // quota guardrail).
9
+ import { execFile } from "child_process";
10
+ import { promisify } from "util";
11
+ const execFileP = promisify(execFile);
12
+ export class NotImplementedError extends Error {
13
+ constructor(platform) {
14
+ super(`Claude OAuth credential reader not implemented for platform "${platform}" yet`);
15
+ this.name = "NotImplementedError";
16
+ }
17
+ }
18
+ /**
19
+ * macOS implementation: shells out to `security find-generic-password` against
20
+ * the Claude Code-credentials entry in the user's Keychain, then parses the
21
+ * JSON blob the CLI stored.
22
+ */
23
+ export class MacOSCredentialReader {
24
+ async readClaudeOAuthToken() {
25
+ let stdout;
26
+ try {
27
+ // -s = service, -w = print password only (the stored JSON blob).
28
+ const result = await execFileP("security", [
29
+ "find-generic-password",
30
+ "-s", "Claude Code-credentials",
31
+ "-w",
32
+ ]);
33
+ stdout = result.stdout;
34
+ }
35
+ catch (err) {
36
+ throw new Error(`security CLI failed — Keychain entry likely missing (${err.message})`);
37
+ }
38
+ const raw = stdout.trim();
39
+ if (!raw)
40
+ throw new Error("Keychain returned empty credential blob");
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch (err) {
46
+ throw new Error(`Keychain credential is not valid JSON: ${err.message}`);
47
+ }
48
+ const token = extractAccessToken(parsed);
49
+ if (!token)
50
+ throw new Error("claudeAiOauth.accessToken missing or empty");
51
+ return token;
52
+ }
53
+ }
54
+ export class LinuxCredentialReader {
55
+ // TODO: probably `secret-tool lookup` via libsecret, or read from
56
+ // ~/.config/claude-code/credentials.json — verify Claude Code's
57
+ // actual storage layout on Linux before implementing.
58
+ async readClaudeOAuthToken() {
59
+ throw new NotImplementedError("linux");
60
+ }
61
+ }
62
+ export class WindowsCredentialReader {
63
+ // TODO: likely the Windows Credential Manager via `cmdkey` or a native
64
+ // binding — verify where Claude Code stores its OAuth token on Win
65
+ // before implementing.
66
+ async readClaudeOAuthToken() {
67
+ throw new NotImplementedError("win32");
68
+ }
69
+ }
70
+ export function createCredentialReader(platform = process.platform) {
71
+ switch (platform) {
72
+ case "darwin": return new MacOSCredentialReader();
73
+ case "linux": return new LinuxCredentialReader();
74
+ case "win32": return new WindowsCredentialReader();
75
+ default: throw new NotImplementedError(platform);
76
+ }
77
+ }
78
+ function extractAccessToken(parsed) {
79
+ if (!parsed || typeof parsed !== "object")
80
+ return null;
81
+ const oauth = parsed.claudeAiOauth;
82
+ if (!oauth || typeof oauth !== "object")
83
+ return null;
84
+ const token = oauth.accessToken;
85
+ return typeof token === "string" && token.length > 0 ? token : null;
86
+ }
@@ -0,0 +1,93 @@
1
+ import type { CredentialReader } from "./credential-reader.js";
2
+ import { type QuotaInfo } from "./quota.js";
3
+ import type { Logger } from "../logger.js";
4
+ export declare const DEFAULT_TTL_MS = 120000;
5
+ export interface QuotaCacheOptions {
6
+ ttlMs?: number;
7
+ reader?: CredentialReader;
8
+ logger?: Logger;
9
+ /**
10
+ * Injected for tests so we don't hit network. Defaults to the real
11
+ * Anthropic fetcher.
12
+ */
13
+ fetcher?: (reader: CredentialReader) => Promise<QuotaInfo>;
14
+ /**
15
+ * Notified every time a refresh produces a new QuotaInfo. The server wires
16
+ * this to MQTT + SSE so dashboards see live updates.
17
+ */
18
+ onRefresh?: (info: QuotaInfo) => void;
19
+ }
20
+ export interface QuotaCacheStatus {
21
+ /** Last fetched info, or null if never successfully fetched. */
22
+ info: QuotaInfo | null;
23
+ /** True when the last fetch attempt threw (Keychain / network / parse). */
24
+ unavailable: boolean;
25
+ /** Most recent failure reason, for debug. */
26
+ lastError: string | null;
27
+ /** Number of agents currently registered as active (drives background refresh). */
28
+ activeAgents: number;
29
+ ttlMs: number;
30
+ /**
31
+ * When > now, the cache is in a 429 cool-down — it refuses to hit the
32
+ * Anthropic usage API until this time passes. Surfaced so the HTTP layer
33
+ * can explain the delay to the user instead of showing a generic 503.
34
+ */
35
+ cooldownUntil: number | null;
36
+ }
37
+ /**
38
+ * Returns true if the cached info is still within TTL. Pure function so
39
+ * callers can decide cache freshness without touching the cache instance.
40
+ */
41
+ export declare function isFresh(info: QuotaInfo | null, ttlMs: number, now?: number): boolean;
42
+ export declare class QuotaCache {
43
+ private info;
44
+ private lastError;
45
+ private refreshInFlight;
46
+ private activeAgents;
47
+ private backgroundTimer;
48
+ private cooldownUntil;
49
+ private readonly ttlMs;
50
+ private readonly reader;
51
+ private readonly logger;
52
+ private readonly fetcher;
53
+ private readonly onRefresh;
54
+ constructor(opts?: QuotaCacheOptions);
55
+ /**
56
+ * Returns the cached QuotaInfo if fresh, otherwise triggers a refresh and
57
+ * returns the new value. Returns null when fetching fails (fail-open path).
58
+ * Concurrent calls share a single in-flight fetch.
59
+ */
60
+ get(): Promise<QuotaInfo | null>;
61
+ /**
62
+ * Returns the cached value synchronously, even if stale or missing. Used
63
+ * by the HTTP handler to decide 200 vs 503 without awaiting a refresh.
64
+ */
65
+ snapshot(): QuotaCacheStatus;
66
+ /**
67
+ * Force a refresh now. Returns the new value or null on failure. Subsequent
68
+ * concurrent callers share the same in-flight promise so we never issue
69
+ * parallel Anthropic calls for the same refresh window. During a 429
70
+ * cool-down we short-circuit to the cached value (possibly null) without
71
+ * hitting the API — keeps us from hammering an endpoint that's already
72
+ * pushing back.
73
+ */
74
+ refresh(): Promise<QuotaInfo | null>;
75
+ private doRefresh;
76
+ /**
77
+ * Call when an agent connects so the background tick keeps the cache warm
78
+ * during an active run. Multiple calls without a matching decrement are
79
+ * idempotent above 0 — the tick runs whenever activeAgents > 0.
80
+ */
81
+ onAgentActive(): void;
82
+ /**
83
+ * Call when an agent disconnects so the background tick stops when the
84
+ * coordinator goes idle — no point re-fetching if nobody will read it.
85
+ */
86
+ onAgentInactive(): void;
87
+ /**
88
+ * Stop the timer so process teardown doesn't leak. Safe to call multiple
89
+ * times. The next onAgentActive() will restart it.
90
+ */
91
+ stopBackgroundTick(): void;
92
+ private startBackgroundTick;
93
+ }