agent-runway 0.2.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/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +27 -0
- package/LICENSE +21 -0
- package/README.md +376 -0
- package/package.json +52 -0
- package/skills/agent-runway/SKILL.md +92 -0
- package/src/cache.mjs +90 -0
- package/src/cli.mjs +200 -0
- package/src/core.mjs +300 -0
- package/src/installs.mjs +425 -0
- package/src/mcp.mjs +279 -0
- package/src/models.mjs +303 -0
- package/src/providers/antigravity.mjs +132 -0
- package/src/providers/claude.mjs +51 -0
- package/src/providers/codex.mjs +119 -0
- package/src/providers/copilot.mjs +87 -0
- package/src/providers/index.mjs +155 -0
- package/src/providers/shared.mjs +123 -0
- package/src/render.mjs +162 -0
- package/src/setup.mjs +412 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Provider registry: read one, or read them all at once.
|
|
2
|
+
//
|
|
3
|
+
// Reading several providers must never fail as a whole. Antigravity only
|
|
4
|
+
// answers while its IDE runs, a Codex token expires, `gh` may be absent — any
|
|
5
|
+
// of those is a per-provider status, never an exception that loses the other
|
|
6
|
+
// three. Every adapter is wrapped so a throw or a hang degrades that provider
|
|
7
|
+
// alone.
|
|
8
|
+
|
|
9
|
+
import * as cache from "../cache.mjs";
|
|
10
|
+
import * as antigravity from "./antigravity.mjs";
|
|
11
|
+
import * as claude from "./claude.mjs";
|
|
12
|
+
import * as codex from "./codex.mjs";
|
|
13
|
+
import * as copilot from "./copilot.mjs";
|
|
14
|
+
import { bindingWindow, secondsUntilReset, unavailable } from "./shared.mjs";
|
|
15
|
+
|
|
16
|
+
export const ADAPTERS = { claude, codex, copilot, antigravity };
|
|
17
|
+
export const PROVIDER_IDS = Object.keys(ADAPTERS);
|
|
18
|
+
|
|
19
|
+
const HARD_TIMEOUT_MS = 20000;
|
|
20
|
+
|
|
21
|
+
/** Never throws, never hangs: an adapter's failure becomes that provider's status. */
|
|
22
|
+
async function readOne(name, options) {
|
|
23
|
+
const adapter = ADAPTERS[name];
|
|
24
|
+
if (!adapter) return unavailable(name, "error", "unknown provider");
|
|
25
|
+
|
|
26
|
+
const maxAge = options.cacheMs ?? cache.ttlMs(options.env ?? process.env);
|
|
27
|
+
|
|
28
|
+
// A recent answer beats asking again: these endpoints rate-limit reads, and a
|
|
29
|
+
// 429 on a usage endpoint reads like the account quota it reports on.
|
|
30
|
+
const hit = cache.read(name, maxAge);
|
|
31
|
+
if (hit?.fresh) {
|
|
32
|
+
return { ...hit.value, cached: true, ageMs: hit.ageMs, latencyMs: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
const guard = new Promise((resolve) =>
|
|
37
|
+
setTimeout(
|
|
38
|
+
() => resolve(unavailable(name, "unreachable", `no answer within ${HARD_TIMEOUT_MS / 1000}s`)),
|
|
39
|
+
options.hardTimeoutMs ?? HARD_TIMEOUT_MS
|
|
40
|
+
).unref?.()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await Promise.race([adapter.read(options), guard]);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
result = unavailable(name, "error", String(error?.message ?? error));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const enriched = { ...result, label: adapter.label ?? name, latencyMs: Date.now() - started };
|
|
51
|
+
|
|
52
|
+
if (enriched.status === "ok") {
|
|
53
|
+
cache.write(name, enriched);
|
|
54
|
+
return enriched;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Failed. Stale numbers about a five-hour window still say more than silence,
|
|
58
|
+
// so long as they are labelled as stale rather than passed off as current.
|
|
59
|
+
if (hit) {
|
|
60
|
+
return {
|
|
61
|
+
...hit.value,
|
|
62
|
+
cached: true,
|
|
63
|
+
stale: true,
|
|
64
|
+
ageMs: hit.ageMs,
|
|
65
|
+
latencyMs: enriched.latencyMs,
|
|
66
|
+
detail: `serving a cached reading ${Math.round(hit.ageMs / 1000)}s old: ${enriched.detail ?? enriched.status}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return enriched;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} [options]
|
|
74
|
+
* @param {string[]} [options.providers] subset to read; defaults to all
|
|
75
|
+
* @returns {Promise<Array>} one result per provider, in registry order
|
|
76
|
+
*/
|
|
77
|
+
export async function readAll(options = {}) {
|
|
78
|
+
const names = options.providers?.length ? options.providers : PROVIDER_IDS;
|
|
79
|
+
// In parallel: Antigravity spawns two processes and Copilot shells out to gh,
|
|
80
|
+
// so serial reads would add up to seconds.
|
|
81
|
+
return Promise.all(names.map((name) => readOne(name, options)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const readProvider = readOne;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Turn readings into a decision.
|
|
88
|
+
*
|
|
89
|
+
* `threshold` is a policy, not a fact: at 92% the provider is not blocked, the
|
|
90
|
+
* caller's own rule says not to start. Three outcomes, because two are not
|
|
91
|
+
* enough — a provider that cannot be read is "unknown", never "fine".
|
|
92
|
+
*/
|
|
93
|
+
export function capacity(results, { threshold = 90 } = {}) {
|
|
94
|
+
const providers = results.map((r) => {
|
|
95
|
+
if (r.status !== "ok") {
|
|
96
|
+
return { provider: r.provider, label: r.label, decision: "unknown", reason: r.status, detail: r.detail };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const binding = bindingWindow(r.windows);
|
|
100
|
+
if (!binding || binding.percentUsed == null) {
|
|
101
|
+
return { provider: r.provider, label: r.label, decision: "unknown", reason: "no_readable_window" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Codex states outright whether it will serve a request; that beats a
|
|
105
|
+
// percentage we interpreted ourselves.
|
|
106
|
+
if (r.allowed === false) {
|
|
107
|
+
return {
|
|
108
|
+
provider: r.provider, label: r.label, decision: "defer", reason: "provider_says_limit_reached",
|
|
109
|
+
binding, retryAt: binding.resetsAt, retryAtBasis: "reported_reset",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const over = binding.percentUsed >= threshold;
|
|
114
|
+
return {
|
|
115
|
+
provider: r.provider,
|
|
116
|
+
label: r.label,
|
|
117
|
+
decision: over ? "defer" : "proceed",
|
|
118
|
+
reason: over ? "threshold_exceeded" : "within_threshold",
|
|
119
|
+
binding,
|
|
120
|
+
retryAt: over ? binding.resetsAt : null,
|
|
121
|
+
// The reset is when the window rolls over, not a promise that service
|
|
122
|
+
// resumes exactly then. Naming the basis keeps the two apart.
|
|
123
|
+
retryAtBasis: over ? "window_reset" : null,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const usable = providers.filter((p) => p.decision === "proceed" && p.binding);
|
|
128
|
+
|
|
129
|
+
// Ranking rule, stated rather than implied: most headroom in the window that
|
|
130
|
+
// would bind first. Windows of different cadence are NOT interchangeable —
|
|
131
|
+
// 0% of a five-hour window is far less capacity than 0% of a monthly
|
|
132
|
+
// allowance — so the basis travels with the recommendation.
|
|
133
|
+
const recommended = usable.length
|
|
134
|
+
? usable.reduce((best, p) => (p.binding.percentUsed < best.binding.percentUsed ? p : best))
|
|
135
|
+
: null;
|
|
136
|
+
|
|
137
|
+
const cadences = new Set(usable.map((p) => p.binding.windowSeconds ?? "calendar"));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
threshold,
|
|
141
|
+
providers,
|
|
142
|
+
recommended: recommended
|
|
143
|
+
? {
|
|
144
|
+
provider: recommended.provider,
|
|
145
|
+
percentUsed: recommended.binding.percentUsed,
|
|
146
|
+
window: recommended.binding.label,
|
|
147
|
+
windowSeconds: recommended.binding.windowSeconds,
|
|
148
|
+
secondsUntilReset: secondsUntilReset(recommended.binding),
|
|
149
|
+
rule: "least consumed binding window among providers under the threshold",
|
|
150
|
+
comparable: cadences.size <= 1,
|
|
151
|
+
}
|
|
152
|
+
: null,
|
|
153
|
+
anyUnknown: providers.some((p) => p.decision === "unknown"),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The normalized contract every provider adapter must produce.
|
|
2
|
+
//
|
|
3
|
+
// Four providers were probed to design this, and each reports "how full is it"
|
|
4
|
+
// differently: Claude and Codex give a percentage consumed, Copilot gives a
|
|
5
|
+
// percentage remaining plus raw counts, Antigravity gives a 0-1 fraction
|
|
6
|
+
// remaining plus credits. Adapters convert into one direction — percentUsed —
|
|
7
|
+
// and keep the raw numbers when a provider supplies them, because "200 of 2000
|
|
8
|
+
// chat requests" says more than "90%".
|
|
9
|
+
|
|
10
|
+
/** @typedef {"ok"|"no_credentials"|"not_installed"|"unreachable"|"error"} ProviderStatus */
|
|
11
|
+
|
|
12
|
+
export const UNKNOWN = null;
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------- percentages
|
|
15
|
+
|
|
16
|
+
export const fromUsedPercent = (n) => clampPercent(n);
|
|
17
|
+
export const fromRemainingPercent = (n) => clampPercent(100 - n);
|
|
18
|
+
export const fromRemainingFraction = (f) => clampPercent(100 - f * 100);
|
|
19
|
+
|
|
20
|
+
/** Copilot-style: remaining out of an entitlement. */
|
|
21
|
+
export function fromCounts(remaining, entitlement) {
|
|
22
|
+
if (!Number.isFinite(remaining) || !Number.isFinite(entitlement) || entitlement <= 0) return UNKNOWN;
|
|
23
|
+
return clampPercent(100 - (remaining / entitlement) * 100);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clampPercent(n) {
|
|
27
|
+
if (!Number.isFinite(n)) return UNKNOWN;
|
|
28
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -------------------------------------------------------------------- windows
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One quota window, whatever its cadence.
|
|
35
|
+
*
|
|
36
|
+
* `kind` is an open string on purpose. Codex already exposes a field called
|
|
37
|
+
* `additional_rate_limits`, and providers state they may add model- or
|
|
38
|
+
* feature-scoped caps, so an unrecognised kind must survive to the caller
|
|
39
|
+
* rather than be dropped.
|
|
40
|
+
*
|
|
41
|
+
* `startsAt` is only ever computed when `windowSeconds` is known, and
|
|
42
|
+
* `windowSource` records whether the provider reported that duration (Codex) or
|
|
43
|
+
* whether we inferred it (Claude). A caller can then choose to trust it or not.
|
|
44
|
+
*/
|
|
45
|
+
export function makeWindow({
|
|
46
|
+
kind,
|
|
47
|
+
label,
|
|
48
|
+
percentUsed,
|
|
49
|
+
resetsAt = null,
|
|
50
|
+
windowSeconds = null,
|
|
51
|
+
windowSource = null,
|
|
52
|
+
remaining = null,
|
|
53
|
+
entitlement = null,
|
|
54
|
+
entitled = true,
|
|
55
|
+
severity = null,
|
|
56
|
+
model = null,
|
|
57
|
+
unit = "requests",
|
|
58
|
+
}) {
|
|
59
|
+
const startsAt =
|
|
60
|
+
resetsAt && Number.isFinite(windowSeconds)
|
|
61
|
+
? new Date(Date.parse(resetsAt) - windowSeconds * 1000).toISOString()
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
kind,
|
|
66
|
+
label: label ?? kind,
|
|
67
|
+
percentUsed,
|
|
68
|
+
remaining,
|
|
69
|
+
entitlement,
|
|
70
|
+
entitled,
|
|
71
|
+
unit,
|
|
72
|
+
resetsAt,
|
|
73
|
+
windowSeconds,
|
|
74
|
+
windowSource,
|
|
75
|
+
startsAt,
|
|
76
|
+
severity,
|
|
77
|
+
model,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Seconds until a window resets; null when unknown or already elapsed. */
|
|
82
|
+
export function secondsUntilReset(window, now = Date.now()) {
|
|
83
|
+
if (!window?.resetsAt) return UNKNOWN;
|
|
84
|
+
const delta = Math.round((Date.parse(window.resetsAt) - now) / 1000);
|
|
85
|
+
return Number.isFinite(delta) ? delta : UNKNOWN;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The window that will bite first: the most consumed one among those the
|
|
90
|
+
* account is actually entitled to. A quota the plan does not include is not a
|
|
91
|
+
* constraint, it is an absence — Copilot's free tier reports
|
|
92
|
+
* premium_interactions at 0 of 0, which a naive reading calls "exhausted".
|
|
93
|
+
*/
|
|
94
|
+
export function bindingWindow(windows = []) {
|
|
95
|
+
const real = windows.filter((w) => w.entitled && Number.isFinite(w.percentUsed));
|
|
96
|
+
if (!real.length) return null;
|
|
97
|
+
return real.reduce((worst, w) => (w.percentUsed > worst.percentUsed ? w : worst));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ------------------------------------------------------------------- results
|
|
101
|
+
|
|
102
|
+
export function ok(provider, { plan = null, allowed = null, windows = [], detail = null }) {
|
|
103
|
+
return { provider, status: "ok", plan, allowed, windows, detail, checkedAt: new Date().toISOString() };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function unavailable(provider, status, detail) {
|
|
107
|
+
return {
|
|
108
|
+
provider,
|
|
109
|
+
status,
|
|
110
|
+
plan: null,
|
|
111
|
+
allowed: null,
|
|
112
|
+
windows: [],
|
|
113
|
+
detail,
|
|
114
|
+
checkedAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Identity fields travel in three of the four payloads (name, email, user id).
|
|
120
|
+
* Adapters must never copy them into a result: `--json` output ends up in CI
|
|
121
|
+
* logs and shared terminals.
|
|
122
|
+
*/
|
|
123
|
+
export const IDENTITY_KEYS = /^(email|name|user_?id|account_?id|login|analytics_tracking_id)$/i;
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Text rendering. Kept separate from fetching so the MCP server can reuse it.
|
|
2
|
+
|
|
3
|
+
const LABEL_WIDTH = 32;
|
|
4
|
+
const BAR_WIDTH = 20;
|
|
5
|
+
|
|
6
|
+
const SEVERITY_NOTE = {
|
|
7
|
+
normal: "",
|
|
8
|
+
warning: " <-- warning",
|
|
9
|
+
critical: " <-- critical",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function bar(percent) {
|
|
13
|
+
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(percent / (100 / BAR_WIDTH))));
|
|
14
|
+
return `[${"#".repeat(filled)}${".".repeat(BAR_WIDTH - filled)}]`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatReset(value, now = Date.now()) {
|
|
18
|
+
if (value == null) return null;
|
|
19
|
+
const date = typeof value === "number"
|
|
20
|
+
? new Date(value < 1e12 ? value * 1000 : value)
|
|
21
|
+
: new Date(value);
|
|
22
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
23
|
+
|
|
24
|
+
const minutes = Math.round((date.getTime() - now) / 60000);
|
|
25
|
+
let relative;
|
|
26
|
+
if (minutes < 0) relative = "elapsed";
|
|
27
|
+
else if (minutes < 60) relative = `in ${minutes}m`;
|
|
28
|
+
else if (minutes < 2880) relative = `in ${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
29
|
+
else relative = `in ${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
|
|
30
|
+
|
|
31
|
+
return `${date.toISOString().slice(0, 16).replace("T", " ")}Z (${relative})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One line per window, for humans. */
|
|
35
|
+
export function renderTable(usage, now = Date.now()) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (!usage.windows.length) {
|
|
38
|
+
lines.push(" No usage window reported.");
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const window of usage.windows) {
|
|
43
|
+
const marker = window.active ? "* " : " ";
|
|
44
|
+
const note = SEVERITY_NOTE[window.severity] ?? "";
|
|
45
|
+
lines.push(
|
|
46
|
+
marker +
|
|
47
|
+
window.label.padEnd(LABEL_WIDTH) +
|
|
48
|
+
bar(window.percent) +
|
|
49
|
+
` ${String(Math.round(window.percent)).padStart(3)} %` +
|
|
50
|
+
note
|
|
51
|
+
);
|
|
52
|
+
const reset = formatReset(window.resetsAt, now);
|
|
53
|
+
if (reset) lines.push(" ".repeat(LABEL_WIDTH + 2) + `resets ${reset}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (usage.extraUsage) {
|
|
57
|
+
lines.push("");
|
|
58
|
+
if (!usage.extraUsage.enabled) {
|
|
59
|
+
lines.push(" Extra usage credits: disabled");
|
|
60
|
+
} else {
|
|
61
|
+
const percent = usage.extraUsage.percent == null ? "?" : `${usage.extraUsage.percent} %`;
|
|
62
|
+
const cap = usage.extraUsage.monthlyLimit
|
|
63
|
+
? ` of ${usage.extraUsage.monthlyLimit} ${usage.extraUsage.currency ?? ""}`.trimEnd()
|
|
64
|
+
: "";
|
|
65
|
+
lines.push(` Extra usage credits: enabled, ${percent} used${cap}`);
|
|
66
|
+
if (usage.extraUsage.spendLimitReached) lines.push(" !! spend limit reached");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (usage.windows.some((w) => w.active)) {
|
|
71
|
+
lines.push("");
|
|
72
|
+
// The API's is_active flag does NOT mark the window being consumed right
|
|
73
|
+
// now: observed three times, twice with the ordering reversed, it lands on
|
|
74
|
+
// whichever window is furthest along. A session at 3% while actively in use
|
|
75
|
+
// goes unflagged and an untouched weekly at 87% carries it.
|
|
76
|
+
lines.push(" * = closest to its limit, as the API flags it");
|
|
77
|
+
}
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every provider at once, each with the state of its own read.
|
|
83
|
+
*
|
|
84
|
+
* A provider that could not be reached gets a line saying so rather than being
|
|
85
|
+
* dropped: the absence is information, and silently showing three of four would
|
|
86
|
+
* invite a delegation to the missing one.
|
|
87
|
+
*/
|
|
88
|
+
export function renderProviders(results) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const provider of results) {
|
|
91
|
+
const name = (provider.label ?? provider.provider).padEnd(16);
|
|
92
|
+
const age = provider.cached ? ` (cached ${Math.round(provider.ageMs / 1000)}s${provider.stale ? ", stale" : ""})` : "";
|
|
93
|
+
|
|
94
|
+
if (provider.status !== "ok") {
|
|
95
|
+
lines.push(`${name}${provider.status}${provider.detail ? ` - ${provider.detail}` : ""}`);
|
|
96
|
+
lines.push("");
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push(`${name}${provider.plan ?? ""}${age}`);
|
|
101
|
+
for (const w of provider.windows) {
|
|
102
|
+
if (!w.entitled) {
|
|
103
|
+
lines.push(` ${w.label.padEnd(26)} not included in this plan`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const counts = w.remaining != null && w.entitlement != null
|
|
107
|
+
? ` ${w.remaining}/${w.entitlement} ${w.unit}`
|
|
108
|
+
: "";
|
|
109
|
+
lines.push(
|
|
110
|
+
` ${w.label.padEnd(26)}${bar(w.percentUsed)} ${String(w.percentUsed).padStart(3)} %${counts}`
|
|
111
|
+
);
|
|
112
|
+
const reset = formatReset(w.resetsAt);
|
|
113
|
+
if (reset) lines.push(` ${" ".repeat(26)}resets ${reset}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n").trimEnd();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What each install will accept as a model, and where they disagree.
|
|
122
|
+
*
|
|
123
|
+
* The disagreement is the point: a slug offered by the build behind an editor
|
|
124
|
+
* and missing from the one on PATH is a delegation that fails.
|
|
125
|
+
*/
|
|
126
|
+
export function renderModels(catalogues, skews = []) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (const c of catalogues) {
|
|
129
|
+
const head = `${c.agent} ${c.kind}`.padEnd(18) + (c.version ?? "?").padEnd(20);
|
|
130
|
+
if (c.error) {
|
|
131
|
+
lines.push(`${head}unreadable - ${c.error}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// "declared" means the binary answered; "inferred" means we read strings
|
|
135
|
+
// out of it, which can produce plausible-looking rubbish.
|
|
136
|
+
lines.push(`${head}${String(c.models.length).padStart(2)} models [${c.authority}]`);
|
|
137
|
+
lines.push(` ${c.models.map((m) => m.id).join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (skews.length) {
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push("Disagreements between installs of the same agent:");
|
|
143
|
+
for (const s of skews) {
|
|
144
|
+
lines.push(` ${s.agent} ${s.install.kind} ${s.install.version ?? ""} does not offer: ${s.missing.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Single line, for an agent checking headroom before a long task. */
|
|
151
|
+
export function renderShort(usage) {
|
|
152
|
+
if (!usage.windows.length) return "no usage data";
|
|
153
|
+
return usage.windows.map((w) => `${w.id}=${Math.round(w.percent)}%`).join(" ");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Highest window, useful for a one-glance verdict. */
|
|
157
|
+
export function peak(usage) {
|
|
158
|
+
return usage.windows.reduce(
|
|
159
|
+
(max, w) => (w.percent > (max?.percent ?? -1) ? w : max),
|
|
160
|
+
null
|
|
161
|
+
);
|
|
162
|
+
}
|