glm-coding-router 1.1.2 → 2.0.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.
@@ -0,0 +1,223 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fetchZaiQuota } from "../core/zai-quota.js";
5
+ import { logger } from "../core/logging.js";
6
+ import { quotaCachePath } from "../core/paths.js";
7
+ /** Same TTL as the shipped config default; callers pass routing.quotaCacheTtlSec. */
8
+ const DEFAULT_TTL_SEC = 60;
9
+ const ZERO_WINDOW = {
10
+ used: 0,
11
+ limit: 0,
12
+ remaining: 0,
13
+ remainingRatio: 0,
14
+ resetAt: null,
15
+ };
16
+ /**
17
+ * Maps one CREDIT_LIMIT entry onto a BudgetWindow. The server's `remaining`
18
+ * is authoritative even though the real payload does not add up
19
+ * (912 + 1087 = 1999 of 2000) — recomputing it would silently change the
20
+ * number routing decides on. `limit - used` is only a fallback for when the
21
+ * server omits `remaining` entirely.
22
+ */
23
+ function windowFrom(limit) {
24
+ if (limit === undefined) {
25
+ return ZERO_WINDOW;
26
+ }
27
+ const limitValue = finiteNumber(limit.usage) ?? 0;
28
+ const used = finiteNumber(limit.currentValue) ?? 0;
29
+ const remaining = finiteNumber(limit.remaining) ?? limitValue - used;
30
+ return {
31
+ used,
32
+ limit: limitValue,
33
+ remaining,
34
+ // 0 — never NaN, never Infinity — when the limit is 0 or missing, so the
35
+ // ratio math downstream (zoneFor, the Phase E router) stays finite no
36
+ // matter what the endpoint reports.
37
+ remainingRatio: limitValue > 0 ? remaining / limitValue : 0,
38
+ resetAt: typeof limit.nextResetTime === "number" && Number.isFinite(limit.nextResetTime)
39
+ ? new Date(limit.nextResetTime).toISOString()
40
+ : null,
41
+ };
42
+ }
43
+ function finiteNumber(value) {
44
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
45
+ }
46
+ /**
47
+ * Window mapping verified live (specs/usage.md): `unit 3` is the 5-hour
48
+ * window, `unit 6 & number 1` the weekly one. Other entries are windows this
49
+ * build does not route on and are ignored.
50
+ */
51
+ function snapshotFrom(data, fetchedAt) {
52
+ const limits = Array.isArray(data.limits) ? data.limits : [];
53
+ const fiveHour = limits.find((limit) => limit.unit === 3);
54
+ const weekly = limits.find((limit) => limit.unit === 6 && limit.number === 1);
55
+ // Fail-open has a side door, and this closes it. A 200 response that carries
56
+ // neither window is a real state of this endpoint, not a hypothetical —
57
+ // `usage.ts` already renders "(no quota windows reported)" for it. Mapped
58
+ // naively it becomes an "exact" all-zero snapshot, which zoneFor reads as
59
+ // CRITICAL: every run downgraded and warned today, every run refused once
60
+ // refuseOnCritical flips in 2.1 — on a payload that told us nothing at all.
61
+ // A window we cannot see is unknown, never empty. Both are required because
62
+ // the router reasons about GLM's 5-hour/weekly PAIR; with one of them
63
+ // missing the min() that picks the binding window is meaningless.
64
+ if (fiveHour === undefined || weekly === undefined) {
65
+ logger.debug("budget: quota payload carried no recognizable 5-hour/weekly pair, treating as unknown");
66
+ return unknownSnapshot(fetchedAt);
67
+ }
68
+ return {
69
+ provider: "zai.zcode",
70
+ unit: "credit",
71
+ costClass: "subscription",
72
+ fiveHour: windowFrom(fiveHour),
73
+ weekly: windowFrom(weekly),
74
+ confidence: "exact",
75
+ fetchedAt,
76
+ };
77
+ }
78
+ function unknownSnapshot(fetchedAt) {
79
+ return {
80
+ provider: "zai.zcode",
81
+ unit: "credit",
82
+ costClass: "subscription",
83
+ fiveHour: ZERO_WINDOW,
84
+ weekly: ZERO_WINDOW,
85
+ confidence: "unknown",
86
+ fetchedAt,
87
+ };
88
+ }
89
+ /**
90
+ * The Z.ai quota as a BudgetSnapshot, read through a short-lived file cache
91
+ * so a burst of runs makes ONE request.
92
+ *
93
+ * FAIL-OPEN is the contract of this function: no key, endpoint down, HTTP
94
+ * error, malformed body, unparseable cache — every one of those returns an
95
+ * all-zero snapshot with confidence "unknown". This function never throws
96
+ * and never rejects; a monitoring outage must not block work, it degrades
97
+ * routing to "run normally".
98
+ */
99
+ export async function fetchBudget(deps) {
100
+ const home = deps.home ?? os.homedir();
101
+ const now = deps.now ?? (() => new Date());
102
+ const ttlSec = deps.ttlSec ?? DEFAULT_TTL_SEC;
103
+ try {
104
+ // `refresh` (what --refresh-quota will drive) bypasses the cache without
105
+ // deleting it — the next normal read may still use the fresh entry this
106
+ // fetch writes.
107
+ if (!deps.refresh) {
108
+ const cached = readCachedSnapshot(home);
109
+ // NaN from an unparseable fetchedAt compares false, i.e. stale: a cache
110
+ // entry with no valid timestamp must never suppress a live fetch.
111
+ if (cached !== null && now().getTime() - Date.parse(cached.fetchedAt) < ttlSec * 1000) {
112
+ return { ...cached, confidence: "cached" };
113
+ }
114
+ }
115
+ if (deps.key === undefined || deps.key.length === 0) {
116
+ return unknownSnapshot(now().toISOString());
117
+ }
118
+ const data = await fetchZaiQuota(deps.key, deps.fetchImpl ?? fetch);
119
+ const snapshot = snapshotFrom(data, now().toISOString());
120
+ writeCachedSnapshot(home, snapshot);
121
+ return snapshot;
122
+ }
123
+ catch (error) {
124
+ logger.debug(`budget: quota unavailable, failing open (${errorMessage(error)})`);
125
+ return unknownSnapshot(now().toISOString());
126
+ }
127
+ }
128
+ /**
129
+ * Zone classification for routing and warnings (doc §11). The worst window
130
+ * wins — a healthy 5-hour window cannot paper over an exhausted weekly one.
131
+ *
132
+ * confidence "unknown" returns HEALTHY on purpose, not CRITICAL: unknown
133
+ * windows are all-zero, and a naive min() over them would read "no credits
134
+ * left" and throttle every run for the whole duration of a monitoring
135
+ * outage — the exact opposite of fail-open. An outage degrades to "run
136
+ * normally, warn once".
137
+ */
138
+ export function zoneFor(snapshot, thresholds) {
139
+ if (snapshot.confidence === "unknown") {
140
+ return "HEALTHY";
141
+ }
142
+ const ratio = Math.min(snapshot.fiveHour.remainingRatio, snapshot.weekly.remainingRatio);
143
+ if (ratio < thresholds.criticalBelow) {
144
+ return "CRITICAL";
145
+ }
146
+ if (ratio < thresholds.handoffReadyBelow) {
147
+ return "HANDOFF_READY";
148
+ }
149
+ if (ratio < thresholds.preferFlashBelow) {
150
+ return "CONSERVE";
151
+ }
152
+ return "HEALTHY";
153
+ }
154
+ /**
155
+ * The cache is a deduplication layer, never a source of truth: any problem
156
+ * reading it (missing, unreadable, unparseable, wrong shape) reads as "no
157
+ * cache" so the caller falls through to a live fetch.
158
+ */
159
+ function readCachedSnapshot(home) {
160
+ const file = quotaCachePath(home);
161
+ let text;
162
+ try {
163
+ text = fs.readFileSync(file, "utf8");
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(text);
171
+ }
172
+ catch {
173
+ logger.debug(`budget: unparseable quota cache at ${file} ignored`);
174
+ return null;
175
+ }
176
+ if (!isSnapshotLike(parsed)) {
177
+ logger.debug(`budget: quota cache at ${file} is not a snapshot, ignored`);
178
+ return null;
179
+ }
180
+ return parsed;
181
+ }
182
+ /** Write failures are debug-logged, never thrown — a read-only home must not fail a run. */
183
+ function writeCachedSnapshot(home, snapshot) {
184
+ const file = quotaCachePath(home);
185
+ try {
186
+ fs.mkdirSync(path.dirname(file), { recursive: true });
187
+ fs.writeFileSync(file, JSON.stringify(snapshot) + "\n", "utf8");
188
+ }
189
+ catch (error) {
190
+ logger.debug(`budget: could not write quota cache (${errorMessage(error)})`);
191
+ }
192
+ }
193
+ /**
194
+ * "Parses and has the fields the consumers read": enough shape to trust the
195
+ * cached numbers without duplicating the snapshot definition. Anything that
196
+ * fails this was not written by this build.
197
+ */
198
+ function isSnapshotLike(value) {
199
+ if (typeof value !== "object" || value === null) {
200
+ return false;
201
+ }
202
+ const candidate = value;
203
+ return ((candidate.confidence === "exact" ||
204
+ candidate.confidence === "cached" ||
205
+ candidate.confidence === "unknown") &&
206
+ typeof candidate.fetchedAt === "string" &&
207
+ isWindowLike(candidate.fiveHour) &&
208
+ isWindowLike(candidate.weekly));
209
+ }
210
+ function isWindowLike(value) {
211
+ if (typeof value !== "object" || value === null) {
212
+ return false;
213
+ }
214
+ const window = value;
215
+ return (typeof window.used === "number" &&
216
+ typeof window.limit === "number" &&
217
+ typeof window.remaining === "number" &&
218
+ typeof window.remainingRatio === "number" &&
219
+ (window.resetAt === null || typeof window.resetAt === "string"));
220
+ }
221
+ function errorMessage(error) {
222
+ return error instanceof Error ? error.message : String(error);
223
+ }
package/dist/cli.js CHANGED
@@ -16,6 +16,9 @@ import { uninstallCommand } from "./commands/uninstall.js";
16
16
  import { delegateCommand } from "./commands/delegate.js";
17
17
  import { benchmarkCommand } from "./commands/benchmark.js";
18
18
  import { usageCommand } from "./commands/usage.js";
19
+ import { runsCleanCommand, runsCommand, runsLogsCommand, runsShowCommand } from "./commands/runs.js";
20
+ import { watchCommand } from "./commands/watch.js";
21
+ import { dashboardCommand } from "./commands/dashboard.js";
19
22
  import { mcpCommand } from "./commands/mcp.js";
20
23
  const program = new Command();
21
24
  program
@@ -109,6 +112,41 @@ program
109
112
  .command("usage")
110
113
  .description("provider usage snapshots: Z.ai Coding Plan quota + local benchmark totals")
111
114
  .action(() => execute(() => usageCommand(globalOptions())));
115
+ const runs = program
116
+ .command("runs")
117
+ .description("inspect recorded worker runs — the default action lists them")
118
+ .option("--active", "list only runs in the active registry")
119
+ .option("--limit <n>", "show at most N runs (default 20)", (value) => Number(value))
120
+ .option("--json", "machine-readable JSON output")
121
+ .action((commandOptions) => execute(() => Promise.resolve(runsCommand({ ...globalOptions(), ...commandOptions }))));
122
+ runs
123
+ .command("show <id>")
124
+ .description("show one run: metadata, summary numbers, per-turn tool tree")
125
+ .option("--json", "machine-readable JSON output")
126
+ .action((id, commandOptions) => execute(() => Promise.resolve(runsShowCommand(id, { ...globalOptions(), ...commandOptions }))));
127
+ runs
128
+ .command("logs <id>")
129
+ .description("render the run's events.jsonl, one line per event")
130
+ .option("--json", "print the raw events.jsonl lines unchanged")
131
+ .action((id, commandOptions) => execute(() => Promise.resolve(runsLogsCommand(id, { ...globalOptions(), ...commandOptions }))));
132
+ runs
133
+ .command("clean")
134
+ .description("prune history by age/count and reap orphaned active runs")
135
+ .option("--older-than <nd>", 'prune runs older than N days, e.g. "30d" (default: history.retentionDays)')
136
+ .option("--orphans", "reap active runs whose heartbeat is stale and whose pid is dead")
137
+ .option("--dry-run", "print what would happen without changing anything")
138
+ .option("--json", "machine-readable JSON output")
139
+ .action((commandOptions) => execute(() => Promise.resolve(runsCleanCommand({ ...globalOptions(), ...commandOptions }))));
140
+ program
141
+ .command("watch [run-id]")
142
+ .description("attach to a running run and follow its progress live (newest active run by default)")
143
+ .option("--from-start", "render the events written before attaching, then follow")
144
+ .action((runId, commandOptions) => execute(() => watchCommand({ ...globalOptions(), runId, ...commandOptions })));
145
+ program
146
+ .command("dashboard")
147
+ .description("quota + active runs + recent runs: one snapshot when piped, a live view on a TTY")
148
+ .option("--interval <seconds>", "repaint interval in seconds on a TTY (default 2)", (value) => Number(value))
149
+ .action((commandOptions) => execute(() => dashboardCommand({ ...globalOptions(), ...commandOptions })));
112
150
  const mcp = program
113
151
  .command("mcp")
114
152
  .description("optional glm-mcp MCP server: snippet, install, remove")
@@ -0,0 +1,348 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../core/config.js";
4
+ import { Errors } from "../core/errors.js";
5
+ import { logger } from "../core/logging.js";
6
+ import { runDir } from "../core/paths.js";
7
+ import { resolveZaiApiKey } from "../core/zai-key.js";
8
+ import { isOrphaned, listActive, listHistory } from "../runs/registry.js";
9
+ import { readEvents } from "../runs/store.js";
10
+ import { ansi, createWriter, paint } from "../tui/render.js";
11
+ import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
12
+ import { emitJson } from "./context.js";
13
+ // Display-only zone thresholds for the dashboard's one-word verdict. Phase E
14
+ // owns the real routing thresholds (routing.preferFlashBelow etc.); these must
15
+ // stay independent so restyling the dashboard can never change a routing
16
+ // decision, and vice versa.
17
+ const ZONE_OK_ABOVE_RATIO = 0.3;
18
+ const ZONE_LOW_ABOVE_RATIO = 0.15;
19
+ /** Doc §8: recent runs are the last few, not the whole history. */
20
+ const RECENT_LIMIT = 5;
21
+ const DEFAULT_INTERVAL_SEC = 2;
22
+ /**
23
+ * glm-router dashboard — quota + active runs + recent runs + errors in one
24
+ * frame (doc §8). Non-TTY (or --json) prints exactly ONE snapshot and exits 0,
25
+ * so the command is pipeable and a monitoring outage never looks like a broken
26
+ * tool; TTY repaints the whole frame every --interval seconds until Ctrl+C.
27
+ */
28
+ export async function dashboardCommand(options, deps = {}) {
29
+ const intervalSec = options.interval ?? DEFAULT_INTERVAL_SEC;
30
+ if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
31
+ throw Errors.invalidArgs(`--interval expects a positive number of seconds, got "${String(options.interval)}"`);
32
+ }
33
+ const stream = deps.stdout ?? process.stdout;
34
+ const isTTY = deps.isTTY ?? stream.isTTY === true;
35
+ if (options.json || !isTTY) {
36
+ const snapshot = await buildSnapshot(deps);
37
+ if (options.json) {
38
+ emitJson(snapshotJson(snapshot));
39
+ }
40
+ else {
41
+ const writer = createWriter(stream); // non-TTY: color off, no ANSI
42
+ writer.line(renderSnapshot(snapshot, writer));
43
+ }
44
+ return 0;
45
+ }
46
+ // TTY: repaint by clearing and rewriting — never scroll. All cursor control
47
+ // goes through render.ts's ansi helpers (D1: no escape codes here).
48
+ const writer = createWriter(stream);
49
+ writer.write(ansi.hideCursor);
50
+ return new Promise((resolve) => {
51
+ let paintedLines = 0;
52
+ let painting = false;
53
+ let stopped = false;
54
+ const stop = () => {
55
+ if (stopped) {
56
+ return;
57
+ }
58
+ stopped = true;
59
+ clearInterval(timer);
60
+ process.removeListener("SIGINT", onSigint);
61
+ writer.write(ansi.showCursor);
62
+ resolve(0);
63
+ };
64
+ const onSigint = () => stop();
65
+ const repaint = async () => {
66
+ if (stopped || painting) {
67
+ return;
68
+ }
69
+ painting = true;
70
+ try {
71
+ const frame = renderSnapshot(await buildSnapshot(deps), writer);
72
+ if (stopped) {
73
+ return;
74
+ }
75
+ if (paintedLines > 0) {
76
+ const clear = ansi.cursorUp(1) + ansi.clearLine;
77
+ writer.write(clear.repeat(paintedLines));
78
+ }
79
+ writer.write(`${frame}\n`);
80
+ paintedLines = frame.split("\n").length;
81
+ }
82
+ catch (error) {
83
+ // One failed repaint (e.g. a transient quota timeout) must not kill
84
+ // the loop; the next tick replaces the frame.
85
+ logger.debug(`dashboard: repaint failed: ${errorMessage(error)}`);
86
+ }
87
+ finally {
88
+ painting = false;
89
+ }
90
+ };
91
+ const timer = setInterval(() => {
92
+ void repaint();
93
+ }, intervalSec * 1000);
94
+ process.on("SIGINT", onSigint);
95
+ void repaint();
96
+ });
97
+ }
98
+ /** Gathers one frame from the quota endpoint and the run registry. */
99
+ async function buildSnapshot(deps) {
100
+ const home = deps.home ?? os.homedir();
101
+ const now = deps.now ?? (() => new Date());
102
+ const nowMs = now().getTime();
103
+ const fetchImpl = deps.fetchImpl ?? fetch;
104
+ const resolveKey = deps.resolveKey ?? (() => resolveZaiApiKey());
105
+ // Fail-open by contract: a monitoring outage must never fail the dashboard
106
+ // (or block work) — it renders as one unavailable line, exit stays 0.
107
+ let quota;
108
+ const resolved = resolveKey();
109
+ if (resolved === undefined) {
110
+ quota = { ok: false, level: null, zone: "unknown", windows: [], error: "no Z.ai API key configured" };
111
+ }
112
+ else {
113
+ try {
114
+ const data = await fetchZaiQuota(resolved.key, fetchImpl);
115
+ const windows = (data.limits ?? []).map(windowView);
116
+ quota = {
117
+ ok: true,
118
+ level: data.level ?? null,
119
+ zone: zoneFor(windows.map((window) => window.remainingRatio)),
120
+ windows,
121
+ error: null,
122
+ };
123
+ }
124
+ catch (error) {
125
+ quota = {
126
+ ok: false,
127
+ level: null,
128
+ zone: "unknown",
129
+ windows: [],
130
+ error: errorMessage(error),
131
+ };
132
+ }
133
+ }
134
+ const active = listActive(home).map((run) => ({
135
+ id: run.id,
136
+ state: run.state,
137
+ kind: run.kind,
138
+ model: run.model,
139
+ elapsedMs: elapsedSince(run.startedAt, nowMs),
140
+ cwd: run.cwd,
141
+ orphaned: isOrphaned(run, { now: () => nowMs, isAlive: deps.isAlive }),
142
+ }));
143
+ // `listHistory` also returns active runs (their directory exists from the
144
+ // first event, with no summary yet — which reads as CRASHED). Recent Runs is
145
+ // "how runs ended": the live ones are the section above, and showing them
146
+ // here as crashed would be actively misleading.
147
+ const activeIds = new Set(active.map((row) => row.id));
148
+ const recent = listHistory(home)
149
+ .filter((ref) => !activeIds.has(ref.id))
150
+ .slice(0, RECENT_LIMIT)
151
+ .map((ref) => ({
152
+ id: ref.id,
153
+ state: ref.state,
154
+ durationMs: ref.summary?.durationMs ?? null,
155
+ turns: ref.summary?.turns ?? null,
156
+ files: ref.summary?.filesChanged.length ?? null,
157
+ reason: failedReason(home, ref),
158
+ }));
159
+ return { generatedAt: new Date(nowMs).toISOString(), quota, active, recent, model: loadConfig(home).models.main };
160
+ }
161
+ /** The machine shape — mirrors the text frame key for key. */
162
+ function snapshotJson(snapshot) {
163
+ return {
164
+ generatedAt: snapshot.generatedAt,
165
+ model: snapshot.model,
166
+ quota: snapshot.quota,
167
+ active: snapshot.active,
168
+ recent: snapshot.recent,
169
+ errors: snapshot.recent
170
+ .filter((row) => row.state === "FAILED" || row.state === "CRASHED")
171
+ .map((row) => ({ id: row.id, state: row.state, reason: row.reason })),
172
+ };
173
+ }
174
+ function renderSnapshot(snapshot, writer) {
175
+ const lines = [`GLM Coding Router — dashboard ${formatLocalTime(snapshot.generatedAt)}`, ""];
176
+ if (snapshot.quota.ok) {
177
+ const level = snapshot.quota.level !== null ? ` (level: ${snapshot.quota.level})` : "";
178
+ lines.push(`Quota${level} — ${paintZone(writer, snapshot.quota.zone)}`);
179
+ if (snapshot.quota.windows.length === 0) {
180
+ lines.push(" (no quota windows reported)");
181
+ }
182
+ for (const window of snapshot.quota.windows) {
183
+ const used = window.used !== null ? String(window.used) : "?";
184
+ const total = window.limit !== null ? String(window.limit) : "?";
185
+ const percent = window.percent !== null ? ` (${String(window.percent)}%)` : "";
186
+ const resets = window.resetsAt !== null ? ` · resets ${window.resetsAt}` : "";
187
+ lines.push(` ${window.window.padEnd(15)} ${used} / ${total} credits${percent} · ` +
188
+ `${paintZone(writer, zoneFor([window.remainingRatio]))}${resets}`);
189
+ }
190
+ }
191
+ else {
192
+ lines.push("Quota", ` quota unavailable — ${snapshot.quota.error ?? "unknown error"}`);
193
+ }
194
+ lines.push("", "Active Runs");
195
+ if (snapshot.active.length === 0) {
196
+ lines.push(" (none)");
197
+ }
198
+ else {
199
+ const rows = snapshot.active.map((row) => [
200
+ row.id.slice(-6),
201
+ row.state,
202
+ row.kind,
203
+ row.model,
204
+ row.elapsedMs !== null ? formatDuration(row.elapsedMs) : "—",
205
+ (path.basename(row.cwd) || row.cwd) + (row.orphaned ? paint(writer, "yellow", " (orphaned)") : ""),
206
+ ]);
207
+ for (const line of renderRows(rows)) {
208
+ lines.push(` ${line}`);
209
+ }
210
+ }
211
+ lines.push("", "Recent Runs");
212
+ if (snapshot.recent.length === 0) {
213
+ lines.push(" (none)");
214
+ }
215
+ else {
216
+ for (const row of snapshot.recent) {
217
+ const numbers = row.durationMs === null && row.turns === null && row.files === null
218
+ ? "—"
219
+ : [
220
+ row.durationMs !== null ? formatDuration(row.durationMs) : null,
221
+ row.turns !== null ? `${String(row.turns)} ${row.turns === 1 ? "turn" : "turns"}` : null,
222
+ row.files !== null ? `${String(row.files)} ${row.files === 1 ? "file" : "files"}` : null,
223
+ ]
224
+ .filter((part) => part !== null)
225
+ .join(" · ");
226
+ lines.push(` ${row.id.slice(-6)} ${row.state.padEnd(10)} ${numbers}`);
227
+ }
228
+ }
229
+ const failed = snapshot.recent.filter((row) => row.state === "FAILED" || row.state === "CRASHED");
230
+ lines.push("", "Errors");
231
+ if (failed.length === 0) {
232
+ lines.push(" (none)");
233
+ }
234
+ else {
235
+ for (const row of failed) {
236
+ const reason = row.reason !== null ? ` — ${row.reason}` : "";
237
+ lines.push(` ${paint(writer, "red", `✗ ${row.id.slice(-6)} ${row.state}${reason}`)}`);
238
+ }
239
+ }
240
+ lines.push("", `Model ${snapshot.model}`);
241
+ return lines.join("\n");
242
+ }
243
+ function paintZone(writer, zone) {
244
+ const style = zone === "ok" ? "green" : zone === "low" ? "yellow" : zone === "critical" ? "red" : null;
245
+ return style !== null ? paint(writer, style, zone) : zone;
246
+ }
247
+ /**
248
+ * Worst window wins, matching how the router will judge the budget: a healthy
249
+ * 5-hour window cannot paper over an exhausted weekly one.
250
+ */
251
+ function zoneFor(ratios) {
252
+ const known = ratios.filter((ratio) => typeof ratio === "number" && Number.isFinite(ratio));
253
+ if (known.length === 0) {
254
+ return "unknown";
255
+ }
256
+ const worst = Math.min(...known);
257
+ if (worst > ZONE_OK_ABOVE_RATIO) {
258
+ return "ok";
259
+ }
260
+ if (worst > ZONE_LOW_ABOVE_RATIO) {
261
+ return "low";
262
+ }
263
+ return "critical";
264
+ }
265
+ function windowView(limit) {
266
+ const used = typeof limit.currentValue === "number" ? limit.currentValue : null;
267
+ const total = typeof limit.usage === "number" ? limit.usage : null;
268
+ const percent = typeof limit.percentage === "number"
269
+ ? limit.percentage
270
+ : used !== null && total !== null && total > 0
271
+ ? Math.round((used / total) * 100)
272
+ : null;
273
+ const remainingRatio = typeof limit.remaining === "number" && total !== null && total > 0
274
+ ? limit.remaining / total
275
+ : percent !== null
276
+ ? (100 - percent) / 100
277
+ : null;
278
+ const resetsAt = typeof limit.nextResetTime === "number" && Number.isFinite(limit.nextResetTime)
279
+ ? new Date(limit.nextResetTime).toISOString()
280
+ : null;
281
+ return { window: describeWindow(limit), used, limit: total, percent, remainingRatio, resetsAt };
282
+ }
283
+ /**
284
+ * Why a run failed. `RunSummary` carries no reason field today (Phase B's
285
+ * choice), so the line falls back to the run's own `RunFailed` event — read
286
+ * only for FAILED/CRASHED entries, at most RECENT_LIMIT of them.
287
+ */
288
+ function failedReason(home, ref) {
289
+ if (ref.state !== "FAILED" && ref.state !== "CRASHED") {
290
+ return null;
291
+ }
292
+ const fromSummary = ref.summary !== null ? ref.summary.reason : undefined;
293
+ if (typeof fromSummary === "string" && fromSummary.length > 0) {
294
+ return fromSummary;
295
+ }
296
+ const events = readEvents(runDir(home, ref.date, ref.id));
297
+ for (let index = events.length - 1; index >= 0; index--) {
298
+ const event = events[index];
299
+ if (event.type === "RunFailed") {
300
+ return event.reason;
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+ /** Pads each column to the widest cell — a model or cwd of any length must not break the frame. */
306
+ function renderRows(rows) {
307
+ const widths = [];
308
+ for (const row of rows) {
309
+ row.forEach((cell, column) => {
310
+ widths[column] = Math.max(widths[column] ?? 0, cell.length);
311
+ });
312
+ }
313
+ return rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd());
314
+ }
315
+ /** Milliseconds since an ISO timestamp; null when unparseable. */
316
+ function elapsedSince(iso, nowMs) {
317
+ const startedMs = Date.parse(iso);
318
+ return Number.isFinite(startedMs) ? Math.max(0, nowMs - startedMs) : null;
319
+ }
320
+ /** Local time built from the date's own components so no locale can change it. */
321
+ function formatLocalTime(iso) {
322
+ const date = new Date(iso);
323
+ if (Number.isNaN(date.getTime())) {
324
+ return iso;
325
+ }
326
+ const p2 = (value) => String(value).padStart(2, "0");
327
+ return (`${date.getFullYear()}-${p2(date.getMonth() + 1)}-${p2(date.getDate())} ` +
328
+ `${p2(date.getHours())}:${p2(date.getMinutes())}:${p2(date.getSeconds())}`);
329
+ }
330
+ /** "5.4s", "2m 13s", "1h 04m" — one decimal below a minute, where it is honest. */
331
+ function formatDuration(durationMs) {
332
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
333
+ return "—";
334
+ }
335
+ const seconds = durationMs / 1000;
336
+ if (seconds < 60) {
337
+ return `${seconds.toFixed(1)}s`;
338
+ }
339
+ const totalSeconds = Math.floor(seconds);
340
+ const minutes = Math.floor(totalSeconds / 60);
341
+ if (minutes < 60) {
342
+ return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
343
+ }
344
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
345
+ }
346
+ function errorMessage(error) {
347
+ return error instanceof Error ? error.message : String(error);
348
+ }