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
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { Errors } from "./errors.js";
3
+ import { logger } from "./logging.js";
3
4
  /**
4
5
  * Spawn a child agent process (spec §18, §32, §39):
5
6
  * - argument array, never a shell string
@@ -64,6 +65,88 @@ export function spawnAgentCapture(binPath, options) {
64
65
  });
65
66
  });
66
67
  }
68
+ /**
69
+ * Like spawnAgentCapture, but delivers every complete stdout/stderr line the
70
+ * moment it arrives (v2 spec Phase D) instead of buffering the whole output
71
+ * until exit — that buffering is exactly what makes live progress impossible.
72
+ * Same no-shell argv rule and signal forwarding. A chunk boundary can fall
73
+ * inside a JSON object, so each stream carries its own partial-line buffer;
74
+ * blank lines are dropped, and a trailing line without a newline is flushed
75
+ * before the promise resolves — a crashed agent's last line is still evidence.
76
+ * A throwing callback is reported at debug level and never kills the run.
77
+ */
78
+ export function spawnAgentStream(binPath, options) {
79
+ const { args, cwd, env, onStdoutLine, onStderrLine, onSpawn } = options;
80
+ return new Promise((resolve, reject) => {
81
+ const child = spawn(binPath, args, {
82
+ cwd,
83
+ env,
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ shell: false,
86
+ windowsHide: false,
87
+ });
88
+ if (onSpawn) {
89
+ try {
90
+ onSpawn(child);
91
+ }
92
+ catch (error) {
93
+ logger.debug(`spawnAgentStream: onSpawn callback failed: ${errorMessage(error)}`);
94
+ }
95
+ }
96
+ const stdout = createLineSplitter(onStdoutLine);
97
+ const stderr = createLineSplitter(onStderrLine);
98
+ child.stdout?.setEncoding("utf8");
99
+ child.stderr?.setEncoding("utf8");
100
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
101
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
102
+ const handlers = forwardSignals(child);
103
+ child.on("error", (error) => {
104
+ removeSignals(handlers);
105
+ reject(Errors.childAgentFailed(errorMessage(error)));
106
+ });
107
+ // "close", not "exit": it fires once the stdio streams are drained, so the
108
+ // trailing-line flush below cannot race data still sitting in the pipe.
109
+ child.on("close", (code) => {
110
+ removeSignals(handlers);
111
+ stdout.flush();
112
+ stderr.flush();
113
+ resolve({ code: code ?? 1 });
114
+ });
115
+ });
116
+ }
117
+ /** Per-stream line buffer for spawnAgentStream: emits each complete line (\n or \r\n) as it arrives. */
118
+ function createLineSplitter(deliver) {
119
+ let buffer = "";
120
+ const emit = (line) => {
121
+ if (line.length === 0)
122
+ return;
123
+ try {
124
+ deliver(line);
125
+ }
126
+ catch (error) {
127
+ logger.debug(`spawnAgentStream: line callback failed: ${errorMessage(error)}`);
128
+ }
129
+ };
130
+ return {
131
+ push(chunk) {
132
+ buffer += chunk;
133
+ let newline = buffer.indexOf("\n");
134
+ while (newline >= 0) {
135
+ const line = buffer.slice(0, newline);
136
+ buffer = buffer.slice(newline + 1);
137
+ emit(line.endsWith("\r") ? line.slice(0, -1) : line);
138
+ newline = buffer.indexOf("\n");
139
+ }
140
+ },
141
+ flush() {
142
+ if (buffer.length > 0) {
143
+ const line = buffer;
144
+ buffer = "";
145
+ emit(line);
146
+ }
147
+ },
148
+ };
149
+ }
67
150
  /** Install SIGINT/SIGTERM forwarding; returns the handlers for cleanup. */
68
151
  function forwardSignals(child) {
69
152
  const handlers = new Map();
@@ -17,16 +17,29 @@ export function readStdin() {
17
17
  }
18
18
  /**
19
19
  * Resolve the task prompt (spec §15, §40):
20
- * stdin text joined arguments → error
20
+ * argumentsstdin text → error
21
+ *
22
+ * Arguments are checked FIRST, and when they carry a prompt stdin is never
23
+ * awaited. The original order was stdin-first, which hangs forever whenever
24
+ * stdin is an open pipe that never reaches EOF — the normal shape under an
25
+ * agent harness, a CI runner or `nohup`. Measured on Windows: with stdin held
26
+ * open, `glm-worker "Reply exactly with X"` did nothing at all — no run
27
+ * directory, no API call, no output — until the pipe closed 25 s later, then
28
+ * completed in 7 s. A prompt in argv is an explicit instruction and must not
29
+ * wait on a stream that may never close.
30
+ *
31
+ * Piping a task packet still works exactly as before: with no arguments,
32
+ * blocking until EOF is the correct thing to do, because there is nothing
33
+ * else to run.
21
34
  */
22
35
  export async function resolvePrompt(argv, readStdinFn = readStdin, command = "glm-worker") {
23
- const stdinText = await readStdinFn();
24
- if (stdinText !== undefined && stdinText.trim().length > 0) {
25
- return stdinText;
26
- }
27
36
  const argText = argv.join(" ").trim();
28
37
  if (argText.length > 0) {
29
38
  return argText;
30
39
  }
40
+ const stdinText = await readStdinFn();
41
+ if (stdinText !== undefined && stdinText.trim().length > 0) {
42
+ return stdinText;
43
+ }
31
44
  throw Errors.promptRequired(command);
32
45
  }
@@ -0,0 +1,59 @@
1
+ import { Errors } from "./errors.js";
2
+ /** The only two values `--model` takes; the router routes between config slots, not model names. */
3
+ const MODEL_SLOTS = ["main", "fast"];
4
+ /**
5
+ * Remove the router's Phase E flags from argv (specs/v2-architecture.md).
6
+ *
7
+ * Every occurrence is consumed, including repeats, because whatever is left in
8
+ * `rest` becomes the PROMPT — `resolvePrompt` joins it — so a flag that
9
+ * survives this function does not reach Claude as a flag, it silently becomes
10
+ * task text. That is why an unknown `--model` value is an error rather than a
11
+ * pass-through: quietly appending "--model gpt-4" to someone's prompt is worse
12
+ * than telling them the flag takes main or fast.
13
+ *
14
+ * Like `--profile` (specs/glm-fast-profiles.md), our `--model` deliberately
15
+ * shadows Claude Code's own flag of that name inside these binaries; callers
16
+ * who need Claude's version can call `claude` directly.
17
+ */
18
+ export function extractRoutingFlags(argv) {
19
+ const rest = [];
20
+ let model;
21
+ let force = false;
22
+ let refreshQuota = false;
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === "--force") {
26
+ force = true;
27
+ continue;
28
+ }
29
+ if (arg === "--refresh-quota") {
30
+ refreshQuota = true;
31
+ continue;
32
+ }
33
+ if (arg === "--model") {
34
+ // The first occurrence wins, but later ones are still consumed so they
35
+ // cannot leak into the prompt.
36
+ model ??= parseSlot(argv[i + 1]);
37
+ i++;
38
+ continue;
39
+ }
40
+ if (arg.startsWith("--model=")) {
41
+ model ??= parseSlot(arg.slice("--model=".length));
42
+ continue;
43
+ }
44
+ rest.push(arg);
45
+ }
46
+ return { rest, model, force, refreshQuota };
47
+ }
48
+ function parseSlot(value) {
49
+ if (value === undefined || !MODEL_SLOTS.includes(value)) {
50
+ throw Errors.invalidArgs(`--model expects "main" or "fast"${value === undefined ? "" : `, got "${value}"`}.`, [
51
+ "Pick the configured slot, not a model name:",
52
+ "",
53
+ " glm-worker --model fast \"<task>\"",
54
+ "",
55
+ "The names behind the slots come from models.main / models.fast in config.",
56
+ ]);
57
+ }
58
+ return value;
59
+ }
@@ -63,12 +63,17 @@ export function describeKeyStore(store) {
63
63
  }
64
64
  }
65
65
  /**
66
- * Read a variable from the per-user store. Returns undefined on any failure —
67
- * callers fall back or raise their own error.
66
+ * Read a variable from the per-user store, distinguishing "no store" / "not
67
+ * set" (readable: true, value: undefined) from "the read itself failed"
68
+ * (readable: false). `readUserEnv` below is a thin wrapper that collapses
69
+ * both to `undefined` for existing callers.
68
70
  */
69
- export function readUserEnv(name, deps = {}) {
71
+ export function readUserEnvDiagnostic(name, deps = {}) {
70
72
  assertEnvVarName(name);
71
73
  const store = detectUserEnvStore(deps);
74
+ if (store === "none") {
75
+ return { readable: true, value: undefined };
76
+ }
72
77
  const run = deps.run ?? defaultRun;
73
78
  try {
74
79
  let value;
@@ -87,16 +92,21 @@ export function readUserEnv(name, deps = {}) {
87
92
  case "libsecret":
88
93
  value = run("secret-tool", ["lookup", "service", KEY_STORE_SERVICE, "account", name], { capture: true });
89
94
  break;
90
- case "none":
91
- return undefined;
92
95
  }
93
96
  const trimmed = value.trim();
94
- return trimmed || undefined;
97
+ return { readable: true, value: trimmed || undefined };
95
98
  }
96
99
  catch {
97
- return undefined;
100
+ return { readable: false, value: undefined };
98
101
  }
99
102
  }
103
+ /**
104
+ * Read a variable from the per-user store. Returns undefined on any failure —
105
+ * callers fall back or raise their own error.
106
+ */
107
+ export function readUserEnv(name, deps = {}) {
108
+ return readUserEnvDiagnostic(name, deps).value;
109
+ }
100
110
  /**
101
111
  * Write a variable to the per-user store. Throws when there is no store —
102
112
  * callers print platform-appropriate guidance instead.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The Z.ai monitor endpoint lives in core because four readers need it —
3
+ * usage, dashboard, the MCP server and the Phase E budget manager — and a
4
+ * budget module must not import from a command module: commands sit on top
5
+ * of core, never underneath it.
6
+ */
7
+ /** Z.ai monitor API used by their own dashboard (specs/usage.md). */
8
+ export const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
9
+ /** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
10
+ export function describeWindow(limit) {
11
+ if (limit.unit === 3 && typeof limit.number === "number") {
12
+ return `${limit.number}-hour window`;
13
+ }
14
+ if (limit.unit === 6 && limit.number === 1) {
15
+ return "weekly";
16
+ }
17
+ return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
18
+ }
19
+ /**
20
+ * Thrown by `fetchZaiQuota` instead of a plain `Error`. `message` is always a
21
+ * safe, locally-constructed string — it never contains the raw response body,
22
+ * a provider `msg`, headers, or a URL with credentials (spec §D "Security").
23
+ */
24
+ export class ZaiQuotaError extends Error {
25
+ kind;
26
+ httpStatus;
27
+ /** Only meaningful when kind === "network": distinguishes a timeout from any other network failure. */
28
+ timeout;
29
+ constructor(kind, message, options = {}) {
30
+ super(message);
31
+ this.name = "ZaiQuotaError";
32
+ this.kind = kind;
33
+ this.httpStatus = options.httpStatus;
34
+ this.timeout = options.timeout ?? false;
35
+ }
36
+ }
37
+ function isPlainObject(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ function isFiniteNumberOrAbsent(value) {
41
+ return value === undefined || (typeof value === "number" && Number.isFinite(value));
42
+ }
43
+ /** Every quota field is optional, but any field that IS present must be well-typed. */
44
+ function isValidLimit(entry) {
45
+ if (!isPlainObject(entry))
46
+ return false;
47
+ if (entry.type !== undefined && typeof entry.type !== "string")
48
+ return false;
49
+ return (isFiniteNumberOrAbsent(entry.unit) &&
50
+ isFiniteNumberOrAbsent(entry.number) &&
51
+ isFiniteNumberOrAbsent(entry.usage) &&
52
+ isFiniteNumberOrAbsent(entry.currentValue) &&
53
+ isFiniteNumberOrAbsent(entry.remaining) &&
54
+ isFiniteNumberOrAbsent(entry.percentage) &&
55
+ isFiniteNumberOrAbsent(entry.nextResetTime));
56
+ }
57
+ /**
58
+ * Validate the monitor response shape (spec §D "Success validation"). Empty
59
+ * `limits` is a valid, fully-authenticated response meaning "no windows
60
+ * reported" — never treated as zero quota. A missing or wrong-type `limits`
61
+ * field means the schema did not match, which is unverified, not rejected.
62
+ */
63
+ function parseQuotaBody(body) {
64
+ if (!isPlainObject(body)) {
65
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint returned an unexpected response shape." };
66
+ }
67
+ if (typeof body.code !== "number") {
68
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing a status code." };
69
+ }
70
+ if (body.code !== 200) {
71
+ return { ok: false, kind: "provider", message: `Z.ai monitor endpoint rejected the request (code ${body.code}).` };
72
+ }
73
+ if (body.success !== undefined && body.success !== true) {
74
+ return { ok: false, kind: "provider", message: "Z.ai monitor endpoint reported an unsuccessful request." };
75
+ }
76
+ if (!isPlainObject(body.data)) {
77
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage data." };
78
+ }
79
+ const data = body.data;
80
+ if (data.level !== undefined && typeof data.level !== "string") {
81
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response has an invalid level field." };
82
+ }
83
+ if (!Array.isArray(data.limits)) {
84
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage limits." };
85
+ }
86
+ for (const entry of data.limits) {
87
+ if (!isValidLimit(entry)) {
88
+ return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response contains a malformed limit entry." };
89
+ }
90
+ }
91
+ return {
92
+ ok: true,
93
+ data: { level: data.level, limits: data.limits },
94
+ };
95
+ }
96
+ /**
97
+ * Fetch and validate the Z.ai quota snapshot. Never logs the Authorization
98
+ * header. Throws `ZaiQuotaError` on any failure — callers branch on `.kind`.
99
+ * `redirect: "error"` refuses to forward the bearer token to a redirect
100
+ * target (spec §D "Security").
101
+ */
102
+ export async function fetchZaiQuota(key, fetchImpl) {
103
+ let response;
104
+ try {
105
+ response = await fetchImpl(ZAI_QUOTA_URL, {
106
+ method: "GET",
107
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
108
+ signal: AbortSignal.timeout(10_000),
109
+ redirect: "error",
110
+ });
111
+ }
112
+ catch (error) {
113
+ const isTimeout = error instanceof Error && error.name === "TimeoutError";
114
+ throw new ZaiQuotaError("network", isTimeout ? "Z.ai monitor endpoint timed out." : "Z.ai monitor endpoint was unreachable.", { timeout: isTimeout });
115
+ }
116
+ if (response.status === 401) {
117
+ throw new ZaiQuotaError("unauthorized", "Z.ai monitor endpoint rejected the key (HTTP 401).", {
118
+ httpStatus: 401,
119
+ });
120
+ }
121
+ if (response.status === 403) {
122
+ throw new ZaiQuotaError("forbidden", "Z.ai monitor endpoint denied access (HTTP 403).", {
123
+ httpStatus: 403,
124
+ });
125
+ }
126
+ if (response.status === 429) {
127
+ throw new ZaiQuotaError("rate-limited", "Z.ai monitor endpoint is rate-limiting this key (HTTP 429).", {
128
+ httpStatus: 429,
129
+ });
130
+ }
131
+ if (!response.ok) {
132
+ throw new ZaiQuotaError("http", `Z.ai monitor endpoint returned HTTP ${response.status}.`, {
133
+ httpStatus: response.status,
134
+ });
135
+ }
136
+ let body;
137
+ try {
138
+ body = await response.json();
139
+ }
140
+ catch {
141
+ throw new ZaiQuotaError("invalid-response", "Z.ai monitor endpoint returned a non-JSON body.");
142
+ }
143
+ const parsed = parseQuotaBody(body);
144
+ if (!parsed.ok) {
145
+ throw new ZaiQuotaError(parsed.kind, parsed.message);
146
+ }
147
+ return parsed.data;
148
+ }
@@ -0,0 +1,64 @@
1
+ import { logger } from "../core/logging.js";
2
+ /**
3
+ * Not a `createEventBus` parameter: v2 has exactly one provider, and a
4
+ * parameter would invite a wrong value into the persisted history (H2).
5
+ */
6
+ const PROVIDER = "zai.zcode";
7
+ /**
8
+ * Build a bus for one run. `taskId` defaults to the `runId` while there is no
9
+ * task graph (hedge H1); `role` defaults to `"worker"`. `deps.now` exists so
10
+ * tests can pin `ts` deterministically; production omits it and gets the real
11
+ * clock.
12
+ */
13
+ export function createEventBus(runId, deps) {
14
+ const taskId = deps?.taskId ?? runId;
15
+ const role = deps?.role ?? "worker";
16
+ const now = deps?.now;
17
+ const listeners = new Set();
18
+ let seq = 0;
19
+ let closed = false;
20
+ return {
21
+ runId,
22
+ taskId,
23
+ emit(event) {
24
+ const stamped = {
25
+ ...event,
26
+ runId,
27
+ taskId,
28
+ provider: PROVIDER,
29
+ role,
30
+ seq: ++seq,
31
+ ts: (now?.() ?? new Date()).toISOString(),
32
+ };
33
+ if (!closed) {
34
+ for (const listener of listeners) {
35
+ try {
36
+ listener(stamped);
37
+ }
38
+ catch (error) {
39
+ // A broken renderer must never kill a run (specs/v2-architecture.md,
40
+ // Phase A): swallow, keep dispatching, leave a debug trace.
41
+ logger.debug(`event subscriber threw on ${stamped.type} seq ${stamped.seq}: ${errorMessage(error)}`);
42
+ }
43
+ }
44
+ }
45
+ return stamped;
46
+ },
47
+ subscribe(listener) {
48
+ if (closed) {
49
+ return () => { };
50
+ }
51
+ listeners.add(listener);
52
+ return () => {
53
+ listeners.delete(listener);
54
+ };
55
+ },
56
+ close() {
57
+ closed = true;
58
+ listeners.clear();
59
+ },
60
+ };
61
+ }
62
+ function errorMessage(error) {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }