glm-coding-router 1.1.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -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/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -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 +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
package/dist/commands/usage.js
CHANGED
|
@@ -5,47 +5,10 @@ import { Errors } from "../core/errors.js";
|
|
|
5
5
|
import { configDir } from "../core/paths.js";
|
|
6
6
|
import { version } from "../core/version.js";
|
|
7
7
|
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
8
|
+
import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
|
|
8
9
|
import { emitJson } from "./context.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
|
|
12
|
-
export function describeWindow(limit) {
|
|
13
|
-
if (limit.unit === 3 && typeof limit.number === "number") {
|
|
14
|
-
return `${limit.number}-hour window`;
|
|
15
|
-
}
|
|
16
|
-
if (limit.unit === 6 && limit.number === 1) {
|
|
17
|
-
return "weekly";
|
|
18
|
-
}
|
|
19
|
-
return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
|
|
20
|
-
}
|
|
21
|
-
/** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
|
|
22
|
-
export async function fetchZaiQuota(key, fetchImpl) {
|
|
23
|
-
let response;
|
|
24
|
-
try {
|
|
25
|
-
response = await fetchImpl(ZAI_QUOTA_URL, {
|
|
26
|
-
method: "GET",
|
|
27
|
-
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
28
|
-
signal: AbortSignal.timeout(10_000),
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
throw new Error(`Z.ai monitor endpoint unreachable (${error instanceof Error ? error.message : "network error"})`);
|
|
33
|
-
}
|
|
34
|
-
if (!response.ok) {
|
|
35
|
-
throw new Error(`Z.ai monitor endpoint returned HTTP ${response.status}`);
|
|
36
|
-
}
|
|
37
|
-
let body;
|
|
38
|
-
try {
|
|
39
|
-
body = (await response.json());
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
throw new Error("Z.ai monitor endpoint returned a non-JSON body");
|
|
43
|
-
}
|
|
44
|
-
if (body.code !== 200 || typeof body.data !== "object" || body.data === null) {
|
|
45
|
-
throw new Error(`Z.ai monitor endpoint rejected the request (${body.msg ?? `code ${String(body.code)}`})`);
|
|
46
|
-
}
|
|
47
|
-
return body.data;
|
|
48
|
-
}
|
|
10
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
11
|
+
import { createWriter } from "../tui/render.js";
|
|
49
12
|
/** Aggregate saved benchmark reports (specs/benchmark.md) into one local summary. */
|
|
50
13
|
export function aggregateLocalUsage(home) {
|
|
51
14
|
const dir = path.join(configDir(home), "benchmarks");
|
|
@@ -124,39 +87,52 @@ export async function usageCommand(options, deps = {}) {
|
|
|
124
87
|
emitJson(json);
|
|
125
88
|
return quotaError ? 1 : 0;
|
|
126
89
|
}
|
|
127
|
-
const
|
|
90
|
+
const stream = deps.stdout ?? process.stdout;
|
|
91
|
+
const writer = createWriter(stream);
|
|
92
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
93
|
+
const blocks = [];
|
|
94
|
+
const header = ui.header(`GLM CODING ROUTER v${version} / USAGE`, "Coding Plan quota snapshot");
|
|
95
|
+
if (header)
|
|
96
|
+
blocks.push(header);
|
|
97
|
+
const quotaRows = [
|
|
98
|
+
ui.section(`Z.AI CODING PLAN${quota?.level ? ` / ${quota.level}` : ""}`),
|
|
99
|
+
];
|
|
128
100
|
if (quotaError) {
|
|
129
|
-
|
|
130
|
-
|
|
101
|
+
quotaRows.push(ui.row("Z.ai Coding Plan", quotaError, "fail"));
|
|
102
|
+
}
|
|
103
|
+
else if (limits.length === 0) {
|
|
104
|
+
quotaRows.push(ui.detail("(no quota windows reported)"));
|
|
131
105
|
}
|
|
132
106
|
else {
|
|
133
|
-
lines.push(`Z.ai Coding Plan${quota?.level ? ` (level: ${quota.level})` : ""}`);
|
|
134
|
-
if (limits.length === 0) {
|
|
135
|
-
lines.push(" (no quota windows reported)");
|
|
136
|
-
}
|
|
137
107
|
for (const limit of limits) {
|
|
138
108
|
const consumed = limit.currentValue ?? "?";
|
|
139
109
|
const total = limit.usage ?? "?";
|
|
140
|
-
|
|
110
|
+
// Never treat a missing consumed/total as zero quota — only a finite
|
|
111
|
+
// ratio with total > 0 may be turned into a percentage (spec §B.5).
|
|
112
|
+
const percentNumeric = typeof limit.percentage === "number"
|
|
141
113
|
? limit.percentage
|
|
142
114
|
: typeof limit.currentValue === "number" && typeof limit.usage === "number" && limit.usage > 0
|
|
143
115
|
? Math.round((limit.currentValue / limit.usage) * 100)
|
|
144
|
-
:
|
|
116
|
+
: undefined;
|
|
117
|
+
const percentage = percentNumeric ?? "?";
|
|
145
118
|
const resets = typeof limit.nextResetTime === "number" ? ` — resets ${new Date(limit.nextResetTime).toISOString()}` : "";
|
|
146
|
-
|
|
119
|
+
quotaRows.push(ui.row(describeWindow(limit), `${consumed} / ${total} credits (${percentage}%)${resets}`));
|
|
120
|
+
const remaining = typeof limit.remaining === "number" ? `${limit.remaining} remaining` : "remaining unknown";
|
|
121
|
+
quotaRows.push(ui.detail(`${ui.bar(percentNumeric)} · ${remaining}`));
|
|
147
122
|
}
|
|
148
123
|
}
|
|
149
|
-
|
|
150
|
-
|
|
124
|
+
blocks.push(quotaRows.join("\n"));
|
|
125
|
+
const benchmarkRows = [ui.section("LOCAL BENCHMARKS")];
|
|
151
126
|
if (local.runs === 0) {
|
|
152
|
-
|
|
127
|
+
benchmarkRows.push(ui.detail("(none yet — run glm-router benchmark)"));
|
|
153
128
|
}
|
|
154
129
|
else {
|
|
155
|
-
|
|
130
|
+
benchmarkRows.push(ui.row("Runs", String(local.runs)));
|
|
131
|
+
benchmarkRows.push(ui.row("Tokens", `${local.tokensIn} in / ${local.tokensOut} out`));
|
|
132
|
+
benchmarkRows.push(ui.row("Last run", String(local.lastFinishedAt)));
|
|
156
133
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
134
|
+
blocks.push(benchmarkRows.join("\n"));
|
|
135
|
+
blocks.push([ui.section("OTHER PROVIDERS"), ui.row("Claude", json.claude), ui.row("Codex", json.codex)].join("\n"));
|
|
136
|
+
writer.line(blocks.join("\n\n"));
|
|
161
137
|
return quotaError ? 1 : 0;
|
|
162
138
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { Errors } from "../core/errors.js";
|
|
5
|
+
import { logger } from "../core/logging.js";
|
|
6
|
+
import { activeRunFile, runDir } from "../core/paths.js";
|
|
7
|
+
import { createEventBus } from "../events/bus.js";
|
|
8
|
+
import { listActive, listHistory } from "../runs/registry.js";
|
|
9
|
+
import { eventsFilePath } from "../runs/store.js";
|
|
10
|
+
import { attachProgress, resolveProgressMode } from "../tui/progress.js";
|
|
11
|
+
/**
|
|
12
|
+
* fs.watch is the primary follow mechanism but is unreliable on some platforms
|
|
13
|
+
* (and fires spuriously on others), so a slow interval re-reads from the last
|
|
14
|
+
* offset as a backstop — never as a tight poll loop.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_FALLBACK_INTERVAL_MS = 1000;
|
|
17
|
+
/** The events that end a run; seeing one means there is nothing left to follow. */
|
|
18
|
+
const TERMINAL_EVENT_TYPES = new Set(["RunCompleted", "RunFailed", "RunCancelled"]);
|
|
19
|
+
/**
|
|
20
|
+
* glm-router watch [run-id] — attach to a running run and follow it live. The
|
|
21
|
+
* stored events are re-emitted onto a fresh bus that `attachProgress` renders
|
|
22
|
+
* through, so a followed run prints exactly what a live one prints. Attaching
|
|
23
|
+
* reads from the CURRENT end of `events.jsonl` (`--from-start` rewinds to 0):
|
|
24
|
+
* the user wants what happens from now on, not a replay of what they missed.
|
|
25
|
+
* Exit 0 on every stop — a run ending, crashing or being interrupted is what
|
|
26
|
+
* watching is for, not a failure of the command.
|
|
27
|
+
*/
|
|
28
|
+
export function watchCommand(options, deps = {}) {
|
|
29
|
+
const home = deps.home ?? os.homedir();
|
|
30
|
+
const target = resolveTarget(home, options.runId);
|
|
31
|
+
if (target.kind === "none") {
|
|
32
|
+
// Nothing running is normal, not an error — one clear line, exit 0.
|
|
33
|
+
process.stdout.write("no active run\n");
|
|
34
|
+
return Promise.resolve(0);
|
|
35
|
+
}
|
|
36
|
+
if (target.kind === "finished") {
|
|
37
|
+
process.stdout.write(`run ${target.id} already finished (${target.state}) — nothing to follow\n`);
|
|
38
|
+
return Promise.resolve(0);
|
|
39
|
+
}
|
|
40
|
+
const run = target.run;
|
|
41
|
+
const stream = deps.stderr ?? process.stderr;
|
|
42
|
+
const intervalMs = deps.intervalMs ?? DEFAULT_FALLBACK_INTERVAL_MS;
|
|
43
|
+
const file = eventsFilePath(runDir(home, run.date, run.id));
|
|
44
|
+
const activeFile = activeRunFile(home, run.id);
|
|
45
|
+
// The registry is the fast path, but the events are the truth: a run whose
|
|
46
|
+
// last recorded event is terminal is over even while a stale active file
|
|
47
|
+
// still lists it (a crash between summary and cleanup leaves exactly that).
|
|
48
|
+
// Following such a file would hang forever, so say so and leave.
|
|
49
|
+
if (options.fromStart !== true) {
|
|
50
|
+
const ended = lastTerminalEventType(file);
|
|
51
|
+
if (ended !== null) {
|
|
52
|
+
process.stdout.write(`run ${run.id} already ended (${ended}) — nothing to follow\n`);
|
|
53
|
+
return Promise.resolve(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Same mode resolution as a live run, so the two views cannot diverge.
|
|
57
|
+
const mode = resolveProgressMode({
|
|
58
|
+
quiet: options.quiet,
|
|
59
|
+
configMode: loadConfig(home).ui.mode,
|
|
60
|
+
env: process.env,
|
|
61
|
+
isTTY: streamIsTTY(stream),
|
|
62
|
+
});
|
|
63
|
+
const bus = createEventBus(run.id);
|
|
64
|
+
const progress = attachProgress(bus, { mode, stream });
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
// Bytes below `offset` have been consumed; `pending` holds a torn final
|
|
67
|
+
// line until its remainder arrives (the store appends whole lines, so a
|
|
68
|
+
// torn line is the normal mid-append state, not corruption).
|
|
69
|
+
let offset = options.fromStart === true ? 0 : fileSize(file);
|
|
70
|
+
let pending = Buffer.alloc(0);
|
|
71
|
+
let watcher = null;
|
|
72
|
+
let timer = null;
|
|
73
|
+
let stopped = false;
|
|
74
|
+
const stop = (code) => {
|
|
75
|
+
if (stopped) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
stopped = true;
|
|
79
|
+
if (timer !== null) {
|
|
80
|
+
clearInterval(timer);
|
|
81
|
+
}
|
|
82
|
+
watcher?.close();
|
|
83
|
+
process.removeListener("SIGINT", onSigint);
|
|
84
|
+
progress.detach(); // restores the cursor in rich mode
|
|
85
|
+
bus.close();
|
|
86
|
+
resolve(code);
|
|
87
|
+
};
|
|
88
|
+
const onSigint = () => {
|
|
89
|
+
stop(0);
|
|
90
|
+
};
|
|
91
|
+
/** A transient fs error inside a timer/watch callback must not kill node. */
|
|
92
|
+
const pump = () => {
|
|
93
|
+
if (stopped) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
pumpOnce();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
logger.debug(`watch: pumping ${file} failed: ${errorMessage(error)}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const pumpOnce = () => {
|
|
104
|
+
// A stat failure (file not created yet by a very fresh run, or a prune
|
|
105
|
+
// racing the watch) reads as "nothing new" — the active-file check below
|
|
106
|
+
// is the only authority on whether the run itself ended.
|
|
107
|
+
const size = fileSize(file);
|
|
108
|
+
if (size > offset) {
|
|
109
|
+
const chunk = readRange(file, offset, size - offset);
|
|
110
|
+
offset = size;
|
|
111
|
+
pending = Buffer.concat([pending, chunk]);
|
|
112
|
+
}
|
|
113
|
+
// Only newline-terminated lines are parsed; anything else waits for the
|
|
114
|
+
// next pump, which is what makes a mid-append read harmless.
|
|
115
|
+
for (;;) {
|
|
116
|
+
const newline = pending.indexOf(10);
|
|
117
|
+
if (newline === -1) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
const line = pending.subarray(0, newline).toString("utf8").trim();
|
|
121
|
+
pending = pending.subarray(newline + 1);
|
|
122
|
+
const event = parseStoredEvent(line);
|
|
123
|
+
if (event === null) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// The bus re-stamps seq/ts on replay; nothing downstream of a watch
|
|
127
|
+
// renders those fields, and a single emit authority stays simpler
|
|
128
|
+
// than a bypass that would let two writers disagree.
|
|
129
|
+
bus.emit(event);
|
|
130
|
+
if (TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
131
|
+
stop(0);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Every terminal event is appended before the active file is deleted,
|
|
136
|
+
// so at this point a missing active file means the run died without
|
|
137
|
+
// one (crash) — the pump above already delivered its last events.
|
|
138
|
+
if (!fs.existsSync(activeFile)) {
|
|
139
|
+
process.stdout.write(`run ${run.id} is no longer active — no terminal event was recorded\n`);
|
|
140
|
+
stop(0);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const tryStartFsWatch = () => {
|
|
144
|
+
if (watcher !== null || stopped) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
watcher = fs.watch(file, () => {
|
|
149
|
+
pump();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// A run so fresh that events.jsonl does not exist yet — the backstop
|
|
154
|
+
// tick retries until the store creates it.
|
|
155
|
+
logger.debug(`watch: fs.watch on ${file} failed: ${errorMessage(error)}`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
process.on("SIGINT", onSigint);
|
|
159
|
+
if (options.fromStart === true) {
|
|
160
|
+
pump();
|
|
161
|
+
}
|
|
162
|
+
if (!stopped) {
|
|
163
|
+
if (deps.watchImpl !== undefined) {
|
|
164
|
+
watcher = deps.watchImpl(file, pump);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
tryStartFsWatch();
|
|
168
|
+
}
|
|
169
|
+
timer = setInterval(() => {
|
|
170
|
+
if (deps.watchImpl === undefined) {
|
|
171
|
+
tryStartFsWatch();
|
|
172
|
+
}
|
|
173
|
+
pump();
|
|
174
|
+
}, intervalMs);
|
|
175
|
+
// fs.watch keeps the loop alive on its own; the interval is only a
|
|
176
|
+
// backstop and must not become a second reason to stay alive. When it
|
|
177
|
+
// IS the mechanism (watchImpl injected, or fs.watch never started), it
|
|
178
|
+
// stays referenced — otherwise node would exit mid-follow.
|
|
179
|
+
if (deps.watchImpl === undefined && watcher !== null) {
|
|
180
|
+
timer.unref();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Full id or unique suffix, exact match first, ambiguity named — the same
|
|
187
|
+
* rule as `runs show`. A suffix can also hit history: a finished run is
|
|
188
|
+
* reported as such rather than "not found" (which would be false) — and
|
|
189
|
+
* watching one would hang forever, so the caller prints and exits instead.
|
|
190
|
+
*/
|
|
191
|
+
function resolveTarget(home, input) {
|
|
192
|
+
const activeRuns = listActive(home); // newest first
|
|
193
|
+
if (input === undefined) {
|
|
194
|
+
return activeRuns.length > 0 ? { kind: "active", run: activeRuns[0] } : { kind: "none" };
|
|
195
|
+
}
|
|
196
|
+
const exact = activeRuns.find((run) => run.id === input);
|
|
197
|
+
if (exact !== undefined) {
|
|
198
|
+
return { kind: "active", run: exact };
|
|
199
|
+
}
|
|
200
|
+
const suffix = activeRuns.filter((run) => run.id.endsWith(input));
|
|
201
|
+
if (suffix.length === 1) {
|
|
202
|
+
return { kind: "active", run: suffix[0] };
|
|
203
|
+
}
|
|
204
|
+
if (suffix.length > 1) {
|
|
205
|
+
throw Errors.invalidArgs(`run id "${input}" is ambiguous — ${suffix.length} active runs end with it:`, suffix.map((run) => run.id));
|
|
206
|
+
}
|
|
207
|
+
const refs = listHistory(home);
|
|
208
|
+
const refExact = refs.find((ref) => ref.id === input);
|
|
209
|
+
const refSuffix = refs.filter((ref) => ref.id.endsWith(input));
|
|
210
|
+
const match = refExact ?? (refSuffix.length === 1 ? refSuffix[0] : undefined);
|
|
211
|
+
if (refExact === undefined && refSuffix.length > 1) {
|
|
212
|
+
throw Errors.invalidArgs(`run id "${input}" is ambiguous — ${refSuffix.length} recorded runs end with it:`, refSuffix.map((candidate) => candidate.id));
|
|
213
|
+
}
|
|
214
|
+
if (match !== undefined) {
|
|
215
|
+
return { kind: "finished", id: match.id, state: match.state };
|
|
216
|
+
}
|
|
217
|
+
throw Errors.invalidArgs(`no run found with id "${input}"`, [`Run "glm-router runs" to list recorded runs.`]);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The runtime check mirrors `readEvents`: "parses and has a `type` string".
|
|
221
|
+
* History written by a future version must degrade to an ignored line, not
|
|
222
|
+
* crash the watcher.
|
|
223
|
+
*/
|
|
224
|
+
function parseStoredEvent(line) {
|
|
225
|
+
if (line === "") {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(line);
|
|
230
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.type === "string") {
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// A malformed newline-terminated line is skipped, like readEvents skips it.
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
/** Reads exactly [offset, offset+length) — a follow must never re-read bytes. */
|
|
240
|
+
function readRange(file, offset, length) {
|
|
241
|
+
const fd = fs.openSync(file, "r");
|
|
242
|
+
try {
|
|
243
|
+
const buffer = Buffer.alloc(length);
|
|
244
|
+
const read = fs.readSync(fd, buffer, 0, length, offset);
|
|
245
|
+
return read === length ? buffer : buffer.subarray(0, read);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
fs.closeSync(fd);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function fileSize(file) {
|
|
252
|
+
try {
|
|
253
|
+
return fs.statSync(file).size;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The `type` of the last COMPLETE line, when that type is terminal — null
|
|
261
|
+
* otherwise (including "file missing" and "last line torn", which both mean
|
|
262
|
+
* "keep following"). Only the file's tail is read; one event line is far
|
|
263
|
+
* smaller than the 8 KiB window.
|
|
264
|
+
*/
|
|
265
|
+
function lastTerminalEventType(file) {
|
|
266
|
+
const size = fileSize(file);
|
|
267
|
+
if (size === 0) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const length = Math.min(size, 8192);
|
|
271
|
+
const body = readRange(file, size - length, length).toString("utf8");
|
|
272
|
+
const terminated = body.endsWith("\n") ? body.slice(0, -1) : body;
|
|
273
|
+
const lastNewline = terminated.lastIndexOf("\n");
|
|
274
|
+
if (lastNewline !== -1 || length === size) {
|
|
275
|
+
const line = (lastNewline === -1 ? terminated : terminated.slice(lastNewline + 1)).trim();
|
|
276
|
+
const event = parseStoredEvent(line);
|
|
277
|
+
if (event !== null && TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
278
|
+
return event.type;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
/** NodeJS.WritableStream has no terminal members; narrow structurally. */
|
|
284
|
+
function streamIsTTY(stream) {
|
|
285
|
+
return stream.isTTY === true;
|
|
286
|
+
}
|
|
287
|
+
function errorMessage(error) {
|
|
288
|
+
return error instanceof Error ? error.message : String(error);
|
|
289
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -68,6 +68,53 @@ export const ConfigSchema = z.object({
|
|
|
68
68
|
codexPath: z.string().min(1).optional(),
|
|
69
69
|
// Named overlays selected via --profile (specs/glm-fast-profiles.md).
|
|
70
70
|
profiles: z.record(z.string(), ProfileSchema).default({}),
|
|
71
|
+
// Run-history retention (specs/v2-architecture.md, Phase B / Config v2).
|
|
72
|
+
// The whole section is defaulted so every v1 config still validates.
|
|
73
|
+
history: z.object({
|
|
74
|
+
retentionDays: z.number().int().positive(),
|
|
75
|
+
maxRuns: z.number().int().positive(),
|
|
76
|
+
}).default({ retentionDays: 30, maxRuns: 1000 }),
|
|
77
|
+
// Progress renderer defaults (specs/v2-architecture.md, Phase C / Config v2).
|
|
78
|
+
// Defaulted exactly like `history` so every v1 config still validates and
|
|
79
|
+
// schemaVersion stays 1; `mode: "auto"` means rich on a TTY, nested otherwise.
|
|
80
|
+
ui: z.object({
|
|
81
|
+
mode: z.enum(["auto", "rich", "nested", "off"]),
|
|
82
|
+
color: z.boolean(),
|
|
83
|
+
}).default({ mode: "auto", color: true }),
|
|
84
|
+
// Quota-aware routing (specs/v2-architecture.md, Phase E / Config v2).
|
|
85
|
+
// Defaulted exactly like `history` so every v1 config still validates and
|
|
86
|
+
// schemaVersion stays 1.
|
|
87
|
+
routing: z.object({
|
|
88
|
+
quotaAware: z.boolean(),
|
|
89
|
+
refuseOnCritical: z.boolean(),
|
|
90
|
+
handoffOnLowQuota: z.boolean(),
|
|
91
|
+
reserveRatio: z.number().gt(0).lt(1),
|
|
92
|
+
safetyFactor: z.number().gte(1),
|
|
93
|
+
preferFlashBelow: z.number().gt(0).lt(1),
|
|
94
|
+
handoffReadyBelow: z.number().gt(0).lt(1),
|
|
95
|
+
criticalBelow: z.number().gt(0).lt(1),
|
|
96
|
+
pollIntervalSec: z.number().int().positive(),
|
|
97
|
+
quotaCacheTtlSec: z.number().int().positive(),
|
|
98
|
+
})
|
|
99
|
+
.refine((routing) => routing.criticalBelow < routing.handoffReadyBelow &&
|
|
100
|
+
routing.handoffReadyBelow < routing.preferFlashBelow, { message: "routing zones must be ordered criticalBelow < handoffReadyBelow < preferFlashBelow" })
|
|
101
|
+
.default({
|
|
102
|
+
quotaAware: true,
|
|
103
|
+
// D3 (specs/v2-architecture.md, Decisions): refuseOnCritical and
|
|
104
|
+
// handoffOnLowQuota ship OFF in 2.0.0. Both act on an unmeasured cost
|
|
105
|
+
// baseline, and a wrong refusal/kill blocks real work behind a --force
|
|
106
|
+
// escape hatch; a wrong downgrade costs almost nothing. Observe and
|
|
107
|
+
// downgrade only until the routingAdvice evidence justifies flipping.
|
|
108
|
+
refuseOnCritical: false,
|
|
109
|
+
handoffOnLowQuota: false,
|
|
110
|
+
reserveRatio: 0.10,
|
|
111
|
+
safetyFactor: 1.3,
|
|
112
|
+
preferFlashBelow: 0.30,
|
|
113
|
+
handoffReadyBelow: 0.15,
|
|
114
|
+
criticalBelow: 0.08,
|
|
115
|
+
pollIntervalSec: 60,
|
|
116
|
+
quotaCacheTtlSec: 60,
|
|
117
|
+
}),
|
|
71
118
|
});
|
|
72
119
|
export function defaultConfig() {
|
|
73
120
|
return {
|
|
@@ -88,6 +135,20 @@ export function defaultConfig() {
|
|
|
88
135
|
codexSkill: true,
|
|
89
136
|
},
|
|
90
137
|
profiles: {},
|
|
138
|
+
history: { retentionDays: 30, maxRuns: 1000 },
|
|
139
|
+
ui: { mode: "auto", color: true },
|
|
140
|
+
routing: {
|
|
141
|
+
quotaAware: true,
|
|
142
|
+
refuseOnCritical: false, // D3: observe in 2.0.0, refuse only on 2.1 evidence
|
|
143
|
+
handoffOnLowQuota: false, // D3: never kill a live child by default
|
|
144
|
+
reserveRatio: 0.10,
|
|
145
|
+
safetyFactor: 1.3,
|
|
146
|
+
preferFlashBelow: 0.30,
|
|
147
|
+
handoffReadyBelow: 0.15,
|
|
148
|
+
criticalBelow: 0.08,
|
|
149
|
+
pollIntervalSec: 60,
|
|
150
|
+
quotaCacheTtlSec: 60,
|
|
151
|
+
},
|
|
91
152
|
};
|
|
92
153
|
}
|
|
93
154
|
/**
|
package/dist/core/errors.js
CHANGED
|
@@ -22,6 +22,8 @@ export const ExitCode = {
|
|
|
22
22
|
ProjectRootNotFound: 30,
|
|
23
23
|
ManagedFileWriteFailed: 31,
|
|
24
24
|
ChildAgentFailed: 40,
|
|
25
|
+
QuotaInsufficient: 41,
|
|
26
|
+
HandoffRequired: 42,
|
|
25
27
|
UnsupportedPlatform: 50,
|
|
26
28
|
};
|
|
27
29
|
/** Base error for all expected failures. Printed as `ERROR [NAME]` (spec §36). */
|
|
@@ -102,6 +104,28 @@ export const Errors = {
|
|
|
102
104
|
message: `The child agent process failed: ${cause}`,
|
|
103
105
|
exitCode: ExitCode.ChildAgentFailed,
|
|
104
106
|
}),
|
|
107
|
+
// 41 and 42 (specs/v2-architecture.md Phase E/F, decision D2) both mean
|
|
108
|
+
// "unfinished, work preserved" — orchestrators read them as a handoff, not
|
|
109
|
+
// a crash, which is why their hints point at resuming rather than retrying.
|
|
110
|
+
quotaInsufficient: (estimatedCost, usableBudget) => new GlmRouterError({
|
|
111
|
+
name: "QUOTA_INSUFFICIENT",
|
|
112
|
+
// Both numbers are plan credits (H7), never currency.
|
|
113
|
+
message: `Estimated cost ${estimatedCost} credits does not fit the usable budget of ${usableBudget} credits.`,
|
|
114
|
+
// 41 is the preflight refusal: nothing was spawned yet, so the only
|
|
115
|
+
// moves are wait for a reset or explicitly accept the risk.
|
|
116
|
+
hint: ["Wait for the quota window to reset, or re-run with --force to run anyway."],
|
|
117
|
+
exitCode: ExitCode.QuotaInsufficient,
|
|
118
|
+
}),
|
|
119
|
+
handoffRequired: (reason, bundlePath) => new GlmRouterError({
|
|
120
|
+
name: "HANDOFF_REQUIRED",
|
|
121
|
+
message: `The run stopped unfinished: ${reason}.`,
|
|
122
|
+
// 42 is the mid-run handoff: the work is never lost, only moved.
|
|
123
|
+
hint: [
|
|
124
|
+
"The work is unfinished but preserved.",
|
|
125
|
+
...(bundlePath !== undefined ? [`Pick it up from: ${bundlePath}`] : []),
|
|
126
|
+
],
|
|
127
|
+
exitCode: ExitCode.HandoffRequired,
|
|
128
|
+
}),
|
|
105
129
|
unsupportedPlatform: (platform) => new GlmRouterError({
|
|
106
130
|
name: "UNSUPPORTED_PLATFORM",
|
|
107
131
|
message: `This platform is not supported (detected: ${platform}).`,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-source credential comparison for `doctor` (specs/terminal-ui-doctor.md
|
|
3
|
+
* §C). This module never changes runtime key precedence — `resolveZaiApiKey`
|
|
4
|
+
* in zai-key.ts remains the single source of truth for which key an agent
|
|
5
|
+
* actually uses. It only *observes* both sources once, privately, so doctor
|
|
6
|
+
* can warn when the process value that wins is stale.
|
|
7
|
+
*
|
|
8
|
+
* `effectiveKey` on the returned snapshot is the real secret value. Never put
|
|
9
|
+
* it in a `DoctorReport`, JSON payload, log line or thrown error — only the
|
|
10
|
+
* `comparison` / `keyMismatch` / `effectiveSource` fields are safe to expose.
|
|
11
|
+
*/
|
|
12
|
+
import { detectUserEnvStore, readUserEnvDiagnostic } from "./user-env.js";
|
|
13
|
+
import { ZAI_API_KEY_ENV } from "./zai-key.js";
|
|
14
|
+
/**
|
|
15
|
+
* Read both sources once and compare them without changing which one wins.
|
|
16
|
+
* Precedence mirrors `resolveZaiApiKey`: process environment, then the
|
|
17
|
+
* per-user store.
|
|
18
|
+
*/
|
|
19
|
+
export function inspectZaiKey(options = {}) {
|
|
20
|
+
const env = options.env ?? process.env;
|
|
21
|
+
const store = options.store ?? detectUserEnvStore();
|
|
22
|
+
const diagnose = options.readUserEnvDiagnostic ?? ((name) => readUserEnvDiagnostic(name));
|
|
23
|
+
const rawProcess = env[ZAI_API_KEY_ENV];
|
|
24
|
+
const processValue = rawProcess && rawProcess.trim() ? rawProcess.trim() : undefined;
|
|
25
|
+
const storeResult = diagnose(ZAI_API_KEY_ENV);
|
|
26
|
+
const storeValue = storeResult.value;
|
|
27
|
+
const effectiveKey = processValue ?? storeValue;
|
|
28
|
+
const effectiveSource = processValue
|
|
29
|
+
? "process-env"
|
|
30
|
+
: storeValue
|
|
31
|
+
? "user-store"
|
|
32
|
+
: undefined;
|
|
33
|
+
let comparison;
|
|
34
|
+
if (!storeResult.readable) {
|
|
35
|
+
comparison = "unavailable";
|
|
36
|
+
}
|
|
37
|
+
else if (processValue && storeValue) {
|
|
38
|
+
comparison = processValue === storeValue ? "match" : "different";
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
comparison = "not-comparable";
|
|
42
|
+
}
|
|
43
|
+
const keyMismatch = comparison === "different" ? true : comparison === "match" ? false : null;
|
|
44
|
+
return { effectiveKey, effectiveSource, comparison, keyMismatch, store };
|
|
45
|
+
}
|
package/dist/core/paths.js
CHANGED
|
@@ -11,3 +11,35 @@ export function configPath(home = os.homedir()) {
|
|
|
11
11
|
export function ownershipPath(home = os.homedir()) {
|
|
12
12
|
return path.join(configDir(home), "ownership.json");
|
|
13
13
|
}
|
|
14
|
+
/** Run history root: <configDir>/runs (specs/v2-architecture.md, Phase B). */
|
|
15
|
+
export function runsDir(home = os.homedir()) {
|
|
16
|
+
return path.join(configDir(home), "runs");
|
|
17
|
+
}
|
|
18
|
+
/** Registry of still-running workers; a run graduates to history on finish. */
|
|
19
|
+
export function activeRunsDir(home = os.homedir()) {
|
|
20
|
+
return path.join(runsDir(home), "active");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* History partitioned per day so retention can prune whole date directories
|
|
24
|
+
* (doc §6). `date` (YYYY-MM-DD) comes from the caller — no clock inside the
|
|
25
|
+
* path helpers, so the store decides when the day flips.
|
|
26
|
+
*/
|
|
27
|
+
export function runHistoryDir(home = os.homedir(), date) {
|
|
28
|
+
return path.join(runsDir(home), "history", date);
|
|
29
|
+
}
|
|
30
|
+
/** One directory per run: events.jsonl, summary.json, checkpoint, handoff bundle. */
|
|
31
|
+
export function runDir(home = os.homedir(), date, id) {
|
|
32
|
+
return path.join(runHistoryDir(home, date), id);
|
|
33
|
+
}
|
|
34
|
+
/** The registry entry for an active run, deleted when the run moves to history. */
|
|
35
|
+
export function activeRunFile(home = os.homedir(), id) {
|
|
36
|
+
return path.join(activeRunsDir(home), `${id}.json`);
|
|
37
|
+
}
|
|
38
|
+
/** Cached quota snapshot (specs/v2-architecture.md, Phase E) — a cache, never truth. */
|
|
39
|
+
export function quotaCachePath(home = os.homedir()) {
|
|
40
|
+
return path.join(configDir(home), "cache", "quota.json");
|
|
41
|
+
}
|
|
42
|
+
/** Cost-history samples, one JSON line per cleanly measured run (doc §13, Phase E). */
|
|
43
|
+
export function costSamplesPath(home = os.homedir()) {
|
|
44
|
+
return path.join(configDir(home), "cost-samples.jsonl");
|
|
45
|
+
}
|