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,177 @@
|
|
|
1
|
+
// Coordinator-side quota cache with hybrid refresh:
|
|
2
|
+
//
|
|
3
|
+
// - "lazy": a consumer requesting /api/quota while the cache is stale
|
|
4
|
+
// triggers a refresh on that call (single-flight-deduped so
|
|
5
|
+
// simultaneous requesters share the same in-flight fetch).
|
|
6
|
+
// - "background": when at least one agent is registered as active, a timer
|
|
7
|
+
// pre-refreshes the cache every TTL seconds so consumers
|
|
8
|
+
// always see fresh data without paying the Anthropic latency.
|
|
9
|
+
//
|
|
10
|
+
// When the credential reader throws NotImplementedError (non-macOS platforms
|
|
11
|
+
// pre-impl) or the API is down, getCachedQuota() returns null and the HTTP
|
|
12
|
+
// layer surfaces that as 503 — the consumer treats "unknown" as "continue",
|
|
13
|
+
// per the project decision to fail-open on quota checks.
|
|
14
|
+
import { createCredentialReader } from "./credential-reader.js";
|
|
15
|
+
import { fetchQuotaFromAnthropic, QuotaUnavailableError } from "./quota.js";
|
|
16
|
+
// 2 min — the quota endpoint moves in percentage points, not token counts, so
|
|
17
|
+
// sub-minute freshness is overkill. Lower values caused Anthropic to 429 the
|
|
18
|
+
// endpoint itself when several agents were online at once.
|
|
19
|
+
export const DEFAULT_TTL_MS = 120_000;
|
|
20
|
+
// Anthropic rate-limits the /api/oauth/usage endpoint itself. When we get 429,
|
|
21
|
+
// we stop hammering and wait this long (or the server-provided Retry-After)
|
|
22
|
+
// before attempting again. 5 min is a comfortable default for a usage endpoint
|
|
23
|
+
// that only moves in %, not token counts — we don't need sub-minute freshness.
|
|
24
|
+
const DEFAULT_429_COOLDOWN_MS = 5 * 60_000;
|
|
25
|
+
const MIN_429_COOLDOWN_MS = 60_000;
|
|
26
|
+
/**
|
|
27
|
+
* Returns true if the cached info is still within TTL. Pure function so
|
|
28
|
+
* callers can decide cache freshness without touching the cache instance.
|
|
29
|
+
*/
|
|
30
|
+
export function isFresh(info, ttlMs, now = Date.now()) {
|
|
31
|
+
if (!info)
|
|
32
|
+
return false;
|
|
33
|
+
return now - info.fetchedAt < ttlMs;
|
|
34
|
+
}
|
|
35
|
+
export class QuotaCache {
|
|
36
|
+
info = null;
|
|
37
|
+
lastError = null;
|
|
38
|
+
refreshInFlight = null;
|
|
39
|
+
activeAgents = 0;
|
|
40
|
+
backgroundTimer = null;
|
|
41
|
+
cooldownUntil = 0;
|
|
42
|
+
ttlMs;
|
|
43
|
+
reader;
|
|
44
|
+
logger;
|
|
45
|
+
fetcher;
|
|
46
|
+
onRefresh;
|
|
47
|
+
constructor(opts = {}) {
|
|
48
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
49
|
+
this.reader = opts.reader ?? createCredentialReader();
|
|
50
|
+
this.logger = opts.logger;
|
|
51
|
+
this.fetcher = opts.fetcher ?? fetchQuotaFromAnthropic;
|
|
52
|
+
this.onRefresh = opts.onRefresh;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns the cached QuotaInfo if fresh, otherwise triggers a refresh and
|
|
56
|
+
* returns the new value. Returns null when fetching fails (fail-open path).
|
|
57
|
+
* Concurrent calls share a single in-flight fetch.
|
|
58
|
+
*/
|
|
59
|
+
async get() {
|
|
60
|
+
if (isFresh(this.info, this.ttlMs))
|
|
61
|
+
return this.info;
|
|
62
|
+
return this.refresh();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Returns the cached value synchronously, even if stale or missing. Used
|
|
66
|
+
* by the HTTP handler to decide 200 vs 503 without awaiting a refresh.
|
|
67
|
+
*/
|
|
68
|
+
snapshot() {
|
|
69
|
+
return {
|
|
70
|
+
info: this.info,
|
|
71
|
+
unavailable: this.info === null && this.lastError !== null,
|
|
72
|
+
lastError: this.lastError,
|
|
73
|
+
activeAgents: this.activeAgents,
|
|
74
|
+
ttlMs: this.ttlMs,
|
|
75
|
+
cooldownUntil: this.cooldownUntil > Date.now() ? this.cooldownUntil : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Force a refresh now. Returns the new value or null on failure. Subsequent
|
|
80
|
+
* concurrent callers share the same in-flight promise so we never issue
|
|
81
|
+
* parallel Anthropic calls for the same refresh window. During a 429
|
|
82
|
+
* cool-down we short-circuit to the cached value (possibly null) without
|
|
83
|
+
* hitting the API — keeps us from hammering an endpoint that's already
|
|
84
|
+
* pushing back.
|
|
85
|
+
*/
|
|
86
|
+
async refresh() {
|
|
87
|
+
if (Date.now() < this.cooldownUntil) {
|
|
88
|
+
this.logger?.debug({ cooldown_ms_remaining: this.cooldownUntil - Date.now() }, "quota refresh skipped — 429 cool-down active");
|
|
89
|
+
return this.info;
|
|
90
|
+
}
|
|
91
|
+
if (this.refreshInFlight)
|
|
92
|
+
return this.refreshInFlight;
|
|
93
|
+
this.refreshInFlight = this.doRefresh()
|
|
94
|
+
.finally(() => { this.refreshInFlight = null; });
|
|
95
|
+
return this.refreshInFlight;
|
|
96
|
+
}
|
|
97
|
+
async doRefresh() {
|
|
98
|
+
try {
|
|
99
|
+
const info = await this.fetcher(this.reader);
|
|
100
|
+
this.info = info;
|
|
101
|
+
this.lastError = null;
|
|
102
|
+
this.cooldownUntil = 0;
|
|
103
|
+
this.logger?.debug({ five_hour: info.fiveHour.utilization, seven_day: info.sevenDay.utilization }, "quota refreshed");
|
|
104
|
+
this.onRefresh?.(info);
|
|
105
|
+
return info;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = err instanceof QuotaUnavailableError ? err.message : err.message;
|
|
109
|
+
this.lastError = msg;
|
|
110
|
+
// 429 = Anthropic itself is rate-limiting the usage endpoint. Back off
|
|
111
|
+
// to avoid making it worse — respect Retry-After when provided,
|
|
112
|
+
// otherwise apply a 5-minute cool-down. MIN_429_COOLDOWN_MS prevents
|
|
113
|
+
// Retry-After spoofing / zero values from letting us spam.
|
|
114
|
+
if (err instanceof QuotaUnavailableError && err.httpStatus === 429) {
|
|
115
|
+
const suggestedMs = err.retryAfterSeconds !== undefined
|
|
116
|
+
? err.retryAfterSeconds * 1000
|
|
117
|
+
: DEFAULT_429_COOLDOWN_MS;
|
|
118
|
+
const cooldownMs = Math.max(suggestedMs, MIN_429_COOLDOWN_MS);
|
|
119
|
+
this.cooldownUntil = Date.now() + cooldownMs;
|
|
120
|
+
this.logger?.warn({ cooldown_ms: cooldownMs }, "quota 429 — backing off");
|
|
121
|
+
}
|
|
122
|
+
else if (this.info === null) {
|
|
123
|
+
// First-ever fetch failed for some other reason — warn once.
|
|
124
|
+
this.logger?.warn({ err: msg }, "quota fetch failed — consumers will see 503");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.logger?.debug({ err: msg }, "quota refresh failed — serving stale cache");
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Call when an agent connects so the background tick keeps the cache warm
|
|
134
|
+
* during an active run. Multiple calls without a matching decrement are
|
|
135
|
+
* idempotent above 0 — the tick runs whenever activeAgents > 0.
|
|
136
|
+
*/
|
|
137
|
+
onAgentActive() {
|
|
138
|
+
this.activeAgents++;
|
|
139
|
+
if (this.backgroundTimer === null)
|
|
140
|
+
this.startBackgroundTick();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Call when an agent disconnects so the background tick stops when the
|
|
144
|
+
* coordinator goes idle — no point re-fetching if nobody will read it.
|
|
145
|
+
*/
|
|
146
|
+
onAgentInactive() {
|
|
147
|
+
if (this.activeAgents > 0)
|
|
148
|
+
this.activeAgents--;
|
|
149
|
+
if (this.activeAgents === 0)
|
|
150
|
+
this.stopBackgroundTick();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Stop the timer so process teardown doesn't leak. Safe to call multiple
|
|
154
|
+
* times. The next onAgentActive() will restart it.
|
|
155
|
+
*/
|
|
156
|
+
stopBackgroundTick() {
|
|
157
|
+
if (this.backgroundTimer) {
|
|
158
|
+
clearInterval(this.backgroundTimer);
|
|
159
|
+
this.backgroundTimer = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
startBackgroundTick() {
|
|
163
|
+
// Kick off one refresh immediately so the first consumer after a cold
|
|
164
|
+
// start doesn't eat the network latency.
|
|
165
|
+
void this.refresh();
|
|
166
|
+
this.backgroundTimer = setInterval(() => {
|
|
167
|
+
// Skip the tick if we have no agents — onAgentInactive will stop us,
|
|
168
|
+
// but belt-and-suspenders in case counting goes wrong.
|
|
169
|
+
if (this.activeAgents === 0)
|
|
170
|
+
return;
|
|
171
|
+
void this.refresh();
|
|
172
|
+
}, this.ttlMs);
|
|
173
|
+
// Don't keep the event loop alive just for the tick.
|
|
174
|
+
if (typeof this.backgroundTimer.unref === "function")
|
|
175
|
+
this.backgroundTimer.unref();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CredentialReader } from "./credential-reader.js";
|
|
2
|
+
export interface QuotaBucket {
|
|
3
|
+
/** 0.0 – 100.0 */
|
|
4
|
+
utilization: number;
|
|
5
|
+
/** ISO 8601 timestamp when this bucket resets */
|
|
6
|
+
resetsAt: string;
|
|
7
|
+
/** Positive = reset is in the future; negative = already reset */
|
|
8
|
+
minutesUntilReset: number;
|
|
9
|
+
}
|
|
10
|
+
export interface QuotaInfo {
|
|
11
|
+
fiveHour: QuotaBucket;
|
|
12
|
+
sevenDay: QuotaBucket;
|
|
13
|
+
/** The 7-day bucket limited to Sonnet usage. Anthropic omits it when absent. */
|
|
14
|
+
sevenDaySonnet: QuotaBucket | null;
|
|
15
|
+
/** Wall-clock time the coordinator fetched this, for cache freshness checks. */
|
|
16
|
+
fetchedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class QuotaUnavailableError extends Error {
|
|
19
|
+
readonly cause?: unknown | undefined;
|
|
20
|
+
/** HTTP status from the upstream API when available — lets the cache
|
|
21
|
+
* differentiate "retry now" vs "back off" (e.g. 429 → cool-down). */
|
|
22
|
+
readonly httpStatus?: number | undefined;
|
|
23
|
+
/** Retry-After hint (seconds) from the upstream when provided. */
|
|
24
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
25
|
+
constructor(message: string, cause?: unknown | undefined,
|
|
26
|
+
/** HTTP status from the upstream API when available — lets the cache
|
|
27
|
+
* differentiate "retry now" vs "back off" (e.g. 429 → cool-down). */
|
|
28
|
+
httpStatus?: number | undefined,
|
|
29
|
+
/** Retry-After hint (seconds) from the upstream when provided. */
|
|
30
|
+
retryAfterSeconds?: number | undefined);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the current quota info from Anthropic. Throws QuotaUnavailableError
|
|
34
|
+
* if the credential is missing (non-macOS platforms stub this path), the API
|
|
35
|
+
* is unreachable, or the response shape is unexpected.
|
|
36
|
+
*/
|
|
37
|
+
export declare function fetchQuotaFromAnthropic(reader: CredentialReader): Promise<QuotaInfo>;
|
|
38
|
+
/**
|
|
39
|
+
* Parse the JSON payload returned by /api/oauth/usage. Exported so tests can
|
|
40
|
+
* exercise shape handling without hitting the network.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseUsageResponse(payload: unknown): QuotaInfo;
|
|
43
|
+
/**
|
|
44
|
+
* Positive = date is in the future. Returns 0 on an unparseable date so
|
|
45
|
+
* callers can still make a "quota already reset" decision without crashing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function minutesUntil(iso8601: string): number;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Anthropic OAuth quota endpoint — port from reserve/agents/src/quota.rs.
|
|
2
|
+
//
|
|
3
|
+
// Pipeline: credential reader → OAuth access token → HTTP GET /api/oauth/usage
|
|
4
|
+
// → QuotaInfo with 3 buckets (5h / 7d / 7d-sonnet).
|
|
5
|
+
//
|
|
6
|
+
// This module is the wire protocol. The cache + refresh strategy live in
|
|
7
|
+
// quota-cache.ts so this file stays side-effect-free and easy to test.
|
|
8
|
+
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
9
|
+
const BETA_HEADER = "oauth-2025-04-20";
|
|
10
|
+
export class QuotaUnavailableError extends Error {
|
|
11
|
+
cause;
|
|
12
|
+
httpStatus;
|
|
13
|
+
retryAfterSeconds;
|
|
14
|
+
constructor(message, cause,
|
|
15
|
+
/** HTTP status from the upstream API when available — lets the cache
|
|
16
|
+
* differentiate "retry now" vs "back off" (e.g. 429 → cool-down). */
|
|
17
|
+
httpStatus,
|
|
18
|
+
/** Retry-After hint (seconds) from the upstream when provided. */
|
|
19
|
+
retryAfterSeconds) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.cause = cause;
|
|
22
|
+
this.httpStatus = httpStatus;
|
|
23
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
24
|
+
this.name = "QuotaUnavailableError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Fetch the current quota info from Anthropic. Throws QuotaUnavailableError
|
|
29
|
+
* if the credential is missing (non-macOS platforms stub this path), the API
|
|
30
|
+
* is unreachable, or the response shape is unexpected.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchQuotaFromAnthropic(reader) {
|
|
33
|
+
let token;
|
|
34
|
+
try {
|
|
35
|
+
token = await reader.readClaudeOAuthToken();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new QuotaUnavailableError(`cannot read OAuth token: ${err.message}`, err);
|
|
39
|
+
}
|
|
40
|
+
let resp;
|
|
41
|
+
try {
|
|
42
|
+
resp = await fetch(USAGE_URL, {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
"anthropic-beta": BETA_HEADER,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new QuotaUnavailableError(`cannot reach Anthropic usage API: ${err.message}`, err);
|
|
51
|
+
}
|
|
52
|
+
if (!resp.ok) {
|
|
53
|
+
const retryAfterRaw = resp.headers.get("retry-after");
|
|
54
|
+
const retryAfter = retryAfterRaw ? Number(retryAfterRaw) : undefined;
|
|
55
|
+
throw new QuotaUnavailableError(`usage API returned HTTP ${resp.status}`, undefined, resp.status, Number.isFinite(retryAfter) ? retryAfter : undefined);
|
|
56
|
+
}
|
|
57
|
+
let body;
|
|
58
|
+
try {
|
|
59
|
+
body = await resp.json();
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
throw new QuotaUnavailableError(`usage API returned non-JSON body: ${err.message}`, err);
|
|
63
|
+
}
|
|
64
|
+
return parseUsageResponse(body);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse the JSON payload returned by /api/oauth/usage. Exported so tests can
|
|
68
|
+
* exercise shape handling without hitting the network.
|
|
69
|
+
*/
|
|
70
|
+
export function parseUsageResponse(payload) {
|
|
71
|
+
if (!payload || typeof payload !== "object") {
|
|
72
|
+
throw new QuotaUnavailableError("usage API body is not an object");
|
|
73
|
+
}
|
|
74
|
+
const rec = payload;
|
|
75
|
+
const fiveHour = parseBucket(rec, "five_hour"); // required
|
|
76
|
+
const sevenDay = parseBucket(rec, "seven_day"); // required
|
|
77
|
+
// Anthropic sometimes omits the sonnet-specific bucket (e.g. workspaces
|
|
78
|
+
// without a seven_day_sonnet cap). Optional — null signals "not tracked".
|
|
79
|
+
let sevenDaySonnet = null;
|
|
80
|
+
try {
|
|
81
|
+
sevenDaySonnet = parseBucket(rec, "seven_day_sonnet");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
sevenDaySonnet = null;
|
|
85
|
+
}
|
|
86
|
+
return { fiveHour, sevenDay, sevenDaySonnet, fetchedAt: Date.now() };
|
|
87
|
+
}
|
|
88
|
+
function parseBucket(payload, key) {
|
|
89
|
+
const raw = payload[key];
|
|
90
|
+
if (!raw || typeof raw !== "object") {
|
|
91
|
+
throw new QuotaUnavailableError(`usage API: field "${key}" missing or not an object`);
|
|
92
|
+
}
|
|
93
|
+
const bucket = raw;
|
|
94
|
+
const utilization = bucket.utilization;
|
|
95
|
+
if (typeof utilization !== "number") {
|
|
96
|
+
throw new QuotaUnavailableError(`usage API: "${key}.utilization" missing or non-numeric`);
|
|
97
|
+
}
|
|
98
|
+
const resetsAt = bucket.resets_at;
|
|
99
|
+
if (typeof resetsAt !== "string") {
|
|
100
|
+
throw new QuotaUnavailableError(`usage API: "${key}.resets_at" missing or non-string`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
utilization,
|
|
104
|
+
resetsAt,
|
|
105
|
+
minutesUntilReset: minutesUntil(resetsAt),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Positive = date is in the future. Returns 0 on an unparseable date so
|
|
110
|
+
* callers can still make a "quota already reset" decision without crashing.
|
|
111
|
+
*/
|
|
112
|
+
export function minutesUntil(iso8601) {
|
|
113
|
+
const t = Date.parse(iso8601);
|
|
114
|
+
if (Number.isNaN(t))
|
|
115
|
+
return 0;
|
|
116
|
+
return Math.round((t - Date.now()) / 60_000);
|
|
117
|
+
}
|