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.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +542 -426
  3. package/dist/bin/glm-review.js +28 -3
  4. package/dist/bin/glm-worker.js +30 -4
  5. package/dist/budget/estimator.js +218 -0
  6. package/dist/budget/manager.js +223 -0
  7. package/dist/cli.js +46 -3
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/doctor-auth.js +107 -0
  10. package/dist/commands/doctor-command.js +171 -41
  11. package/dist/commands/landing.js +47 -0
  12. package/dist/commands/runs.js +568 -0
  13. package/dist/commands/status.js +28 -15
  14. package/dist/commands/usage.js +34 -58
  15. package/dist/commands/watch.js +289 -0
  16. package/dist/core/config.js +61 -0
  17. package/dist/core/errors.js +24 -0
  18. package/dist/core/key-inspector.js +45 -0
  19. package/dist/core/paths.js +32 -0
  20. package/dist/core/process.js +83 -0
  21. package/dist/core/prompt.js +18 -5
  22. package/dist/core/routing-flags.js +59 -0
  23. package/dist/core/user-env.js +17 -7
  24. package/dist/core/zai-quota.js +148 -0
  25. package/dist/events/bus.js +64 -0
  26. package/dist/events/claude-adapter.js +416 -0
  27. package/dist/events/types.js +9 -0
  28. package/dist/handoff/bundle.js +203 -0
  29. package/dist/handoff/parent-handoff.js +48 -0
  30. package/dist/mcp/server.js +45 -1
  31. package/dist/routing/glm-routing.js +131 -0
  32. package/dist/runs/checkpoint.js +204 -0
  33. package/dist/runs/drain.js +165 -0
  34. package/dist/runs/heartbeat.js +45 -0
  35. package/dist/runs/registry.js +350 -0
  36. package/dist/runs/store.js +186 -0
  37. package/dist/runs/ulid.js +112 -0
  38. package/dist/runs/worker-run.js +672 -0
  39. package/dist/templates/agents-block.js +53 -44
  40. package/dist/templates/claude-block.js +56 -47
  41. package/dist/templates/glm-delegation-skill.js +76 -65
  42. package/dist/tui/command-ui.js +158 -0
  43. package/dist/tui/progress.js +338 -0
  44. package/dist/tui/render.js +144 -0
  45. package/package.json +1 -1
@@ -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
- /** Z.ai monitor API used by their own dashboard (specs/usage.md). */
10
- const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
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 lines = [`GLM Coding Router v${version} — usage snapshot`, ""];
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
- lines.push(`Z.ai Coding Plan`);
130
- lines.push(` ✗ ${quotaError}`);
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
- const percentage = typeof limit.percentage === "number"
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
- lines.push(` ${describeWindow(limit).padEnd(15)} ${consumed} / ${total} credits (${percentage}%)${resets}`);
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
- lines.push("");
150
- lines.push("Local (benchmark reports)");
124
+ blocks.push(quotaRows.join("\n"));
125
+ const benchmarkRows = [ui.section("LOCAL BENCHMARKS")];
151
126
  if (local.runs === 0) {
152
- lines.push(" (none yet — run glm-router benchmark)");
127
+ benchmarkRows.push(ui.detail("(none yet — run glm-router benchmark)"));
153
128
  }
154
129
  else {
155
- lines.push(` runs ${local.runs} · tokens ${local.tokensIn} in / ${local.tokensOut} out · last ${local.lastFinishedAt}`);
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
- lines.push("");
158
- lines.push(`Claude quota ${json.claude}`);
159
- lines.push(`Codex usage ${json.codex}`);
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
+ }
@@ -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
  /**
@@ -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
+ }
@@ -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
+ }