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.
- package/README.md +534 -419
- package/dist/bin/glm-review.js +46 -4
- package/dist/bin/glm-worker.js +37 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +38 -0
- package/dist/commands/benchmark.js +4 -0
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/usage.js +1 -40
- package/dist/commands/watch.js +289 -0
- package/dist/core/agent-args.js +20 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/zai-quota.js +46 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +9 -0
- package/dist/templates/claude-block.js +9 -0
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +78 -0
- package/package.json +1 -1
package/dist/bin/glm-review.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/bin/glm-worker.js
CHANGED
|
@@ -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:
|
|
66
|
+
* Prompt priority: arguments → stdin → 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
|
-
|
|
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
|
-
|
|
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")
|