glm-coding-router 1.1.1 → 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.
Files changed (39) hide show
  1. package/README.md +534 -419
  2. package/dist/bin/glm-review.js +46 -4
  3. package/dist/bin/glm-worker.js +37 -4
  4. package/dist/budget/estimator.js +218 -0
  5. package/dist/budget/manager.js +223 -0
  6. package/dist/cli.js +38 -0
  7. package/dist/commands/benchmark.js +4 -0
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/runs.js +568 -0
  10. package/dist/commands/usage.js +1 -40
  11. package/dist/commands/watch.js +289 -0
  12. package/dist/core/agent-args.js +20 -0
  13. package/dist/core/config.js +61 -0
  14. package/dist/core/errors.js +24 -0
  15. package/dist/core/paths.js +32 -0
  16. package/dist/core/process.js +83 -0
  17. package/dist/core/prompt.js +18 -5
  18. package/dist/core/routing-flags.js +59 -0
  19. package/dist/core/zai-quota.js +46 -0
  20. package/dist/events/bus.js +64 -0
  21. package/dist/events/claude-adapter.js +416 -0
  22. package/dist/events/types.js +9 -0
  23. package/dist/handoff/bundle.js +203 -0
  24. package/dist/handoff/parent-handoff.js +48 -0
  25. package/dist/mcp/server.js +45 -1
  26. package/dist/routing/glm-routing.js +131 -0
  27. package/dist/runs/checkpoint.js +204 -0
  28. package/dist/runs/drain.js +165 -0
  29. package/dist/runs/heartbeat.js +45 -0
  30. package/dist/runs/registry.js +350 -0
  31. package/dist/runs/store.js +186 -0
  32. package/dist/runs/ulid.js +112 -0
  33. package/dist/runs/worker-run.js +672 -0
  34. package/dist/templates/agents-block.js +9 -0
  35. package/dist/templates/claude-block.js +9 -0
  36. package/dist/templates/glm-delegation-skill.js +76 -65
  37. package/dist/tui/progress.js +338 -0
  38. package/dist/tui/render.js +78 -0
  39. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { STRICT_MCP_ARGS } from "../core/agent-args.js";
2
3
  import { loadConfig } from "../core/config.js";
3
4
  import { locateClaude } from "../core/claude.js";
4
5
  import { createGlmEnv } from "../core/env.js";
@@ -6,20 +7,41 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
6
7
  import { isMainModule } from "../core/main-guard.js";
7
8
  import { logger, redact } from "../core/logging.js";
8
9
  import { applyProfile, extractProfileFlag } from "../core/profile.js";
10
+ import { extractRoutingFlags } from "../core/routing-flags.js";
9
11
  import { readStdin, resolvePrompt } from "../core/prompt.js";
10
12
  import { spawnAgent } from "../core/process.js";
11
13
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
12
15
  /** Read-only review surface (spec §17) — no Edit, Write, or Bash. */
13
16
  export const REVIEW_TOOLS = "Read,Glob,Grep";
17
+ /**
18
+ * Build the child arguments (spec §17, specs/review-mcp-isolation.md).
19
+ *
20
+ * `--tools` alone does not make this read-only: it restricts the built-in set,
21
+ * while MCP tools from the user's config are additive — with our own server
22
+ * registered, a review could call `glm_worker` and write files. STRICT_MCP_ARGS
23
+ * is what actually holds the guarantee.
24
+ */
14
25
  export function buildReviewArgs(prompt, config) {
15
- return ["-p", prompt, "--max-turns", String(config.review.maxTurns), "--tools", REVIEW_TOOLS];
26
+ return [
27
+ "-p",
28
+ prompt,
29
+ "--max-turns",
30
+ String(config.review.maxTurns),
31
+ "--tools",
32
+ REVIEW_TOOLS,
33
+ ...STRICT_MCP_ARGS,
34
+ ];
16
35
  }
17
36
  /**
18
37
  * glm-review (spec §17): read-only worker for exploration, call-graph
19
38
  * discovery, duplicate detection, dependency inspection, and review.
20
39
  */
21
40
  export async function runReview(argv) {
22
- const { rest, profile } = extractProfileFlag(argv);
41
+ const { rest: withoutProfile, profile } = extractProfileFlag(argv);
42
+ // Phase E flags come off before resolvePrompt: whatever is still in
43
+ // `rest` at that point becomes the prompt.
44
+ const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutProfile);
23
45
  const prompt = await resolvePrompt(rest, readStdin, "glm-review");
24
46
  const config = applyProfile(loadConfig(), profile);
25
47
  const resolved = resolveZaiApiKey();
@@ -30,12 +52,32 @@ export async function runReview(argv) {
30
52
  const args = buildReviewArgs(prompt, config);
31
53
  const env = createGlmEnv(config, resolved.key);
32
54
  logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
33
- return spawnAgent(claudePath, {
55
+ // v2 spec Phase D (C4): observe unless the caller opted out or already asked
56
+ // for a specific --output-format. The legacy path stays byte-identical.
57
+ if (!shouldObserve(args, process.env)) {
58
+ return spawnAgent(claudePath, {
59
+ args,
60
+ cwd: process.cwd(),
61
+ env,
62
+ interactive: false,
63
+ });
64
+ }
65
+ const observed = await runInstrumented({
66
+ kind: "review",
67
+ prompt,
34
68
  args,
69
+ claudePath,
70
+ config,
71
+ secrets: [resolved.key],
35
72
  cwd: process.cwd(),
36
73
  env,
37
- interactive: false,
74
+ // Phase E; instrumented runs only, same as glm-worker.
75
+ zaiKey: resolved.key,
76
+ requestedModel: model,
77
+ force,
78
+ refreshQuota,
38
79
  });
80
+ return observed.code;
39
81
  }
40
82
  if (isMainModule(import.meta.url)) {
41
83
  runReview(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { STRICT_MCP_ARGS } from "../core/agent-args.js";
2
3
  import { loadConfig } from "../core/config.js";
3
4
  import { locateClaude } from "../core/claude.js";
4
5
  import { createGlmEnv } from "../core/env.js";
@@ -6,9 +7,11 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
6
7
  import { isMainModule } from "../core/main-guard.js";
7
8
  import { logger, redact } from "../core/logging.js";
8
9
  import { applyProfile, extractProfileFlag } from "../core/profile.js";
10
+ import { extractRoutingFlags } from "../core/routing-flags.js";
9
11
  import { resolvePrompt } from "../core/prompt.js";
10
12
  import { spawnAgent } from "../core/process.js";
11
13
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
12
15
  /** Worker tool surface (spec §16). */
13
16
  export const WORKER_TOOLS = "Read,Glob,Grep,Edit,Write,Bash";
14
17
  /** Same surface minus Bash, used when no Bash command is allowed. */
@@ -21,6 +24,10 @@ export const WORKER_TOOLS_NO_BASH = "Read,Glob,Grep,Edit,Write";
21
24
  * `--allowedTools` every Bash call comes back "This command requires
22
25
  * approval". When the allowlist is empty we drop Bash from `--tools` entirely
23
26
  * rather than advertising a tool the worker can never use.
27
+ *
28
+ * STRICT_MCP_ARGS keeps the user's MCP servers out of the child
29
+ * (specs/review-mcp-isolation.md): write is expected here, undeclared
30
+ * recursion into `glm_delegate` and its own turn budget is not.
24
31
  */
25
32
  export function buildWorkerArgs(prompt, config) {
26
33
  const allowedBash = config.worker.allowedBash;
@@ -33,6 +40,8 @@ export function buildWorkerArgs(prompt, config) {
33
40
  "acceptEdits",
34
41
  "--tools",
35
42
  allowedBash.length > 0 ? WORKER_TOOLS : WORKER_TOOLS_NO_BASH,
43
+ // Before --allowedTools: its values are variadic and must stay last.
44
+ ...STRICT_MCP_ARGS,
36
45
  ];
37
46
  if (allowedBash.length > 0) {
38
47
  args.push("--allowedTools", ...allowedBash.map((pattern) => `Bash(${pattern})`));
@@ -54,12 +63,15 @@ export function extractNoBashFlag(argv) {
54
63
  }
55
64
  /**
56
65
  * glm-worker (spec §15, §16): headless implementation worker.
57
- * Prompt priority: stdinarguments → error. Never uses
66
+ * Prompt priority: argumentsstdin → error. Never uses
58
67
  * --dangerously-skip-permissions.
59
68
  */
60
69
  export async function runWorker(argv) {
61
70
  const { rest: withoutProfile, profile } = extractProfileFlag(argv);
62
- const { rest, noBash } = extractNoBashFlag(withoutProfile);
71
+ const { rest: withoutBashFlag, noBash } = extractNoBashFlag(withoutProfile);
72
+ // Phase E flags come off last, and before resolvePrompt: everything still in
73
+ // `rest` at that point becomes the prompt.
74
+ const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutBashFlag);
63
75
  const prompt = await resolvePrompt(rest);
64
76
  const loaded = applyProfile(loadConfig(), profile);
65
77
  const config = noBash
@@ -73,12 +85,33 @@ export async function runWorker(argv) {
73
85
  const args = buildWorkerArgs(prompt, config);
74
86
  const env = createGlmEnv(config, resolved.key);
75
87
  logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
76
- return spawnAgent(claudePath, {
88
+ // v2 spec Phase D (C4): observe unless the caller opted out or already asked
89
+ // for a specific --output-format. The legacy path stays byte-identical.
90
+ if (!shouldObserve(args, process.env)) {
91
+ return spawnAgent(claudePath, {
92
+ args,
93
+ cwd: process.cwd(),
94
+ env,
95
+ interactive: false,
96
+ });
97
+ }
98
+ const observed = await runInstrumented({
99
+ kind: "worker",
100
+ prompt,
77
101
  args,
102
+ claudePath,
103
+ config,
104
+ secrets: [resolved.key],
78
105
  cwd: process.cwd(),
79
106
  env,
80
- interactive: false,
107
+ // Phase E. The legacy path above never reaches here, so these flags apply
108
+ // to instrumented runs only — routing needs the event stream it observes.
109
+ zaiKey: resolved.key,
110
+ requestedModel: model,
111
+ force,
112
+ refreshQuota,
81
113
  });
114
+ return observed.code;
82
115
  }
83
116
  if (isMainModule(import.meta.url)) {
84
117
  runWorker(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../core/logging.js";
4
+ import { costSamplesPath } from "../core/paths.js";
5
+ /**
6
+ * Priority-ordered keyword table: the first row with a case-insensitive WHOLE
7
+ * WORD match wins, which is what makes "fix the failing test" a bugfix rather
8
+ * than a tests task — repair beats coverage when both match.
9
+ *
10
+ * Whole words, not substrings: a substring table reads "docker" as docs and
11
+ * "fixture" as bugfix, and this classifier is not cosmetic — it picks the cost
12
+ * row that decides `wouldRefuse`, which is the evidence D3 says 2.1's
13
+ * refuse-by-default decision will be argued from. Noise here becomes a wrong
14
+ * answer to "how often would the refusal have been wrong?". Word forms are
15
+ * therefore listed explicitly; an unlisted form falls through to a later row
16
+ * or to "other", and guessing low is the conservative failure.
17
+ */
18
+ const KEYWORDS = [
19
+ [
20
+ "bugfix",
21
+ ["fix", "fixes", "fixed", "fixing", "bug", "bugs", "broken", "regression", "regressions",
22
+ "crash", "crashes", "crashing", "error", "errors", "defect", "defects", "repair"],
23
+ ],
24
+ ["tests", ["test", "tests", "testing", "spec", "specs", "coverage", "vitest", "jest", "pytest"]],
25
+ [
26
+ "docs",
27
+ ["doc", "docs", "document", "documents", "documentation", "docstring", "docstrings",
28
+ "readme", "comment", "comments", "changelog"],
29
+ ],
30
+ [
31
+ "refactor",
32
+ ["refactor", "refactors", "refactoring", "rename", "renames", "renaming", "extract",
33
+ "cleanup", "clean up", "restructure", "simplify", "migrate", "migration"],
34
+ ],
35
+ [
36
+ "explore",
37
+ ["explore", "investigate", "find", "search", "understand", "audit", "review", "analyze",
38
+ "analyse", "analysis"],
39
+ ],
40
+ [
41
+ "crud",
42
+ ["add", "create", "implement", "implements", "implementing", "endpoint", "endpoints",
43
+ "model", "models", "schema", "schemas", "crud", "build", "write"],
44
+ ],
45
+ ];
46
+ /** Whole-word matcher for one row; `clean up` shows why a plain split() is not enough. */
47
+ const MATCHERS = KEYWORDS.map(([kind, words]) => [
48
+ kind,
49
+ new RegExp(`\\b(?:${words.map(escapeRegExp).join("|")})\\b`, "i"),
50
+ ]);
51
+ function escapeRegExp(text) {
52
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+ /**
55
+ * Deterministic keyword classifier — no model call, so preflight stays free,
56
+ * instant and reproducible. The prompt itself is never persisted here (C3);
57
+ * only the resulting kind reaches `cost-samples.jsonl`.
58
+ */
59
+ export function classifyTask(prompt) {
60
+ for (const [kind, matcher] of MATCHERS) {
61
+ if (matcher.test(prompt)) {
62
+ return kind;
63
+ }
64
+ }
65
+ return "other";
66
+ }
67
+ /**
68
+ * Appends one JSON line to cost-samples.jsonl. Never throws: cost history is
69
+ * an optimization the next run can live without, and the run that earned the
70
+ * sample has already done its work by the time this is called.
71
+ */
72
+ export function recordSample(home, sample) {
73
+ const file = costSamplesPath(home);
74
+ try {
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.appendFileSync(file, JSON.stringify(sample) + "\n", "utf8");
77
+ }
78
+ catch (error) {
79
+ logger.debug(`estimator: could not record cost sample (${errorMessage(error)})`);
80
+ }
81
+ }
82
+ /**
83
+ * Reads the cost history. A missing file is "no history yet" and a malformed
84
+ * line is skipped, not fatal — the same tolerate-the-wreckage contract as the
85
+ * run store, because a truncated final line is normal crash wreckage.
86
+ */
87
+ export function readSamples(home) {
88
+ let text;
89
+ try {
90
+ text = fs.readFileSync(costSamplesPath(home), "utf8");
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const samples = [];
96
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
97
+ if (line.trim() === "") {
98
+ continue;
99
+ }
100
+ try {
101
+ const parsed = JSON.parse(line);
102
+ if (isSampleLike(parsed)) {
103
+ samples.push(parsed);
104
+ }
105
+ else {
106
+ logger.debug(`estimator: line ${index + 1} in ${costSamplesPath(home)} is not a sample`);
107
+ }
108
+ }
109
+ catch {
110
+ logger.debug(`estimator: skipping malformed line ${index + 1} in ${costSamplesPath(home)}`);
111
+ }
112
+ }
113
+ return samples;
114
+ }
115
+ /**
116
+ * True only when the quota delta between two snapshots can be attributed to
117
+ * exactly one run. All four rules must hold:
118
+ *
119
+ * - both snapshots have confidence "exact" or "cached" — an "unknown" side
120
+ * makes the delta fiction;
121
+ * - fiveHour.resetAt is unchanged — a window reset mid-run makes the delta
122
+ * meaningless (used drops to 0 and the difference goes negative);
123
+ * - the credit delta (end.used - start.used) is >= 0 — a negative delta
124
+ * means a reset or a server correction, not a cost;
125
+ * - activeRunCount is exactly 1 — concurrent runs make credit attribution
126
+ * meaningless, so those runs record nothing rather than a wrong number.
127
+ */
128
+ export function isCleanMeasurement(input) {
129
+ const trusted = (confidence) => confidence === "exact" || confidence === "cached";
130
+ return (trusted(input.startSnapshot.confidence) &&
131
+ trusted(input.endSnapshot.confidence) &&
132
+ input.startSnapshot.fiveHour.resetAt === input.endSnapshot.fiveHour.resetAt &&
133
+ input.endSnapshot.fiveHour.used - input.startSnapshot.fiveHour.used >= 0 &&
134
+ input.activeRunCount === 1);
135
+ }
136
+ /**
137
+ * Main-model baseline, p50/p90 in plan credits. Transcribed from doc §13 and
138
+ * NEVER MEASURED on this stack — decision D3 keeps preflight refusal off in
139
+ * 2.0.0 precisely because this table is unmeasured; a wrongly-high row would
140
+ * refuse runs the quota could have afforded.
141
+ */
142
+ const BASELINE_MAIN = {
143
+ explore: { p50: 15, p90: 28 },
144
+ crud: { p50: 42, p90: 66 },
145
+ tests: { p50: 30, p90: 50 },
146
+ docs: { p50: 12, p90: 22 },
147
+ refactor: { p50: 45, p90: 75 },
148
+ bugfix: { p50: 35, p90: 60 },
149
+ other: { p50: 35, p90: 60 },
150
+ };
151
+ /** Fast models are assumed to cost 40% of the main row, rounded. */
152
+ const FAST_MODEL_RATIO = 0.4;
153
+ /** History starts winning at this many samples; below it the noise would outrank the baseline. */
154
+ const MIN_HISTORY_SAMPLES = 5;
155
+ /**
156
+ * Estimated credits for one run of a task kind on a model. History wins when
157
+ * at least MIN_HISTORY_SAMPLES samples match BOTH the task kind and the model;
158
+ * otherwise the baseline table answers, with `samples: 0` and
159
+ * `source: "baseline"`.
160
+ *
161
+ * Main vs fast is decided by comparing `model` with the `fastModel`
162
+ * ARGUMENT — this module is deliberately config-free so it stays pure and
163
+ * testable, and callers (preflight, part 2) pass config.models.fast in.
164
+ */
165
+ export function estimateCost(home, taskKind, model, fastModel) {
166
+ const credits = readSamples(home)
167
+ .filter((entry) => entry.taskKind === taskKind && entry.model === model)
168
+ .map((entry) => entry.credits);
169
+ if (credits.length >= MIN_HISTORY_SAMPLES) {
170
+ const sorted = [...credits].sort((a, b) => a - b);
171
+ return {
172
+ p50: nearestRank(sorted, 0.5),
173
+ p90: nearestRank(sorted, 0.9),
174
+ samples: sorted.length,
175
+ source: "history",
176
+ };
177
+ }
178
+ const main = BASELINE_MAIN[taskKind];
179
+ if (model === fastModel) {
180
+ return {
181
+ p50: Math.round(main.p50 * FAST_MODEL_RATIO),
182
+ p90: Math.round(main.p90 * FAST_MODEL_RATIO),
183
+ samples: 0,
184
+ source: "baseline",
185
+ };
186
+ }
187
+ return { p50: main.p50, p90: main.p90, samples: 0, source: "baseline" };
188
+ }
189
+ /**
190
+ * Nearest-rank percentile, deliberately NOT interpolation: sort ascending,
191
+ * take index ceil(p * n) - 1, clamped into [0, n-1]. For p90 with n = 5 that
192
+ * is literally the maximum sample. Interpolating would invent costs between
193
+ * samples that never happened; the rank is the honest, conservative reading.
194
+ * Do not "fix" this into interpolation later.
195
+ */
196
+ function nearestRank(sortedAsc, p) {
197
+ const index = Math.min(sortedAsc.length - 1, Math.max(0, Math.ceil(p * sortedAsc.length) - 1));
198
+ return sortedAsc[index];
199
+ }
200
+ /**
201
+ * Same philosophy as the run store's isEventLike: "parses and has the fields
202
+ * estimateCost reads" (taskKind, model, credits). A line that parses but
203
+ * carries none of those cannot feed the estimator and is dropped; anything
204
+ * richer is history written by a future version and is kept.
205
+ */
206
+ function isSampleLike(value) {
207
+ if (typeof value !== "object" || value === null) {
208
+ return false;
209
+ }
210
+ const candidate = value;
211
+ return (typeof candidate.taskKind === "string" &&
212
+ typeof candidate.model === "string" &&
213
+ typeof candidate.credits === "number" &&
214
+ Number.isFinite(candidate.credits));
215
+ }
216
+ function errorMessage(error) {
217
+ return error instanceof Error ? error.message : String(error);
218
+ }
@@ -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")